homeboy 0.76.0

CLI for multi-component deployment and development workflow automation
Documentation
use crate::component::Component;
use crate::extension::test::drift::{
    detect_drift, generate_transform_rules, DriftOptions, DriftReport,
};
use crate::extension::test::TestScopeOutput;
use crate::extension::test::{ChangeType, TestAnalysis};
use crate::extension::test::{TestBaselineComparison, TestCounts};
use crate::refactor::AppliedRefactor;
use crate::refactor::{
    self,
    auto::{self, AutofixMode},
    TransformSet,
};
use serde::Serialize;

#[derive(Debug, Clone, Serialize)]
pub struct AutoFixDriftOutput {
    pub since: String,
    pub auto_fixable_changes: usize,
    pub generated_rules: usize,
    pub replacements: usize,
    pub files_modified: usize,
    pub written: bool,
    pub rerun_recommended: bool,
}

#[derive(Debug, Clone, Serialize)]
pub struct DriftWorkflowResult {
    pub component: String,
    pub report: DriftReport,
    pub exit_code: i32,
}

#[derive(Debug, Clone, Serialize)]
pub struct AutoFixDriftWorkflowResult {
    pub component: String,
    pub output: AutoFixDriftOutput,
    pub hints: Vec<String>,
    pub report: Option<DriftReport>,
}

#[derive(Debug, Clone, Serialize)]
pub struct MainTestWorkflowResult {
    pub status: String,
    pub component: String,
    pub exit_code: i32,
    pub test_counts: Option<TestCounts>,
    pub coverage: Option<serde_json::Value>,
    pub baseline_comparison: Option<TestBaselineComparison>,
    pub analysis: Option<TestAnalysis>,
    pub autofix: Option<AppliedRefactor>,
    pub hints: Option<Vec<String>>,
    pub test_scope: Option<TestScopeOutput>,
    pub summary: Option<serde_json::Value>,
}

pub fn detect_test_drift(
    component_id: &str,
    component: &Component,
    since: &str,
) -> Result<DriftWorkflowResult, crate::Error> {
    let source_path = {
        let expanded = shellexpand::tilde(&component.local_path);
        std::path::PathBuf::from(expanded.as_ref())
    };

    crate::log_status!(
        "drift",
        "Detecting test drift since {} in {}",
        since,
        component_id
    );

    let opts = if source_path.join("Cargo.toml").exists() {
        DriftOptions::rust(&source_path, since)
    } else {
        DriftOptions::php(&source_path, since)
    };

    let report = detect_drift(component_id, &opts)?;

    if report.production_changes.is_empty() {
        crate::log_status!("drift", "No production changes detected since {}", since);
    } else {
        crate::log_status!(
            "drift",
            "{} production change{} detected",
            report.production_changes.len(),
            if report.production_changes.len() == 1 {
                ""
            } else {
                "s"
            }
        );

        for change in &report.production_changes {
            let label = match change.change_type {
                ChangeType::MethodRename => "method rename",
                ChangeType::MethodRemoved => "method removed",
                ChangeType::ClassRename => "class rename",
                ChangeType::ClassRemoved => "class removed",
                ChangeType::ErrorCodeChange => "error code change",
                ChangeType::ReturnTypeChange => "return type change",
                ChangeType::SignatureChange => "signature change",
                ChangeType::FileMove => "file moved",
                ChangeType::StringChange => "string changed",
            };

            if let Some(ref new) = change.new_symbol {
                crate::log_status!(
                    "  change",
                    "{}: {} → {} ({})",
                    label,
                    change.old_symbol,
                    new,
                    change.file
                );
            } else {
                crate::log_status!(
                    "  change",
                    "{}: {} ({})",
                    label,
                    change.old_symbol,
                    change.file
                );
            }
        }

        if !report.drifted_tests.is_empty() {
            crate::log_status!(
                "drift",
                "{} drifted reference{} in {} test file{}",
                report.drifted_tests.len(),
                if report.drifted_tests.len() == 1 {
                    ""
                } else {
                    "s"
                },
                report.total_drifted_files,
                if report.total_drifted_files == 1 {
                    ""
                } else {
                    "s"
                },
            );

            for drift in report.drifted_tests.iter().take(20) {
                let change = &report.production_changes[drift.change_index];
                crate::log_status!(
                    "  ref",
                    "{}:{} references '{}' ({})",
                    drift.test_file,
                    drift.line,
                    change.old_symbol,
                    format!("{:?}", change.change_type).to_lowercase()
                );
            }

            if report.drifted_tests.len() > 20 {
                crate::log_status!(
                    "info",
                    "... and {} more (use --json for full list)",
                    report.drifted_tests.len() - 20
                );
            }
        }

        if report.auto_fixable > 0 {
            crate::log_status!(
                "hint",
                "{} change{} auto-fixable with refactor transform",
                report.auto_fixable,
                if report.auto_fixable == 1 { "" } else { "s" }
            );
        }
    }

    let exit_code = if report.drifted_tests.is_empty() {
        0
    } else {
        1
    };

    Ok(DriftWorkflowResult {
        component: component_id.to_string(),
        report,
        exit_code,
    })
}

pub fn auto_fix_test_drift(
    component_id: &str,
    component: &Component,
    since: &str,
    write: bool,
    include_report: bool,
) -> Result<AutoFixDriftWorkflowResult, crate::Error> {
    let source_path = {
        let expanded = shellexpand::tilde(&component.local_path);
        std::path::PathBuf::from(expanded.as_ref())
    };

    let opts = if source_path.join("Cargo.toml").exists() {
        DriftOptions::rust(&source_path, since)
    } else {
        DriftOptions::php(&source_path, since)
    };

    crate::log_status!(
        "test",
        "Auto-fixing drift since {} in {} ({})",
        since,
        component_id,
        if write { "write" } else { "dry-run" }
    );

    let drift_report = detect_drift(component_id, &opts)?;
    let rules = generate_transform_rules(&drift_report);

    let output = if rules.is_empty() {
        crate::log_status!("test", "No auto-fixable drift detected. Nothing to apply.");

        AutoFixDriftOutput {
            since: since.to_string(),
            auto_fixable_changes: drift_report.auto_fixable,
            generated_rules: 0,
            replacements: 0,
            files_modified: 0,
            written: write,
            rerun_recommended: false,
        }
    } else {
        let set = TransformSet {
            description: format!(
                "Auto-generated drift fixes for {} since {}",
                component_id, since
            ),
            rules,
        };

        let result =
            refactor::apply_transforms(&source_path, "test_auto_fix_drift", &set, write, None)?;

        crate::log_status!(
            "test",
            "Applied {} replacement{} across {} file{}",
            result.total_replacements,
            if result.total_replacements == 1 {
                ""
            } else {
                "s"
            },
            result.total_files,
            if result.total_files == 1 { "" } else { "s" },
        );

        if !write {
            crate::log_status!(
                "hint",
                "Dry-run only. Re-run with --write to apply generated fixes."
            );
        } else if result.total_replacements > 0 {
            crate::log_status!(
                "hint",
                "Re-run tests: homeboy test {} --analyze",
                component_id
            );
        }

        AutoFixDriftOutput {
            since: since.to_string(),
            auto_fixable_changes: drift_report.auto_fixable,
            generated_rules: set.rules.len(),
            replacements: result.total_replacements,
            files_modified: result.total_files,
            written: write,
            rerun_recommended: write && result.total_replacements > 0,
        }
    };

    let outcome = auto::standard_outcome(
        if write {
            AutofixMode::Write
        } else {
            AutofixMode::DryRun
        },
        output.replacements,
        Some(format!("homeboy test {} --analyze", component_id)),
        vec![format!(
            "Use --since <ref> to target a drift window (current: {})",
            since
        )],
    );

    Ok(AutoFixDriftWorkflowResult {
        component: component_id.to_string(),
        output: AutoFixDriftOutput {
            rerun_recommended: outcome.rerun_recommended,
            ..output
        },
        hints: outcome.hints,
        report: if include_report {
            Some(drift_report)
        } else {
            None
        },
    })
}