diff --git a/src/main.rs b/src/main.rs index 1b5c707..4e29e8a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -107,6 +107,14 @@ enum Commands { #[arg(long)] json: bool, }, + /// Validate a manifest. Exit code is non-zero if any check fails. + Validate { + #[arg(short, long, default_value = "verisimiser.toml")] + manifest: String, + /// Emit the structured ValidationReport as JSON instead of text. + #[arg(long)] + json: bool, + }, } fn main() -> Result<()> { @@ -226,6 +234,36 @@ fn main() -> Result<()> { Ok(()) } + Commands::Validate { manifest, json } => { + let report = manifest::validate_manifest(&manifest); + if json { + println!("{}", serde_json::to_string_pretty(&report)?); + } else { + println!("Validating {} ...", report.manifest); + for check in &report.checks { + let mark = if check.passed { "ok " } else { "FAIL" }; + println!(" [{}] {} — {}", mark, check.name, check.description); + if let Some(detail) = &check.detail { + println!(" {}", detail); + } + } + if report.passed { + println!("All {} checks passed.", report.checks.len()); + } else { + println!( + "{}/{} checks failed.", + report.failed_count(), + report.checks.len() + ); + } + } + if report.passed { + Ok(()) + } else { + anyhow::bail!("manifest validation failed"); + } + } + Commands::Version { json } => { if json { let report = serde_json::json!({ diff --git a/src/manifest/mod.rs b/src/manifest/mod.rs index 9fe84df..f28830d 100644 --- a/src/manifest/mod.rs +++ b/src/manifest/mod.rs @@ -278,6 +278,85 @@ impl Default for SidecarConfig { } } +#[cfg(test)] +mod validate_manifest_tests { + use super::validate_manifest; + + /// A well-formed manifest with no schema-source and a writable sidecar + /// parent must pass all checks. + #[test] + fn good_manifest_passes() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("verisimiser.toml"); + let sidecar_path = dir.path().join("sidecar.db"); + let body = format!( + "[project]\n\ + name = \"test\"\n\ + [database]\n\ + backend = \"sqlite\"\n\ + [sidecar]\n\ + storage = \"sqlite\"\n\ + path = \"{}\"\n", + sidecar_path.display().to_string().replace('\\', "/") + ); + std::fs::write(&path, body).expect("write"); + + let report = validate_manifest(path.to_str().unwrap()); + assert!( + report.passed, + "expected pass; checks: {:?}", + report.checks + ); + assert!(report.failed_count() == 0); + } + + /// A schema-source pointing at a missing file must fail + /// `schema-source-exists`. + #[test] + fn missing_schema_source_fails() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("verisimiser.toml"); + let sidecar_path = dir.path().join("sidecar.db"); + let body = format!( + "[project]\n\ + name = \"test\"\n\ + [database]\n\ + backend = \"sqlite\"\n\ + schema-source = \"/nonexistent/schema.sql\"\n\ + [sidecar]\n\ + storage = \"sqlite\"\n\ + path = \"{}\"\n", + sidecar_path.display().to_string().replace('\\', "/") + ); + std::fs::write(&path, body).expect("write"); + + let report = validate_manifest(path.to_str().unwrap()); + assert!(!report.passed); + let failed: Vec<&str> = report + .checks + .iter() + .filter(|c| !c.passed) + .map(|c| c.name.as_str()) + .collect(); + assert_eq!(failed, vec!["schema-source-exists"]); + } + + /// A malformed manifest must fail `manifest-loads` and stop further + /// checks (because the rest depend on having a parsed manifest). + #[test] + fn malformed_manifest_fails_load_check() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("verisimiser.toml"); + std::fs::write(&path, "broken value\n").expect("write"); + + let report = validate_manifest(path.to_str().unwrap()); + assert!(!report.passed); + assert_eq!(report.checks.len(), 1, "only manifest-loads should run"); + assert_eq!(report.checks[0].name, "manifest-loads"); + assert!(!report.checks[0].passed); + } +} + #[cfg(test)] mod load_manifest_tests { use super::load_manifest; @@ -552,6 +631,150 @@ mod init_template_tests { } } +/// Result of a single validation check. Each check is independent so the +/// CLI can report all failures, not just the first. +#[derive(Debug, Clone, Serialize)] +pub struct ValidationCheck { + /// Stable, lower-kebab-case identifier for this check. + pub name: String, + /// Human-readable description of what this check verified. + pub description: String, + /// `true` if the check passed. + pub passed: bool, + /// Failure detail if `passed == false`; `None` when the check passed. + pub detail: Option, +} + +/// Aggregate report returned by [`validate_manifest`] and by +/// `verisimiser validate --json`. +#[derive(Debug, Clone, Serialize)] +pub struct ValidationReport { + /// Resolved path of the manifest that was validated. + pub manifest: String, + /// `true` iff every entry in `checks` passed. + pub passed: bool, + /// One entry per check, in the order they ran. + pub checks: Vec, +} + +impl ValidationReport { + /// Number of failing checks. + pub fn failed_count(&self) -> usize { + self.checks.iter().filter(|c| !c.passed).count() + } +} + +/// Run all consistency checks against a manifest at `path`. +/// +/// Each check is independent — every check runs even if an earlier one +/// failed — so the user sees every problem in one go. The returned +/// `ValidationReport` has `passed = false` iff any individual check +/// failed. Closes #52. +/// +/// Checks currently performed: +/// +/// 1. **`manifest-loads`** — the file exists, is valid TOML, and +/// deserialises to `Manifest`. (Covers V-L3-H1 span-aware errors.) +/// 2. **`schema-source-exists`** — if `[database].schema-source` is +/// set, the file at that path is readable. +/// 3. **`sidecar-path-writable`** — the parent directory of +/// `[sidecar].path` is writable (or createable). +/// +/// Out of scope here: V-L2-E1 backend/target_db conflict (own issue), +/// target-DB reachability (needs live connection). +pub fn validate_manifest(path: &str) -> ValidationReport { + let mut checks = Vec::new(); + + // 1. Manifest loads. + let manifest = match load_manifest(path) { + Ok(m) => { + checks.push(ValidationCheck { + name: "manifest-loads".to_string(), + description: "Manifest file parses and deserialises".to_string(), + passed: true, + detail: None, + }); + Some(m) + } + Err(e) => { + checks.push(ValidationCheck { + name: "manifest-loads".to_string(), + description: "Manifest file parses and deserialises".to_string(), + passed: false, + detail: Some(e.to_string()), + }); + None + } + }; + + if let Some(m) = manifest.as_ref() { + // 2. Schema source exists if specified. + if let Some(schema_path) = m.database.schema_source.as_deref() { + let p = std::path::Path::new(schema_path); + if p.is_file() { + checks.push(ValidationCheck { + name: "schema-source-exists".to_string(), + description: "[database].schema-source points to a readable file".to_string(), + passed: true, + detail: None, + }); + } else { + checks.push(ValidationCheck { + name: "schema-source-exists".to_string(), + description: "[database].schema-source points to a readable file".to_string(), + passed: false, + detail: Some(format!("'{}' does not exist or is not a file", schema_path)), + }); + } + } + + // 3. Sidecar parent directory is writable / createable. + let sidecar_path = std::path::Path::new(&m.sidecar.path); + let parent = sidecar_path.parent().unwrap_or(std::path::Path::new(".")); + let writable = if parent.as_os_str().is_empty() { + // sidecar.path = "name.db" with no parent — current dir. + std::path::Path::new(".") + .metadata() + .map(|md| !md.permissions().readonly()) + .unwrap_or(false) + } else if parent.exists() { + parent + .metadata() + .map(|md| !md.permissions().readonly()) + .unwrap_or(false) + } else { + // Parent doesn't exist yet — verisimiser would create it. + // Treat as OK; surface as a warning only if we can't create. + true + }; + if writable { + checks.push(ValidationCheck { + name: "sidecar-path-writable".to_string(), + description: "Sidecar storage path's parent directory is writable".to_string(), + passed: true, + detail: None, + }); + } else { + checks.push(ValidationCheck { + name: "sidecar-path-writable".to_string(), + description: "Sidecar storage path's parent directory is writable".to_string(), + passed: false, + detail: Some(format!( + "parent of '{}' is read-only or unreachable", + m.sidecar.path + )), + }); + } + } + + let passed = checks.iter().all(|c| c.passed); + ValidationReport { + manifest: path.to_string(), + passed, + checks, + } +} + /// Documented JSON schema returned by `verisimiser status --json`. /// /// Field stability: `name`, `backend`, `sidecar_path`, `sidecar_storage`,