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 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
102pub 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 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 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}