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