Skip to main content

forge_agent/translate/
commands.rs

1//! Available-commands catalogue parser, read by the workspace's
2//! session task.
3
4use serde_json::Value;
5
6use forge_primitives::AvailableCommand;
7
8/// Parse a `slash_commands` / `commands` array into `AvailableCommand`s.
9/// Entries are either bare name strings (the `system/init`
10/// `slash_commands` shape) or `{name, description, argumentHint}`
11/// objects (the `commands_changed` shape); both flow through here so
12/// init and the live refresh share one boundary. Non-string / nameless
13/// entries are skipped; an empty `argumentHint` collapses to `None`.
14pub fn map_available_commands_from_json(arr: &[Value]) -> Vec<AvailableCommand> {
15    arr.iter()
16        .filter_map(|entry| {
17            if let Some(name) = entry.as_str() {
18                if name.is_empty() {
19                    return None;
20                }
21                return Some(AvailableCommand {
22                    name: name.to_owned(),
23                    description: String::new(),
24                    input_hint: None,
25                });
26            }
27            let obj = entry.as_object()?;
28            let name = obj.get("name")?.as_str().filter(|s| !s.is_empty())?.to_owned();
29            let description =
30                obj.get("description").and_then(Value::as_str).unwrap_or_default().to_owned();
31            let input_hint = obj
32                .get("argumentHint")
33                .and_then(Value::as_str)
34                .filter(|s| !s.is_empty())
35                .map(str::to_owned);
36            Some(AvailableCommand { name, description, input_hint })
37        })
38        .collect()
39}
40
41/// The name a slash command is typed and drawn with. The wire carries it
42/// without the leading slash - the committed baseline's `slash_commands`
43/// is `["doctor", "color", "reload-plugins"]` - so every surface adds one
44/// before it renders.
45pub fn slash_name(name: &str) -> String {
46    if name.is_empty() || name.starts_with('/') { name.to_owned() } else { format!("/{name}") }
47}
48
49#[cfg(test)]
50mod tests {
51    use super::{map_available_commands_from_json, slash_name};
52    use forge_primitives::AvailableCommand;
53    use serde_json::{Value, json};
54
55    fn parse(entries: &Value) -> Vec<AvailableCommand> {
56        map_available_commands_from_json(entries.as_array().expect("an array"))
57    }
58
59    /// The wire carries the name without the slash, and every surface
60    /// types and draws it with one.
61    #[test]
62    fn a_bare_name_gains_the_slash_it_is_typed_with() {
63        assert_eq!(slash_name("doctor"), "/doctor");
64    }
65
66    /// A name that already carries one is left alone, so applying this
67    /// twice is the same as applying it once.
68    #[test]
69    fn a_name_that_already_carries_one_is_unchanged() {
70        assert_eq!(slash_name("/doctor"), "/doctor");
71    }
72
73    /// An empty name is not a command, and a bare slash is not a better
74    /// answer than an empty string. No caller reaches this today - the
75    /// parser drops nameless entries - but this is shared API.
76    #[test]
77    fn an_empty_name_stays_empty() {
78        assert_eq!(slash_name(""), "");
79    }
80
81    /// The `system/init` shape: bare names, no descriptions, which is
82    /// what the CLI sends on the first frame of a turn.
83    #[test]
84    fn bare_name_entries_map_to_commands_without_descriptions() {
85        let commands = parse(&json!(["/help", "memory"]));
86
87        let names: Vec<&str> = commands.iter().map(|c| c.name.as_str()).collect();
88        assert_eq!(names, vec!["/help", "memory"], "both entries survive, in wire order");
89        assert!(commands.iter().all(|c| c.description.is_empty()), "and carry no description");
90        assert!(commands.iter().all(|c| c.input_hint.is_none()), "nor an input hint");
91    }
92
93    /// The `commands_changed` shape: objects with a description and an
94    /// optional argument hint.
95    #[test]
96    fn object_entries_carry_their_description_and_argument_hint() {
97        let commands = parse(&json!([
98            {"name": "/model", "description": "Pick a model", "argumentHint": "<id>"},
99        ]));
100
101        assert_eq!(commands.len(), 1);
102        assert_eq!(commands[0].description, "Pick a model");
103        assert_eq!(commands[0].input_hint.as_deref(), Some("<id>"));
104    }
105
106    /// An absent or empty hint is no hint at all: `Some("")` would draw
107    /// an empty argument slot beside the command.
108    #[test]
109    fn an_empty_argument_hint_collapses_to_none() {
110        let commands = parse(&json!([
111            {"name": "/clear", "description": "Clear", "argumentHint": ""},
112            {"name": "/compact"},
113        ]));
114
115        assert_eq!(commands.len(), 2);
116        assert!(commands[0].input_hint.is_none(), "an empty hint is no hint");
117        assert!(commands[1].input_hint.is_none(), "and a missing one is the same");
118        assert!(commands[1].description.is_empty(), "a missing description is empty, not absent");
119    }
120
121    /// A nameless or non-string entry would draw a blank row in the
122    /// dropdown, so it is dropped rather than carried.
123    #[test]
124    fn entries_without_a_name_are_dropped() {
125        let commands = parse(&json!([
126            "",
127            {"description": "no name here"},
128            {"name": ""},
129            42,
130            "/kept",
131        ]));
132
133        let names: Vec<&str> = commands.iter().map(|c| c.name.as_str()).collect();
134        assert_eq!(names, vec!["/kept"], "only the entry with a name survives: {names:?}");
135    }
136}