Skip to main content

diffguard_domain/
evaluate.rs

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    /// Aggregated per-rule hit counts (deterministically sorted by rule ID).
25    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    // Avoid slicing by byte indices (which can panic on Unicode boundaries).
557    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    /// Helper to create a RuleConfig for testing with default help/url
589    #[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        // This test verifies that Python hash comments are properly ignored
755        // when processing Python files (language-aware preprocessing)
756        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        // Hash comment should be masked for Python files
780        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        // This test verifies that print() is detected when not in a comment
787        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        // This test verifies that JavaScript template literals are properly ignored
817        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        // Template literal should be masked for JavaScript files
841        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        // This test verifies that Go backtick raw strings are properly ignored
848        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        // Backtick raw string should be masked for Go files
872        assert_eq!(eval.counts.warn, 0);
873        assert_eq!(eval.findings.len(), 0);
874    }
875
876    #[test]
877    fn language_changes_between_files() {
878        // This test verifies that the preprocessor correctly switches languages
879        // when processing files with different extensions
880        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                // Python file - hash comment should be ignored
896                InputLine {
897                    path: "src/main.py".to_string(),
898                    line: 1,
899                    content: "# pattern in python comment".to_string(),
900                },
901                // Rust file - hash is NOT a comment, should be detected
902                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        // Only the Rust file should have a finding (hash is not a comment in Rust)
913        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    // ==================== Suppression tests ====================
1015
1016    #[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        // Line 2 is suppressed, line 3 is not
1151        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        // The suppression is for a different rule, so unwrap is still flagged
1183        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                // File 1: set up next-line suppression
1206                InputLine {
1207                    path: "file1.txt".to_string(),
1208                    line: 1,
1209                    content: "// diffguard: ignore-next-line test.rule".to_string(),
1210                },
1211                // File 2: different file, suppression should NOT apply
1212                InputLine {
1213                    path: "file2.txt".to_string(),
1214                    line: 1,
1215                    content: "pattern".to_string(),
1216                },
1217            ],
1218            &rules,
1219            100,
1220        );
1221
1222        // Pattern in file2 should be detected (suppression was for file1's next line)
1223        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        // Both rules should be suppressed
1268        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        // Both rules should be suppressed with ignore-all
1313        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}