Skip to main content

pecto_core/
rules.rs

1use crate::model::*;
2use serde::Deserialize;
3use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
4
5/// Configuration for architecture fitness rules.
6#[derive(Debug, Deserialize)]
7pub struct RulesConfig {
8    #[serde(default = "default_rules")]
9    pub rules: BTreeMap<String, RuleValue>,
10}
11
12#[derive(Debug, Clone, Deserialize)]
13#[serde(untagged)]
14pub enum RuleValue {
15    Enabled(bool),
16    Threshold(u32),
17}
18
19impl RuleValue {
20    fn is_enabled(&self) -> bool {
21        match self {
22            RuleValue::Enabled(b) => *b,
23            RuleValue::Threshold(_) => true,
24        }
25    }
26
27    fn threshold(&self) -> u32 {
28        match self {
29            RuleValue::Threshold(n) => *n,
30            _ => 0,
31        }
32    }
33}
34
35fn default_rules() -> BTreeMap<String, RuleValue> {
36    let mut rules = BTreeMap::new();
37    rules.insert(
38        "no-circular-dependencies".to_string(),
39        RuleValue::Enabled(true),
40    );
41    rules.insert(
42        "controllers-no-direct-db-access".to_string(),
43        RuleValue::Enabled(true),
44    );
45    rules.insert(
46        "all-endpoints-need-authentication".to_string(),
47        RuleValue::Enabled(true),
48    );
49    rules.insert(
50        "no-entity-without-validation".to_string(),
51        RuleValue::Enabled(false),
52    );
53    rules.insert(
54        "max-service-dependencies".to_string(),
55        RuleValue::Threshold(5),
56    );
57    rules
58}
59
60impl Default for RulesConfig {
61    fn default() -> Self {
62        RulesConfig {
63            rules: default_rules(),
64        }
65    }
66}
67
68/// Result of checking a single rule.
69#[derive(Debug)]
70pub struct RuleResult {
71    pub name: String,
72    pub passed: bool,
73    pub violations: Vec<String>,
74}
75
76/// Check all enabled rules against the given spec.
77pub fn check_rules(spec: &ProjectSpec, config: &RulesConfig) -> Vec<RuleResult> {
78    let mut results = Vec::new();
79
80    for (name, value) in &config.rules {
81        if !value.is_enabled() {
82            continue;
83        }
84
85        let result = match name.as_str() {
86            "no-circular-dependencies" => check_no_circular_deps(spec),
87            "controllers-no-direct-db-access" => check_controllers_no_db(spec),
88            "all-endpoints-need-authentication" => check_all_endpoints_auth(spec),
89            "no-entity-without-validation" => check_entity_validation(spec),
90            "max-service-dependencies" => check_max_service_deps(spec, value.threshold()),
91            _ => RuleResult {
92                name: name.clone(),
93                passed: true,
94                violations: vec![format!("Unknown rule: {}", name)],
95            },
96        };
97
98        results.push(result);
99    }
100
101    results
102}
103
104// ─── Rule implementations ───
105
106/// No circular dependencies in the dependency graph.
107fn check_no_circular_deps(spec: &ProjectSpec) -> RuleResult {
108    let mut violations = Vec::new();
109
110    // Build adjacency list
111    let mut graph: HashMap<&str, Vec<&str>> = HashMap::new();
112    for dep in &spec.dependencies {
113        graph
114            .entry(dep.from.as_str())
115            .or_default()
116            .push(dep.to.as_str());
117    }
118
119    // DFS cycle detection
120    let mut visited = HashSet::new();
121    let mut in_stack = HashSet::new();
122
123    for node in graph.keys() {
124        if !visited.contains(node) {
125            let mut path = Vec::new();
126            if has_cycle(node, &graph, &mut visited, &mut in_stack, &mut path) {
127                violations.push(format!("Cycle: {}", path.join(" → ")));
128            }
129        }
130    }
131
132    RuleResult {
133        name: "no-circular-dependencies".to_string(),
134        passed: violations.is_empty(),
135        violations,
136    }
137}
138
139fn has_cycle<'a>(
140    node: &'a str,
141    graph: &HashMap<&'a str, Vec<&'a str>>,
142    visited: &mut HashSet<&'a str>,
143    in_stack: &mut HashSet<&'a str>,
144    path: &mut Vec<String>,
145) -> bool {
146    visited.insert(node);
147    in_stack.insert(node);
148    path.push(node.to_string());
149
150    if let Some(neighbors) = graph.get(node) {
151        for &neighbor in neighbors {
152            if !visited.contains(neighbor) {
153                if has_cycle(neighbor, graph, visited, in_stack, path) {
154                    return true;
155                }
156            } else if in_stack.contains(neighbor) {
157                path.push(neighbor.to_string());
158                return true;
159            }
160        }
161    }
162
163    in_stack.remove(node);
164    path.pop();
165    false
166}
167
168/// Controllers should not directly depend on entities/repositories (should go through services).
169fn check_controllers_no_db(spec: &ProjectSpec) -> RuleResult {
170    let mut violations = Vec::new();
171
172    // Identify controller and entity/repository capabilities
173    let controllers: BTreeSet<&str> = spec
174        .capabilities
175        .iter()
176        .filter(|c| !c.endpoints.is_empty())
177        .map(|c| c.name.as_str())
178        .collect();
179
180    let db_caps: BTreeSet<&str> = spec
181        .capabilities
182        .iter()
183        .filter(|c| {
184            !c.entities.is_empty() || c.name.contains("repository") || c.name.contains("context")
185        })
186        .map(|c| c.name.as_str())
187        .collect();
188
189    for dep in &spec.dependencies {
190        if controllers.contains(dep.from.as_str()) && db_caps.contains(dep.to.as_str()) {
191            violations.push(format!(
192                "{} → {} (controller directly accesses data layer)",
193                dep.from, dep.to
194            ));
195        }
196    }
197
198    RuleResult {
199        name: "controllers-no-direct-db-access".to_string(),
200        passed: violations.is_empty(),
201        violations,
202    }
203}
204
205/// All endpoints must have authentication configured.
206fn check_all_endpoints_auth(spec: &ProjectSpec) -> RuleResult {
207    let mut violations = Vec::new();
208
209    for cap in &spec.capabilities {
210        for ep in &cap.endpoints {
211            let has_auth = ep
212                .security
213                .as_ref()
214                .is_some_and(|s| s.authentication.is_some());
215            if !has_auth {
216                let method = format!("{:?}", ep.method).to_uppercase();
217                violations.push(format!(
218                    "{} {} ({}) — no authentication",
219                    method, ep.path, cap.name
220                ));
221            }
222        }
223    }
224
225    RuleResult {
226        name: "all-endpoints-need-authentication".to_string(),
227        passed: violations.is_empty(),
228        violations,
229    }
230}
231
232/// Endpoints with request body input should have validation rules.
233fn check_entity_validation(spec: &ProjectSpec) -> RuleResult {
234    let mut violations = Vec::new();
235
236    for cap in &spec.capabilities {
237        for ep in &cap.endpoints {
238            let has_body = ep.input.as_ref().is_some_and(|i| i.body.is_some());
239            let has_validation = !ep.validation.is_empty();
240
241            if has_body && !has_validation {
242                let method = format!("{:?}", ep.method).to_uppercase();
243                violations.push(format!(
244                    "{} {} ({}) — has request body but no validation",
245                    method, ep.path, cap.name
246                ));
247            }
248        }
249    }
250
251    RuleResult {
252        name: "no-entity-without-validation".to_string(),
253        passed: violations.is_empty(),
254        violations,
255    }
256}
257
258/// No service should have more than N outgoing dependencies.
259fn check_max_service_deps(spec: &ProjectSpec, max: u32) -> RuleResult {
260    let mut violations = Vec::new();
261
262    // Count outgoing deps per capability
263    let mut dep_count: HashMap<&str, u32> = HashMap::new();
264    for dep in &spec.dependencies {
265        *dep_count.entry(dep.from.as_str()).or_default() += 1;
266    }
267
268    // Only check service capabilities
269    let services: BTreeSet<&str> = spec
270        .capabilities
271        .iter()
272        .filter(|c| !c.operations.is_empty())
273        .map(|c| c.name.as_str())
274        .collect();
275
276    for (name, count) in &dep_count {
277        if services.contains(name) && *count > max {
278            violations.push(format!(
279                "{} has {} dependencies (max: {})",
280                name, count, max
281            ));
282        }
283    }
284
285    RuleResult {
286        name: "max-service-dependencies".to_string(),
287        passed: violations.is_empty(),
288        violations,
289    }
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295
296    fn config_with(rule: &str, value: RuleValue) -> RulesConfig {
297        let mut rules = BTreeMap::new();
298        rules.insert(rule.to_string(), value);
299        RulesConfig { rules }
300    }
301
302    #[test]
303    fn test_no_circular_deps_pass() {
304        let mut spec = ProjectSpec::new("test".to_string());
305        spec.dependencies.push(DependencyEdge {
306            from: "a".to_string(),
307            to: "b".to_string(),
308            kind: DependencyKind::Calls,
309            references: Vec::new(),
310        });
311        spec.dependencies.push(DependencyEdge {
312            from: "b".to_string(),
313            to: "c".to_string(),
314            kind: DependencyKind::Calls,
315            references: Vec::new(),
316        });
317
318        let config = config_with("no-circular-dependencies", RuleValue::Enabled(true));
319        let results = check_rules(&spec, &config);
320        assert!(results[0].passed);
321    }
322
323    #[test]
324    fn test_no_circular_deps_fail() {
325        let mut spec = ProjectSpec::new("test".to_string());
326        spec.dependencies.push(DependencyEdge {
327            from: "a".to_string(),
328            to: "b".to_string(),
329            kind: DependencyKind::Calls,
330            references: Vec::new(),
331        });
332        spec.dependencies.push(DependencyEdge {
333            from: "b".to_string(),
334            to: "a".to_string(),
335            kind: DependencyKind::Calls,
336            references: Vec::new(),
337        });
338
339        let config = config_with("no-circular-dependencies", RuleValue::Enabled(true));
340        let results = check_rules(&spec, &config);
341        assert!(!results[0].passed);
342        assert!(results[0].violations[0].contains("Cycle"));
343    }
344
345    #[test]
346    fn test_controllers_no_db_pass() {
347        let mut spec = ProjectSpec::new("test".to_string());
348        let mut controller = Capability::new("users".to_string(), "controller.ts".to_string());
349        controller.endpoints.push(Endpoint {
350            method: HttpMethod::Get,
351            path: "/users".to_string(),
352            input: None,
353            validation: Vec::new(),
354            behaviors: Vec::new(),
355            security: None,
356        });
357        let service = Capability::new("users-service".to_string(), "service.ts".to_string());
358        spec.capabilities.push(controller);
359        spec.capabilities.push(service);
360        spec.dependencies.push(DependencyEdge {
361            from: "users".to_string(),
362            to: "users-service".to_string(),
363            kind: DependencyKind::Calls,
364            references: Vec::new(),
365        });
366
367        let config = config_with("controllers-no-direct-db-access", RuleValue::Enabled(true));
368        let results = check_rules(&spec, &config);
369        assert!(results[0].passed);
370    }
371
372    #[test]
373    fn test_controllers_no_db_fail() {
374        let mut spec = ProjectSpec::new("test".to_string());
375        let mut controller = Capability::new("users".to_string(), "controller.ts".to_string());
376        controller.endpoints.push(Endpoint {
377            method: HttpMethod::Get,
378            path: "/users".to_string(),
379            input: None,
380            validation: Vec::new(),
381            behaviors: Vec::new(),
382            security: None,
383        });
384        let mut entity = Capability::new("user-entity".to_string(), "entity.ts".to_string());
385        entity.entities.push(Entity {
386            name: "User".to_string(),
387            table: "users".to_string(),
388            fields: Vec::new(),
389            bases: Vec::new(),
390        });
391        spec.capabilities.push(controller);
392        spec.capabilities.push(entity);
393        spec.dependencies.push(DependencyEdge {
394            from: "users".to_string(),
395            to: "user-entity".to_string(),
396            kind: DependencyKind::Queries,
397            references: Vec::new(),
398        });
399
400        let config = config_with("controllers-no-direct-db-access", RuleValue::Enabled(true));
401        let results = check_rules(&spec, &config);
402        assert!(!results[0].passed);
403    }
404
405    #[test]
406    fn test_all_endpoints_auth_fail() {
407        let mut spec = ProjectSpec::new("test".to_string());
408        let mut cap = Capability::new("users".to_string(), "controller.ts".to_string());
409        cap.endpoints.push(Endpoint {
410            method: HttpMethod::Post,
411            path: "/users".to_string(),
412            input: None,
413            validation: Vec::new(),
414            behaviors: Vec::new(),
415            security: None, // no auth!
416        });
417        spec.capabilities.push(cap);
418
419        let config = config_with(
420            "all-endpoints-need-authentication",
421            RuleValue::Enabled(true),
422        );
423        let results = check_rules(&spec, &config);
424        assert!(!results[0].passed);
425        assert!(results[0].violations[0].contains("POST"));
426    }
427
428    #[test]
429    fn test_max_service_deps_pass() {
430        let mut spec = ProjectSpec::new("test".to_string());
431        let mut svc = Capability::new("order-service".to_string(), "service.ts".to_string());
432        svc.operations.push(Operation {
433            name: "create".to_string(),
434            source_method: "OrderService#create".to_string(),
435            input: None,
436            behaviors: Vec::new(),
437            transaction: None,
438        });
439        spec.capabilities.push(svc);
440        spec.dependencies.push(DependencyEdge {
441            from: "order-service".to_string(),
442            to: "repo".to_string(),
443            kind: DependencyKind::Calls,
444            references: Vec::new(),
445        });
446
447        let config = config_with("max-service-dependencies", RuleValue::Threshold(5));
448        let results = check_rules(&spec, &config);
449        assert!(results[0].passed);
450    }
451
452    #[test]
453    fn test_max_service_deps_fail() {
454        let mut spec = ProjectSpec::new("test".to_string());
455        let mut svc = Capability::new("order-service".to_string(), "service.ts".to_string());
456        svc.operations.push(Operation {
457            name: "create".to_string(),
458            source_method: "OrderService#create".to_string(),
459            input: None,
460            behaviors: Vec::new(),
461            transaction: None,
462        });
463        spec.capabilities.push(svc);
464
465        // Add 3 deps (max is 2)
466        for i in 0..3 {
467            spec.dependencies.push(DependencyEdge {
468                from: "order-service".to_string(),
469                to: format!("dep-{}", i),
470                kind: DependencyKind::Calls,
471                references: Vec::new(),
472            });
473        }
474
475        let config = config_with("max-service-dependencies", RuleValue::Threshold(2));
476        let results = check_rules(&spec, &config);
477        assert!(!results[0].passed);
478        assert!(results[0].violations[0].contains("3 dependencies"));
479    }
480
481    #[test]
482    fn test_disabled_rule_skipped() {
483        let spec = ProjectSpec::new("test".to_string());
484        let config = config_with("no-circular-dependencies", RuleValue::Enabled(false));
485        let results = check_rules(&spec, &config);
486        assert!(results.is_empty());
487    }
488
489    #[test]
490    fn test_default_config() {
491        let config = RulesConfig::default();
492        assert!(config.rules.contains_key("no-circular-dependencies"));
493        assert!(config.rules.contains_key("max-service-dependencies"));
494        // no-entity-without-validation should be disabled by default
495        assert!(!config.rules["no-entity-without-validation"].is_enabled());
496    }
497}