//! search: keyword search over the catalog. use super::*; use crate::output::{boxed_table, print_json}; use serde::Serialize; #[derive(Serialize)] struct SearchRow { name: String, display_name: String, category: Option, installable: bool, description: Option, website: Option, tags: Vec, /// Personal tags (issue #39). personal_tags: Vec, favorite: bool, } pub fn run(app: &App, keyword: Option<&str>, category: Option<&str>, tag: Option<&str>) -> Result { let keyword = keyword.unwrap_or(""); // An empty keyword (search --tag only) starts from the whole catalog. let mut hits: Vec<&AgentDef> = if keyword.is_empty() { app.catalog .agents() .iter() .filter(|a| !a.hidden) .filter(|a| category.map_or(true, |c| a.category.as_deref() == Some(c))) .collect() } else { app.catalog.search(keyword, category) }; // Personal tag filter (issue #39): keep agents carrying this tag. if let Some(t) = tag { let tagged = crate::commands::annotations_cmd::agents_with_tag(app, t); if tagged.is_empty() { app.log.info(&format!( "no agent is tagged '{t}' — 'am tags' lists every personal tag" )); return Ok(0); } hits.retain(|a| tagged.contains(&a.name)); } if hits.is_empty() && tag.is_some() { app.log.info(&format!( "no agent matches the keyword and the tag '{}'", tag.unwrap_or("") )); return Ok(0); } if app.json() { let rows: Vec = hits .iter() .map(|a| { let personal = crate::commands::annotations_cmd::tags_of(app, &a.name); SearchRow { name: a.name.clone(), display_name: a.title().to_string(), category: a.category.clone(), installable: a.installable, description: a.description.clone(), website: a.website.clone(), tags: a.tags.clone(), personal_tags: personal, favorite: crate::commands::annotations_cmd::is_favorite(app, &a.name), } }) .collect(); print_json(&rows); return Ok(0); } let mut table_rows = Vec::new(); for a in &hits { let desc = truncate(a.description.as_deref().unwrap_or(""), 56); let star = if crate::commands::annotations_cmd::is_favorite(app, &a.name) { "★ " } else { "" }; table_rows.push(vec![ format!("{star}{}", a.title()), a.category.clone().unwrap_or_else(|| "—".to_string()), if a.installable { "yes" } else { "no" }.to_string(), desc, ]); } print!( "{}", boxed_table( vec!["NAME", "CATEGORY", "INSTALLABLE", "DESCRIPTION"], table_rows, app.theme(), app.color(), ) ); if hits.is_empty() && !keyword.is_empty() { let suggestions = app.catalog.suggest(keyword); if suggestions.is_empty() { app.log .info(&format!("no agent matches '{keyword}' (categories: {})", app.catalog.categories().join(", "))); } else { app.log.info(&format!( "no agent matches '{keyword}' — vouliez-vous dire : {} ?", suggestions.join(", ") )); } } Ok(0) } pub fn truncate(s: &str, max: usize) -> String { if s.chars().count() <= max { s.to_string() } else { let mut cut: String = s.chars().take(max - 1).collect(); cut.push('…'); cut } }