//! 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 "), "{}", 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 }