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