Skip to main content

mago_guard/
settings.rs

1use std::fmt;
2
3use foldhash::HashMap;
4use schemars::JsonSchema;
5#[cfg(feature = "serde")]
6use serde::de;
7#[cfg(feature = "serde")]
8use serde::de::Deserializer;
9#[cfg(feature = "serde")]
10use serde::de::MapAccess;
11#[cfg(feature = "serde")]
12use serde::de::Visitor;
13#[cfg(feature = "serde")]
14use serde::ser::SerializeStruct;
15#[cfg(feature = "serde")]
16use serde::ser::Serializer;
17
18#[cfg(feature = "serde")]
19use serde::Deserialize;
20
21use crate::path::NamespacePath;
22use crate::path::Path;
23use crate::path::SymbolSelector;
24#[cfg(feature = "serde")]
25use crate::path::is_valid_identifier_part;
26
27#[derive(Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
28#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
29#[cfg_attr(feature = "serde", serde(default, rename_all = "kebab-case", deny_unknown_fields))]
30pub struct Settings {
31    pub mode: GuardMode,
32    pub perimeter: PerimeterSettings,
33    pub structural: StructuralSettings,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
37#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
38#[cfg_attr(feature = "serde", serde(default, rename_all = "kebab-case", deny_unknown_fields))]
39pub struct PerimeterSettings {
40    pub layers: HashMap<String, Vec<Path>>,
41    pub layering: Vec<NamespacePath>,
42    pub rules: Vec<PerimeterRule>,
43    /// Target-oriented dependency restrictions applied before ordinary perimeter rules.
44    pub restrictions: Vec<DependencyRestriction>,
45}
46
47#[derive(Debug, Clone, PartialEq, Eq, Hash, JsonSchema)]
48#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
49#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
50pub struct PerimeterRule {
51    pub namespace: NamespacePath,
52    pub permit: Vec<PermittedDependency>,
53}
54
55/// Restricts where a dependency may or may not be used.
56#[derive(Debug, Clone, PartialEq, Eq, Hash, JsonSchema)]
57#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
58#[cfg_attr(feature = "serde", serde(rename_all = "kebab-case", deny_unknown_fields))]
59pub struct DependencyRestriction {
60    /// The symbol, namespace, or pattern being restricted.
61    pub dependency: SymbolSelector,
62    /// Source namespace patterns from which the dependency may be used.
63    #[cfg_attr(feature = "serde", serde(default))]
64    pub allow_from: Vec<String>,
65    /// Source namespace patterns from which the dependency may not be used.
66    #[cfg_attr(feature = "serde", serde(default))]
67    pub deny_from: Vec<String>,
68    /// Optional dependency kinds to which this restriction applies. An empty list means all kinds.
69    #[cfg_attr(feature = "serde", serde(default))]
70    pub kinds: Vec<PermittedDependencyKind>,
71}
72
73#[derive(Debug, Clone, PartialEq, Eq, Hash, JsonSchema)]
74#[schemars(untagged)]
75pub enum PermittedDependency {
76    Dependency(Path),
77    DependencyOfKind { path: Path, kinds: Vec<PermittedDependencyKind> },
78}
79
80/// Represents the specific types of symbols allowed from a path.
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, JsonSchema)]
82#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
83#[cfg_attr(feature = "serde", serde(rename_all = "kebab-case"))]
84pub enum PermittedDependencyKind {
85    ClassLike,
86    Function,
87    Constant,
88    Attribute,
89}
90
91#[derive(Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
92#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
93#[cfg_attr(feature = "serde", serde(default, rename_all = "kebab-case", deny_unknown_fields))]
94pub struct StructuralSettings {
95    /// A list of structural rules to enforce across the codebase.
96    pub rules: Vec<StructuralRule>,
97}
98
99/// Represents a single structural enforcement rule from `[[guard.structural.rules]]`.
100#[derive(Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
101#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
102#[cfg_attr(feature = "serde", serde(default, rename_all = "kebab-case", deny_unknown_fields))]
103pub struct StructuralRule {
104    /// The namespace pattern this rule applies to.
105    pub on: String,
106    /// An optional exclusion pattern; if the namespace matches this, the rule is skipped.
107    pub not_on: Option<String>,
108    /// The kind of symbol this policy applies to (e.g., "class").
109    pub target: Option<StructuralSymbolKind>,
110    /// Restricts the namespace to only contain the specified symbol kinds.
111    pub must_be: Option<Vec<StructuralSymbolKind>>,
112    /// Optional naming pattern the symbol's name must match.
113    pub must_be_named: Option<String>,
114    /// If true, the symbol must be declared `final`.
115    pub must_be_final: Option<bool>,
116    /// If true, the symbol must be declared `abstract`.
117    pub must_be_abstract: Option<bool>,
118    /// If true, the symbol must be declared `readonly`.
119    pub must_be_readonly: Option<bool>,
120    /// Structural implementation constraints.
121    pub must_implement: Option<StructuralInheritanceConstraint>,
122    /// Structural extension constraints.
123    pub must_extend: Option<StructuralInheritanceConstraint>,
124    /// Structural trait usage constraints.
125    pub must_use_trait: Option<StructuralInheritanceConstraint>,
126    /// Structural attribute usage constraints.
127    pub must_use_attribute: Option<StructuralInheritanceConstraint>,
128    /// The public methods that matched classes may declare. Private and protected methods are unrestricted.
129    pub only_public_methods: Option<Vec<String>>,
130    /// A human-readable reason for this rule.
131    pub reason: Option<String>,
132}
133
134#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, JsonSchema)]
135#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
136#[cfg_attr(feature = "serde", serde(rename_all = "kebab-case"))]
137pub enum StructuralSymbolKind {
138    ClassLike,
139    Class,
140    Interface,
141    Trait,
142    Enum,
143    Constant,
144    Function,
145}
146
147/// Represents a logical constraint for `implement`, `extend`, or `use_traits`.
148#[derive(Debug, Clone, PartialEq, Eq, Hash, JsonSchema)]
149#[cfg_attr(feature = "serde", derive(serde::Serialize))]
150#[cfg_attr(feature = "serde", serde(untagged))]
151#[schemars(untagged)]
152pub enum StructuralInheritanceConstraint {
153    /// An OR of ANDs, e.g., `[["A", "B"], ["C"]]`
154    AnyOfAllOf(Vec<Vec<String>>),
155    /// An AND group, e.g., `["A", "B"]`
156    AllOf(Vec<String>),
157    /// A single required item, e.g., `"A"`
158    Single(String),
159    /// `None` indicates the rule requires no constraints.
160    Nothing,
161}
162
163impl PermittedDependencyKind {
164    /// Returns the string representation of the symbol type.
165    #[must_use]
166    pub const fn as_str(&self) -> &'static str {
167        match self {
168            PermittedDependencyKind::ClassLike => "class-like",
169            PermittedDependencyKind::Function => "function",
170            PermittedDependencyKind::Constant => "constant",
171            PermittedDependencyKind::Attribute => "attribute",
172        }
173    }
174}
175
176impl StructuralSymbolKind {
177    #[must_use]
178    pub const fn is_constant(&self) -> bool {
179        matches!(self, StructuralSymbolKind::Constant)
180    }
181
182    /// Returns the string representation of the symbol kind.
183    #[must_use]
184    pub const fn as_str(&self) -> &'static str {
185        match self {
186            StructuralSymbolKind::ClassLike => "class-like",
187            StructuralSymbolKind::Class => "class",
188            StructuralSymbolKind::Interface => "interface",
189            StructuralSymbolKind::Trait => "trait",
190            StructuralSymbolKind::Enum => "enum",
191            StructuralSymbolKind::Constant => "constant",
192            StructuralSymbolKind::Function => "function",
193        }
194    }
195}
196
197#[cfg(feature = "serde")]
198impl<'de> serde::Deserialize<'de> for PermittedDependency {
199    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
200    where
201        D: Deserializer<'de>,
202    {
203        struct AllowedPathVisitor;
204
205        impl<'de> Visitor<'de> for AllowedPathVisitor {
206            type Value = PermittedDependency;
207
208            fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
209                formatter.write_str("a path string or a detailed object with path and types")
210            }
211
212            fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
213            where
214                E: de::Error,
215            {
216                let path = Path::deserialize(de::value::StrDeserializer::new(value))?;
217                Ok(PermittedDependency::Dependency(path))
218            }
219
220            fn visit_map<M>(self, map: M) -> Result<Self::Value, M::Error>
221            where
222                M: MapAccess<'de>,
223            {
224                #[cfg_attr(feature = "serde", derive(serde::Deserialize))]
225                struct DetailedHelper {
226                    path: Path,
227                    kinds: Vec<PermittedDependencyKind>,
228                }
229
230                let helper: DetailedHelper = Deserialize::deserialize(de::value::MapAccessDeserializer::new(map))?;
231
232                Ok(PermittedDependency::DependencyOfKind { path: helper.path, kinds: helper.kinds })
233            }
234        }
235
236        deserializer.deserialize_any(AllowedPathVisitor)
237    }
238}
239
240#[cfg(feature = "serde")]
241impl<'de> serde::Deserialize<'de> for StructuralInheritanceConstraint {
242    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
243    where
244        D: Deserializer<'de>,
245    {
246        // Helper enum to let serde handle the shape detection (string vs array vs array of arrays).
247        #[cfg_attr(feature = "serde", derive(serde::Deserialize))]
248        #[cfg_attr(feature = "serde", serde(untagged))]
249        enum Untagged {
250            AnyOfAllOf(Vec<Vec<String>>),
251            AllOf(Vec<String>),
252            Single(String),
253        }
254
255        match Untagged::deserialize(deserializer)? {
256            Untagged::Single(s) => {
257                if s.eq_ignore_ascii_case("@nothing") {
258                    Ok(Self::Nothing)
259                } else if s.split('\\').all(is_valid_identifier_part) {
260                    Ok(Self::Single(s))
261                } else {
262                    Err(de::Error::custom(format!("Expected a valid fully qualified name or '@nothing', found '{s}'")))
263                }
264            }
265            Untagged::AllOf(items) => {
266                for item in &items {
267                    if !item.split('\\').all(is_valid_identifier_part) {
268                        return Err(de::Error::custom(format!("'{item}' is not a valid fully qualified name")));
269                    }
270                }
271
272                Ok(Self::AllOf(items))
273            }
274            Untagged::AnyOfAllOf(groups) => {
275                for group in &groups {
276                    for item in group {
277                        if !item.split('\\').all(is_valid_identifier_part) {
278                            return Err(de::Error::custom(format!("'{item}' is not a valid fully qualified name")));
279                        }
280                    }
281                }
282
283                Ok(Self::AnyOfAllOf(groups))
284            }
285        }
286    }
287}
288
289#[cfg(feature = "serde")]
290impl serde::Serialize for PermittedDependency {
291    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
292    where
293        S: Serializer,
294    {
295        match self {
296            PermittedDependency::Dependency(path) => path.serialize(serializer),
297            PermittedDependency::DependencyOfKind { path, kinds } => {
298                let mut state = serializer.serialize_struct("DependencyOfKind", 2)?;
299                state.serialize_field("path", path)?;
300                state.serialize_field("kinds", kinds)?;
301                state.end()
302            }
303        }
304    }
305}
306
307impl fmt::Display for PermittedDependencyKind {
308    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
309        write!(f, "{}", self.as_str())
310    }
311}
312
313impl fmt::Display for StructuralInheritanceConstraint {
314    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
315        match self {
316            // "nothing"
317            Self::Nothing => write!(f, "<nothing>"),
318            // "`SomeInterface`"
319            Self::Single(item) => write!(f, "`{item}`"),
320            // "`InterfaceA` and `InterfaceB`"
321            Self::AllOf(items) => {
322                let formatted = items.iter().map(|item| format!("`{item}`")).collect::<Vec<_>>().join(" and ");
323                write!(f, "{formatted}")
324            }
325            // "(`InterfaceA` and `InterfaceB`) or `InterfaceC`"
326            Self::AnyOfAllOf(groups) => {
327                let formatted = groups
328                    .iter()
329                    .map(|group| {
330                        let inner = group.iter().map(|item| format!("`{item}`")).collect::<Vec<_>>().join(" and ");
331                        if group.len() > 1 { format!("({inner})") } else { inner }
332                    })
333                    .collect::<Vec<_>>()
334                    .join(" or ");
335                write!(f, "{formatted}")
336            }
337        }
338    }
339}
340
341impl fmt::Display for StructuralSymbolKind {
342    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
343        write!(f, "{}", self.as_str())
344    }
345}
346
347impl PerimeterSettings {
348    /// Returns true if there are no perimeter rules, restrictions, or layering configured.
349    #[must_use]
350    pub fn is_empty(&self) -> bool {
351        self.rules.is_empty() && self.restrictions.is_empty() && self.layering.is_empty()
352    }
353}
354
355impl StructuralSettings {
356    /// Returns true if there are no structural rules configured.
357    #[must_use]
358    pub fn is_empty(&self) -> bool {
359        self.rules.is_empty()
360    }
361}
362
363impl Settings {
364    /// Returns true if perimeter guard has configuration.
365    #[must_use]
366    pub fn has_perimeter_config(&self) -> bool {
367        !self.perimeter.is_empty()
368    }
369
370    /// Returns true if structural guard has configuration.
371    #[must_use]
372    pub fn has_structural_config(&self) -> bool {
373        !self.structural.is_empty()
374    }
375
376    /// Returns whether structural guard should run.
377    ///
378    /// - `None` - mode does not allow structural guard
379    /// - `Some(true)` - should run, configuration exists
380    /// - `Some(false)` - should run but no configuration
381    #[must_use]
382    pub fn should_run_structural(&self) -> Option<bool> {
383        if !self.mode.includes_structural() {
384            return None;
385        }
386
387        Some(self.has_structural_config())
388    }
389
390    /// Returns whether perimeter guard should run.
391    ///
392    /// - `None` - mode does not allow perimeter guard
393    /// - `Some(true)` - should run, configuration exists
394    /// - `Some(false)` - should run but no configuration
395    #[must_use]
396    pub fn should_run_perimeter(&self) -> Option<bool> {
397        if !self.mode.includes_perimeter() {
398            return None;
399        }
400
401        Some(self.has_perimeter_config())
402    }
403}
404
405/// Specifies which guard modes to run.
406#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, JsonSchema)]
407#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
408#[cfg_attr(feature = "serde", serde(rename_all = "kebab-case"))]
409pub enum GuardMode {
410    /// Run both structural and perimeter guards (default)
411    #[default]
412    Default,
413    /// Run only structural guard
414    Structural,
415    /// Run only perimeter guard
416    Perimeter,
417}
418
419impl GuardMode {
420    /// Returns true if the mode includes structural guard.
421    #[must_use]
422    pub const fn includes_structural(&self) -> bool {
423        matches!(self, GuardMode::Default | GuardMode::Structural)
424    }
425
426    /// Returns true if the mode includes perimeter guard.
427    #[must_use]
428    pub const fn includes_perimeter(&self) -> bool {
429        matches!(self, GuardMode::Default | GuardMode::Perimeter)
430    }
431
432    /// Returns the string representation of the guard mode.
433    #[must_use]
434    pub const fn as_str(&self) -> &'static str {
435        match self {
436            GuardMode::Default => "default",
437            GuardMode::Structural => "structural",
438            GuardMode::Perimeter => "perimeter",
439        }
440    }
441}
442
443#[cfg(test)]
444#[allow(clippy::unwrap_used, clippy::expect_used)]
445mod tests {
446    use super::*;
447
448    #[test]
449    fn test_structural_inheritance_constraint_display() {
450        let single = StructuralInheritanceConstraint::Single("SomeInterface".to_string());
451        assert_eq!(single.to_string(), "`SomeInterface`");
452
453        let all_of = StructuralInheritanceConstraint::AllOf(vec!["InterfaceA".to_string(), "InterfaceB".to_string()]);
454        assert_eq!(all_of.to_string(), "`InterfaceA` and `InterfaceB`");
455
456        let any_of_all_of = StructuralInheritanceConstraint::AnyOfAllOf(vec![
457            vec!["InterfaceA".to_string(), "InterfaceB".to_string()],
458            vec!["InterfaceC".to_string()],
459        ]);
460        assert_eq!(any_of_all_of.to_string(), "(`InterfaceA` and `InterfaceB`) or `InterfaceC`");
461
462        let none = StructuralInheritanceConstraint::Nothing;
463        assert_eq!(none.to_string(), "<nothing>");
464    }
465
466    #[cfg(feature = "serde")]
467    #[test]
468    fn deserializes_dependency_restrictions_and_public_method_allowlists() {
469        let toml = r#"
470            [[perimeter.restrictions]]
471            dependency = "App\\Http\\Controllers\\Controller"
472            allow-from = ["App\\Http\\Controllers\\"]
473            kinds = ["class-like"]
474
475            [[structural.rules]]
476            on = "App\\Http\\Controllers\\**"
477            target = "class"
478            only-public-methods = ["__construct", "__invoke"]
479        "#;
480
481        let settings: Settings = toml::from_str(toml).unwrap();
482        let restriction = &settings.perimeter.restrictions[0];
483        assert_eq!(restriction.dependency, SymbolSelector::Symbol("App\\Http\\Controllers\\Controller".to_string()));
484        assert_eq!(restriction.allow_from, ["App\\Http\\Controllers\\"]);
485        assert_eq!(restriction.kinds, [PermittedDependencyKind::ClassLike]);
486        assert_eq!(
487            settings.structural.rules[0].only_public_methods,
488            Some(vec!["__construct".to_string(), "__invoke".to_string()])
489        );
490    }
491
492    #[cfg_attr(feature = "serde", derive(serde::Deserialize))]
493    struct Wrapper {
494        constraint: StructuralInheritanceConstraint,
495    }
496
497    #[test]
498    fn it_deserializes_none_keyword() {
499        let toml = r#"constraint = "@nothing""#;
500        let wrapped: Wrapper = toml::from_str(toml).unwrap();
501        assert_eq!(wrapped.constraint, StructuralInheritanceConstraint::Nothing);
502    }
503
504    #[test]
505    fn it_deserializes_valid_single_string() {
506        let toml = r#"constraint = "App\\Domain\\MyInterface""#;
507        let wrapped: Wrapper = toml::from_str(toml).unwrap();
508        assert_eq!(wrapped.constraint, StructuralInheritanceConstraint::Single("App\\Domain\\MyInterface".to_string()));
509    }
510
511    #[test]
512    fn it_deserializes_valid_array_of_strings() {
513        let toml = r#"constraint = ["App\\InterfaceA", "App\\InterfaceB"]"#;
514        let wrapped: Wrapper = toml::from_str(toml).unwrap();
515        assert_eq!(
516            wrapped.constraint,
517            StructuralInheritanceConstraint::AllOf(vec!["App\\InterfaceA".to_string(), "App\\InterfaceB".to_string()])
518        );
519    }
520
521    #[test]
522    fn it_deserializes_valid_array_of_arrays() {
523        let toml = r#"constraint = [["App\\A", "App\\B"], ["App\\C"]]"#;
524        let wrapped: Wrapper = toml::from_str(toml).unwrap();
525        assert_eq!(
526            wrapped.constraint,
527            StructuralInheritanceConstraint::AnyOfAllOf(vec![
528                vec!["App\\A".to_string(), "App\\B".to_string()],
529                vec!["App\\C".to_string()]
530            ])
531        );
532    }
533
534    #[test]
535    fn it_fails_on_invalid_identifier_in_single_string() {
536        let toml = r#"constraint = "Invalid-Interface""#;
537        assert!(toml::from_str::<Wrapper>(toml).is_err());
538    }
539
540    #[test]
541    fn it_fails_on_invalid_identifier_in_array() {
542        let toml = r#"constraint = ["App\\InterfaceA", "Invalid-Interface"]"#;
543        assert!(toml::from_str::<Wrapper>(toml).is_err());
544    }
545
546    #[test]
547    fn it_fails_on_invalid_identifier_in_nested_array() {
548        let toml = r#"constraint = [["App\\A", "Invalid-B"], ["App\\C"]]"#;
549        assert!(toml::from_str::<Wrapper>(toml).is_err());
550    }
551}