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