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