ashlar_control/
explain.rs1use ashlar_command::{CommandError, CommandInfo, did_you_mean_command, params_summary};
6use serde_json::{Value, json};
7
8use crate::protocol::Reply;
9
10const SUGGESTIONS: &str = "suggestions";
12const TAKES: &str = "takes";
13
14pub 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
24pub 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
45fn 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 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 let Reply::Err { error, .. } = explain_reply(Reply::err("unknown command `timeline.razer`"), list) else { panic!() };
143 assert!(error.contains("`timeline.razor`"), "{error}");
144 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}