1use bylaw_core::{
4 BuiltInRuleSpec, ComponentKind, CycleGrouping, DependencyScopes, LayerDependencySpec,
5 NamedSelectorSpec, RuleBuildError, RuleMetadata, SelectorSpec, Severity, TargetKind,
6};
7use camino::{Utf8Path, Utf8PathBuf};
8use serde::Deserialize;
9use std::collections::{BTreeMap, HashSet};
10use std::fs;
11use std::ops::Range;
12use thiserror::Error;
13
14pub const CONFIG_VERSION: u32 = 1;
15
16#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
17#[serde(deny_unknown_fields)]
18pub struct Config {
19 pub version: u32,
20 #[serde(default)]
21 pub analysis: AnalysisConfig,
22 #[serde(default)]
23 pub output: OutputConfig,
24 #[serde(default)]
25 pub selectors: BTreeMap<String, SelectorConfig>,
26 #[serde(default, rename = "rule")]
27 pub rules: Vec<RuleConfig>,
28}
29
30impl Config {
31 pub fn from_toml(source: &str) -> Result<Self, ConfigError> {
32 let config = toml::from_str::<Self>(source).map_err(|error| {
33 let span = error.span();
34 let (line, column) = span
35 .as_ref()
36 .map(|span| line_column(source, span.start))
37 .unzip();
38 ConfigError::Parse {
39 message: error.message().to_owned(),
40 span,
41 line,
42 column,
43 }
44 })?;
45 config.validate()?;
46 Ok(config)
47 }
48
49 pub fn load(path: impl AsRef<Utf8Path>) -> Result<Self, ConfigError> {
50 let path = path.as_ref();
51 let source = fs::read_to_string(path).map_err(|source| ConfigError::Read {
52 path: path.to_owned(),
53 source,
54 })?;
55 Self::from_toml(&source).map_err(|error| error.with_path(path.to_owned()))
56 }
57
58 pub fn compile(&self) -> Result<CompiledConfig, ConfigError> {
59 self.validate()?;
60 let selectors = self
61 .selectors
62 .iter()
63 .map(|(name, selector)| {
64 if name.trim().is_empty() {
65 return Err(ConfigError::EmptySelectorName);
66 }
67 selector
68 .lower()
69 .map(|selector| (name.clone(), selector))
70 .map_err(|error| ConfigError::InvalidSelector {
71 selector: name.clone(),
72 message: error,
73 })
74 })
75 .collect::<Result<BTreeMap<_, _>, _>>()?;
76
77 let mut ids = HashSet::new();
78 let mut rules = Vec::with_capacity(self.rules.len());
79 for rule in &self.rules {
80 let id = rule.common().id.clone();
81 if id.trim().is_empty() {
82 return Err(ConfigError::EmptyRuleId);
83 }
84 if !ids.insert(id.clone()) {
85 return Err(ConfigError::DuplicateRuleId(id));
86 }
87 let spec = rule.lower(&selectors)?;
88 spec.compile()
89 .map_err(|source| ConfigError::InvalidRule { rule: id, source })?;
90 rules.push(spec);
91 }
92
93 Ok(CompiledConfig {
94 analysis: self.analysis.clone(),
95 output: self.output,
96 rules,
97 })
98 }
99
100 fn validate(&self) -> Result<(), ConfigError> {
101 if self.version != CONFIG_VERSION {
102 return Err(ConfigError::UnsupportedVersion {
103 found: self.version,
104 supported: CONFIG_VERSION,
105 });
106 }
107 if self.analysis.all_features && self.analysis.no_default_features {
108 return Err(ConfigError::ConflictingFeatureOptions);
109 }
110 if self.analysis.target_kinds.is_empty() {
111 return Err(ConfigError::EmptyTargetKinds);
112 }
113 Ok(())
114 }
115}
116
117#[derive(Clone, Debug, Eq, PartialEq)]
118pub struct CompiledConfig {
119 pub analysis: AnalysisConfig,
120 pub output: OutputConfig,
121 pub rules: Vec<BuiltInRuleSpec>,
122}
123
124#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
125#[serde(deny_unknown_fields)]
126pub struct AnalysisConfig {
127 pub manifest_path: Option<Utf8PathBuf>,
128 #[serde(default)]
129 pub packages: Vec<String>,
130 #[serde(default)]
131 pub features: Vec<String>,
132 #[serde(default)]
133 pub all_features: bool,
134 #[serde(default)]
135 pub no_default_features: bool,
136 pub target: Option<String>,
137 #[serde(default = "default_target_kinds")]
138 pub target_kinds: Vec<TargetKindConfig>,
139 #[serde(default)]
140 pub incomplete: IncompleteAnalysisPolicy,
141 #[serde(default = "default_true")]
142 pub proc_macros: bool,
143 #[serde(default = "default_true")]
144 pub build_scripts: bool,
145}
146
147impl Default for AnalysisConfig {
148 fn default() -> Self {
149 Self {
150 manifest_path: None,
151 packages: Vec::new(),
152 features: Vec::new(),
153 all_features: false,
154 no_default_features: false,
155 target: None,
156 target_kinds: default_target_kinds(),
157 incomplete: IncompleteAnalysisPolicy::Deny,
158 proc_macros: true,
159 build_scripts: true,
160 }
161 }
162}
163
164#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)]
165#[serde(deny_unknown_fields)]
166pub struct OutputConfig {
167 #[serde(default)]
168 pub format: OutputFormatConfig,
169}
170
171#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)]
172#[serde(rename_all = "kebab-case")]
173pub enum OutputFormatConfig {
174 #[default]
175 Human,
176 Json,
177}
178
179fn default_true() -> bool {
180 true
181}
182
183fn default_target_kinds() -> Vec<TargetKindConfig> {
184 vec![TargetKindConfig::Library, TargetKindConfig::Binary]
185}
186
187#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)]
188#[serde(rename_all = "kebab-case")]
189pub enum IncompleteAnalysisPolicy {
190 #[default]
191 Deny,
192 Allow,
193}
194
195#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
196#[serde(rename_all = "kebab-case")]
197pub enum TargetKindConfig {
198 Library,
199 Binary,
200 Test,
201 Example,
202 Bench,
203 BuildScript,
204 ProcMacro,
205}
206
207impl From<&TargetKindConfig> for TargetKind {
208 fn from(value: &TargetKindConfig) -> Self {
209 match value {
210 TargetKindConfig::Library => Self::Library,
211 TargetKindConfig::Binary => Self::Binary,
212 TargetKindConfig::Test => Self::Test,
213 TargetKindConfig::Example => Self::Example,
214 TargetKindConfig::Bench => Self::Bench,
215 TargetKindConfig::BuildScript => Self::BuildScript,
216 TargetKindConfig::ProcMacro => Self::ProcMacro,
217 }
218 }
219}
220
221#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)]
222#[serde(deny_unknown_fields)]
223pub struct SelectorConfig {
224 #[serde(default)]
225 pub packages: Vec<String>,
226 #[serde(default)]
227 pub crates: Vec<String>,
228 #[serde(default)]
229 pub modules: Vec<String>,
230 #[serde(default)]
231 pub external_crates: Vec<String>,
232 #[serde(default)]
233 pub target_kinds: Vec<TargetKindConfig>,
234 #[serde(default)]
235 pub component_kinds: Vec<ComponentKindConfig>,
236}
237
238impl SelectorConfig {
239 fn lower(&self) -> Result<SelectorSpec, String> {
240 if self
241 .packages
242 .iter()
243 .chain(&self.crates)
244 .chain(&self.modules)
245 .chain(&self.external_crates)
246 .any(|value| value.trim().is_empty())
247 {
248 return Err("selector matcher values cannot be empty".to_owned());
249 }
250 let mut selectors = Vec::new();
251 if !self.packages.is_empty() {
252 selectors.push(SelectorSpec::Packages {
253 names: self.packages.clone(),
254 });
255 }
256 if !self.crates.is_empty() {
257 selectors.push(SelectorSpec::Crates {
258 names: self.crates.clone(),
259 });
260 }
261 if !self.modules.is_empty() {
262 selectors.push(SelectorSpec::Modules {
263 patterns: self.modules.clone(),
264 });
265 }
266 if !self.external_crates.is_empty() {
267 selectors.push(SelectorSpec::ExternalCrates {
268 names: self.external_crates.clone(),
269 });
270 }
271 if !self.target_kinds.is_empty() {
272 selectors.push(SelectorSpec::TargetKinds {
273 kinds: self.target_kinds.iter().map(TargetKind::from).collect(),
274 });
275 }
276 if !self.component_kinds.is_empty() {
277 selectors.push(SelectorSpec::ComponentKinds {
278 kinds: self
279 .component_kinds
280 .iter()
281 .map(ComponentKind::from)
282 .collect(),
283 });
284 }
285
286 match selectors.len() {
287 0 => Err("selector must define at least one matcher".to_owned()),
288 1 => Ok(selectors.pop().expect("length was checked")),
289 _ => Ok(SelectorSpec::AllOf { selectors }),
290 }
291 }
292}
293
294#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
295#[serde(rename_all = "kebab-case")]
296pub enum ComponentKindConfig {
297 Crate,
298 Module,
299 ExternalCrate,
300}
301
302impl From<&ComponentKindConfig> for ComponentKind {
303 fn from(value: &ComponentKindConfig) -> Self {
304 match value {
305 ComponentKindConfig::Crate => Self::Crate,
306 ComponentKindConfig::Module => Self::Module,
307 ComponentKindConfig::ExternalCrate => Self::ExternalCrate,
308 }
309 }
310}
311
312#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
313#[serde(untagged)]
314pub enum SelectorReference {
315 One(String),
316 Many(Vec<String>),
317}
318
319impl SelectorReference {
320 fn names(&self) -> &[String] {
321 match self {
322 Self::One(name) => std::slice::from_ref(name),
323 Self::Many(names) => names,
324 }
325 }
326
327 fn resolve(
328 &self,
329 selectors: &BTreeMap<String, SelectorSpec>,
330 ) -> Result<SelectorSpec, ConfigError> {
331 let mut resolved = self
332 .names()
333 .iter()
334 .map(|name| {
335 selectors
336 .get(name)
337 .cloned()
338 .ok_or_else(|| ConfigError::UnknownSelector(name.clone()))
339 })
340 .collect::<Result<Vec<_>, _>>()?;
341 match resolved.len() {
342 0 => Err(ConfigError::EmptySelectorReference),
343 1 => Ok(resolved.pop().expect("length was checked")),
344 _ => Ok(SelectorSpec::AnyOf {
345 selectors: resolved,
346 }),
347 }
348 }
349}
350
351#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
352#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
353pub enum RuleConfig {
354 ForbidDependencies {
355 #[serde(flatten)]
356 common: RuleCommon,
357 from: SelectorReference,
358 to: SelectorReference,
359 #[serde(default)]
360 scope: DependencyScopes,
361 },
362 OnlyDependencies {
363 #[serde(flatten)]
364 common: RuleCommon,
365 from: SelectorReference,
366 allowed: SelectorReference,
367 #[serde(default)]
368 scope: DependencyScopes,
369 #[serde(default = "default_true")]
370 allow_toolchain: bool,
371 #[serde(default = "default_true")]
372 allow_self: bool,
373 },
374 Layers {
375 #[serde(flatten)]
376 common: RuleCommon,
377 layers: Vec<LayerConfig>,
378 #[serde(default)]
379 dependencies: Vec<LayerDependencyConfig>,
380 #[serde(default)]
381 scope: DependencyScopes,
382 },
383 NoCycles {
384 #[serde(flatten)]
385 common: RuleCommon,
386 within: SelectorReference,
387 #[serde(default)]
388 grouping: CycleGroupingConfig,
389 #[serde(default)]
390 slices: Vec<LayerConfig>,
391 #[serde(default)]
392 scope: DependencyScopes,
393 },
394}
395
396impl RuleConfig {
397 fn common(&self) -> &RuleCommon {
398 match self {
399 Self::ForbidDependencies { common, .. }
400 | Self::OnlyDependencies { common, .. }
401 | Self::Layers { common, .. }
402 | Self::NoCycles { common, .. } => common,
403 }
404 }
405
406 fn lower(
407 &self,
408 selectors: &BTreeMap<String, SelectorSpec>,
409 ) -> Result<BuiltInRuleSpec, ConfigError> {
410 match self {
411 Self::ForbidDependencies {
412 common,
413 from,
414 to,
415 scope,
416 } => Ok(BuiltInRuleSpec::ForbidDependencies {
417 metadata: common.metadata(),
418 from: from.resolve(selectors)?,
419 to: to.resolve(selectors)?,
420 scopes: *scope,
421 }),
422 Self::OnlyDependencies {
423 common,
424 from,
425 allowed,
426 scope,
427 allow_toolchain,
428 allow_self,
429 } => Ok(BuiltInRuleSpec::OnlyDependencies {
430 metadata: common.metadata(),
431 from: from.resolve(selectors)?,
432 allowed: allowed.resolve(selectors)?,
433 scopes: *scope,
434 allow_toolchain: *allow_toolchain,
435 allow_self: *allow_self,
436 }),
437 Self::Layers {
438 common,
439 layers,
440 dependencies,
441 scope,
442 } => Ok(BuiltInRuleSpec::Layers {
443 metadata: common.metadata(),
444 layers: lower_named_selectors(layers, selectors)?,
445 dependencies: dependencies
446 .iter()
447 .map(|dependency| LayerDependencySpec {
448 from: dependency.from.clone(),
449 may_depend_on: dependency.may_depend_on.clone(),
450 })
451 .collect(),
452 scopes: *scope,
453 }),
454 Self::NoCycles {
455 common,
456 within,
457 grouping,
458 slices,
459 scope,
460 } => {
461 let grouping = match grouping {
462 CycleGroupingConfig::Components => CycleGrouping::Components,
463 CycleGroupingConfig::Modules => CycleGrouping::Modules,
464 CycleGroupingConfig::Crates => CycleGrouping::Crates,
465 CycleGroupingConfig::Slices => CycleGrouping::Slices {
466 slices: lower_named_selectors(slices, selectors)?,
467 },
468 };
469 if matches!(grouping, CycleGrouping::Slices { ref slices } if slices.is_empty()) {
470 return Err(ConfigError::MissingSlices(common.id.clone()));
471 }
472 Ok(BuiltInRuleSpec::NoCycles {
473 metadata: common.metadata(),
474 within: within.resolve(selectors)?,
475 grouping,
476 scopes: *scope,
477 })
478 }
479 }
480 }
481}
482
483fn lower_named_selectors(
484 layers: &[LayerConfig],
485 selectors: &BTreeMap<String, SelectorSpec>,
486) -> Result<Vec<NamedSelectorSpec>, ConfigError> {
487 layers
488 .iter()
489 .map(|layer| {
490 if layer.name.trim().is_empty() {
491 return Err(ConfigError::EmptyLayerName);
492 }
493 Ok(NamedSelectorSpec {
494 name: layer.name.clone(),
495 selector: SelectorReference::One(layer.selector.clone()).resolve(selectors)?,
496 })
497 })
498 .collect()
499}
500
501#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
502#[serde(deny_unknown_fields)]
503pub struct RuleCommon {
504 pub id: String,
505 pub description: Option<String>,
506 pub because: Option<String>,
507 #[serde(default)]
508 pub severity: Severity,
509}
510
511impl RuleCommon {
512 fn metadata(&self) -> RuleMetadata {
513 RuleMetadata {
514 id: self.id.clone(),
515 description: self.description.clone(),
516 because: self.because.clone(),
517 severity: self.severity,
518 }
519 }
520}
521
522#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
523#[serde(deny_unknown_fields)]
524pub struct LayerConfig {
525 pub name: String,
526 pub selector: String,
527}
528
529#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
530#[serde(deny_unknown_fields)]
531pub struct LayerDependencyConfig {
532 pub from: String,
533 #[serde(default)]
534 pub may_depend_on: Vec<String>,
535}
536
537#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)]
538#[serde(rename_all = "kebab-case")]
539pub enum CycleGroupingConfig {
540 Components,
541 Modules,
542 #[default]
543 Crates,
544 Slices,
545}
546
547#[derive(Debug, Error)]
548pub enum ConfigError {
549 #[error("failed to read configuration `{path}`: {source}")]
550 Read {
551 path: Utf8PathBuf,
552 #[source]
553 source: std::io::Error,
554 },
555 #[error("invalid configuration: {message}")]
556 Parse {
557 message: String,
558 span: Option<Range<usize>>,
559 line: Option<usize>,
560 column: Option<usize>,
561 },
562 #[error("invalid configuration `{path}`: {message}")]
563 ParseFile {
564 path: Utf8PathBuf,
565 message: String,
566 span: Option<Range<usize>>,
567 line: Option<usize>,
568 column: Option<usize>,
569 },
570 #[error("unsupported configuration version {found}; supported version is {supported}")]
571 UnsupportedVersion { found: u32, supported: u32 },
572 #[error("`all_features` and `no_default_features` cannot both be enabled")]
573 ConflictingFeatureOptions,
574 #[error("analysis must include at least one target kind")]
575 EmptyTargetKinds,
576 #[error("selector `{selector}` is invalid: {message}")]
577 InvalidSelector { selector: String, message: String },
578 #[error("selector names cannot be empty")]
579 EmptySelectorName,
580 #[error("rule IDs cannot be empty")]
581 EmptyRuleId,
582 #[error("layer names cannot be empty")]
583 EmptyLayerName,
584 #[error("rule ID `{0}` is duplicated")]
585 DuplicateRuleId(String),
586 #[error("unknown selector `{0}`")]
587 UnknownSelector(String),
588 #[error("selector reference cannot be empty")]
589 EmptySelectorReference,
590 #[error("cycle rule `{0}` uses slice grouping but defines no slices")]
591 MissingSlices(String),
592 #[error("rule `{rule}` is invalid: {source}")]
593 InvalidRule {
594 rule: String,
595 #[source]
596 source: RuleBuildError,
597 },
598}
599
600impl ConfigError {
601 fn with_path(self, path: Utf8PathBuf) -> Self {
602 match self {
603 Self::Parse {
604 message,
605 span,
606 line,
607 column,
608 } => Self::ParseFile {
609 path,
610 message,
611 span,
612 line,
613 column,
614 },
615 error => error,
616 }
617 }
618
619 pub fn span(&self) -> Option<Range<usize>> {
620 match self {
621 Self::Parse { span, .. } | Self::ParseFile { span, .. } => span.clone(),
622 _ => None,
623 }
624 }
625
626 pub fn render(&self) -> String {
627 match self {
628 Self::Parse {
629 message,
630 line: Some(line),
631 column: Some(column),
632 ..
633 } => format!("invalid configuration at {line}:{column}: {message}"),
634 Self::ParseFile {
635 path,
636 message,
637 line: Some(line),
638 column: Some(column),
639 ..
640 } => format!("invalid configuration `{path}` at {line}:{column}: {message}"),
641 _ => self.to_string(),
642 }
643 }
644}
645
646fn line_column(source: &str, byte_offset: usize) -> (usize, usize) {
647 let prefix = &source[..byte_offset.min(source.len())];
648 let line = prefix.bytes().filter(|byte| *byte == b'\n').count() + 1;
649 let column = prefix
650 .rsplit_once('\n')
651 .map_or(prefix, |(_, current_line)| current_line)
652 .chars()
653 .count()
654 + 1;
655 (line, column)
656}
657
658#[cfg(test)]
659mod tests {
660 use super::*;
661
662 const MODEL_BOUNDARIES: &str = r#"
663version = 1
664
665[selectors.domain]
666packages = ["shop-domain"]
667
668[selectors.persistence]
669packages = ["shop-persistence"]
670
671[selectors.contract]
672packages = ["shop-contract"]
673
674[selectors.api]
675packages = ["shop-api"]
676
677[[rule]]
678id = "domain-is-internal"
679kind = "forbid-dependencies"
680from = "domain"
681to = ["persistence", "contract", "api"]
682scope = "both"
683because = "the internal domain model must not leak boundary representations"
684
685[[rule]]
686id = "model-crates-are-acyclic"
687kind = "no-cycles"
688within = ["domain", "persistence", "contract", "api"]
689grouping = "crates"
690"#;
691
692 #[test]
693 fn parses_and_lowers_model_boundary_rules() {
694 let config = Config::from_toml(MODEL_BOUNDARIES).unwrap();
695 let compiled = config.compile().unwrap();
696 assert_eq!(compiled.rules.len(), 2);
697 assert_eq!(
698 compiled.analysis.target_kinds,
699 vec![TargetKindConfig::Library, TargetKindConfig::Binary]
700 );
701 assert_eq!(compiled.output.format, OutputFormatConfig::Human);
702 }
703
704 #[test]
705 fn repository_example_is_valid_configuration() {
706 let source = include_str!("../../../examples/model-boundaries/bylaw.toml");
707 let compiled = Config::from_toml(source).unwrap().compile().unwrap();
708 assert_eq!(compiled.rules.len(), 6);
709 }
710
711 #[test]
712 fn repository_self_enforcement_is_valid_configuration() {
713 let source = include_str!("../../../bylaw.toml");
714 let compiled = Config::from_toml(source).unwrap().compile().unwrap();
715 assert_eq!(compiled.rules.len(), 5);
716 }
717
718 #[test]
719 fn module_fixture_configurations_are_valid() {
720 for source in [
721 include_str!("../../../fixtures/model-boundaries-modules-valid/bylaw.toml"),
722 include_str!("../../../fixtures/model-boundaries-modules-invalid/bylaw.toml"),
723 ] {
724 let compiled = Config::from_toml(source).unwrap().compile().unwrap();
725 assert_eq!(compiled.rules.len(), 4);
726 }
727 }
728
729 #[test]
730 fn rejects_unknown_selector_references() {
731 let config = Config::from_toml(
732 r#"
733version = 1
734
735[[rule]]
736id = "broken"
737kind = "forbid-dependencies"
738from = "missing"
739to = "also-missing"
740"#,
741 )
742 .unwrap();
743
744 assert!(matches!(
745 config.compile(),
746 Err(ConfigError::UnknownSelector(name)) if name == "missing"
747 ));
748 }
749
750 #[test]
751 fn rejects_unknown_fields() {
752 let error = Config::from_toml(
753 r#"
754version = 1
755surprise = true
756"#,
757 )
758 .unwrap_err();
759 assert!(matches!(error, ConfigError::Parse { .. }));
760 assert!(error.span().is_some());
761 assert!(error.render().contains("at 3:1"));
762 }
763
764 #[test]
765 fn rejects_unknown_rule_fields() {
766 let error = Config::from_toml(
767 r#"
768version = 1
769
770[selectors.domain]
771packages = ["domain"]
772
773[[rule]]
774id = "broken"
775kind = "forbid-dependencies"
776from = "domain"
777to = "domain"
778surprise = true
779"#,
780 )
781 .unwrap_err();
782 assert!(matches!(error, ConfigError::Parse { .. }));
783 }
784
785 #[test]
786 fn rejects_an_empty_target_set() {
787 let error = Config::from_toml(
788 r#"
789version = 1
790
791[analysis]
792target_kinds = []
793"#,
794 )
795 .unwrap_err();
796 assert!(matches!(error, ConfigError::EmptyTargetKinds));
797 }
798}