Skip to content

Commit 8ad32b9

Browse files
hyperpolymathclaude
andcommitted
feat(cli): add verisimiser doctor subcommand
Closes #53. `validate` answers "is this manifest sane?". `doctor` answers "is this host fit to run verisimiser at all?" — environment-level diagnostics that surface before users hit cryptic permission/PATH errors at runtime. New module `src/doctor.rs`: - `run_doctor(manifest_path: Option<&str>) -> ValidationReport` — reuses the same `ValidationReport`/`ValidationCheck` shape as `validate`, so consumers (text + JSON) share rendering. - Environment checks: - `path-cargo` — Rust toolchain reachable for development. - `path-git` — git on PATH (used by `verisimiser status` provenance and the build script). - `cwd-writable` — sidecar databases and generated DDL all land under cwd by default. - If `manifest_path` is supplied, the full manifest validation checks from `validate_manifest` are appended. New CLI subcommand `verisimiser doctor [--manifest <path>] [--json]`. Shares an `emit_report` helper with `validate` so plain-text and JSON output stay in lockstep across both. New tests in `doctor::tests`: - `doctor_without_manifest_runs_env_checks_only` — names contain `path-cargo`, `path-git`, `cwd-writable`; no `manifest-*`. - `doctor_with_manifest_runs_both_sets` — env checks still present; `manifest-loads` appended. `cargo clippy --all-targets -- -D warnings` clean; 48 unit tests pass. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 0cefa56 commit 8ad32b9

3 files changed

Lines changed: 195 additions & 27 deletions

File tree

src/doctor.rs

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
// SPDX-License-Identifier: PMPL-1.0-or-later
2+
// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
3+
//
4+
// `verisimiser doctor` — environment-level diagnostics. Mirrors `just
5+
// doctor` with CLI semantics + `--json`. Closes #53.
6+
//
7+
// Distinction from `verisimiser validate`:
8+
//
9+
// - `validate` is *manifest-centric* — does this `verisimiser.toml` make
10+
// sense? Per-check failure surfaces TOML, schema, sidecar issues.
11+
// - `doctor` is *environment-centric* — is this host fit to run
12+
// verisimiser at all? Reports on toolchain, PATH, working directory.
13+
// When a manifest path is supplied, also runs the manifest checks.
14+
15+
use std::process::Command;
16+
17+
use crate::manifest::{ValidationCheck, ValidationReport, validate_manifest};
18+
19+
/// Run all doctor checks. If `manifest_path` is `Some(_)`, the manifest
20+
/// validation checks (from [`validate_manifest`]) are appended to the
21+
/// environment checks.
22+
pub fn run_doctor(manifest_path: Option<&str>) -> ValidationReport {
23+
let mut checks: Vec<ValidationCheck> = Vec::new();
24+
25+
checks.push(check_command_in_path("cargo", "Rust toolchain (cargo)"));
26+
checks.push(check_command_in_path("git", "git in PATH"));
27+
checks.push(check_cwd_writable());
28+
29+
let manifest_label = manifest_path.unwrap_or("<none>").to_string();
30+
if let Some(path) = manifest_path {
31+
let report = validate_manifest(path);
32+
checks.extend(report.checks);
33+
}
34+
35+
let passed = checks.iter().all(|c| c.passed);
36+
ValidationReport {
37+
manifest: manifest_label,
38+
passed,
39+
checks,
40+
}
41+
}
42+
43+
/// Check whether a CLI tool resolves on PATH. Runs `<cmd> --version` with
44+
/// a short timeout-free invocation; we only care about exit status.
45+
fn check_command_in_path(cmd: &str, description: &str) -> ValidationCheck {
46+
let name = format!("path-{}", cmd);
47+
let status = Command::new(cmd).arg("--version").output();
48+
match status {
49+
Ok(out) if out.status.success() => ValidationCheck {
50+
name,
51+
description: description.to_string(),
52+
passed: true,
53+
detail: None,
54+
},
55+
Ok(out) => ValidationCheck {
56+
name,
57+
description: description.to_string(),
58+
passed: false,
59+
detail: Some(format!(
60+
"`{} --version` exited with status {:?}",
61+
cmd, out.status.code()
62+
)),
63+
},
64+
Err(e) => ValidationCheck {
65+
name,
66+
description: description.to_string(),
67+
passed: false,
68+
detail: Some(format!("`{}` not found on PATH: {}", cmd, e)),
69+
},
70+
}
71+
}
72+
73+
/// Check whether the current working directory is writable. Verisimiser
74+
/// writes manifests, sidecar databases, and generated DDL — a read-only
75+
/// cwd will fail with permission errors at runtime.
76+
fn check_cwd_writable() -> ValidationCheck {
77+
let cwd_meta = std::env::current_dir().and_then(std::fs::metadata);
78+
match cwd_meta {
79+
Ok(md) if !md.permissions().readonly() => ValidationCheck {
80+
name: "cwd-writable".to_string(),
81+
description: "Current working directory is writable".to_string(),
82+
passed: true,
83+
detail: None,
84+
},
85+
Ok(_) => ValidationCheck {
86+
name: "cwd-writable".to_string(),
87+
description: "Current working directory is writable".to_string(),
88+
passed: false,
89+
detail: Some("cwd is read-only".to_string()),
90+
},
91+
Err(e) => ValidationCheck {
92+
name: "cwd-writable".to_string(),
93+
description: "Current working directory is writable".to_string(),
94+
passed: false,
95+
detail: Some(format!("cannot stat cwd: {}", e)),
96+
},
97+
}
98+
}
99+
100+
#[cfg(test)]
101+
mod tests {
102+
use super::run_doctor;
103+
104+
/// Doctor without a manifest path runs only environment checks.
105+
#[test]
106+
fn doctor_without_manifest_runs_env_checks_only() {
107+
let report = run_doctor(None);
108+
let names: Vec<&str> = report.checks.iter().map(|c| c.name.as_str()).collect();
109+
assert!(names.contains(&"path-cargo"));
110+
assert!(names.contains(&"path-git"));
111+
assert!(names.contains(&"cwd-writable"));
112+
// No manifest-* checks present.
113+
assert!(!names.iter().any(|n| n.starts_with("manifest-")));
114+
}
115+
116+
/// Doctor with a manifest path runs env checks AND manifest checks.
117+
#[test]
118+
fn doctor_with_manifest_runs_both_sets() {
119+
let dir = tempfile::tempdir().expect("tempdir");
120+
let path = dir.path().join("verisimiser.toml");
121+
let sidecar_path = dir.path().join("sidecar.db");
122+
let body = format!(
123+
"[project]\n\
124+
name = \"test\"\n\
125+
[database]\n\
126+
backend = \"sqlite\"\n\
127+
[sidecar]\n\
128+
storage = \"sqlite\"\n\
129+
path = \"{}\"\n",
130+
sidecar_path.display().to_string().replace('\\', "/")
131+
);
132+
std::fs::write(&path, body).expect("write");
133+
134+
let report = run_doctor(Some(path.to_str().unwrap()));
135+
let names: Vec<&str> = report.checks.iter().map(|c| c.name.as_str()).collect();
136+
// Env checks still present.
137+
assert!(names.contains(&"path-cargo"));
138+
// Manifest-loads check appended.
139+
assert!(names.contains(&"manifest-loads"));
140+
}
141+
}

src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
pub mod abi;
1111
pub mod codegen;
12+
pub mod doctor;
1213
pub mod intercept;
1314
pub mod manifest;
1415
pub mod tier1;

src/main.rs

Lines changed: 53 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717

1818
use anyhow::Result;
1919
use clap::{Parser, Subcommand};
20-
use verisimiser::{abi, codegen, manifest};
20+
use verisimiser::{abi, codegen, doctor, manifest};
2121

2222
/// Long version string: `<crate-version> (<git-describe>, built <date>)`.
2323
const LONG_VERSION: &str = concat!(
@@ -115,6 +115,16 @@ enum Commands {
115115
#[arg(long)]
116116
json: bool,
117117
},
118+
/// Environment-level diagnostics (toolchain, PATH, cwd). Optionally
119+
/// also runs the manifest checks from `validate`.
120+
Doctor {
121+
/// If supplied, also run `validate` checks against this manifest.
122+
#[arg(short, long)]
123+
manifest: Option<String>,
124+
/// Emit the structured ValidationReport as JSON instead of text.
125+
#[arg(long)]
126+
json: bool,
127+
},
118128
}
119129

120130
fn main() -> Result<()> {
@@ -236,32 +246,12 @@ fn main() -> Result<()> {
236246

237247
Commands::Validate { manifest, json } => {
238248
let report = manifest::validate_manifest(&manifest);
239-
if json {
240-
println!("{}", serde_json::to_string_pretty(&report)?);
241-
} else {
242-
println!("Validating {} ...", report.manifest);
243-
for check in &report.checks {
244-
let mark = if check.passed { "ok " } else { "FAIL" };
245-
println!(" [{}] {} — {}", mark, check.name, check.description);
246-
if let Some(detail) = &check.detail {
247-
println!(" {}", detail);
248-
}
249-
}
250-
if report.passed {
251-
println!("All {} checks passed.", report.checks.len());
252-
} else {
253-
println!(
254-
"{}/{} checks failed.",
255-
report.failed_count(),
256-
report.checks.len()
257-
);
258-
}
259-
}
260-
if report.passed {
261-
Ok(())
262-
} else {
263-
anyhow::bail!("manifest validation failed");
264-
}
249+
emit_report(&report, json, "manifest validation")
250+
}
251+
252+
Commands::Doctor { manifest, json } => {
253+
let report = doctor::run_doctor(manifest.as_deref());
254+
emit_report(&report, json, "doctor")
265255
}
266256

267257
Commands::Version { json } => {
@@ -281,6 +271,42 @@ fn main() -> Result<()> {
281271
}
282272
}
283273

274+
/// Render a `ValidationReport` (from `validate` or `doctor`) and exit
275+
/// non-zero if any check failed. Plain-text by default; JSON when
276+
/// `json == true`.
277+
fn emit_report(
278+
report: &manifest::ValidationReport,
279+
json: bool,
280+
kind: &str,
281+
) -> Result<()> {
282+
if json {
283+
println!("{}", serde_json::to_string_pretty(report)?);
284+
} else {
285+
println!("Running {} for {} ...", kind, report.manifest);
286+
for check in &report.checks {
287+
let mark = if check.passed { "ok " } else { "FAIL" };
288+
println!(" [{}] {} — {}", mark, check.name, check.description);
289+
if let Some(detail) = &check.detail {
290+
println!(" {}", detail);
291+
}
292+
}
293+
if report.passed {
294+
println!("All {} checks passed.", report.checks.len());
295+
} else {
296+
println!(
297+
"{}/{} checks failed.",
298+
report.failed_count(),
299+
report.checks.len()
300+
);
301+
}
302+
}
303+
if report.passed {
304+
Ok(())
305+
} else {
306+
anyhow::bail!("{} failed", kind);
307+
}
308+
}
309+
284310
/// Print the 8 octad dimensions with descriptions.
285311
fn print_octad() {
286312
println!("=== VeriSimDB Octad: Eight Dimensions ===");

0 commit comments

Comments
 (0)