use std::path::Path;
use serde::Serialize;
use crate::config::Config;
use crate::context::resolve_agent_docs;
use crate::error::RepographError;
use crate::git::validate_git_repo;
pub const DOCTOR_SCHEMA_VERSION: u32 = 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Severity {
Ok,
Warn,
Error,
}
impl Severity {
const fn rank(self) -> u8 {
match self {
Self::Error => 2,
Self::Warn => 1,
Self::Ok => 0,
}
}
}
impl PartialOrd for Severity {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for Severity {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.rank().cmp(&other.rank())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
pub enum Check {
ConfigPresent,
ConfigParse,
AgentsConfigured,
ProjectsRootExists,
RepoPathExists,
RepoIsGitRepo,
WorkspaceMembersResolve,
AgentDocPresent,
}
#[derive(Debug, Clone, Serialize)]
pub struct Finding {
pub check: Check,
pub severity: Severity,
pub target: String,
pub message: String,
}
#[derive(Debug, Clone, Copy, Default, Serialize)]
pub struct Summary {
pub ok: u32,
pub warn: u32,
pub error: u32,
pub total: u32,
}
#[derive(Debug, Clone, Serialize)]
pub struct DoctorReport {
pub schema_version: u32,
pub generated_at: String,
pub checks: Vec<Finding>,
pub summary: Summary,
}
impl DoctorReport {
#[must_use]
pub fn run(
config_load: Result<&Config, &RepographError>,
config_path: &Path,
generated_at: String,
) -> Self {
let mut findings: Vec<Finding> = Vec::new();
let file_exists = config_path.is_file();
findings.push(config_present_finding(config_path, file_exists));
let config = match config_load {
Ok(c) => {
if file_exists {
findings.push(Finding {
check: Check::ConfigParse,
severity: Severity::Ok,
target: config_path.display().to_string(),
message: "config file is valid TOML".to_string(),
});
}
c
}
Err(err) => {
findings.push(Finding {
check: Check::ConfigParse,
severity: Severity::Error,
target: config_path.display().to_string(),
message: format!("config could not be loaded: {err}"),
});
return assemble(findings, generated_at);
}
};
let agents_configured = config.agents().is_some();
findings.push(agents_configured_finding(config_path, agents_configured));
if let Some(f) = projects_root_finding(config) {
findings.push(f);
}
for (name, repo) in config.repos() {
findings.extend(check_repo(name, &repo.path));
}
findings.extend(check_workspaces(config));
if agents_configured {
findings.extend(check_agent_docs(config));
}
assemble(findings, generated_at)
}
}
fn config_present_finding(config_path: &Path, file_exists: bool) -> Finding {
if file_exists {
Finding {
check: Check::ConfigPresent,
severity: Severity::Ok,
target: config_path.display().to_string(),
message: "config file is present".to_string(),
}
} else {
Finding {
check: Check::ConfigPresent,
severity: Severity::Error,
target: config_path.display().to_string(),
message: "config file does not exist".to_string(),
}
}
}
fn agents_configured_finding(config_path: &Path, agents_configured: bool) -> Finding {
if agents_configured {
Finding {
check: Check::AgentsConfigured,
severity: Severity::Ok,
target: config_path.display().to_string(),
message: "[agents] section is present".to_string(),
}
} else {
Finding {
check: Check::AgentsConfigured,
severity: Severity::Warn,
target: config_path.display().to_string(),
message: "[agents] section missing — run `repograph init`".to_string(),
}
}
}
fn projects_root_finding(config: &Config) -> Option<Finding> {
let root = config.settings()?.projects_root.as_deref()?;
if root.is_dir() {
Some(Finding {
check: Check::ProjectsRootExists,
severity: Severity::Ok,
target: root.display().to_string(),
message: "[settings].projects_root exists".to_string(),
})
} else {
Some(Finding {
check: Check::ProjectsRootExists,
severity: Severity::Warn,
target: root.display().to_string(),
message: format!(
"[settings].projects_root does not exist: {}",
root.display()
),
})
}
}
fn check_workspaces(config: &Config) -> Vec<Finding> {
let mut out = Vec::new();
for (ws_name, workspace) in config.workspaces() {
for member in &workspace.members {
if config.repos().contains_key(member) {
out.push(Finding {
check: Check::WorkspaceMembersResolve,
severity: Severity::Ok,
target: format!("{ws_name} / {member}"),
message: "member resolves to a registered repo".to_string(),
});
} else {
out.push(Finding {
check: Check::WorkspaceMembersResolve,
severity: Severity::Warn,
target: ws_name.clone(),
message: format!(
"workspace member '{member}' is not a registered repo (dangling)"
),
});
}
}
}
out
}
fn check_agent_docs(config: &Config) -> Vec<Finding> {
let mut out = Vec::new();
let selected: &[crate::agents::AgentId] =
config.agents().map_or(&[], |a| a.selected.as_slice());
if selected.is_empty() {
return out;
}
for (name, repo) in config.repos() {
if !repo.path.is_dir() {
continue;
}
for agent in selected {
let (docs, _) = resolve_agent_docs(&repo.path, std::slice::from_ref(agent));
let has_file = docs.iter().any(|d| !d.files.is_empty());
let target = format!("{name} / {}", agent.as_str());
if has_file {
out.push(Finding {
check: Check::AgentDocPresent,
severity: Severity::Ok,
target,
message: "at least one matching agent doc found".to_string(),
});
} else {
out.push(Finding {
check: Check::AgentDocPresent,
severity: Severity::Warn,
target,
message: format!(
"no files matched {} patterns ({})",
agent.as_str(),
agent.file_patterns().join(", ")
),
});
}
}
}
out
}
fn check_repo(name: &str, repo_path: &Path) -> Vec<Finding> {
let mut out = Vec::with_capacity(2);
if repo_path.exists() {
out.push(Finding {
check: Check::RepoPathExists,
severity: Severity::Ok,
target: name.to_string(),
message: format!("path exists: {}", repo_path.display()),
});
match validate_git_repo(repo_path) {
Ok(_) => out.push(Finding {
check: Check::RepoIsGitRepo,
severity: Severity::Ok,
target: name.to_string(),
message: "path is a git repository".to_string(),
}),
Err(e) => out.push(Finding {
check: Check::RepoIsGitRepo,
severity: Severity::Error,
target: name.to_string(),
message: format!("path is not a git repository: {e}"),
}),
}
} else {
out.push(Finding {
check: Check::RepoPathExists,
severity: Severity::Error,
target: name.to_string(),
message: format!("path does not exist: {}", repo_path.display()),
});
}
out
}
fn assemble(mut findings: Vec<Finding>, generated_at: String) -> DoctorReport {
findings.sort_by(|a, b| {
b.severity
.cmp(&a.severity)
.then_with(|| a.check.cmp(&b.check))
.then_with(|| a.target.cmp(&b.target))
});
let summary = findings.iter().fold(Summary::default(), |mut acc, f| {
match f.severity {
Severity::Ok => acc.ok += 1,
Severity::Warn => acc.warn += 1,
Severity::Error => acc.error += 1,
}
acc.total += 1;
acc
});
DoctorReport {
schema_version: DOCTOR_SCHEMA_VERSION,
generated_at,
checks: findings,
summary,
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used)]
use super::*;
use crate::agents::AgentId;
use crate::config::{Agents, CONFIG_FILE_NAME, Repo, Settings};
use std::path::PathBuf;
use tempfile::TempDir;
fn ts() -> String {
"2026-05-24T00:00:00Z".to_string()
}
fn init_git_repo(parent: &Path, name: &str) -> PathBuf {
let path = parent.join(name);
std::fs::create_dir_all(&path).unwrap();
let repo = git2::Repository::init(&path).unwrap();
let sig = git2::Signature::now("T", "t@e").unwrap();
let tree_id = {
let mut index = repo.index().unwrap();
index.write_tree().unwrap()
};
let tree = repo.find_tree(tree_id).unwrap();
repo.commit(Some("HEAD"), &sig, &sig, "init", &tree, &[])
.unwrap();
crate::path::canonicalize(&path).unwrap()
}
fn write_config(dir: &Path, body: &str) {
std::fs::create_dir_all(dir).unwrap();
std::fs::write(dir.join(CONFIG_FILE_NAME), body).unwrap();
}
fn count(report: &DoctorReport, check: Check, severity: Severity) -> usize {
report
.checks
.iter()
.filter(|f| f.check == check && f.severity == severity)
.count()
}
#[test]
fn missing_config_file_emits_config_present_error() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join(CONFIG_FILE_NAME);
let cfg = Config::default();
let report = DoctorReport::run(Ok(&cfg), &path, ts());
assert_eq!(count(&report, Check::ConfigPresent, Severity::Error), 1);
assert!(report.summary.error >= 1);
}
#[test]
fn config_load_error_short_circuits_after_parse() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join(CONFIG_FILE_NAME);
write_config(tmp.path(), "[unterminated");
let err = Config::load(tmp.path()).unwrap_err();
let report = DoctorReport::run(Err(&err), &path, ts());
assert_eq!(count(&report, Check::ConfigParse, Severity::Error), 1);
assert!(
report
.checks
.iter()
.all(|f| matches!(f.check, Check::ConfigPresent | Check::ConfigParse))
);
}
#[test]
fn agents_missing_emits_warn_and_skips_agent_doc_present() {
let tmp = TempDir::new().unwrap();
let repo = init_git_repo(tmp.path(), "api");
let mut cfg = Config::default();
cfg.add_repo(
"api".into(),
Repo {
path: repo,
description: None,
stack: vec![],
},
)
.unwrap();
cfg.save(tmp.path()).unwrap();
let path = tmp.path().join(CONFIG_FILE_NAME);
let report = DoctorReport::run(Ok(&cfg), &path, ts());
assert_eq!(count(&report, Check::AgentsConfigured, Severity::Warn), 1);
assert_eq!(count(&report, Check::AgentDocPresent, Severity::Ok), 0);
assert_eq!(count(&report, Check::AgentDocPresent, Severity::Warn), 0);
}
#[test]
fn projects_root_missing_emits_warn() {
let tmp = TempDir::new().unwrap();
let mut cfg = Config::default();
cfg.set_settings(Some(Settings {
projects_root: Some(tmp.path().join("does-not-exist")),
}));
cfg.save(tmp.path()).unwrap();
let path = tmp.path().join(CONFIG_FILE_NAME);
let report = DoctorReport::run(Ok(&cfg), &path, ts());
assert_eq!(count(&report, Check::ProjectsRootExists, Severity::Warn), 1);
}
#[test]
fn projects_root_existing_emits_ok() {
let tmp = TempDir::new().unwrap();
let mut cfg = Config::default();
cfg.set_settings(Some(Settings {
projects_root: Some(tmp.path().to_path_buf()),
}));
cfg.save(tmp.path()).unwrap();
let path = tmp.path().join(CONFIG_FILE_NAME);
let report = DoctorReport::run(Ok(&cfg), &path, ts());
assert_eq!(count(&report, Check::ProjectsRootExists, Severity::Ok), 1);
}
#[test]
fn missing_repo_path_emits_error_and_skips_git_check() {
let tmp = TempDir::new().unwrap();
let mut cfg = Config::default();
cfg.add_repo(
"ghost".into(),
Repo {
path: tmp.path().join("does-not-exist"),
description: None,
stack: vec![],
},
)
.unwrap();
cfg.save(tmp.path()).unwrap();
let path = tmp.path().join(CONFIG_FILE_NAME);
let report = DoctorReport::run(Ok(&cfg), &path, ts());
assert_eq!(count(&report, Check::RepoPathExists, Severity::Error), 1);
assert_eq!(count(&report, Check::RepoIsGitRepo, Severity::Error), 0);
assert_eq!(count(&report, Check::RepoIsGitRepo, Severity::Ok), 0);
assert!(report.summary.error >= 1);
}
#[test]
fn non_git_path_emits_repo_path_ok_and_git_error() {
let tmp = TempDir::new().unwrap();
let plain_dir = tmp.path().join("notes");
std::fs::create_dir_all(&plain_dir).unwrap();
let mut cfg = Config::default();
cfg.add_repo(
"notes".into(),
Repo {
path: plain_dir,
description: None,
stack: vec![],
},
)
.unwrap();
cfg.save(tmp.path()).unwrap();
let path = tmp.path().join(CONFIG_FILE_NAME);
let report = DoctorReport::run(Ok(&cfg), &path, ts());
assert_eq!(count(&report, Check::RepoPathExists, Severity::Ok), 1);
assert_eq!(count(&report, Check::RepoIsGitRepo, Severity::Error), 1);
}
#[test]
fn healthy_git_repo_emits_both_ok() {
let tmp = TempDir::new().unwrap();
let repo = init_git_repo(tmp.path(), "api");
let mut cfg = Config::default();
cfg.add_repo(
"api".into(),
Repo {
path: repo,
description: None,
stack: vec![],
},
)
.unwrap();
cfg.save(tmp.path()).unwrap();
let path = tmp.path().join(CONFIG_FILE_NAME);
let report = DoctorReport::run(Ok(&cfg), &path, ts());
assert_eq!(count(&report, Check::RepoPathExists, Severity::Ok), 1);
assert_eq!(count(&report, Check::RepoIsGitRepo, Severity::Ok), 1);
}
#[test]
fn dangling_workspace_member_emits_warn() {
let tmp = TempDir::new().unwrap();
let repo = init_git_repo(tmp.path(), "api");
let mut cfg = Config::default();
cfg.add_repo(
"api".into(),
Repo {
path: repo,
description: None,
stack: vec![],
},
)
.unwrap();
cfg.create_workspace("acme".into(), None).unwrap();
cfg.add_members("acme", &["api".into()]).unwrap();
cfg.remove_repo("api").unwrap();
cfg.save(tmp.path()).unwrap();
let path = tmp.path().join(CONFIG_FILE_NAME);
let report = DoctorReport::run(Ok(&cfg), &path, ts());
let dangling = report
.checks
.iter()
.filter(|f| {
f.check == Check::WorkspaceMembersResolve
&& f.severity == Severity::Warn
&& f.message.contains("api")
})
.count();
assert_eq!(dangling, 1);
assert_eq!(report.summary.error, 0);
}
#[test]
fn agent_doc_missing_emits_warn() {
let tmp = TempDir::new().unwrap();
let repo = init_git_repo(tmp.path(), "api");
let mut cfg = Config::default();
cfg.add_repo(
"api".into(),
Repo {
path: repo,
description: None,
stack: vec![],
},
)
.unwrap();
cfg.set_agents(Some(Agents {
selected: vec![AgentId::ClaudeCode],
}));
cfg.save(tmp.path()).unwrap();
let path = tmp.path().join(CONFIG_FILE_NAME);
let report = DoctorReport::run(Ok(&cfg), &path, ts());
assert_eq!(count(&report, Check::AgentDocPresent, Severity::Warn), 1);
assert_eq!(report.summary.error, 0);
}
#[test]
fn agent_doc_present_emits_ok() {
let tmp = TempDir::new().unwrap();
let repo = init_git_repo(tmp.path(), "api");
std::fs::write(repo.join("CLAUDE.md"), "context\n").unwrap();
let mut cfg = Config::default();
cfg.add_repo(
"api".into(),
Repo {
path: repo,
description: None,
stack: vec![],
},
)
.unwrap();
cfg.set_agents(Some(Agents {
selected: vec![AgentId::ClaudeCode],
}));
cfg.save(tmp.path()).unwrap();
let path = tmp.path().join(CONFIG_FILE_NAME);
let report = DoctorReport::run(Ok(&cfg), &path, ts());
assert_eq!(count(&report, Check::AgentDocPresent, Severity::Ok), 1);
assert_eq!(report.summary.error, 0);
assert_eq!(report.summary.warn, 0);
}
#[test]
fn summary_totals_match_findings() {
let tmp = TempDir::new().unwrap();
let mut cfg = Config::default();
cfg.set_agents(Some(Agents { selected: vec![] }));
cfg.save(tmp.path()).unwrap();
let path = tmp.path().join(CONFIG_FILE_NAME);
let report = DoctorReport::run(Ok(&cfg), &path, ts());
assert_eq!(
report.summary.total,
report.summary.ok + report.summary.warn + report.summary.error
);
assert_eq!(report.summary.total as usize, report.checks.len());
}
#[test]
fn findings_sorted_severity_desc_then_check_asc_then_target_asc() {
let findings = vec![
Finding {
check: Check::AgentDocPresent,
severity: Severity::Ok,
target: "z".into(),
message: String::new(),
},
Finding {
check: Check::RepoPathExists,
severity: Severity::Error,
target: "a".into(),
message: String::new(),
},
Finding {
check: Check::AgentsConfigured,
severity: Severity::Warn,
target: "b".into(),
message: String::new(),
},
Finding {
check: Check::ConfigPresent,
severity: Severity::Ok,
target: "a".into(),
message: String::new(),
},
];
let report = assemble(findings, ts());
let order: Vec<_> = report
.checks
.iter()
.map(|f| (f.severity, f.check, f.target.clone()))
.collect();
assert_eq!(order[0].0, Severity::Error);
assert_eq!(order[1].0, Severity::Warn);
assert_eq!(order[2].0, Severity::Ok);
assert_eq!(order[3].0, Severity::Ok);
assert!(matches!(order[2].1, Check::ConfigPresent));
assert!(matches!(order[3].1, Check::AgentDocPresent));
}
#[test]
fn severity_ordering_error_is_max() {
assert!(Severity::Error > Severity::Warn);
assert!(Severity::Warn > Severity::Ok);
assert!(Severity::Error > Severity::Ok);
}
#[test]
fn json_envelope_has_documented_top_level_keys() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join(CONFIG_FILE_NAME);
let cfg = Config::default();
let report = DoctorReport::run(Ok(&cfg), &path, ts());
let v = serde_json::to_value(&report).unwrap();
assert_eq!(v["schema_version"], 1);
assert!(v["generated_at"].is_string());
assert!(v["checks"].is_array());
assert!(v["summary"].is_object());
assert!(v["summary"]["total"].is_number());
}
#[test]
fn check_serializes_as_pascal_case_variant_name() {
let f = Finding {
check: Check::RepoIsGitRepo,
severity: Severity::Ok,
target: "x".into(),
message: "y".into(),
};
let v = serde_json::to_value(&f).unwrap();
assert_eq!(v["check"], "RepoIsGitRepo");
assert_eq!(v["severity"], "ok");
}
}