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 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 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 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 let rows = stmt.query_map([], |row| {
346 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 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}