use std::path::{Path, PathBuf};
use clap::Args;
use anyhow::Context as _;
use tga::audit::{
ensure_analyze_daemon, ensure_repositories_indexed, ensure_search_daemon, index_gap_lines,
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::core::progress::{ProgressBus, ProgressEvent, Stage, StageRelay};
use tga::report::dd_manifest::{
build_dd_manifest, configured_secrets, dd_repository_entries, repo_name, DdManifestOptions,
};
use tga::report::build_ticketing_summary;
use tga::report::{build_authorship_summary, recorded_repository_names, repository_has_commits};
use trusty_common::credentials::scrub_secrets;
#[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>,
#[arg(long)]
pub no_render: bool,
}
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()?;
ensure_search_daemon().await?;
ensure_analyze_daemon().await?;
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 relay = StageRelay::from_env();
let stats = run_full_sweep(&config, db, &options, relay.bus()).await?;
print_stage_report(&stats);
let secrets = configured_secrets(&config);
let mut gaps = sweep_gap_lines(&stats, &secrets);
let base_dir = std::env::current_dir().unwrap_or_default();
let phase = announce(relay.bus(), PHASE_INDEX);
let indexed = ensure_repositories_indexed(&dd_repository_entries(&config, &base_dir)).await;
finish_phase(relay.bus(), phase);
gaps.extend(index_gap_lines(&indexed, &secrets));
let ticketing = match ticketing_artifact(&stats, db, &output) {
Ok(path) => path,
Err(e) => {
gaps.push(scrub_secrets(
&format!(
"Ticketing correlation: the sweep linked commits to board items, but the \
figures could not be written to {TICKETING_FILE} ({e:#}). The report states \
no board coverage for this run."
),
&secrets,
));
None
}
};
gaps.push(DATA_HANDLING_NOTE.to_string());
let mut manifest = build_dd_manifest(
&config,
&DdManifestOptions {
title: args.resolved_title(),
analyst: args.analyst.clone(),
client: args.client.clone(),
gaps,
base_dir,
ticketing,
},
)?;
let mut authorship_gaps: Vec<String> = Vec::new();
for (i, (entry, repo_cfg)) in manifest
.repositories
.iter_mut()
.zip(&config.repositories)
.enumerate()
{
let repository = repo_name(repo_cfg.name.as_deref(), &repo_cfg.path);
match authorship_artifact(db, &output, &repository, i) {
Ok(AuthorshipArtifact::Written(path)) => entry.authorship = Some(path),
Ok(AuthorshipArtifact::NameMatchedNothing(recorded)) => {
authorship_gaps.push(scrub_secrets(
&authorship_no_match_gap(&entry.name, &repository, &recorded),
&configured_secrets(&config),
));
}
Err(e) => authorship_gaps.push(scrub_secrets(
&format!(
"Authorship ({}): could not write the authorship artifact ({e:#}). The \
report states no authorship/key-person signal for this application.",
entry.name
),
&configured_secrets(&config),
)),
}
}
manifest.report.gaps.extend(authorship_gaps);
let manifest_path = output.join(MANIFEST_FILE);
let existing = read_existing_manifest(&manifest_path)?;
std::fs::write(
&manifest_path,
manifest.to_toml_merged(existing.as_deref())?,
)?;
println!("\nManifest: {}", manifest_path.display());
if args.no_render {
println!("Skipping the report render (--no-render).");
relay.finish().await;
return Ok(());
}
let phase = announce(relay.bus(), PHASE_RENDER);
let rendered = render_report(&manifest_path, &output).await;
match &rendered {
Ok(()) => finish_phase(relay.bus(), phase),
Err(e) => fail_phase(relay.bus(), phase, format!("{e:#}")),
}
relay.finish().await;
rendered
}
const PHASE_INDEX: &str = "index repositories";
const PHASE_RENDER: &str = "render report";
fn announce(progress: Option<&ProgressBus>, phase: &'static str) -> &'static str {
if let Some(bus) = progress {
bus.emit(ProgressEvent::started(Stage::Audit, phase, Some(1)));
}
phase
}
fn finish_phase(progress: Option<&ProgressBus>, phase: &'static str) {
if let Some(bus) = progress {
bus.emit(ProgressEvent::completed(Stage::Audit, phase, 1));
}
}
fn fail_phase(progress: Option<&ProgressBus>, phase: &'static str, reason: String) {
if let Some(bus) = progress {
bus.emit(ProgressEvent::failed(Stage::Audit, phase, reason));
}
}
const MANIFEST_FILE: &str = "manifest.toml";
fn read_existing_manifest(path: &Path) -> anyhow::Result<Option<String>> {
match std::fs::read_to_string(path) {
Ok(text) => Ok(Some(text)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(anyhow::Error::new(e).context(format!(
"the existing manifest at {} could not be read; it may carry investigation scope this \
run does not own, so it is not overwritten",
path.display()
))),
}
}
const TICKETING_FILE: &str = "ticketing.json";
fn ticketing_artifact(
stats: &AuditSweepStats,
db: &Database,
output: &Path,
) -> anyhow::Result<Option<PathBuf>> {
let correlated = stats
.outcomes
.iter()
.any(|o| o.stage == SweepStage::Correlate && !o.status.is_failure());
if !correlated {
return Ok(None);
}
let summary = build_ticketing_summary(db.connection())?;
std::fs::write(output.join(TICKETING_FILE), summary.to_json()?)?;
Ok(Some(PathBuf::from(TICKETING_FILE)))
}
fn authorship_artifact(
db: &Database,
output: &Path,
repository: &str,
index: usize,
) -> anyhow::Result<AuthorshipArtifact> {
if !repository_has_commits(db.connection(), repository)? {
return Ok(AuthorshipArtifact::NameMatchedNothing(
recorded_repository_names(db.connection())?,
));
}
let summary = build_authorship_summary(db.connection(), repository)?;
let filename = format!("authorship-{index}.json");
std::fs::write(output.join(&filename), summary.to_json()?)?;
Ok(AuthorshipArtifact::Written(PathBuf::from(filename)))
}
fn authorship_no_match_gap(display_name: &str, repository: &str, recorded: &[String]) -> String {
if recorded.is_empty() {
format!(
"Authorship ({display_name}): the sweep recorded no commits at all, so there is no \
authorship/key-person signal for this application."
)
} else {
format!(
"Authorship ({display_name}): no collected commit is recorded under the repository \
name `{repository}` (the database holds commits under: {}). The report states no \
authorship/key-person signal for this application rather than deriving zeroes from \
an unmatched name.",
recorded.join(", ")
)
}
}
#[derive(Debug)]
enum AuthorshipArtifact {
Written(PathBuf),
NameMatchedNothing(Vec<String>),
}
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 tga::core::db::Database;
use super::write_stage_report;
use super::{announce, fail_phase, finish_phase, PHASE_INDEX, PHASE_RENDER};
use tga::core::progress::{Outcome, ProgressBus};
#[test]
fn the_post_sweep_phases_are_announced() {
let bus = ProgressBus::new();
let phase = announce(Some(&bus), PHASE_INDEX);
finish_phase(Some(&bus), phase);
let phase = announce(Some(&bus), PHASE_RENDER);
fail_phase(Some(&bus), phase, "the renderer exited 1".to_string());
let events = bus.drain();
let targets: Vec<&str> = events.iter().map(|e| e.target.as_str()).collect();
assert_eq!(
targets,
vec![PHASE_INDEX, PHASE_INDEX, PHASE_RENDER, PHASE_RENDER]
);
assert_eq!(events[1].outcome, Some(Outcome::Completed));
assert_eq!(
events[3].outcome.as_ref().and_then(Outcome::reason),
Some("the renderer exited 1")
);
announce(None, PHASE_INDEX);
finish_phase(None, PHASE_INDEX);
fail_phase(None, PHASE_RENDER, "ignored".to_string());
}
#[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}"
);
}
#[test]
fn a_failed_correlation_stage_writes_no_ticketing_artifact() {
let dir = tempfile::tempdir().expect("tempdir");
let db = Database::open(&dir.path().join("tga.db")).expect("open db");
let mut stats = AuditSweepStats::default();
stats.record(
SweepStage::Correlate,
Instant::now(),
Err(anyhow::anyhow!("database is locked")),
);
let path = super::ticketing_artifact(&stats, &db, dir.path()).expect("no hard failure");
assert_eq!(path, None, "a failed correlation stage declares nothing");
assert!(
!dir.path().join(super::TICKETING_FILE).exists(),
"no artifact may be written for a failed correlation stage"
);
}
#[test]
fn a_succeeded_correlation_stage_writes_the_artifact() {
let dir = tempfile::tempdir().expect("tempdir");
let db = Database::open(&dir.path().join("tga.db")).expect("open db");
let mut stats = AuditSweepStats::default();
stats.record(SweepStage::Correlate, Instant::now(), Ok(()));
let path = super::ticketing_artifact(&stats, &db, dir.path()).expect("write");
assert_eq!(path, Some(std::path::PathBuf::from(super::TICKETING_FILE)));
let written = std::fs::read_to_string(dir.path().join(super::TICKETING_FILE))
.expect("artifact written");
assert!(
written.contains("\"commits\"") && written.contains("\"work_items\""),
"the artifact must carry the board counts: {written}"
);
}
fn seed_commit(db: &Database, sha: &str, repository: &str) {
db.connection()
.execute(
"INSERT INTO commits (sha, author_name, author_email, timestamp, message, \
repository, is_merge) \
VALUES (?1, 'Alice', 'alice@x.com', '2026-01-15T00:00:00Z', 'msg', ?2, 0)",
rusqlite::params![sha, repository],
)
.expect("insert commit");
let commit_id = db.connection().last_insert_rowid();
db.connection()
.execute(
"INSERT INTO files (commit_id, path, change_type) VALUES (?1, 'src/lib.rs', 'modified')",
rusqlite::params![commit_id],
)
.expect("insert file");
}
#[test]
fn authorship_artifact_is_written_per_repository() {
let dir = tempfile::tempdir().expect("tempdir");
let db = Database::open(&dir.path().join("tga.db")).expect("open db");
seed_commit(&db, "a1", "acme-web");
seed_commit(&db, "b1", "acme-api");
let first = super::authorship_artifact(&db, dir.path(), "acme-web", 0).expect("write");
let second = super::authorship_artifact(&db, dir.path(), "acme-api", 1).expect("write");
let (super::AuthorshipArtifact::Written(first), super::AuthorshipArtifact::Written(second)) =
(first, second)
else {
panic!("both repositories have commits, so both must produce figures");
};
assert_eq!(first, std::path::PathBuf::from("authorship-0.json"));
assert_eq!(second, std::path::PathBuf::from("authorship-1.json"));
let written = std::fs::read_to_string(dir.path().join("authorship-0.json"))
.expect("artifact written");
assert!(
written.contains("\"repository\": \"acme-web\"") && written.contains("\"bus_factor\""),
"the artifact must carry THIS repository's figures: {written}"
);
assert!(
!written.contains("acme-api"),
"the per-repository filter must not leak the sibling's commits: {written}"
);
}
#[test]
fn a_failed_authorship_write_is_a_named_gap() {
let dir = tempfile::tempdir().expect("tempdir");
let db = Database::open(&dir.path().join("tga.db")).expect("open db");
seed_commit(&db, "a1", "acme-web");
let blocked = dir.path().join("not-a-directory");
std::fs::write(&blocked, "").expect("create blocking file");
let err = super::authorship_artifact(&db, &blocked, "acme-web", 0)
.expect_err("an unwritable output must surface, not be swallowed");
let rendered = format!("{err:#}");
assert!(
!rendered.is_empty(),
"the error must carry a reason for the gap line to quote"
);
assert!(
!blocked.join("authorship-0.json").exists(),
"nothing may be left behind by a failed write"
);
}
#[test]
fn a_repository_name_matching_no_commits_is_a_named_gap_not_zero_authors() {
let dir = tempfile::tempdir().expect("tempdir");
let db = Database::open(&dir.path().join("tga.db")).expect("open db");
seed_commit(&db, "a1", "acme_web");
let outcome = super::authorship_artifact(&db, dir.path(), "acme-web", 0).expect("no error");
let super::AuthorshipArtifact::NameMatchedNothing(recorded) = outcome else {
panic!("an unmatched name must never produce an artifact of zeroes");
};
assert_eq!(recorded, vec!["acme_web".to_string()]);
assert!(
!dir.path().join("authorship-0.json").exists(),
"no artifact may be written for a name that matched nothing"
);
let gap = super::authorship_no_match_gap("Acme Web", "acme-web", &recorded);
assert!(
gap.contains("acme-web") && gap.contains("acme_web"),
"the gap must name BOTH the name asked for and the name recorded: {gap}"
);
let empty = super::authorship_no_match_gap("Acme Web", "acme-web", &[]);
assert!(
empty.contains("no commits at all"),
"an empty sweep must not be reported as a name mismatch: {empty}"
);
}
}