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