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 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#[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 pub dependency: SymbolSelector,
62 #[cfg_attr(feature = "serde", serde(default))]
64 pub allow_from: Vec<String>,
65 #[cfg_attr(feature = "serde", serde(default))]
67 pub deny_from: Vec<String>,
68 #[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#[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 pub rules: Vec<StructuralRule>,
97}
98
99#[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 pub on: String,
106 pub not_on: Option<String>,
108 pub target: Option<StructuralSymbolKind>,
110 pub must_be: Option<Vec<StructuralSymbolKind>>,
112 pub must_be_named: Option<String>,
114 pub must_be_final: Option<bool>,
116 pub must_be_abstract: Option<bool>,
118 pub must_be_readonly: Option<bool>,
120 pub must_implement: Option<StructuralInheritanceConstraint>,
122 pub must_extend: Option<StructuralInheritanceConstraint>,
124 pub must_use_trait: Option<StructuralInheritanceConstraint>,
126 pub must_use_attribute: Option<StructuralInheritanceConstraint>,
128 pub only_public_methods: Option<Vec<String>>,
130 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#[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 AnyOfAllOf(Vec<Vec<String>>),
155 AllOf(Vec<String>),
157 Single(String),
159 Nothing,
161}
162
163impl PermittedDependencyKind {
164 #[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 #[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 #[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 Self::Nothing => write!(f, "<nothing>"),
318 Self::Single(item) => write!(f, "`{item}`"),
320 Self::AllOf(items) => {
322 let formatted = items.iter().map(|item| format!("`{item}`")).collect::<Vec<_>>().join(" and ");
323 write!(f, "{formatted}")
324 }
325 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 #[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 #[must_use]
358 pub fn is_empty(&self) -> bool {
359 self.rules.is_empty()
360 }
361}
362
363impl Settings {
364 #[must_use]
366 pub fn has_perimeter_config(&self) -> bool {
367 !self.perimeter.is_empty()
368 }
369
370 #[must_use]
372 pub fn has_structural_config(&self) -> bool {
373 !self.structural.is_empty()
374 }
375
376 #[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 #[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#[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 #[default]
412 Default,
413 Structural,
415 Perimeter,
417}
418
419impl GuardMode {
420 #[must_use]
422 pub const fn includes_structural(&self) -> bool {
423 matches!(self, GuardMode::Default | GuardMode::Structural)
424 }
425
426 #[must_use]
428 pub const fn includes_perimeter(&self) -> bool {
429 matches!(self, GuardMode::Default | GuardMode::Perimeter)
430 }
431
432 #[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}