Skip to main content

cargo_bless/
code_audit.rs

1//! Static Rust code audit for suspicious complexity and brittle patterns.
2
3use std::collections::{HashMap, HashSet};
4use std::fs;
5use std::path::{Path, PathBuf};
6use std::process::Command;
7
8use anyhow::{bail, Context, Result};
9use regex::Regex;
10use serde::{Deserialize, Serialize};
11use tree_sitter::{Node, Parser};
12
13const MAX_FILE_BYTES: u64 = 1024 * 1024;
14
15#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
16pub enum BullshitKind {
17    FakeComplexity,
18    CargoCult,
19    OverEngineering,
20    ArcAbuse,
21    RwLockAbuse,
22    SleepAbuse,
23    UnwrapAbuse,
24    DynTraitAbuse,
25    CloneAbuse,
26    MutexAbuse,
27}
28
29impl BullshitKind {
30    fn label(self) -> &'static str {
31        match self {
32            Self::FakeComplexity => "fake complexity",
33            Self::CargoCult => "cargo cult",
34            Self::OverEngineering => "over-engineering",
35            Self::ArcAbuse => "Arc abuse",
36            Self::RwLockAbuse => "RwLock abuse",
37            Self::SleepAbuse => "sleep abuse",
38            Self::UnwrapAbuse => "unwrap abuse",
39            Self::DynTraitAbuse => "dyn trait abuse",
40            Self::CloneAbuse => "clone abuse",
41            Self::MutexAbuse => "mutex abuse",
42        }
43    }
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct BullshitAlert {
48    pub kind: BullshitKind,
49    pub confidence: f32,
50    pub severity: f32,
51    pub file: PathBuf,
52    pub line: usize,
53    pub column: usize,
54    pub context_snippet: String,
55    pub why_bs: String,
56    pub suggestion: String,
57}
58
59#[derive(Debug, Clone)]
60pub struct CodeAuditConfig {
61    pub confidence_threshold: f32,
62    pub max_file_bytes: u64,
63    pub ignore_paths: Vec<String>,
64    pub ignore_kinds: HashSet<String>,
65}
66
67impl Default for CodeAuditConfig {
68    fn default() -> Self {
69        Self {
70            confidence_threshold: 0.60,
71            max_file_bytes: MAX_FILE_BYTES,
72            ignore_paths: Vec::new(),
73            ignore_kinds: HashSet::new(),
74        }
75    }
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct CodeAuditReport {
80    pub files_scanned: usize,
81    pub alerts: Vec<BullshitAlert>,
82}
83
84impl CodeAuditReport {
85    pub fn is_clean(&self) -> bool {
86        self.alerts.is_empty()
87    }
88}
89
90/// Concatenate workspace member audits into one report (sums `files_scanned`, merges alerts).
91pub fn merge_reports(reports: Vec<CodeAuditReport>) -> CodeAuditReport {
92    let mut files_scanned = 0usize;
93    let mut alerts = Vec::new();
94    for r in reports {
95        files_scanned += r.files_scanned;
96        alerts.extend(r.alerts);
97    }
98    CodeAuditReport {
99        files_scanned,
100        alerts,
101    }
102}
103
104pub fn scan_project(
105    manifest_path: Option<&Path>,
106    config: &CodeAuditConfig,
107) -> Result<CodeAuditReport> {
108    scan_project_with_filter(manifest_path, config, None)
109}
110
111pub fn scan_git_diff(
112    manifest_path: Option<&Path>,
113    config: &CodeAuditConfig,
114) -> Result<CodeAuditReport> {
115    let base_dir = project_base_dir(manifest_path);
116    let filter = DiffFilter::from_git_diff(base_dir)?;
117    scan_project_with_filter(manifest_path, config, Some(&filter))
118}
119
120fn scan_project_with_filter(
121    manifest_path: Option<&Path>,
122    config: &CodeAuditConfig,
123    diff_filter: Option<&DiffFilter>,
124) -> Result<CodeAuditReport> {
125    let base_dir = manifest_path
126        .and_then(Path::parent)
127        .filter(|p| !p.as_os_str().is_empty())
128        .unwrap_or_else(|| Path::new("."));
129
130    let mut files = Vec::new();
131    for dir in ["src", "tests", "examples", "benches"] {
132        collect_rust_files(&base_dir.join(dir), config, &mut files)?;
133    }
134
135    let mut alerts = Vec::new();
136    for file in &files {
137        if is_ignored_path(file, config) {
138            continue;
139        }
140        let code = fs::read_to_string(file)
141            .with_context(|| format!("failed to read {}", file.display()))?;
142        let mut file_alerts = scan_code(&code, file, config)?;
143        if let Some(filter) = diff_filter {
144            file_alerts.retain(|alert| filter.includes(alert));
145        }
146        alerts.extend(file_alerts);
147    }
148
149    alerts.sort_by(|a, b| {
150        b.severity
151            .partial_cmp(&a.severity)
152            .unwrap_or(std::cmp::Ordering::Equal)
153            .then_with(|| a.file.cmp(&b.file))
154            .then_with(|| a.line.cmp(&b.line))
155    });
156
157    Ok(CodeAuditReport {
158        files_scanned: files.len(),
159        alerts,
160    })
161}
162
163pub fn scan_code(
164    code: &str,
165    file: impl Into<PathBuf>,
166    config: &CodeAuditConfig,
167) -> Result<Vec<BullshitAlert>> {
168    let file = file.into();
169    if is_ignored_path(&file, config) {
170        return Ok(Vec::new());
171    }
172
173    let ignored_ranges = parse_ignored_ranges(code).unwrap_or_default();
174    let masked = mask_ranges(code, &ignored_ranges);
175    let mut alerts = Vec::new();
176
177    scan_regex_patterns(&masked, &file, &mut alerts)?;
178    scan_line_patterns(&masked, &file, &mut alerts);
179    scan_function_complexity(&masked, &file, &mut alerts);
180
181    alerts.retain(|alert| alert.confidence >= config.confidence_threshold);
182    alerts.retain(|alert| !config.ignore_kinds.contains(&format!("{:?}", alert.kind)));
183    dedupe_alerts(&mut alerts);
184    Ok(alerts)
185}
186
187pub fn config_from_policy(policy: Option<&crate::policy::Policy>) -> CodeAuditConfig {
188    let mut config = CodeAuditConfig::default();
189    if let Some(policy) = policy {
190        config.ignore_paths = policy.code_audit.ignore_paths.clone();
191        config.ignore_kinds = policy.code_audit.ignore_kinds.iter().cloned().collect();
192        if policy.settings.min_confidence > 0.0 {
193            config.confidence_threshold = policy.settings.min_confidence as f32;
194        }
195    }
196    config
197}
198
199fn project_base_dir(manifest_path: Option<&Path>) -> &Path {
200    manifest_path
201        .and_then(Path::parent)
202        .filter(|p| !p.as_os_str().is_empty())
203        .unwrap_or_else(|| Path::new("."))
204}
205
206fn is_ignored_path(path: &Path, config: &CodeAuditConfig) -> bool {
207    let path = path.to_string_lossy();
208    config
209        .ignore_paths
210        .iter()
211        .any(|pattern| path.contains(pattern))
212}
213
214fn collect_rust_files(
215    dir: &Path,
216    config: &CodeAuditConfig,
217    files: &mut Vec<PathBuf>,
218) -> Result<()> {
219    if !dir.exists() {
220        return Ok(());
221    }
222
223    for entry in fs::read_dir(dir).with_context(|| format!("failed to read {}", dir.display()))? {
224        let entry = entry?;
225        let path = entry.path();
226        let name = entry.file_name();
227        let name = name.to_string_lossy();
228
229        if path.is_dir() {
230            if should_skip_dir(&name) {
231                continue;
232            }
233            collect_rust_files(&path, config, files)?;
234            continue;
235        }
236
237        if path.extension().and_then(|e| e.to_str()) != Some("rs") {
238            continue;
239        }
240
241        let metadata = entry.metadata()?;
242        if metadata.len() <= config.max_file_bytes {
243            files.push(path);
244        }
245    }
246
247    Ok(())
248}
249
250fn should_skip_dir(name: &str) -> bool {
251    name.starts_with('.')
252        || matches!(
253            name,
254            "target" | "vendor" | "node_modules" | "dist" | "build" | "third_party"
255        )
256}
257
258#[derive(Debug)]
259struct DiffFilter {
260    base_dir: PathBuf,
261    changed_lines: HashMap<PathBuf, Vec<(usize, usize)>>,
262}
263
264impl DiffFilter {
265    fn from_git_diff(base_dir: &Path) -> Result<Self> {
266        let output = Command::new("git")
267            .arg("-C")
268            .arg(base_dir)
269            .arg("diff")
270            .arg("HEAD")
271            .arg("--unified=0")
272            .arg("--")
273            .output()
274            .with_context(|| "failed to run git diff HEAD --unified=0")?;
275
276        if !output.status.success() {
277            bail!(
278                "git diff failed: {}",
279                String::from_utf8_lossy(&output.stderr).trim()
280            );
281        }
282
283        Ok(Self {
284            base_dir: base_dir.to_path_buf(),
285            changed_lines: parse_changed_lines(&String::from_utf8_lossy(&output.stdout)),
286        })
287    }
288
289    fn includes(&self, alert: &BullshitAlert) -> bool {
290        let path = alert
291            .file
292            .strip_prefix(&self.base_dir)
293            .map(Path::to_path_buf)
294            .unwrap_or_else(|_| alert.file.clone());
295        let path = normalize_diff_path(&path);
296        self.changed_lines.get(&path).is_some_and(|ranges| {
297            ranges
298                .iter()
299                .any(|(start, end)| alert.line >= *start && alert.line <= *end)
300        })
301    }
302}
303
304fn parse_changed_lines(diff: &str) -> HashMap<PathBuf, Vec<(usize, usize)>> {
305    let mut current_file: Option<PathBuf> = None;
306    let mut changed = HashMap::<PathBuf, Vec<(usize, usize)>>::new();
307
308    for line in diff.lines() {
309        if let Some(path) = line.strip_prefix("+++ b/") {
310            current_file = Some(PathBuf::from(path));
311            continue;
312        }
313        if line.starts_with("+++ /dev/null") {
314            current_file = None;
315            continue;
316        }
317
318        if let (Some(file), Some(range)) = (current_file.as_ref(), parse_hunk_new_range(line)) {
319            changed.entry(file.clone()).or_default().push(range);
320        }
321    }
322
323    changed
324}
325
326fn parse_hunk_new_range(line: &str) -> Option<(usize, usize)> {
327    let hunk = line.strip_prefix("@@ ")?;
328    let plus = hunk.split_whitespace().find(|part| part.starts_with('+'))?;
329    let plus = plus.trim_start_matches('+');
330    let (start, count) = plus
331        .split_once(',')
332        .map(|(start, count)| (start, count.parse::<usize>().ok()))
333        .unwrap_or((plus, Some(1)));
334    let start = start.parse::<usize>().ok()?;
335    let count = count?;
336    if count == 0 {
337        None
338    } else {
339        Some((start, start + count - 1))
340    }
341}
342
343fn normalize_diff_path(path: &Path) -> PathBuf {
344    let mut normalized = PathBuf::new();
345    for component in path.components() {
346        match component {
347            std::path::Component::CurDir => {}
348            other => normalized.push(other.as_os_str()),
349        }
350    }
351    normalized
352}
353
354fn parse_ignored_ranges(code: &str) -> Result<Vec<(usize, usize)>> {
355    let mut parser = Parser::new();
356    parser
357        .set_language(&tree_sitter_rust::LANGUAGE.into())
358        .map_err(|err| anyhow::anyhow!("failed to load Rust tree-sitter grammar: {err}"))?;
359    let tree = parser
360        .parse(code, None)
361        .ok_or_else(|| anyhow::anyhow!("tree-sitter failed to parse Rust source"))?;
362
363    let mut ranges = Vec::new();
364    collect_ignored_ranges(tree.root_node(), &mut ranges);
365    Ok(ranges)
366}
367
368fn collect_ignored_ranges(node: Node<'_>, ranges: &mut Vec<(usize, usize)>) {
369    if is_ignored_node(node.kind()) {
370        ranges.push((node.start_byte(), node.end_byte()));
371        return;
372    }
373
374    let mut cursor = node.walk();
375    for child in node.children(&mut cursor) {
376        collect_ignored_ranges(child, ranges);
377    }
378}
379
380fn is_ignored_node(kind: &str) -> bool {
381    matches!(
382        kind,
383        "line_comment" | "block_comment" | "string_literal" | "raw_string_literal" | "char_literal"
384    )
385}
386
387fn mask_ranges(code: &str, ranges: &[(usize, usize)]) -> String {
388    let mut bytes = code.as_bytes().to_vec();
389    for (start, end) in ranges {
390        for idx in *start..*end {
391            if let Some(byte) = bytes.get_mut(idx) {
392                if *byte != b'\n' {
393                    *byte = b' ';
394                }
395            }
396        }
397    }
398    String::from_utf8(bytes).unwrap_or_else(|_| code.to_string())
399}
400
401fn scan_regex_patterns(code: &str, file: &Path, alerts: &mut Vec<BullshitAlert>) -> Result<()> {
402    let patterns = [
403        (
404            r"Arc\s*<\s*RwLock\s*<",
405            BullshitKind::OverEngineering,
406            0.86,
407            "Arc<RwLock<...>> is often shared mutable state wearing a tuxedo.",
408            "Try explicit ownership, message passing, or a narrower shared state boundary.",
409        ),
410        (
411            r"Arc\s*<\s*Mutex\s*<",
412            BullshitKind::OverEngineering,
413            0.82,
414            "Arc<Mutex<...>> can be valid, but it is also a classic complexity magnet.",
415            "Check whether ownership can stay local or the locked data can be smaller.",
416        ),
417        (
418            r"Mutex\s*<\s*HashMap\s*<",
419            BullshitKind::MutexAbuse,
420            0.76,
421            "A Mutex<HashMap<...>> is a blunt concurrency primitive.",
422            "Consider sharding, DashMap, or reducing shared mutable state.",
423        ),
424        (
425            r"RwLock\s*<",
426            BullshitKind::RwLockAbuse,
427            0.64,
428            "RwLock adds coordination cost and can hide unclear ownership.",
429            "Use it only when read-heavy sharing is real and measured.",
430        ),
431        (
432            r"\b(std::thread::sleep|tokio::time::sleep)\s*\(",
433            BullshitKind::SleepAbuse,
434            0.78,
435            "Sleep calls are often timing bullshit instead of synchronization.",
436            "Replace sleeps with explicit readiness, timeouts, retries, or test clocks.",
437        ),
438        (
439            r"Arc\s*<\s*(String|Vec\s*<|Box\s*<)",
440            BullshitKind::ArcAbuse,
441            0.62,
442            "Arc<String>, Arc<Vec<...>>, or Arc<Box<...>> wraps a value type in shared ownership — often unnecessary.",
443            "Use Arc<str> instead of Arc<String>, or reconsider whether sharing is needed at all.",
444        ),
445    ];
446
447    for (pattern, kind, confidence, why, suggestion) in patterns {
448        let regex = Regex::new(pattern)?;
449        for mat in regex.find_iter(code) {
450            alerts.push(make_alert(
451                kind,
452                confidence,
453                file,
454                code,
455                mat.start(),
456                mat.end(),
457                why,
458                suggestion,
459            ));
460        }
461    }
462
463    Ok(())
464}
465
466fn scan_line_patterns(code: &str, file: &Path, alerts: &mut Vec<BullshitAlert>) {
467    for (line_idx, line) in code.lines().enumerate() {
468        let trimmed = line.trim();
469
470        if let Some(col) = line.find(".unwrap()") {
471            alerts.push(alert_from_line(
472                BullshitKind::UnwrapAbuse,
473                0.72,
474                file,
475                line_idx + 1,
476                col + 1,
477                line,
478                "unwrap() is a runtime trap dressed up as confidence.",
479                "Propagate the error with ?, add context, or handle the failure explicitly.",
480            ));
481        }
482
483        let clone_count = line.matches(".clone()").count();
484        if clone_count >= 2 {
485            alerts.push(alert_from_line(
486                BullshitKind::CloneAbuse,
487                (0.60 + clone_count as f32 * 0.08).min(0.92),
488                file,
489                line_idx + 1,
490                line.find(".clone()").unwrap_or(0) + 1,
491                line,
492                "Multiple clone() calls on one line can hide ownership confusion.",
493                "Check whether borrowing, moving, or restructuring removes the copies.",
494            ));
495        }
496
497        let dyn_count = trimmed.matches("dyn ").count();
498        if dyn_count >= 3 {
499            alerts.push(alert_from_line(
500                BullshitKind::DynTraitAbuse,
501                0.80,
502                file,
503                line_idx + 1,
504                line.find("dyn ").unwrap_or(0) + 1,
505                line,
506                "Heavy dyn usage may be abstraction theater.",
507                "Prefer concrete types or generics unless runtime polymorphism is needed.",
508            ));
509        }
510
511        if trimmed.starts_with("use std::collections::{")
512            && trimmed.contains("HashMap")
513            && trimmed.contains("BTreeMap")
514        {
515            alerts.push(alert_from_line(
516                BullshitKind::CargoCult,
517                0.62,
518                file,
519                line_idx + 1,
520                line.find("HashMap").unwrap_or(0) + 1,
521                line,
522                "Broad collection imports can signal cargo-cult scaffolding.",
523                "Import the collection you actually use, or qualify rare uses inline.",
524            ));
525        }
526    }
527}
528
529fn scan_function_complexity(code: &str, file: &Path, alerts: &mut Vec<BullshitAlert>) {
530    let lines: Vec<&str> = code.lines().collect();
531    let mut idx = 0;
532
533    while idx < lines.len() {
534        let line = lines[idx];
535        if !looks_like_fn_start(line) {
536            idx += 1;
537            continue;
538        }
539
540        let start_line = idx + 1;
541        let mut brace_balance = 0isize;
542        let mut saw_body = false;
543        let mut complexity = 0usize;
544        let mut end_idx = idx;
545
546        while end_idx < lines.len() {
547            let current = lines[end_idx];
548            complexity += line_complexity(current);
549            for ch in current.chars() {
550                if ch == '{' {
551                    saw_body = true;
552                    brace_balance += 1;
553                } else if ch == '}' {
554                    brace_balance -= 1;
555                }
556            }
557            if saw_body && brace_balance <= 0 {
558                break;
559            }
560            end_idx += 1;
561        }
562
563        if saw_body && complexity >= 6 {
564            let confidence = (complexity as f32 / 24.0).clamp(0.66, 0.95);
565            alerts.push(alert_from_line(
566                BullshitKind::FakeComplexity,
567                confidence,
568                file,
569                start_line,
570                line.find("fn").unwrap_or(0) + 1,
571                line,
572                &format!(
573                    "Function complexity score is {complexity}; this smells like fake complexity."
574                ),
575                "Split the function around decisions, loops, and side effects.",
576            ));
577        }
578
579        idx = end_idx.saturating_add(1);
580    }
581}
582
583fn looks_like_fn_start(line: &str) -> bool {
584    let trimmed = line.trim_start();
585    trimmed.starts_with("fn ")
586        || trimmed.starts_with("pub fn ")
587        || trimmed.starts_with("pub(crate) fn ")
588        || trimmed.starts_with("async fn ")
589        || trimmed.starts_with("pub async fn ")
590}
591
592fn line_complexity(line: &str) -> usize {
593    let mut score = 0;
594    let trimmed = line.trim_start();
595    for token in [
596        "if ", "if(", "match ", "for ", "while ", "loop ", "&&", "||",
597    ] {
598        score += line.matches(token).count();
599    }
600    if trimmed.starts_with("if(") {
601        score += 1;
602    }
603    score += line.matches("?;").count();
604    score += line.matches(".unwrap()").count() * 2;
605    score
606}
607
608#[allow(clippy::too_many_arguments)]
609fn make_alert(
610    kind: BullshitKind,
611    confidence: f32,
612    file: &Path,
613    code: &str,
614    start: usize,
615    end: usize,
616    why_bs: &str,
617    suggestion: &str,
618) -> BullshitAlert {
619    let (line, column) = line_column(code, start);
620    BullshitAlert {
621        kind,
622        confidence,
623        severity: confidence,
624        file: file.to_path_buf(),
625        line,
626        column,
627        context_snippet: snippet(code, start, end),
628        why_bs: why_bs.to_string(),
629        suggestion: suggestion.to_string(),
630    }
631}
632
633#[allow(clippy::too_many_arguments)]
634fn alert_from_line(
635    kind: BullshitKind,
636    confidence: f32,
637    file: &Path,
638    line: usize,
639    column: usize,
640    context: &str,
641    why_bs: &str,
642    suggestion: &str,
643) -> BullshitAlert {
644    BullshitAlert {
645        kind,
646        confidence,
647        severity: confidence,
648        file: file.to_path_buf(),
649        line,
650        column,
651        context_snippet: context.trim().to_string(),
652        why_bs: why_bs.to_string(),
653        suggestion: suggestion.to_string(),
654    }
655}
656
657fn line_column(code: &str, byte_pos: usize) -> (usize, usize) {
658    let mut line = 1;
659    let mut col = 1;
660
661    for (idx, ch) in code.char_indices() {
662        if idx >= byte_pos {
663            break;
664        }
665        if ch == '\n' {
666            line += 1;
667            col = 1;
668        } else {
669            col += 1;
670        }
671    }
672
673    (line, col)
674}
675
676fn snippet(code: &str, start: usize, end: usize) -> String {
677    let line_start = code[..start].rfind('\n').map_or(0, |idx| idx + 1);
678    let line_end = code[end..].find('\n').map_or(code.len(), |idx| end + idx);
679    code[line_start..line_end].trim().to_string()
680}
681
682fn dedupe_alerts(alerts: &mut Vec<BullshitAlert>) {
683    alerts.sort_by(|a, b| {
684        a.file
685            .cmp(&b.file)
686            .then_with(|| a.line.cmp(&b.line))
687            .then_with(|| a.column.cmp(&b.column))
688            .then_with(|| format!("{:?}", a.kind).cmp(&format!("{:?}", b.kind)))
689    });
690    alerts.dedup_by(|a, b| {
691        a.file == b.file && a.line == b.line && a.column == b.column && a.kind == b.kind
692    });
693}
694
695pub fn kind_label(kind: BullshitKind) -> &'static str {
696    kind.label()
697}
698
699#[cfg(test)]
700mod tests {
701    use super::*;
702
703    fn config() -> CodeAuditConfig {
704        CodeAuditConfig::default()
705    }
706
707    #[test]
708    fn detects_unwrap_and_sleep() {
709        let code = r#"
710fn main() {
711    let value = thing().unwrap();
712    std::thread::sleep(std::time::Duration::from_millis(10));
713}
714"#;
715        let alerts = scan_code(code, "src/main.rs", &config()).unwrap();
716        assert!(alerts.iter().any(|a| a.kind == BullshitKind::UnwrapAbuse));
717        assert!(alerts.iter().any(|a| a.kind == BullshitKind::SleepAbuse));
718    }
719
720    #[test]
721    fn detects_shared_mutable_state() {
722        let code = "type Store = Arc<RwLock<HashMap<String, String>>>;";
723        let alerts = scan_code(code, "src/lib.rs", &config()).unwrap();
724        assert!(alerts
725            .iter()
726            .any(|a| a.kind == BullshitKind::OverEngineering));
727    }
728
729    #[test]
730    fn detects_fake_complexity() {
731        let code = r#"
732fn tangled(x: usize) -> usize {
733    if x > 1 { if x > 2 { if x > 3 { if x > 4 { if x > 5 { return x; }}}}}
734    match x { 0 => 1, 1 => 2, _ => 3 }
735}
736"#;
737        let alerts = scan_code(code, "src/lib.rs", &config()).unwrap();
738        assert!(alerts
739            .iter()
740            .any(|a| a.kind == BullshitKind::FakeComplexity));
741    }
742
743    #[test]
744    fn ignores_patterns_in_strings_and_comments() {
745        let code = r#"
746fn main() {
747    let text = "Arc<RwLock<HashMap<String, String>>> and thing().unwrap()";
748    // std::thread::sleep(std::time::Duration::from_millis(10));
749}
750"#;
751        let alerts = scan_code(code, "src/main.rs", &config()).unwrap();
752        assert!(
753            alerts.is_empty(),
754            "strings/comments should not produce bullshit alerts: {alerts:?}"
755        );
756    }
757
758    #[test]
759    fn policy_suppresses_kind_and_path() {
760        let mut cfg = config();
761        cfg.ignore_kinds.insert("UnwrapAbuse".to_string());
762        let alerts = scan_code("fn main() { thing().unwrap(); }", "src/main.rs", &cfg).unwrap();
763        assert!(alerts.is_empty());
764
765        let mut cfg = config();
766        cfg.ignore_paths.push("generated".to_string());
767        let alerts = scan_code(
768            "fn main() { thing().unwrap(); }",
769            "src/generated/main.rs",
770            &cfg,
771        )
772        .unwrap();
773        assert!(alerts.is_empty());
774    }
775
776    #[test]
777    fn parses_diff_changed_ranges() {
778        let diff = r#"diff --git a/src/main.rs b/src/main.rs
779index 111..222 100644
780--- a/src/main.rs
781+++ b/src/main.rs
782@@ -1,0 +2,3 @@
783+fn main() {
784+    thing().unwrap();
785+}
786"#;
787        let changed = parse_changed_lines(diff);
788        assert_eq!(changed.get(Path::new("src/main.rs")), Some(&vec![(2, 4)]));
789    }
790}