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.
|
//! supervisor tree, and the HTTP surface as those issues land.
|
||||||
|
|
||||||
pub mod app;
|
pub mod app;
|
||||||
|
pub mod doctor;
|
||||||
pub mod health;
|
pub mod health;
|
||||||
pub mod http;
|
pub mod http;
|
||||||
pub mod index;
|
pub mod index;
|
||||||
|
|
|
||||||
10
src/main.rs
10
src/main.rs
|
|
@ -6,6 +6,7 @@
|
||||||
mod cli;
|
mod cli;
|
||||||
|
|
||||||
use anwesen::app::Anwesen;
|
use anwesen::app::Anwesen;
|
||||||
|
use anwesen::doctor;
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use hydra::Application;
|
use hydra::Application;
|
||||||
|
|
@ -31,10 +32,11 @@ fn main() -> Result<()> {
|
||||||
}
|
}
|
||||||
Command::Doctor(args) => {
|
Command::Doctor(args) => {
|
||||||
init_logging(args.log_level);
|
init_logging(args.log_level);
|
||||||
tracing::info!(
|
let (rendered, exit) = doctor::run_and_render(&args.vault);
|
||||||
vault = %args.vault.display(),
|
// Render to stdout so the report is pipe-friendly; logs go to
|
||||||
"anwesen doctor: not yet implemented (ANW-10 stub)"
|
// stderr via the tracing subscriber.
|
||||||
);
|
print!("{rendered}");
|
||||||
|
std::process::exit(exit);
|
||||||
}
|
}
|
||||||
Command::Version => {
|
Command::Version => {
|
||||||
println!("{}", env!("CARGO_PKG_VERSION"));
|
println!("{}", env!("CARGO_PKG_VERSION"));
|
||||||
|
|
|
||||||
94
src/vault.rs
94
src/vault.rs
|
|
@ -112,7 +112,11 @@ pub fn frontmatter_to_json(fm: &Frontmatter) -> JsonValue {
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct ScanResult {
|
pub struct ScanResult {
|
||||||
pub notes: Vec<Note>,
|
pub notes: Vec<Note>,
|
||||||
|
/// Hard failures -- file could not be loaded at all.
|
||||||
pub issues: Vec<ScanIssue>,
|
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)]
|
#[derive(Debug)]
|
||||||
|
|
@ -133,11 +137,27 @@ pub enum ScanIssueKind {
|
||||||
NonUtf8Body,
|
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
|
/// Walk the vault and return every readable Markdown note alongside any
|
||||||
/// per-file issues. The walk never panics on a single broken file.
|
/// per-file issues. The walk never panics on a single broken file.
|
||||||
pub fn scan(vault_root: &Path) -> ScanResult {
|
pub fn scan(vault_root: &Path) -> ScanResult {
|
||||||
let mut notes = Vec::new();
|
let mut notes = Vec::new();
|
||||||
let mut issues = Vec::new();
|
let mut issues = Vec::new();
|
||||||
|
let mut warnings = Vec::new();
|
||||||
|
|
||||||
let walker = WalkDir::new(vault_root)
|
let walker = WalkDir::new(vault_root)
|
||||||
.follow_links(false)
|
.follow_links(false)
|
||||||
|
|
@ -165,8 +185,16 @@ pub fn scan(vault_root: &Path) -> ScanResult {
|
||||||
if !is_markdown(abs_path) {
|
if !is_markdown(abs_path) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
match scan_one(vault_root, abs_path) {
|
match scan_one_audit(vault_root, abs_path) {
|
||||||
Ok(note) => notes.push(note),
|
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 {
|
Err(kind) => issues.push(ScanIssue {
|
||||||
path: abs_path.to_path_buf(),
|
path: abs_path.to_path_buf(),
|
||||||
kind,
|
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 {
|
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
|
/// 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
|
/// # Errors
|
||||||
/// Returns a [`ScanIssueKind`] when the file cannot be read, the body is not
|
/// 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
|
/// valid UTF-8, the path itself isn't UTF-8, or the frontmatter YAML fails to
|
||||||
/// parse.
|
/// parse.
|
||||||
pub fn scan_one(vault_root: &Path, abs_path: &Path) -> Result<Note, ScanIssueKind> {
|
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 raw_bytes = std::fs::read(abs_path)?;
|
||||||
let metadata = std::fs::metadata(abs_path)?;
|
let metadata = std::fs::metadata(abs_path)?;
|
||||||
// Size from the bytes we actually hashed -- avoids the one-frame drift
|
// 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 text = std::str::from_utf8(&raw_bytes).map_err(|_| ScanIssueKind::NonUtf8Body)?;
|
||||||
let (frontmatter_yaml, body) = split_frontmatter(text);
|
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
|
let rel = abs_path
|
||||||
.strip_prefix(vault_root)
|
.strip_prefix(vault_root)
|
||||||
|
|
@ -214,15 +261,18 @@ 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.
|
// Normalize separators for HTTP-facing storage; on Linux this is a no-op.
|
||||||
let path = path.replace('\\', "/");
|
let path = path.replace('\\', "/");
|
||||||
|
|
||||||
Ok(Note {
|
Ok((
|
||||||
path,
|
Note {
|
||||||
frontmatter,
|
path,
|
||||||
body: body.to_string(),
|
frontmatter,
|
||||||
raw_bytes,
|
body: body.to_string(),
|
||||||
last_modified,
|
raw_bytes,
|
||||||
etag,
|
last_modified,
|
||||||
size,
|
etag,
|
||||||
})
|
size,
|
||||||
|
},
|
||||||
|
warning,
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Split a Markdown source into `(frontmatter_yaml, body)`. The frontmatter
|
/// Split a Markdown source into `(frontmatter_yaml, body)`. The frontmatter
|
||||||
|
|
@ -250,15 +300,21 @@ fn split_frontmatter(src: &str) -> (&str, &str) {
|
||||||
("", src)
|
("", 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() {
|
if yaml.trim().is_empty() {
|
||||||
return Ok(BTreeMap::new());
|
return Ok((BTreeMap::new(), None));
|
||||||
}
|
}
|
||||||
let raw: serde_yaml::Value = serde_yaml::from_str(yaml)?;
|
let raw: serde_yaml::Value = serde_yaml::from_str(yaml)?;
|
||||||
// A frontmatter that is not a mapping is not what Obsidian writes;
|
// 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 {
|
let serde_yaml::Value::Mapping(map) = raw else {
|
||||||
return Ok(BTreeMap::new());
|
return Ok((
|
||||||
|
BTreeMap::new(),
|
||||||
|
Some(ScanWarningKind::FrontmatterNotMapping),
|
||||||
|
));
|
||||||
};
|
};
|
||||||
let mut out = BTreeMap::new();
|
let mut out = BTreeMap::new();
|
||||||
for (k, v) in map {
|
for (k, v) in map {
|
||||||
|
|
@ -268,7 +324,7 @@ fn parse_frontmatter(yaml: &str) -> Result<Frontmatter, ScanIssueKind> {
|
||||||
};
|
};
|
||||||
out.insert(key, coerce(v));
|
out.insert(key, coerce(v));
|
||||||
}
|
}
|
||||||
Ok(out)
|
Ok((out, None))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn coerce(v: serde_yaml::Value) -> Value {
|
fn coerce(v: serde_yaml::Value) -> Value {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue