diff --git a/src/doctor.rs b/src/doctor.rs new file mode 100644 index 0000000..1cd392a --- /dev/null +++ b/src/doctor.rs @@ -0,0 +1,195 @@ +//! `anwesen doctor` base checks per [ANW-18]. Walks the vault once, +//! collects hard failures and soft warnings from [`crate::vault::scan`], +//! plus any HTTP-surface path collisions that scrape past the OS path +//! distinction (e.g. Unicode normalization differences). Returns a +//! [`Report`] the binary renders to stdout; a non-empty report exits +//! non-zero per the User Manual. + +use std::collections::BTreeMap; +use std::fmt::Write; +use std::path::Path; + +use crate::vault::{self, ScanIssue, ScanWarning}; + +#[derive(Debug, Default)] +pub struct Report { + pub note_count: usize, + pub issues: Vec, + pub warnings: Vec, + pub path_collisions: Vec, +} + +#[derive(Debug)] +pub struct PathCollision { + pub path: String, + pub count: usize, +} + +impl Report { + /// True if any anomaly was found -- the contract for the doctor exit + /// code per the User Manual. + #[must_use] + pub fn is_clean(&self) -> bool { + self.issues.is_empty() && self.warnings.is_empty() && self.path_collisions.is_empty() + } +} + +/// Run `doctor`'s base checks on the given vault root. Pure -- the binary +/// is responsible for rendering the report and choosing an exit code. +#[must_use] +pub fn run(vault_root: &Path) -> Report { + let scan = vault::scan(vault_root); + let collisions = detect_path_collisions( + &scan + .notes + .iter() + .map(|n| n.path.clone()) + .collect::>(), + ); + Report { + note_count: scan.notes.len(), + issues: scan.issues, + warnings: scan.warnings, + path_collisions: collisions, + } +} + +/// Find HTTP-surface path collisions: two distinct OS files whose +/// vault-relative, forward-slash-normalized paths are equal. Almost +/// never bites on Linux (the OS already enforces unique paths), but +/// Unicode-NFC vs -NFD or symlink-stitched paths can in principle hit it. +#[must_use] +pub fn detect_path_collisions(paths: &[String]) -> Vec { + let mut counts: BTreeMap<&str, usize> = BTreeMap::new(); + for p in paths { + *counts.entry(p.as_str()).or_insert(0) += 1; + } + counts + .into_iter() + .filter(|(_, c)| *c > 1) + .map(|(p, c)| PathCollision { + path: p.to_string(), + count: c, + }) + .collect() +} + +/// Render the report to a multi-line string the binary writes to stdout. +#[must_use] +pub fn render(vault_root: &Path, report: &Report) -> String { + let mut out = String::new(); + let _ = writeln!(out, "anwesen doctor: {}", vault_root.display()); + let _ = writeln!(out, " notes: {}", report.note_count); + let _ = writeln!(out, " issues: {}", report.issues.len()); + let _ = writeln!(out, " warnings: {}", report.warnings.len()); + let _ = writeln!(out, " path collisions: {}", report.path_collisions.len()); + if !report.issues.is_empty() { + out.push_str("\nissues:\n"); + for issue in &report.issues { + let _ = writeln!(out, " {}: {}", issue.path.display(), issue.kind); + } + } + if !report.warnings.is_empty() { + out.push_str("\nwarnings:\n"); + for w in &report.warnings { + let _ = writeln!(out, " {}: {}", w.path.display(), w.kind); + } + } + if !report.path_collisions.is_empty() { + out.push_str("\npath collisions:\n"); + for c in &report.path_collisions { + let _ = writeln!(out, " {} ({} files)", c.path, c.count); + } + } + if report.is_clean() { + out.push_str("\nOK.\n"); + } else { + out.push_str("\nFAIL.\n"); + } + out +} + +/// Convenience: run + render against an absolute vault path. Returns the +/// rendered report and the exit code the binary should propagate. +#[must_use] +pub fn run_and_render(vault_root: &Path) -> (String, i32) { + let report = run(vault_root); + let exit = i32::from(!report.is_clean()); + (render(vault_root, &report), exit) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + fn write(root: &Path, rel: &str, body: &str) { + let p = root.join(rel); + if let Some(parent) = p.parent() { + fs::create_dir_all(parent).unwrap(); + } + fs::write(p, body).unwrap(); + } + + #[test] + fn clean_vault_reports_clean() { + let tmp = TempDir::new().unwrap(); + write(tmp.path(), "a.md", "---\ntags: [demo]\n---\nbody\n"); + let r = run(tmp.path()); + assert!(r.is_clean(), "expected clean: {r:?}"); + assert_eq!(r.note_count, 1); + } + + #[test] + fn malformed_frontmatter_reports_issue_not_warning() { + let tmp = TempDir::new().unwrap(); + write(tmp.path(), "bad.md", "---\nkey: : :\n---\n"); + let r = run(tmp.path()); + assert!(!r.is_clean()); + assert_eq!(r.note_count, 0); + assert_eq!(r.issues.len(), 1); + assert!(r.warnings.is_empty()); + } + + #[test] + fn frontmatter_not_mapping_reports_warning_keeps_note() { + let tmp = TempDir::new().unwrap(); + write(tmp.path(), "list.md", "---\n- a\n- b\n---\nbody\n"); + let r = run(tmp.path()); + // Note is kept (serve compatibility); doctor flags the warning. + assert_eq!(r.note_count, 1); + assert!(r.issues.is_empty()); + assert_eq!(r.warnings.len(), 1); + assert!(!r.is_clean()); + } + + #[test] + fn detect_path_collisions_finds_duplicates() { + let dups = + detect_path_collisions(&["a.md".into(), "b.md".into(), "a.md".into(), "a.md".into()]); + assert_eq!(dups.len(), 1); + assert_eq!(dups[0].path, "a.md"); + assert_eq!(dups[0].count, 3); + } + + #[test] + fn run_and_render_exits_zero_on_clean() { + let tmp = TempDir::new().unwrap(); + write(tmp.path(), "a.md", "---\n---\n"); + let (out, exit) = run_and_render(tmp.path()); + assert_eq!(exit, 0); + assert!(out.contains("OK.")); + assert!(out.contains("notes: 1")); + } + + #[test] + fn run_and_render_exits_nonzero_on_issue() { + let tmp = TempDir::new().unwrap(); + write(tmp.path(), "bad.md", "---\n:: :: ::\n---\n"); + let (out, exit) = run_and_render(tmp.path()); + assert_eq!(exit, 1); + assert!(out.contains("FAIL.")); + assert!(out.contains("issues:")); + } +} diff --git a/src/lib.rs b/src/lib.rs index 9685f20..d068d0f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,6 +5,7 @@ //! supervisor tree, and the HTTP surface as those issues land. pub mod app; +pub mod doctor; pub mod health; pub mod http; pub mod index; diff --git a/src/main.rs b/src/main.rs index 72686c4..8f9997d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,6 +6,7 @@ mod cli; use anwesen::app::Anwesen; +use anwesen::doctor; use anyhow::Result; use clap::Parser; use hydra::Application; @@ -31,10 +32,11 @@ fn main() -> Result<()> { } Command::Doctor(args) => { init_logging(args.log_level); - tracing::info!( - vault = %args.vault.display(), - "anwesen doctor: not yet implemented (ANW-10 stub)" - ); + let (rendered, exit) = doctor::run_and_render(&args.vault); + // Render to stdout so the report is pipe-friendly; logs go to + // stderr via the tracing subscriber. + print!("{rendered}"); + std::process::exit(exit); } Command::Version => { println!("{}", env!("CARGO_PKG_VERSION")); diff --git a/src/vault.rs b/src/vault.rs index ab9e871..15ab7bd 100644 --- a/src/vault.rs +++ b/src/vault.rs @@ -112,7 +112,11 @@ pub fn frontmatter_to_json(fm: &Frontmatter) -> JsonValue { #[derive(Debug)] pub struct ScanResult { pub notes: Vec, + /// Hard failures -- file could not be loaded at all. pub issues: Vec, + /// Soft anomalies -- file loaded but flagged for `doctor`. `serve` + /// ignores these; ANW-18 surfaces them. + pub warnings: Vec, } #[derive(Debug)] @@ -133,11 +137,27 @@ pub enum ScanIssueKind { NonUtf8Body, } +#[derive(Debug)] +pub struct ScanWarning { + pub path: PathBuf, + pub kind: ScanWarningKind, +} + +#[derive(Debug, Error)] +pub enum ScanWarningKind { + /// Top-level YAML was syntactically valid but not a mapping + /// (e.g., a stray top-level list). `serve` keeps the note with an + /// empty frontmatter; `doctor` reports it. + #[error("frontmatter root is not a YAML mapping")] + FrontmatterNotMapping, +} + /// Walk the vault and return every readable Markdown note alongside any /// per-file issues. The walk never panics on a single broken file. pub fn scan(vault_root: &Path) -> ScanResult { let mut notes = Vec::new(); let mut issues = Vec::new(); + let mut warnings = Vec::new(); let walker = WalkDir::new(vault_root) .follow_links(false) @@ -165,8 +185,16 @@ pub fn scan(vault_root: &Path) -> ScanResult { if !is_markdown(abs_path) { continue; } - match scan_one(vault_root, abs_path) { - Ok(note) => notes.push(note), + match scan_one_audit(vault_root, abs_path) { + Ok((note, maybe_warning)) => { + if let Some(kind) = maybe_warning { + warnings.push(ScanWarning { + path: abs_path.to_path_buf(), + kind, + }); + } + notes.push(note); + } Err(kind) => issues.push(ScanIssue { path: abs_path.to_path_buf(), kind, @@ -174,7 +202,11 @@ pub fn scan(vault_root: &Path) -> ScanResult { } } - ScanResult { notes, issues } + ScanResult { + notes, + issues, + warnings, + } } fn is_markdown(path: &Path) -> bool { @@ -186,13 +218,28 @@ fn is_dot_prefixed(name: &std::ffi::OsStr) -> bool { } /// Read a single Markdown file off disk and produce its [`Note`]. Used by -/// [`scan`] and by the filesystem watcher's per-event handler. +/// the filesystem watcher's per-event handler -- which discards any +/// soft-warning surface. /// /// # Errors /// Returns a [`ScanIssueKind`] when the file cannot be read, the body is not /// valid UTF-8, the path itself isn't UTF-8, or the frontmatter YAML fails to /// parse. pub fn scan_one(vault_root: &Path, abs_path: &Path) -> Result { + scan_one_audit(vault_root, abs_path).map(|(note, _)| note) +} + +/// Read a single Markdown file and surface both the [`Note`] and any +/// soft warning (currently [`ScanWarningKind::FrontmatterNotMapping`]). +/// Used by [`scan`] so `doctor` can report the diagnostic without +/// changing what `serve` ingests. +/// +/// # Errors +/// Same as [`scan_one`]. +pub fn scan_one_audit( + vault_root: &Path, + abs_path: &Path, +) -> Result<(Note, Option), ScanIssueKind> { let raw_bytes = std::fs::read(abs_path)?; let metadata = std::fs::metadata(abs_path)?; // Size from the bytes we actually hashed -- avoids the one-frame drift @@ -204,7 +251,7 @@ pub fn scan_one(vault_root: &Path, abs_path: &Path) -> Result Result (&str, &str) { ("", src) } -fn parse_frontmatter(yaml: &str) -> Result { +fn parse_frontmatter_audit( + yaml: &str, +) -> Result<(Frontmatter, Option), ScanIssueKind> { if yaml.trim().is_empty() { - return Ok(BTreeMap::new()); + return Ok((BTreeMap::new(), None)); } let raw: serde_yaml::Value = serde_yaml::from_str(yaml)?; // A frontmatter that is not a mapping is not what Obsidian writes; - // treat as empty to avoid surfacing a contract surprise to consumers. + // serve keeps an empty frontmatter so the note is still served, but + // doctor sees the warning so a user can fix the file. let serde_yaml::Value::Mapping(map) = raw else { - return Ok(BTreeMap::new()); + return Ok(( + BTreeMap::new(), + Some(ScanWarningKind::FrontmatterNotMapping), + )); }; let mut out = BTreeMap::new(); for (k, v) in map { @@ -268,7 +324,7 @@ fn parse_frontmatter(yaml: &str) -> Result { }; out.insert(key, coerce(v)); } - Ok(out) + Ok((out, None)) } fn coerce(v: serde_yaml::Value) -> Value {