ANW-18 doctor base checks + FrontmatterNotMapping variant
This commit is contained in:
parent
1e4d2a7b7c
commit
e7eea4b3aa
4 changed files with 277 additions and 23 deletions
195
src/doctor.rs
Normal file
195
src/doctor.rs
Normal 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:"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
10
src/main.rs
10
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"));
|
||||
|
|
|
|||
80
src/vault.rs
80
src/vault.rs
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue