1use rusqlite::Connection;
8use serde::Serialize;
9
10use crate::core::property_graph::GraphSource;
11
12#[derive(Debug, Clone, Serialize)]
13pub struct SmellFinding {
14 pub rule: &'static str,
15 pub severity: Severity,
16 pub file_path: String,
17 pub symbol: Option<String>,
18 pub line: Option<usize>,
19 pub message: String,
20 pub metric: Option<f64>,
21}
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
24#[serde(rename_all = "lowercase")]
25pub enum Severity {
26 Info,
27 Warning,
28 Error,
29}
30
31#[derive(Debug, Clone, Serialize)]
32pub struct SmellSummary {
33 pub rule: &'static str,
34 pub description: &'static str,
35 pub findings: usize,
36}
37
38pub struct SmellConfig {
39 pub long_function_lines: usize,
40 pub long_file_lines: usize,
41 pub god_file_symbols: usize,
42 pub fan_out_threshold: usize,
43}
44
45impl Default for SmellConfig {
46 fn default() -> Self {
47 Self {
48 long_function_lines: 100,
49 long_file_lines: 500,
50 god_file_symbols: 30,
51 fan_out_threshold: 15,
52 }
53 }
54}
55
56pub static RULES: &[(&str, &str)] = &[
57 ("dead_code", "Symbols defined but never referenced"),
58 ("long_function", "Functions exceeding line threshold"),
59 ("long_file", "Files exceeding line threshold"),
60 ("god_file", "Files with excessive symbol count"),
61 ("fan_out_skew", "Functions calling too many other symbols"),
62 (
63 "duplicate_definitions",
64 "Same symbol name defined in multiple files",
65 ),
66 (
67 "untested_function",
68 "Exported symbols without test coverage",
69 ),
70 (
71 "cyclomatic_complexity",
72 "Functions with high branching complexity",
73 ),
74];
75
76pub fn scan_all<S: GraphSource + ?Sized>(source: &S, cfg: &SmellConfig) -> Vec<SmellFinding> {
81 let mut all = Vec::new();
82 for &(rule, _) in RULES {
83 all.extend(scan_rule(source, rule, cfg));
84 }
85 all
86}
87
88pub fn scan_rule<S: GraphSource + ?Sized>(
89 source: &S,
90 rule: &str,
91 cfg: &SmellConfig,
92) -> Vec<SmellFinding> {
93 let conn = source.store();
94 if rule == "duplicate_definitions" {
95 return detect_duplicate_definitions(
96 conn,
97 source.checks_sources(),
98 &|path: &str, name: &str| source.symbol_served(path, name),
99 );
100 }
101 match rule {
102 "dead_code" => detect_dead_code(conn),
103 "long_function" => detect_long_functions(conn, cfg.long_function_lines),
104 "long_file" => detect_long_files(conn, cfg.long_file_lines),
105 "god_file" => detect_god_files(conn, cfg.god_file_symbols),
106 "fan_out_skew" => detect_fan_out(conn, cfg.fan_out_threshold),
107 "untested_function" => detect_untested(conn),
108 "cyclomatic_complexity" => detect_cyclomatic_complexity(conn),
109 _ => Vec::new(),
110 }
111}
112
113pub fn summarize(findings: &[SmellFinding]) -> Vec<SmellSummary> {
114 RULES
115 .iter()
116 .map(|&(rule, desc)| SmellSummary {
117 rule,
118 description: desc,
119 findings: findings.iter().filter(|f| f.rule == rule).count(),
120 })
121 .collect()
122}
123
124fn detect_dead_code(conn: &Connection) -> Vec<SmellFinding> {
133 let sql = "
134 SELECT n.name, p.path, n.line_start,
135 EXISTS(
136 SELECT 1 FROM edges e2
137 WHERE e2.source_id = n.id AND e2.kind = 'exports'
138 ) AS exported,
139 EXISTS(
140 SELECT 1 FROM edges e3
141 JOIN nodes f ON f.id = e3.target_id
142 WHERE e3.kind = 'imports'
143 AND f.kind = 'file'
144 AND f.file_id = n.file_id
145 ) AS file_imported
146 FROM nodes n
147 JOIN paths p ON p.id = n.file_id
148 WHERE n.kind = 'symbol'
149 AND p.path NOT LIKE '%test%'
150 AND p.path NOT LIKE '%spec%'
151 AND n.name NOT IN ('main', 'new', 'default', 'fmt', 'drop', '<module>')
152 AND n.id NOT IN (
153 SELECT DISTINCT e.target_id FROM edges e
154 WHERE e.kind IN ('calls', 'type_ref', 'imports')
155 )
156 ORDER BY p.path, n.line_start
157 LIMIT 200
158 ";
159 let mut findings = Vec::new();
160 let Ok(mut stmt) = conn.prepare(sql) else {
161 return findings;
162 };
163 let Ok(rows) = stmt.query_map([], |row| {
164 Ok((
165 row.get::<_, String>(0)?,
166 row.get::<_, String>(1)?,
167 row.get::<_, Option<i64>>(2)?,
168 row.get::<_, bool>(3)?,
169 row.get::<_, bool>(4)?,
170 ))
171 }) else {
172 return findings;
173 };
174 for (name, path, line, exported, file_imported) in rows.flatten() {
175 let (severity, confidence) = if exported && file_imported {
176 (
177 Severity::Info,
178 "low confidence: exported and its module is imported elsewhere — \
179 may be referenced via an unresolved import or dynamic access",
180 )
181 } else if exported {
182 (
183 Severity::Warning,
184 "high confidence: exported but its module is never imported",
185 )
186 } else {
187 (
188 Severity::Warning,
189 "high confidence: private symbol with no references",
190 )
191 };
192 findings.push(SmellFinding {
193 rule: "dead_code",
194 severity,
195 file_path: path.clone(),
196 symbol: Some(name.clone()),
197 line: line.map(|l| l as usize),
198 message: format!("'{name}' defined in {path} but never referenced ({confidence})"),
199 metric: None,
200 });
201 }
202 findings
203}
204
205fn detect_long_functions(conn: &Connection, threshold: usize) -> Vec<SmellFinding> {
206 let sql = format!(
207 "SELECT n.name, p.path, n.line_start,
208 (n.line_end - n.line_start) AS span
209 FROM nodes n
210 JOIN paths p ON p.id = n.file_id
211 WHERE n.kind = 'symbol'
212 AND n.line_start IS NOT NULL
213 AND n.line_end IS NOT NULL
214 AND (n.line_end - n.line_start) > {threshold}
215 ORDER BY span DESC
216 LIMIT 100"
217 );
218 query_findings_with_metric(
219 conn,
220 &sql,
221 "long_function",
222 Severity::Warning,
223 |name, _path, _line, metric| {
224 format!("'{name}' is {metric:.0} lines (threshold: {threshold})")
225 },
226 )
227}
228
229fn detect_long_files(conn: &Connection, threshold: usize) -> Vec<SmellFinding> {
230 let sql = format!(
231 "SELECT n.name, p.path, NULL,
232 CAST(n.metadata AS INTEGER) AS line_count
233 FROM nodes n
234 JOIN paths p ON p.id = n.file_id
235 WHERE n.kind = 'file'
236 AND n.metadata IS NOT NULL
237 AND CAST(n.metadata AS INTEGER) > {threshold}
238 ORDER BY line_count DESC
239 LIMIT 100"
240 );
241 query_findings_with_metric(
242 conn,
243 &sql,
244 "long_file",
245 Severity::Info,
246 |_name, path, _line, metric| {
247 format!("{path} has {metric:.0} lines (threshold: {threshold})")
248 },
249 )
250}
251
252fn detect_god_files(conn: &Connection, threshold: usize) -> Vec<SmellFinding> {
253 let sql = format!(
254 "SELECT COUNT(*) AS sym_count, p.path
255 FROM nodes n
256 JOIN paths p ON p.id = n.file_id
257 WHERE n.kind = 'symbol'
258 GROUP BY p.path
259 HAVING sym_count > {threshold}
260 ORDER BY sym_count DESC
261 LIMIT 50"
262 );
263 let mut findings = Vec::new();
264 let Ok(mut stmt) = conn.prepare(&sql) else {
265 return findings;
266 };
267 let Ok(rows) = stmt.query_map([], |row| {
268 Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
269 }) else {
270 return findings;
271 };
272 for row in rows.flatten() {
273 let (count, path) = row;
274 findings.push(SmellFinding {
275 rule: "god_file",
276 severity: Severity::Warning,
277 file_path: path.clone(),
278 symbol: None,
279 line: None,
280 message: format!("{path} has {count} symbols (threshold: {threshold})"),
281 metric: Some(count as f64),
282 });
283 }
284 findings
285}
286
287fn detect_fan_out(conn: &Connection, threshold: usize) -> Vec<SmellFinding> {
288 let sql = format!(
289 "SELECT n.name, p.path, n.line_start, COUNT(e.id) AS call_count
290 FROM nodes n
291 JOIN paths p ON p.id = n.file_id
292 JOIN edges e ON e.source_id = n.id AND e.kind = 'calls'
293 WHERE n.kind = 'symbol'
294 GROUP BY n.id
295 HAVING call_count > {threshold}
296 ORDER BY call_count DESC
297 LIMIT 100"
298 );
299 query_findings_with_metric(
300 conn,
301 &sql,
302 "fan_out_skew",
303 Severity::Warning,
304 |name, _path, _line, metric| {
305 format!("'{name}' calls {metric:.0} symbols (threshold: {threshold})")
306 },
307 )
308}
309
310fn detect_duplicate_definitions(
321 conn: &Connection,
322 checked: bool,
323 served: &dyn Fn(&str, &str) -> bool,
324) -> Vec<SmellFinding> {
325 let sql = "
326 SELECT n.name, p.path
327 FROM nodes n
328 JOIN paths p ON p.id = n.file_id
329 WHERE n.kind = 'symbol'
330 AND n.name NOT IN ('new', 'default', 'fmt', 'from', 'into', 'drop', 'clone', 'eq')
331 AND n.name IN (
332 SELECT name FROM nodes WHERE kind = 'symbol' GROUP BY name HAVING COUNT(*) > 1
333 )
334 ORDER BY n.name, p.path
335 ";
336 let Ok(mut stmt) = conn.prepare(sql) else {
337 return Vec::new();
338 };
339 let Ok(rows) = stmt.query_map([], |row| {
340 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
341 }) else {
342 return Vec::new();
343 };
344 let mut definitions: std::collections::BTreeMap<String, Vec<String>> =
345 std::collections::BTreeMap::new();
346 for (name, path) in rows.flatten() {
347 if served(&path, &name) {
348 definitions.entry(name).or_default().push(path);
349 }
350 }
351 let mut duplicated: Vec<(String, Vec<String>)> = definitions
352 .into_iter()
353 .filter(|(_, files)| files.len() > 1)
354 .collect();
355 duplicated.sort_by(|a, b| b.1.len().cmp(&a.1.len()).then_with(|| a.0.cmp(&b.0)));
357 duplicated
358 .into_iter()
359 .take(50)
360 .map(|(name, files)| {
361 let count = files.len();
362 let listed = files.join(", ");
363 SmellFinding {
364 rule: "duplicate_definitions",
365 severity: Severity::Info,
366 file_path: if checked {
367 files[0].clone()
368 } else {
369 listed.clone()
370 },
371 message: format!("'{name}' defined in {count} files: {listed}"),
372 symbol: Some(name),
373 line: None,
374 metric: Some(count as f64),
375 }
376 })
377 .collect()
378}
379
380fn detect_untested(conn: &Connection) -> Vec<SmellFinding> {
381 let sql = "
382 SELECT n.name, p.path, n.line_start
383 FROM nodes n
384 JOIN paths p ON p.id = n.file_id
385 WHERE n.kind = 'symbol'
386 AND p.path NOT LIKE '%test%'
387 AND p.path NOT LIKE '%spec%'
388 AND n.metadata LIKE '%export%'
389 AND n.id NOT IN (
390 SELECT DISTINCT e.source_id FROM edges e WHERE e.kind = 'tested_by'
391 )
392 AND n.id NOT IN (
393 SELECT DISTINCT e.target_id FROM edges e WHERE e.kind = 'tested_by'
394 )
395 ORDER BY p.path, n.line_start
396 LIMIT 100
397 ";
398 query_findings(
399 conn,
400 sql,
401 "untested_function",
402 Severity::Info,
403 |name, path, _line| format!("'{name}' in {path} has no test coverage"),
404 )
405}
406
407fn detect_cyclomatic_complexity(conn: &Connection) -> Vec<SmellFinding> {
408 #[cfg(feature = "tree-sitter")]
409 {
410 detect_cyclomatic_tree_sitter(conn)
411 }
412 #[cfg(not(feature = "tree-sitter"))]
413 {
414 detect_cyclomatic_heuristic(conn)
415 }
416}
417
418#[cfg(not(feature = "tree-sitter"))]
420fn detect_cyclomatic_heuristic(conn: &Connection) -> Vec<SmellFinding> {
421 let sql = "
422 SELECT n.name, p.path, n.line_start,
423 (n.line_end - n.line_start) AS span,
424 (SELECT COUNT(*) FROM edges e WHERE e.source_id = n.id AND e.kind = 'calls') AS calls
425 FROM nodes n
426 JOIN paths p ON p.id = n.file_id
427 WHERE n.kind = 'symbol'
428 AND n.line_start IS NOT NULL
429 AND n.line_end IS NOT NULL
430 AND (n.line_end - n.line_start) > 20
431 ORDER BY (span * 0.3 + calls * 0.7) DESC
432 LIMIT 100
433 ";
434 let mut findings = Vec::new();
435 let Ok(mut stmt) = conn.prepare(sql) else {
436 return findings;
437 };
438 let Ok(rows) = stmt.query_map([], |row| {
439 Ok((
440 row.get::<_, String>(0)?,
441 row.get::<_, String>(1)?,
442 row.get::<_, Option<i64>>(2)?,
443 row.get::<_, i64>(3)?,
444 row.get::<_, i64>(4)?,
445 ))
446 }) else {
447 return findings;
448 };
449 for row in rows.flatten() {
450 let (name, path, line, span, calls) = row;
451 let complexity_proxy = (span as f64) * 0.3 + (calls as f64) * 0.7;
452 if complexity_proxy < 10.0 {
453 continue;
454 }
455 let severity = if complexity_proxy > 30.0 {
456 Severity::Error
457 } else if complexity_proxy > 20.0 {
458 Severity::Warning
459 } else {
460 Severity::Info
461 };
462 findings.push(SmellFinding {
463 rule: "cyclomatic_complexity",
464 severity,
465 file_path: path,
466 symbol: Some(name.clone()),
467 line: line.map(|l| l as usize),
468 message: format!(
469 "'{name}' complexity proxy {complexity_proxy:.1} (span={span}, calls={calls})"
470 ),
471 metric: Some(complexity_proxy),
472 });
473 }
474 findings
475}
476
477#[cfg(feature = "tree-sitter")]
478fn detect_cyclomatic_tree_sitter(conn: &Connection) -> Vec<SmellFinding> {
479 use std::collections::HashMap;
480 use std::path::Path;
481
482 const WARN_CC: u32 = 11;
483 const ERR_CC: u32 = 21;
484
485 let sql = "
486 SELECT DISTINCT p.path
487 FROM nodes n
488 JOIN paths p ON p.id = n.file_id
489 WHERE n.kind = 'symbol'
490 AND p.path IS NOT NULL
491 AND length(trim(p.path)) > 0
492 LIMIT 400
493 ";
494 let mut paths = Vec::new();
495 let Ok(mut stmt) = conn.prepare(sql) else {
496 return Vec::new();
497 };
498 let Ok(rows) = stmt.query_map([], |row| row.get::<_, String>(0)) else {
499 return Vec::new();
500 };
501 for row in rows.flatten() {
502 paths.push(row);
503 }
504
505 let mut per_file: HashMap<String, Vec<crate::core::cyclomatic::FunctionComplexity>> =
506 HashMap::new();
507
508 for path in paths {
509 if per_file.contains_key(&path) {
510 continue;
511 }
512 let Ok(content) = std::fs::read_to_string(&path) else {
513 continue;
514 };
515 let Some(ext) = Path::new(&path).extension().and_then(|e| e.to_str()) else {
516 continue;
517 };
518 let Some(metrics) = crate::core::cyclomatic::cyclomatic_per_function(&content, ext) else {
519 continue;
520 };
521 per_file.insert(path, metrics);
522 }
523
524 let mut findings = Vec::new();
525 for (path, metrics) in per_file {
526 for m in metrics {
527 if m.cyclomatic < WARN_CC {
528 continue;
529 }
530 let severity = if m.cyclomatic >= ERR_CC {
531 Severity::Error
532 } else {
533 Severity::Warning
534 };
535 findings.push(SmellFinding {
536 rule: "cyclomatic_complexity",
537 severity,
538 file_path: path.clone(),
539 symbol: Some(m.name.clone()),
540 line: Some(m.line),
541 message: format!(
542 "'{}' cyclomatic complexity {} (thresholds: warning {WARN_CC}, error {ERR_CC})",
543 m.name, m.cyclomatic
544 ),
545 metric: Some(f64::from(m.cyclomatic)),
546 });
547 }
548 }
549
550 findings.sort_by(|a, b| {
551 b.metric
552 .unwrap_or(0.0)
553 .partial_cmp(&a.metric.unwrap_or(0.0))
554 .unwrap_or(std::cmp::Ordering::Equal)
555 });
556 findings.truncate(100);
557 findings
558}
559
560fn query_findings(
561 conn: &Connection,
562 sql: &str,
563 rule: &'static str,
564 severity: Severity,
565 msg_fn: impl Fn(&str, &str, Option<usize>) -> String,
566) -> Vec<SmellFinding> {
567 let mut findings = Vec::new();
568 let Ok(mut stmt) = conn.prepare(sql) else {
569 return findings;
570 };
571 let Ok(rows) = stmt.query_map([], |row| {
572 Ok((
573 row.get::<_, String>(0)?,
574 row.get::<_, String>(1)?,
575 row.get::<_, Option<i64>>(2)?,
576 ))
577 }) else {
578 return findings;
579 };
580 for row in rows.flatten() {
581 let (name, path, line) = row;
582 let line_usize = line.map(|l| l as usize);
583 findings.push(SmellFinding {
584 rule,
585 severity,
586 file_path: path.clone(),
587 symbol: Some(name.clone()),
588 line: line_usize,
589 message: msg_fn(&name, &path, line_usize),
590 metric: None,
591 });
592 }
593 findings
594}
595
596fn query_findings_with_metric(
597 conn: &Connection,
598 sql: &str,
599 rule: &'static str,
600 severity: Severity,
601 msg_fn: impl Fn(&str, &str, Option<usize>, f64) -> String,
602) -> Vec<SmellFinding> {
603 let mut findings = Vec::new();
604 let Ok(mut stmt) = conn.prepare(sql) else {
605 return findings;
606 };
607 let Ok(rows) = stmt.query_map([], |row| {
608 Ok((
609 row.get::<_, String>(0)?,
610 row.get::<_, String>(1)?,
611 row.get::<_, Option<i64>>(2)?,
612 row.get::<_, f64>(3)?,
613 ))
614 }) else {
615 return findings;
616 };
617 for row in rows.flatten() {
618 let (name, path, line, metric) = row;
619 let line_usize = line.map(|l| l as usize);
620 findings.push(SmellFinding {
621 rule,
622 severity,
623 file_path: path.clone(),
624 symbol: Some(name.clone()),
625 line: line_usize,
626 message: msg_fn(&name, &path, line_usize, metric),
627 metric: Some(metric),
628 });
629 }
630 findings
631}
632
633#[cfg(test)]
634mod tests {
635 use super::*;
636 use crate::core::property_graph::{CodeGraph, Edge, EdgeKind, Node, NodeKind};
637
638 fn setup_graph() -> CodeGraph {
639 let g = CodeGraph::open_in_memory().unwrap();
640
641 let file_a = g.upsert_node(&Node::file("src/main.rs")).unwrap();
642 let file_b = g.upsert_node(&Node::file("src/lib.rs")).unwrap();
643 let file_c = g
644 .upsert_node(&Node::file("src/utils.rs").with_metadata("600"))
645 .unwrap();
646
647 let sym_used = g
648 .upsert_node(
649 &Node::symbol("process", "src/lib.rs", NodeKind::Symbol).with_lines(10, 50),
650 )
651 .unwrap();
652 let sym_dead = g
653 .upsert_node(
654 &Node::symbol("unused_helper", "src/lib.rs", NodeKind::Symbol).with_lines(60, 80),
655 )
656 .unwrap();
657 let sym_long = g
658 .upsert_node(
659 &Node::symbol("mega_function", "src/utils.rs", NodeKind::Symbol).with_lines(1, 200),
660 )
661 .unwrap();
662
663 g.upsert_edge(&Edge::new(file_a, file_b, EdgeKind::Imports))
664 .unwrap();
665 g.upsert_edge(&Edge::new(file_a, sym_used, EdgeKind::Calls))
666 .unwrap();
667
668 let _ = sym_dead;
670 let _ = sym_long;
671 let _ = file_c;
672
673 g
674 }
675
676 #[test]
677 fn dead_code_detection() {
678 let g = setup_graph();
679 let findings = detect_dead_code(g.connection());
680 let dead: Vec<_> = findings
681 .iter()
682 .filter(|f| f.symbol.as_deref() == Some("unused_helper"))
683 .collect();
684 assert!(!dead.is_empty(), "Should detect unused_helper as dead code");
685 }
686
687 #[test]
688 fn dead_code_class_with_incoming_call_is_not_flagged() {
689 let g = CodeGraph::open_in_memory().unwrap();
696 let engine_file = g.upsert_node(&Node::file("models/engine.py")).unwrap();
697 let app_file = g.upsert_node(&Node::file("app.py")).unwrap();
698
699 let mk = |name: &str, lo: usize, hi: usize| {
700 g.upsert_node(
701 &Node::symbol(name, "models/engine.py", NodeKind::Symbol).with_lines(lo, hi),
702 )
703 .unwrap()
704 };
705 let engine = mk("Engine", 1, 6);
706 let orphan = mk("Orphan", 8, 12);
707 let private_dead = mk("_private", 14, 18);
708 let module_caller = g
710 .upsert_node(&Node::symbol("<module>", "app.py", NodeKind::Symbol))
711 .unwrap();
712
713 for sym in [engine, orphan, private_dead] {
714 g.upsert_edge(&Edge::new(engine_file, sym, EdgeKind::Defines))
715 .unwrap();
716 }
717 g.upsert_edge(&Edge::new(engine, engine_file, EdgeKind::Exports))
719 .unwrap();
720 g.upsert_edge(&Edge::new(orphan, engine_file, EdgeKind::Exports))
721 .unwrap();
722 g.upsert_edge(&Edge::new(app_file, engine_file, EdgeKind::Imports))
723 .unwrap();
724 g.upsert_edge(&Edge::new(module_caller, engine, EdgeKind::Calls))
726 .unwrap();
727
728 let findings = detect_dead_code(g.connection());
729 let by_name = |n: &str| findings.iter().find(|f| f.symbol.as_deref() == Some(n));
730
731 assert!(
732 by_name("Engine").is_none(),
733 "instantiated class must not be dead"
734 );
735 assert!(
736 by_name("<module>").is_none(),
737 "synthetic <module> must never be flagged"
738 );
739
740 let orphan_f = by_name("Orphan").expect("unused exported class should still be reported");
741 assert_eq!(
742 orphan_f.severity,
743 Severity::Info,
744 "exported + imported module = low confidence (Info)"
745 );
746 assert!(orphan_f.message.contains("low confidence"));
747
748 let priv_f = by_name("_private").expect("unused private symbol should be reported");
749 assert_eq!(
750 priv_f.severity,
751 Severity::Warning,
752 "private symbol = high confidence (Warning)"
753 );
754 assert!(priv_f.message.contains("high confidence"));
755 }
756
757 #[test]
758 fn long_function_detection() {
759 let g = setup_graph();
760 let findings = detect_long_functions(g.connection(), 100);
761 let long: Vec<_> = findings
762 .iter()
763 .filter(|f| f.symbol.as_deref() == Some("mega_function"))
764 .collect();
765 assert!(!long.is_empty(), "Should detect mega_function as too long");
766 }
767
768 #[test]
769 fn long_file_detection() {
770 let g = setup_graph();
771 let findings = detect_long_files(g.connection(), 500);
772 let long: Vec<_> = findings
773 .iter()
774 .filter(|f| f.file_path == "src/utils.rs")
775 .collect();
776 assert!(
777 !long.is_empty(),
778 "Should detect src/utils.rs as long file (600 lines)"
779 );
780 }
781
782 #[test]
783 fn scan_all_returns_findings() {
784 let g = setup_graph();
785 let cfg = SmellConfig::default();
786 let all = scan_all(g.connection(), &cfg);
787 assert!(!all.is_empty(), "Should find at least one smell");
788 }
789
790 #[test]
791 fn summarize_groups_by_rule() {
792 let g = setup_graph();
793 let cfg = SmellConfig::default();
794 let all = scan_all(g.connection(), &cfg);
795 let summary = summarize(&all);
796 assert_eq!(summary.len(), RULES.len());
797 for s in &summary {
798 assert!(!s.description.is_empty(), "`s.description` is empty");
799 }
800 }
801}