homeboy 0.80.0

CLI for multi-component deployment and development workflow automation
Documentation
//! Layer ownership rules for architecture-level audit constraints.
//!
//! Rules are optional and loaded from `homeboy.json` under `audit_rules`.

use std::path::Path;

use glob_match::glob_match;

use crate::engine::codebase_scan::{self, ExtensionFilter, ScanConfig};

use super::conventions::AuditFinding;
use super::findings::{Finding, Severity};

#[derive(Debug, Clone, serde::Deserialize)]
pub struct AuditRulesConfig {
    #[serde(default)]
    pub layer_rules: Vec<LayerRule>,
}

#[derive(Debug, Clone, serde::Deserialize)]
pub struct LayerRule {
    pub name: String,
    pub forbid: LayerForbid,
    #[serde(default)]
    pub allow: Option<LayerAllow>,
}

#[derive(Debug, Clone, serde::Deserialize)]
pub struct LayerForbid {
    pub glob: String,
    #[serde(default)]
    pub patterns: Vec<String>,
}

#[derive(Debug, Clone, serde::Deserialize)]
pub struct LayerAllow {
    pub glob: String,
}

pub(super) fn run(root: &Path) -> Vec<Finding> {
    analyze_layer_ownership(root)
}

fn analyze_layer_ownership(root: &Path) -> Vec<Finding> {
    let Some(config) = load_rules_config(root) else {
        return Vec::new();
    };

    let files = match walk_candidate_files(root) {
        Ok(v) => v,
        Err(_) => return Vec::new(),
    };

    let mut findings = Vec::new();

    for file in files {
        let relative = match file.strip_prefix(root) {
            Ok(p) => p.to_string_lossy().to_string(),
            Err(_) => continue,
        };
        let normalized = relative.replace('\\', "/");

        for rule in &config.layer_rules {
            if !glob_match(&rule.forbid.glob, &normalized) {
                continue;
            }

            if let Some(allow) = &rule.allow {
                if glob_match(&allow.glob, &normalized) {
                    continue;
                }
            }

            let Ok(content) = std::fs::read_to_string(&file) else {
                continue;
            };

            for pattern in &rule.forbid.patterns {
                if content.contains(pattern) {
                    findings.push(Finding {
                        convention: "layer_ownership".to_string(),
                        severity: Severity::Warning,
                        file: normalized.clone(),
                        description: format!(
                            "Rule '{}' violated: forbidden pattern '{}' matched",
                            rule.name, pattern
                        ),
                        suggestion: format!(
                            "Move this responsibility to the owning layer for rule '{}'",
                            rule.name
                        ),
                        kind: AuditFinding::LayerOwnershipViolation,
                    });
                }
            }
        }
    }

    findings.sort_by(|a, b| a.file.cmp(&b.file).then(a.description.cmp(&b.description)));
    findings
}

fn walk_candidate_files(root: &Path) -> std::io::Result<Vec<std::path::PathBuf>> {
    let config = ScanConfig {
        extensions: ExtensionFilter::All,
        ..Default::default()
    };
    Ok(codebase_scan::walk_files(root, &config))
}

fn load_rules_config(root: &Path) -> Option<AuditRulesConfig> {
    let homeboy_json = root.join("homeboy.json");
    let content = std::fs::read_to_string(homeboy_json).ok()?;
    let value: serde_json::Value = serde_json::from_str(&content).ok()?;
    let audit_rules = value.get("audit_rules")?.clone();
    serde_json::from_value::<AuditRulesConfig>(audit_rules).ok()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_walk_candidate_files_finds_non_extension_files() {
        let dir = tempfile::tempdir().unwrap();
        let steps_dir = dir.path().join("inc/Core/Steps");
        std::fs::create_dir_all(&steps_dir).unwrap();
        std::fs::write(steps_dir.join("agent_ping.php"), "<?php\n").unwrap();
        std::fs::write(steps_dir.join("README.txt"), "notes\n").unwrap();

        let files = walk_candidate_files(dir.path()).unwrap();
        let names: Vec<String> = files
            .iter()
            .filter_map(|p| {
                p.file_name()
                    .and_then(|n| n.to_str())
                    .map(ToString::to_string)
            })
            .collect();

        assert!(names.contains(&"agent_ping.php".to_string()));
        assert!(names.contains(&"README.txt".to_string()));
    }

    #[test]
    fn test_detects_violation_from_homeboy_json() {
        let dir = tempfile::tempdir().unwrap();
        let steps_dir = dir.path().join("inc/Core/Steps");
        std::fs::create_dir_all(&steps_dir).unwrap();

        std::fs::write(
            dir.path().join("homeboy.json"),
            r#"{
              "audit_rules": {
                "layer_rules": [
                  {
                    "name": "engine-owns-terminal-status",
                    "forbid": {
                      "glob": "inc/Core/Steps/**/*.php",
                      "patterns": ["JobStatus::", "datamachine_fail_job"]
                    },
                    "allow": {"glob": "inc/Abilities/Engine/**/*.php"}
                  }
                ]
              }
            }"#,
        )
        .unwrap();

        std::fs::write(
            steps_dir.join("agent_ping.php"),
            "<?php\n$status = JobStatus::FAILED;\n",
        )
        .unwrap();

        let findings = analyze_layer_ownership(dir.path());
        assert_eq!(findings.len(), 1);
        assert_eq!(findings[0].convention, "layer_ownership");
        assert_eq!(findings[0].kind, AuditFinding::LayerOwnershipViolation);
    }

    #[test]
    fn test_supports_homeboy_json_audit_rules() {
        let dir = tempfile::tempdir().unwrap();
        let steps_dir = dir.path().join("inc/Core/Steps");
        std::fs::create_dir_all(&steps_dir).unwrap();

        std::fs::write(
            dir.path().join("homeboy.json"),
            r#"{
              "audit_rules": {
                "layer_rules": [
                  {
                    "name": "engine-owns-terminal-status",
                    "forbid": {
                      "glob": "inc/Core/Steps/**/*.php",
                      "patterns": ["datamachine_fail_job"]
                    }
                  }
                ]
              }
            }"#,
        )
        .unwrap();

        std::fs::write(
            steps_dir.join("agent_ping.php"),
            "<?php\ndatamachine_fail_job($job_id);\n",
        )
        .unwrap();

        let findings = analyze_layer_ownership(dir.path());
        assert_eq!(findings.len(), 1);
    }

    #[test]
    fn test_no_config_means_no_findings() {
        let dir = tempfile::tempdir().unwrap();
        let findings = analyze_layer_ownership(dir.path());
        assert!(findings.is_empty());
    }

    #[test]
    fn test_load_rules_config() {
        let dir = tempfile::tempdir().unwrap();
        std::fs::write(
            dir.path().join("homeboy.json"),
            r#"{
              "audit_rules": {
                "layer_rules": [
                  {
                    "name": "example-rule",
                    "forbid": {
                      "glob": "src/**/*.rs",
                      "patterns": ["println!"]
                    }
                  }
                ]
              }
            }"#,
        )
        .unwrap();

        let config = load_rules_config(dir.path()).expect("config should load");
        assert_eq!(config.layer_rules.len(), 1);
        assert_eq!(config.layer_rules[0].name, "example-rule");
    }

    #[test]
    fn test_walk_candidate_files() {
        let dir = tempfile::tempdir().unwrap();
        std::fs::create_dir_all(dir.path().join("src")).unwrap();
        std::fs::write(dir.path().join("src/lib.rs"), "pub fn x() {}\n").unwrap();

        let files = walk_candidate_files(dir.path()).expect("walk should succeed");
        assert!(files.iter().any(|p| p.ends_with("src/lib.rs")));
    }
}