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#[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#[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#[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#[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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
187pub struct IgnoreRuleConfig {
188 pub rule: String,
189 pub paths: Vec<String>,
190}
191
192#[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 #[serde(default = "default_blocked_packages")]
209 pub blocked_packages: Vec<String>,
210 #[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 "gorm.io/*".to_string(),
237 "github.com/go-gorm/*".to_string(),
238 "entgo.io/*".to_string(),
239 "github.com/gin-gonic/*".to_string(),
241 "github.com/labstack/echo/*".to_string(),
242 "github.com/gofiber/fiber/*".to_string(),
243 "cloud.google.com/*".to_string(),
245 "github.com/aws/aws-sdk-go/*".to_string(),
246 "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 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 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 pub fn load_or_default(dir: &Path) -> Self {
321 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 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 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 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 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 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 assert_eq!(
693 rules.resolve_severity(&kind, Severity::Warning),
694 Severity::Info
695 );
696
697 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 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 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 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}