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
102        .conn
103        .prepare("SELECT DISTINCT src, dst FROM edges WHERE kind = 'IMPORTS' AND src != dst")
104    {
105        Ok(s) => s,
106        Err(e) => {
107            return RuleResult {
108                rule: rule.clone(),
109                violations: Vec::new(),
110                error: Some(format!("Query failed: {}", e)),
111            }
112        }
113    };
114
115    let mapped = match stmt.query_map([], |row| {
116        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
117    }) {
118        Ok(m) => m,
119        Err(_) => {
120            return RuleResult {
121                rule: rule.clone(),
122                violations: Vec::new(),
123                error: None,
124            }
125        }
126    };
127    let edges: Vec<(String, String)> = mapped.filter_map(|r| r.ok()).collect();
128
129    // Build adjacency list
130    let mut adj: std::collections::HashMap<String, Vec<String>> = std::collections::HashMap::new();
131    for (src, dst) in &edges {
132        adj.entry(src.clone()).or_default().push(dst.clone());
133    }
134
135    // DFS cycle detection
136    let mut visited: std::collections::HashSet<String> = std::collections::HashSet::new();
137    let mut in_stack: std::collections::HashSet<String> = std::collections::HashSet::new();
138    let mut cycles: Vec<String> = Vec::new();
139
140    let nodes: Vec<String> = adj.keys().cloned().collect();
141    for node in &nodes {
142        if !visited.contains(node) {
143            detect_cycle(node, &adj, &mut visited, &mut in_stack, &mut cycles);
144        }
145    }
146
147    let violations: Vec<RuleViolation> = cycles
148        .into_iter()
149        .take(10)
150        .map(|cycle| RuleViolation {
151            rule_name: rule.name.clone(),
152            severity: rule.severity.clone(),
153            message: format!("Circular import detected: {}", cycle),
154            file: None,
155            line: None,
156        })
157        .collect();
158
159    RuleResult {
160        rule: rule.clone(),
161        violations,
162        error: None,
163    }
164}
165
166fn detect_cycle(
167    node: &str,
168    adj: &std::collections::HashMap<String, Vec<String>>,
169    visited: &mut std::collections::HashSet<String>,
170    in_stack: &mut std::collections::HashSet<String>,
171    cycles: &mut Vec<String>,
172) {
173    visited.insert(node.to_string());
174    in_stack.insert(node.to_string());
175
176    if let Some(neighbors) = adj.get(node) {
177        for neighbor in neighbors {
178            if !visited.contains(neighbor) {
179                detect_cycle(neighbor, adj, visited, in_stack, cycles);
180            } else if in_stack.contains(neighbor) {
181                cycles.push(format!("{} -> {}", node, neighbor));
182            }
183        }
184    }
185
186    in_stack.remove(node);
187}
188
189fn run_max_coupling(db: &crate::graph::GraphDb, rule: &Rule) -> RuleResult {
190    let threshold = rule.threshold.unwrap_or(30.0) as i64;
191    let mut stmt = match db.conn.prepare(
192        "SELECT name, path, in_degree FROM nodes WHERE kind != 'Author' AND in_degree > ? ORDER BY in_degree DESC LIMIT 20",
193    ) {
194        Ok(s) => s,
195        Err(e) => {
196            return RuleResult {
197                rule: rule.clone(),
198                violations: Vec::new(),
199                error: Some(format!("Query failed: {}", e)),
200            }
201        }
202    };
203
204    let mapped = match stmt.query_map(duckdb::params![threshold], |row| {
205        Ok((
206            row.get::<_, String>(0)?,
207            row.get::<_, String>(1)?,
208            row.get::<_, i64>(2)?,
209        ))
210    }) {
211        Ok(m) => m,
212        Err(e) => {
213            return RuleResult {
214                rule: rule.clone(),
215                violations: Vec::new(),
216                error: Some(format!("Query failed: {}", e)),
217            }
218        }
219    };
220    let violations: Vec<RuleViolation> = mapped
221        .filter_map(|r| r.ok())
222        .map(|(name, path, degree)| RuleViolation {
223            rule_name: rule.name.clone(),
224            severity: rule.severity.clone(),
225            message: format!("{} has {} callers (threshold: {})", name, degree, threshold),
226            file: Some(path),
227            line: None,
228        })
229        .collect();
230
231    RuleResult {
232        rule: rule.clone(),
233        violations,
234        error: None,
235    }
236}
237
238fn run_max_complexity(db: &crate::graph::GraphDb, rule: &Rule) -> RuleResult {
239    let threshold = rule.threshold.unwrap_or(0.3);
240    let mut stmt = match db.conn.prepare(
241        "SELECT name, path, complexity FROM nodes WHERE kind = 'Function' AND complexity > ? ORDER BY complexity DESC LIMIT 20",
242    ) {
243        Ok(s) => s,
244        Err(e) => {
245            return RuleResult {
246                rule: rule.clone(),
247                violations: Vec::new(),
248                error: Some(format!("Query failed: {}", e)),
249            }
250        }
251    };
252
253    let mapped = match stmt.query_map(duckdb::params![threshold], |row| {
254        Ok((
255            row.get::<_, String>(0)?,
256            row.get::<_, String>(1)?,
257            row.get::<_, f64>(2)?,
258        ))
259    }) {
260        Ok(m) => m,
261        Err(e) => {
262            return RuleResult {
263                rule: rule.clone(),
264                violations: Vec::new(),
265                error: Some(format!("Query failed: {}", e)),
266            }
267        }
268    };
269    let violations: Vec<RuleViolation> = mapped
270        .filter_map(|r| r.ok())
271        .map(|(name, path, complexity)| RuleViolation {
272            rule_name: rule.name.clone(),
273            severity: rule.severity.clone(),
274            message: format!(
275                "{} has complexity {:.2} (threshold: {:.2})",
276                name, complexity, threshold
277            ),
278            file: Some(path),
279            line: None,
280        })
281        .collect();
282
283    RuleResult {
284        rule: rule.clone(),
285        violations,
286        error: None,
287    }
288}
289
290fn run_require_docs(db: &crate::graph::GraphDb, rule: &Rule) -> RuleResult {
291    let mut stmt = match db.conn.prepare(
292        "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",
293    ) {
294        Ok(s) => s,
295        Err(e) => {
296            return RuleResult {
297                rule: rule.clone(),
298                violations: Vec::new(),
299                error: Some(format!("Query failed: {}", e)),
300            }
301        }
302    };
303
304    let mapped = match stmt.query_map([], |row| {
305        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
306    }) {
307        Ok(m) => m,
308        Err(e) => {
309            return RuleResult {
310                rule: rule.clone(),
311                violations: Vec::new(),
312                error: Some(format!("Query failed: {}", e)),
313            }
314        }
315    };
316    let violations: Vec<RuleViolation> = mapped
317        .filter_map(|r| r.ok())
318        .map(|(name, path)| RuleViolation {
319            rule_name: rule.name.clone(),
320            severity: rule.severity.clone(),
321            message: format!("Public {} has no doc comment", name),
322            file: Some(path),
323            line: None,
324        })
325        .collect();
326
327    RuleResult {
328        rule: rule.clone(),
329        violations,
330        error: None,
331    }
332}
333
334fn run_sql_rule(db: &crate::graph::GraphDb, rule: &Rule, sql: &str) -> RuleResult {
335    let mut stmt = match db.conn.prepare(sql) {
336        Ok(s) => s,
337        Err(e) => {
338            return RuleResult {
339                rule: rule.clone(),
340                violations: Vec::new(),
341                error: Some(format!("SQL error: {}", e)),
342            }
343        }
344    };
345
346    // Execute and collect all rows as Vec<String> — read each column as String
347    let rows = stmt.query_map([], |row| {
348        // Try to read up to 10 columns; stop at first error
349        let mut parts = Vec::new();
350        for i in 0..10usize {
351            match row.get::<_, Option<String>>(i) {
352                Ok(Some(s)) => parts.push(s),
353                Ok(None) => parts.push(String::new()),
354                Err(_) => {
355                    // Try integer
356                    match row.get::<_, i64>(i) {
357                        Ok(v) => parts.push(v.to_string()),
358                        Err(_) => break,
359                    }
360                }
361            }
362        }
363        Ok(parts)
364    });
365
366    let violations: Vec<RuleViolation> = match rows {
367        Err(e) => {
368            return RuleResult {
369                rule: rule.clone(),
370                violations: Vec::new(),
371                error: Some(format!("Query execution failed: {}", e)),
372            }
373        }
374        Ok(rows) => rows
375            .filter_map(|r| r.ok())
376            .filter(|cols| !cols.is_empty())
377            .map(|cols| {
378                let file = cols.first().cloned();
379                let message = cols.join(", ");
380                RuleViolation {
381                    rule_name: rule.name.clone(),
382                    severity: rule.severity.clone(),
383                    message,
384                    file,
385                    line: None,
386                }
387            })
388            .collect(),
389    };
390
391    RuleResult {
392        rule: rule.clone(),
393        violations,
394        error: None,
395    }
396}