tga 10.3.0

Developer productivity analytics — git commit collection, classification, and reporting
//! `tga classify` — stage 2 (classification cascade) entry point.

use tga::classify::pipeline_buckets::bucket_counts;
use tga::classify::ClassificationPipeline;
use tga::core::config::{BucketMap, BucketSource, ClassificationConfig, Config};
use tga::core::db::{CheckpointMode, Database};

use crate::commands::args::ClassifyArgs;
use crate::commands::date_range::resolve_date_range;

/// Run the classification stage over previously-collected commits.
///
/// Why: wire CLI flags (`--rules`, `--use-llm`, `--force`, `--repos`,
/// `--weeks`, `--since/--until`, `--no-external`) into the
/// [`ClassificationPipeline`] without exposing the pipeline internals in
/// `main.rs`.  The new uniform filter flags (`--repos`, `--weeks`,
/// `--since/--until`) enable surgical re-classification of slices without
/// touching the full DB.
/// What: mutates the `ClassificationConfig` section of the loaded YAML config
/// to honor each override, then builds and runs the pipeline.
/// Test: integration-tested via pipeline unit tests in `classify::pipeline::tests`.
pub async fn run(config: Config, db: &mut Database, args: ClassifyArgs) -> anyhow::Result<()> {
    let mut cfg = config;

    // Ensure a classification section exists so overrides have somewhere to land.
    if cfg.classification.is_none() && (args.rules.is_some() || args.use_llm) {
        cfg.classification = Some(ClassificationConfig::default());
    }
    if let Some(ref mut c) = cfg.classification {
        if let Some(rules) = args.rules {
            // Prepend the CLI-supplied rules file to rules_files (back-compat
            // with `--rules`: single file, treated as the primary rules source).
            c.rules_files.insert(0, rules);
        }
        if args.use_llm {
            c.use_llm = true;
        }
        // When --no-external is passed, suppress all external classification
        // sources for this run regardless of what the rules file configures.
        if args.no_external {
            c.no_external = true;
        }
    }

    // Resolve the date window from --weeks / --since / --until.
    // Priority: --weeks > --since/--until.
    let (resolved_since, resolved_until) = resolve_date_range(
        args.weeks,
        args.since.as_deref(),
        args.until.as_deref(),
        None,
    )?;

    // Effective since: resolved window (from --weeks or --since) drives the
    // pipeline's `with_since` filter.  When --weeks is given we only have a
    // lower bound; when --since is given directly we may also have --until.
    let effective_since = resolved_since;
    let effective_until = resolved_until;

    // --since/--until without --force is a no-op (default flow already skips
    // classified rows). Flag this rather than silently ignoring it so the
    // operator notices the missing `--force`.
    if (effective_since.is_some() || effective_until.is_some()) && !args.force {
        tracing::warn!(
            "--since/--until/--weeks was supplied without --force; ignoring date window. \
             Pass --force to re-classify commits already in the DB."
        );
    }

    // #111: `--shas` subset. The pipeline rejects an empty list and unknown
    // SHAs before writing anything.
    let shas = match &args.shas {
        Some(path) => Some(read_sha_list(path)?),
        None => None,
    };
    if shas.is_some() && !args.force {
        tracing::warn!(
            "--shas without --force classifies only listed commits that have no verdict yet"
        );
    }

    let cfg_source = cfg.source_path.clone();
    let pipeline = ClassificationPipeline::new(cfg)
        .with_shas(shas)
        .with_force(args.force)
        .with_since(effective_since.clone())
        .with_until(effective_until.clone())
        .with_repos(args.repos.clone());

    // Backfill mode: fill in only the missing complexity scores and return,
    // leaving existing category/confidence/method verdicts untouched.
    if args.backfill_complexity {
        let updated = pipeline.backfill_complexity(db).await?;
        println!("Backfilled complexity for {updated} commit(s)");
        // Checkpoint the WAL after backfill too (issue #298).
        if let Err(e) = db.wal_checkpoint(CheckpointMode::Truncate) {
            tracing::warn!(error = %e, "WAL TRUNCATE checkpoint failed after backfill");
        }
        return Ok(());
    }

    // #111: check the bucket map before any write; it is a derived view.
    let (buckets, bucket_source) = pipeline.bucket_map_with_source()?;
    let stats = pipeline.run(db).await?;

    // Issue #298: call PRAGMA wal_checkpoint(TRUNCATE) on clean exit to flush
    // the WAL into the main DB file and zero the WAL. Without this the main
    // DB file can lag behind the WAL, causing post-run queries to see a prior
    // state of the classifications table.
    if let Err(e) = db.wal_checkpoint(CheckpointMode::Truncate) {
        tracing::warn!(error = %e, "WAL TRUNCATE checkpoint failed after classify — \
            the WAL may not be flushed; run `sqlite3 <db> 'PRAGMA wal_checkpoint(TRUNCATE)'` manually");
    }

    println!(
        "Classified {}/{} commits ({:.1}% coverage)",
        stats.classified, stats.total_commits, stats.coverage_pct
    );
    if !stats.by_method.is_empty() {
        println!("By method:");
        for (method, count) in &stats.by_method {
            println!("  {method}: {count}");
        }
    }
    if !stats.by_category.is_empty() {
        println!("By category:");
        for (category, count) in &stats.by_category {
            println!("  {category}: {count}");
        }
    }
    print_buckets(&buckets, bucket_source, &stats.by_category);
    print_llm_usage(&stats.llm_usage);
    print_skipped_recovery(&stats.llm_usage, &cfg_source, db)?;
    Ok(())
}

/// #111: the per-category counts rolled up to the bucket map in effect, a
/// derived view; nothing is stored. The header names the map's source.
fn print_buckets(
    buckets: &BucketMap,
    source: BucketSource,
    by_category: &std::collections::HashMap<String, usize>,
) {
    let rows = bucket_counts(
        buckets,
        by_category.iter().map(|(c, &n)| (c.as_str(), n as u64)),
    );
    if rows.is_empty() {
        return;
    }
    println!("By bucket (map from {}):", source.describe());
    for row in rows {
        let parts: Vec<String> = row
            .categories
            .iter()
            .map(|(c, n)| format!("{c} {n}"))
            .collect();
        println!("  {}: {} ({})", row.bucket, row.total, parts.join(", "));
    }
}

/// When LLM calls were skipped (spend cap or payload dump), write their SHAs
/// to a file and print the exact command that re-sends them (#111).
///
/// Why: a skipped commit keeps its rule verdict and so has a
/// `classification_id`; a plain re-run never revisits it.
/// What: writes `tga-llm-skipped.txt` next to the database (the working
/// directory for an in-memory one) and prints the skipped count with
/// `tga [--config C] [--database D] classify --force --shas <file>`.
/// Test: `tests/jev_skipped_recovery.rs`.
///
/// # Errors
///
/// The SHA file cannot be written.
pub(crate) fn print_skipped_recovery(
    u: &tga::classify::LlmUsageTotals,
    config_path: &Option<std::path::PathBuf>,
    db: &Database,
) -> anyhow::Result<()> {
    if u.skipped == 0 || u.skipped_shas.is_empty() {
        return Ok(());
    }
    let db_path = db
        .connection()
        .path()
        .filter(|p| !p.is_empty())
        .map(std::path::PathBuf::from);
    let dir = db_path
        .as_deref()
        .and_then(|p| p.parent())
        .filter(|d| !d.as_os_str().is_empty())
        .map(std::path::Path::to_path_buf)
        .unwrap_or_else(|| std::path::PathBuf::from("."));
    let file = dir.join("tga-llm-skipped.txt");
    std::fs::write(&file, format!("{}\n", u.skipped_shas.join("\n")))
        .map_err(|e| anyhow::anyhow!("cannot write {}: {e}", file.display()))?;
    let mut cmd = String::from("tga");
    if let Some(c) = config_path {
        cmd.push_str(&format!(" --config {}", shell_quote(c)));
    }
    if let Some(d) = &db_path {
        cmd.push_str(&format!(" --database {}", shell_quote(d)));
    }
    cmd.push_str(&format!(" classify --force --shas {}", shell_quote(&file)));
    println!(
        "LLM skipped {} commit(s) (spend cap or payload dump); they kept their rule verdict. \
         Re-send them with:\n  {cmd}",
        u.skipped_shas.len()
    );
    Ok(())
}

/// Single-quote `p` for a POSIX shell when it holds anything but
/// `[A-Za-z0-9_./-]`.
fn shell_quote(p: &std::path::Path) -> String {
    let s = p.display().to_string();
    if !s.is_empty()
        && s.chars()
            .all(|c| c.is_ascii_alphanumeric() || "_./-".contains(c))
    {
        s
    } else {
        format!("'{}'", s.replace('\'', r"'\''"))
    }
}

/// Print the run's LLM call count and token totals (#111).
///
/// Why: the owner prices a full run from these numbers before approving it.
/// What: prints nothing when no LLM call was made; otherwise the outcome
/// counts, token totals, and the average tokens per call. Per-call rows are
/// in the `llm_usage` table.
fn print_llm_usage(u: &tga::classify::LlmUsageTotals) {
    if u.calls == 0 {
        return;
    }
    println!(
        "LLM calls: {} (adopted {}, not adopted {}, abstained {}, out-of-set {}, failed {}, \
         skipped {})",
        u.calls, u.adopted, u.not_adopted, u.abstained, u.out_of_set, u.failed, u.skipped
    );
    println!(
        "LLM tokens: input {}, output {} ({} of {} calls reported usage)",
        u.input_tokens, u.output_tokens, u.calls_with_usage, u.calls
    );
    if u.calls_with_usage > 0 {
        let n = u.calls_with_usage as f64;
        println!(
            "LLM tokens per call: input {:.1}, output {:.1}",
            u.input_tokens as f64 / n,
            u.output_tokens as f64 / n
        );
    }
}

/// Read a `--shas` file: one SHA per line, blank lines and `#` comments
/// skipped, surrounding whitespace trimmed (#111).
fn read_sha_list(path: &std::path::Path) -> anyhow::Result<Vec<String>> {
    let text = std::fs::read_to_string(path)
        .map_err(|e| anyhow::anyhow!("cannot read --shas file {}: {e}", path.display()))?;
    Ok(text
        .lines()
        .map(str::trim)
        .filter(|l| !l.is_empty() && !l.starts_with('#'))
        .map(str::to_string)
        .collect())
}