Skip to main content

cgx_engine/
rules.rs

1use std::path::Path;
2
3use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, Serialize, Deserialize)]
6pub struct Rule {
7    pub name: String,
8    #[serde(default)]
9    pub description: String,
10    pub severity: String,
11    #[serde(default)]
12    pub query: Option<String>,
13    #[serde(default)]
14    pub built_in: Option<String>,
15    #[serde(default)]
16    pub threshold: Option<f64>,
17}
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct RulesConfig {
21    #[serde(default)]
22    pub rules: Vec<Rule>,
23}
24
25impl RulesConfig {
26    pub fn load(repo_root: &Path) -> anyhow::Result<Self> {
27        let rules_path = repo_root.join(".cgx").join("rules.toml");
28        if !rules_path.exists() {
29            return Ok(Self { rules: Vec::new() });
30        }
31        let content = std::fs::read_to_string(&rules_path)?;
32        let config: Self = toml::from_str(&content)?;
33        Ok(config)
34    }
35}
36
37#[derive(Debug, Clone)]
38pub struct RuleViolation {
39    pub rule_name: String,
40    pub severity: String,
41    pub message: String,
42    pub file: Option<String>,
43    pub line: Option<u32>,
44}
45
46#[derive(Debug, Clone)]
47pub struct RuleResult {
48    pub rule: Rule,
49    pub violations: Vec<RuleViolation>,
50    pub error: Option<String>,
51}
52
53impl RuleResult {
54    pub fn passed(&self) -> bool {
55        self.violations.is_empty() && self.error.is_none()
56    }
57}
58
59pub fn run_rules(
60    db: &crate::graph::GraphDb,
61    rules: &[Rule],
62    filter_name: Option<&str>,
63) -> Vec<RuleResult> {
64    rules
65        .iter()
66        .filter(|r| filter_name.is_none_or(|n| r.name == n))
67        .map(|rule| run_single_rule(db, rule))
68        .collect()
69}
70
71fn run_single_rule(db: &crate::graph::GraphDb, rule: &Rule) -> RuleResult {
72    if let Some(ref builtin) = rule.built_in {
73        run_builtin_rule(db, rule, builtin)
74    } else if let Some(ref query) = rule.query {
75        run_sql_rule(db, rule, query)
76    } else {
77        RuleResult {
78            rule: rule.clone(),
79            violations: Vec::new(),
80            error: Some("Rule has neither 'query' nor 'built_in' key".to_string()),
81        }
82    }
83}
84
85fn run_builtin_rule(db: &crate::graph::GraphDb, rule: &Rule, builtin: &str) -> RuleResult {
86    match builtin {
87        "no_cycles" => run_no_cycles(db, rule),
88        "max_coupling" => run_max_coupling(db, rule),
89        "max_complexity" => run_max_complexity(db, rule),
90        "require_docs_for_public" => run_require_docs(db, rule),
91        _ => RuleResult {
92            rule: rule.clone(),
93            violations: Vec::new(),
94            error: Some(format!("Unknown built-in rule: {}", builtin)),
95        },
96    }
97}
98
99fn run_no_cycles(db: &crate::graph::GraphDb, rule: &Rule) -> RuleResult {
100    // Detect cycles using DFS on IMPORTS edges
101    let mut stmt = match db.conn.prepare(
102        "SELECT DISTINCT src, dst FROM edges WHERE kind = 'IMPORTS' AND src != dst",
103    ) {
104        Ok(s) => s,
105        Err(e) => {
106            return RuleResult {
107                rule: rule.clone(),
108                violations: Vec::new(),
109                error: Some(format!("Query failed: {}", e)),
110            }
111        }
112    };
113
114    let mapped = match stmt.query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))) {
115        Ok(m) => m,
116        Err(_) => {
117            return RuleResult {
118                rule: rule.clone(),
119                violations: Vec::new(),
120                error: None,
121            }
122        }
123    };
124    let edges: Vec<(String, String)> = mapped.filter_map(|r| r.ok()).collect();
125
126    // Build adjacency list
127    let mut adj: std::collections::HashMap<String, Vec<String>> =
128        std::collections::HashMap::new();
129    for (src, dst) in &edges {
130        adj.entry(src.clone()).or_default().push(dst.clone());
131    }
132
133    // DFS cycle detection
134    let mut visited: std::collections::HashSet<String> = std::collections::HashSet::new();
135    let mut in_stack: std::collections::HashSet<String> = std::collections::HashSet::new();
136    let mut cycles: Vec<String> = Vec::new();
137
138    let nodes: Vec<String> = adj.keys().cloned().collect();
139    for node in &nodes {
140        if !visited.contains(node) {
141            detect_cycle(node, &adj, &mut visited, &mut in_stack, &mut cycles);
142        }
143    }
144
145    let violations: Vec<RuleViolation> = cycles
146        .into_iter()
147        .take(10)
148        .map(|cycle| RuleViolation {
149            rule_name: rule.name.clone(),
150            severity: rule.severity.clone(),
151            message: format!("Circular import detected: {}", cycle),
152            file: None,
153            line: None,
154        })
155        .collect();
156
157    RuleResult {
158        rule: rule.clone(),
159        violations,
160        error: None,
161    }
162}
163
164fn detect_cycle(
165    node: &str,
166    adj: &std::collections::HashMap<String, Vec<String>>,
167    visited: &mut std::collections::HashSet<String>,
168    in_stack: &mut std::collections::HashSet<String>,
169    cycles: &mut Vec<String>,
170) {
171    visited.insert(node.to_string());
172    in_stack.insert(node.to_string());
173
174    if let Some(neighbors) = adj.get(node) {
175        for neighbor in neighbors {
176            if !visited.contains(neighbor) {
177                detect_cycle(neighbor, adj, visited, in_stack, cycles);
178            } else if in_stack.contains(neighbor) {
179                cycles.push(format!("{} -> {}", node, neighbor));
180            }
181        }
182    }
183
184    in_stack.remove(node);
185}
186
187fn run_max_coupling(db: &crate::graph::GraphDb, rule: &Rule) -> RuleResult {
188    let threshold = rule.threshold.unwrap_or(30.0) as i64;
189    let mut stmt = match db.conn.prepare(
190        "SELECT name, path, in_degree FROM nodes WHERE kind != 'Author' AND in_degree > ? ORDER BY in_degree DESC LIMIT 20",
191    ) {
192        Ok(s) => s,
193        Err(e) => {
194            return RuleResult {
195                rule: rule.clone(),
196                violations: Vec::new(),
197                error: Some(format!("Query failed: {}", e)),
198            }
199        }
200    };
201
202    let mapped = match stmt.query_map(duckdb::params![threshold], |row| {
203        Ok((
204            row.get::<_, String>(0)?,
205            row.get::<_, String>(1)?,
206            row.get::<_, i64>(2)?,
207        ))
208    }) {
209        Ok(m) => m,
210        Err(e) => {
211            return RuleResult {
212                rule: rule.clone(),
213                violations: Vec::new(),
214                error: Some(format!("Query failed: {}", e)),
215            }
216        }
217    };
218    let violations: Vec<RuleViolation> = mapped
219        .filter_map(|r| r.ok())
220        .map(|(name, path, degree)| RuleViolation {
221            rule_name: rule.name.clone(),
222            severity: rule.severity.clone(),
223            message: format!("{} has {} callers (threshold: {})", name, degree, threshold),
224            file: Some(path),
225            line: None,
226        })
227        .collect();
228
229    RuleResult {
230        rule: rule.clone(),
231        violations,
232        error: None,
233    }
234}
235
236fn run_max_complexity(db: &crate::graph::GraphDb, rule: &Rule) -> RuleResult {
237    let threshold = rule.threshold.unwrap_or(0.3);
238    let mut stmt = match db.conn.prepare(
239        "SELECT name, path, complexity FROM nodes WHERE kind = 'Function' AND complexity > ? ORDER BY complexity DESC LIMIT 20",
240    ) {
241        Ok(s) => s,
242        Err(e) => {
243            return RuleResult {
244                rule: rule.clone(),
245                violations: Vec::new(),
246                error: Some(format!("Query failed: {}", e)),
247            }
248        }
249    };
250
251    let mapped = match stmt.query_map(duckdb::params![threshold], |row| {
252        Ok((
253            row.get::<_, String>(0)?,
254            row.get::<_, String>(1)?,
255            row.get::<_, f64>(2)?,
256        ))
257    }) {
258        Ok(m) => m,
259        Err(e) => {
260            return RuleResult {
261                rule: rule.clone(),
262                violations: Vec::new(),
263                error: Some(format!("Query failed: {}", e)),
264            }
265        }
266    };
267    let violations: Vec<RuleViolation> = mapped
268        .filter_map(|r| r.ok())
269        .map(|(name, path, complexity)| RuleViolation {
270            rule_name: rule.name.clone(),
271            severity: rule.severity.clone(),
272            message: format!(
273                "{} has complexity {:.2} (threshold: {:.2})",
274                name, complexity, threshold
275            ),
276            file: Some(path),
277            line: None,
278        })
279        .collect();
280
281    RuleResult {
282        rule: rule.clone(),
283        violations,
284        error: None,
285    }
286}
287
288fn run_require_docs(db: &crate::graph::GraphDb, rule: &Rule) -> RuleResult {
289    let mut stmt = match db.conn.prepare(
290        "SELECT name, path FROM nodes WHERE kind IN ('Function','Class') AND exported = 1 AND (doc_comment IS NULL OR doc_comment = '') ORDER BY name LIMIT 50",
291    ) {
292        Ok(s) => s,
293        Err(e) => {
294            return RuleResult {
295                rule: rule.clone(),
296                violations: Vec::new(),
297                error: Some(format!("Query failed: {}", e)),
298            }
299        }
300    };
301
302    let mapped = match stmt.query_map([], |row| {
303        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
304    }) {
305        Ok(m) => m,
306        Err(e) => {
307            return RuleResult {
308                rule: rule.clone(),
309                violations: Vec::new(),
310                error: Some(format!("Query failed: {}", e)),
311            }
312        }
313    };
314    let violations: Vec<RuleViolation> = mapped
315        .filter_map(|r| r.ok())
316        .map(|(name, path)| RuleViolation {
317            rule_name: rule.name.clone(),
318            severity: rule.severity.clone(),
319            message: format!("Public {} has no doc comment", name),
320            file: Some(path),
321            line: None,
322        })
323        .collect();
324
325    RuleResult {
326        rule: rule.clone(),
327        violations,
328        error: None,
329    }
330}
331
332fn run_sql_rule(db: &crate::graph::GraphDb, rule: &Rule, sql: &str) -> RuleResult {
333    let mut stmt = match db.conn.prepare(sql) {
334        Ok(s) => s,
335        Err(e) => {
336            return RuleResult {
337                rule: rule.clone(),
338                violations: Vec::new(),
339                error: Some(format!("SQL error: {}", e)),
340            }
341        }
342    };
343
344    // Execute and collect all rows as Vec<String> — read each column as String
345    let rows = stmt.query_map([], |row| {
346        // Try to read up to 10 columns; stop at first error
347        let mut parts = Vec::new();
348        for i in 0..10usize {
349            match row.get::<_, Option<String>>(i) {
350                Ok(Some(s)) => parts.push(s),
351                Ok(None) => parts.push(String::new()),
352                Err(_) => {
353                    // Try integer
354                    match row.get::<_, i64>(i) {
355                        Ok(v) => parts.push(v.to_string()),
356                        Err(_) => break,
357                    }
358                }
359            }
360        }
361        Ok(parts)
362    });
363
364    let violations: Vec<RuleViolation> = match rows {
365        Err(e) => {
366            return RuleResult {
367                rule: rule.clone(),
368                violations: Vec::new(),
369                error: Some(format!("Query execution failed: {}", e)),
370            }
371        }
372        Ok(rows) => rows
373            .filter_map(|r| r.ok())
374            .filter(|cols| !cols.is_empty())
375            .map(|cols| {
376                let file = cols.first().cloned();
377                let message = cols.join(", ");
378                RuleViolation {
379                    rule_name: rule.name.clone(),
380                    severity: rule.severity.clone(),
381                    message,
382                    file,
383                    line: None,
384                }
385            })
386            .collect(),
387    };
388
389    RuleResult {
390        rule: rule.clone(),
391        violations,
392        error: None,
393    }
394}