Skip to main content

boundary_core/
config.rs

1use anyhow::{Context, Result};
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4use std::path::Path;
5
6use crate::types::{ArchitectureMode, Severity, ViolationKind};
7
8/// Top-level configuration from `.boundary.toml`
9#[derive(Debug, Clone, Default, Serialize, Deserialize)]
10pub struct Config {
11    #[serde(default)]
12    pub project: ProjectConfig,
13    #[serde(default)]
14    pub layers: LayersConfig,
15    #[serde(default)]
16    pub scoring: ScoringConfig,
17    #[serde(default)]
18    pub rules: RulesConfig,
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct ProjectConfig {
23    #[serde(default = "default_languages")]
24    pub languages: Vec<String>,
25    #[serde(default)]
26    pub exclude_patterns: Vec<String>,
27    #[serde(default)]
28    pub services_pattern: Option<String>,
29}
30
31fn default_languages() -> Vec<String> {
32    vec![]
33}
34
35impl Default for ProjectConfig {
36    fn default() -> Self {
37        Self {
38            languages: default_languages(),
39            exclude_patterns: vec![
40                "vendor/**".to_string(),
41                "**/*_test.go".to_string(),
42                "**/testdata/**".to_string(),
43            ],
44            services_pattern: None,
45        }
46    }
47}
48
49/// Per-module override for layer classification patterns.
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct LayerOverrideConfig {
52    pub scope: String,
53    #[serde(default)]
54    pub domain: Vec<String>,
55    #[serde(default)]
56    pub application: Vec<String>,
57    #[serde(default)]
58    pub infrastructure: Vec<String>,
59    #[serde(default)]
60    pub presentation: Vec<String>,
61    #[serde(default)]
62    pub architecture_mode: Option<ArchitectureMode>,
63}
64
65/// Glob patterns mapping file paths to architectural layers
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct LayersConfig {
68    #[serde(default = "default_domain_patterns")]
69    pub domain: Vec<String>,
70    #[serde(default = "default_application_patterns")]
71    pub application: Vec<String>,
72    #[serde(default = "default_infrastructure_patterns")]
73    pub infrastructure: Vec<String>,
74    #[serde(default = "default_presentation_patterns")]
75    pub presentation: Vec<String>,
76    #[serde(default)]
77    pub overrides: Vec<LayerOverrideConfig>,
78    #[serde(default)]
79    pub cross_cutting: Vec<String>,
80    #[serde(default)]
81    pub architecture_mode: ArchitectureMode,
82}
83
84fn default_domain_patterns() -> Vec<String> {
85    vec![
86        "**/domain/**".to_string(),
87        "**/entity/**".to_string(),
88        "**/model/**".to_string(),
89    ]
90}
91
92fn default_application_patterns() -> Vec<String> {
93    vec![
94        "**/application/**".to_string(),
95        "**/usecase/**".to_string(),
96        "**/service/**".to_string(),
97    ]
98}
99
100fn default_infrastructure_patterns() -> Vec<String> {
101    vec![
102        "**/infrastructure/**".to_string(),
103        "**/adapter/**".to_string(),
104        "**/repository/**".to_string(),
105        "**/persistence/**".to_string(),
106    ]
107}
108
109fn default_presentation_patterns() -> Vec<String> {
110    vec![
111        "**/presentation/**".to_string(),
112        "**/handler/**".to_string(),
113        "**/api/**".to_string(),
114        "**/cmd/**".to_string(),
115    ]
116}
117
118impl Default for LayersConfig {
119    fn default() -> Self {
120        Self {
121            domain: default_domain_patterns(),
122            application: default_application_patterns(),
123            infrastructure: default_infrastructure_patterns(),
124            presentation: default_presentation_patterns(),
125            overrides: Vec::new(),
126            cross_cutting: Vec::new(),
127            architecture_mode: ArchitectureMode::default(),
128        }
129    }
130}
131
132/// Weights for scoring sub-components (should sum to ~1.0)
133#[derive(Debug, Clone, Serialize, Deserialize)]
134pub struct ScoringConfig {
135    #[serde(default = "default_layer_weight", alias = "layer_isolation_weight")]
136    pub layer_conformance_weight: f64,
137    #[serde(default = "default_dep_weight", alias = "dependency_direction_weight")]
138    pub dependency_compliance_weight: f64,
139    #[serde(default = "default_interface_weight")]
140    pub interface_coverage_weight: f64,
141}
142
143fn default_layer_weight() -> f64 {
144    0.4
145}
146fn default_dep_weight() -> f64 {
147    0.4
148}
149fn default_interface_weight() -> f64 {
150    0.2
151}
152
153impl Default for ScoringConfig {
154    fn default() -> Self {
155        Self {
156            layer_conformance_weight: default_layer_weight(),
157            dependency_compliance_weight: default_dep_weight(),
158            interface_coverage_weight: default_interface_weight(),
159        }
160    }
161}
162
163/// A custom rule defined in configuration.
164#[derive(Debug, Clone, Serialize, Deserialize)]
165pub struct CustomRuleConfig {
166    pub name: String,
167    pub from_pattern: String,
168    pub to_pattern: String,
169    #[serde(default = "default_deny")]
170    pub action: String,
171    #[serde(default = "default_custom_rule_severity")]
172    pub severity: Severity,
173    #[serde(default)]
174    pub message: Option<String>,
175}
176
177fn default_deny() -> String {
178    "deny".to_string()
179}
180
181fn default_custom_rule_severity() -> Severity {
182    Severity::Error
183}
184
185/// A path-specific rule ignore entry from `[[rules.ignore]]`.
186#[derive(Debug, Clone, Serialize, Deserialize)]
187pub struct IgnoreRuleConfig {
188    pub rule: String,
189    pub paths: Vec<String>,
190}
191
192/// Rule configuration
193#[derive(Debug, Clone, Serialize, Deserialize)]
194pub struct RulesConfig {
195    #[serde(default = "default_severities")]
196    pub severities: HashMap<String, Severity>,
197    #[serde(default = "default_fail_on")]
198    pub fail_on: Severity,
199    #[serde(default)]
200    pub min_score: Option<f64>,
201    #[serde(default)]
202    pub custom_rules: Vec<CustomRuleConfig>,
203    #[serde(default = "default_true")]
204    pub detect_init_functions: bool,
205    #[serde(default)]
206    pub ignore: Vec<IgnoreRuleConfig>,
207    /// Package patterns blocked from domain layer (L006).
208    #[serde(default = "default_blocked_packages")]
209    pub blocked_packages: Vec<String>,
210    /// Standard library packages allowed in domain layer (L006).
211    #[serde(default = "default_allowed_std_packages")]
212    pub allowed_std_packages: Vec<String>,
213}
214
215fn default_true() -> bool {
216    true
217}
218
219fn default_severities() -> HashMap<String, Severity> {
220    let mut m = HashMap::new();
221    m.insert("layer_boundary".to_string(), Severity::Error);
222    m.insert("circular_dependency".to_string(), Severity::Error);
223    m.insert("missing_port".to_string(), Severity::Warning);
224    m.insert("init_coupling".to_string(), Severity::Warning);
225    m.insert("domain_infra_leak".to_string(), Severity::Error);
226    m.insert("constructor_concrete".to_string(), Severity::Warning);
227    m.insert("missing_implementation".to_string(), Severity::Info);
228    m.insert("framework_imports".to_string(), Severity::Error);
229    m.insert("anemic_model".to_string(), Severity::Warning);
230    m
231}
232
233fn default_blocked_packages() -> Vec<String> {
234    vec![
235        // ORM
236        "gorm.io/*".to_string(),
237        "github.com/go-gorm/*".to_string(),
238        "entgo.io/*".to_string(),
239        // Web frameworks
240        "github.com/gin-gonic/*".to_string(),
241        "github.com/labstack/echo/*".to_string(),
242        "github.com/gofiber/fiber/*".to_string(),
243        // Cloud SDKs
244        "cloud.google.com/*".to_string(),
245        "github.com/aws/aws-sdk-go/*".to_string(),
246        // HTTP server (net/url is intentionally NOT blocked — useful for value objects)
247        "net/http".to_string(),
248    ]
249}
250
251fn default_allowed_std_packages() -> Vec<String> {
252    vec![
253        "time".to_string(),
254        "errors".to_string(),
255        "fmt".to_string(),
256        "context".to_string(),
257        "strings".to_string(),
258        "strconv".to_string(),
259    ]
260}
261
262fn default_fail_on() -> Severity {
263    Severity::Error
264}
265
266impl Default for RulesConfig {
267    fn default() -> Self {
268        Self {
269            severities: default_severities(),
270            fail_on: default_fail_on(),
271            min_score: None,
272            custom_rules: Vec::new(),
273            detect_init_functions: true,
274            ignore: Vec::new(),
275            blocked_packages: default_blocked_packages(),
276            allowed_std_packages: default_allowed_std_packages(),
277        }
278    }
279}
280
281impl RulesConfig {
282    /// Resolve severity for a violation kind.
283    /// Precedence: rule ID (e.g. "L001") > category name (e.g. "layer_boundary") > default.
284    pub fn resolve_severity(&self, kind: &ViolationKind, default: Severity) -> Severity {
285        let rule_id = kind.rule_id().to_string();
286        if let Some(&sev) = self.severities.get(&rule_id) {
287            return sev;
288        }
289        let category = match kind {
290            ViolationKind::LayerBoundary { .. } => "layer_boundary",
291            ViolationKind::CircularDependency { .. } => "circular_dependency",
292            ViolationKind::MissingPort { .. } => "missing_port",
293            ViolationKind::InitFunctionCoupling { .. } => "init_coupling",
294            ViolationKind::DomainInfrastructureLeak { .. } => "domain_infra_leak",
295            ViolationKind::ConstructorReturnsConcrete { .. } => "constructor_concrete",
296            ViolationKind::PortWithoutImplementation { .. } => "missing_implementation",
297            ViolationKind::FrameworkImportsInDomain { .. } => "framework_imports",
298            ViolationKind::AnemicDomainModel { .. } => "anemic_model",
299            ViolationKind::CustomRule { .. } => return default,
300        };
301        self.severities.get(category).copied().unwrap_or(default)
302    }
303}
304
305impl Config {
306    /// Load configuration from a `.boundary.toml` file.
307    pub fn load(path: &Path) -> Result<Self> {
308        let content = std::fs::read_to_string(path)
309            .with_context(|| format!("failed to read config file '{}'", path.display()))?;
310        let config: Config = toml::from_str(&content).with_context(|| {
311            format!(
312                "failed to parse '{}'. Run `boundary init` to create a valid config file",
313                path.display()
314            )
315        })?;
316        Ok(config)
317    }
318
319    /// Load from `.boundary.toml` in the given directory or any ancestor, or return defaults.
320    pub fn load_or_default(dir: &Path) -> Self {
321        // Walk up from dir to find .boundary.toml (similar to how git finds .git)
322        let start = dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf());
323        let mut current = start.as_path();
324        loop {
325            let config_path = current.join(".boundary.toml");
326            if config_path.exists() {
327                return match Self::load(&config_path) {
328                    Ok(config) => config,
329                    Err(e) => {
330                        eprintln!(
331                            "Warning: failed to load config from '{}': {e:#}. Using defaults.",
332                            config_path.display()
333                        );
334                        Self::default()
335                    }
336                };
337            }
338            match current.parent() {
339                Some(parent) => current = parent,
340                None => break,
341            }
342        }
343        Self::default()
344    }
345
346    /// Generate default TOML content for `boundary init`.
347    pub fn default_toml() -> String {
348        r#"# Boundary - Architecture Analysis Configuration
349# See https://github.com/rebelopsio/boundary for documentation
350
351[project]
352languages = ["go"]
353exclude_patterns = ["vendor/**", "**/*_test.go", "**/testdata/**"]
354
355[layers]
356# Glob patterns to classify files into architectural layers
357domain = ["**/domain/**", "**/entity/**", "**/model/**"]
358application = ["**/application/**", "**/usecase/**", "**/service/**"]
359infrastructure = ["**/infrastructure/**", "**/adapter/**", "**/repository/**", "**/persistence/**"]
360presentation = ["**/presentation/**", "**/handler/**", "**/api/**", "**/cmd/**"]
361
362# Paths exempt from layer violation checks (cross-cutting concerns)
363# cross_cutting = ["common/utils/**", "pkg/logger/**", "pkg/errors/**"]
364
365# Per-module overrides — matched by scope, first match wins.
366# Omitted layers fall back to global patterns above.
367# [[layers.overrides]]
368# scope = "services/auth/**"
369# domain = ["services/auth/core/**"]
370# infrastructure = ["services/auth/server/**", "services/auth/adapters/**"]
371
372[scoring]
373# Weights for score components (should sum to 1.0)
374layer_conformance_weight = 0.4
375dependency_compliance_weight = 0.4
376interface_coverage_weight = 0.2
377
378[rules]
379# Severity levels: "error", "warning", "info"
380fail_on = "error"
381# min_score = 70.0
382
383[rules.severities]
384# Category names (backward compatible)
385layer_boundary = "error"
386circular_dependency = "error"
387missing_port = "warning"
388init_coupling = "warning"
389domain_infra_leak = "error"
390#
391# Rule IDs (more precise, takes precedence over category names)
392# L001 = "error"    # domain-depends-on-infrastructure
393# PA001 = "info"    # missing-port-interface
394# PA002 = "info"    # port-without-implementation
395# PA003 = "warning"  # constructor-returns-concrete-type
396
397# Path-specific ignores
398# [[rules.ignore]]
399# rule = "PA001"
400# paths = ["infrastructure/**/*document.go"]
401"#
402        .to_string()
403    }
404}
405
406#[cfg(test)]
407mod tests {
408    use super::*;
409
410    #[test]
411    fn test_default_config() {
412        let config = Config::default();
413        assert!(
414            config.project.languages.is_empty(),
415            "default should be empty for auto-detection"
416        );
417        assert!(!config.layers.domain.is_empty());
418        assert!((config.scoring.layer_conformance_weight - 0.4).abs() < f64::EPSILON);
419    }
420
421    #[test]
422    fn test_deserialize_config() {
423        let toml_str = r#"
424[project]
425languages = ["go", "rust"]
426
427[layers]
428domain = ["**/core/**"]
429application = ["**/app/**"]
430infrastructure = ["**/infra/**"]
431presentation = ["**/web/**"]
432
433[scoring]
434layer_conformance_weight = 0.5
435dependency_compliance_weight = 0.3
436interface_coverage_weight = 0.2
437
438[rules]
439fail_on = "warning"
440"#;
441        let config: Config = toml::from_str(toml_str).unwrap();
442        assert_eq!(config.project.languages, vec!["go", "rust"]);
443        assert_eq!(config.layers.domain, vec!["**/core/**"]);
444        assert_eq!(config.rules.fail_on, Severity::Warning);
445    }
446
447    #[test]
448    fn test_default_toml_is_valid() {
449        let toml_str = Config::default_toml();
450        let config: Config = toml::from_str(&toml_str).unwrap();
451        // The template specifies "go" as a starter config
452        assert_eq!(config.project.languages, vec!["go"]);
453    }
454
455    #[test]
456    fn test_deserialize_layer_overrides() {
457        let toml_str = r#"
458[layers]
459domain = ["**/domain/**"]
460application = ["**/application/**"]
461infrastructure = ["**/infrastructure/**"]
462presentation = ["**/handler/**"]
463
464[[layers.overrides]]
465scope = "services/auth/**"
466domain = ["services/auth/core/**"]
467infrastructure = ["services/auth/server/**", "services/auth/adapters/**"]
468
469[[layers.overrides]]
470scope = "common/modules/*/**"
471domain = ["common/modules/*/domain/**"]
472application = ["common/modules/*/app/**"]
473"#;
474        let config: Config = toml::from_str(toml_str).unwrap();
475        assert_eq!(config.layers.overrides.len(), 2);
476        assert_eq!(config.layers.overrides[0].scope, "services/auth/**");
477        assert_eq!(
478            config.layers.overrides[0].domain,
479            vec!["services/auth/core/**"]
480        );
481        assert_eq!(
482            config.layers.overrides[0].infrastructure,
483            vec!["services/auth/server/**", "services/auth/adapters/**"]
484        );
485        // Second override has no infrastructure/presentation
486        assert!(config.layers.overrides[1].infrastructure.is_empty());
487        assert!(config.layers.overrides[1].presentation.is_empty());
488    }
489
490    #[test]
491    fn test_empty_overrides_backward_compatible() {
492        let toml_str = r#"
493[layers]
494domain = ["**/domain/**"]
495"#;
496        let config: Config = toml::from_str(toml_str).unwrap();
497        assert!(config.layers.overrides.is_empty());
498    }
499
500    #[test]
501    fn test_deserialize_cross_cutting() {
502        let toml_str = r#"
503[layers]
504domain = ["**/domain/**"]
505application = ["**/application/**"]
506infrastructure = ["**/infrastructure/**"]
507presentation = ["**/handler/**"]
508cross_cutting = ["common/utils/**", "pkg/logger/**", "pkg/errors/**"]
509"#;
510        let config: Config = toml::from_str(toml_str).unwrap();
511        assert_eq!(config.layers.cross_cutting.len(), 3);
512        assert_eq!(config.layers.cross_cutting[0], "common/utils/**");
513        assert_eq!(config.layers.cross_cutting[1], "pkg/logger/**");
514        assert_eq!(config.layers.cross_cutting[2], "pkg/errors/**");
515    }
516
517    #[test]
518    fn test_architecture_mode_deserializes() {
519        let toml_str = r#"
520[layers]
521domain = ["**/domain/**"]
522architecture_mode = "active-record"
523
524[[layers.overrides]]
525scope = "services/legacy/**"
526architecture_mode = "service-oriented"
527"#;
528        let config: Config = toml::from_str(toml_str).unwrap();
529        assert_eq!(
530            config.layers.architecture_mode,
531            ArchitectureMode::ActiveRecord
532        );
533        assert_eq!(
534            config.layers.overrides[0].architecture_mode,
535            Some(ArchitectureMode::ServiceOriented)
536        );
537    }
538
539    #[test]
540    fn test_architecture_mode_missing_backward_compat() {
541        let toml_str = r#"
542[layers]
543domain = ["**/domain/**"]
544"#;
545        let config: Config = toml::from_str(toml_str).unwrap();
546        assert_eq!(config.layers.architecture_mode, ArchitectureMode::Ddd);
547    }
548
549    #[test]
550    fn test_services_pattern_parses() {
551        let toml_str = r#"
552[project]
553services_pattern = "apps/*"
554"#;
555        let config: Config = toml::from_str(toml_str).unwrap();
556        assert_eq!(config.project.services_pattern.as_deref(), Some("apps/*"));
557    }
558
559    #[test]
560    fn test_detect_init_functions_defaults_true() {
561        let config = Config::default();
562        assert!(config.rules.detect_init_functions);
563    }
564
565    #[test]
566    fn test_missing_cross_cutting_backward_compatible() {
567        let toml_str = r#"
568[layers]
569domain = ["**/domain/**"]
570"#;
571        let config: Config = toml::from_str(toml_str).unwrap();
572        assert!(config.layers.cross_cutting.is_empty());
573    }
574
575    #[test]
576    fn test_resolve_severity_rule_id_takes_precedence() {
577        let mut rules = RulesConfig::default();
578        // Set category to warning, but rule ID to info
579        rules
580            .severities
581            .insert("missing_port".to_string(), Severity::Warning);
582        rules.severities.insert("PA001".to_string(), Severity::Info);
583
584        let kind = ViolationKind::MissingPort {
585            adapter_name: "TestAdapter".to_string(),
586        };
587        assert_eq!(
588            rules.resolve_severity(&kind, Severity::Error),
589            Severity::Info
590        );
591    }
592
593    #[test]
594    fn test_resolve_severity_category_fallback() {
595        let mut rules = RulesConfig::default();
596        rules
597            .severities
598            .insert("missing_port".to_string(), Severity::Info);
599        // No rule ID override
600
601        let kind = ViolationKind::MissingPort {
602            adapter_name: "TestAdapter".to_string(),
603        };
604        assert_eq!(
605            rules.resolve_severity(&kind, Severity::Error),
606            Severity::Info
607        );
608    }
609
610    #[test]
611    fn test_resolve_severity_default_when_nothing_configured() {
612        let rules = RulesConfig {
613            severities: HashMap::new(),
614            ..Default::default()
615        };
616
617        let kind = ViolationKind::MissingPort {
618            adapter_name: "TestAdapter".to_string(),
619        };
620        assert_eq!(
621            rules.resolve_severity(&kind, Severity::Warning),
622            Severity::Warning
623        );
624    }
625
626    #[test]
627    fn test_resolve_severity_domain_infra_leak_configurable() {
628        let mut rules = RulesConfig::default();
629        rules
630            .severities
631            .insert("domain_infra_leak".to_string(), Severity::Warning);
632
633        let kind = ViolationKind::DomainInfrastructureLeak {
634            detail: "test".to_string(),
635        };
636        assert_eq!(
637            rules.resolve_severity(&kind, Severity::Error),
638            Severity::Warning
639        );
640    }
641
642    #[test]
643    fn test_deserialize_ignore_rules() {
644        let toml_str = r#"
645[rules]
646fail_on = "error"
647
648[[rules.ignore]]
649rule = "PA001"
650paths = ["infrastructure/**/*document.go"]
651
652[[rules.ignore]]
653rule = "L005"
654paths = ["legacy/**"]
655"#;
656        let config: Config = toml::from_str(toml_str).unwrap();
657        assert_eq!(config.rules.ignore.len(), 2);
658        assert_eq!(config.rules.ignore[0].rule, "PA001");
659        assert_eq!(
660            config.rules.ignore[0].paths,
661            vec!["infrastructure/**/*document.go"]
662        );
663        assert_eq!(config.rules.ignore[1].rule, "L005");
664    }
665
666    #[test]
667    fn test_rule_id_severity_in_toml() {
668        let toml_str = r#"
669[rules.severities]
670layer_boundary = "error"
671PA001 = "info"
672L001 = "warning"
673"#;
674        let config: Config = toml::from_str(toml_str).unwrap();
675        assert_eq!(
676            config.rules.severities.get("PA001").copied(),
677            Some(Severity::Info)
678        );
679        assert_eq!(
680            config.rules.severities.get("L001").copied(),
681            Some(Severity::Warning)
682        );
683    }
684
685    #[test]
686    fn test_resolve_severity_missing_implementation() {
687        let rules = RulesConfig::default();
688        let kind = ViolationKind::PortWithoutImplementation {
689            port_name: "UserRepository".to_string(),
690        };
691        // Default should be info
692        assert_eq!(
693            rules.resolve_severity(&kind, Severity::Warning),
694            Severity::Info
695        );
696
697        // Rule ID takes precedence
698        let mut rules2 = RulesConfig::default();
699        rules2
700            .severities
701            .insert("PA002".to_string(), Severity::Error);
702        assert_eq!(
703            rules2.resolve_severity(&kind, Severity::Warning),
704            Severity::Error
705        );
706    }
707
708    #[test]
709    fn test_resolve_severity_constructor_concrete() {
710        let mut rules = RulesConfig::default();
711        // Default should be warning
712        let kind = ViolationKind::ConstructorReturnsConcrete {
713            adapter_name: "TestAdapter".to_string(),
714            concrete_type: "ConcreteType".to_string(),
715        };
716        assert_eq!(
717            rules.resolve_severity(&kind, Severity::Warning),
718            Severity::Warning
719        );
720
721        // Category override
722        rules
723            .severities
724            .insert("constructor_concrete".to_string(), Severity::Info);
725        assert_eq!(
726            rules.resolve_severity(&kind, Severity::Warning),
727            Severity::Info
728        );
729
730        // Rule ID takes precedence
731        rules
732            .severities
733            .insert("PA003".to_string(), Severity::Error);
734        assert_eq!(
735            rules.resolve_severity(&kind, Severity::Warning),
736            Severity::Error
737        );
738    }
739}