Skip to main content

atd_cli/
list.rs

1//! `atd list` — discover tools and print them, filtered by flags.
2
3use atd_protocol::{AtdError, ToolTier, ToolVisibility};
4use atd_sdk::{AtdClient, DiscoverFilter};
5use std::io::Write;
6
7use crate::cli::ListArgs;
8
9pub async fn run(client: &AtdClient, args: ListArgs, out: &mut impl Write) -> Result<(), AtdError> {
10    let filter = DiscoverFilter {
11        tier: args.tier.as_deref().and_then(parse_tier),
12        visibility: args.visibility.as_deref().and_then(parse_visibility),
13        domain: args.domain,
14        limit: args.limit,
15    };
16
17    let summaries = client.discover(args.query.as_deref(), filter).await?;
18
19    if args.json {
20        serde_json::to_writer(&mut *out, &summaries).map_err(|e| AtdError::ProtocolError {
21            expected: "serializable ToolSummary list".into(),
22            got: format!("serde error: {e}"),
23        })?;
24        writeln!(out).ok();
25        return Ok(());
26    }
27
28    if summaries.is_empty() {
29        writeln!(out, "no tools matched").ok();
30        return Ok(());
31    }
32
33    writeln!(
34        out,
35        "{:<40} {:<24} {:<10} {:<6} {:<10}",
36        "ID", "NAME", "DOMAIN", "TIER", "VIS"
37    )
38    .ok();
39    for s in &summaries {
40        writeln!(
41            out,
42            "{:<40} {:<24} {:<10} {:<6} {:<10}",
43            truncate(&s.id, 40),
44            truncate(&s.name, 24),
45            truncate(&s.domain, 10),
46            tier_str(s.tier),
47            visibility_str(s.visibility)
48        )
49        .ok();
50    }
51    writeln!(out, "{} tool(s) total", summaries.len()).ok();
52    Ok(())
53}
54
55fn parse_tier(s: &str) -> Option<ToolTier> {
56    match s {
57        "hot" => Some(ToolTier::Hot),
58        "warm" => Some(ToolTier::Warm),
59        "cold" => Some(ToolTier::Cold),
60        _ => None,
61    }
62}
63
64fn parse_visibility(s: &str) -> Option<ToolVisibility> {
65    match s {
66        "read" => Some(ToolVisibility::Read),
67        "write" => Some(ToolVisibility::Write),
68        "dangerous" => Some(ToolVisibility::Dangerous),
69        "system" => Some(ToolVisibility::System),
70        "hidden" => Some(ToolVisibility::Hidden),
71        _ => None,
72    }
73}
74
75fn tier_str(t: ToolTier) -> &'static str {
76    match t {
77        ToolTier::Hot => "hot",
78        ToolTier::Warm => "warm",
79        ToolTier::Cold => "cold",
80    }
81}
82
83fn visibility_str(v: ToolVisibility) -> &'static str {
84    match v {
85        ToolVisibility::Read => "read",
86        ToolVisibility::Write => "write",
87        ToolVisibility::Dangerous => "dangerous",
88        ToolVisibility::System => "system",
89        ToolVisibility::Hidden => "hidden",
90    }
91}
92
93fn truncate(s: &str, n: usize) -> String {
94    if s.len() <= n {
95        s.to_string()
96    } else {
97        format!("{}…", &s[..n.saturating_sub(1)])
98    }
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104    use atd_sdk::Endpoint;
105    use tokio::io::{AsyncReadExt, AsyncWriteExt};
106    use tokio::net::UnixListener;
107
108    async fn spawn_fake_server() -> std::path::PathBuf {
109        let dir = tempfile::tempdir().unwrap();
110        let path = dir.path().join("s.sock");
111        let listener = UnixListener::bind(&path).unwrap();
112        std::mem::forget(dir);
113
114        let ret = path.clone();
115        tokio::spawn(async move {
116            while let Ok((stream, _)) = listener.accept().await {
117                tokio::spawn(async move {
118                    let (mut r, mut w) = stream.into_split();
119                    loop {
120                        let mut lb = [0u8; 4];
121                        if r.read_exact(&mut lb).await.is_err() {
122                            return;
123                        }
124                        let n = u32::from_be_bytes(lb) as usize;
125                        let mut buf = vec![0u8; n];
126                        if r.read_exact(&mut buf).await.is_err() {
127                            return;
128                        }
129                        let req: serde_json::Value = serde_json::from_slice(&buf).unwrap();
130                        let reply: serde_json::Value = match req["type"].as_str() {
131                            Some("ping") => serde_json::json!({"type":"pong"}),
132                            Some("tool_list") => serde_json::json!({
133                                "type":"tool_list",
134                                "tools":[
135                                    {"id":"anos:fs.read","description":"Read a file","tier":"hot","visibility":"read"},
136                                    {"id":"anos:fs.write","description":"Write a file","tier":"hot","visibility":"write"}
137                                ]
138                            }),
139                            _ => serde_json::json!({"type":"error","message":"no"}),
140                        };
141                        let body = serde_json::to_vec(&reply).unwrap();
142                        if w.write_all(&(body.len() as u32).to_be_bytes())
143                            .await
144                            .is_err()
145                        {
146                            return;
147                        }
148                        if w.write_all(&body).await.is_err() {
149                            return;
150                        }
151                        let _ = w.flush().await;
152                    }
153                });
154            }
155        });
156        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
157        ret
158    }
159
160    #[tokio::test]
161    async fn list_prints_table_with_totals() {
162        let sock = spawn_fake_server().await;
163        let client = AtdClient::connect(Endpoint::unix(sock)).await.unwrap();
164        let mut out: Vec<u8> = Vec::new();
165        run(
166            &client,
167            ListArgs {
168                query: None,
169                domain: None,
170                tier: None,
171                visibility: None,
172                limit: None,
173                json: false,
174            },
175            &mut out,
176        )
177        .await
178        .unwrap();
179        let s = String::from_utf8(out).unwrap();
180        assert!(s.contains("ID") && s.contains("NAME") && s.contains("DOMAIN"));
181        assert!(s.contains("anos:fs.read"));
182        assert!(s.contains("anos:fs.write"));
183        assert!(s.contains("2 tool(s) total"));
184    }
185
186    #[tokio::test]
187    async fn list_json_flag_emits_array() {
188        let sock = spawn_fake_server().await;
189        let client = AtdClient::connect(Endpoint::unix(sock)).await.unwrap();
190        let mut out: Vec<u8> = Vec::new();
191        run(
192            &client,
193            ListArgs {
194                query: None,
195                domain: None,
196                tier: None,
197                visibility: None,
198                limit: None,
199                json: true,
200            },
201            &mut out,
202        )
203        .await
204        .unwrap();
205        let s = String::from_utf8(out).unwrap();
206        let v: serde_json::Value = serde_json::from_str(s.trim()).unwrap();
207        assert!(v.is_array());
208        assert_eq!(v.as_array().unwrap().len(), 2);
209    }
210
211    #[tokio::test]
212    async fn list_limit_truncates_output() {
213        let sock = spawn_fake_server().await;
214        let client = AtdClient::connect(Endpoint::unix(sock)).await.unwrap();
215        let mut out: Vec<u8> = Vec::new();
216        run(
217            &client,
218            ListArgs {
219                query: None,
220                domain: None,
221                tier: None,
222                visibility: None,
223                limit: Some(1),
224                json: false,
225            },
226            &mut out,
227        )
228        .await
229        .unwrap();
230        let s = String::from_utf8(out).unwrap();
231        assert!(s.contains("1 tool(s) total"));
232        assert!(s.contains("anos:fs.read"));
233        assert!(!s.contains("anos:fs.write"));
234    }
235}