diff --git a/src/doctor.rs b/src/doctor.rs new file mode 100644 index 0000000..50fd2a0 --- /dev/null +++ b/src/doctor.rs @@ -0,0 +1,141 @@ +// SPDX-License-Identifier: PMPL-1.0-or-later +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// `verisimiser doctor` — environment-level diagnostics. Mirrors `just +// doctor` with CLI semantics + `--json`. Closes #53. +// +// Distinction from `verisimiser validate`: +// +// - `validate` is *manifest-centric* — does this `verisimiser.toml` make +// sense? Per-check failure surfaces TOML, schema, sidecar issues. +// - `doctor` is *environment-centric* — is this host fit to run +// verisimiser at all? Reports on toolchain, PATH, working directory. +// When a manifest path is supplied, also runs the manifest checks. + +use std::process::Command; + +use crate::manifest::{ValidationCheck, ValidationReport, validate_manifest}; + +/// Run all doctor checks. If `manifest_path` is `Some(_)`, the manifest +/// validation checks (from [`validate_manifest`]) are appended to the +/// environment checks. +pub fn run_doctor(manifest_path: Option<&str>) -> ValidationReport { + let mut checks: Vec = Vec::new(); + + checks.push(check_command_in_path("cargo", "Rust toolchain (cargo)")); + checks.push(check_command_in_path("git", "git in PATH")); + checks.push(check_cwd_writable()); + + let manifest_label = manifest_path.unwrap_or("").to_string(); + if let Some(path) = manifest_path { + let report = validate_manifest(path); + checks.extend(report.checks); + } + + let passed = checks.iter().all(|c| c.passed); + ValidationReport { + manifest: manifest_label, + passed, + checks, + } +} + +/// Check whether a CLI tool resolves on PATH. Runs ` --version` with +/// a short timeout-free invocation; we only care about exit status. +fn check_command_in_path(cmd: &str, description: &str) -> ValidationCheck { + let name = format!("path-{}", cmd); + let status = Command::new(cmd).arg("--version").output(); + match status { + Ok(out) if out.status.success() => ValidationCheck { + name, + description: description.to_string(), + passed: true, + detail: None, + }, + Ok(out) => ValidationCheck { + name, + description: description.to_string(), + passed: false, + detail: Some(format!( + "`{} --version` exited with status {:?}", + cmd, out.status.code() + )), + }, + Err(e) => ValidationCheck { + name, + description: description.to_string(), + passed: false, + detail: Some(format!("`{}` not found on PATH: {}", cmd, e)), + }, + } +} + +/// Check whether the current working directory is writable. Verisimiser +/// writes manifests, sidecar databases, and generated DDL — a read-only +/// cwd will fail with permission errors at runtime. +fn check_cwd_writable() -> ValidationCheck { + let cwd_meta = std::env::current_dir().and_then(std::fs::metadata); + match cwd_meta { + Ok(md) if !md.permissions().readonly() => ValidationCheck { + name: "cwd-writable".to_string(), + description: "Current working directory is writable".to_string(), + passed: true, + detail: None, + }, + Ok(_) => ValidationCheck { + name: "cwd-writable".to_string(), + description: "Current working directory is writable".to_string(), + passed: false, + detail: Some("cwd is read-only".to_string()), + }, + Err(e) => ValidationCheck { + name: "cwd-writable".to_string(), + description: "Current working directory is writable".to_string(), + passed: false, + detail: Some(format!("cannot stat cwd: {}", e)), + }, + } +} + +#[cfg(test)] +mod tests { + use super::run_doctor; + + /// Doctor without a manifest path runs only environment checks. + #[test] + fn doctor_without_manifest_runs_env_checks_only() { + let report = run_doctor(None); + let names: Vec<&str> = report.checks.iter().map(|c| c.name.as_str()).collect(); + assert!(names.contains(&"path-cargo")); + assert!(names.contains(&"path-git")); + assert!(names.contains(&"cwd-writable")); + // No manifest-* checks present. + assert!(!names.iter().any(|n| n.starts_with("manifest-"))); + } + + /// Doctor with a manifest path runs env checks AND manifest checks. + #[test] + fn doctor_with_manifest_runs_both_sets() { + 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 = run_doctor(Some(path.to_str().unwrap())); + let names: Vec<&str> = report.checks.iter().map(|c| c.name.as_str()).collect(); + // Env checks still present. + assert!(names.contains(&"path-cargo")); + // Manifest-loads check appended. + assert!(names.contains(&"manifest-loads")); + } +} diff --git a/src/lib.rs b/src/lib.rs index 38eba2c..cad426a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,6 +9,7 @@ pub mod abi; pub mod codegen; +pub mod doctor; pub mod intercept; pub mod manifest; pub mod tier1; diff --git a/src/main.rs b/src/main.rs index 4e29e8a..92741e9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,7 +17,7 @@ use anyhow::Result; use clap::{Parser, Subcommand}; -use verisimiser::{abi, codegen, manifest}; +use verisimiser::{abi, codegen, doctor, manifest}; /// Long version string: ` (, built )`. const LONG_VERSION: &str = concat!( @@ -115,6 +115,16 @@ enum Commands { #[arg(long)] json: bool, }, + /// Environment-level diagnostics (toolchain, PATH, cwd). Optionally + /// also runs the manifest checks from `validate`. + Doctor { + /// If supplied, also run `validate` checks against this manifest. + #[arg(short, long)] + manifest: Option, + /// Emit the structured ValidationReport as JSON instead of text. + #[arg(long)] + json: bool, + }, } fn main() -> Result<()> { @@ -236,32 +246,12 @@ fn main() -> Result<()> { 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"); - } + emit_report(&report, json, "manifest validation") + } + + Commands::Doctor { manifest, json } => { + let report = doctor::run_doctor(manifest.as_deref()); + emit_report(&report, json, "doctor") } Commands::Version { json } => { @@ -281,6 +271,42 @@ fn main() -> Result<()> { } } +/// Render a `ValidationReport` (from `validate` or `doctor`) and exit +/// non-zero if any check failed. Plain-text by default; JSON when +/// `json == true`. +fn emit_report( + report: &manifest::ValidationReport, + json: bool, + kind: &str, +) -> Result<()> { + if json { + println!("{}", serde_json::to_string_pretty(report)?); + } else { + println!("Running {} for {} ...", kind, 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!("{} failed", kind); + } +} + /// Print the 8 octad dimensions with descriptions. fn print_octad() { println!("=== VeriSimDB Octad: Eight Dimensions ===");