Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
218 changes: 218 additions & 0 deletions crates/sprout-cli/src/commands/channels.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ChannelSummary> = 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<String>,
visibility: Option<String>,
archived: bool,
about: Option<String>,
topic: Option<String>,
purpose: Option<String>,
}

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<Self> {
let tags = event.get("tags")?.as_array()?;
let mut channel_id: Option<String> = None;
let mut name: Option<String> = None;
let mut channel_type: Option<String> = None;
let mut visibility: Option<String> = None;
let mut archived = false;
let mut about: Option<String> = None;
let mut topic: Option<String> = None;
let mut purpose: Option<String> = 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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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));
}
}
18 changes: 18 additions & 0 deletions crates/sprout-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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\""
Expand Down
Loading