1use std::collections::{HashMap, HashSet};
7use std::path::Path;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
14#[serde(rename_all = "lowercase")]
15pub enum Severity {
16 Error,
17 #[default]
18 Warning,
19 Info,
20 Hint,
21}
22
23impl std::fmt::Display for Severity {
24 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
25 match self {
26 Severity::Error => write!(f, "error"),
27 Severity::Warning => write!(f, "warning"),
28 Severity::Info => write!(f, "info"),
29 Severity::Hint => write!(f, "hint"),
30 }
31 }
32}
33
34impl std::str::FromStr for Severity {
35 type Err = String;
36
37 fn from_str(s: &str) -> Result<Self, Self::Err> {
38 match s.to_lowercase().as_str() {
39 "error" => Ok(Severity::Error),
40 "warning" | "warn" => Ok(Severity::Warning),
41 "info" | "note" => Ok(Severity::Info),
42 "hint" => Ok(Severity::Hint),
43 _ => Err(format!("unknown severity: {}", s)),
44 }
45 }
46}
47
48#[derive(Debug, Clone, serde::Deserialize, serde::Serialize, Default, schemars::JsonSchema)]
52#[serde(default)]
53pub struct SarifTool {
54 pub name: String,
56 pub command: Vec<String>,
59 #[serde(default)]
69 pub watch: Vec<String>,
70}
71
72#[derive(Debug, Clone, serde::Deserialize, serde::Serialize, Default, schemars::JsonSchema)]
79#[serde(default)]
80pub struct RuleOverride {
81 pub severity: Option<String>,
83 pub enabled: Option<bool>,
85 #[serde(default)]
87 pub allow: Vec<String>,
88 #[serde(default)]
90 pub tags: Vec<String>,
91 #[serde(flatten)]
94 #[schemars(skip)]
95 pub extra: std::collections::HashMap<String, toml::Value>,
96}
97
98pub fn deserialize_one_or_many<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
99where
100 D: serde::Deserializer<'de>,
101{
102 use serde::Deserialize as _;
103
104 #[derive(serde::Deserialize)]
105 #[serde(untagged)]
106 enum OneOrMany {
107 One(String),
108 Many(Vec<String>),
109 }
110
111 match OneOrMany::deserialize(deserializer)? {
112 OneOrMany::One(s) => Ok(vec![s]),
113 OneOrMany::Many(v) => Ok(v),
114 }
115}
116
117impl normalize_core::Merge for RuleOverride {
118 fn merge(self, other: Self) -> Self {
125 let mut extra = self.extra;
126 extra.extend(other.extra);
127 Self {
128 severity: other.severity.or(self.severity),
129 enabled: other.enabled.or(self.enabled),
130 allow: if other.allow.is_empty() {
131 self.allow
132 } else {
133 other.allow
134 },
135 tags: if other.tags.is_empty() {
136 self.tags
137 } else {
138 other.tags
139 },
140 extra,
141 }
142 }
143}
144
145impl RuleOverride {
146 pub fn rule_config<T: serde::de::DeserializeOwned + Default>(&self) -> T {
159 let table = toml::Value::Table(
160 self.extra
161 .iter()
162 .map(|(k, v)| (k.clone(), v.clone()))
163 .collect(),
164 );
165 table.try_into().unwrap_or_default()
166 }
167}
168
169#[derive(Debug, Clone, serde::Serialize, Default, schemars::JsonSchema)]
180pub struct RulesConfig {
181 #[serde(
184 rename = "global-allow",
185 default,
186 skip_serializing_if = "Vec::is_empty"
187 )]
188 pub global_allow: Vec<String>,
189 #[serde(rename = "sarif-tools", default, skip_serializing_if = "Vec::is_empty")]
191 pub sarif_tools: Vec<SarifTool>,
192 #[serde(default, rename = "rule", skip_serializing_if = "HashMap::is_empty")]
199 pub rules: HashMap<String, RuleOverride>,
200}
201
202const RULES_RESERVED_KEYS: &[&str] = &["global-allow", "sarif-tools", "rule"];
206
207impl<'de> serde::Deserialize<'de> for RulesConfig {
208 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
209 where
210 D: serde::Deserializer<'de>,
211 {
212 let raw: HashMap<String, toml::Value> = HashMap::deserialize(deserializer)?;
216
217 let mut global_allow: Vec<String> = Vec::new();
218 let mut sarif_tools: Vec<SarifTool> = Vec::new();
219 let mut rules: HashMap<String, RuleOverride> = HashMap::new();
220 let mut legacy_rule_ids: Vec<String> = Vec::new();
221
222 for (key, value) in raw {
223 match key.as_str() {
224 "global-allow" => {
225 global_allow = value.try_into().map_err(serde::de::Error::custom)?;
226 }
227 "sarif-tools" => {
228 sarif_tools = value.try_into().map_err(serde::de::Error::custom)?;
229 }
230 "rule" => {
231 let nested: HashMap<String, RuleOverride> =
232 value.try_into().map_err(serde::de::Error::custom)?;
233 rules.extend(nested);
236 }
237 _ => {
238 let override_: RuleOverride =
240 value.try_into().map_err(serde::de::Error::custom)?;
241 legacy_rule_ids.push(key.clone());
242 rules.entry(key).or_insert(override_);
245 }
246 }
247 }
248
249 if !legacy_rule_ids.is_empty() {
250 legacy_rule_ids.sort();
251 eprintln!(
252 "warning: deprecated [rules.\"<id>\"] layout in .normalize/config.toml — \
253 migrate to [rules.rule.\"<id>\"] (affected rule ids: {}). \
254 The legacy layout will be removed in a future release.",
255 legacy_rule_ids.join(", "),
256 );
257 }
258
259 let _ = RULES_RESERVED_KEYS;
264
265 Ok(RulesConfig {
266 global_allow,
267 sarif_tools,
268 rules,
269 })
270 }
271}
272
273impl normalize_core::Merge for RulesConfig {
274 fn merge(self, other: Self) -> Self {
283 let global_allow = if other.global_allow.is_empty() {
284 self.global_allow
285 } else {
286 other.global_allow
287 };
288 let sarif_tools = if other.sarif_tools.is_empty() {
289 self.sarif_tools
290 } else {
291 other.sarif_tools
292 };
293 let mut merged_rules = self.rules;
294 merged_rules.extend(other.rules);
295 Self {
296 global_allow,
297 sarif_tools,
298 rules: merged_rules,
299 }
300 }
301}
302
303#[derive(
314 Debug,
315 Clone,
316 serde::Deserialize,
317 serde::Serialize,
318 Default,
319 schemars::JsonSchema,
320 normalize_core::Merge,
321)]
322#[serde(default)]
323pub struct WalkConfig {
324 pub ignore_files: Option<Vec<String>>,
327 pub exclude: Option<Vec<String>>,
340}
341
342impl WalkConfig {
343 pub fn ignore_files(&self) -> Vec<&str> {
345 match &self.ignore_files {
346 Some(v) => v.iter().map(|s| s.as_str()).collect(),
347 None => vec![".gitignore"],
348 }
349 }
350
351 pub fn exclude(&self) -> Vec<&str> {
356 match &self.exclude {
357 Some(v) => v.iter().map(|s| s.as_str()).collect(),
358 None => Vec::new(),
359 }
360 }
361
362 pub fn compiled_excludes(&self, root: &Path) -> ignore::gitignore::Gitignore {
367 let mut builder = ignore::gitignore::GitignoreBuilder::new(root);
368 for pat in self.exclude() {
369 let _ = builder.add_line(None, pat);
371 }
372 builder.build().unwrap_or_else(|_| {
373 ignore::gitignore::Gitignore::empty()
375 })
376 }
377
378 pub fn is_excluded_path(&self, root: &Path, rel_path: &Path, is_dir: bool) -> bool {
384 let gi = self.compiled_excludes(root);
385 gi.matched_path_or_any_parents(rel_path, is_dir).is_ignore()
386 }
387}
388
389#[derive(Debug, Clone, Default)]
395pub struct PathFilter {
396 pub only: Vec<glob::Pattern>,
397 pub exclude: Vec<glob::Pattern>,
398}
399
400impl PathFilter {
401 pub fn new(only: &[String], exclude: &[String]) -> Self {
404 Self {
405 only: only
406 .iter()
407 .filter_map(|s| glob::Pattern::new(s).ok())
408 .collect(),
409 exclude: exclude
410 .iter()
411 .filter_map(|s| glob::Pattern::new(s).ok())
412 .collect(),
413 }
414 }
415
416 pub fn is_empty(&self) -> bool {
418 self.only.is_empty() && self.exclude.is_empty()
419 }
420
421 pub fn matches(&self, rel_path: &str) -> bool {
426 if !self.exclude.is_empty() && self.exclude.iter().any(|p| p.matches(rel_path)) {
427 return false;
428 }
429 if !self.only.is_empty() && !self.only.iter().any(|p| p.matches(rel_path)) {
430 return false;
431 }
432 true
433 }
434
435 pub fn matches_path(&self, rel_path: &Path) -> bool {
437 self.matches(&rel_path.to_string_lossy())
438 }
439}
440
441#[derive(Debug, Default, Clone)]
462pub struct ConfigDiff {
463 pub rules_to_rerun: HashSet<String>,
466 pub rules_disabled: HashSet<String>,
469 pub allow_lists_changed: bool,
472 pub severities_changed: bool,
475 pub walk_exclude_changed: bool,
478}
479
480impl ConfigDiff {
481 pub fn compute(
487 old_rules: &RulesConfig,
488 new_rules: &RulesConfig,
489 old_walk: &WalkConfig,
490 new_walk: &WalkConfig,
491 ) -> Self {
492 let mut diff = ConfigDiff::default();
493
494 if old_walk.exclude() != new_walk.exclude() {
496 diff.walk_exclude_changed = true;
497 }
498
499 if old_rules.global_allow != new_rules.global_allow {
501 diff.allow_lists_changed = true;
502 }
503
504 let ids: HashSet<&str> = old_rules
506 .rules
507 .keys()
508 .chain(new_rules.rules.keys())
509 .map(String::as_str)
510 .collect();
511
512 for id in ids {
513 let old = old_rules.rules.get(id);
514 let new = new_rules.rules.get(id);
515
516 let was_enabled = old.is_none_or(|o| o.enabled.unwrap_or(true));
519 let is_enabled = new.is_none_or(|n| n.enabled.unwrap_or(true));
520 match (was_enabled, is_enabled) {
521 (true, false) => {
522 diff.rules_disabled.insert(id.to_string());
523 }
524 (false, true) => {
525 diff.rules_to_rerun.insert(id.to_string());
527 }
528 _ => {}
529 }
530
531 if was_enabled && is_enabled {
535 let old_sev = old.and_then(|o| o.severity.as_deref());
536 let new_sev = new.and_then(|n| n.severity.as_deref());
537 if old_sev != new_sev {
538 diff.severities_changed = true;
539 }
540
541 let old_allow = old.map(|o| o.allow.as_slice()).unwrap_or(&[]);
543 let new_allow = new.map(|n| n.allow.as_slice()).unwrap_or(&[]);
544 if old_allow != new_allow {
545 diff.allow_lists_changed = true;
546 }
547
548 let old_extra = old.map(|o| &o.extra);
551 let new_extra = new.map(|n| &n.extra);
552 if old_extra != new_extra {
553 diff.rules_to_rerun.insert(id.to_string());
554 }
555 let old_tags = old.map(|o| o.tags.as_slice()).unwrap_or(&[]);
556 let new_tags = new.map(|n| n.tags.as_slice()).unwrap_or(&[]);
557 if old_tags != new_tags {
558 diff.allow_lists_changed = true;
561 }
562 }
563 }
564
565 if old_rules.sarif_tools.len() != new_rules.sarif_tools.len() {
571 diff.rules_to_rerun
572 .insert("__sarif_tools_changed__".to_string());
573 } else {
574 for (a, b) in old_rules
575 .sarif_tools
576 .iter()
577 .zip(new_rules.sarif_tools.iter())
578 {
579 if a.name != b.name || a.command != b.command || a.watch != b.watch {
580 diff.rules_to_rerun
581 .insert("__sarif_tools_changed__".to_string());
582 break;
583 }
584 }
585 }
586
587 diff
588 }
589
590 pub fn is_filter_only(&self) -> bool {
599 self.rules_to_rerun.is_empty() && !self.walk_exclude_changed
600 }
601
602 pub fn requires_full_reprime(&self) -> bool {
608 self.walk_exclude_changed
609 }
610
611 pub fn is_empty(&self) -> bool {
613 self.rules_to_rerun.is_empty()
614 && self.rules_disabled.is_empty()
615 && !self.allow_lists_changed
616 && !self.severities_changed
617 && !self.walk_exclude_changed
618 }
619}
620
621#[cfg(test)]
622mod tests {
623 use super::*;
624
625 #[test]
626 fn allow_field_not_swallowed_by_extra() {
627 let toml_str = r#"
628global-allow = ["**/fixtures/**"]
629
630[rule."no-grammar-loader-new"]
631allow = ["**/tests/**", "src/lib.rs"]
632threshold = 42
633"#;
634 let config: RulesConfig = toml::from_str(toml_str).unwrap();
635 let rule = config.rules.get("no-grammar-loader-new").unwrap();
636 assert_eq!(rule.allow, vec!["**/tests/**", "src/lib.rs"]);
637 assert!(!rule.extra.contains_key("allow"));
638 assert!(rule.extra.contains_key("threshold"));
639 }
640
641 #[test]
642 fn full_config_round_trip() {
643 let toml_str = r#"
645global-allow = ["**/tests/fixtures/**", "**/fixtures/**", ".claude/**"]
646
647[rule."rust/dbg-macro"]
648severity = "error"
649allow = ["**/tests/fixtures/**"]
650
651[rule."no-grammar-loader-new"]
652allow = ["**/tests/**", "crates/*/tests/**", "**/normalize-scope/**"]
653"#;
654 let config: RulesConfig = toml::from_str(toml_str).unwrap();
655 let dbg = config.rules.get("rust/dbg-macro").unwrap();
656 assert_eq!(dbg.severity.as_deref(), Some("error"));
657 assert_eq!(dbg.allow, vec!["**/tests/fixtures/**"]);
658
659 let ngl = config.rules.get("no-grammar-loader-new").unwrap();
660 assert_eq!(ngl.allow.len(), 3);
661 assert_eq!(ngl.allow[2], "**/normalize-scope/**");
662 }
663
664 #[test]
665 fn legacy_layout_still_parses() {
666 let toml_str = r#"
669global-allow = ["**/fixtures/**"]
670
671["rust/dbg-macro"]
672severity = "error"
673"#;
674 let config: RulesConfig = toml::from_str(toml_str).unwrap();
675 assert_eq!(config.global_allow, vec!["**/fixtures/**"]);
676 let dbg = config.rules.get("rust/dbg-macro").unwrap();
677 assert_eq!(dbg.severity.as_deref(), Some("error"));
678 }
679
680 #[test]
681 fn nested_layout_does_not_collide_with_engine_keys() {
682 let toml_str = r#"
686global-allow = ["**/fixtures/**"]
687
688[rule."global-allow"]
689severity = "error"
690allow = ["legacy/**"]
691"#;
692 let config: RulesConfig = toml::from_str(toml_str).unwrap();
693 assert_eq!(config.global_allow, vec!["**/fixtures/**"]);
695 let r = config.rules.get("global-allow").unwrap();
697 assert_eq!(r.severity.as_deref(), Some("error"));
698 assert_eq!(r.allow, vec!["legacy/**"]);
699 }
700
701 #[test]
702 fn nested_layout_wins_over_legacy_on_id_collision() {
703 let toml_str = r#"
705[rule."rust/dbg-macro"]
706severity = "warning"
707
708["rust/dbg-macro"]
709severity = "error"
710"#;
711 let config: RulesConfig = toml::from_str(toml_str).unwrap();
712 let dbg = config.rules.get("rust/dbg-macro").unwrap();
713 assert_eq!(dbg.severity.as_deref(), Some("warning"));
714 }
715
716 #[test]
717 fn path_filter_empty_passes_everything() {
718 let f = PathFilter::default();
719 assert!(f.is_empty());
720 assert!(f.matches("anything/at/all.rs"));
721 }
722
723 #[test]
724 fn path_filter_only() {
725 let f = PathFilter::new(&["src/**/*.rs".into()], &[]);
726 assert!(f.matches("src/lib.rs"));
727 assert!(f.matches("src/deep/mod.rs"));
728 assert!(!f.matches("tests/integration.rs"));
729 }
730
731 #[test]
732 fn path_filter_exclude() {
733 let f = PathFilter::new(&[], &["**/tests/**".into()]);
734 assert!(f.matches("src/lib.rs"));
735 assert!(!f.matches("crates/foo/tests/bar.rs"));
736 }
737
738 #[test]
739 fn path_filter_only_and_exclude() {
740 let f = PathFilter::new(&["crates/**/*.rs".into()], &["**/tests/**".into()]);
741 assert!(f.matches("crates/foo/src/lib.rs"));
742 assert!(!f.matches("crates/foo/tests/it.rs")); assert!(!f.matches("src/main.rs")); }
745
746 #[test]
747 fn walk_config_defaults() {
748 let config = WalkConfig::default();
751 assert_eq!(config.ignore_files(), vec![".gitignore"]);
752 assert!(config.exclude().is_empty());
753 let root = Path::new("/tmp/root");
754 assert!(!config.is_excluded_path(root, Path::new(".git"), true));
755 assert!(!config.is_excluded_path(root, Path::new("src"), true));
756 }
757
758 #[test]
759 fn walk_config_custom() {
760 let config = WalkConfig {
761 ignore_files: Some(vec![".gitignore".into(), ".npmignore".into()]),
762 exclude: Some(vec![".git".into(), "node_modules".into()]),
763 };
764 assert_eq!(config.ignore_files(), vec![".gitignore", ".npmignore"]);
765 assert_eq!(config.exclude(), vec![".git", "node_modules"]);
766 let root = Path::new("/tmp/root");
767 assert!(config.is_excluded_path(root, Path::new("node_modules"), true));
768 assert!(!config.is_excluded_path(root, Path::new("src"), true));
769 }
770
771 #[test]
772 fn walk_config_empty_disables() {
773 let config = WalkConfig {
774 ignore_files: Some(vec![]),
775 exclude: Some(vec![]),
776 };
777 assert!(config.ignore_files().is_empty());
778 assert!(config.exclude().is_empty());
779 let root = Path::new("/tmp/root");
780 assert!(!config.is_excluded_path(root, Path::new(".git"), true));
781 }
782
783 #[test]
784 fn walk_config_excludes_basename_at_any_depth() {
785 let config = WalkConfig {
787 ignore_files: None,
788 exclude: Some(vec!["node_modules".into(), "worktrees".into()]),
789 };
790 let root = Path::new("/tmp/root");
791 assert!(config.is_excluded_path(root, Path::new("node_modules"), true));
793 assert!(config.is_excluded_path(root, Path::new("crates/foo/node_modules"), true));
795 assert!(config.is_excluded_path(root, Path::new(".claude/worktrees"), true));
797 }
798
799 #[test]
800 fn walk_config_excludes_anchored_glob() {
801 let config = WalkConfig {
802 ignore_files: None,
803 exclude: Some(vec!["**/target/".into(), "path/to/specific.rs".into()]),
804 };
805 let root = Path::new("/tmp/root");
806 assert!(config.is_excluded_path(root, Path::new("crates/foo/target"), true));
807 assert!(config.is_excluded_path(root, Path::new("target"), true));
808 assert!(config.is_excluded_path(root, Path::new("path/to/specific.rs"), false));
809 assert!(!config.is_excluded_path(root, Path::new("path/to/other.rs"), false));
810 }
811
812 #[test]
813 fn walk_config_deserialize() {
814 let toml_str = r#"
815ignore_files = [".gitignore", ".dockerignore"]
816exclude = [".git", "node_modules", ".cache"]
817"#;
818 let config: WalkConfig = toml::from_str(toml_str).unwrap();
819 assert_eq!(config.ignore_files(), vec![".gitignore", ".dockerignore"]);
820 assert_eq!(config.exclude(), vec![".git", "node_modules", ".cache"]);
821 }
822
823 #[test]
824 fn walk_config_merge_option_semantics() {
825 use normalize_core::Merge;
826
827 let a = WalkConfig::default();
829 let b = WalkConfig::default();
830 let merged = a.merge(b);
831 assert_eq!(merged.ignore_files(), vec![".gitignore"]);
832 assert!(merged.exclude().is_empty());
833
834 let a = WalkConfig {
836 ignore_files: Some(vec![".npmignore".into()]),
837 exclude: Some(vec!["dist".into()]),
838 };
839 let b = WalkConfig::default();
840 let merged = a.merge(b);
841 assert_eq!(merged.ignore_files(), vec![".npmignore"]);
842 assert_eq!(merged.exclude(), vec!["dist"]);
843
844 let a = WalkConfig::default();
846 let b = WalkConfig {
847 ignore_files: Some(vec![".npmignore".into()]),
848 exclude: None,
849 };
850 let merged = a.merge(b);
851 assert_eq!(merged.ignore_files(), vec![".npmignore"]);
852 assert!(merged.exclude().is_empty()); }
854
855 fn parse_rules(s: &str) -> RulesConfig {
858 toml::from_str(s).unwrap()
859 }
860
861 #[test]
862 fn config_diff_no_change_is_empty() {
863 let cfg = parse_rules(
864 r#"
865[rule."rust/dbg-macro"]
866severity = "error"
867"#,
868 );
869 let walk = WalkConfig::default();
870 let diff = ConfigDiff::compute(&cfg, &cfg, &walk, &walk);
871 assert!(diff.is_empty());
872 assert!(diff.is_filter_only());
873 assert!(!diff.requires_full_reprime());
874 }
875
876 #[test]
877 fn config_diff_severity_only_is_filter_only() {
878 let old = parse_rules(
879 r#"
880[rule."rust/dbg-macro"]
881severity = "error"
882"#,
883 );
884 let new = parse_rules(
885 r#"
886[rule."rust/dbg-macro"]
887severity = "info"
888"#,
889 );
890 let walk = WalkConfig::default();
891 let diff = ConfigDiff::compute(&old, &new, &walk, &walk);
892 assert!(diff.severities_changed);
893 assert!(diff.is_filter_only());
894 assert!(diff.rules_to_rerun.is_empty());
895 }
896
897 #[test]
898 fn config_diff_allow_change_is_filter_only() {
899 let old = parse_rules(
900 r#"
901global-allow = ["**/fixtures/**"]
902"#,
903 );
904 let new = parse_rules(
905 r#"
906global-allow = ["**/fixtures/**", "**/tests/**"]
907"#,
908 );
909 let walk = WalkConfig::default();
910 let diff = ConfigDiff::compute(&old, &new, &walk, &walk);
911 assert!(diff.allow_lists_changed);
912 assert!(diff.is_filter_only());
913 }
914
915 #[test]
916 fn config_diff_disable_is_filter_only() {
917 let old = parse_rules(
918 r#"
919[rule."rust/dbg-macro"]
920severity = "error"
921"#,
922 );
923 let new = parse_rules(
924 r#"
925[rule."rust/dbg-macro"]
926severity = "error"
927enabled = false
928"#,
929 );
930 let walk = WalkConfig::default();
931 let diff = ConfigDiff::compute(&old, &new, &walk, &walk);
932 assert!(diff.rules_disabled.contains("rust/dbg-macro"));
933 assert!(diff.rules_to_rerun.is_empty());
934 assert!(diff.is_filter_only());
935 }
936
937 #[test]
938 fn config_diff_enable_requires_rerun() {
939 let old = parse_rules(
940 r#"
941[rule."rust/dbg-macro"]
942enabled = false
943"#,
944 );
945 let new = parse_rules(
946 r#"
947[rule."rust/dbg-macro"]
948enabled = true
949"#,
950 );
951 let walk = WalkConfig::default();
952 let diff = ConfigDiff::compute(&old, &new, &walk, &walk);
953 assert!(diff.rules_to_rerun.contains("rust/dbg-macro"));
954 assert!(!diff.is_filter_only());
955 assert!(!diff.requires_full_reprime());
956 }
957
958 #[test]
959 fn config_diff_threshold_change_requires_rerun() {
960 let old = parse_rules(
961 r#"
962[rule."long-function"]
963threshold = 100
964"#,
965 );
966 let new = parse_rules(
967 r#"
968[rule."long-function"]
969threshold = 50
970"#,
971 );
972 let walk = WalkConfig::default();
973 let diff = ConfigDiff::compute(&old, &new, &walk, &walk);
974 assert!(diff.rules_to_rerun.contains("long-function"));
975 }
976
977 #[test]
978 fn config_diff_walk_exclude_change_requires_full_reprime() {
979 let cfg = RulesConfig::default();
980 let old_walk = WalkConfig::default();
981 let new_walk = WalkConfig {
982 ignore_files: None,
983 exclude: Some(vec![".git".into(), "node_modules".into()]),
984 };
985 let diff = ConfigDiff::compute(&cfg, &cfg, &old_walk, &new_walk);
986 assert!(diff.walk_exclude_changed);
987 assert!(diff.requires_full_reprime());
988 assert!(!diff.is_filter_only());
989 }
990}