Skip to main content

bylaw_config/
lib.rs

1//! Versioned `bylaw.toml` configuration and lowering into core rule specs.
2
3use 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}