feat(cli): add channels search for human-readable channel lookup (#712)

Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
tlongwell-block
2026-05-26 11:41:49 -04:00
committed by GitHub
co-authored by Dawn
parent efa93c4383
commit 6d50090c01
2 changed files with 236 additions and 0 deletions
+218
View File
@@ -111,6 +111,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)?;
let filter = serde_json::json!({
@@ -421,6 +539,12 @@ pub async fn dispatch(
cmd_list_channels(client, vis_str.as_deref(), Some(member), limit, format).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,
@@ -471,3 +595,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
View File
@@ -378,6 +378,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\""