use std::path::{Path, PathBuf};
use scema_memory::MemoryStore;
use scema_verify::{verify, RecordStore};
use crate::connect::{self, Scope};
use crate::launch;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Verdict {
Ok,
Warn,
Fail,
Unknown,
}
impl Verdict {
pub fn glyph(self) -> &'static str {
match self {
Verdict::Ok => "ok ",
Verdict::Warn => "warn",
Verdict::Fail => "FAIL",
Verdict::Unknown => "? ",
}
}
}
pub struct Finding {
pub verdict: Verdict,
pub check: String,
pub detail: String,
pub fix: String,
}
impl Finding {
fn new(verdict: Verdict, check: impl Into<String>, detail: impl Into<String>) -> Self {
Finding {
verdict,
check: check.into(),
detail: detail.into(),
fix: String::new(),
}
}
fn fix(mut self, f: impl Into<String>) -> Self {
self.fix = f.into();
self
}
}
pub fn run(root: &Path, project: &Path) -> Vec<Finding> {
let mut out = Vec::new();
out.extend(components());
out.extend(state(root));
out.extend(records(root));
out.extend(memory(root));
out.extend(assistants(project));
out.extend(terminal());
out
}
fn components() -> Vec<Finding> {
launch::inventory()
.into_iter()
.map(|(c, path)| match path {
Some(p) => Finding::new(
Verdict::Ok,
format!("component `{}`", c.bin),
p.display().to_string(),
),
None => Finding::new(
Verdict::Warn,
format!("component `{}`", c.bin),
format!("not installed — {}", c.about),
)
.fix(format!("cargo install {}", c.krate)),
})
.collect()
}
fn state(root: &Path) -> Vec<Finding> {
if !root.exists() {
return vec![Finding::new(
Verdict::Warn,
"state directory",
format!("{} does not exist yet — nothing has been decided here", root.display()),
)
.fix("scema init")];
}
let probe = root.join(".scema-doctor-probe");
match std::fs::write(&probe, b"") {
Ok(()) => {
std::fs::remove_file(&probe).ok();
vec![Finding::new(Verdict::Ok, "state directory", root.display().to_string())]
}
Err(e) => vec![Finding::new(
Verdict::Fail,
"state directory",
format!("{} is not writable: {e}", root.display()),
)],
}
}
fn records(root: &Path) -> Vec<Finding> {
let store = RecordStore::new(root.to_path_buf());
let ids = match store.ids() {
Ok(i) => i,
Err(e) => {
return vec![Finding::new(
Verdict::Unknown,
"decision records",
format!("could not list: {e}"),
)]
}
};
if ids.is_empty() {
return vec![Finding::new(Verdict::Ok, "decision records", "none sealed yet")];
}
let mut valid = 0usize;
let mut invalid = Vec::new();
let mut unreadable = Vec::new();
for id in &ids {
match store.load(id) {
Ok(r) => {
if verify(&r).valid {
valid += 1;
} else {
invalid.push(id.clone());
}
}
Err(_) => unreadable.push(id.clone()),
}
}
let mut out = vec![];
if invalid.is_empty() {
out.push(Finding::new(
Verdict::Ok,
"decision records",
format!("{valid} of {} verify", ids.len()),
));
} else {
out.push(
Finding::new(
Verdict::Fail,
"decision records",
format!(
"{} record(s) DO NOT VERIFY: {}",
invalid.len(),
invalid.join(", ")
),
)
.fix("scema verify --all # names the field that moved"),
);
}
if !unreadable.is_empty() {
out.push(Finding::new(
Verdict::Unknown,
"decision records",
format!(
"{} record(s) could not be read at all: {}",
unreadable.len(),
unreadable.join(", ")
),
));
}
out
}
fn memory(root: &Path) -> Vec<Finding> {
let mem = MemoryStore::new(root.to_path_buf());
let counts = match mem.counts() {
Ok(c) => c,
Err(e) => {
return vec![Finding::new(Verdict::Unknown, "memory", format!("unreadable: {e}"))]
}
};
let total: usize = counts.iter().map(|(_, n, _)| n).sum();
let corrupt: usize = counts.iter().map(|(_, _, c)| c).sum();
let mut out = vec![Finding::new(
Verdict::Ok,
"memory",
format!("{total} record(s) across {} log(s)", counts.len()),
)];
if corrupt > 0 {
out.push(Finding::new(
Verdict::Warn,
"memory",
format!("{corrupt} unreadable line(s) — a torn append, most likely a kill mid-write"),
));
}
match mem.calibration() {
Ok(c) if c.recorded > 0 && c.resolved == 0 => out.push(Finding::new(
Verdict::Ok,
"calibration",
format!(
"{} counterfactual(s), none resolved — expected: a branch nobody ran has no outcome",
c.recorded
),
)),
Ok(c) => out.push(Finding::new(
Verdict::Ok,
"calibration",
format!("{} recorded, {} resolved", c.recorded, c.resolved),
)),
Err(e) => out.push(Finding::new(Verdict::Unknown, "calibration", format!("{e}"))),
}
out
}
fn assistants(project: &Path) -> Vec<Finding> {
let mut out = Vec::new();
let mut any = false;
for h in connect::HOSTS.iter().filter(|h| h.scope == Scope::Project) {
let path = project.join(h.project_path);
if !path.exists() {
continue;
}
any = true;
let text = match std::fs::read_to_string(&path) {
Ok(t) => t,
Err(e) => {
out.push(Finding::new(
Verdict::Unknown,
format!("assistant `{}`", h.key),
format!("{} unreadable: {e}", path.display()),
));
continue;
}
};
let parsed: Result<serde_json::Value, _> = serde_json::from_str(&text);
match parsed {
Ok(v) => {
let container = match h.shape {
connect::Shape::VsCodeServers => "servers",
_ => "mcpServers",
};
if v.get(container).and_then(|c| c.get("scema")).is_some() {
out.push(Finding::new(
Verdict::Ok,
format!("assistant `{}`", h.key),
format!("{} names `scema`", path.display()),
));
} else {
out.push(
Finding::new(
Verdict::Warn,
format!("assistant `{}`", h.key),
format!("{} exists but does not name `scema`", path.display()),
)
.fix(format!("scema connect {} --write", h.key)),
);
}
}
Err(e) => out.push(Finding::new(
Verdict::Fail,
format!("assistant `{}`", h.key),
format!("{} is not valid JSON: {e}", path.display()),
)),
}
}
if !any {
out.push(
Finding::new(
Verdict::Warn,
"assistants",
"no project-local MCP configuration found",
)
.fix("scema connect --list # then `scema connect <host> --write`"),
);
}
if launch::locate(launch::MCP).is_none() && any {
out.push(
Finding::new(
Verdict::Fail,
"assistants",
"a host is configured to run `scema-mcp`, which is not on PATH — the server will fail to start with no message the host can show you",
)
.fix("cargo install scema-mcp"),
);
}
out
}
fn terminal() -> Vec<Finding> {
let no_color = std::env::var_os("NO_COLOR").is_some();
let colorterm = std::env::var("COLORTERM").unwrap_or_default();
let term = std::env::var("TERM").unwrap_or_default();
let detail = format!(
"TERM={} COLORTERM={} NO_COLOR={}",
if term.is_empty() { "(unset)" } else { &term },
if colorterm.is_empty() { "(unset)" } else { &colorterm },
if no_color { "set" } else { "unset" }
);
vec![Finding::new(
if no_color { Verdict::Warn } else { Verdict::Ok },
"terminal",
if no_color {
format!("{detail} — the console will draw without colour, which it is designed to survive")
} else {
detail
},
)]
}
pub fn worst(findings: &[Finding]) -> Verdict {
if findings.iter().any(|f| f.verdict == Verdict::Fail) {
Verdict::Fail
} else if findings.iter().any(|f| f.verdict == Verdict::Unknown) {
Verdict::Unknown
} else if findings.iter().any(|f| f.verdict == Verdict::Warn) {
Verdict::Warn
} else {
Verdict::Ok
}
}
pub fn cwd() -> PathBuf {
std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn scratch() -> PathBuf {
let p = std::env::temp_dir().join(format!(
"scema-doctor-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
fs::create_dir_all(&p).unwrap();
p
}
#[test]
fn a_missing_state_directory_is_a_warning_with_a_fix_not_a_failure() {
let dir = scratch();
let f = state(&dir.join("nope"));
assert_eq!(f[0].verdict, Verdict::Warn);
assert_eq!(f[0].fix, "scema init");
fs::remove_dir_all(&dir).ok();
}
#[test]
fn an_unreadable_record_is_unknown_and_an_invalid_one_is_a_failure() {
let dir = scratch();
let decisions = dir.join("decisions");
fs::create_dir_all(&decisions).unwrap();
fs::write(decisions.join("deadbeef.json"), "{ not a record").unwrap();
let f = records(&dir);
assert!(
f.iter().any(|x| x.verdict == Verdict::Unknown),
"{:?}",
f.iter().map(|x| (x.verdict, x.detail.clone())).collect::<Vec<_>>()
);
assert!(f.iter().all(|x| x.verdict != Verdict::Fail));
fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_project_with_no_assistant_config_says_how_to_add_one() {
let dir = scratch();
let f = assistants(&dir);
assert!(f.iter().any(|x| x.fix.contains("scema connect")));
fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_config_that_does_not_name_scema_is_a_warning_not_a_pass() {
let dir = scratch();
fs::write(dir.join(".mcp.json"), r#"{"mcpServers":{"other":{"command":"x"}}}"#).unwrap();
let f = assistants(&dir);
let row = f.iter().find(|x| x.check.contains("claude-code")).unwrap();
assert_eq!(row.verdict, Verdict::Warn);
assert!(row.fix.contains("--write"));
fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_malformed_assistant_config_is_a_failure_and_names_the_file() {
let dir = scratch();
fs::write(dir.join(".mcp.json"), "{ nope").unwrap();
let f = assistants(&dir);
let row = f.iter().find(|x| x.check.contains("claude-code")).unwrap();
assert_eq!(row.verdict, Verdict::Fail);
assert!(row.detail.contains(".mcp.json"));
fs::remove_dir_all(&dir).ok();
}
#[test]
fn unknown_outranks_warn_but_not_fail() {
let f = |v: Verdict| Finding::new(v, "c", "d");
assert_eq!(worst(&[f(Verdict::Ok)]), Verdict::Ok);
assert_eq!(worst(&[f(Verdict::Ok), f(Verdict::Warn)]), Verdict::Warn);
assert_eq!(worst(&[f(Verdict::Warn), f(Verdict::Unknown)]), Verdict::Unknown);
assert_eq!(worst(&[f(Verdict::Unknown), f(Verdict::Fail)]), Verdict::Fail);
}
}