1use 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}