Files
Marcos Paulo 7a70adc7ac feat(cli): wave 8
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 00:00:21 -03:00

250 lines
11 KiB
Rust

//! Every subcommand of `notescraft-cli`, in text and in `--json` (SPEC 10.1).
mod common;
use common::{empty_vault, note, read_file, run, run_with_stdin, sample_vault, write_file};
use serde_json::Value;
#[test]
fn help_prints_the_usage_text_and_needs_no_vault() {
let text = run(&empty_vault("help"), &["help"]);
assert_eq!(text.code, 0);
assert!(text.stdout.contains("notescraft-cli [--vault <path>"), "{}", text.stdout);
let json = run(&empty_vault("help-json"), &["--json", "help"]);
assert_eq!(json.code, 0);
assert_eq!(json.envelope().get("ok").and_then(Value::as_bool), Some(true));
assert_eq!(json.result().get("usage").and_then(Value::as_str).map(str::len), Some(common::run(&empty_vault("help-len"), &["help"]).stdout.len()));
}
#[test]
fn info_counts_the_vault() {
let vault = sample_vault("info");
let text = run(&vault, &["info"]);
assert_eq!(text.code, 0, "{}", text.stderr);
assert!(text.stdout.contains("notes: 5"), "{}", text.stdout);
assert!(text.stdout.contains("unresolved links: 1"), "{}", text.stdout);
assert!(text.stdout.contains("read-only: yes"), "a reader never takes the lock: {}", text.stdout);
let json = run(&vault, &["--json", "info"]);
assert_eq!(json.code, 0);
assert_eq!(json.result().get("notes").and_then(Value::as_u64), Some(5));
assert_eq!(json.result().get("unresolved").and_then(Value::as_u64), Some(1));
}
#[test]
fn search_ranks_hits_with_snippets() {
let vault = sample_vault("search");
let text = run(&vault, &["search", "plan", "--limit", "2"]);
assert_eq!(text.code, 0, "{}", text.stderr);
assert_eq!(text.stdout.to_lowercase().lines().filter(|line| line.contains("plan.md")).count(), 2, "the limit is honoured: {}", text.stdout);
let json = run(&vault, &["--json", "search", "plan", "--limit", "3"]);
assert_eq!(json.code, 0);
assert_eq!(json.result().get("count").and_then(Value::as_u64), Some(3));
assert!(json.result().get("hits").and_then(Value::as_array).is_some_and(|hits| hits.len() == 3));
assert_eq!(run(&vault, &["--json", "search", "plan", "--limit", "500"]).code, 1, "over the engine cap is a command error");
}
#[test]
fn read_prints_the_note_verbatim() {
let vault = sample_vault("read");
let text = run(&vault, &["read", "Plan.md"]);
assert_eq!(text.code, 0);
assert_eq!(text.stdout, read_file(&vault, "Plan.md"), "byte for byte, with the trailing newline");
let json = run(&vault, &["--json", "read", "Plan.md"]);
assert_eq!(json.code, 0);
assert_eq!(json.result().get("path").and_then(Value::as_str), Some("Plan.md"));
assert_eq!(json.result().get("content").and_then(Value::as_str), Some(read_file(&vault, "Plan.md").as_str()));
}
#[test]
fn write_replaces_and_append_adds_from_stdin() {
let vault = sample_vault("write");
let written = run_with_stdin(&vault, &["write", "Notes/Hello.md", "--yes"], "# Hello\n");
assert_eq!(written.code, 0, "{}", written.stderr);
assert_eq!(read_file(&vault, "Notes/Hello.md"), "# Hello\n");
assert!(written.stdout.contains("written: Notes/Hello.md"), "{}", written.stdout);
let appended = run_with_stdin(&vault, &["append", "Notes/Hello.md"], "more\n");
assert_eq!(appended.code, 0, "{}", appended.stderr);
assert_eq!(read_file(&vault, "Notes/Hello.md"), "# Hello\nmore\n");
let fresh = run_with_stdin(&vault, &["--json", "append", "Notes/Fresh.md"], "brand new\n");
assert_eq!(fresh.code, 0);
assert_eq!(fresh.result().get("created").and_then(Value::as_bool), Some(true));
assert_eq!(read_file(&vault, "Notes/Fresh.md"), "brand new\n");
let unchanged = run_with_stdin(&vault, &["--json", "write", "Notes/Fresh.md", "--yes"], "brand new\n");
assert_eq!(unchanged.code, 0);
assert_eq!(unchanged.result().get("changed").and_then(Value::as_bool), Some(false), "an identical write touches nothing");
}
#[test]
fn links_and_backlinks_report_the_graph() {
let vault = sample_vault("links");
let text = run(&vault, &["links", "Plan.md"]);
assert_eq!(text.code, 0);
assert!(text.stdout.contains("Missing -> unresolved"), "{}", text.stdout);
assert!(text.stdout.contains("Notes/Other -> Notes/Other.md"), "{}", text.stdout);
let json = run(&vault, &["--json", "backlinks", "Notes/Other.md"]);
assert_eq!(json.code, 0);
assert_eq!(json.result().get("count").and_then(Value::as_u64), Some(1));
let unlinked = run(&vault, &["--json", "backlinks", "Plan.md", "--unlinked"]);
assert_eq!(unlinked.code, 0);
assert_eq!(unlinked.result().get("linked").and_then(Value::as_u64), Some(0));
let mentions = {
let result = unlinked.result();
result.get("unlinked").and_then(Value::as_array).expect("unlinked is a list").clone()
};
assert_eq!(unlinked.result().get("unlinked_count").and_then(Value::as_u64), Some(mentions.len() as u64));
assert!(mentions.iter().any(|hit| hit.get("path").and_then(Value::as_str) == Some("Notes/Other.md")), "{mentions:?}");
}
#[test]
fn tags_list_the_tree_with_and_without_a_prefix() {
let vault = sample_vault("tags");
let all = run(&vault, &["tags"]);
assert_eq!(all.code, 0);
assert!(all.stdout.contains("work (1)"), "{}", all.stdout);
assert!(all.stdout.contains(" work/q1 (1)"), "the tree is indented: {}", all.stdout);
let filtered = run(&vault, &["--json", "tags", "work"]);
assert_eq!(filtered.code, 0);
let result = filtered.result();
let roots = result.get("tags").and_then(Value::as_array).expect("tags is a list");
assert!(roots.iter().all(|node| node.get("tag").and_then(Value::as_str).is_some_and(|tag| tag.starts_with("work"))));
}
#[test]
fn daily_opens_then_reopens_the_same_note() {
let vault = sample_vault("daily");
let created = run(&vault, &["--json", "daily", "--date", "2026-10-13"]);
assert_eq!(created.code, 0, "{}", created.stderr);
assert_eq!(created.result().get("created").and_then(Value::as_bool), Some(true));
let path = created.result().get("path").and_then(Value::as_str).expect("a path").to_owned();
assert!(path.ends_with("2026-10-13.md"), "{path}");
let again = run(&vault, &["--json", "daily", "--date=2026-10-13"]);
assert_eq!(again.result().get("created").and_then(Value::as_bool), Some(false));
assert_eq!(again.result().get("path").and_then(Value::as_str), Some(path.as_str()));
assert_eq!(run(&vault, &["daily", "--date", "nope"]).code, 1, "a malformed date is a command error");
}
#[test]
fn template_list_new_and_insert() {
let vault = sample_vault("template");
let listed = run(&vault, &["template", "list"]);
assert_eq!(listed.code, 0);
assert!(listed.stdout.contains("Templates/Daily.md"), "{}", listed.stdout);
let created = run(&vault, &["--json", "template", "new", "Templates/Daily.md", "Notes/FromTpl.md"]);
assert_eq!(created.code, 0, "{}", created.stderr);
assert!(read_file(&vault, "Notes/FromTpl.md").contains("# FromTpl"), "the variables are expanded");
let inserted = run(&vault, &["template", "insert", "Notes/Other.md", "Templates/Daily.md"]);
assert_eq!(inserted.code, 0, "{}", inserted.stderr);
assert!(read_file(&vault, "Notes/Other.md").contains("# Other"), "{}", read_file(&vault, "Notes/Other.md"));
assert_eq!(run(&vault, &["template", "insert", "Notes/Nope.md", "Templates/Daily.md"]).code, 1, "a missing note is a command error");
}
#[test]
fn commands_lists_the_registry_and_run_dispatches_by_id() {
let vault = sample_vault("commands");
let listed = run(&vault, &["commands"]);
assert_eq!(listed.code, 0);
assert!(listed.stdout.contains("vault.info"), "{}", listed.stdout);
assert!(listed.stdout.contains("note.read"), "{}", listed.stdout);
let json = run(&vault, &["--json", "commands"]);
assert_eq!(json.code, 0);
let result = json.result();
let count = result.get("count").and_then(Value::as_u64).expect("a count");
assert!(count > 10, "{count} commands are registered");
let every = run(&vault, &["--json", "run", "vault.info"]);
assert_eq!(every.code, 0, "{}", every.stderr);
assert_eq!(every.result().get("notes").and_then(Value::as_u64), Some(5));
let unknown = run(&vault, &["run", "does.not.exist"]);
assert_eq!(unknown.code, 1, "an unknown id is a command error");
assert!(unknown.stderr.contains("unknown command"), "{}", unknown.stderr);
}
#[test]
fn doctor_reports_unresolved_duplicates_frontmatter_and_orphans() {
let vault = sample_vault("doctor");
write_file(&vault, "orphan.png", "linked by nobody");
let text = run(&vault, &["doctor"]);
assert_eq!(text.code, 0, "{}", text.stderr);
assert!(text.stdout.contains("unresolved links: 1"), "{}", text.stdout);
assert!(text.stdout.contains("duplicate names: 1"), "{}", text.stdout);
assert!(text.stdout.contains("Notes/Broken.md: the frontmatter block is never closed"), "{}", text.stdout);
assert!(text.stdout.contains("orphan.png"), "{}", text.stdout);
let json = run(&vault, &["--json", "doctor"]);
assert_eq!(json.code, 0);
let result = json.result();
let counts = result.get("counts").expect("counts");
assert_eq!(counts.get("unresolved").and_then(Value::as_u64), Some(1));
assert_eq!(counts.get("duplicates").and_then(Value::as_u64), Some(1));
assert_eq!(counts.get("frontmatter").and_then(Value::as_u64), Some(1));
assert_eq!(counts.get("orphans").and_then(Value::as_u64), Some(1));
}
#[test]
fn read_only_commands_never_touch_the_vault() {
let vault = sample_vault("readonly");
note(&vault, "Notes/Hello.md", "untouched\n");
let before = list_files(&vault);
for args in [
vec!["info"],
vec!["search", "plan"],
vec!["read", "Plan.md"],
vec!["links", "Plan.md"],
vec!["tags"],
vec!["doctor"],
vec!["commands"],
vec!["backlinks", "Plan.md"],
] {
assert_eq!(run(&vault, &args).code, 0, "{args:?} failed");
}
assert_eq!(list_files(&vault), before, "a reader changes nothing");
}
#[test]
fn the_vault_comes_from_the_flag_or_the_environment() {
let vault = sample_vault("vault-flag");
let flagged = run(&vault, &["info"]);
assert_eq!(flagged.code, 0);
let explicit = std::process::Command::new(env!("CARGO_BIN_EXE_notescraft-cli"))
.env_remove("NOTESCRAFT_VAULT")
.args(["--vault", vault.to_str().expect("utf-8 path"), "info"])
.output()
.expect("run the binary");
assert!(explicit.status.success(), "the flag wins over the environment");
let missing = common::run_without_vault(&["info"]);
assert_eq!(missing.code, 2, "{}", missing.stderr);
}
/// Every file in the vault, with its bytes, sorted by path.
fn list_files(root: &std::path::Path) -> Vec<(String, String)> {
let mut files = Vec::new();
let mut stack = vec![root.to_path_buf()];
while let Some(folder) = stack.pop() {
let entries = std::fs::read_dir(&folder).expect("read the folder");
for entry in entries {
let path = entry.expect("an entry").path();
if path.is_dir() {
stack.push(path);
continue;
}
let name = path.strip_prefix(root).expect("inside the vault").to_string_lossy().into_owned();
let bytes = std::fs::read(&path).unwrap_or_default();
files.push((name, String::from_utf8_lossy(&bytes).into_owned()));
}
}
files.sort();
files
}