Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 141 additions & 0 deletions src/doctor.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
// SPDX-License-Identifier: PMPL-1.0-or-later
// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
//
// `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<ValidationCheck> = 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("<none>").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 `<cmd> --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"));
}
}
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

pub mod abi;
pub mod codegen;
pub mod doctor;
pub mod intercept;
pub mod manifest;
pub mod tier1;
Expand Down
80 changes: 53 additions & 27 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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: `<crate-version> (<git-describe>, built <date>)`.
const LONG_VERSION: &str = concat!(
Expand Down Expand Up @@ -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<String>,
/// Emit the structured ValidationReport as JSON instead of text.
#[arg(long)]
json: bool,
},
}

fn main() -> Result<()> {
Expand Down Expand Up @@ -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 } => {
Expand All @@ -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 ===");
Expand Down
Loading