1use std::collections::{BTreeMap, BTreeSet};
2
3use diffguard_types::{Finding, MatchMode, Severity, VerdictCounts};
4
5use crate::overrides::RuleOverrideMatcher;
6use crate::preprocess::{Language, PreprocessOptions, Preprocessor};
7use crate::rules::{CompiledRule, detect_language};
8use crate::suppression::SuppressionTracker;
9
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct InputLine {
12 pub path: String,
13 pub line: u32,
14 pub content: String,
15}
16
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct Evaluation {
19 pub findings: Vec<Finding>,
20 pub counts: VerdictCounts,
21 pub truncated_findings: u32,
22 pub files_scanned: u32,
23 pub lines_scanned: u32,
24 pub rule_hits: Vec<RuleHitStat>,
26}
27
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct RuleHitStat {
30 pub rule_id: String,
31 pub total: u32,
32 pub emitted: u32,
33 pub suppressed: u32,
34 pub info: u32,
35 pub warn: u32,
36 pub error: u32,
37}
38
39#[derive(Debug, Clone)]
40struct PreparedLine {
41 line: InputLine,
42 lang: Option<String>,
43 masked_comments: String,
44 masked_strings: String,
45 masked_both: String,
46 suppressions: crate::suppression::EffectiveSuppressions,
47}
48
49#[derive(Debug, Clone)]
50struct RawMatchEvent {
51 anchor_file_pos: usize,
52 match_start: Option<usize>,
53 match_text: String,
54}
55
56#[derive(Debug, Clone)]
57struct MatchEvent {
58 rule_idx: usize,
59 anchor_idx: usize,
60 match_start: Option<usize>,
61 match_text: String,
62 severity: Severity,
63}
64
65pub fn evaluate_lines(
66 lines: impl IntoIterator<Item = InputLine>,
67 rules: &[CompiledRule],
68 max_findings: usize,
69) -> Evaluation {
70 evaluate_lines_with_overrides_and_language(lines, rules, max_findings, None, None)
71}
72
73pub fn evaluate_lines_with_overrides(
74 lines: impl IntoIterator<Item = InputLine>,
75 rules: &[CompiledRule],
76 max_findings: usize,
77 overrides: Option<&RuleOverrideMatcher>,
78) -> Evaluation {
79 evaluate_lines_with_overrides_and_language(lines, rules, max_findings, overrides, None)
80}
81
82pub fn evaluate_lines_with_overrides_and_language(
83 lines: impl IntoIterator<Item = InputLine>,
84 rules: &[CompiledRule],
85 max_findings: usize,
86 overrides: Option<&RuleOverrideMatcher>,
87 force_language: Option<&str>,
88) -> Evaluation {
89 let input_lines: Vec<InputLine> = lines.into_iter().collect();
90 let mut findings: Vec<Finding> = Vec::new();
91 let mut counts = VerdictCounts::default();
92 let mut truncated_findings: u32 = 0;
93 let mut per_rule_hits = BTreeMap::<String, RuleHitStat>::new();
94
95 let files_seen = input_lines
96 .iter()
97 .map(|line| line.path.clone())
98 .collect::<BTreeSet<_>>();
99 let lines_scanned = (input_lines.len().min(u32::MAX as usize)) as u32;
100
101 let mut current_file: Option<String> = None;
102 let mut current_lang = Language::Unknown;
103 let mut p_comments =
104 Preprocessor::with_language(PreprocessOptions::comments_only(), current_lang);
105 let mut p_strings =
106 Preprocessor::with_language(PreprocessOptions::strings_only(), current_lang);
107 let mut p_both =
108 Preprocessor::with_language(PreprocessOptions::comments_and_strings(), current_lang);
109
110 let forced_language_name = force_language.map(|lang| lang.to_ascii_lowercase());
111 let forced_language_enum =
112 forced_language_name
113 .as_deref()
114 .map(|lang| match lang.parse::<Language>() {
115 Ok(parsed) => parsed,
116 Err(infallible) => match infallible {},
117 });
118
119 let mut suppression_tracker = SuppressionTracker::new();
120 let mut prepared_lines: Vec<PreparedLine> = Vec::with_capacity(input_lines.len());
121 for input in input_lines {
122 if current_file.as_deref() != Some(&input.path) {
123 current_file = Some(input.path.clone());
124 current_lang = if let Some(forced_lang) = forced_language_enum {
125 forced_lang
126 } else {
127 let path = std::path::Path::new(&input.path);
128 detect_language(path)
129 .map(|s| s.parse::<Language>().unwrap_or(Language::Unknown))
130 .unwrap_or(Language::Unknown)
131 };
132
133 p_comments.set_language(current_lang);
134 p_strings.set_language(current_lang);
135 p_both.set_language(current_lang);
136 suppression_tracker.reset();
137 }
138
139 let path = std::path::Path::new(&input.path);
140 let lang = forced_language_name
141 .as_deref()
142 .or_else(|| detect_language(path))
143 .map(ToOwned::to_owned);
144
145 let masked_comments = p_comments.sanitize_line(&input.content);
146 let suppressions = suppression_tracker.process_line(&input.content, &masked_comments);
147 let masked_strings = p_strings.sanitize_line(&input.content);
148 let masked_both = p_both.sanitize_line(&input.content);
149
150 prepared_lines.push(PreparedLine {
151 line: input,
152 lang,
153 masked_comments,
154 masked_strings,
155 masked_both,
156 suppressions,
157 });
158 }
159
160 let mut by_file = BTreeMap::<String, Vec<usize>>::new();
161 for (idx, line) in prepared_lines.iter().enumerate() {
162 by_file.entry(line.line.path.clone()).or_default().push(idx);
163 }
164
165 let mut events: Vec<MatchEvent> = Vec::new();
166
167 for (path, file_indices) in &by_file {
168 if file_indices.is_empty() {
169 continue;
170 }
171
172 let path_ref = std::path::Path::new(path);
173 let lang = prepared_lines[file_indices[0]].lang.as_deref();
174 let mut per_rule_events = vec![Vec::<MatchEvent>::new(); rules.len()];
175
176 for (rule_idx, rule) in rules.iter().enumerate() {
177 if !rule.applies_to(path_ref, lang) {
178 continue;
179 }
180
181 let resolved_override = overrides.map(|m| m.resolve(path, &rule.id));
182 if resolved_override.is_some_and(|resolved| !resolved.enabled) {
183 continue;
184 }
185
186 let base_severity = resolved_override
187 .and_then(|resolved| resolved.severity)
188 .unwrap_or(rule.severity);
189
190 let rule_matches = match rule.match_mode {
191 MatchMode::Any => {
192 find_positive_matches_for_rule(rule, file_indices, &prepared_lines)
193 }
194 MatchMode::Absent => {
195 let positive =
196 find_positive_matches_for_rule(rule, file_indices, &prepared_lines);
197 if positive.is_empty() {
198 vec![RawMatchEvent {
199 anchor_file_pos: 0,
200 match_start: None,
201 match_text: "<absent>".to_string(),
202 }]
203 } else {
204 Vec::new()
205 }
206 }
207 };
208
209 if rule_matches.is_empty() {
210 continue;
211 }
212
213 let mut converted = Vec::with_capacity(rule_matches.len());
214 for matched in rule_matches {
215 let anchor_idx = file_indices[matched.anchor_file_pos];
216 let severity = maybe_escalate_severity(
217 rule,
218 file_indices,
219 matched.anchor_file_pos,
220 &prepared_lines,
221 base_severity,
222 );
223 converted.push(MatchEvent {
224 rule_idx,
225 anchor_idx,
226 match_start: matched.match_start,
227 match_text: matched.match_text,
228 severity,
229 });
230 }
231
232 per_rule_events[rule_idx] = converted;
233 }
234
235 let active_rule_ids = resolve_dependency_gated_rule_ids(rules, &per_rule_events);
236 for (rule_idx, mut matched) in per_rule_events.into_iter().enumerate() {
237 if matched.is_empty() {
238 continue;
239 }
240 if !active_rule_ids.contains(&rules[rule_idx].id) {
241 continue;
242 }
243 events.append(&mut matched);
244 }
245 }
246
247 events.sort_by(|a, b| {
248 a.anchor_idx
249 .cmp(&b.anchor_idx)
250 .then_with(|| a.rule_idx.cmp(&b.rule_idx))
251 .then_with(|| {
252 a.match_start
253 .unwrap_or(usize::MAX)
254 .cmp(&b.match_start.unwrap_or(usize::MAX))
255 })
256 });
257
258 for event in events {
259 let rule = &rules[event.rule_idx];
260 let prepared = &prepared_lines[event.anchor_idx];
261 let stat = per_rule_hits
262 .entry(rule.id.clone())
263 .or_insert_with(|| RuleHitStat {
264 rule_id: rule.id.clone(),
265 total: 0,
266 emitted: 0,
267 suppressed: 0,
268 info: 0,
269 warn: 0,
270 error: 0,
271 });
272 stat.total = stat.total.saturating_add(1);
273
274 if prepared.suppressions.is_suppressed(&rule.id) {
275 counts.suppressed = counts.suppressed.saturating_add(1);
276 stat.suppressed = stat.suppressed.saturating_add(1);
277 continue;
278 }
279
280 bump_counts(&mut counts, event.severity);
281 stat.emitted = stat.emitted.saturating_add(1);
282 match event.severity {
283 Severity::Info => stat.info = stat.info.saturating_add(1),
284 Severity::Warn => stat.warn = stat.warn.saturating_add(1),
285 Severity::Error => stat.error = stat.error.saturating_add(1),
286 }
287
288 if findings.len() < max_findings {
289 let column = event
290 .match_start
291 .and_then(|start| byte_to_column(&prepared.line.content, start))
292 .map(|c| c as u32);
293 findings.push(Finding {
294 rule_id: rule.id.clone(),
295 severity: event.severity,
296 message: rule.message.clone(),
297 path: prepared.line.path.clone(),
298 line: prepared.line.line,
299 column,
300 match_text: event.match_text,
301 snippet: trim_snippet(&prepared.line.content),
302 });
303 } else {
304 truncated_findings = truncated_findings.saturating_add(1);
305 }
306 }
307
308 Evaluation {
309 findings,
310 counts,
311 truncated_findings,
312 files_scanned: files_seen.len() as u32,
313 lines_scanned,
314 rule_hits: per_rule_hits.into_values().collect(),
315 }
316}
317
318fn resolve_dependency_gated_rule_ids(
319 rules: &[CompiledRule],
320 per_rule_events: &[Vec<MatchEvent>],
321) -> BTreeSet<String> {
322 let mut active_rule_ids = rules
323 .iter()
324 .enumerate()
325 .filter(|(idx, _)| !per_rule_events[*idx].is_empty())
326 .map(|(_, rule)| rule.id.clone())
327 .collect::<BTreeSet<_>>();
328
329 loop {
330 let mut removed_any = false;
331 let mut removed_ids = Vec::new();
332 for rule in rules {
333 if !active_rule_ids.contains(&rule.id) {
334 continue;
335 }
336 if rule
337 .depends_on
338 .iter()
339 .any(|dependency| !active_rule_ids.contains(dependency))
340 {
341 removed_ids.push(rule.id.clone());
342 }
343 }
344
345 for id in removed_ids {
346 if active_rule_ids.remove(&id) {
347 removed_any = true;
348 }
349 }
350
351 if !removed_any {
352 break;
353 }
354 }
355
356 active_rule_ids
357}
358
359fn find_positive_matches_for_rule(
360 rule: &CompiledRule,
361 file_indices: &[usize],
362 prepared_lines: &[PreparedLine],
363) -> Vec<RawMatchEvent> {
364 let mut events = if rule.multiline {
365 find_multiline_matches(rule, file_indices, prepared_lines)
366 } else {
367 find_single_line_matches(rule, file_indices, prepared_lines)
368 };
369
370 if !rule.context_patterns.is_empty() {
371 events.retain(|event| {
372 has_required_context(rule, file_indices, event.anchor_file_pos, prepared_lines)
373 });
374 }
375
376 events
377}
378
379fn find_single_line_matches(
380 rule: &CompiledRule,
381 file_indices: &[usize],
382 prepared_lines: &[PreparedLine],
383) -> Vec<RawMatchEvent> {
384 let mut out = Vec::new();
385 for (file_pos, global_idx) in file_indices.iter().copied().enumerate() {
386 let line = &prepared_lines[global_idx];
387 let candidate = candidate_line_for_rule(rule, line);
388 if let Some((start, end)) = first_match(&rule.patterns, candidate) {
389 out.push(RawMatchEvent {
390 anchor_file_pos: file_pos,
391 match_start: Some(start),
392 match_text: safe_slice(&line.line.content, start, end),
393 });
394 }
395 }
396 out
397}
398
399fn find_multiline_matches(
400 rule: &CompiledRule,
401 file_indices: &[usize],
402 prepared_lines: &[PreparedLine],
403) -> Vec<RawMatchEvent> {
404 if file_indices.len() < 2 {
405 return Vec::new();
406 }
407
408 let mut seen = BTreeSet::<(usize, usize, String)>::new();
409 let mut out = Vec::new();
410
411 for start in 0..file_indices.len() {
412 let end = (start + rule.multiline_window).min(file_indices.len());
413 if end.saturating_sub(start) < 2 {
414 continue;
415 }
416
417 let mut joined_candidate = String::new();
418 let mut joined_raw = String::new();
419 let mut offsets = Vec::with_capacity(end - start);
420 let mut cursor = 0usize;
421
422 for (pos, idx) in file_indices
423 .iter()
424 .copied()
425 .enumerate()
426 .take(end)
427 .skip(start)
428 {
429 offsets.push(cursor);
430 let line = &prepared_lines[idx];
431 let candidate = candidate_line_for_rule(rule, line);
432
433 joined_candidate.push_str(candidate);
434 joined_raw.push_str(&line.line.content);
435 cursor = cursor.saturating_add(candidate.len());
436
437 if pos + 1 < end {
438 joined_candidate.push('\n');
439 joined_raw.push('\n');
440 cursor = cursor.saturating_add(1);
441 }
442 }
443
444 if let Some((m_start, m_end)) = first_match(&rule.patterns, &joined_candidate) {
445 let rel = offsets
446 .partition_point(|offset| *offset <= m_start)
447 .saturating_sub(1);
448 let anchor_file_pos = start + rel;
449 let start_in_line = m_start.saturating_sub(offsets[rel]);
450 let match_text = safe_slice(&joined_raw, m_start, m_end);
451 let dedupe_key = (anchor_file_pos, start_in_line, match_text.clone());
452
453 if seen.insert(dedupe_key) {
454 out.push(RawMatchEvent {
455 anchor_file_pos,
456 match_start: Some(start_in_line),
457 match_text,
458 });
459 }
460 }
461 }
462
463 out
464}
465
466fn has_required_context(
467 rule: &CompiledRule,
468 file_indices: &[usize],
469 anchor_file_pos: usize,
470 prepared_lines: &[PreparedLine],
471) -> bool {
472 if rule.context_patterns.is_empty() {
473 return true;
474 }
475
476 let start = anchor_file_pos.saturating_sub(rule.context_window);
477 let end = (anchor_file_pos + rule.context_window + 1).min(file_indices.len());
478 for idx in file_indices[start..end].iter().copied() {
479 let candidate = candidate_line_for_rule(rule, &prepared_lines[idx]);
480 if first_match(&rule.context_patterns, candidate).is_some() {
481 return true;
482 }
483 }
484
485 false
486}
487
488fn maybe_escalate_severity(
489 rule: &CompiledRule,
490 file_indices: &[usize],
491 anchor_file_pos: usize,
492 prepared_lines: &[PreparedLine],
493 base: Severity,
494) -> Severity {
495 if rule.escalate_patterns.is_empty() {
496 return base;
497 }
498
499 let start = anchor_file_pos.saturating_sub(rule.escalate_window);
500 let end = (anchor_file_pos + rule.escalate_window + 1).min(file_indices.len());
501 let should_escalate = file_indices[start..end].iter().copied().any(|idx| {
502 let candidate = candidate_line_for_rule(rule, &prepared_lines[idx]);
503 first_match(&rule.escalate_patterns, candidate).is_some()
504 });
505
506 if !should_escalate {
507 return base;
508 }
509
510 let target = rule.escalate_to.unwrap_or(Severity::Error);
511 max_severity(base, target)
512}
513
514fn candidate_line_for_rule<'a>(rule: &CompiledRule, line: &'a PreparedLine) -> &'a str {
515 match (rule.ignore_comments, rule.ignore_strings) {
516 (true, true) => line.masked_both.as_str(),
517 (true, false) => line.masked_comments.as_str(),
518 (false, true) => line.masked_strings.as_str(),
519 (false, false) => line.line.content.as_str(),
520 }
521}
522
523fn max_severity(a: Severity, b: Severity) -> Severity {
524 fn rank(s: Severity) -> u8 {
525 match s {
526 Severity::Info => 0,
527 Severity::Warn => 1,
528 Severity::Error => 2,
529 }
530 }
531
532 if rank(a) >= rank(b) { a } else { b }
533}
534
535fn first_match(patterns: &[regex::Regex], s: &str) -> Option<(usize, usize)> {
536 for p in patterns {
537 if let Some(m) = p.find(s) {
538 return Some((m.start(), m.end()));
539 }
540 }
541 None
542}
543
544fn bump_counts(counts: &mut VerdictCounts, severity: Severity) {
545 match severity {
546 Severity::Info => counts.info = counts.info.saturating_add(1),
547 Severity::Warn => counts.warn = counts.warn.saturating_add(1),
548 Severity::Error => counts.error = counts.error.saturating_add(1),
549 }
550}
551
552fn trim_snippet(s: &str) -> String {
553 let trimmed = s.trim_end();
554 const MAX_CHARS: usize = 240;
555
556 let mut out = String::new();
558 for (i, ch) in trimmed.chars().enumerate() {
559 if i >= MAX_CHARS {
560 out.push('…');
561 break;
562 }
563 out.push(ch);
564 }
565 out
566}
567
568fn safe_slice(s: &str, start: usize, end: usize) -> String {
569 let end = end.min(s.len());
570 let start = start.min(end);
571 s.get(start..end).unwrap_or("").to_string()
572}
573
574fn byte_to_column(s: &str, byte_idx: usize) -> Option<usize> {
575 if byte_idx > s.len() {
576 return None;
577 }
578 Some(s[..byte_idx].chars().count() + 1)
579}
580
581#[cfg(test)]
582mod tests {
583 use super::*;
584 use crate::rules::compile_rules;
585 use crate::{DirectoryRuleOverride, RuleOverrideMatcher};
586 use diffguard_types::{MatchMode, RuleConfig};
587
588 #[allow(clippy::too_many_arguments)]
590 fn test_rule(
591 id: &str,
592 severity: Severity,
593 message: &str,
594 languages: Vec<&str>,
595 patterns: Vec<&str>,
596 paths: Vec<&str>,
597 exclude_paths: Vec<&str>,
598 ignore_comments: bool,
599 ignore_strings: bool,
600 ) -> RuleConfig {
601 RuleConfig {
602 id: id.to_string(),
603 severity,
604 message: message.to_string(),
605 languages: languages.into_iter().map(|s| s.to_string()).collect(),
606 patterns: patterns.into_iter().map(|s| s.to_string()).collect(),
607 paths: paths.into_iter().map(|s| s.to_string()).collect(),
608 exclude_paths: exclude_paths.into_iter().map(|s| s.to_string()).collect(),
609 ignore_comments,
610 ignore_strings,
611 match_mode: MatchMode::Any,
612 multiline: false,
613 multiline_window: None,
614 context_patterns: vec![],
615 context_window: None,
616 escalate_patterns: vec![],
617 escalate_window: None,
618 escalate_to: None,
619 depends_on: vec![],
620 help: None,
621 url: None,
622 tags: vec![],
623 test_cases: vec![],
624 }
625 }
626
627 #[test]
628 fn finds_unwrap_in_added_line() {
629 let rules = compile_rules(&[test_rule(
630 "rust.no_unwrap",
631 Severity::Error,
632 "no",
633 vec!["rust"],
634 vec!["\\.unwrap\\("],
635 vec!["**/*.rs"],
636 vec![],
637 true,
638 true,
639 )])
640 .unwrap();
641
642 let eval = evaluate_lines(
643 [InputLine {
644 path: "src/lib.rs".to_string(),
645 line: 12,
646 content: "let x = y.unwrap();".to_string(),
647 }],
648 &rules,
649 100,
650 );
651
652 assert_eq!(eval.counts.error, 1);
653 assert_eq!(eval.findings.len(), 1);
654 assert_eq!(eval.findings[0].line, 12);
655 assert!(eval.findings[0].column.is_some());
656 }
657
658 #[test]
659 fn skips_rules_that_do_not_apply_to_language() {
660 let rules = compile_rules(&[test_rule(
661 "python.no_print",
662 Severity::Warn,
663 "no",
664 vec!["python"],
665 vec!["print\\("],
666 vec!["**/*.py"],
667 vec!["**/tests/**"],
668 false,
669 false,
670 )])
671 .unwrap();
672
673 let eval = evaluate_lines(
674 [InputLine {
675 path: "src/lib.rs".to_string(),
676 line: 1,
677 content: "print(\"hello\")".to_string(),
678 }],
679 &rules,
680 100,
681 );
682
683 assert!(eval.findings.is_empty());
684 assert_eq!(eval.counts.warn, 0);
685 }
686
687 #[test]
688 fn does_not_match_in_comment_when_ignored() {
689 let rules = compile_rules(&[test_rule(
690 "rust.no_unwrap",
691 Severity::Error,
692 "no",
693 vec!["rust"],
694 vec!["unwrap"],
695 vec!["**/*.rs"],
696 vec![],
697 true,
698 false,
699 )])
700 .unwrap();
701
702 let eval = evaluate_lines(
703 [InputLine {
704 path: "src/lib.rs".to_string(),
705 line: 1,
706 content: "// unwrap should be ignored".to_string(),
707 }],
708 &rules,
709 100,
710 );
711
712 assert_eq!(eval.counts.error, 0);
713 }
714
715 #[test]
716 fn caps_findings_but_keeps_counts() {
717 let rules = compile_rules(&[test_rule(
718 "r",
719 Severity::Warn,
720 "m",
721 vec![],
722 vec!["x"],
723 vec![],
724 vec![],
725 false,
726 false,
727 )])
728 .unwrap();
729
730 let lines = (0..5).map(|i| InputLine {
731 path: "a.txt".to_string(),
732 line: i,
733 content: "x".to_string(),
734 });
735
736 let eval = evaluate_lines(lines, &rules, 2);
737 assert_eq!(eval.counts.warn, 5);
738 assert_eq!(eval.findings.len(), 2);
739 assert_eq!(eval.truncated_findings, 3);
740 }
741
742 #[test]
743 fn trim_snippet_truncates_and_appends_ellipsis() {
744 let long = "a".repeat(300);
745 let trimmed = super::trim_snippet(&long);
746
747 assert!(trimmed.ends_with('…'));
748 assert_eq!(trimmed.chars().count(), 241);
749 assert!(trimmed.len() <= long.len() + 3);
750 }
751
752 #[test]
753 fn python_hash_comment_ignored_with_language_aware_preprocessing() {
754 let rules = compile_rules(&[test_rule(
757 "python.no_print",
758 Severity::Warn,
759 "no print",
760 vec!["python"],
761 vec![r"\bprint\s*\("],
762 vec!["**/*.py"],
763 vec![],
764 true,
765 false,
766 )])
767 .unwrap();
768
769 let eval = evaluate_lines(
770 [InputLine {
771 path: "src/main.py".to_string(),
772 line: 1,
773 content: "# print() should be ignored in comment".to_string(),
774 }],
775 &rules,
776 100,
777 );
778
779 assert_eq!(eval.counts.warn, 0);
781 assert_eq!(eval.findings.len(), 0);
782 }
783
784 #[test]
785 fn python_print_detected_outside_comment() {
786 let rules = compile_rules(&[test_rule(
788 "python.no_print",
789 Severity::Warn,
790 "no print",
791 vec!["python"],
792 vec![r"\bprint\s*\("],
793 vec!["**/*.py"],
794 vec![],
795 true,
796 false,
797 )])
798 .unwrap();
799
800 let eval = evaluate_lines(
801 [InputLine {
802 path: "src/main.py".to_string(),
803 line: 1,
804 content: "print('hello')".to_string(),
805 }],
806 &rules,
807 100,
808 );
809
810 assert_eq!(eval.counts.warn, 1);
811 assert_eq!(eval.findings.len(), 1);
812 }
813
814 #[test]
815 fn javascript_template_literal_ignored_with_language_aware_preprocessing() {
816 let rules = compile_rules(&[test_rule(
818 "js.no_console",
819 Severity::Warn,
820 "no console",
821 vec!["javascript"],
822 vec![r"\bconsole\.log\s*\("],
823 vec!["**/*.js"],
824 vec![],
825 false,
826 true,
827 )])
828 .unwrap();
829
830 let eval = evaluate_lines(
831 [InputLine {
832 path: "src/main.js".to_string(),
833 line: 1,
834 content: "const msg = `console.log() in template literal`;".to_string(),
835 }],
836 &rules,
837 100,
838 );
839
840 assert_eq!(eval.counts.warn, 0);
842 assert_eq!(eval.findings.len(), 0);
843 }
844
845 #[test]
846 fn go_backtick_raw_string_ignored_with_language_aware_preprocessing() {
847 let rules = compile_rules(&[test_rule(
849 "go.no_fmt_print",
850 Severity::Warn,
851 "no fmt.Println",
852 vec!["go"],
853 vec![r"\bfmt\.Println\s*\("],
854 vec!["**/*.go"],
855 vec![],
856 false,
857 true,
858 )])
859 .unwrap();
860
861 let eval = evaluate_lines(
862 [InputLine {
863 path: "src/main.go".to_string(),
864 line: 1,
865 content: "var s = `fmt.Println() in raw string`".to_string(),
866 }],
867 &rules,
868 100,
869 );
870
871 assert_eq!(eval.counts.warn, 0);
873 assert_eq!(eval.findings.len(), 0);
874 }
875
876 #[test]
877 fn language_changes_between_files() {
878 let rules = compile_rules(&[test_rule(
881 "detect_pattern",
882 Severity::Warn,
883 "found pattern",
884 vec![],
885 vec!["pattern"],
886 vec![],
887 vec![],
888 true,
889 false,
890 )])
891 .unwrap();
892
893 let eval = evaluate_lines(
894 [
895 InputLine {
897 path: "src/main.py".to_string(),
898 line: 1,
899 content: "# pattern in python comment".to_string(),
900 },
901 InputLine {
903 path: "src/lib.rs".to_string(),
904 line: 1,
905 content: "# pattern in rust (not a comment)".to_string(),
906 },
907 ],
908 &rules,
909 100,
910 );
911
912 assert_eq!(eval.counts.warn, 1);
914 assert_eq!(eval.findings.len(), 1);
915 assert_eq!(eval.findings[0].path, "src/lib.rs");
916 }
917
918 #[test]
919 fn forced_language_override_applies_to_unknown_extension() {
920 let rules = compile_rules(&[test_rule(
921 "rust.no_unwrap",
922 Severity::Error,
923 "no unwrap",
924 vec!["rust"],
925 vec!["\\.unwrap\\("],
926 vec![],
927 vec![],
928 true,
929 true,
930 )])
931 .unwrap();
932
933 let lines = [InputLine {
934 path: "src/custom.ext".to_string(),
935 line: 1,
936 content: "let x = y.unwrap();".to_string(),
937 }];
938
939 let without_override = evaluate_lines(lines.clone(), &rules, 100);
940 assert_eq!(without_override.counts.error, 0);
941
942 let with_override =
943 evaluate_lines_with_overrides_and_language(lines, &rules, 100, None, Some("rust"));
944 assert_eq!(with_override.counts.error, 1);
945 assert_eq!(with_override.findings.len(), 1);
946 }
947
948 #[test]
949 fn rule_hits_track_emitted_and_suppressed() {
950 let rules = compile_rules(&[
951 test_rule(
952 "rule.warn",
953 Severity::Warn,
954 "warn",
955 vec![],
956 vec!["pattern"],
957 vec![],
958 vec![],
959 false,
960 false,
961 ),
962 test_rule(
963 "rule.error",
964 Severity::Error,
965 "error",
966 vec![],
967 vec!["pattern"],
968 vec![],
969 vec![],
970 false,
971 false,
972 ),
973 ])
974 .unwrap();
975
976 let eval = evaluate_lines(
977 [
978 InputLine {
979 path: "a.txt".to_string(),
980 line: 1,
981 content: "pattern".to_string(),
982 },
983 InputLine {
984 path: "a.txt".to_string(),
985 line: 2,
986 content: "pattern // diffguard: ignore rule.warn".to_string(),
987 },
988 ],
989 &rules,
990 100,
991 );
992
993 let warn_stats = eval
994 .rule_hits
995 .iter()
996 .find(|s| s.rule_id == "rule.warn")
997 .expect("warn stats");
998 assert_eq!(warn_stats.total, 2);
999 assert_eq!(warn_stats.emitted, 1);
1000 assert_eq!(warn_stats.suppressed, 1);
1001 assert_eq!(warn_stats.warn, 1);
1002
1003 let error_stats = eval
1004 .rule_hits
1005 .iter()
1006 .find(|s| s.rule_id == "rule.error")
1007 .expect("error stats");
1008 assert_eq!(error_stats.total, 2);
1009 assert_eq!(error_stats.emitted, 2);
1010 assert_eq!(error_stats.suppressed, 0);
1011 assert_eq!(error_stats.error, 2);
1012 }
1013
1014 #[test]
1017 fn suppression_same_line_ignores_specific_rule() {
1018 let rules = compile_rules(&[test_rule(
1019 "rust.no_unwrap",
1020 Severity::Error,
1021 "no unwrap",
1022 vec!["rust"],
1023 vec!["\\.unwrap\\("],
1024 vec!["**/*.rs"],
1025 vec![],
1026 true,
1027 true,
1028 )])
1029 .unwrap();
1030
1031 let eval = evaluate_lines(
1032 [InputLine {
1033 path: "src/lib.rs".to_string(),
1034 line: 1,
1035 content: "let x = y.unwrap(); // diffguard: ignore rust.no_unwrap".to_string(),
1036 }],
1037 &rules,
1038 100,
1039 );
1040
1041 assert_eq!(eval.counts.error, 0);
1042 assert_eq!(eval.counts.suppressed, 1);
1043 assert!(eval.findings.is_empty());
1044 }
1045
1046 #[test]
1047 fn suppression_same_line_wildcard() {
1048 let rules = compile_rules(&[test_rule(
1049 "rust.no_unwrap",
1050 Severity::Error,
1051 "no unwrap",
1052 vec!["rust"],
1053 vec!["\\.unwrap\\("],
1054 vec!["**/*.rs"],
1055 vec![],
1056 true,
1057 true,
1058 )])
1059 .unwrap();
1060
1061 let eval = evaluate_lines(
1062 [InputLine {
1063 path: "src/lib.rs".to_string(),
1064 line: 1,
1065 content: "let x = y.unwrap(); // diffguard: ignore *".to_string(),
1066 }],
1067 &rules,
1068 100,
1069 );
1070
1071 assert_eq!(eval.counts.error, 0);
1072 assert_eq!(eval.counts.suppressed, 1);
1073 assert!(eval.findings.is_empty());
1074 }
1075
1076 #[test]
1077 fn suppression_next_line_ignores_rule() {
1078 let rules = compile_rules(&[test_rule(
1079 "rust.no_dbg",
1080 Severity::Warn,
1081 "no dbg",
1082 vec!["rust"],
1083 vec!["\\bdbg!\\("],
1084 vec!["**/*.rs"],
1085 vec![],
1086 true,
1087 true,
1088 )])
1089 .unwrap();
1090
1091 let eval = evaluate_lines(
1092 [
1093 InputLine {
1094 path: "src/lib.rs".to_string(),
1095 line: 1,
1096 content: "// diffguard: ignore-next-line rust.no_dbg".to_string(),
1097 },
1098 InputLine {
1099 path: "src/lib.rs".to_string(),
1100 line: 2,
1101 content: "dbg!(value);".to_string(),
1102 },
1103 ],
1104 &rules,
1105 100,
1106 );
1107
1108 assert_eq!(eval.counts.warn, 0);
1109 assert_eq!(eval.counts.suppressed, 1);
1110 assert!(eval.findings.is_empty());
1111 }
1112
1113 #[test]
1114 fn suppression_next_line_does_not_affect_third_line() {
1115 let rules = compile_rules(&[test_rule(
1116 "rust.no_dbg",
1117 Severity::Warn,
1118 "no dbg",
1119 vec!["rust"],
1120 vec!["\\bdbg!\\("],
1121 vec!["**/*.rs"],
1122 vec![],
1123 true,
1124 true,
1125 )])
1126 .unwrap();
1127
1128 let eval = evaluate_lines(
1129 [
1130 InputLine {
1131 path: "src/lib.rs".to_string(),
1132 line: 1,
1133 content: "// diffguard: ignore-next-line rust.no_dbg".to_string(),
1134 },
1135 InputLine {
1136 path: "src/lib.rs".to_string(),
1137 line: 2,
1138 content: "dbg!(value);".to_string(),
1139 },
1140 InputLine {
1141 path: "src/lib.rs".to_string(),
1142 line: 3,
1143 content: "dbg!(other);".to_string(),
1144 },
1145 ],
1146 &rules,
1147 100,
1148 );
1149
1150 assert_eq!(eval.counts.warn, 1);
1152 assert_eq!(eval.counts.suppressed, 1);
1153 assert_eq!(eval.findings.len(), 1);
1154 assert_eq!(eval.findings[0].line, 3);
1155 }
1156
1157 #[test]
1158 fn suppression_wrong_rule_does_not_suppress() {
1159 let rules = compile_rules(&[test_rule(
1160 "rust.no_unwrap",
1161 Severity::Error,
1162 "no unwrap",
1163 vec!["rust"],
1164 vec!["\\.unwrap\\("],
1165 vec!["**/*.rs"],
1166 vec![],
1167 true,
1168 true,
1169 )])
1170 .unwrap();
1171
1172 let eval = evaluate_lines(
1173 [InputLine {
1174 path: "src/lib.rs".to_string(),
1175 line: 1,
1176 content: "let x = y.unwrap(); // diffguard: ignore wrong.rule".to_string(),
1177 }],
1178 &rules,
1179 100,
1180 );
1181
1182 assert_eq!(eval.counts.error, 1);
1184 assert_eq!(eval.counts.suppressed, 0);
1185 assert_eq!(eval.findings.len(), 1);
1186 }
1187
1188 #[test]
1189 fn suppression_resets_on_file_change() {
1190 let rules = compile_rules(&[test_rule(
1191 "test.rule",
1192 Severity::Warn,
1193 "test",
1194 vec![],
1195 vec!["pattern"],
1196 vec![],
1197 vec![],
1198 false,
1199 false,
1200 )])
1201 .unwrap();
1202
1203 let eval = evaluate_lines(
1204 [
1205 InputLine {
1207 path: "file1.txt".to_string(),
1208 line: 1,
1209 content: "// diffguard: ignore-next-line test.rule".to_string(),
1210 },
1211 InputLine {
1213 path: "file2.txt".to_string(),
1214 line: 1,
1215 content: "pattern".to_string(),
1216 },
1217 ],
1218 &rules,
1219 100,
1220 );
1221
1222 assert_eq!(eval.counts.warn, 1);
1224 assert_eq!(eval.counts.suppressed, 0);
1225 assert_eq!(eval.findings.len(), 1);
1226 assert_eq!(eval.findings[0].path, "file2.txt");
1227 }
1228
1229 #[test]
1230 fn suppression_multiple_rules_on_same_line() {
1231 let rules = compile_rules(&[
1232 test_rule(
1233 "rule.one",
1234 Severity::Warn,
1235 "one",
1236 vec![],
1237 vec!["pattern"],
1238 vec![],
1239 vec![],
1240 false,
1241 false,
1242 ),
1243 test_rule(
1244 "rule.two",
1245 Severity::Error,
1246 "two",
1247 vec![],
1248 vec!["pattern"],
1249 vec![],
1250 vec![],
1251 false,
1252 false,
1253 ),
1254 ])
1255 .unwrap();
1256
1257 let eval = evaluate_lines(
1258 [InputLine {
1259 path: "test.txt".to_string(),
1260 line: 1,
1261 content: "pattern // diffguard: ignore rule.one, rule.two".to_string(),
1262 }],
1263 &rules,
1264 100,
1265 );
1266
1267 assert_eq!(eval.counts.warn, 0);
1269 assert_eq!(eval.counts.error, 0);
1270 assert_eq!(eval.counts.suppressed, 2);
1271 assert!(eval.findings.is_empty());
1272 }
1273
1274 #[test]
1275 fn suppression_ignore_all_directive() {
1276 let rules = compile_rules(&[
1277 test_rule(
1278 "rule.one",
1279 Severity::Warn,
1280 "one",
1281 vec![],
1282 vec!["pattern"],
1283 vec![],
1284 vec![],
1285 false,
1286 false,
1287 ),
1288 test_rule(
1289 "rule.two",
1290 Severity::Error,
1291 "two",
1292 vec![],
1293 vec!["pattern"],
1294 vec![],
1295 vec![],
1296 false,
1297 false,
1298 ),
1299 ])
1300 .unwrap();
1301
1302 let eval = evaluate_lines(
1303 [InputLine {
1304 path: "test.txt".to_string(),
1305 line: 1,
1306 content: "pattern // diffguard: ignore-all".to_string(),
1307 }],
1308 &rules,
1309 100,
1310 );
1311
1312 assert_eq!(eval.counts.warn, 0);
1314 assert_eq!(eval.counts.error, 0);
1315 assert_eq!(eval.counts.suppressed, 2);
1316 assert!(eval.findings.is_empty());
1317 }
1318
1319 #[test]
1320 fn safe_slice_clamps_and_slices() {
1321 let s = "abcde";
1322 assert_eq!(safe_slice(s, 1, 3), "bc");
1323 assert_eq!(safe_slice(s, 0, 100), "abcde");
1324 assert_eq!(safe_slice(s, 10, 12), "");
1325 }
1326
1327 #[test]
1328 fn byte_to_column_counts_chars() {
1329 let s = "aβc";
1330 assert_eq!(byte_to_column(s, 0), Some(1));
1331 assert_eq!(byte_to_column(s, 1), Some(2));
1332 assert_eq!(byte_to_column(s, 3), Some(3));
1333 assert_eq!(byte_to_column(s, s.len()), Some(4));
1334 assert_eq!(byte_to_column(s, s.len() + 1), None);
1335 }
1336
1337 #[test]
1338 fn first_match_returns_none_for_empty_patterns() {
1339 let patterns: Vec<regex::Regex> = Vec::new();
1340 assert_eq!(first_match(&patterns, "abc"), None);
1341 }
1342
1343 #[test]
1344 fn bump_counts_increments_all_severities() {
1345 let mut counts = VerdictCounts::default();
1346 bump_counts(&mut counts, Severity::Info);
1347 bump_counts(&mut counts, Severity::Warn);
1348 bump_counts(&mut counts, Severity::Error);
1349
1350 assert_eq!(counts.info, 1);
1351 assert_eq!(counts.warn, 1);
1352 assert_eq!(counts.error, 1);
1353 }
1354
1355 #[test]
1356 fn directory_overrides_can_change_severity_or_disable_rule() {
1357 let rules = compile_rules(&[test_rule(
1358 "rust.no_unwrap",
1359 Severity::Error,
1360 "no unwrap",
1361 vec!["rust"],
1362 vec!["\\.unwrap\\("],
1363 vec!["**/*.rs"],
1364 vec![],
1365 true,
1366 true,
1367 )])
1368 .unwrap();
1369
1370 let overrides = RuleOverrideMatcher::compile(&[
1371 DirectoryRuleOverride {
1372 directory: "src/legacy".to_string(),
1373 rule_id: "rust.no_unwrap".to_string(),
1374 enabled: None,
1375 severity: Some(Severity::Warn),
1376 exclude_paths: vec![],
1377 },
1378 DirectoryRuleOverride {
1379 directory: "src/generated".to_string(),
1380 rule_id: "rust.no_unwrap".to_string(),
1381 enabled: Some(false),
1382 severity: None,
1383 exclude_paths: vec![],
1384 },
1385 ])
1386 .expect("compile overrides");
1387
1388 let eval = evaluate_lines_with_overrides(
1389 [
1390 InputLine {
1391 path: "src/new/lib.rs".to_string(),
1392 line: 1,
1393 content: "let x = y.unwrap();".to_string(),
1394 },
1395 InputLine {
1396 path: "src/legacy/lib.rs".to_string(),
1397 line: 1,
1398 content: "let x = y.unwrap();".to_string(),
1399 },
1400 InputLine {
1401 path: "src/generated/lib.rs".to_string(),
1402 line: 1,
1403 content: "let x = y.unwrap();".to_string(),
1404 },
1405 ],
1406 &rules,
1407 100,
1408 Some(&overrides),
1409 );
1410
1411 assert_eq!(eval.counts.error, 1);
1412 assert_eq!(eval.counts.warn, 1);
1413 assert_eq!(eval.findings.len(), 2);
1414 assert!(
1415 eval.findings
1416 .iter()
1417 .any(|f| { f.path == "src/new/lib.rs" && matches!(f.severity, Severity::Error) })
1418 );
1419 assert!(
1420 eval.findings
1421 .iter()
1422 .any(|f| { f.path == "src/legacy/lib.rs" && matches!(f.severity, Severity::Warn) })
1423 );
1424 assert!(
1425 !eval
1426 .findings
1427 .iter()
1428 .any(|f| f.path == "src/generated/lib.rs")
1429 );
1430 }
1431
1432 #[test]
1433 fn multiline_rule_matches_across_consecutive_lines() {
1434 let rule = RuleConfig {
1435 id: "js.console_then_return".to_string(),
1436 severity: Severity::Warn,
1437 message: "console.log before return".to_string(),
1438 languages: vec!["javascript".to_string()],
1439 patterns: vec![r"console\.log\('[^']*'\);\nreturn".to_string()],
1440 paths: vec!["**/*.js".to_string()],
1441 exclude_paths: vec![],
1442 ignore_comments: false,
1443 ignore_strings: false,
1444 match_mode: MatchMode::Any,
1445 multiline: true,
1446 multiline_window: Some(2),
1447 context_patterns: vec![],
1448 context_window: None,
1449 escalate_patterns: vec![],
1450 escalate_window: None,
1451 escalate_to: None,
1452 depends_on: vec![],
1453 help: None,
1454 url: None,
1455 tags: vec![],
1456 test_cases: vec![],
1457 };
1458 let rules = compile_rules(&[rule]).expect("compile rule");
1459
1460 let eval = evaluate_lines(
1461 [
1462 InputLine {
1463 path: "src/app.js".to_string(),
1464 line: 10,
1465 content: "console.log('x');".to_string(),
1466 },
1467 InputLine {
1468 path: "src/app.js".to_string(),
1469 line: 11,
1470 content: "return value;".to_string(),
1471 },
1472 ],
1473 &rules,
1474 100,
1475 );
1476 assert_eq!(eval.counts.warn, 1);
1477 assert_eq!(eval.findings.len(), 1);
1478 assert_eq!(eval.findings[0].line, 10);
1479 }
1480
1481 #[test]
1482 fn absent_mode_emits_when_pattern_missing() {
1483 let rule = RuleConfig {
1484 id: "rust.missing_timeout".to_string(),
1485 severity: Severity::Warn,
1486 message: "timeout should be configured".to_string(),
1487 languages: vec!["rust".to_string()],
1488 patterns: vec![r"\btimeout\b".to_string()],
1489 paths: vec!["**/*.rs".to_string()],
1490 exclude_paths: vec![],
1491 ignore_comments: false,
1492 ignore_strings: false,
1493 match_mode: MatchMode::Absent,
1494 multiline: false,
1495 multiline_window: None,
1496 context_patterns: vec![],
1497 context_window: None,
1498 escalate_patterns: vec![],
1499 escalate_window: None,
1500 escalate_to: None,
1501 depends_on: vec![],
1502 help: None,
1503 url: None,
1504 tags: vec![],
1505 test_cases: vec![],
1506 };
1507 let rules = compile_rules(&[rule]).expect("compile rule");
1508
1509 let eval = evaluate_lines(
1510 [InputLine {
1511 path: "src/lib.rs".to_string(),
1512 line: 7,
1513 content: "let retries = 3;".to_string(),
1514 }],
1515 &rules,
1516 100,
1517 );
1518
1519 assert_eq!(eval.counts.warn, 1);
1520 assert_eq!(eval.findings.len(), 1);
1521 assert_eq!(eval.findings[0].match_text, "<absent>");
1522 }
1523
1524 #[test]
1525 fn context_patterns_require_nearby_match() {
1526 let rule = RuleConfig {
1527 id: "sql.where_required_for_delete".to_string(),
1528 severity: Severity::Error,
1529 message: "DELETE requires WHERE nearby".to_string(),
1530 languages: vec!["sql".to_string()],
1531 patterns: vec![r"(?i)\bDELETE\s+FROM\b".to_string()],
1532 paths: vec!["**/*.sql".to_string()],
1533 exclude_paths: vec![],
1534 ignore_comments: false,
1535 ignore_strings: false,
1536 match_mode: MatchMode::Any,
1537 multiline: false,
1538 multiline_window: None,
1539 context_patterns: vec![r"(?i)\bWHERE\b".to_string()],
1540 context_window: Some(1),
1541 escalate_patterns: vec![],
1542 escalate_window: None,
1543 escalate_to: None,
1544 depends_on: vec![],
1545 help: None,
1546 url: None,
1547 tags: vec![],
1548 test_cases: vec![],
1549 };
1550 let rules = compile_rules(&[rule]).expect("compile rule");
1551
1552 let eval = evaluate_lines(
1553 [
1554 InputLine {
1555 path: "migrations/a.sql".to_string(),
1556 line: 1,
1557 content: "DELETE FROM users".to_string(),
1558 },
1559 InputLine {
1560 path: "migrations/a.sql".to_string(),
1561 line: 2,
1562 content: "SET active = false;".to_string(),
1563 },
1564 ],
1565 &rules,
1566 100,
1567 );
1568
1569 assert_eq!(eval.counts.error, 0);
1570 assert!(eval.findings.is_empty());
1571 }
1572
1573 #[test]
1574 fn escalation_patterns_raise_effective_severity() {
1575 let rule = RuleConfig {
1576 id: "python.exec_usage".to_string(),
1577 severity: Severity::Warn,
1578 message: "Avoid exec".to_string(),
1579 languages: vec!["python".to_string()],
1580 patterns: vec![r"\bexec\s*\(".to_string()],
1581 paths: vec!["**/*.py".to_string()],
1582 exclude_paths: vec![],
1583 ignore_comments: false,
1584 ignore_strings: false,
1585 match_mode: MatchMode::Any,
1586 multiline: false,
1587 multiline_window: None,
1588 context_patterns: vec![],
1589 context_window: None,
1590 escalate_patterns: vec![r"(?i)\buntrusted".to_string()],
1591 escalate_window: Some(0),
1592 escalate_to: Some(Severity::Error),
1593 depends_on: vec![],
1594 help: None,
1595 url: None,
1596 tags: vec![],
1597 test_cases: vec![],
1598 };
1599 let rules = compile_rules(&[rule]).expect("compile rule");
1600
1601 let eval = evaluate_lines(
1602 [InputLine {
1603 path: "src/run.py".to_string(),
1604 line: 20,
1605 content: "exec(untrusted_input)".to_string(),
1606 }],
1607 &rules,
1608 100,
1609 );
1610 assert_eq!(eval.counts.warn, 0);
1611 assert_eq!(eval.counts.error, 1);
1612 assert_eq!(eval.findings[0].severity, Severity::Error);
1613 }
1614
1615 #[test]
1616 fn dependency_gates_secondary_rule() {
1617 let rules = compile_rules(&[
1618 RuleConfig {
1619 id: "python.has_eval".to_string(),
1620 severity: Severity::Warn,
1621 message: "eval used".to_string(),
1622 languages: vec!["python".to_string()],
1623 patterns: vec![r"\beval\s*\(".to_string()],
1624 paths: vec!["**/*.py".to_string()],
1625 exclude_paths: vec![],
1626 ignore_comments: false,
1627 ignore_strings: false,
1628 match_mode: MatchMode::Any,
1629 multiline: false,
1630 multiline_window: None,
1631 context_patterns: vec![],
1632 context_window: None,
1633 escalate_patterns: vec![],
1634 escalate_window: None,
1635 escalate_to: None,
1636 depends_on: vec![],
1637 help: None,
1638 url: None,
1639 tags: vec![],
1640 test_cases: vec![],
1641 },
1642 RuleConfig {
1643 id: "python.eval_untrusted".to_string(),
1644 severity: Severity::Error,
1645 message: "eval with untrusted input".to_string(),
1646 languages: vec!["python".to_string()],
1647 patterns: vec![r"(?i)\buntrusted".to_string()],
1648 paths: vec!["**/*.py".to_string()],
1649 exclude_paths: vec![],
1650 ignore_comments: false,
1651 ignore_strings: false,
1652 match_mode: MatchMode::Any,
1653 multiline: false,
1654 multiline_window: None,
1655 context_patterns: vec![],
1656 context_window: None,
1657 escalate_patterns: vec![],
1658 escalate_window: None,
1659 escalate_to: None,
1660 depends_on: vec!["python.has_eval".to_string()],
1661 help: None,
1662 url: None,
1663 tags: vec![],
1664 test_cases: vec![],
1665 },
1666 ])
1667 .expect("compile rules");
1668
1669 let eval_without_eval = evaluate_lines(
1670 [InputLine {
1671 path: "src/a.py".to_string(),
1672 line: 1,
1673 content: "untrusted_input".to_string(),
1674 }],
1675 &rules,
1676 100,
1677 );
1678 assert_eq!(eval_without_eval.counts.error, 0);
1679
1680 let eval_with_eval = evaluate_lines(
1681 [
1682 InputLine {
1683 path: "src/a.py".to_string(),
1684 line: 1,
1685 content: "eval(x)".to_string(),
1686 },
1687 InputLine {
1688 path: "src/a.py".to_string(),
1689 line: 2,
1690 content: "untrusted_input".to_string(),
1691 },
1692 ],
1693 &rules,
1694 100,
1695 );
1696 assert_eq!(eval_with_eval.counts.warn, 1);
1697 assert_eq!(eval_with_eval.counts.error, 1);
1698 }
1699}