fallow-mcp 2.104.0

MCP server for fallow codebase intelligence (exposes fallow as typed tools to AI agents)
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum CliFallbackReason {
    Baseline,
    Regression,
    GroupedOutput,
    DuplicationExplainSkipped,
    HealthMinScoreGate,
    HealthMinSeverity,
    HealthChurnFile,
    HealthSnapshot,
    HealthTrend,
    HealthSummary,
    HealthRuntimeCoverage,
}

pub(super) fn baseline_fallback_reason(
    baseline: Option<&str>,
    save_baseline: Option<&str>,
) -> Option<CliFallbackReason> {
    (filled(baseline) || filled(save_baseline)).then_some(CliFallbackReason::Baseline)
}

pub(super) fn regression_fallback_reason(
    fail_on_regression: Option<bool>,
    tolerance: Option<&str>,
    regression_baseline: Option<&str>,
    save_regression_baseline: Option<&str>,
) -> Option<CliFallbackReason> {
    (fail_on_regression == Some(true)
        || filled(tolerance)
        || filled(regression_baseline)
        || filled(save_regression_baseline))
    .then_some(CliFallbackReason::Regression)
}

pub(super) fn grouped_fallback_reason(group_by: Option<&str>) -> Option<CliFallbackReason> {
    filled(group_by).then_some(CliFallbackReason::GroupedOutput)
}

pub(super) fn duplication_fallback_reason(
    group_by: Option<&str>,
    explain_skipped: Option<bool>,
) -> Option<CliFallbackReason> {
    grouped_fallback_reason(group_by).or_else(|| {
        (explain_skipped == Some(true)).then_some(CliFallbackReason::DuplicationExplainSkipped)
    })
}

pub(super) fn filled(value: Option<&str>) -> bool {
    value.is_some_and(|value| !value.is_empty())
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::path::{Path, PathBuf};

    #[test]
    fn baseline_reason_tracks_either_baseline_surface() {
        assert_eq!(
            baseline_fallback_reason(Some("base.json"), None),
            Some(CliFallbackReason::Baseline)
        );
        assert_eq!(
            baseline_fallback_reason(None, Some("next.json")),
            Some(CliFallbackReason::Baseline)
        );
        assert_eq!(baseline_fallback_reason(None, None), None);
    }

    #[test]
    fn duplication_reason_preserves_grouping_precedence() {
        assert_eq!(
            duplication_fallback_reason(Some("owner"), Some(true)),
            Some(CliFallbackReason::GroupedOutput)
        );
        assert_eq!(
            duplication_fallback_reason(None, Some(true)),
            Some(CliFallbackReason::DuplicationExplainSkipped)
        );
    }

    #[test]
    fn cli_fallback_surfaces_are_explicitly_owned() {
        let tools_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src/tools");
        let unconditional_cli_backed = [
            "audit.rs",
            "check_runtime_coverage.rs",
            "code_mode.rs",
            "code_mode_subprocess.rs",
            "decision_surface.rs",
            "fix.rs",
            "impact.rs",
            "inspect_target.rs",
            "mod.rs",
            "security.rs",
        ];
        let conditional_cli_backed = [
            "analyze.rs",
            "audit.rs",
            "check_changed.rs",
            "dupes.rs",
            "health.rs",
        ];

        for entry in std::fs::read_dir(&tools_dir).expect("read tools dir") {
            let entry = entry.expect("read tools entry");
            let path = entry.path();
            if path.extension().and_then(|ext| ext.to_str()) != Some("rs") {
                continue;
            }

            let file_name = file_name(&path);
            if file_name == "fallback_policy.rs" {
                continue;
            }
            let source = std::fs::read_to_string(&path).expect("read mcp tool source");
            let invokes_cli = source.contains("run_tool(") || source.contains("run_fallow(");
            let is_unconditional = unconditional_cli_backed.contains(&file_name);
            let is_conditional = conditional_cli_backed.contains(&file_name);

            if invokes_cli {
                assert!(
                    is_unconditional || is_conditional,
                    "{file_name} must be API-backed or added to the explicit CLI fallback policy"
                );
            }

            if is_conditional {
                assert!(
                    source.contains("requires_cli_fallback"),
                    "{file_name} must gate subprocess execution behind requires_cli_fallback"
                );
                continue;
            }

            if !is_unconditional {
                assert!(
                    !invokes_cli,
                    "{file_name} is a pure API-backed MCP tool and must not spawn the CLI"
                );
            }
        }
    }

    fn file_name(path: &Path) -> &str {
        path.file_name()
            .and_then(|name| name.to_str())
            .expect("utf-8 filename")
    }
}