use std::path::{Path, PathBuf};
use clap::Args;
use anyhow::Context as _;
use tga::audit::{
require_inference_credential, require_rendered_report_carries_synthesis,
require_review_supports_required_inference, resolve_review_binary, run_full_sweep,
run_review_report, sweep_gap_lines, AuditSweepStats, SweepOptions, SweepStage,
DATA_HANDLING_NOTE,
};
use tga::core::config::Config;
use tga::core::db::Database;
use tga::report::dd_manifest::{build_dd_manifest, configured_secrets, DdManifestOptions};
#[derive(Args, Debug, Default)]
#[command(
about = "One-shot acquisition-diligence sweep over an org or configured repo set.",
long_about = "Run tga's full data-collection pipeline across every configured repository \
and prepare an acquisition due-diligence package.\n\n\
This command is strictly non-interactive: once started it never prompts, \
confirms, or waits for input. Configure sources first with `tga install` or by \
hand in config.yaml, then run this.\n\n\
A stage that fails does not abort the run. Every stage is attempted, and the \
failures are named in the summary so a missing dimension reads as \"not \
assessed\" rather than as a clean pass.",
after_help = "EXAMPLES:\n\
# Audit everything in config.yaml, writing into ./audit-output\n\
tga audit\n\n\
# Named engagement, last 26 weeks, custom output directory\n\
tga audit --org acme --client \"Acme Holdings\" --analyst \"J. Reviewer\" \\\n\
--weeks 26 --output ./acme-dd"
)]
pub struct AuditArgs {
#[arg(long, value_name = "ORG")]
pub org: Option<String>,
#[arg(long, value_name = "TITLE")]
pub title: Option<String>,
#[arg(long, value_name = "NAME")]
pub analyst: Option<String>,
#[arg(long, value_name = "NAME")]
pub client: Option<String>,
#[arg(short, long, value_name = "DIR")]
pub output: Option<PathBuf>,
#[arg(long, value_name = "N")]
pub weeks: Option<u32>,
}
impl AuditArgs {
fn resolved_title(&self) -> String {
if let Some(t) = &self.title {
return t.clone();
}
match &self.org {
Some(org) => format!("{org} — Technical Due Diligence"),
None => "Technical Due Diligence".to_string(),
}
}
}
const DEFAULT_OUTPUT_DIR: &str = "audit-output";
pub async fn run(config: Config, db: &mut Database, args: AuditArgs) -> anyhow::Result<()> {
require_inference_credential()?;
require_review_supports_required_inference()?;
let output = args
.output
.clone()
.unwrap_or_else(|| PathBuf::from(DEFAULT_OUTPUT_DIR));
std::fs::create_dir_all(&output)?;
println!("Audit: {}", args.resolved_title());
println!(
" analyst: {}\n client: {}\n output: {}",
args.analyst.as_deref().unwrap_or("not stated"),
args.client.as_deref().unwrap_or("not stated"),
output.display()
);
let options = SweepOptions {
output: Some(output.clone()),
weeks: args.weeks,
};
let stats = run_full_sweep(&config, db, &options, None).await?;
print_stage_report(&stats);
let secrets = configured_secrets(&config);
let mut gaps = sweep_gap_lines(&stats, &secrets);
gaps.push(DATA_HANDLING_NOTE.to_string());
let manifest = build_dd_manifest(
&config,
&DdManifestOptions {
title: args.resolved_title(),
analyst: args.analyst.clone(),
client: args.client.clone(),
gaps,
base_dir: std::env::current_dir().unwrap_or_default(),
},
)?;
let manifest_path = output.join(MANIFEST_FILE);
std::fs::write(&manifest_path, manifest.to_toml()?)?;
println!("\nManifest: {}", manifest_path.display());
render_report(&manifest_path, &output).await
}
const MANIFEST_FILE: &str = "manifest.toml";
async fn render_report(manifest_path: &Path, output: &Path) -> anyhow::Result<()> {
println!("Rendering: {} report --manifest …", resolve_review_binary());
let run = run_review_report(manifest_path, output).await?;
if !run.stderr.trim().is_empty() {
eprintln!("{}", run.stderr.trim_end());
}
if !run.success {
anyhow::bail!(
"`{bin} report` exited with {code}; no due-diligence report was produced. Everything \
collected is intact — the manifest at {manifest} survives this, so once the cause is \
addressed re-run just the render:\n\n {bin} report --manifest {manifest} \
--analyze --synthesize --out {out}",
bin = resolve_review_binary(),
code = run
.code
.map_or_else(|| "a signal".to_string(), |c| format!("code {c}")),
manifest = manifest_path.display(),
out = output.display(),
);
}
require_rendered_report_carries_synthesis(&run).with_context(|| {
format!(
"no due-diligence report was delivered. Everything collected is intact — the manifest \
at {manifest} survives this, so once the renderer is upgraded re-run just the \
render:\n\n {bin} report --manifest {manifest} --analyze --out {out}",
bin = resolve_review_binary(),
manifest = manifest_path.display(),
out = output.display(),
)
})?;
println!("\nReport artifacts:");
for path in &run.artifacts {
println!(" {}", path.display());
}
Ok(())
}
fn print_stage_report(stats: &AuditSweepStats) {
let mut out = std::io::stdout();
let mut err = std::io::stderr();
write_stage_report(stats, &mut out, &mut err).expect("writing to stdout/stderr");
}
fn write_stage_report(
stats: &AuditSweepStats,
out: &mut impl std::io::Write,
err: &mut impl std::io::Write,
) -> std::io::Result<()> {
writeln!(out, "\nStages:")?;
for outcome in &stats.outcomes {
writeln!(
out,
" {:<20} {:>6} {:.1}s",
outcome.stage.as_str(),
stage_mark(stats, outcome),
outcome.elapsed.as_secs_f64()
)?;
}
writeln!(out, "\n{}", stats.summary())?;
if stats.any_failed() {
writeln!(
err,
"\nStages that did not complete (not assessed in this audit):"
)?;
for outcome in stats.failures() {
if let tga::audit::StageStatus::Failed(msg) = &outcome.status {
writeln!(err, " {}: {msg}", outcome.stage)?;
}
}
}
Ok(())
}
fn stage_mark(stats: &AuditSweepStats, outcome: &tga::audit::StageOutcome) -> String {
if outcome.status.is_failure() {
return "FAILED".to_string();
}
let stale = stats.stale_fetches.len();
if outcome.stage == SweepStage::Collect && stale > 0 {
return format!("ok ({stale} stale)");
}
"ok".to_string()
}
#[cfg(test)]
mod tests {
use std::time::Instant;
use tga::audit::{AuditSweepStats, StaleFetch, SweepStage};
use super::write_stage_report;
#[test]
fn audit_command_reports_each_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")),
);
let mut out = Vec::new();
let mut err = Vec::new();
write_stage_report(&stats, &mut out, &mut err).expect("write to an in-memory buffer");
let out = String::from_utf8(out).expect("stdout is UTF-8");
let err = String::from_utf8(err).expect("stderr is UTF-8");
assert!(
out.contains("collect") && out.contains("ok"),
"missing the succeeded stage's ok mark: {out}"
);
assert!(
!out.contains("no JIRA project configured"),
"the failure detail must not appear on stdout: {out}"
);
assert!(
out.contains("jira sync") && out.contains("FAILED"),
"missing the failed stage's FAILED mark: {out}"
);
assert!(
out.contains("1 of 2 stage(s) succeeded"),
"missing the summary rollup line: {out}"
);
assert!(
err.contains("jira sync") && err.contains("no JIRA project configured"),
"missing the named failure detail on stderr: {err}"
);
}
#[test]
fn collect_row_counts_stale_repositories() {
let render = |stats: &AuditSweepStats| {
let (mut out, mut err) = (Vec::new(), Vec::new());
write_stage_report(stats, &mut out, &mut err).expect("write to an in-memory buffer");
String::from_utf8(out).expect("stdout is UTF-8")
};
let collect_row = |rendered: &str| {
rendered
.lines()
.find(|l| l.contains("collect"))
.expect("collect row present")
.to_string()
};
let mut clean = AuditSweepStats::default();
clean.record(SweepStage::Collect, Instant::now(), Ok(()));
let clean_out = render(&clean);
assert!(
collect_row(&clean_out).contains(" ok "),
"a run with no stale repository must render the row unchanged: {clean_out}"
);
assert!(
!clean_out.contains("stale"),
"nothing about staleness belongs in a clean run: {clean_out}"
);
let mut stale = AuditSweepStats::default();
stale.record(SweepStage::Collect, Instant::now(), Ok(()));
for repo in ["acme-service", "acme-web"] {
stale.record_stale_fetch(StaleFetch {
repo: repo.to_string(),
remote: "origin".to_string(),
error: "unsupported URL protocol".to_string(),
});
}
let stale_row = collect_row(&render(&stale));
assert!(
stale_row.contains("ok (2 stale)"),
"the row must count the repositories that fell back: {stale_row}"
);
}
}