1use 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
98pub 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}