homeboy 0.72.0

CLI for multi-component deployment and development workflow automation
Documentation
use serde::Serialize;

use homeboy::test_analyze::{TestAnalysis, TestAnalysisInput};
use homeboy::test_baseline::TestCounts;
use homeboy::utils::io;
use homeboy::utils::output_parse::{Aggregate, DeriveRule, ParseRule, ParseSpec};

#[derive(Serialize)]
pub struct CoverageOutput {
    pub lines_pct: f64,
    pub lines_total: u64,
    pub lines_covered: u64,
    pub methods_pct: f64,
    #[serde(skip_serializing_if = "Vec::is_empty")]
    pub uncovered_files: Vec<UncoveredFile>,
}

#[derive(Serialize)]
pub struct UncoveredFile {
    pub file: String,
    pub line_pct: f64,
}

#[derive(Serialize)]
pub struct TestFailureSummaryItem {
    pub test_name: String,
    pub message: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub file: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub line: Option<u32>,
}

#[derive(Serialize)]
pub struct TestSummaryOutput {
    pub total: u64,
    pub passed: u64,
    pub failed: u64,
    pub skipped: u64,
    #[serde(skip_serializing_if = "Vec::is_empty")]
    pub failures: Vec<TestFailureSummaryItem>,
    pub exit_code: i32,
}

pub fn build_test_summary(
    test_counts: Option<&TestCounts>,
    analysis: Option<&TestAnalysis>,
    exit_code: i32,
) -> TestSummaryOutput {
    let (total, passed, failed, skipped) = if let Some(counts) = test_counts {
        (counts.total, counts.passed, counts.failed, counts.skipped)
    } else {
        let total = analysis.map(|a| a.total_tests).unwrap_or(0);
        let passed = analysis.map(|a| a.total_passed).unwrap_or(0);
        let failed = analysis.map(|a| a.total_failures as u64).unwrap_or(0);
        let skipped = total.saturating_sub(passed + failed);
        (total, passed, failed, skipped)
    };

    let failures = analysis
        .map(|a| {
            a.clusters
                .iter()
                .flat_map(|cluster| {
                    cluster
                        .example_tests
                        .iter()
                        .map(|name| TestFailureSummaryItem {
                            test_name: name.clone(),
                            message: cluster.pattern.clone(),
                            file: cluster.affected_files.first().cloned(),
                            line: None,
                        })
                })
                .take(20)
                .collect::<Vec<_>>()
        })
        .unwrap_or_default();

    TestSummaryOutput {
        total,
        passed,
        failed,
        skipped,
        failures,
        exit_code,
    }
}

/// Parse the test failures JSON file written by the extension test runner.
pub fn parse_failures_file(path: &std::path::Path) -> Option<TestAnalysisInput> {
    let content = io::read_file(path, "read test failures file").ok()?;
    let mut parsed: TestAnalysisInput = serde_json::from_str(&content).ok()?;

    // Backfill aggregate counters from failure list when extension output omits
    // totals (legacy parser shape). This keeps --analyze metadata accurate.
    if parsed.total == 0 && !parsed.failures.is_empty() {
        parsed.total = parsed.failures.len() as u64;
    }

    if parsed.passed > parsed.total {
        parsed.passed = parsed.total;
    }

    Some(parsed)
}

/// Parse the test results JSON file written by the extension test runner.
pub fn parse_test_results_file(path: &std::path::Path) -> Option<TestCounts> {
    let content = io::read_file(path, "read test results file").ok()?;
    let data: serde_json::Value = serde_json::from_str(&content).ok()?;

    let total = data.get("total").and_then(|v| v.as_u64()).unwrap_or(0);
    let passed = data.get("passed").and_then(|v| v.as_u64()).unwrap_or(0);
    let failed = data.get("failed").and_then(|v| v.as_u64()).unwrap_or(0);
    let skipped = data.get("skipped").and_then(|v| v.as_u64()).unwrap_or(0);

    Some(TestCounts::new(total, passed, failed, skipped))
}

/// Parse human-readable test runner output (fallback when sidecar JSON isn't present).
pub fn parse_test_results_text(text: &str) -> Option<TestCounts> {
    let spec = ParseSpec {
        rules: vec![
            ParseRule {
                pattern: r"Tests:\s*(\d+)".to_string(),
                field: "total".to_string(),
                group: 1,
                aggregate: Aggregate::Last,
            },
            ParseRule {
                pattern: r"Failures:\s*(\d+)".to_string(),
                field: "failed".to_string(),
                group: 1,
                aggregate: Aggregate::Last,
            },
            ParseRule {
                pattern: r"Errors:\s*(\d+)".to_string(),
                field: "errors".to_string(),
                group: 1,
                aggregate: Aggregate::Last,
            },
            ParseRule {
                pattern: r"Skipped:\s*(\d+)".to_string(),
                field: "skipped".to_string(),
                group: 1,
                aggregate: Aggregate::Last,
            },
            ParseRule {
                pattern: r"OK\s*\((\d+) tests".to_string(),
                field: "total".to_string(),
                group: 1,
                aggregate: Aggregate::Last,
            },
        ],
        defaults: std::collections::HashMap::from([
            ("failed".to_string(), 0.0),
            ("errors".to_string(), 0.0),
            ("skipped".to_string(), 0.0),
        ]),
        derive: vec![DeriveRule {
            field: "passed".to_string(),
            expr: "total - failed - errors - skipped".to_string(),
        }],
    };

    let parsed = spec.parse(text);
    let total = parsed.get("total").copied().unwrap_or(0.0).max(0.0) as u64;
    if total == 0 {
        return None;
    }
    let passed = parsed.get("passed").copied().unwrap_or(0.0).max(0.0) as u64;
    let failed = parsed.get("failed").copied().unwrap_or(0.0).max(0.0) as u64;
    let skipped = parsed.get("skipped").copied().unwrap_or(0.0).max(0.0) as u64;
    Some(TestCounts::new(total, passed, failed, skipped))
}

/// Parse the coverage JSON file written by the extension test runner.
pub fn parse_coverage_file(path: &std::path::Path) -> std::result::Result<CoverageOutput, ()> {
    let content = io::read_file(path, "read coverage file").map_err(|_| ())?;
    let data: serde_json::Value = serde_json::from_str(&content).map_err(|_| ())?;

    let totals = data.get("totals").ok_or(())?;
    let lines = totals.get("lines").ok_or(())?;
    let methods = totals.get("methods").ok_or(())?;

    let lines_pct = lines.get("pct").and_then(|v| v.as_f64()).unwrap_or(0.0);
    let lines_total = lines.get("total").and_then(|v| v.as_u64()).unwrap_or(0);
    let lines_covered = lines.get("covered").and_then(|v| v.as_u64()).unwrap_or(0);
    let methods_pct = methods.get("pct").and_then(|v| v.as_f64()).unwrap_or(0.0);

    // Collect files below 50% coverage as "uncovered"
    let uncovered_files = data
        .get("files")
        .and_then(|f| f.as_array())
        .map(|files| {
            files
                .iter()
                .filter_map(|f| {
                    let pct = f.get("line_pct").and_then(|v| v.as_f64())?;
                    if pct < 50.0 {
                        Some(UncoveredFile {
                            file: f
                                .get("file")
                                .and_then(|v| v.as_str())
                                .unwrap_or("?")
                                .to_string(),
                            line_pct: pct,
                        })
                    } else {
                        None
                    }
                })
                .collect()
        })
        .unwrap_or_default();

    Ok(CoverageOutput {
        lines_pct,
        lines_total,
        lines_covered,
        methods_pct,
        uncovered_files,
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use homeboy::utils::output_parse::{Aggregate, ParseRule, ParseSpec};

    #[test]
    fn parse_failures_file_backfills_totals_when_missing() {
        let tmp = std::env::temp_dir().join("homeboy-test-failures-backfill.json");
        let _ = std::fs::remove_file(&tmp);

        let payload = r#"{
            "failures": [
                {
                    "test_name": "Suite::test_one",
                    "test_file": "tests/suite_test.php",
                    "error_type": "Error",
                    "message": "Call to undefined method Foo::bar()"
                },
                {
                    "test_name": "Suite::test_two",
                    "test_file": "tests/suite_test.php",
                    "error_type": "Error",
                    "message": "Call to undefined method Foo::bar()"
                }
            ]
        }"#;

        std::fs::write(&tmp, payload).unwrap();
        let parsed = parse_failures_file(&tmp).expect("should parse failures file");

        assert_eq!(parsed.failures.len(), 2);
        assert_eq!(parsed.total, 2);
        assert_eq!(parsed.passed, 0);

        let _ = std::fs::remove_file(&tmp);
    }

    #[test]
    fn parse_failures_file_clamps_invalid_passed_count() {
        let tmp = std::env::temp_dir().join("homeboy-test-failures-clamp.json");
        let _ = std::fs::remove_file(&tmp);

        let payload = r#"{
            "failures": [
                {
                    "test_name": "Suite::test_one",
                    "test_file": "tests/suite_test.php",
                    "error_type": "Error",
                    "message": "Call to undefined method Foo::bar()"
                }
            ],
            "total": 3,
            "passed": 9
        }"#;

        std::fs::write(&tmp, payload).unwrap();
        let parsed = parse_failures_file(&tmp).expect("should parse failures file");

        assert_eq!(parsed.total, 3);
        assert_eq!(parsed.passed, 3);

        let _ = std::fs::remove_file(&tmp);
    }

    #[test]
    fn test_build_test_summary() {
        let counts = TestCounts::new(10, 8, 1, 1);
        let summary = build_test_summary(Some(&counts), None, 0);

        assert_eq!(summary.total, 10);
        assert_eq!(summary.passed, 8);
        assert_eq!(summary.failed, 1);
        assert_eq!(summary.skipped, 1);
        assert_eq!(summary.exit_code, 0);
    }

    #[test]
    fn parse_test_results_text_works_for_phpunit_style_summary() {
        let text = "Tests: 20, Assertions: 50, Failures: 2, Errors: 1, Skipped: 3.";
        let counts = parse_test_results_text(text).expect("should parse summary text");
        assert_eq!(counts.total, 20);
        assert_eq!(counts.failed, 2);
        assert_eq!(counts.skipped, 3);
        assert_eq!(counts.passed, 14);
    }

    #[test]
    fn parse_test_results_text_works_for_ok_line() {
        let text = "OK (12 tests, 44 assertions)";
        let counts = parse_test_results_text(text).expect("should parse ok line");
        assert_eq!(counts.total, 12);
        assert_eq!(counts.failed, 0);
        assert_eq!(counts.skipped, 0);
        assert_eq!(counts.passed, 12);
    }

    #[test]
    fn test_parse_output_applies_rules_and_derives() {
        let spec = ParseSpec {
            rules: vec![
                ParseRule {
                    pattern: r"Tests:\s*(\d+)".to_string(),
                    field: "total".to_string(),
                    group: 1,
                    aggregate: Aggregate::Last,
                },
                ParseRule {
                    pattern: r"Failures:\s*(\d+)".to_string(),
                    field: "failed".to_string(),
                    group: 1,
                    aggregate: Aggregate::Last,
                },
            ],
            defaults: std::collections::HashMap::new(),
            derive: vec![],
        };

        let parsed = spec.parse("Tests: 10\nFailures: 2\n");
        assert_eq!(parsed.get("total").copied().unwrap_or(0.0), 10.0);
        assert_eq!(parsed.get("failed").copied().unwrap_or(0.0), 2.0);
    }

    #[test]
    fn test_parse_output_supports_sum_aggregate() {
        let spec = ParseSpec {
            rules: vec![ParseRule {
                pattern: r"Errors:\s*(\d+)".to_string(),
                field: "errors".to_string(),
                group: 1,
                aggregate: Aggregate::Sum,
            }],
            defaults: std::collections::HashMap::new(),
            derive: vec![],
        };

        let parsed = spec.parse("Errors: 2\nErrors: 3\n");
        assert_eq!(parsed.get("errors").copied().unwrap_or(0.0), 5.0);
    }
}