Skip to main content

diffguard_core/
check.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::path::Path;
3
4use globset::{Glob, GlobSet, GlobSetBuilder};
5
6use diffguard_diff::parse_unified_diff;
7use diffguard_domain::{
8    DirectoryRuleOverride, InputLine, RuleOverrideMatcher, compile_rules,
9    evaluate_lines_with_overrides_and_language,
10};
11use diffguard_types::{
12    CheckReceipt, DiffMeta, FailOn, Finding, REASON_TRUNCATED, ToolMeta, Verdict, VerdictCounts,
13    VerdictStatus,
14};
15
16use crate::fingerprint::compute_fingerprint;
17
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct CheckPlan {
20    pub base: String,
21    pub head: String,
22    pub scope: diffguard_types::Scope,
23    pub diff_context: u32,
24    pub fail_on: FailOn,
25    pub max_findings: usize,
26    pub path_filters: Vec<String>,
27    /// Only include rules that have at least one of these tags.
28    /// Empty means no filtering by this criterion.
29    pub only_tags: Vec<String>,
30    /// Include rules that have at least one of these tags (additive).
31    /// Empty means no filtering by this criterion.
32    pub enable_tags: Vec<String>,
33    /// Exclude rules that have any of these tags.
34    /// Empty means no filtering by this criterion.
35    pub disable_tags: Vec<String>,
36    /// Per-directory rule overrides loaded from `.diffguard.toml` files.
37    pub directory_overrides: Vec<DirectoryRuleOverride>,
38    /// Force all files to be treated as this language for preprocessing/rule filtering.
39    pub force_language: Option<String>,
40    /// Optional line-level allowlist `(path, line)` for secondary filtering.
41    /// When set, only these diff lines are evaluated.
42    pub allowed_lines: Option<BTreeSet<(String, u32)>>,
43    /// Finding fingerprints to treat as acknowledged false positives.
44    pub false_positive_fingerprints: BTreeSet<String>,
45}
46
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct CheckRun {
49    pub receipt: CheckReceipt,
50    pub markdown: String,
51    pub annotations: Vec<String>,
52    pub exit_code: i32,
53    /// Number of findings dropped due to max_findings truncation.
54    pub truncated_findings: u32,
55    /// Number of rules that were evaluated (after tag filtering).
56    pub rules_evaluated: usize,
57    /// Per-rule hit aggregation for analytics.
58    pub rule_hits: Vec<RuleHitStat>,
59    /// Number of findings filtered as acknowledged false positives.
60    pub false_positive_findings: u32,
61}
62
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct RuleHitStat {
65    pub rule_id: String,
66    pub total: u32,
67    pub emitted: u32,
68    pub suppressed: u32,
69    pub info: u32,
70    pub warn: u32,
71    pub error: u32,
72    pub false_positive: u32,
73}
74
75#[derive(Debug, thiserror::Error)]
76pub enum PathFilterError {
77    #[error("invalid path filter glob '{glob}': {source}")]
78    InvalidGlob {
79        glob: String,
80        source: globset::Error,
81    },
82}
83
84pub fn run_check(
85    plan: &CheckPlan,
86    config: &diffguard_types::ConfigFile,
87    diff_text: &str,
88) -> Result<CheckRun, anyhow::Error> {
89    let (mut diff_lines, _stats) = parse_unified_diff(diff_text, plan.scope)?;
90
91    if !plan.path_filters.is_empty() {
92        let filters = compile_filter_globs(&plan.path_filters)?;
93        diff_lines.retain(|l| filters.is_match(Path::new(&l.path)));
94    }
95
96    if let Some(allowed_lines) = &plan.allowed_lines {
97        diff_lines.retain(|l| allowed_lines.contains(&(l.path.clone(), l.line)));
98    }
99
100    // Multiple diff sources (or unusual diffs) can contain duplicates for the same
101    // path/line/content tuple. Keep first occurrence to preserve deterministic ordering.
102    let mut seen = BTreeSet::<(String, u32, String)>::new();
103    diff_lines.retain(|l| seen.insert((l.path.clone(), l.line, l.content.clone())));
104
105    // Filter rules by tags
106    let filtered_rules: Vec<_> = config
107        .rule
108        .iter()
109        .filter(|r| filter_rule_by_tags(r, plan))
110        .cloned()
111        .collect();
112
113    let rules = compile_rules(&filtered_rules)?;
114    let rules_evaluated = filtered_rules.len();
115    let override_matcher = RuleOverrideMatcher::compile(&plan.directory_overrides)?;
116
117    let lines = diff_lines.into_iter().map(|l| InputLine {
118        path: l.path,
119        line: l.line,
120        content: l.content,
121    });
122
123    let evaluation = evaluate_lines_with_overrides_and_language(
124        lines,
125        &rules,
126        plan.max_findings,
127        {
128            if plan.directory_overrides.is_empty() {
129                None
130            } else {
131                Some(&override_matcher)
132            }
133        },
134        plan.force_language.as_deref(),
135    );
136
137    let mut filtered_findings = Vec::with_capacity(evaluation.findings.len());
138    let mut adjusted_counts = evaluation.counts.clone();
139    let mut false_positive_findings = 0u32;
140    let mut per_rule_false_positive = BTreeMap::<String, (u32, u32, u32, u32)>::new();
141
142    for finding in evaluation.findings {
143        let fingerprint = compute_fingerprint(&finding);
144        if plan.false_positive_fingerprints.contains(&fingerprint) {
145            false_positive_findings = false_positive_findings.saturating_add(1);
146            let entry = per_rule_false_positive
147                .entry(finding.rule_id.clone())
148                .or_insert((0, 0, 0, 0));
149            entry.0 = entry.0.saturating_add(1);
150            match finding.severity {
151                diffguard_types::Severity::Info => {
152                    adjusted_counts.info = adjusted_counts.info.saturating_sub(1);
153                    entry.1 = entry.1.saturating_add(1);
154                }
155                diffguard_types::Severity::Warn => {
156                    adjusted_counts.warn = adjusted_counts.warn.saturating_sub(1);
157                    entry.2 = entry.2.saturating_add(1);
158                }
159                diffguard_types::Severity::Error => {
160                    adjusted_counts.error = adjusted_counts.error.saturating_sub(1);
161                    entry.3 = entry.3.saturating_add(1);
162                }
163            }
164            continue;
165        }
166        filtered_findings.push(finding);
167    }
168
169    let verdict_status = if adjusted_counts.error > 0 {
170        VerdictStatus::Fail
171    } else if adjusted_counts.warn > 0 {
172        VerdictStatus::Warn
173    } else {
174        VerdictStatus::Pass
175    };
176
177    let mut reasons: Vec<String> = Vec::new();
178    if evaluation.truncated_findings > 0 {
179        reasons.push(REASON_TRUNCATED.to_string());
180    }
181
182    let receipt = CheckReceipt {
183        schema: diffguard_types::CHECK_SCHEMA_V1.to_string(),
184        tool: ToolMeta {
185            name: "diffguard".to_string(),
186            version: env!("CARGO_PKG_VERSION").to_string(),
187        },
188        diff: DiffMeta {
189            base: plan.base.clone(),
190            head: plan.head.clone(),
191            context_lines: plan.diff_context,
192            scope: plan.scope,
193            files_scanned: evaluation.files_scanned,
194            lines_scanned: evaluation.lines_scanned,
195        },
196        findings: filtered_findings,
197        verdict: Verdict {
198            status: verdict_status,
199            counts: adjusted_counts,
200            reasons,
201        },
202        timing: None,
203    };
204
205    let markdown = crate::render::render_markdown_for_receipt(&receipt);
206    let annotations = render_annotations(&receipt.findings);
207
208    let exit_code = compute_exit_code(plan.fail_on, &receipt.verdict.counts);
209
210    let mut rule_hits: Vec<RuleHitStat> = evaluation
211        .rule_hits
212        .into_iter()
213        .map(|s| RuleHitStat {
214            rule_id: s.rule_id,
215            total: s.total,
216            emitted: s.emitted,
217            suppressed: s.suppressed,
218            info: s.info,
219            warn: s.warn,
220            error: s.error,
221            false_positive: 0,
222        })
223        .collect();
224
225    if !per_rule_false_positive.is_empty() {
226        for stat in &mut rule_hits {
227            if let Some((filtered, info, warn, error)) = per_rule_false_positive.get(&stat.rule_id)
228            {
229                stat.emitted = stat.emitted.saturating_sub(*filtered);
230                stat.info = stat.info.saturating_sub(*info);
231                stat.warn = stat.warn.saturating_sub(*warn);
232                stat.error = stat.error.saturating_sub(*error);
233                stat.false_positive = stat.false_positive.saturating_add(*filtered);
234            }
235        }
236    }
237
238    Ok(CheckRun {
239        receipt,
240        markdown,
241        annotations,
242        exit_code,
243        truncated_findings: evaluation.truncated_findings,
244        rules_evaluated,
245        rule_hits,
246        false_positive_findings,
247    })
248}
249
250fn compile_filter_globs(globs: &[String]) -> Result<GlobSet, PathFilterError> {
251    let mut b = GlobSetBuilder::new();
252    for g in globs {
253        let glob = Glob::new(g).map_err(|e| PathFilterError::InvalidGlob {
254            glob: g.clone(),
255            source: e,
256        })?;
257        b.add(glob);
258    }
259    Ok(b.build().expect("globset build should succeed"))
260}
261
262/// Filter a rule based on tag criteria in the plan.
263///
264/// - If `only_tags` is non-empty, the rule must have at least one matching tag.
265/// - `enable_tags` is additive to `only_tags` (a rule matching either set is included).
266/// - If `disable_tags` is non-empty and the rule has any matching tag, it's excluded.
267fn filter_rule_by_tags(rule: &diffguard_types::RuleConfig, plan: &CheckPlan) -> bool {
268    // If only_tags is specified, allow rules matching only_tags OR enable_tags.
269    // This keeps enable_tags additive rather than restrictive on its own.
270    if !plan.only_tags.is_empty() {
271        let has_only_tag = rule
272            .tags
273            .iter()
274            .any(|t| plan.only_tags.iter().any(|ot| ot.eq_ignore_ascii_case(t)));
275        let has_enabled_tag = !plan.enable_tags.is_empty()
276            && rule
277                .tags
278                .iter()
279                .any(|t| plan.enable_tags.iter().any(|et| et.eq_ignore_ascii_case(t)));
280        if !has_only_tag && !has_enabled_tag {
281            return false;
282        }
283    }
284
285    // If disable_tags is specified, exclude rules that have any matching tag
286    if !plan.disable_tags.is_empty() {
287        let has_disabled_tag = rule.tags.iter().any(|t| {
288            plan.disable_tags
289                .iter()
290                .any(|dt| dt.eq_ignore_ascii_case(t))
291        });
292        if has_disabled_tag {
293            return false;
294        }
295    }
296
297    true
298}
299
300fn compute_exit_code(fail_on: FailOn, counts: &VerdictCounts) -> i32 {
301    if matches!(fail_on, FailOn::Never) {
302        return 0;
303    }
304
305    if counts.error > 0 {
306        return 2;
307    }
308
309    if matches!(fail_on, FailOn::Warn) && counts.warn > 0 {
310        return 3;
311    }
312
313    0
314}
315
316fn render_annotations(findings: &[Finding]) -> Vec<String> {
317    findings
318        .iter()
319        .map(|f| {
320            let level = match f.severity {
321                diffguard_types::Severity::Info => "notice",
322                diffguard_types::Severity::Warn => "warning",
323                diffguard_types::Severity::Error => "error",
324            };
325            format!(
326                "::{level} file={path},line={line}::{rule} {msg}",
327                level = level,
328                path = f.path,
329                line = f.line,
330                rule = f.rule_id,
331                msg = f.message
332            )
333        })
334        .collect()
335}
336
337#[cfg(test)]
338mod tests {
339    use super::*;
340    use proptest::prelude::*;
341
342    fn test_finding(severity: diffguard_types::Severity) -> Finding {
343        Finding {
344            rule_id: "test.rule".to_string(),
345            severity,
346            message: "Test message".to_string(),
347            path: "src/lib.rs".to_string(),
348            line: 42,
349            column: Some(3),
350            match_text: "match".to_string(),
351            snippet: "let x = match;".to_string(),
352        }
353    }
354
355    fn test_rule_config(
356        severity: diffguard_types::Severity,
357        pattern: &str,
358    ) -> diffguard_types::ConfigFile {
359        diffguard_types::ConfigFile {
360            includes: vec![],
361            defaults: diffguard_types::Defaults::default(),
362            rule: vec![diffguard_types::RuleConfig {
363                id: "test.rule".to_string(),
364                severity,
365                message: "Test message".to_string(),
366                languages: vec!["rust".to_string()],
367                patterns: vec![pattern.to_string()],
368                paths: vec!["**/*.rs".to_string()],
369                exclude_paths: vec![],
370                ignore_comments: false,
371                ignore_strings: false,
372                match_mode: Default::default(),
373                multiline: false,
374                multiline_window: None,
375                context_patterns: vec![],
376                context_window: None,
377                escalate_patterns: vec![],
378                escalate_window: None,
379                escalate_to: None,
380                depends_on: vec![],
381                help: None,
382                url: None,
383                tags: vec![],
384                test_cases: vec![],
385            }],
386        }
387    }
388
389    fn test_plan(max_findings: usize, fail_on: FailOn, path_filters: Vec<&str>) -> CheckPlan {
390        CheckPlan {
391            base: "base".to_string(),
392            head: "head".to_string(),
393            scope: diffguard_types::Scope::Added,
394            diff_context: 0,
395            fail_on,
396            max_findings,
397            path_filters: path_filters.into_iter().map(|s| s.to_string()).collect(),
398            only_tags: vec![],
399            enable_tags: vec![],
400            disable_tags: vec![],
401            directory_overrides: vec![],
402            force_language: None,
403            allowed_lines: None,
404            false_positive_fingerprints: BTreeSet::new(),
405        }
406    }
407
408    #[test]
409    fn exit_code_semantics() {
410        let mut counts = VerdictCounts::default();
411        assert_eq!(compute_exit_code(FailOn::Error, &counts), 0);
412        assert_eq!(compute_exit_code(FailOn::Warn, &counts), 0);
413
414        counts.warn = 1;
415        assert_eq!(compute_exit_code(FailOn::Error, &counts), 0);
416        assert_eq!(compute_exit_code(FailOn::Warn, &counts), 3);
417
418        counts.error = 1;
419        assert_eq!(compute_exit_code(FailOn::Error, &counts), 2);
420        assert_eq!(compute_exit_code(FailOn::Warn, &counts), 2);
421        assert_eq!(compute_exit_code(FailOn::Never, &counts), 0);
422    }
423
424    #[test]
425    fn compile_filter_globs_rejects_invalid() {
426        let err = compile_filter_globs(&["[".to_string()]).unwrap_err();
427        match err {
428            PathFilterError::InvalidGlob { glob, .. } => assert_eq!(glob, "["),
429        }
430    }
431
432    #[test]
433    fn run_check_without_path_filters_keeps_findings() {
434        let plan = test_plan(100, FailOn::Error, vec![]);
435        let config = test_rule_config(diffguard_types::Severity::Warn, "warn_me");
436        let diff = r#"
437diff --git a/src/lib.rs b/src/lib.rs
438--- a/src/lib.rs
439+++ b/src/lib.rs
440@@ -1,1 +1,2 @@
441 fn a() {}
442+let x = warn_me();
443"#;
444
445        let run = run_check(&plan, &config, diff).expect("run_check");
446        assert_eq!(run.receipt.findings.len(), 1);
447    }
448
449    #[test]
450    fn run_check_with_path_filters_filters_findings() {
451        let plan = test_plan(100, FailOn::Error, vec!["src/lib.rs"]);
452        let config = test_rule_config(diffguard_types::Severity::Warn, "warn_me");
453        let diff = r#"
454diff --git a/src/lib.rs b/src/lib.rs
455--- a/src/lib.rs
456+++ b/src/lib.rs
457@@ -1,1 +1,2 @@
458 fn a() {}
459+let x = warn_me();
460diff --git a/other.rs b/other.rs
461--- a/other.rs
462+++ b/other.rs
463@@ -1,1 +1,2 @@
464 fn b() {}
465+let y = warn_me();
466"#;
467
468        let run = run_check(&plan, &config, diff).expect("run_check");
469        assert_eq!(run.receipt.findings.len(), 1);
470        assert_eq!(run.receipt.findings[0].path, "src/lib.rs");
471    }
472
473    #[test]
474    fn run_check_dedupes_duplicate_diff_lines() {
475        let plan = test_plan(100, FailOn::Error, vec![]);
476        let config = test_rule_config(diffguard_types::Severity::Warn, "warn_me");
477        let single = r#"
478diff --git a/src/lib.rs b/src/lib.rs
479--- a/src/lib.rs
480+++ b/src/lib.rs
481@@ -1,1 +1,2 @@
482 fn a() {}
483+let x = warn_me();
484"#;
485        let duplicated = format!("{single}\n{single}");
486
487        let run = run_check(&plan, &config, &duplicated).expect("run_check");
488        assert_eq!(run.receipt.findings.len(), 1);
489        assert_eq!(run.receipt.verdict.counts.warn, 1);
490    }
491
492    #[test]
493    fn run_check_force_language_applies_rules_for_unknown_extensions() {
494        let mut plan = test_plan(100, FailOn::Error, vec![]);
495        plan.force_language = Some("rust".to_string());
496        let config = diffguard_types::ConfigFile {
497            includes: vec![],
498            defaults: diffguard_types::Defaults::default(),
499            rule: vec![diffguard_types::RuleConfig {
500                id: "test.rule".to_string(),
501                severity: diffguard_types::Severity::Warn,
502                message: "Test message".to_string(),
503                languages: vec!["rust".to_string()],
504                patterns: vec!["warn_me".to_string()],
505                paths: vec!["**/*.custom".to_string()],
506                exclude_paths: vec![],
507                ignore_comments: false,
508                ignore_strings: false,
509                match_mode: Default::default(),
510                multiline: false,
511                multiline_window: None,
512                context_patterns: vec![],
513                context_window: None,
514                escalate_patterns: vec![],
515                escalate_window: None,
516                escalate_to: None,
517                depends_on: vec![],
518                help: None,
519                url: None,
520                tags: vec![],
521                test_cases: vec![],
522            }],
523        };
524        let diff = r#"
525diff --git a/src/file.custom b/src/file.custom
526--- a/src/file.custom
527+++ b/src/file.custom
528@@ -0,0 +1,1 @@
529+warn_me();
530"#;
531
532        let run = run_check(&plan, &config, diff).expect("run_check");
533        assert_eq!(run.receipt.findings.len(), 1);
534        assert_eq!(run.receipt.verdict.counts.warn, 1);
535    }
536
537    #[test]
538    fn run_check_filters_by_allowed_lines() {
539        let mut plan = test_plan(100, FailOn::Error, vec![]);
540        plan.allowed_lines = Some(BTreeSet::from([(String::from("src/lib.rs"), 3)]));
541        let config = test_rule_config(diffguard_types::Severity::Warn, "warn_me");
542        let diff = r#"
543diff --git a/src/lib.rs b/src/lib.rs
544--- a/src/lib.rs
545+++ b/src/lib.rs
546@@ -1,1 +1,3 @@
547 fn a() {}
548+let x = warn_me();
549+let y = warn_me();
550"#;
551
552        let run = run_check(&plan, &config, diff).expect("run_check");
553        assert_eq!(run.receipt.findings.len(), 1);
554        assert_eq!(run.receipt.findings[0].line, 3);
555    }
556
557    #[test]
558    fn run_check_filters_acknowledged_false_positive_fingerprints() {
559        let config = test_rule_config(diffguard_types::Severity::Warn, "warn_me");
560        let diff = r#"
561diff --git a/src/lib.rs b/src/lib.rs
562--- a/src/lib.rs
563+++ b/src/lib.rs
564@@ -1,1 +1,2 @@
565 fn a() {}
566+let x = warn_me();
567"#;
568
569        let run_unfiltered =
570            run_check(&test_plan(100, FailOn::Warn, vec![]), &config, diff).expect("run_check");
571        let fingerprint = crate::compute_fingerprint(&run_unfiltered.receipt.findings[0]);
572
573        let mut plan = test_plan(100, FailOn::Warn, vec![]);
574        plan.false_positive_fingerprints.insert(fingerprint);
575        let filtered = run_check(&plan, &config, diff).expect("run_check");
576
577        assert_eq!(filtered.receipt.findings.len(), 0);
578        assert_eq!(filtered.receipt.verdict.counts.warn, 0);
579        assert_eq!(filtered.receipt.verdict.status, VerdictStatus::Pass);
580        assert_eq!(filtered.false_positive_findings, 1);
581        assert_eq!(filtered.rule_hits.len(), 1);
582        assert_eq!(filtered.rule_hits[0].false_positive, 1);
583    }
584
585    #[test]
586    fn run_check_sets_warn_verdict_and_reasons() {
587        let plan = test_plan(100, FailOn::Warn, vec![]);
588        let config = test_rule_config(diffguard_types::Severity::Warn, "warn_me");
589        let diff = r#"
590diff --git a/src/lib.rs b/src/lib.rs
591--- a/src/lib.rs
592+++ b/src/lib.rs
593@@ -1,1 +1,2 @@
594 fn a() {}
595+let x = warn_me();
596"#;
597
598        let run = run_check(&plan, &config, diff).expect("run_check");
599        assert_eq!(run.receipt.verdict.status, VerdictStatus::Warn);
600        assert!(run.receipt.verdict.reasons.is_empty());
601    }
602
603    #[test]
604    fn run_check_sets_error_verdict_and_reasons() {
605        let plan = test_plan(100, FailOn::Error, vec![]);
606        let config = test_rule_config(diffguard_types::Severity::Error, "error_me");
607        let diff = r#"
608diff --git a/src/lib.rs b/src/lib.rs
609--- a/src/lib.rs
610+++ b/src/lib.rs
611@@ -1,1 +1,2 @@
612 fn a() {}
613+let x = error_me();
614"#;
615
616        let run = run_check(&plan, &config, diff).expect("run_check");
617        assert_eq!(run.receipt.verdict.status, VerdictStatus::Fail);
618        assert!(run.receipt.verdict.reasons.is_empty());
619    }
620
621    #[test]
622    fn run_check_includes_truncation_reason() {
623        let plan = test_plan(1, FailOn::Warn, vec![]);
624        let config = test_rule_config(diffguard_types::Severity::Warn, "warn_me");
625        let diff = r#"
626diff --git a/src/lib.rs b/src/lib.rs
627--- a/src/lib.rs
628+++ b/src/lib.rs
629@@ -1,1 +1,3 @@
630 fn a() {}
631+let x = warn_me();
632+let y = warn_me();
633"#;
634
635        let run = run_check(&plan, &config, diff).expect("run_check");
636        assert!(
637            run.receipt
638                .verdict
639                .reasons
640                .iter()
641                .any(|r| r == REASON_TRUNCATED)
642        );
643    }
644
645    #[test]
646    fn run_check_passes_with_no_findings() {
647        let plan = test_plan(100, FailOn::Warn, vec![]);
648        let config = test_rule_config(diffguard_types::Severity::Warn, "warn_me");
649        let diff = r#"
650diff --git a/src/lib.rs b/src/lib.rs
651--- a/src/lib.rs
652+++ b/src/lib.rs
653@@ -1,1 +1,2 @@
654 fn a() {}
655+let x = clean();
656"#;
657
658        let run = run_check(&plan, &config, diff).expect("run_check");
659        assert_eq!(run.receipt.verdict.status, VerdictStatus::Pass);
660        assert!(run.receipt.verdict.reasons.is_empty());
661    }
662
663    proptest! {
664        #![proptest_config(ProptestConfig::with_cases(100))]
665
666        #[test]
667        fn property_annotations_format_matches_expected(
668            severity in prop_oneof![Just(diffguard_types::Severity::Info), Just(diffguard_types::Severity::Warn), Just(diffguard_types::Severity::Error)],
669            line in 1u32..1000,
670        ) {
671            let mut finding = test_finding(severity);
672            finding.line = line;
673
674            let annotations = render_annotations(&[finding.clone()]);
675            prop_assert_eq!(annotations.len(), 1);
676
677            let level = match severity {
678                diffguard_types::Severity::Info => "notice",
679                diffguard_types::Severity::Warn => "warning",
680                diffguard_types::Severity::Error => "error",
681            };
682
683            let expected = format!(
684                "::{level} file={path},line={line}::{rule} {msg}",
685                level = level,
686                path = finding.path,
687                line = finding.line,
688                rule = finding.rule_id,
689                msg = finding.message
690            );
691
692            prop_assert_eq!(annotations[0].as_str(), expected.as_str());
693        }
694    }
695
696    #[test]
697    fn snapshot_annotations_with_multiple_severities() {
698        let findings = vec![
699            test_finding(diffguard_types::Severity::Info),
700            test_finding(diffguard_types::Severity::Warn),
701            test_finding(diffguard_types::Severity::Error),
702        ];
703        let annotations = render_annotations(&findings);
704        insta::assert_snapshot!(annotations.join("\n"));
705    }
706
707    #[test]
708    fn snapshot_json_receipt_pretty() {
709        let receipt = CheckReceipt {
710            schema: diffguard_types::CHECK_SCHEMA_V1.to_string(),
711            tool: ToolMeta {
712                name: "diffguard".to_string(),
713                version: "0.1.0".to_string(),
714            },
715            diff: DiffMeta {
716                base: "origin/main".to_string(),
717                head: "HEAD".to_string(),
718                context_lines: 0,
719                scope: diffguard_types::Scope::Added,
720                files_scanned: 1,
721                lines_scanned: 2,
722            },
723            findings: vec![
724                test_finding(diffguard_types::Severity::Warn),
725                test_finding(diffguard_types::Severity::Error),
726            ],
727            verdict: Verdict {
728                status: VerdictStatus::Fail,
729                counts: VerdictCounts {
730                    info: 0,
731                    warn: 1,
732                    error: 1,
733                    suppressed: 0,
734                },
735                reasons: vec![],
736            },
737            timing: None,
738        };
739
740        let json = serde_json::to_string_pretty(&receipt).expect("serialize receipt");
741        insta::assert_snapshot!(json);
742    }
743
744    // =========================================================================
745    // Tag filtering tests
746    // =========================================================================
747
748    fn make_rule_with_tags(id: &str, tags: Vec<&str>) -> diffguard_types::RuleConfig {
749        diffguard_types::RuleConfig {
750            id: id.to_string(),
751            severity: diffguard_types::Severity::Warn,
752            message: "Test message".to_string(),
753            languages: vec![],
754            patterns: vec!["test".to_string()],
755            paths: vec![],
756            exclude_paths: vec![],
757            ignore_comments: false,
758            ignore_strings: false,
759            match_mode: Default::default(),
760            multiline: false,
761            multiline_window: None,
762            context_patterns: vec![],
763            context_window: None,
764            escalate_patterns: vec![],
765            escalate_window: None,
766            escalate_to: None,
767            depends_on: vec![],
768            help: None,
769            url: None,
770            tags: tags.into_iter().map(|s| s.to_string()).collect(),
771            test_cases: vec![],
772        }
773    }
774
775    #[test]
776    fn filter_rule_by_tags_no_filters() {
777        let rule = make_rule_with_tags("test.rule", vec!["debug"]);
778        let plan = test_plan(100, FailOn::Error, vec![]);
779        assert!(filter_rule_by_tags(&rule, &plan));
780    }
781
782    #[test]
783    fn filter_rule_by_tags_only_tags_matches() {
784        let rule = make_rule_with_tags("test.rule", vec!["debug", "safety"]);
785        let mut plan = test_plan(100, FailOn::Error, vec![]);
786        plan.only_tags = vec!["debug".to_string()];
787        assert!(filter_rule_by_tags(&rule, &plan));
788    }
789
790    #[test]
791    fn filter_rule_by_tags_only_tags_no_match() {
792        let rule = make_rule_with_tags("test.rule", vec!["security"]);
793        let mut plan = test_plan(100, FailOn::Error, vec![]);
794        plan.only_tags = vec!["debug".to_string()];
795        assert!(!filter_rule_by_tags(&rule, &plan));
796    }
797
798    #[test]
799    fn filter_rule_by_tags_only_tags_case_insensitive() {
800        let rule = make_rule_with_tags("test.rule", vec!["DEBUG"]);
801        let mut plan = test_plan(100, FailOn::Error, vec![]);
802        plan.only_tags = vec!["debug".to_string()];
803        assert!(filter_rule_by_tags(&rule, &plan));
804    }
805
806    #[test]
807    fn filter_rule_by_tags_enable_tags_additive_with_only_tags() {
808        let rule = make_rule_with_tags("test.rule", vec!["security"]);
809        let mut plan = test_plan(100, FailOn::Error, vec![]);
810        plan.only_tags = vec!["debug".to_string()];
811        plan.enable_tags = vec!["security".to_string()];
812
813        assert!(filter_rule_by_tags(&rule, &plan));
814    }
815
816    #[test]
817    fn filter_rule_by_tags_enable_tags_no_effect_without_only_tags() {
818        let rule = make_rule_with_tags("test.rule", vec!["style"]);
819        let mut plan = test_plan(100, FailOn::Error, vec![]);
820        plan.enable_tags = vec!["security".to_string()];
821
822        assert!(filter_rule_by_tags(&rule, &plan));
823    }
824
825    #[test]
826    fn filter_rule_by_tags_disable_tags_excludes() {
827        let rule = make_rule_with_tags("test.rule", vec!["debug"]);
828        let mut plan = test_plan(100, FailOn::Error, vec![]);
829        plan.disable_tags = vec!["debug".to_string()];
830        assert!(!filter_rule_by_tags(&rule, &plan));
831    }
832
833    #[test]
834    fn filter_rule_by_tags_disable_tags_no_match() {
835        let rule = make_rule_with_tags("test.rule", vec!["safety"]);
836        let mut plan = test_plan(100, FailOn::Error, vec![]);
837        plan.disable_tags = vec!["debug".to_string()];
838        assert!(filter_rule_by_tags(&rule, &plan));
839    }
840
841    #[test]
842    fn filter_rule_by_tags_combined_filters() {
843        // Rule has both "security" and "debug" tags
844        let rule = make_rule_with_tags("test.rule", vec!["security", "debug"]);
845
846        // Only security rules, but exclude debug rules
847        let mut plan = test_plan(100, FailOn::Error, vec![]);
848        plan.only_tags = vec!["security".to_string()];
849        plan.disable_tags = vec!["debug".to_string()];
850
851        // Should be excluded because it has a disabled tag
852        assert!(!filter_rule_by_tags(&rule, &plan));
853    }
854
855    #[test]
856    fn filter_rule_by_tags_rule_without_tags() {
857        let rule = make_rule_with_tags("test.rule", vec![]);
858        let mut plan = test_plan(100, FailOn::Error, vec![]);
859        plan.only_tags = vec!["debug".to_string()];
860        // Rule without tags doesn't match only_tags filter
861        assert!(!filter_rule_by_tags(&rule, &plan));
862
863        // But with no filters, it should pass
864        plan.only_tags.clear();
865        assert!(filter_rule_by_tags(&rule, &plan));
866    }
867}