Files
Marcos Paulo f893c85a18 feat(cli-mcp): wave 9
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 00:32:04 -03:00

182 lines
7.2 KiB
Rust

//! Exit codes, the lock rules and hostile arguments (SPEC 10.1, 3.2).
mod common;
use std::ffi::OsString;
use std::path::PathBuf;
#[cfg(unix)]
use std::os::unix::ffi::OsStringExt;
use common::{empty_vault, note, run, run_raw, run_with_stdin, run_without_vault, sample_vault};
use notescraft_engine::Session;
use serde_json::Value;
#[test]
fn a_locked_vault_makes_a_write_exit_three() {
let vault = sample_vault("locked");
let session = Session::open(&vault).expect("take the lock from this process");
let written = run_with_stdin(&vault, &["write", "Notes/Hello.md", "--yes"], "nope\n");
assert_eq!(written.code, 3, "{}", written.stderr);
assert!(written.stderr.contains("locked"), "{}", written.stderr);
assert!(!vault.join("Notes/Hello.md").exists(), "nothing was written");
let read = run(&vault, &["info"]);
assert_eq!(read.code, 0, "a reader does not need the lock: {}", read.stderr);
drop(session);
assert_eq!(run_with_stdin(&vault, &["write", "Notes/Hello.md", "--yes"], "now fine\n").code, 0, "the lock was released");
}
#[test]
fn a_locked_vault_reports_the_json_envelope_with_exit_three() {
let vault = sample_vault("locked-json");
let session = Session::open(&vault).expect("take the lock");
let failed = run(&vault, &["--json", "append", "Notes/Hello.md"]);
assert_eq!(failed.code, 3);
let envelope = failed.envelope();
assert_eq!(envelope.get("ok").and_then(Value::as_bool), Some(false));
assert!(envelope.get("result").is_some_and(Value::is_null));
assert_eq!(failed.error_code(), "vault_error");
drop(session);
}
#[test]
fn a_path_outside_the_vault_exits_three() {
let vault = sample_vault("outside");
for args in [vec!["read", "../x.md"], vec!["read", "/etc/passwd.md"], vec!["read", "../../etc/hosts.md"]] {
let result = run(&vault, &args);
assert_eq!(result.code, 3, "{args:?}: {}", result.stderr);
}
let written = run_with_stdin(&vault, &["write", "../x.md", "--yes"], "escaped\n");
assert_eq!(written.code, 3, "{}", written.stderr);
assert!(!vault.parent().expect("a parent").join("x.md").exists(), "nothing escaped the vault");
let reserved = run_with_stdin(&vault, &["write", ".obsidian/config.md", "--yes"], "clobbered\n");
assert_eq!(reserved.code, 3, "{}", reserved.stderr);
assert!(!vault.join(".obsidian/config.md").exists(), "NotesCraft never writes .obsidian/");
}
#[test]
fn usage_errors_exit_two_with_the_usage_text_on_stderr() {
let vault = sample_vault("usage");
for args in [
vec![],
vec!["frobnicate"],
vec!["read"],
vec!["read", ""],
vec!["search", "plan", "--limit", "abc"],
vec!["search", "plan", "--limit", "-1"],
vec!["backlinks", "Plan.md", "--nope"],
vec!["daily", "--date"],
vec!["template", "delete", "Templates/Daily.md"],
vec!["run", "note.read", "not json"],
vec!["run", "note.read", "[1, 2]"],
vec!["--nope", "info"],
vec!["--vault"],
vec!["info", "extra"],
vec!["write", "a.md"],
vec!["mcp", "--nope"],
] {
let result = run(&vault, &args);
assert_eq!(result.code, 2, "{args:?}: {}", result.stderr);
assert!(result.stderr.contains("notescraft-cli [--vault"), "{args:?} prints the usage: {}", result.stderr);
}
}
#[test]
fn command_errors_exit_one_with_an_envelope() {
let vault = sample_vault("command-error");
let missing = run(&vault, &["--json", "read", "Notes/Nope.md"]);
assert_eq!(missing.code, 1, "{}", missing.stderr);
assert_eq!(missing.error_code(), "command_failed");
assert!(missing.stderr.contains("not_found"), "{}", missing.stderr);
let bad_query = run(&vault, &["--json", "search", "-only-negations"]);
assert_eq!(bad_query.code, 1);
assert!(bad_query.stderr.contains("invalid_query"), "{}", bad_query.stderr);
let not_a_note = run(&vault, &["--json", "read", "photo.png"]);
assert_eq!(not_a_note.code, 3, "a path that is not a note leaves the note surface");
}
#[test]
fn mcp_without_a_vault_is_a_usage_error() {
let plain = run_without_vault(&["mcp"]);
assert_eq!(plain.code, 2, "{}", plain.stderr);
assert!(plain.stderr.contains("no vault"), "{}", plain.stderr);
assert_eq!(plain.stdout, "", "nothing on stdout for a command that never ran");
let json = run_without_vault(&["--json", "mcp"]);
assert_eq!(json.code, 2);
assert_eq!(json.stdout, "", "stdout belongs to the mcp protocol, so not even the envelope goes there");
}
#[test]
fn hostile_arguments_never_panic() {
let vault = sample_vault("hostile");
let huge = "a".repeat(100 * 1024);
for args in [
vec!["read", ""],
vec!["read", " "],
vec!["read", "\u{feff}\u{202e}"],
vec!["read", &huge],
vec!["search", &huge],
vec!["search", "\"unclosed"],
vec!["tags", "../.."],
vec!["run", &huge],
vec!["run", "vault.info", &huge],
vec!["backlinks", "Plan.md", "--unlinked", "--unlinked"],
vec!["template", "insert", "../x.md", "Templates/Daily.md"],
vec!["--vault", &huge, "info"],
] {
let result = run(&vault, &args);
assert!(result.code != 101, "{args:?} panicked: {}", result.stderr);
assert!(matches!(result.code, 0..=3), "{args:?} exited with {}", result.code);
}
}
#[test]
fn a_100_kib_stdin_payload_is_accepted_but_the_note_stays_sane() {
let vault = empty_vault("big-stdin");
note(&vault, "Big.md", "");
let payload = "x".repeat(100 * 1024);
let result = run_with_stdin(&vault, &["write", "Big.md", "--yes"], &payload);
assert_eq!(result.code, 0, "{}", result.stderr);
let size = |path: PathBuf| std::fs::metadata(path).map(|meta| meta.len()).unwrap_or_default();
assert_eq!(size(vault.join("Big.md")), payload.len() as u64);
let appended = run_with_stdin(&vault, &["append", "Big.md"], &payload);
assert_eq!(appended.code, 0, "{}", appended.stderr);
assert_eq!(size(vault.join("Big.md")), payload.len() as u64 * 2);
}
#[cfg(unix)]
#[test]
fn invalid_utf8_arguments_are_refused_not_panicked_on() {
let bad = OsString::from_vec(vec![b'r', b'e', b'a', b'd', 0x80, 0xff]);
for args in [vec![bad.clone()], vec![OsString::from("--vault"), bad.clone()], vec![OsString::from("read"), bad]] {
let result = run_raw(&args);
assert_ne!(result.code, 101, "{args:?} panicked");
assert!(matches!(result.code, 0..=3), "{args:?} exited with {}", result.code);
}
}
#[test]
fn a_vault_that_is_a_file_is_an_error_not_a_panic() {
let dir = empty_vault("file-vault");
let file = dir.join("vault");
std::fs::write(&file, "not a vault").expect("write the file");
let result = run(&file, &["info"]);
assert_ne!(result.code, 101, "{}", result.stderr);
assert!(matches!(result.code, 1 | 3), "{}", result.code);
}
#[test]
fn an_empty_vault_is_not_an_error() {
let vault: PathBuf = empty_vault("empty");
let info = run(&vault, &["--json", "info"]);
assert_eq!(info.code, 0, "{}", info.stderr);
assert_eq!(info.result().get("notes").and_then(Value::as_u64), Some(0));
assert_eq!(run(&vault, &["tags"]).stdout.trim(), "no tags");
assert_eq!(run(&vault, &["search", "anything"]).stdout.trim(), "no hits");
}