Files
Marcos Paulo a8ca62b924 feat(engine): command registry
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 19:36:44 -03:00

232 lines
9.6 KiB
Rust

//! Integration tests for registration, listing and dispatch by id.
use notescraft_engine::{CommandError, CommandInfo, CommandResult, CommandSpec, Error, ParamKind, ParamSpec, Registry};
use serde_json::{Value, json};
/// Keys a sample body tolerates before refusing.
const MAX_KEYS: usize = 32;
/// Stand-in for the future `Session`: enough to observe that a body ran.
#[derive(Debug, Default)]
struct Ctx {
allow: bool,
ran: Vec<String>,
last: Option<Value>,
}
fn record(ctx: &mut Ctx, params: &Value) -> CommandResult {
ctx.ran.push("record".to_owned());
ctx.last = Some(params.clone());
if params.as_object().is_some_and(|map| map.len() > MAX_KEYS) {
return Err(CommandError::new("too_many_keys", "record takes a few keys"));
}
Ok(params.clone())
}
fn only_path(ctx: &mut Ctx, params: &Value) -> CommandResult {
if ctx.allow { Ok(params.clone()) } else { Err(CommandError::new("locked", "vault is locked")) }
}
fn boom(_ctx: &mut Ctx, _params: &Value) -> CommandResult {
Err(CommandError::new("boom", "deliberate failure"))
}
fn kaboom(_ctx: &mut Ctx, _params: &Value) -> CommandResult {
panic!("deliberate panic in a test command body");
}
fn spec(id: &str) -> CommandSpec<Ctx> {
CommandSpec::new(id, format!("label {id}"), format!("doc {id}"), |_| true, record)
}
fn registry_with(ids: &[&str]) -> Registry<Ctx> {
let mut reg = Registry::new();
for id in ids {
reg.register(spec(id)).expect("test registration");
}
reg
}
#[test]
fn valid_and_invalid_ids() {
let reg = registry_with(&["a", "note.open", "plugin.demo.do-thing_1", &format!("x{}", "y".repeat(127))]);
assert!(reg.get("note.open").is_some());
let mut reg = reg;
for bad in ["", "Upper", "with space", "trailing.", ".leading", "double..dot", "plugin/demo", "emoji😀", &"z".repeat(129)] {
assert!(matches!(reg.register(spec(bad)), Err(Error::InvalidId { .. })), "expected `{bad}` to be refused");
}
}
#[test]
fn duplicate_ids_are_rejected() {
let mut reg = registry_with(&["note.open"]);
assert!(matches!(reg.register(spec("note.open")), Err(Error::Duplicate(id)) if id == "note.open"));
assert_eq!(reg.len(), 1);
assert!(reg.register(spec("note.new")).is_ok());
assert_eq!(reg.len(), 2);
}
#[test]
fn plugin_ids_are_forced_into_their_namespace() {
let mut reg = registry_with(&["note.open"]);
reg.register_plugin_command("demo", spec("refresh")).expect("plugin registration");
reg.register_plugin_command("demo", spec("plugin.demo.already_full")).expect("already-namespaced id");
assert!(reg.get("plugin.demo.refresh").is_some());
assert!(reg.get("plugin.demo.already_full").is_some());
assert!(matches!(reg.register_plugin_command("demo", spec("plugin.other.escape")), Err(Error::PrefixEscape { .. })));
assert!(matches!(reg.register_plugin_command("DeMo", spec("x")), Err(Error::InvalidPluginId(_))));
assert!(reg.get("plugin.demo.escape").is_none());
assert_eq!(reg.unregister_prefix("plugin.demo."), 2);
assert!(reg.get("plugin.demo.refresh").is_none());
assert!(reg.get("note.open").is_some());
assert_eq!(reg.unregister_prefix("plugin.demo."), 0);
}
#[test]
fn list_is_sorted_and_serialises_with_the_documented_shape() {
let mut reg = Registry::new();
reg.register(
CommandSpec::new("note.new", "New note", "creates a note", |_| true, record)
.with_menu(vec!["File".to_owned(), "New note".to_owned()])
.with_shortcut("Mod-n")
.with_param(ParamSpec::required("path", ParamKind::String, "note path")),
)
.expect("register");
reg.register(CommandSpec::new("app.quit", "Quit", "quits", |_: &Ctx| false, record)).expect("register");
let ctx = Ctx::default();
let listed = reg.list(&ctx);
let ids: Vec<&str> = listed.iter().map(|info| info.id.as_str()).collect();
assert_eq!(ids, ["app.quit", "note.new"]);
let value = serde_json::to_value(&listed).expect("serialisable");
let first = value.get(0).expect("first entry");
assert_eq!(first.get("id"), Some(&json!("app.quit")));
assert_eq!(first.get("label"), Some(&json!("Quit")));
assert_eq!(first.get("enabled"), Some(&json!(false)));
assert_eq!(first.get("shortcut"), Some(&Value::Null));
assert_eq!(first.get("params"), Some(&json!([])));
let second = value.get(1).expect("second entry");
assert_eq!(second.get("menu_path"), Some(&json!(["File", "New note"])));
assert_eq!(second.get("shortcut"), Some(&json!("Mod-n")));
assert_eq!(second.get("params"), Some(&json!([{ "name": "path", "kind": "string", "required": true, "doc": "note path" }])));
let round_trip: Vec<CommandInfo> = serde_json::from_value(value).expect("deserialisable");
assert_eq!(round_trip, listed);
assert_eq!(listed.len(), 2);
}
#[test]
fn run_dispatches_by_id_and_reports_failures() {
let mut reg = Registry::new();
reg.register(CommandSpec::new("note.read", "Read", "reads", |_| true, only_path)).expect("register");
reg.register(CommandSpec::new("note.boom", "Boom", "fails", |_| true, boom)).expect("register");
let mut ctx = Ctx { allow: true, ..Ctx::default() };
assert_eq!(reg.run("note.read", &mut ctx, &Value::Null), Ok(Value::Null));
assert!(matches!(reg.run("note.boom", &mut ctx, &Value::Null), Err(Error::Command { .. })));
ctx.allow = false;
let err = reg.run("note.read", &mut ctx, &Value::Null).err();
assert!(matches!(
err,
Some(Error::Command { ref source, .. }) if source == &CommandError::new("locked", "vault is locked")
));
assert!(reg.get("note.read").is_some());
}
#[test]
fn unknown_disabled_and_null_params() {
let mut reg = Registry::new();
reg.register(CommandSpec::new("note.pick", "Pick", "needs allow", |ctx: &Ctx| ctx.allow, record)).expect("register");
let mut ctx = Ctx { allow: false, ..Ctx::default() };
assert!(matches!(reg.run("nope.nope", &mut ctx, &Value::Null), Err(Error::UnknownCommand(id)) if id == "nope.nope"));
assert!(matches!(reg.run("note.pick", &mut ctx, &Value::Null), Err(Error::Disabled(id)) if id == "note.pick"));
assert_eq!(ctx.ran, Vec::<String>::new());
assert!(!reg.is_enabled("note.pick", &ctx));
assert!(!reg.is_enabled("nope", &ctx));
ctx.allow = true;
assert!(reg.is_enabled("note.pick", &ctx));
assert_eq!(reg.run("note.pick", &mut ctx, &Value::Null), Ok(Value::Null));
assert_eq!(reg.run("note.pick", &mut ctx, &json!({})), Ok(json!({})));
assert_eq!(ctx.ran.len(), 2);
for bad in [json!([1, 2]), json!("text"), json!(3), json!(true)] {
assert!(matches!(reg.run("note.pick", &mut ctx, &bad), Err(Error::InvalidParams { .. })), "params {bad} must be refused");
}
}
#[test]
fn params_are_validated_before_the_body_runs() {
let mut reg = Registry::new();
reg.register(
CommandSpec::new("note.echo", "Echo", "echoes", |_| true, record)
.with_param(ParamSpec::required("path", ParamKind::String, "note path"))
.with_param(ParamSpec::optional("count", ParamKind::Integer, "how many"))
.with_param(ParamSpec::optional("ratio", ParamKind::Number, "a number"))
.with_param(ParamSpec::optional("force", ParamKind::Bool, "a flag"))
.with_param(ParamSpec::optional("tags", ParamKind::Array, "a list"))
.with_param(ParamSpec::optional("extra", ParamKind::Object, "an object"))
.with_param(ParamSpec::optional("anything", ParamKind::Any, "anything")),
)
.expect("register");
let mut ctx = Ctx::default();
let good = json!({
"path": "a.md", "count": 3, "ratio": 1.5, "force": true,
"tags": ["x"], "extra": {}, "anything": null
});
assert_eq!(reg.run("note.echo", &mut ctx, &good), Ok(good.clone()));
assert_eq!(ctx.ran.len(), 1);
let bad_values = [
json!({}),
json!({"path": 1}),
json!({"path": "a", "count": 1.5}),
json!({"path": "a", "count": "3"}),
json!({"path": "a", "count": 9_223_372_036_854_775_808u64}),
json!({"path": "a", "ratio": "x"}),
json!({"path": "a", "force": "yes"}),
json!({"path": "a", "tags": {}}),
json!({"path": "a", "extra": []}),
json!({"path": "a", "typo": 1}),
json!({"path": null}),
];
for bad in bad_values {
let ran = ctx.ran.len();
assert!(matches!(reg.run("note.echo", &mut ctx, &bad), Err(Error::InvalidParams { .. })), "params {bad} must be refused");
assert_eq!(ctx.ran.len(), ran, "the body must not run for {bad}");
}
}
#[test]
fn params_over_the_cap_are_refused_before_the_body_runs() {
let mut reg = Registry::new();
reg.register(CommandSpec::new("note.write", "Write", "writes", |_| true, record)).expect("register");
let mut ctx = Ctx::default();
let huge = json!({ "content": "x".repeat(notescraft_engine::MAX_PARAMS_BYTES + 1) });
assert!(matches!(reg.run("note.write", &mut ctx, &huge), Err(Error::ParamsTooLarge { .. })));
assert_eq!(ctx.ran, Vec::<String>::new());
}
#[test]
fn a_panicking_body_is_contained() {
let mut reg = registry_with(&["note.open"]);
reg.register(CommandSpec::new("note.panic", "Panic", "panics", |_| true, kaboom)).expect("register");
let mut ctx = Ctx::default();
let previous = std::panic::take_hook();
std::panic::set_hook(Box::new(|_| {}));
let result = reg.run("note.panic", &mut ctx, &Value::Null);
std::panic::set_hook(previous);
assert!(matches!(result, Err(Error::Panicked(id)) if id == "note.panic"));
assert_eq!(reg.run("note.open", &mut ctx, &Value::Null), Ok(Value::Null));
assert_eq!(reg.len(), 2);
assert!(ctx.ran.iter().any(|id| id == "record"));
}