use std::path::Path;
use std::process::Command;
use colored::Colorize;
use crate::brain::rule_based::RuleBasedBrain;
use crate::core::doctor::{DoctorOptions, DoctorReport, run_doctor};
use crate::core::governance::GovernanceChecker;
use crate::core::release_debt::{
DEFAULT_COMMIT_WARN_THRESHOLD, MarketplaceParse, ReleaseDebtInput, VersionSurface, evaluate,
parse_cargo_lock_version, parse_cargo_toml_version, parse_json_version, parse_marketplace,
};
pub fn execute(project_root: &Path, strict: bool, json: bool) -> anyhow::Result<i32> {
let mut skip_reason = None;
let release_debt = match collect_release_debt(project_root) {
Ok(Some(input)) => Some(evaluate(&input)),
Ok(None) => {
skip_reason = Some("no recognized version manifest in this project".to_string());
None
}
Err(error) => {
skip_reason = Some(format!("release debt unresolvable: {error}"));
None
}
};
let initialized = project_root.join(".forge").join("state.json").is_file();
let governance = if initialized {
let checker = GovernanceChecker::new(project_root);
Some(checker.full_check(&RuleBasedBrain))
} else {
None
};
let report = run_doctor(&DoctorOptions {
release_debt,
governance,
skip_reason,
drift_evaluable: false,
});
if json {
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
print_report(&report);
}
Ok(report.exit_code(strict))
}
pub enum Preflight {
Clean(String),
Warned(Vec<String>),
Blocked(Vec<String>),
Skipped(String),
}
pub fn release_preflight(project_root: &Path) -> Preflight {
let input = match collect_release_debt(project_root) {
Ok(Some(input)) => input,
Ok(None) => return Preflight::Skipped("no recognized version manifest".to_string()),
Err(error) => return Preflight::Skipped(format!("release debt unresolvable: {error}")),
};
let report = evaluate(&input);
let details: Vec<String> = report
.findings
.iter()
.map(|f| format!("{}: {}", f.kind, f.detail))
.collect();
match report.status.as_str() {
"FAIL" => Preflight::Blocked(details),
"WARN" => Preflight::Warned(details),
_ => Preflight::Clean(format!(
"version {} agrees across {} surface(s); tag {}",
report.version.as_deref().unwrap_or("unknown"),
input.surfaces.len(),
report.latest_tag.as_deref().unwrap_or("none")
)),
}
}
fn print_report(report: &DoctorReport) {
let header = match report.overall.as_str() {
"FAIL" => "FORGE DOCTOR: FAIL".red().bold(),
"WARN" => "FORGE DOCTOR: WARN".yellow().bold(),
_ => "FORGE DOCTOR: OK".green().bold(),
};
println!(
"{header} | ok={} warn={} fail={} skip={}",
report.ok, report.warn, report.fail, report.skip
);
for check in &report.checks {
let tag = match check.status.as_str() {
"FAIL" => "[FAIL]".red().bold(),
"WARN" => "[WARN]".yellow().bold(),
"SKIP" => "[SKIP]".dimmed(),
_ => "[ OK ]".green(),
};
println!(" {tag} {} — {}", check.name.bold(), check.detail);
}
}
fn collect_release_debt(root: &Path) -> anyhow::Result<Option<ReleaseDebtInput>> {
let (surfaces, inventory_errors) = collect_version_surfaces(root)?;
if surfaces.is_empty() && inventory_errors.is_empty() {
return Ok(None);
}
let latest_tag = git_latest_tag(root);
let commits_since_tag = git_commit_count(root, latest_tag.as_deref());
Ok(Some(ReleaseDebtInput {
surfaces,
latest_tag,
commits_since_tag,
commit_warn_threshold: DEFAULT_COMMIT_WARN_THRESHOLD,
inventory_errors,
}))
}
const EXCLUDED_DIRS: &[&str] = &[
"node_modules",
"target",
"dist",
"dist-ui",
"build",
"out",
"coverage",
"vendor",
".git",
".next",
".venv",
"__pycache__",
".turbo",
".vite",
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SurfaceKind {
CargoManifest,
CargoLock,
JsonManifest,
JsonLock,
Marketplace,
}
fn classify(file_name: &str) -> Option<SurfaceKind> {
match file_name {
"Cargo.toml" => Some(SurfaceKind::CargoManifest),
"Cargo.lock" => Some(SurfaceKind::CargoLock),
"package.json" | "plugin.json" => Some(SurfaceKind::JsonManifest),
"package-lock.json" => Some(SurfaceKind::JsonLock),
"marketplace.json" => Some(SurfaceKind::Marketplace),
_ => None,
}
}
fn collect_version_surfaces(root: &Path) -> anyhow::Result<(Vec<VersionSurface>, Vec<String>)> {
let mut surfaces = Vec::new();
let mut problems: Vec<String> = Vec::new();
let walker = walkdir::WalkDir::new(root)
.max_depth(6)
.into_iter()
.filter_entry(|e| {
if e.depth() == 0 {
return true;
}
let name = e.file_name().to_string_lossy();
!(e.file_type().is_dir() && EXCLUDED_DIRS.contains(&name.as_ref()))
});
for entry in walker {
let entry = match entry {
Ok(entry) => entry,
Err(error) => {
problems.push(format!("could not traverse: {error}"));
continue;
}
};
if !entry.file_type().is_file() {
continue;
}
let file_name = entry.file_name().to_string_lossy().to_string();
let Some(kind) = classify(&file_name) else {
continue;
};
let rel = entry
.path()
.strip_prefix(root)
.unwrap_or(entry.path())
.display()
.to_string();
let text = match std::fs::read_to_string(entry.path()) {
Ok(text) => text,
Err(error) => {
problems.push(format!("{rel}: unreadable ({error})"));
continue;
}
};
match kind {
SurfaceKind::CargoManifest => {
surfaces.push(VersionSurface::manifest(
rel.clone(),
parse_cargo_toml_version(&text),
));
}
SurfaceKind::CargoLock => {
let manifest = entry.path().with_file_name("Cargo.toml");
match std::fs::read_to_string(&manifest)
.ok()
.and_then(|t| parse_cargo_package_name(&t))
{
Some(name) => surfaces.push(VersionSurface::lockfile(
rel.clone(),
parse_cargo_lock_version(&text, &name),
)),
None => problems.push(format!(
"{rel}: cannot resolve the crate name from its sibling Cargo.toml, so this lockfile was not verified"
)),
}
}
SurfaceKind::JsonManifest => {
surfaces.push(VersionSurface::manifest(
rel.clone(),
parse_json_version(&text),
));
}
SurfaceKind::JsonLock => {
surfaces.push(VersionSurface::lockfile(
rel.clone(),
parse_json_version(&text),
));
}
SurfaceKind::Marketplace => match parse_marketplace(&text) {
MarketplaceParse::Entries(entries) => {
for (name, version) in entries {
surfaces.push(VersionSurface::manifest(
format!("{rel}#plugins[{name}]"),
version,
));
}
}
MarketplaceParse::Malformed(reason) => {
problems.push(format!("{rel}: malformed marketplace manifest — {reason}"));
}
},
}
}
surfaces.sort_by(|a, b| a.path.cmp(&b.path));
problems.sort();
Ok((surfaces, problems))
}
fn parse_cargo_package_name(text: &str) -> Option<String> {
let mut in_package = false;
for line in text.lines() {
let trimmed = line.trim();
if trimmed.starts_with('[') {
in_package = trimmed == "[package]";
continue;
}
if in_package
&& let Some(rest) = trimmed.strip_prefix("name")
&& let Some(rest) = rest.trim_start().strip_prefix('=')
&& let Some(rest) = rest.trim().strip_prefix('"')
&& let Some(end) = rest.find('"')
{
return Some(rest[..end].to_string());
}
}
None
}
fn git_latest_tag(root: &Path) -> Option<String> {
run_git(root, &["describe", "--tags", "--abbrev=0"])
}
fn git_commit_count(root: &Path, tag: Option<&str>) -> u32 {
let range = match tag {
Some(tag) => format!("{tag}..HEAD"),
None => "HEAD".to_string(),
};
run_git(root, &["rev-list", &range, "--count"])
.and_then(|out| out.parse().ok())
.unwrap_or(0)
}
fn run_git(root: &Path, args: &[&str]) -> Option<String> {
let output = Command::new("git")
.args(args)
.current_dir(root)
.output()
.ok()?;
if !output.status.success() {
return None;
}
let text = String::from_utf8_lossy(&output.stdout).trim().to_string();
if text.is_empty() { None } else { Some(text) }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reads_cargo_package_name() {
let text = "[package]\nname = \"forge-orchestrator\"\nversion = \"1.5.2\"\n";
assert_eq!(
parse_cargo_package_name(text).as_deref(),
Some("forge-orchestrator")
);
}
#[test]
fn package_name_ignores_dependency_names() {
let text = "[dependencies]\nname = \"wrong\"\n\n[package]\nname = \"right\"\n";
assert_eq!(parse_cargo_package_name(text).as_deref(), Some("right"));
}
#[test]
fn empty_directory_yields_no_surfaces() {
let dir = std::env::temp_dir().join(format!("forge-doctor-empty-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let (surfaces, problems) = collect_version_surfaces(&dir).unwrap();
assert!(surfaces.is_empty());
assert!(
problems.is_empty(),
"an empty dir is not a failure: {problems:?}"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn this_repo_reports_agreeing_cargo_surfaces() {
let root = Path::new(env!("CARGO_MANIFEST_DIR"));
let (surfaces, problems) = collect_version_surfaces(root).unwrap();
assert!(
problems.is_empty(),
"this repo must inventory cleanly: {problems:?}"
);
let manifest = surfaces
.iter()
.find(|s| s.path == "Cargo.toml")
.expect("Cargo.toml surface");
let lock = surfaces
.iter()
.find(|s| s.path == "Cargo.lock")
.expect("Cargo.lock surface");
assert!(manifest.version.is_some(), "Cargo.toml version must parse");
assert_eq!(
manifest.version, lock.version,
"Cargo.toml and Cargo.lock must agree — this is the v1.5.1 desync guard"
);
}
}