use std::path::{Path, PathBuf};
use std::process::ExitCode;
use anyhow::{Context, Result};
use api_debug_lab::cases::{list_cases, Case};
use api_debug_lab::report::Format;
use api_debug_lab::rules::{all_rules, diagnose};
use clap::{Parser, Subcommand};
#[derive(Parser)]
#[command(
name = "api-debug-lab",
version,
about = "Reproducible API troubleshooting fixtures and a Rust diagnostic CLI."
)]
struct Cli {
#[arg(long, value_name = "DIR", global = true)]
fixtures: Option<PathBuf>,
#[command(subcommand)]
cmd: Cmd,
}
#[derive(Subcommand)]
enum Cmd {
ListCases,
Diagnose {
case: String,
#[arg(long, value_enum, default_value_t)]
format: Format,
#[arg(long)]
trace: bool,
},
Explain { case: String },
Replay { case: String },
Report { case: String },
Corpus {
dir: PathBuf,
#[arg(long)]
ndjson: bool,
},
}
fn main() -> ExitCode {
let cli = Cli::parse();
let fixtures = cli
.fixtures
.clone()
.unwrap_or_else(|| PathBuf::from("fixtures"));
let result = match cli.cmd {
Cmd::ListCases => run_list(&fixtures),
Cmd::Diagnose {
case,
format,
trace,
} => run_diagnose(&case, &fixtures, format, trace),
Cmd::Explain { case } => run_explain(&case, &fixtures),
Cmd::Replay { case } => run_replay(&case, &fixtures),
Cmd::Report { case } => run_diagnose(&case, &fixtures, Format::Human, false),
Cmd::Corpus { dir, ndjson } => run_corpus(&dir, ndjson),
};
match result {
Ok(code) => ExitCode::from(code as u8),
Err(err) => {
eprintln!("error: {err:#}");
ExitCode::from(2)
}
}
}
fn run_list(fixtures: &Path) -> Result<i32> {
let names = list_cases(fixtures);
if names.is_empty() {
anyhow::bail!(
"no fixtures found under {} (run from repo root or pass --fixtures)",
fixtures.display()
);
}
for name in names {
println!("{name}");
}
Ok(0)
}
fn run_diagnose(case_name: &str, fixtures: &Path, format: Format, trace: bool) -> Result<i32> {
use api_debug_lab::rules::diagnose_traced;
let case =
Case::load(case_name, fixtures).with_context(|| format!("loading case {case_name}"))?;
if trace {
let (report, traces) = diagnose_traced(&case);
eprintln!("# trace: per-rule wall-clock timing");
let total_ns: u128 = traces.iter().map(|t| t.duration.as_nanos()).sum();
for t in &traces {
let outcome = match t.confidence {
Some(c) => format!("fired (confidence {:.2})", c),
None => "skipped".to_string(),
};
eprintln!(
"{:<32} {:>9.2} µs {}",
t.rule_id,
t.duration.as_secs_f64() * 1e6,
outcome
);
}
eprintln!("{:<32} {:>9.2} µs total", "", total_ns as f64 / 1000.0);
print!("{}", report.render(format));
Ok(report.exit_code())
} else {
let report = diagnose(&case);
print!("{}", report.render(format));
Ok(report.exit_code())
}
}
fn run_explain(case_name: &str, fixtures: &Path) -> Result<i32> {
let case =
Case::load(case_name, fixtures).with_context(|| format!("loading case {case_name}"))?;
let mut fired = false;
for rule in all_rules() {
if let Some(diag) = rule.evaluate(&case) {
fired = true;
println!(
"rule {} fired with confidence {:.2}",
rule.id(),
diag.confidence
);
for ev in &diag.evidence {
match &ev.pointer {
Some(p) => match p.line {
Some(line) => {
println!(" - [{}:{line}] {}", p.source, ev.message);
}
None => println!(" - [{}] {}", p.source, ev.message),
},
None => println!(" - {}", ev.message),
}
}
}
}
if !fired {
println!("no rule fired for case {case_name}");
return Ok(1);
}
Ok(0)
}
fn run_replay(case_name: &str, fixtures: &Path) -> Result<i32> {
let case =
Case::load(case_name, fixtures).with_context(|| format!("loading case {case_name}"))?;
let report = diagnose(&case);
println!("# Reproduction (offline; copy/paste to run against a real service)");
println!("{}", report.reproduction);
println!();
print!("{}", report.render(Format::Human));
Ok(report.exit_code())
}
fn collect_case_files(dir: &Path) -> Result<Vec<PathBuf>> {
let mut out = Vec::new();
walk(dir, &mut out)?;
out.sort();
Ok(out)
}
fn walk(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
let entries =
std::fs::read_dir(dir).with_context(|| format!("reading directory {}", dir.display()))?;
for entry in entries {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
walk(&path, out)?;
} else if path.file_name().and_then(|s| s.to_str()) == Some("case.json") {
out.push(path);
}
}
Ok(())
}
fn run_corpus(dir: &Path, ndjson: bool) -> Result<i32> {
let case_files = collect_case_files(dir)?;
if case_files.is_empty() {
anyhow::bail!("no case.json files found under {}", dir.display());
}
let mut classified = 0usize;
let mut unclassified = 0usize;
let mut load_errors = 0usize;
let mut by_rule: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();
for path in &case_files {
let case = match Case::load(path.to_str().unwrap_or_default(), dir) {
Ok(c) => c,
Err(e) => {
load_errors += 1;
if ndjson {
println!(
"{}",
serde_json::json!({"path": path.to_string_lossy(), "error": e.to_string()})
);
} else {
println!("{}: load error: {e}", path.display());
}
continue;
}
};
let report = diagnose(&case);
let rel = path.strip_prefix(dir).unwrap_or(path);
match &report.primary {
Some(d) if d.confidence >= 0.6 => {
classified += 1;
*by_rule.entry(d.rule_id.clone()).or_insert(0) += 1;
if ndjson {
println!(
"{}",
serde_json::json!({
"path": rel.to_string_lossy(),
"case": case.name,
"rule_id": d.rule_id,
"confidence": d.confidence,
"primary_likely_cause": d.likely_cause,
"also_considered": report.also_considered.len(),
})
);
} else {
println!(
"{:<60} {:<28} confidence {:.2}",
rel.display(),
d.rule_id,
d.confidence
);
}
}
_ => {
unclassified += 1;
if ndjson {
println!(
"{}",
serde_json::json!({
"path": rel.to_string_lossy(),
"case": case.name,
"rule_id": null,
"confidence": 0.0,
})
);
} else {
println!(
"{:<60} {:<28} confidence 0.00",
rel.display(),
"<unclassified>"
);
}
}
}
}
if !ndjson {
println!();
println!(
"Summary: {} case(s); {} classified, {} unclassified, {} load errors",
case_files.len(),
classified,
unclassified,
load_errors
);
if !by_rule.is_empty() {
println!("Per-rule fire counts:");
for (rule, count) in &by_rule {
println!(" {rule:<32} {count}");
}
}
}
if unclassified == 0 && load_errors == 0 {
Ok(0)
} else {
Ok(1)
}
}