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