//! The CLI and the control channel end to end: a write subcommand defers to //! the running app's live session (SPEC 10.1, 4.2, D9) even though the vault //! lock belongs to that app, and falls back to the local path when there is no //! app. use notescraft_engine::{CommandResult, CommandSpec, ControlServer, ParamKind, ParamSpec, Registry, Session, register_core}; use serde_json::{Value, json}; mod common; use common::{empty_vault, read_file, run, run_with_stdin}; /// The `test.echo` body: params back out unchanged. #[expect(clippy::unnecessary_wraps, reason = "the registry signature demands CommandResult")] fn echo(_session: &mut Session, params: &Value) -> CommandResult { Ok(params.clone()) } /// A vault (its content untouched) with the app's stand-in running: a control /// server over a live session and a pumper thread in place of the UI thread. /// /// Returns the vault root; `stop` ends the pumper thread. fn spun_up(name: &str) -> (std::path::PathBuf, std::sync::mpsc::Sender<()>) { let root = empty_vault(name); let session = Session::open(&root).expect("open the vault"); let mut registry = Registry::new(); register_core(&mut registry).expect("register the core commands"); registry .register(CommandSpec::new("test.echo", "Echo", "echoes its params", |_: &Session| true, echo).with_param(ParamSpec::optional( "question", ParamKind::Integer, "number echoed back", ))) .expect("register test.echo"); let handle = ControlServer::start_with(session.vault(), notescraft_engine::ControlOptions::default()).expect("start the control server"); let (stop_tx, stop_rx) = std::sync::mpsc::channel(); std::thread::spawn(move || { let mut session = session; let mut handle = handle; while stop_rx.try_recv().is_err() { if handle.pump(&mut session, ®istry) == 0 { std::thread::sleep(std::time::Duration::from_millis(1)); } } }); (root, stop_tx) } /// A write goes to the live app even though the app owns the vault lock: the /// CLI never takes the lock itself. `run` defers to the app's registry too. #[test] fn a_write_defers_to_the_running_app() { let (root, stop) = spun_up("remote-write"); let out = run_with_stdin(&root, &["--json", "write", "a.md", "--yes"], "hello from the app\n"); assert_eq!(out.code, 0, "{}", out.stdout); assert_eq!(out.envelope().get("ok"), Some(&json!(true)), "{}", out.stdout); // The note exists only because the app session wrote it. assert_eq!(read_file(&root, "a.md"), "hello from the app\n", "the app session wrote the note"); // `run` defers too: the stub command only exists in the app's registry. let out = run(&root, &["--json", "run", "test.echo", r#"{"question":1}"#]); let _ = stop.send(()); assert_eq!(out.code, 0, "{}", out.stdout); assert_eq!(out.envelope().pointer("/result/question"), Some(&json!(1u64)), "{}", out.stdout); } /// Without a running app the CLI falls back to the local path and answers. #[test] fn without_an_app_the_write_falls_back_to_the_local_path() { let root = empty_vault("local-write"); let out = run_with_stdin(&root, &["--json", "write", "a.md", "--yes"], "hello local\n"); assert_eq!(out.code, 0, "{}", out.stdout); assert_eq!(read_file(&root, "a.md"), "hello local\n", "the local session wrote the note"); } /// `control ping` without an app is a command error with a stable code, never /// a usage error and never a panic. #[test] fn control_ping_without_an_app_reports_the_control_error() { let root = empty_vault("ping-offline"); let out = run(&root, &["--json", "control", "ping"]); assert_eq!(out.code, 1, "{}", out.stdout); assert_eq!(out.envelope().get("ok"), Some(&json!(false)), "{}", out.stdout); assert_eq!(out.envelope().pointer("/error/code"), Some(&json!("control")), "{}", out.stdout); } /// `control ping` against the live app round trips. #[test] fn control_ping_reaches_the_running_app() { let (root, stop) = spun_up("ping"); let out = run(&root, &["--json", "control", "ping"]); let _ = stop.send(()); assert_eq!(out.code, 0, "{}", out.stdout); assert_eq!(out.envelope().get("ok"), Some(&json!(true)), "{}", out.stdout); assert_eq!(out.envelope().pointer("/result/protocol"), Some(&json!(1u64)), "{}", out.stdout); }