assay-cli 3.10.2

CLI for Assay
use super::pipeline::PipelineTimings;
use super::run_output::{export_baseline, summary_from_outcome, write_run_json_minimal};
use crate::exit_codes::{ExitCodeVersion, ReasonCode, RunOutcome};
use std::path::{Path, PathBuf};

pub(crate) fn write_error_artifacts(
    reason: ReasonCode,
    message: String,
    version: ExitCodeVersion,
    verify_enabled: bool,
    run_json_path: &Path,
) -> anyhow::Result<i32> {
    let mut o = RunOutcome::from_reason(reason, Some(message), None);
    o.exit_code = reason.exit_code_for(version);
    if let Err(e) = write_run_json_minimal(&o, &run_json_path.to_path_buf()) {
        eprintln!("WARNING: failed to write run.json: {}", e);
    }

    let summary_path = run_json_path
        .parent()
        .map(|p| p.join("summary.json"))
        .unwrap_or_else(|| PathBuf::from("summary.json"));
    let summary = summary_from_outcome(&o, verify_enabled).with_seeds(None, None);
    if let Err(e) = assay_core::report::summary::write_summary(&summary, &summary_path) {
        eprintln!("WARNING: failed to write summary.json: {}", e);
    }
    Ok(o.exit_code)
}

pub(crate) fn build_summary_from_artifacts(
    outcome: &RunOutcome,
    verify_enabled: bool,
    artifacts: &assay_core::report::RunArtifacts,
    pipeline_timings: Option<&PipelineTimings>,
    report_ms: Option<u64>,
) -> assay_core::report::summary::Summary {
    let mut summary = summary_from_outcome(outcome, verify_enabled);
    let passed = artifacts
        .results
        .iter()
        .filter(|r| r.status.is_passing())
        .count();
    let failed = artifacts
        .results
        .iter()
        .filter(|r| r.status.is_blocking())
        .count();
    summary = summary.with_results(passed, failed, artifacts.results.len());
    summary = summary.with_seeds(artifacts.order_seed, None);
    if let Some(metrics) =
        assay_core::report::summary::judge_metrics_from_results(&artifacts.results)
    {
        summary = summary.with_judge_metrics(metrics);
    }
    let performance = build_performance_metrics(artifacts, pipeline_timings, report_ms);
    summary = summary.with_performance(performance);
    summary
}

fn build_performance_metrics(
    artifacts: &assay_core::report::RunArtifacts,
    pipeline_timings: Option<&PipelineTimings>,
    report_ms: Option<u64>,
) -> assay_core::report::summary::PerformanceMetrics {
    let (total_ms, ingest_ms, eval_ms) = match pipeline_timings {
        Some(t) => (
            t.total_ms.saturating_add(report_ms.unwrap_or(0)),
            t.ingest_ms,
            t.run_suite_ms,
        ),
        None => (report_ms.unwrap_or(0), None, None),
    };

    let cache_hit_rate = if artifacts.results.is_empty() {
        None
    } else {
        let cached = artifacts.results.iter().filter(|r| r.cached).count() as f64;
        Some(cached / artifacts.results.len() as f64)
    };

    let mut slowest: Vec<assay_core::report::summary::SlowestTest> = artifacts
        .results
        .iter()
        .filter_map(|row| {
            row.duration_ms
                .map(|d| assay_core::report::summary::SlowestTest {
                    test_id: row.test_id.clone(),
                    duration_ms: d,
                })
        })
        .collect();
    slowest.sort_by(|a, b| {
        b.duration_ms
            .cmp(&a.duration_ms)
            .then_with(|| a.test_id.cmp(&b.test_id))
    });
    slowest.truncate(5);
    let slowest_tests = if slowest.is_empty() {
        None
    } else {
        Some(slowest)
    };

    let phase_timings = Some(assay_core::report::summary::PhaseTimings {
        ingest_ms,
        eval_ms,
        judge_ms: None,
        report_ms,
    });

    assay_core::report::summary::PerformanceMetrics {
        total_duration_ms: total_ms,
        verify_ms: None,
        lint_ms: None,
        runner_clone_ms: artifacts.runner_clone_ms,
        runner_clone_count: None,
        profile_store_ms: None,
        run_id_memory_bytes: None,
        cache_hit_rate,
        slowest_tests,
        phase_timings,
    }
}

pub(crate) fn print_pipeline_summary(
    artifacts: &assay_core::report::RunArtifacts,
    explain_skip: bool,
    summary: &assay_core::report::summary::Summary,
) {
    assay_core::report::console::print_summary(&artifacts.results, explain_skip);
    assay_core::report::console::print_run_footer(
        Some(&summary.seeds),
        summary.judge_metrics.as_ref(),
    );
}

pub(crate) fn maybe_export_baseline(
    export_path: &Option<PathBuf>,
    config_path: &Path,
    cfg: &assay_core::model::EvalConfig,
    artifacts: &assay_core::report::RunArtifacts,
) {
    if let Some(path) = export_path {
        if let Err(e) = export_baseline(path, config_path, cfg, &artifacts.results) {
            eprintln!("Failed to export baseline: {}", e);
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use assay_core::model::{TestResultRow, TestStatus};
    use serde_json::json;

    fn row(id: &str, duration_ms: Option<u64>, cached: bool, status: TestStatus) -> TestResultRow {
        TestResultRow {
            test_id: id.to_string(),
            status,
            score: None,
            cached,
            message: String::new(),
            details: json!({}),
            duration_ms,
            fingerprint: None,
            skip_reason: None,
            attempts: None,
            error_policy_applied: None,
        }
    }

    #[test]
    fn performance_metrics_include_pipeline_and_report_timings() {
        let artifacts = assay_core::report::RunArtifacts {
            run_id: 1,
            suite: "demo".to_string(),
            results: vec![row("t1", Some(10), true, TestStatus::Pass)],
            order_seed: None,
            runner_clone_ms: Some(13),
        };
        let timings = PipelineTimings {
            total_ms: 120,
            config_load_ms: Some(5),
            ingest_ms: Some(12),
            runner_build_ms: Some(8),
            run_suite_ms: Some(90),
        };

        let performance = build_performance_metrics(&artifacts, Some(&timings), Some(30));

        assert_eq!(performance.total_duration_ms, 150);
        let phases = performance.phase_timings.expect("phase timings");
        assert_eq!(phases.ingest_ms, Some(12));
        assert_eq!(phases.eval_ms, Some(90));
        assert_eq!(phases.report_ms, Some(30));
        assert_eq!(performance.runner_clone_ms, Some(13));
    }

    #[test]
    fn performance_metrics_compute_cache_hit_rate_and_slowest_top5() {
        let artifacts = assay_core::report::RunArtifacts {
            run_id: 2,
            suite: "demo".to_string(),
            results: vec![
                row("t1", Some(80), true, TestStatus::Pass),
                row("t2", Some(20), false, TestStatus::Fail),
                row("t3", Some(60), true, TestStatus::Pass),
                row("t4", Some(10), false, TestStatus::Warn),
                row("t5", Some(40), false, TestStatus::Pass),
                row("t6", Some(50), false, TestStatus::Pass),
            ],
            order_seed: None,
            runner_clone_ms: None,
        };

        let performance = build_performance_metrics(&artifacts, None, Some(7));

        let hit_rate = performance.cache_hit_rate.expect("cache hit rate");
        assert!((hit_rate - (2.0 / 6.0)).abs() < f64::EPSILON);

        let slowest = performance.slowest_tests.expect("slowest tests");
        assert_eq!(slowest.len(), 5);
        assert_eq!(slowest[0].test_id, "t1");
        assert_eq!(slowest[1].test_id, "t3");
        assert_eq!(slowest[2].test_id, "t6");
        assert_eq!(slowest[3].test_id, "t5");
        assert_eq!(slowest[4].test_id, "t2");
    }

    #[test]
    fn performance_metrics_slowest_tie_breaks_by_test_id() {
        let artifacts = assay_core::report::RunArtifacts {
            run_id: 3,
            suite: "demo".to_string(),
            results: vec![
                row("b", Some(42), false, TestStatus::Pass),
                row("a", Some(42), false, TestStatus::Pass),
                row("c", Some(42), false, TestStatus::Pass),
            ],
            order_seed: None,
            runner_clone_ms: None,
        };

        let performance = build_performance_metrics(&artifacts, None, Some(1));
        let slowest = performance.slowest_tests.expect("slowest tests");
        assert_eq!(slowest.len(), 3);
        assert_eq!(slowest[0].test_id, "a");
        assert_eq!(slowest[1].test_id, "b");
        assert_eq!(slowest[2].test_id, "c");
    }
}