1use crate::model::*;
2use serde::Deserialize;
3use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
4
5#[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#[derive(Debug)]
70pub struct RuleResult {
71 pub name: String,
72 pub passed: bool,
73 pub violations: Vec<String>,
74}
75
76pub 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
104fn check_no_circular_deps(spec: &ProjectSpec) -> RuleResult {
108 let mut violations = Vec::new();
109
110 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 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
168fn check_controllers_no_db(spec: &ProjectSpec) -> RuleResult {
170 let mut violations = Vec::new();
171
172 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
205fn 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
232fn 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
258fn check_max_service_deps(spec: &ProjectSpec, max: u32) -> RuleResult {
260 let mut violations = Vec::new();
261
262 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 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, });
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 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 assert!(!config.rules["no-entity-without-validation"].is_enabled());
496 }
497}