use ashlar_command::{CommandError, CommandInfo, did_you_mean_command, params_summary};
use serde_json::{Value, json};
use crate::protocol::Reply;
const SUGGESTIONS: &str = "suggestions";
const TAKES: &str = "takes";
pub fn explain(e: &CommandError, commands: &[CommandInfo]) -> Reply {
let mut detail = serde_json::to_value(e).unwrap_or_else(|_| json!({}));
let message = explain_parts(e.to_string(), e, &mut detail, commands);
Reply::Err { error: message, detail: Some(detail) }
}
pub fn explain_reply(r: Reply, commands: impl FnOnce() -> Vec<CommandInfo>) -> Reply {
let Reply::Err { error, detail } = r else { return r };
let explained = detail.as_ref().is_some_and(|d| d.get(SUGGESTIONS).is_some() || d.get(TAKES).is_some()) || error.contains("did you mean");
let parsed = detail.as_ref().and_then(|d| serde_json::from_value::<CommandError>(d.clone()).ok()).or_else(|| unknown_from_message(&error));
match parsed {
Some(e @ (CommandError::Unknown { .. } | CommandError::BadParams { .. })) if !explained => {
let mut d = detail.unwrap_or_else(|| serde_json::to_value(&e).unwrap_or_else(|_| json!({})));
if !d.is_object() {
d = serde_json::to_value(&e).unwrap_or_else(|_| json!({}));
}
let message = explain_parts(error, &e, &mut d, &commands());
Reply::Err { error: message, detail: Some(d) }
}
_ => Reply::Err { error, detail },
}
}
fn unknown_from_message(error: &str) -> Option<CommandError> {
let rest = error.split("unknown command `").nth(1)?;
let id = rest.split('`').next()?;
(!id.is_empty()).then(|| CommandError::Unknown { id: id.to_string() })
}
fn explain_parts(mut message: String, e: &CommandError, detail: &mut Value, commands: &[CommandInfo]) -> String {
let Some(o) = detail.as_object_mut() else { return message };
match e {
CommandError::Unknown { id } => {
let hits = ashlar_command::closest(id, commands.iter().map(|c| c.id.as_str()), 3);
let list: Vec<Value> = hits
.iter()
.filter_map(|h| commands.iter().find(|c| c.id == *h))
.map(|c| json!({"id": c.id, "label": c.label, "params": params_summary(c)}))
.collect();
o.insert(SUGGESTIONS.into(), Value::Array(list));
match did_you_mean_command(id, commands) {
Some(hint) => message.push_str(&format!("; {hint}")),
None => message.push_str("; list the commands to find the right id"),
}
}
CommandError::BadParams { id, msg } => {
if let Some(c) = commands.iter().find(|c| c.id == *id) {
let takes = params_summary(c);
let listed = msg.contains("accepted:") || msg.contains("it takes") || msg.contains("takes:");
if !listed {
message.push_str(&format!("; `{id}` takes {takes}"));
}
o.insert(TAKES.into(), json!(takes));
}
}
_ => {}
}
message
}
#[cfg(test)]
mod tests {
use super::*;
fn info(id: &str, text: &str) -> CommandInfo {
CommandInfo {
id: id.into(),
label: id.into(),
menu: vec![],
shortcut: None,
params_schema: Value::Null,
params_text: Some(text.into()),
enabled: true,
disabled_reason: None,
mutates: true,
}
}
fn list() -> Vec<CommandInfo> {
vec![info("timeline.razor", "{time, track?}"), info("timeline.roll", "{left, right, delta}")]
}
#[test]
fn unknown_commands_name_the_closest() {
let Reply::Err { error, detail } = explain(&CommandError::Unknown { id: "timeline.razr".into() }, &list()) else { panic!() };
assert_eq!(error, "unknown command `timeline.razr`; did you mean `timeline.razor` {time, track?}?");
let d = detail.unwrap();
assert_eq!((d["kind"].as_str(), d["id"].as_str()), (Some("unknown"), Some("timeline.razr")));
assert_eq!(d["suggestions"][0]["id"], "timeline.razor");
let Reply::Err { error, .. } = explain(&CommandError::Unknown { id: "qqqqq".into() }, &list()) else { panic!() };
assert!(error.ends_with("list the commands to find the right id"), "{error}");
}
#[test]
fn bad_params_name_what_the_command_takes() {
let e = CommandError::bad_params("timeline.razor", "`time` is required");
let Reply::Err { error, detail } = explain(&e, &list()) else { panic!() };
assert_eq!(error, "invalid parameters for `timeline.razor`: `time` is required; `timeline.razor` takes {time, track?}");
assert_eq!(detail.unwrap()["takes"], "{time, track?}");
let e = CommandError::bad_params("timeline.razor", "unknown parameter(s) `x`; accepted: time, track");
let Reply::Err { error, .. } = explain(&e, &list()) else { panic!() };
assert!(!error.contains("takes"), "{error}");
}
#[test]
fn replies_from_elsewhere_are_explained_once() {
let calls = std::cell::Cell::new(0);
let commands = || {
calls.set(calls.get() + 1);
list()
};
let r = Reply::from_command(Err(CommandError::Unknown { id: "timeline.rol".into() }));
let once = explain_reply(r, commands);
let Reply::Err { error, .. } = &once else { panic!() };
assert!(error.contains("did you mean `timeline.roll`"), "{error}");
let twice = explain_reply(once.clone(), list);
assert_eq!(twice, once);
let Reply::Err { error, .. } = explain_reply(Reply::err("unknown command `timeline.razer`"), list) else { panic!() };
assert!(error.contains("`timeline.razor`"), "{error}");
let failed = Reply::from_command(Err(CommandError::failed("timeline.razor", "no sequence")));
assert_eq!(explain_reply(failed.clone(), commands), failed);
assert_eq!(explain_reply(Reply::ok(json!(1)), commands), Reply::ok(json!(1)));
assert_eq!(calls.get(), 1);
}
}