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