1use anyhow::Context;
9use serde_json::{Value, json};
10
11use super::{ClientCmd, LockCmd, NoteCmd, TaskCmd};
12
13pub(super) fn compact(value: Value) -> Value {
14 match value {
15 Value::Object(map) => Value::Object(
16 map.into_iter()
17 .filter(|(_, v)| !v.is_null())
18 .map(|(k, v)| (k, compact(v)))
19 .collect(),
20 ),
21 other => other,
22 }
23}
24
25fn guess_content_type(path: &std::path::Path) -> &'static str {
26 match path.extension().and_then(|e| e.to_str()).unwrap_or("") {
27 "txt" | "md" | "log" | "diff" | "patch" | "rs" | "toml" | "yml" | "yaml" | "sh" => {
28 "text/plain"
29 }
30 "json" => "application/json",
31 _ => "application/octet-stream",
32 }
33}
34
35pub(super) fn attachment_json(
36 path: &std::path::Path,
37 content_type: Option<&str>,
38) -> anyhow::Result<Value> {
39 use base64::Engine;
40 let data = std::fs::read(path).with_context(|| format!("cannot read {}", path.display()))?;
41 let filename = path
42 .file_name()
43 .and_then(|n| n.to_str())
44 .unwrap_or("file")
45 .to_owned();
46 Ok(json!({
47 "filename": filename,
48 "content_type": content_type.unwrap_or_else(|| guess_content_type(path)),
49 "data_base64": base64::engine::general_purpose::STANDARD.encode(&data),
50 }))
51}
52
53#[derive(Clone, Debug, Default)]
57pub struct Defaults {
58 pub channel: Option<String>,
59}
60
61pub fn to_call(cmd: &ClientCmd) -> anyhow::Result<Option<(&'static str, Value)>> {
62 to_call_with(cmd, &Defaults::default())
63}
64
65pub fn to_call_with(
66 cmd: &ClientCmd,
67 defaults: &Defaults,
68) -> anyhow::Result<Option<(&'static str, Value)>> {
69 let (tool, args): (&str, Value) = match cmd {
70 ClientCmd::Whoami => ("whoami", json!({})),
71 ClientCmd::Tools => return Ok(None),
72 ClientCmd::Send {
73 channel,
74 to,
75 body,
76 announce,
77 reply_to,
78 file,
79 } => {
80 let attachments = file
81 .iter()
82 .map(|p| attachment_json(p, None))
83 .collect::<anyhow::Result<Vec<_>>>()?;
84 let channel = match (channel, to) {
88 (Some(c), _) => Some(c.clone()),
89 (None, Some(_)) => None,
90 (None, None) => defaults.channel.clone(),
91 };
92 let channel = &channel;
93 (
94 "post_message",
95 json!({
96 "channel": channel, "to": to, "body": body,
97 "announce": announce, "reply_to": reply_to,
98 "attachments": if attachments.is_empty() { Value::Null } else { json!(attachments) }
99 }),
100 )
101 }
102 ClientCmd::Attach {
103 task,
104 file,
105 content_type,
106 } => {
107 let mut att = attachment_json(file, content_type.as_deref())?;
108 att["task"] = json!(task);
109 ("attach_file", att)
110 }
111 ClientCmd::Download { id, .. } => ("get_attachment", json!({"id": id})),
112 ClientCmd::Ask {
113 to,
114 question,
115 timeout_seconds,
116 resume_id,
117 } => (
118 "ask_agent",
119 json!({
120 "to": to, "question": question,
121 "timeout_seconds": timeout_seconds, "resume_message_id": resume_id
122 }),
123 ),
124 ClientCmd::Read {
125 scope,
126 history,
127 limit,
128 all_sessions,
129 } => (
130 "read_messages",
131 json!({"scope": scope, "only_new": !history, "limit": limit,
132 "all_sessions": all_sessions}),
133 ),
134 ClientCmd::Search { query, limit } => {
135 ("search_messages", json!({"query": query, "limit": limit}))
136 }
137 ClientCmd::Channels => ("list_channels", json!({})),
138 ClientCmd::ChannelCreate { name, topic } => {
139 ("create_channel", json!({"name": name, "topic": topic}))
140 }
141 ClientCmd::Agents { online } => ("list_agents", json!({"online_only": online})),
142 ClientCmd::Sessions {
143 project,
144 role,
145 online,
146 limit,
147 } => (
148 "list_sessions",
149 json!({"project": project, "role": role, "online_only": online, "limit": limit}),
150 ),
151 ClientCmd::Beat {
152 status,
153 repo,
154 branch,
155 activity,
156 project,
157 role,
158 ttl_seconds,
159 } => (
160 "heartbeat",
161 json!({
162 "status": status, "repo": repo, "branch": branch,
163 "activity": activity, "project": project, "role": role,
164 "ttl_seconds": ttl_seconds
165 }),
166 ),
167 ClientCmd::Tasks { status, mine } => {
168 ("list_tasks", json!({"status": status, "mine_only": mine}))
169 }
170 ClientCmd::Wait {
171 timeout_seconds,
172 kinds,
173 all_channels,
174 } => (
175 "wait_for_updates",
176 json!({
177 "timeout_seconds": timeout_seconds,
178 "kinds": if kinds.is_empty() { Value::Null } else { json!(kinds) },
179 "all_channels": all_channels
180 }),
181 ),
182 ClientCmd::Digest {
183 hours,
184 all_channels,
185 } => (
186 "team_digest",
187 json!({"hours": hours, "all_channels": all_channels}),
188 ),
189 ClientCmd::Lock(lock) => match lock {
190 LockCmd::Acquire {
191 name,
192 ttl_seconds,
193 purpose,
194 } => (
195 "acquire_lock",
196 json!({"name": name, "ttl_seconds": ttl_seconds, "purpose": purpose}),
197 ),
198 LockCmd::Release { name } => ("release_lock", json!({"name": name})),
199 LockCmd::List => ("list_locks", json!({})),
200 },
201 ClientCmd::Task(task) => match task {
202 TaskCmd::Create {
203 key,
204 title,
205 description,
206 depends_on,
207 } => (
208 "create_task",
209 json!({
210 "key": key, "title": title, "description": description,
211 "depends_on": if depends_on.is_empty() { Value::Null } else { json!(depends_on) }
212 }),
213 ),
214 TaskCmd::Show { key } => ("get_task", json!({"key": key})),
215 TaskCmd::Claim { key, lease_seconds } => (
216 "claim_task",
217 json!({"key": key, "lease_seconds": lease_seconds}),
218 ),
219 TaskCmd::Next { lease_seconds } => {
220 ("claim_next_task", json!({"lease_seconds": lease_seconds}))
221 }
222 TaskCmd::Renew { key, lease_seconds } => (
223 "renew_task_lease",
224 json!({"key": key, "lease_seconds": lease_seconds}),
225 ),
226 TaskCmd::Release { key } => ("release_task", json!({"key": key})),
227 TaskCmd::Done { key, result } => {
228 ("complete_task", json!({"key": key, "result": result}))
229 }
230 },
231 ClientCmd::Notes { scope, tag } => ("list_notes", json!({"scope": scope, "tag": tag})),
232 ClientCmd::Note(note) => match note {
233 NoteCmd::Get { key, scope } => ("get_note", json!({"key": key, "scope": scope})),
234 NoteCmd::Set {
235 key,
236 value,
237 scope,
238 tags,
239 } => (
240 "set_note",
241 json!({
242 "key": key, "value": value, "scope": scope,
243 "tags": if tags.is_empty() { Value::Null } else { json!(tags) }
244 }),
245 ),
246 NoteCmd::Rm { key, scope } => ("delete_note", json!({"key": key, "scope": scope})),
247 NoteCmd::Search { query, scope } => {
248 ("search_notes", json!({"query": query, "scope": scope}))
249 }
250 },
251 ClientCmd::Call { .. } => unreachable!("handled by caller"),
252 };
253 Ok(Some((tool, compact(args))))
254}
255
256#[cfg(test)]
257mod tests {
258 use super::*;
259
260 fn mapped(cmd: ClientCmd) -> (String, Value) {
262 let (tool, args) = to_call(&cmd)
263 .expect("mapping succeeds")
264 .expect("this command is a tool call");
265 (tool.to_string(), args)
266 }
267
268 #[test]
269 fn every_subcommand_maps_to_a_tool() {
270 let cases: Vec<ClientCmd> = vec![
273 ClientCmd::Whoami,
274 ClientCmd::Send {
275 channel: Some("dev".into()),
276 to: None,
277 body: "hi".into(),
278 announce: false,
279 reply_to: None,
280 file: vec![],
281 },
282 ClientCmd::Ask {
283 to: "marta".into(),
284 question: Some("q".into()),
285 timeout_seconds: None,
286 resume_id: None,
287 },
288 ClientCmd::Read {
289 scope: "all".into(),
290 history: false,
291 limit: 50,
292 all_sessions: false,
293 },
294 ClientCmd::Search {
295 query: "x".into(),
296 limit: 50,
297 },
298 ClientCmd::Channels,
299 ClientCmd::ChannelCreate {
300 name: "dev".into(),
301 topic: None,
302 },
303 ClientCmd::Agents { online: false },
304 ClientCmd::Sessions {
305 project: None,
306 role: None,
307 online: false,
308 limit: None,
309 },
310 ClientCmd::Beat {
311 status: None,
312 repo: None,
313 branch: None,
314 activity: None,
315 project: None,
316 role: None,
317 ttl_seconds: None,
318 },
319 ClientCmd::Tasks {
320 status: None,
321 mine: false,
322 },
323 ClientCmd::Task(TaskCmd::Show { key: "k".into() }),
324 ClientCmd::Notes {
325 scope: None,
326 tag: None,
327 },
328 ClientCmd::Note(NoteCmd::Get {
329 key: "k".into(),
330 scope: None,
331 }),
332 ClientCmd::Wait {
333 timeout_seconds: None,
334 kinds: vec![],
335 all_channels: false,
336 },
337 ClientCmd::Lock(LockCmd::List),
338 ClientCmd::Digest {
339 hours: 24,
340 all_channels: false,
341 },
342 ClientCmd::Download { id: 1, out: None },
343 ];
344 for cmd in cases {
345 let mapped = to_call(&cmd).expect("mapping succeeds");
346 assert!(mapped.is_some(), "a subcommand mapped to no tool");
347 }
348 }
349
350 #[test]
351 fn tools_is_not_a_tool_call() {
352 assert!(
353 to_call(&ClientCmd::Tools).expect("ok").is_none(),
354 "`tools` lists the surface, it does not call into it"
355 );
356 }
357
358 #[test]
359 fn send_uses_the_schema_argument_names() {
360 let (tool, args) = mapped(ClientCmd::Send {
361 channel: None,
362 to: Some("marta".into()),
363 body: "hi".into(),
364 announce: false,
365 reply_to: Some(7),
366 file: vec![],
367 });
368 assert_eq!(tool, "post_message");
369 assert_eq!(args["to"], "marta");
370 assert_eq!(args["body"], "hi");
371 assert_eq!(args["reply_to"], 7);
372 assert!(
373 args.get("channel").is_none(),
374 "an unset flag is absent, not null: the server treats null as a value"
375 );
376 }
377
378 #[test]
379 fn read_inverts_history_into_only_new() {
380 let (_, args) = mapped(ClientCmd::Read {
381 scope: "inbox".into(),
382 history: true,
383 limit: 10,
384 all_sessions: false,
385 });
386 assert_eq!(
387 args["only_new"], false,
388 "--history means re-read, which is only_new = false"
389 );
390 }
391
392 #[test]
393 fn tasks_maps_mine_to_the_schemas_mine_only() {
394 let (tool, args) = mapped(ClientCmd::Tasks {
395 status: Some("open".into()),
396 mine: true,
397 });
398 assert_eq!(tool, "list_tasks");
399 assert_eq!(
400 args["mine_only"], true,
401 "the schema argument is mine_only; `mine` is the flag name"
402 );
403 }
404
405 #[test]
406 fn wait_omits_an_empty_kind_filter() {
407 let (_, args) = mapped(ClientCmd::Wait {
408 timeout_seconds: Some(30),
409 kinds: vec![],
410 all_channels: false,
411 });
412 assert_eq!(args["timeout_seconds"], 30);
413 assert!(
414 args.get("kinds").is_none(),
415 "no --kinds means no filter, not an empty one"
416 );
417
418 let (_, args) = mapped(ClientCmd::Wait {
419 timeout_seconds: None,
420 kinds: vec!["message".into()],
421 all_channels: false,
422 });
423 assert_eq!(args["kinds"][0], "message");
424 }
425
426 #[test]
427 fn attachments_are_base64_with_a_guessed_type() {
428 let dir = std::env::temp_dir().join("acs-mapping-test");
429 std::fs::create_dir_all(&dir).expect("tmpdir");
430 let path = dir.join("fix.diff");
431 std::fs::write(&path, b"--- a\n+++ b\n").expect("write");
432
433 let att = attachment_json(&path, None).expect("encodes");
434 assert_eq!(att["filename"], "fix.diff");
435 assert_eq!(att["content_type"], "text/plain", "guessed from .diff");
436 assert_eq!(
437 att["data_base64"], "LS0tIGEKKysrIGIK",
438 "base64 of the file's bytes"
439 );
440
441 let att = attachment_json(&path, Some("application/x-custom")).expect("encodes");
442 assert_eq!(att["content_type"], "application/x-custom", "explicit wins");
443
444 std::fs::remove_file(&path).ok();
445 }
446
447 #[test]
448 fn a_missing_attachment_is_an_error_not_a_panic() {
449 let err = attachment_json(std::path::Path::new("/nonexistent/nope.diff"), None);
450 assert!(err.is_err(), "a missing file must surface as an error");
451 assert!(
452 format!("{:#}", err.unwrap_err()).contains("cannot read"),
453 "the error names the problem"
454 );
455 }
456
457 #[test]
458 fn compact_drops_unset_flags_recursively() {
459 let v = compact(json!({"a": 1, "b": null, "c": {"d": null, "e": 2}}));
460 assert_eq!(v, json!({"a": 1, "c": {"e": 2}}));
461 }
462}