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 pub only_tags: Vec<String>,
30 pub enable_tags: Vec<String>,
33 pub disable_tags: Vec<String>,
36 pub directory_overrides: Vec<DirectoryRuleOverride>,
38 pub force_language: Option<String>,
40 pub allowed_lines: Option<BTreeSet<(String, u32)>>,
43 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 pub truncated_findings: u32,
55 pub rules_evaluated: usize,
57 pub rule_hits: Vec<RuleHitStat>,
59 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 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 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
262fn filter_rule_by_tags(rule: &diffguard_types::RuleConfig, plan: &CheckPlan) -> bool {
268 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 !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 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 let rule = make_rule_with_tags("test.rule", vec!["security", "debug"]);
845
846 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 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 assert!(!filter_rule_by_tags(&rule, &plan));
862
863 plan.only_tags.clear();
865 assert!(filter_rule_by_tags(&rule, &plan));
866 }
867}