diff --git a/crates/sprout-cli/src/commands/channels.rs b/crates/sprout-cli/src/commands/channels.rs index 17018f8ab..d0b9b51b1 100644 --- a/crates/sprout-cli/src/commands/channels.rs +++ b/crates/sprout-cli/src/commands/channels.rs @@ -29,6 +29,124 @@ pub async fn cmd_list_channels( Ok(()) } +/// Search channels by human-readable name (kind:39000 group metadata). +/// +/// The relay's access control already filters out channels the caller can't see +/// (private channels they're not a member of), so we just post-filter the +/// returned events by name and project them into a stable JSON shape. +pub async fn cmd_search_channels( + client: &SproutClient, + query: &str, + exact: bool, + include_archived: bool, + limit: u32, +) -> Result<(), CliError> { + if query.trim().is_empty() { + return Err(CliError::Usage("--query cannot be empty".into())); + } + + let filter = serde_json::json!({ + "kinds": [39000], + "limit": limit, + }); + let raw = client.query(&filter).await?; + + let events: serde_json::Value = serde_json::from_str(&raw) + .map_err(|e| CliError::Other(format!("failed to parse response: {e}")))?; + let Some(arr) = events.as_array() else { + println!("[]"); + return Ok(()); + }; + + let needle = query.to_ascii_lowercase(); + let mut matches: Vec = arr + .iter() + .filter_map(ChannelSummary::from_event) + .filter(|c| if include_archived { true } else { !c.archived }) + .filter(|c| name_matches(&c.name, &needle, exact)) + .collect(); + matches.sort_by(|a, b| { + a.name + .cmp(&b.name) + .then_with(|| a.channel_id.cmp(&b.channel_id)) + }); + + let output = serde_json::to_string(&matches).expect("serializing ChannelSummary"); + println!("{output}"); + Ok(()) +} + +/// Stable, scriptable projection of a kind:39000 channel-metadata event. +#[derive(serde::Serialize)] +struct ChannelSummary { + channel_id: String, + name: String, + channel_type: Option, + visibility: Option, + archived: bool, + about: Option, + topic: Option, + purpose: Option, +} + +impl ChannelSummary { + /// Parse a kind:39000 event JSON value into a summary. Returns `None` if the + /// event lacks the required `d` (channel UUID) or `name` tags. + fn from_event(event: &serde_json::Value) -> Option { + let tags = event.get("tags")?.as_array()?; + let mut channel_id: Option = None; + let mut name: Option = None; + let mut channel_type: Option = None; + let mut visibility: Option = None; + let mut archived = false; + let mut about: Option = None; + let mut topic: Option = None; + let mut purpose: Option = None; + + for tag in tags { + let Some(tag_arr) = tag.as_array() else { + continue; + }; + let key = tag_arr.first().and_then(|v| v.as_str()).unwrap_or(""); + let val = tag_arr.get(1).and_then(|v| v.as_str()); + match key { + "d" => channel_id = val.map(str::to_string), + "name" => name = val.map(str::to_string), + "t" => channel_type = val.map(str::to_string), + // NIP-29 emits both `private` and `public` (Sprout adds the latter). + // The presence of either tag is the source of truth; tag value is unused. + "private" => visibility = Some("private".to_string()), + "public" => visibility = Some("public".to_string()), + "about" => about = val.map(str::to_string), + "topic" => topic = val.map(str::to_string), + "purpose" => purpose = val.map(str::to_string), + "archived" => archived = val == Some("true"), + _ => {} + } + } + + Some(ChannelSummary { + channel_id: channel_id?, + name: name?, + channel_type, + visibility, + archived, + about, + topic, + purpose, + }) + } +} + +fn name_matches(name: &str, needle_lower: &str, exact: bool) -> bool { + let hay = name.to_ascii_lowercase(); + if exact { + hay == needle_lower + } else { + hay.contains(needle_lower) + } +} + pub async fn cmd_get_channel(client: &SproutClient, channel_id: &str) -> Result<(), CliError> { validate_uuid(channel_id)?; // Query kind:39002 with #h tag matching the channel UUID @@ -312,6 +430,12 @@ pub async fn dispatch(cmd: crate::ChannelsCmd, client: &SproutClient) -> Result< cmd_list_channels(client, vis_str.as_deref(), Some(member)).await } ChannelsCmd::Get { channel } => cmd_get_channel(client, &channel).await, + ChannelsCmd::Search { + query, + exact, + include_archived, + limit, + } => cmd_search_channels(client, &query, exact, include_archived, limit).await, ChannelsCmd::Create { name, channel_type, @@ -362,3 +486,97 @@ pub async fn dispatch_canvas(cmd: crate::CanvasCmd, client: &SproutClient) -> Re CanvasCmd::Set { channel, content } => cmd_set_canvas(client, &channel, &content).await, } } + +#[cfg(test)] +mod tests { + use super::{name_matches, ChannelSummary}; + use serde_json::json; + + fn event(tags: serde_json::Value) -> serde_json::Value { + json!({ "tags": tags }) + } + + #[test] + fn from_event_extracts_known_tags() { + let ev = event(json!([ + ["d", "11111111-1111-1111-1111-111111111111"], + ["name", "sprout-chat-composer"], + ["t", "stream"], + ["public"], + ["about", "About text"], + ["topic", "Composer work"], + ["purpose", "Track UI for the composer"], + ])); + let s = ChannelSummary::from_event(&ev).expect("parse"); + assert_eq!(s.channel_id, "11111111-1111-1111-1111-111111111111"); + assert_eq!(s.name, "sprout-chat-composer"); + assert_eq!(s.channel_type.as_deref(), Some("stream")); + assert_eq!(s.visibility.as_deref(), Some("public")); + assert!(!s.archived); + assert_eq!(s.about.as_deref(), Some("About text")); + assert_eq!(s.topic.as_deref(), Some("Composer work")); + assert_eq!(s.purpose.as_deref(), Some("Track UI for the composer")); + } + + #[test] + fn from_event_marks_archived() { + let ev = event(json!([ + ["d", "11111111-1111-1111-1111-111111111111"], + ["name", "old-channel"], + ["archived", "true"], + ])); + let s = ChannelSummary::from_event(&ev).expect("parse"); + assert!(s.archived); + } + + #[test] + fn from_event_marks_private() { + let ev = event(json!([ + ["d", "11111111-1111-1111-1111-111111111111"], + ["name", "secret"], + ["private"], + ])); + let s = ChannelSummary::from_event(&ev).expect("parse"); + assert_eq!(s.visibility.as_deref(), Some("private")); + } + + #[test] + fn from_event_returns_none_without_required_tags() { + // missing `name` + let ev = event(json!([["d", "11111111-1111-1111-1111-111111111111"]])); + assert!(ChannelSummary::from_event(&ev).is_none()); + // missing `d` + let ev = event(json!([["name", "no-id"]])); + assert!(ChannelSummary::from_event(&ev).is_none()); + } + + #[test] + fn from_event_tolerates_malformed_tags() { + // Non-array tag entry, empty tag, single-element tag — all must be skipped, not panic. + let ev = event(json!([ + "not-an-array", + [], + ["name"], + ["d", "11111111-1111-1111-1111-111111111111"], + ["name", "fine"], + ])); + let s = ChannelSummary::from_event(&ev).expect("parse"); + assert_eq!(s.name, "fine"); + } + + // `name_matches` takes a pre-lowercased needle (caller responsibility, set in + // cmd_search_channels). Tests follow the same contract. + + #[test] + fn name_matches_substring_case_insensitive() { + assert!(name_matches("Sprout-Chat-Composer", "composer", false)); + assert!(name_matches("Sprout-Chat-Composer", "sprout", false)); + assert!(!name_matches("design", "composer", false)); + } + + #[test] + fn name_matches_exact_case_insensitive() { + assert!(name_matches("Sprout", "sprout", true)); + assert!(!name_matches("Sprout-Chat", "sprout", true)); + } +} diff --git a/crates/sprout-cli/src/lib.rs b/crates/sprout-cli/src/lib.rs index fa5ab2f88..f8ca44e1a 100644 --- a/crates/sprout-cli/src/lib.rs +++ b/crates/sprout-cli/src/lib.rs @@ -359,6 +359,24 @@ pub enum ChannelsCmd { #[arg(long)] channel: String, }, + /// Search channels by human-readable name + #[command( + after_help = "Examples:\n sprout channels search --query composer\n sprout channels search --query sprout-chat-composer --exact\n sprout channels search --query design --include-archived" + )] + Search { + /// Search query (case-insensitive substring of channel name) + #[arg(long)] + query: String, + /// Require an exact case-insensitive match instead of substring + #[arg(long, default_value_t = false)] + exact: bool, + /// Include archived channels in results + #[arg(long, default_value_t = false)] + include_archived: bool, + /// Maximum number of channel-metadata events to fetch from the relay + #[arg(long, default_value_t = 1000)] + limit: u32, + }, /// Create a new channel #[command( after_help = "Examples:\n sprout channels create --name general --type stream --visibility open\n sprout channels create --name design --type forum --visibility open --description \"Design discussions\""