Skip to main content

ashlar_control/
explain.rs

1//! Errors that say what to do instead: an unknown command names the closest commands and their
2//! parameters, bad parameters name what the command takes. Used for `engine.execute` and
3//! `engine.batch` here, and by the MCP server and CLIs for replies that come from elsewhere.
4
5use ashlar_command::{CommandError, CommandInfo, did_you_mean_command, params_summary};
6use serde_json::{Value, json};
7
8use crate::protocol::Reply;
9
10/// `detail` keys an explained error carries.
11const SUGGESTIONS: &str = "suggestions";
12const TAKES: &str = "takes";
13
14/// A command's error as a reply, explained against `commands` (what the app lists). `detail`
15/// keeps the error's `kind` and `id`; an unknown command adds `suggestions` ([{id, params}]) and
16/// says "did you mean …" in the message, bad parameters add `takes` (the command's parameters)
17/// and name them in the message unless it already lists them.
18pub fn explain(e: &CommandError, commands: &[CommandInfo]) -> Reply {
19    let mut detail = serde_json::to_value(e).unwrap_or_else(|_| json!({}));
20    let message = explain_parts(e.to_string(), e, &mut detail, commands);
21    Reply::Err { error: message, detail: Some(detail) }
22}
23
24/// [`explain`] for a reply that came from elsewhere (a running app, a worker): an error whose
25/// `detail.kind` is `unknown` or `bad_params` (or whose message starts with "unknown command")
26/// and that is not explained yet. `commands` is only called when there is something to explain.
27/// Anything else comes back unchanged.
28pub fn explain_reply(r: Reply, commands: impl FnOnce() -> Vec<CommandInfo>) -> Reply {
29    let Reply::Err { error, detail } = r else { return r };
30    let explained = detail.as_ref().is_some_and(|d| d.get(SUGGESTIONS).is_some() || d.get(TAKES).is_some()) || error.contains("did you mean");
31    let parsed = detail.as_ref().and_then(|d| serde_json::from_value::<CommandError>(d.clone()).ok()).or_else(|| unknown_from_message(&error));
32    match parsed {
33        Some(e @ (CommandError::Unknown { .. } | CommandError::BadParams { .. })) if !explained => {
34            let mut d = detail.unwrap_or_else(|| serde_json::to_value(&e).unwrap_or_else(|_| json!({})));
35            if !d.is_object() {
36                d = serde_json::to_value(&e).unwrap_or_else(|_| json!({}));
37            }
38            let message = explain_parts(error, &e, &mut d, &commands());
39            Reply::Err { error: message, detail: Some(d) }
40        }
41        _ => Reply::Err { error, detail },
42    }
43}
44
45/// "unknown command `x`" (as [`CommandError::Unknown`] prints) without a machine-readable detail.
46fn unknown_from_message(error: &str) -> Option<CommandError> {
47    let rest = error.split("unknown command `").nth(1)?;
48    let id = rest.split('`').next()?;
49    (!id.is_empty()).then(|| CommandError::Unknown { id: id.to_string() })
50}
51
52fn explain_parts(mut message: String, e: &CommandError, detail: &mut Value, commands: &[CommandInfo]) -> String {
53    let Some(o) = detail.as_object_mut() else { return message };
54    match e {
55        CommandError::Unknown { id } => {
56            let hits = ashlar_command::closest(id, commands.iter().map(|c| c.id.as_str()), 3);
57            let list: Vec<Value> = hits
58                .iter()
59                .filter_map(|h| commands.iter().find(|c| c.id == *h))
60                .map(|c| json!({"id": c.id, "label": c.label, "params": params_summary(c)}))
61                .collect();
62            o.insert(SUGGESTIONS.into(), Value::Array(list));
63            match did_you_mean_command(id, commands) {
64                Some(hint) => message.push_str(&format!("; {hint}")),
65                None => message.push_str("; list the commands to find the right id"),
66            }
67        }
68        CommandError::BadParams { id, msg } => {
69            if let Some(c) = commands.iter().find(|c| c.id == *id) {
70                let takes = params_summary(c);
71                let listed = msg.contains("accepted:") || msg.contains("it takes") || msg.contains("takes:");
72                if !listed {
73                    message.push_str(&format!("; `{id}` takes {takes}"));
74                }
75                o.insert(TAKES.into(), json!(takes));
76            }
77        }
78        _ => {}
79    }
80    message
81}
82
83#[cfg(test)]
84mod tests {
85    use super::*;
86
87    fn info(id: &str, text: &str) -> CommandInfo {
88        CommandInfo {
89            id: id.into(),
90            label: id.into(),
91            menu: vec![],
92            shortcut: None,
93            params_schema: Value::Null,
94            params_text: Some(text.into()),
95            enabled: true,
96            disabled_reason: None,
97            mutates: true,
98        }
99    }
100
101    fn list() -> Vec<CommandInfo> {
102        vec![info("timeline.razor", "{time, track?}"), info("timeline.roll", "{left, right, delta}")]
103    }
104
105    #[test]
106    fn unknown_commands_name_the_closest() {
107        let Reply::Err { error, detail } = explain(&CommandError::Unknown { id: "timeline.razr".into() }, &list()) else { panic!() };
108        assert_eq!(error, "unknown command `timeline.razr`; did you mean `timeline.razor` {time, track?}?");
109        let d = detail.unwrap();
110        assert_eq!((d["kind"].as_str(), d["id"].as_str()), (Some("unknown"), Some("timeline.razr")));
111        assert_eq!(d["suggestions"][0]["id"], "timeline.razor");
112        let Reply::Err { error, .. } = explain(&CommandError::Unknown { id: "qqqqq".into() }, &list()) else { panic!() };
113        assert!(error.ends_with("list the commands to find the right id"), "{error}");
114    }
115
116    #[test]
117    fn bad_params_name_what_the_command_takes() {
118        let e = CommandError::bad_params("timeline.razor", "`time` is required");
119        let Reply::Err { error, detail } = explain(&e, &list()) else { panic!() };
120        assert_eq!(error, "invalid parameters for `timeline.razor`: `time` is required; `timeline.razor` takes {time, track?}");
121        assert_eq!(detail.unwrap()["takes"], "{time, track?}");
122        // a message that already lists them is left alone
123        let e = CommandError::bad_params("timeline.razor", "unknown parameter(s) `x`; accepted: time, track");
124        let Reply::Err { error, .. } = explain(&e, &list()) else { panic!() };
125        assert!(!error.contains("takes"), "{error}");
126    }
127
128    #[test]
129    fn replies_from_elsewhere_are_explained_once() {
130        let calls = std::cell::Cell::new(0);
131        let commands = || {
132            calls.set(calls.get() + 1);
133            list()
134        };
135        let r = Reply::from_command(Err(CommandError::Unknown { id: "timeline.rol".into() }));
136        let once = explain_reply(r, commands);
137        let Reply::Err { error, .. } = &once else { panic!() };
138        assert!(error.contains("did you mean `timeline.roll`"), "{error}");
139        let twice = explain_reply(once.clone(), list);
140        assert_eq!(twice, once);
141        // a message without detail
142        let Reply::Err { error, .. } = explain_reply(Reply::err("unknown command `timeline.razer`"), list) else { panic!() };
143        assert!(error.contains("`timeline.razor`"), "{error}");
144        // other errors and successes pass through without listing the commands
145        let failed = Reply::from_command(Err(CommandError::failed("timeline.razor", "no sequence")));
146        assert_eq!(explain_reply(failed.clone(), commands), failed);
147        assert_eq!(explain_reply(Reply::ok(json!(1)), commands), Reply::ok(json!(1)));
148        assert_eq!(calls.get(), 1);
149    }
150}