use std::time::Instant;
use clap::{Args as _, FromArgMatches, Parser};
use crate::audit::{run_full_sweep, AuditSweepStats, StageStatus, SweepOptions, SweepStage};
use crate::commands::audit::AuditArgs;
use crate::core::config::Config;
use crate::core::db::Database;
use crate::core::progress::{ProgressBus, Stage};
const EXPECTED_ORDER: [SweepStage; 8] = [
SweepStage::Collect,
SweepStage::Classify,
SweepStage::JiraSync,
SweepStage::Deployments,
SweepStage::Incidents,
SweepStage::Dora,
SweepStage::PrMetrics,
SweepStage::Report,
];
#[test]
fn failed_stage_is_recorded_and_does_not_stop_the_sweep() {
let mut stats = AuditSweepStats::default();
stats.record(SweepStage::Collect, Instant::now(), Ok(()));
stats.record(
SweepStage::JiraSync,
Instant::now(),
Err(anyhow::anyhow!("no JIRA project configured")),
);
stats.record(SweepStage::Report, Instant::now(), Ok(()));
let stages: Vec<_> = stats.outcomes.iter().map(|o| o.stage).collect();
assert_eq!(
stages,
vec![
SweepStage::Collect,
SweepStage::JiraSync,
SweepStage::Report
]
);
let failures: Vec<_> = stats.failures().collect();
assert_eq!(failures.len(), 1);
assert_eq!(failures[0].stage, SweepStage::JiraSync);
assert_eq!(
failures[0].status,
StageStatus::Failed("no JIRA project configured".to_string())
);
assert!(stats.any_failed());
}
#[test]
fn error_cause_chain_is_preserved_in_the_stage_record() {
let mut stats = AuditSweepStats::default();
let err = anyhow::anyhow!("connection refused").context("fetching deploy events");
stats.record(SweepStage::Deployments, Instant::now(), Err(err));
let StageStatus::Failed(msg) = &stats.outcomes[0].status else {
panic!("expected a failed status");
};
assert!(
msg.contains("fetching deploy events") && msg.contains("connection refused"),
"cause chain lost: {msg}"
);
}
#[test]
fn summary_counts_successes_and_failures() {
let mut stats = AuditSweepStats::default();
assert_eq!(stats.summary(), "0 of 0 stage(s) succeeded");
assert!(!stats.any_failed());
stats.record(SweepStage::Collect, Instant::now(), Ok(()));
stats.record(
SweepStage::Dora,
Instant::now(),
Err(anyhow::anyhow!("boom")),
);
assert_eq!(stats.summary(), "1 of 2 stage(s) succeeded");
}
#[test]
fn stage_names_match_their_subcommands() {
assert_eq!(SweepStage::JiraSync.as_str(), "jira sync");
assert_eq!(SweepStage::PrMetrics.to_string(), "pr-metrics");
assert_eq!(SweepStage::Deployments.as_str(), "deployments collect");
}
#[tokio::test]
async fn sweep_runs_every_stage_in_order_and_survives_failures() {
let dir = tempfile::tempdir().expect("tempdir");
let mut db = Database::open(&dir.path().join("tga.db")).expect("open db");
let options = SweepOptions {
output: Some(dir.path().join("out")),
weeks: Some(1),
};
let stats = run_full_sweep(&Config::default(), &mut db, &options, None)
.await
.expect("sequencing itself must not fail");
let stages: Vec<_> = stats.outcomes.iter().map(|o| o.stage).collect();
assert_eq!(stages, EXPECTED_ORDER.to_vec());
assert_eq!(stages.len(), super::sweep::TOTAL_STAGES);
assert!(
stats.any_failed(),
"an unconfigured JIRA sync should have been recorded as a failure"
);
assert!(
stats.failures().any(|o| o.stage == SweepStage::JiraSync),
"expected the jira sync stage among the failures, got {:?}",
stats.failures().map(|o| o.stage).collect::<Vec<_>>()
);
let jira_index = stages
.iter()
.position(|s| *s == SweepStage::JiraSync)
.expect("jira stage present");
assert_eq!(
&stages[jira_index + 1..],
&EXPECTED_ORDER[jira_index + 1..],
"stages after the failing jira-sync stage should still have run, but were missing"
);
}
#[tokio::test]
async fn sweep_writes_reports_into_the_requested_directory() {
let dir = tempfile::tempdir().expect("tempdir");
let out = dir.path().join("audit-out");
let mut db = Database::open(&dir.path().join("tga.db")).expect("open db");
let options = SweepOptions {
output: Some(out.clone()),
weeks: None,
};
let stats = run_full_sweep(&Config::default(), &mut db, &options, None)
.await
.expect("sweep");
let report = stats
.outcomes
.iter()
.find(|o| o.stage == SweepStage::Report)
.expect("report stage ran");
assert_eq!(
report.status,
StageStatus::Succeeded,
"report stage failed: {:?}",
report.status
);
assert!(
out.is_dir(),
"report stage did not create {}",
out.display()
);
let written: Vec<String> = std::fs::read_dir(&out)
.expect("read report dir")
.map(|e| {
e.expect("dir entry")
.file_name()
.to_string_lossy()
.into_owned()
})
.collect();
assert!(
written.len() > 1,
"report stage wrote {} file(s) into {}: {written:?}",
written.len(),
out.display()
);
for expected in [
crate::report::formatters::csv::SUMMARY_CSV,
crate::report::formatters::json::REPORT_JSON,
"pr-metrics.csv",
] {
assert!(
out.join(expected).is_file(),
"expected artifact {expected} missing from {}: {written:?}",
out.display()
);
}
}
#[tokio::test]
async fn sweep_emits_progress_for_non_collection_stages() {
let dir = tempfile::tempdir().expect("tempdir");
let mut db = Database::open(&dir.path().join("tga.db")).expect("open db");
let options = SweepOptions {
output: Some(dir.path().join("out")),
weeks: Some(1),
};
let bus = ProgressBus::new();
let stats = run_full_sweep(&Config::default(), &mut db, &options, Some(&bus))
.await
.expect("sweep");
let events = bus.drain();
assert_eq!(bus.dropped(), 0, "the run must fit in the default ring");
assert!(
!events.is_empty(),
"a bus handed to run_full_sweep received nothing at all"
);
for stage in [SweepStage::Classify, SweepStage::Report] {
let mine: Vec<_> = events
.iter()
.filter(|e| e.stage == Stage::Audit && e.target == stage.as_str())
.collect();
assert!(
mine.iter().any(|e| !e.is_terminal()),
"no start event for the {stage} stage: {events:#?}"
);
assert!(
mine.iter().any(|e| e.is_terminal()),
"no completion event for the {stage} stage: {events:#?}"
);
}
for outcome in &stats.outcomes {
let terminal = events
.iter()
.find(|e| {
e.stage == Stage::Audit && e.target == outcome.stage.as_str() && e.is_terminal()
})
.unwrap_or_else(|| panic!("no terminal event for {}", outcome.stage));
let announced_failure = terminal
.outcome
.as_ref()
.is_some_and(|o| matches!(o, crate::core::progress::Outcome::Failed { .. }));
assert_eq!(
announced_failure,
outcome.status.is_failure(),
"{} announced and recorded different verdicts",
outcome.stage
);
}
}
#[tokio::test]
async fn a_disabled_bus_stays_a_no_op() {
let dir = tempfile::tempdir().expect("tempdir");
let mut db = Database::open(&dir.path().join("tga.db")).expect("open db");
let options = SweepOptions {
output: Some(dir.path().join("out")),
weeks: Some(1),
};
let bus = ProgressBus::disabled();
let stats = run_full_sweep(&Config::default(), &mut db, &options, Some(&bus))
.await
.expect("sweep");
assert!(bus.drain().is_empty(), "a disabled bus queued events");
assert_eq!(bus.dropped(), 0);
let stages: Vec<_> = stats.outcomes.iter().map(|o| o.stage).collect();
assert_eq!(
stages,
EXPECTED_ORDER.to_vec(),
"sequencing must not depend on whether anybody is watching"
);
}
#[derive(Parser, Debug)]
struct AuditOnly {
#[command(flatten)]
args: AuditArgs,
}
#[test]
fn audit_args_parse_every_flag() {
let parsed = AuditOnly::try_parse_from([
"tga-audit",
"--org",
"acme",
"--title",
"Acme DD",
"--analyst",
"J. Reviewer",
"--client",
"Acme Holdings",
"--output",
"/tmp/acme-dd",
"--weeks",
"26",
])
.expect("flags must parse");
assert_eq!(parsed.args.org.as_deref(), Some("acme"));
assert_eq!(parsed.args.title.as_deref(), Some("Acme DD"));
assert_eq!(parsed.args.analyst.as_deref(), Some("J. Reviewer"));
assert_eq!(parsed.args.client.as_deref(), Some("Acme Holdings"));
assert_eq!(
parsed.args.output.as_deref(),
Some(std::path::Path::new("/tmp/acme-dd"))
);
assert_eq!(parsed.args.weeks, Some(26));
}
#[test]
fn audit_runs_with_no_flags_at_all() {
let parsed = AuditOnly::try_parse_from(["tga-audit"]).expect("bare invocation must parse");
assert!(parsed.args.org.is_none());
assert!(parsed.args.output.is_none());
}
#[test]
fn audit_takes_no_positional_arguments() {
let err = AuditOnly::try_parse_from(["tga-audit", "acme"])
.expect_err("a positional argument must be rejected");
let rendered = err.to_string();
assert!(
rendered.contains("acme"),
"rejection must name the offending argument, got: {rendered}"
);
}
#[test]
fn audit_args_expose_a_complete_clap_command() {
let cmd = AuditArgs::augment_args(clap::Command::new("audit"));
let names: Vec<_> = cmd.get_arguments().map(|a| a.get_id().as_str()).collect();
for expected in ["org", "title", "analyst", "client", "output", "weeks"] {
assert!(
names.contains(&expected),
"missing --{expected} in {names:?}"
);
}
let matches = cmd
.try_get_matches_from(["audit", "--analyst", "me"])
.expect("parse");
let args = AuditArgs::from_arg_matches(&matches).expect("from_arg_matches");
assert_eq!(args.analyst.as_deref(), Some("me"));
}
const NO_SECRETS: &[&str] = &[];
#[test]
fn sweep_gap_lines_name_each_failed_stage() {
let mut stats = AuditSweepStats::default();
stats.record(SweepStage::Collect, Instant::now(), Ok(()));
stats.record(
SweepStage::JiraSync,
Instant::now(),
Err(anyhow::anyhow!("no JIRA project configured")),
);
stats.record(
SweepStage::Dora,
Instant::now(),
Err(anyhow::anyhow!("fact_deployments is empty")),
);
let lines = crate::audit::sweep_gap_lines(&stats, NO_SECRETS);
assert_eq!(lines.len(), 2, "one line per failure, none for success");
assert!(lines[0].contains("`jira sync`"), "{}", lines[0]);
assert!(
lines[0].contains("no JIRA project configured"),
"{}",
lines[0]
);
assert!(lines[1].contains("`dora`"), "{}", lines[1]);
for line in &lines {
assert!(line.contains("not assessed"), "{line}");
}
assert_eq!(lines, crate::audit::sweep_gap_lines(&stats, NO_SECRETS));
}
#[tokio::test]
async fn a_repo_that_fell_back_to_stale_local_refs_is_named_in_the_gap_lines() {
let dir = tempfile::tempdir().expect("tempdir");
let repo_path = dir.path().join("acme-service");
init_repo_with_dead_origin(
&repo_path,
"sshx://git@example.invalid/acme/acme-service.git",
);
let mut config = Config::default();
config
.repositories
.push(crate::core::config::RepositoryConfig {
name: Some("acme-service".to_string()),
path: repo_path,
branch: None,
since_date: None,
until_date: None,
org: None,
head_only: false,
fetch_timeout_secs: None,
});
let mut db = Database::open(&dir.path().join("tga.db")).expect("open db");
let options = SweepOptions {
output: Some(dir.path().join("out")),
weeks: Some(1),
};
let stats = run_full_sweep(&config, &mut db, &options, None)
.await
.expect("sweep");
let collect = stats
.outcomes
.iter()
.find(|o| o.stage == SweepStage::Collect)
.expect("collect stage ran");
assert_eq!(
collect.status,
StageStatus::Succeeded,
"collect should still report ok under --allow-stale: {:?}",
collect.status
);
let lines = crate::audit::sweep_gap_lines(&stats, NO_SECRETS);
let stale = lines
.iter()
.find(|l| l.contains("acme-service"))
.unwrap_or_else(|| {
panic!("no Gaps & Caveats line names the repo that fell back to stale refs: {lines:#?}")
});
assert!(
stale.contains("origin"),
"the line must name the affected remote: {stale}"
);
assert!(
stale.contains("behind the true remote state"),
"the line must say the data may be out of date: {stale}"
);
}
fn init_repo_with_dead_origin(path: &std::path::Path, remote_url: &str) {
std::fs::create_dir_all(path).expect("mkdir");
let repo = git2::Repository::init(path).expect("git init");
repo.remote("origin", remote_url).expect("add origin");
std::fs::write(path.join("README.md"), "acme").expect("write file");
let mut index = repo.index().expect("index");
index
.add_path(std::path::Path::new("README.md"))
.expect("index add");
index.write().expect("index write");
let tree = repo
.find_tree(index.write_tree().expect("write_tree"))
.expect("find_tree");
let sig = git2::Signature::now("Test", "t@example.com").expect("signature");
repo.commit(Some("HEAD"), &sig, &sig, "init", &tree, &[])
.expect("commit");
}
#[test]
fn a_credential_in_a_fetch_error_never_reaches_the_gap_line() {
let mut stats = AuditSweepStats::default();
stats.record(
SweepStage::Dora,
Instant::now(),
Err(anyhow::anyhow!("fact_deployments is empty")),
);
stats.record_stale_fetch(crate::audit::StaleFetch {
repo: "acme-service".to_string(),
remote: "origin".to_string(),
error: "failed to connect to https://x-access-token:ghp_SECRETVALUE@github.com/acme/svc"
.to_string(),
});
let lines = crate::audit::sweep_gap_lines(&stats, &["ghp_SECRETVALUE"]);
assert_eq!(lines.len(), 2, "{lines:#?}");
assert!(lines[0].contains("`dora`"), "{}", lines[0]);
assert!(lines[1].contains("acme-service"), "{}", lines[1]);
assert!(
!lines[1].contains("ghp_SECRETVALUE"),
"the token survived into the report: {}",
lines[1]
);
}
#[test]
fn sweep_gap_lines_are_empty_for_a_clean_run() {
let mut stats = AuditSweepStats::default();
for stage in EXPECTED_ORDER {
stats.record(stage, Instant::now(), Ok(()));
}
assert!(crate::audit::sweep_gap_lines(&stats, NO_SECRETS).is_empty());
}
#[test]
fn long_stage_reasons_are_truncated() {
let mut stats = AuditSweepStats::default();
stats.record(
SweepStage::Collect,
Instant::now(),
Err(anyhow::anyhow!("x".repeat(4000))),
);
let line = crate::audit::sweep_gap_lines(&stats, NO_SECRETS).remove(0);
assert!(line.contains('…'), "a long reason is excerpted: {line}");
assert!(
line.chars().count() < 400,
"one verbose error must not dominate the Gaps section ({} chars)",
line.chars().count()
);
}
#[test]
fn data_handling_note_is_a_pending_claim() {
let note = crate::audit::DATA_HANDLING_NOTE;
assert!(note.contains("pending"), "{note}");
assert!(note.contains("#5218"), "{note}");
assert!(note.contains("no file content, diffs, patches, hunks, or blobs"));
assert!(!note.contains("no code"), "{note}");
}
#[test]
fn artifact_paths_are_parsed_from_stdout() {
let stdout = " /out/acme.md\n\n/out/acme.json\n";
let paths = crate::audit::artifact_paths(stdout);
assert_eq!(
paths,
vec![
std::path::PathBuf::from("/out/acme.md"),
std::path::PathBuf::from("/out/acme.json")
]
);
assert!(crate::audit::artifact_paths("").is_empty());
}
#[tokio::test]
async fn missing_binary_is_a_named_actionable_error() {
let dir = tempfile::tempdir().expect("tempdir");
let manifest = dir.path().join("manifest.toml");
std::fs::write(&manifest, "[report]\ntitle = \"T\"\n").expect("write");
let missing = dir.path().join("definitely-not-installed");
let err =
super::review::run_review_report_with(missing.display().to_string(), &manifest, dir.path())
.await
.expect_err("a missing binary must be an error");
let msg = err.to_string();
assert!(
matches!(err, crate::audit::ReviewRunError::BinaryNotFound { .. }),
"{msg}"
);
assert!(
msg.contains("TRUSTY_REVIEW_BIN"),
"names the override: {msg}"
);
assert!(msg.contains("cargo install"), "names the fix: {msg}");
assert!(
msg.contains(&manifest.display().to_string()),
"the written manifest is still usable and must be named: {msg}"
);
}
#[test]
fn binary_resolution_prefers_the_env_override() {
use super::review::binary_from_override;
assert_eq!(
binary_from_override(Some("/opt/bin/trusty-review")),
"/opt/bin/trusty-review"
);
assert_eq!(
binary_from_override(Some("")),
crate::audit::DEFAULT_REVIEW_BIN,
"an empty override falls back to the PATH lookup"
);
assert_eq!(binary_from_override(None), crate::audit::DEFAULT_REVIEW_BIN);
let resolved = crate::audit::resolve_review_binary();
assert!(!resolved.is_empty(), "{resolved}");
}