Skip to main content

lean_ctx/core/
smells.rs

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