7a70adc7ac
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
250 lines
11 KiB
Rust
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
|
|
}
|