Skip to main content

ai_crew_sync/client/
mapping.rs

1//! Turning a parsed subcommand into an MCP `tools/call`.
2//!
3//! Kept apart from transport and rendering because this is the layer with the
4//! most invariants worth testing: every subcommand must map to a tool the
5//! server actually serves, with the argument names its schema expects. A
6//! rename on either side should fail a test here, not a user's command.
7
8use 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/// The project's defaults, threaded into the calls that can use them. Only
54/// what the resolver proved local and trusted: a repository names a channel,
55/// it never names a credential.
56#[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            // A message with neither --channel nor --to falls back to the
85            // project's channel, which is what .acs.toml is for. With --to
86            // it stays a direct message, and an explicit --channel wins.
87            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    /// Map a command and assert the tool name and the arguments it carries.
261    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        // The escape hatch and `tools` are the only two that are not a
271        // straight mapping; everything else must produce a tool name.
272        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}