ANW-18 doctor base checks + FrontmatterNotMapping variant

This commit is contained in:
Andreas Brenner 2026-05-14 18:22:30 +02:00
parent 1e4d2a7b7c
commit e7eea4b3aa
4 changed files with 277 additions and 23 deletions

195
src/doctor.rs Normal file
View file

@ -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<ScanIssue>,
pub warnings: Vec<ScanWarning>,
pub path_collisions: Vec<PathCollision>,
}
#[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::<Vec<_>>(),
);
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<PathCollision> {
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:"));
}
}

View file

@ -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;

View file

@ -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"));

View file

@ -112,7 +112,11 @@ pub fn frontmatter_to_json(fm: &Frontmatter) -> JsonValue {
#[derive(Debug)]
pub struct ScanResult {
pub notes: Vec<Note>,
/// Hard failures -- file could not be loaded at all.
pub issues: Vec<ScanIssue>,
/// Soft anomalies -- file loaded but flagged for `doctor`. `serve`
/// ignores these; ANW-18 surfaces them.
pub warnings: Vec<ScanWarning>,
}
#[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<Note, ScanIssueKind> {
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<ScanWarningKind>), 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<Note, ScanIssueKin
let text = std::str::from_utf8(&raw_bytes).map_err(|_| ScanIssueKind::NonUtf8Body)?;
let (frontmatter_yaml, body) = split_frontmatter(text);
let frontmatter = parse_frontmatter(frontmatter_yaml)?;
let (frontmatter, warning) = parse_frontmatter_audit(frontmatter_yaml)?;
let rel = abs_path
.strip_prefix(vault_root)
@ -214,7 +261,8 @@ pub fn scan_one(vault_root: &Path, abs_path: &Path) -> Result<Note, ScanIssueKin
// Normalize separators for HTTP-facing storage; on Linux this is a no-op.
let path = path.replace('\\', "/");
Ok(Note {
Ok((
Note {
path,
frontmatter,
body: body.to_string(),
@ -222,7 +270,9 @@ pub fn scan_one(vault_root: &Path, abs_path: &Path) -> Result<Note, ScanIssueKin
last_modified,
etag,
size,
})
},
warning,
))
}
/// Split a Markdown source into `(frontmatter_yaml, body)`. The frontmatter
@ -250,15 +300,21 @@ fn split_frontmatter(src: &str) -> (&str, &str) {
("", src)
}
fn parse_frontmatter(yaml: &str) -> Result<Frontmatter, ScanIssueKind> {
fn parse_frontmatter_audit(
yaml: &str,
) -> Result<(Frontmatter, Option<ScanWarningKind>), 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<Frontmatter, ScanIssueKind> {
};
out.insert(key, coerce(v));
}
Ok(out)
Ok((out, None))
}
fn coerce(v: serde_yaml::Value) -> Value {