use anyhow::Result;
use metrale_bench::artifacts::{HomeFault, MetraleHome};
use metrale_bench::gate;
pub struct Finding {
pub label: &'static str,
pub problem: bool,
pub detail: String,
pub remedy: String,
}
impl Finding {
fn ok(label: &'static str, detail: impl Into<String>) -> Self {
Self {
label,
problem: false,
detail: detail.into(),
remedy: String::new(),
}
}
fn bad(label: &'static str, detail: impl Into<String>, remedy: impl Into<String>) -> Self {
Self {
label,
problem: true,
detail: detail.into(),
remedy: remedy.into(),
}
}
}
pub fn check_home() -> Finding {
match MetraleHome::resolve() {
Err(e) => Finding::bad(
"home",
format!("cannot be resolved: {e:#}"),
"set METRALE_HOME, or ensure HOME is set and non-empty.",
),
Ok(h) => Finding::ok("home", h.describe()),
}
}
pub fn check_writable() -> Finding {
let Ok(h) = MetraleHome::resolve() else {
return Finding::bad(
"writable",
"skipped — the home could not be resolved",
"fix `home` first.",
);
};
match h.fault() {
None => Finding::ok("writable", format!("{} is writable", h.root.display())),
Some(f @ HomeFault::NotWritable { .. }) => Finding::bad(
"writable",
format!("{} {f}", h.root.display()),
"every gate writes its run frames and provisioned artifacts here; \
an unwritable home fails the run minutes in, reported as an empty \
recipe index.",
),
Some(f) => Finding::bad(
"writable",
format!("{} {f}", h.root.display()),
"point METRALE_HOME at a directory this user can create and write.",
),
}
}
pub fn check_identity(repo_root: Option<&std::path::Path>) -> Finding {
let Ok(h) = MetraleHome::resolve() else {
return Finding::bad(
"identity",
"skipped — the home could not be resolved",
"fix `home` first.",
);
};
let key = h.root.join("identity").join("ed25519.pk8");
if !key.exists() {
return Finding::ok(
"identity",
"no signing key yet — one is minted on this box's first gate record",
);
}
let Some(root) = repo_root else {
return Finding::bad(
"identity",
format!("{} exists, but this is not a git repo", key.display()),
"run from inside the metrale checkout so the committed signer list \
can be read.",
);
};
let Ok(identity) = gate::signing::load_or_create(&h.root) else {
return Finding::bad(
"identity",
format!("{} is present but unusable", key.display()),
"delete it and let the next gate run mint a fresh one.",
);
};
let fp = identity.fingerprint().to_string();
match gate::signing::committed_signers(root) {
Err(e) => Finding::bad(
"identity",
format!("{fp}; could not read .github/record-signers/: {e:#}"),
"run from inside the checkout.",
),
Ok(list) if list.contains(&fp) => Finding::ok("identity", format!("{fp}, committed")),
Ok(list) => Finding::bad(
"identity",
format!(
"{fp} is NOT committed in .github/record-signers/ ({} signer(s) are)",
list.len()
),
"commit the one-line .pub beside the records this box produces, and \
remember every record one PR adds for a SPEED-class gate must carry \
the same fingerprint — a campaign split across boxes cannot be \
certified.",
),
}
}
pub fn check_recipes() -> Finding {
let Ok(h) = MetraleHome::resolve() else {
return Finding::bad(
"recipes",
"skipped — the home could not be resolved",
"fix `home` first.",
);
};
let index = crate::recipe::fetch::cache_dir(&h.root).join("index.json");
match std::fs::read_to_string(&index) {
Ok(text) => match crate::recipe::fetch::parse_cache(&text) {
Ok(idx) => {
let n = idx.recipes.len();
if n == 0 {
Finding::bad(
"recipes",
format!("{} parses but lists none", index.display()),
"run `met sync-recipes`.",
)
} else {
Finding::ok("recipes", format!("{n} cached in {}", index.display()))
}
}
Err(e) => Finding::bad(
"recipes",
format!(
"{} does not parse as a recipe index: {e:#}",
index.display()
),
"delete it and run `met sync-recipes`.",
),
},
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Finding::bad(
"recipes",
format!("{} has never been written", index.display()),
"run `met sync-recipes`.",
),
Err(e) => Finding::bad(
"recipes",
format!("{} exists but cannot be read: {e}", index.display()),
"this is a permission problem, not a missing sync — check `writable` \
above; `met sync-recipes` would fail on the same path.",
),
}
}
pub fn run(repo_root: Option<&std::path::Path>) -> (Vec<Finding>, bool) {
let findings = vec![
check_home(),
check_writable(),
check_identity(repo_root),
check_recipes(),
];
let bad = findings.iter().any(|f| f.problem);
(findings, bad)
}
pub fn dispatch() -> Result<i32> {
let repo_root = super::bench_run::repo_root().ok();
let (findings, bad) = run(repo_root.as_deref());
for f in &findings {
let mark = if f.problem { "PROBLEM" } else { "ok" };
println!("{:>8} {:<9} {}", mark, f.label, f.detail);
if f.problem && !f.remedy.is_empty() {
println!(" {:<9} {}", "", f.remedy);
}
}
println!();
if bad {
println!(
"{} problem(s) found.",
findings.iter().filter(|f| f.problem).count()
);
} else {
println!("no problems found.");
}
Ok(i32::from(bad))
}
#[cfg(test)]
#[path = "doctor_tests.rs"]
mod doctor_tests;