1use std::collections::{HashMap, HashSet};
2use std::path::PathBuf;
3
4use serde::Deserialize;
5
6use crate::facts::FactSpec;
7use crate::level::Level;
8
9#[derive(Debug, Clone, Deserialize, Default)]
11#[serde(deny_unknown_fields)]
12pub struct Config {
13 pub version: u32,
14 #[serde(default)]
22 pub extends: Vec<ExtendsEntry>,
23 #[serde(default)]
24 pub ignore: Vec<String>,
25 #[serde(default = "default_respect_gitignore")]
26 pub respect_gitignore: bool,
27 #[serde(default)]
30 pub vars: HashMap<String, String>,
31 #[serde(default)]
34 pub facts: Vec<FactSpec>,
35 #[serde(default)]
36 pub rules: Vec<RuleSpec>,
37 #[serde(default = "default_fix_size_limit")]
46 pub fix_size_limit: Option<u64>,
47 #[serde(default)]
59 pub nested_configs: bool,
60 #[serde(skip)]
68 pub allow_out_of_root: AllowOutOfRoot,
69}
70
71#[allow(clippy::unnecessary_wraps)]
77fn default_fix_size_limit() -> Option<u64> {
78 Some(1 << 20)
79}
80
81fn default_respect_gitignore() -> bool {
82 true
83}
84
85impl Config {
86 pub const CURRENT_VERSION: u32 = 1;
87}
88
89#[derive(Debug, Clone, Default, PartialEq, Eq)]
101pub enum AllowOutOfRoot {
102 #[default]
104 Confined,
105 All,
107 Selective {
109 kinds: HashSet<String>,
110 rules: HashSet<String>,
111 },
112}
113
114impl AllowOutOfRoot {
115 #[must_use]
117 pub fn allows(&self, id: &str, kind: &str) -> bool {
118 match self {
119 Self::Confined => false,
120 Self::All => true,
121 Self::Selective { kinds, rules } => kinds.contains(kind) || rules.contains(id),
122 }
123 }
124
125 #[must_use]
128 pub fn is_confined(&self) -> bool {
129 matches!(self, Self::Confined)
130 }
131}
132
133impl<'de> Deserialize<'de> for AllowOutOfRoot {
134 fn deserialize<D: serde::Deserializer<'de>>(
135 deserializer: D,
136 ) -> std::result::Result<Self, D::Error> {
137 #[derive(Deserialize)]
138 #[serde(deny_unknown_fields)]
139 struct SelectiveSpec {
140 #[serde(default)]
141 kinds: Vec<String>,
142 #[serde(default)]
143 rules: Vec<String>,
144 }
145 #[derive(Deserialize)]
146 #[serde(untagged)]
147 enum Raw {
148 Flag(bool),
149 Selective(SelectiveSpec),
150 }
151 Ok(match Raw::deserialize(deserializer)? {
152 Raw::Flag(true) => Self::All,
153 Raw::Flag(false) => Self::Confined,
154 Raw::Selective(s) => Self::Selective {
155 kinds: s.kinds.into_iter().collect(),
156 rules: s.rules.into_iter().collect(),
157 },
158 })
159 }
160}
161
162#[derive(Debug, Clone, Deserialize)]
182#[serde(untagged)]
183pub enum ExtendsEntry {
184 Url(String),
185 Filtered {
186 url: String,
187 #[serde(default)]
188 only: Option<Vec<String>>,
189 #[serde(default)]
190 except: Option<Vec<String>>,
191 },
192}
193
194impl ExtendsEntry {
195 pub fn url(&self) -> &str {
198 match self {
199 Self::Url(s) | Self::Filtered { url: s, .. } => s,
200 }
201 }
202
203 pub fn only(&self) -> Option<&[String]> {
206 match self {
207 Self::Filtered { only: Some(v), .. } => Some(v),
208 _ => None,
209 }
210 }
211
212 pub fn except(&self) -> Option<&[String]> {
215 match self {
216 Self::Filtered {
217 except: Some(v), ..
218 } => Some(v),
219 _ => None,
220 }
221 }
222}
223
224#[derive(Debug, Clone, Deserialize)]
229#[serde(untagged)]
230pub enum PathsSpec {
231 Single(String),
232 Many(Vec<String>),
233 IncludeExclude {
234 #[serde(default, deserialize_with = "string_or_vec")]
235 include: Vec<String>,
236 #[serde(default, deserialize_with = "string_or_vec")]
237 exclude: Vec<String>,
238 },
239}
240
241fn string_or_vec<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
242where
243 D: serde::Deserializer<'de>,
244{
245 #[derive(Deserialize)]
246 #[serde(untagged)]
247 enum OneOrMany {
248 One(String),
249 Many(Vec<String>),
250 }
251 match OneOrMany::deserialize(deserializer)? {
252 OneOrMany::One(s) => Ok(vec![s]),
253 OneOrMany::Many(v) => Ok(v),
254 }
255}
256
257#[derive(Debug, Clone, Deserialize)]
260pub struct RuleSpec {
261 pub id: String,
262 pub kind: String,
263 pub level: Level,
264 #[serde(default)]
265 pub paths: Option<PathsSpec>,
266 #[serde(default)]
267 pub message: Option<String>,
268 #[serde(default)]
269 pub policy_url: Option<String>,
270 #[serde(default)]
271 pub when: Option<String>,
272 #[serde(default)]
277 pub fix: Option<FixSpec>,
278 #[serde(default)]
290 pub git_tracked_only: bool,
291 #[serde(default)]
312 pub respect_gitignore: Option<bool>,
313 #[serde(default)]
328 pub scope_filter: Option<crate::ScopeFilterSpec>,
329 #[serde(flatten)]
332 pub extra: serde_yaml_ng::Mapping,
333}
334
335#[derive(Debug, Clone, Deserialize)]
338#[serde(untagged)]
339pub enum FixSpec {
340 FileCreate {
341 file_create: FileCreateFixSpec,
342 },
343 FileRemove {
344 file_remove: FileRemoveFixSpec,
345 },
346 FilePrepend {
347 file_prepend: FilePrependFixSpec,
348 },
349 FileAppend {
350 file_append: FileAppendFixSpec,
351 },
352 FileRename {
353 file_rename: FileRenameFixSpec,
354 },
355 FileTrimTrailingWhitespace {
356 file_trim_trailing_whitespace: FileTrimTrailingWhitespaceFixSpec,
357 },
358 FileAppendFinalNewline {
359 file_append_final_newline: FileAppendFinalNewlineFixSpec,
360 },
361 FileNormalizeLineEndings {
362 file_normalize_line_endings: FileNormalizeLineEndingsFixSpec,
363 },
364 FileStripBidi {
365 file_strip_bidi: FileStripBidiFixSpec,
366 },
367 FileStripZeroWidth {
368 file_strip_zero_width: FileStripZeroWidthFixSpec,
369 },
370 FileStripBom {
371 file_strip_bom: FileStripBomFixSpec,
372 },
373 FileCollapseBlankLines {
374 file_collapse_blank_lines: FileCollapseBlankLinesFixSpec,
375 },
376}
377
378impl FixSpec {
379 pub fn op_name(&self) -> &'static str {
381 match self {
382 Self::FileCreate { .. } => "file_create",
383 Self::FileRemove { .. } => "file_remove",
384 Self::FilePrepend { .. } => "file_prepend",
385 Self::FileAppend { .. } => "file_append",
386 Self::FileRename { .. } => "file_rename",
387 Self::FileTrimTrailingWhitespace { .. } => "file_trim_trailing_whitespace",
388 Self::FileAppendFinalNewline { .. } => "file_append_final_newline",
389 Self::FileNormalizeLineEndings { .. } => "file_normalize_line_endings",
390 Self::FileStripBidi { .. } => "file_strip_bidi",
391 Self::FileStripZeroWidth { .. } => "file_strip_zero_width",
392 Self::FileStripBom { .. } => "file_strip_bom",
393 Self::FileCollapseBlankLines { .. } => "file_collapse_blank_lines",
394 }
395 }
396}
397
398#[derive(Debug, Clone, Deserialize)]
399#[serde(deny_unknown_fields)]
400pub struct FileCreateFixSpec {
401 #[serde(default)]
405 pub content: Option<String>,
406 #[serde(default)]
413 pub content_from: Option<PathBuf>,
414 #[serde(default)]
418 pub path: Option<PathBuf>,
419 #[serde(default = "default_create_parents")]
421 pub create_parents: bool,
422}
423
424fn default_create_parents() -> bool {
425 true
426}
427
428#[derive(Debug, Clone, Deserialize, Default)]
429#[serde(deny_unknown_fields)]
430pub struct FileRemoveFixSpec {}
431
432#[derive(Debug, Clone, Deserialize)]
433#[serde(deny_unknown_fields)]
434pub struct FilePrependFixSpec {
435 #[serde(default)]
439 pub content: Option<String>,
440 #[serde(default)]
443 pub content_from: Option<PathBuf>,
444}
445
446#[derive(Debug, Clone, Deserialize)]
447#[serde(deny_unknown_fields)]
448pub struct FileAppendFixSpec {
449 #[serde(default)]
453 pub content: Option<String>,
454 #[serde(default)]
457 pub content_from: Option<PathBuf>,
458}
459
460pub fn resolve_content_source(
464 rule_id: &str,
465 op_name: &str,
466 inline: &Option<String>,
467 from: &Option<PathBuf>,
468) -> crate::error::Result<ContentSourceSpec> {
469 match (inline, from) {
470 (Some(_), Some(_)) => Err(crate::error::Error::rule_config(
471 rule_id,
472 format!("fix.{op_name}: `content` and `content_from` are mutually exclusive"),
473 )),
474 (None, None) => Err(crate::error::Error::rule_config(
475 rule_id,
476 format!("fix.{op_name}: one of `content` or `content_from` is required"),
477 )),
478 (Some(s), None) => Ok(ContentSourceSpec::Inline(s.clone())),
479 (None, Some(p)) => Ok(ContentSourceSpec::File(p.clone())),
480 }
481}
482
483#[derive(Debug, Clone)]
487pub enum ContentSourceSpec {
488 Inline(String),
490 File(PathBuf),
493}
494
495impl From<String> for ContentSourceSpec {
496 fn from(s: String) -> Self {
497 Self::Inline(s)
498 }
499}
500
501impl From<&str> for ContentSourceSpec {
502 fn from(s: &str) -> Self {
503 Self::Inline(s.to_string())
504 }
505}
506
507#[derive(Debug, Clone, Deserialize, Default)]
511#[serde(deny_unknown_fields)]
512pub struct FileRenameFixSpec {}
513
514#[derive(Debug, Clone, Deserialize, Default)]
517#[serde(deny_unknown_fields)]
518pub struct FileTrimTrailingWhitespaceFixSpec {}
519
520#[derive(Debug, Clone, Deserialize, Default)]
523#[serde(deny_unknown_fields)]
524pub struct FileAppendFinalNewlineFixSpec {}
525
526#[derive(Debug, Clone, Deserialize, Default)]
529#[serde(deny_unknown_fields)]
530pub struct FileNormalizeLineEndingsFixSpec {}
531
532#[derive(Debug, Clone, Deserialize, Default)]
535#[serde(deny_unknown_fields)]
536pub struct FileStripBidiFixSpec {}
537
538#[derive(Debug, Clone, Deserialize, Default)]
543#[serde(deny_unknown_fields)]
544pub struct FileStripZeroWidthFixSpec {}
545
546#[derive(Debug, Clone, Deserialize, Default)]
549#[serde(deny_unknown_fields)]
550pub struct FileStripBomFixSpec {}
551
552#[derive(Debug, Clone, Deserialize, Default)]
555#[serde(deny_unknown_fields)]
556pub struct FileCollapseBlankLinesFixSpec {}
557
558impl RuleSpec {
559 pub fn deserialize_options<T>(&self) -> crate::error::Result<T>
564 where
565 T: serde::de::DeserializeOwned,
566 {
567 Ok(serde_yaml_ng::from_value(serde_yaml_ng::Value::Mapping(
568 self.extra.clone(),
569 ))?)
570 }
571
572 pub fn parse_scope_filter(&self) -> crate::error::Result<Option<crate::ScopeFilter>> {
590 match &self.scope_filter {
591 Some(spec) => Ok(Some(crate::ScopeFilter::from_spec(&self.id, spec.clone())?)),
592 None => Ok(None),
593 }
594 }
595}
596
597#[derive(Debug, Clone, Deserialize)]
602pub struct NestedRuleSpec {
603 pub kind: String,
604 #[serde(default)]
605 pub paths: Option<PathsSpec>,
606 #[serde(default)]
607 pub message: Option<String>,
608 #[serde(default)]
609 pub policy_url: Option<String>,
610 #[serde(default)]
611 pub when: Option<String>,
612 #[serde(default)]
617 pub scope_filter: Option<crate::ScopeFilterSpec>,
618 #[serde(flatten)]
619 pub extra: serde_yaml_ng::Mapping,
620}
621
622#[derive(Debug)]
640pub struct CompiledNestedSpec {
641 pub spec: NestedRuleSpec,
646 pub when: Option<crate::when::WhenExpr>,
649}
650
651impl CompiledNestedSpec {
652 pub fn compile(
659 spec: NestedRuleSpec,
660 parent_id: &str,
661 idx: usize,
662 ) -> crate::error::Result<Self> {
663 let when = match spec.when.as_deref() {
664 Some(src) => Some(crate::when::parse(src).map_err(|e| {
665 crate::error::Error::rule_config(
666 parent_id,
667 format!("nested rule #{idx}: invalid when: {e}"),
668 )
669 })?),
670 None => None,
671 };
672 Ok(Self { spec, when })
673 }
674}
675
676impl NestedRuleSpec {
677 pub fn instantiate(
682 &self,
683 parent_id: &str,
684 idx: usize,
685 level: Level,
686 tokens: &crate::template::PathTokens,
687 ) -> RuleSpec {
688 RuleSpec {
689 id: format!("{parent_id}.require[{idx}]"),
690 kind: self.kind.clone(),
691 level,
692 paths: self
693 .paths
694 .as_ref()
695 .map(|p| crate::template::render_paths_spec(p, tokens)),
696 message: self
697 .message
698 .as_deref()
699 .map(|m| crate::template::render_path(m, tokens)),
700 policy_url: self.policy_url.clone(),
701 when: self.when.clone(),
702 fix: None,
703 git_tracked_only: false,
709 respect_gitignore: None,
710 scope_filter: self.scope_filter.clone(),
711 extra: crate::template::render_mapping(self.extra.clone(), tokens),
712 }
713 }
714}
715
716#[cfg(test)]
717mod tests {
718 use super::*;
719 use crate::template::PathTokens;
720 use std::path::Path;
721
722 #[test]
723 fn allow_out_of_root_policy_resolves() {
724 assert!(!AllowOutOfRoot::Confined.allows("r", "k"));
725 assert!(AllowOutOfRoot::All.allows("r", "k"));
726 let sel = AllowOutOfRoot::Selective {
727 kinds: ["json_schema_passes".to_string()].into_iter().collect(),
728 rules: ["my-rule".to_string()].into_iter().collect(),
729 };
730 assert!(sel.allows("anything", "json_schema_passes"), "by kind");
731 assert!(sel.allows("my-rule", "other_kind"), "by id");
732 assert!(!sel.allows("nope", "other_kind"), "neither id nor kind");
733 assert!(AllowOutOfRoot::Confined.is_confined());
734 assert!(!AllowOutOfRoot::All.is_confined());
735 }
736
737 #[test]
738 fn allow_out_of_root_deserializes_bool_and_map() {
739 let t: AllowOutOfRoot = serde_yaml_ng::from_str("true").unwrap();
740 assert_eq!(t, AllowOutOfRoot::All);
741 let f: AllowOutOfRoot = serde_yaml_ng::from_str("false").unwrap();
742 assert_eq!(f, AllowOutOfRoot::Confined);
743 let m: AllowOutOfRoot = serde_yaml_ng::from_str("kinds: [pair_hash]\nrules: [x]").unwrap();
744 match m {
745 AllowOutOfRoot::Selective { kinds, rules } => {
746 assert!(kinds.contains("pair_hash"));
747 assert!(rules.contains("x"));
748 }
749 other => panic!("expected Selective, got {other:?}"),
750 }
751 assert!(serde_yaml_ng::from_str::<AllowOutOfRoot>("bogus: 1").is_err());
753 }
754
755 #[test]
756 fn config_default_respects_gitignore_and_caps_fix_size() {
757 let cfg: Config = serde_yaml_ng::from_str("version: 1\n").expect("minimal config");
760 assert_eq!(cfg.version, 1);
761 assert!(cfg.respect_gitignore);
762 assert_eq!(cfg.fix_size_limit, Some(1 << 20));
763 assert!(!cfg.nested_configs);
764 assert!(cfg.extends.is_empty());
765 assert!(cfg.rules.is_empty());
766 }
767
768 #[test]
769 fn config_rejects_unknown_top_level_field() {
770 let err = serde_yaml_ng::from_str::<Config>("version: 1\nignored_typo: true\n");
771 assert!(err.is_err(), "deny_unknown_fields should reject typos");
772 }
773
774 #[test]
775 fn config_explicit_null_disables_fix_size_limit() {
776 let cfg: Config = serde_yaml_ng::from_str("version: 1\nfix_size_limit: null\n").unwrap();
777 assert_eq!(cfg.fix_size_limit, None);
778 }
779
780 #[test]
781 fn extends_entry_url_form_has_no_filters() {
782 let e = ExtendsEntry::Url("alint://bundled/oss-baseline@v1".into());
783 assert_eq!(e.url(), "alint://bundled/oss-baseline@v1");
784 assert!(e.only().is_none());
785 assert!(e.except().is_none());
786 }
787
788 #[test]
789 fn extends_entry_filtered_form_exposes_only_and_except() {
790 let e = ExtendsEntry::Filtered {
791 url: "alint://bundled/rust@v1".into(),
792 only: Some(vec!["rust-edition".into()]),
793 except: None,
794 };
795 assert_eq!(e.url(), "alint://bundled/rust@v1");
796 assert_eq!(e.only(), Some(&["rust-edition".to_string()][..]));
797 assert!(e.except().is_none());
798 }
799
800 #[test]
801 fn extends_entry_filtered_form_supports_except_only() {
802 let e = ExtendsEntry::Filtered {
803 url: "./team.yml".into(),
804 only: None,
805 except: Some(vec!["legacy-rule".into()]),
806 };
807 assert_eq!(e.except(), Some(&["legacy-rule".to_string()][..]));
808 assert!(e.only().is_none());
809 }
810
811 #[test]
812 fn paths_spec_accepts_three_shapes() {
813 let single: PathsSpec = serde_yaml_ng::from_str("\"src/**\"").unwrap();
814 assert!(matches!(single, PathsSpec::Single(s) if s == "src/**"));
815
816 let many: PathsSpec = serde_yaml_ng::from_str("[\"src/**\", \"!src/vendor/**\"]").unwrap();
817 assert!(matches!(many, PathsSpec::Many(v) if v.len() == 2));
818
819 let inc_exc: PathsSpec =
820 serde_yaml_ng::from_str("include: src/**\nexclude: src/vendor/**\n").unwrap();
821 match inc_exc {
822 PathsSpec::IncludeExclude { include, exclude } => {
823 assert_eq!(include, vec!["src/**"]);
824 assert_eq!(exclude, vec!["src/vendor/**"]);
825 }
826 _ => panic!("expected include/exclude shape"),
827 }
828 }
829
830 #[test]
831 fn paths_spec_include_accepts_string_or_vec() {
832 let from_string: PathsSpec =
833 serde_yaml_ng::from_str("include: a\nexclude:\n - b\n - c\n").unwrap();
834 let PathsSpec::IncludeExclude { include, exclude } = from_string else {
835 panic!("expected include/exclude shape");
836 };
837 assert_eq!(include, vec!["a"]);
838 assert_eq!(exclude, vec!["b", "c"]);
839 }
840
841 #[test]
842 fn rule_spec_deserialize_options_picks_up_kind_specific_fields() {
843 #[derive(Deserialize, Debug)]
844 struct PatternOnly {
845 pattern: String,
846 }
847 let spec: RuleSpec = serde_yaml_ng::from_str(
848 "id: r\nkind: file_content_matches\nlevel: error\npaths: src/**\npattern: TODO\n",
849 )
850 .unwrap();
851 let opts: PatternOnly = spec.deserialize_options().unwrap();
852 assert_eq!(opts.pattern, "TODO");
853 }
854
855 #[test]
856 fn fix_spec_op_name_covers_every_variant() {
857 let cases = [
861 ("file_create:\n content: x\n", "file_create"),
862 ("file_remove: {}", "file_remove"),
863 ("file_prepend:\n content: x\n", "file_prepend"),
864 ("file_append:\n content: x\n", "file_append"),
865 ("file_rename: {}", "file_rename"),
866 (
867 "file_trim_trailing_whitespace: {}",
868 "file_trim_trailing_whitespace",
869 ),
870 ("file_append_final_newline: {}", "file_append_final_newline"),
871 (
872 "file_normalize_line_endings: {}",
873 "file_normalize_line_endings",
874 ),
875 ("file_strip_bidi: {}", "file_strip_bidi"),
876 ("file_strip_zero_width: {}", "file_strip_zero_width"),
877 ("file_strip_bom: {}", "file_strip_bom"),
878 ("file_collapse_blank_lines: {}", "file_collapse_blank_lines"),
879 ];
880 for (yaml, expected) in cases {
881 let spec: FixSpec =
882 serde_yaml_ng::from_str(yaml).unwrap_or_else(|e| panic!("{yaml}: {e}"));
883 assert_eq!(spec.op_name(), expected);
884 }
885 }
886
887 #[test]
888 fn resolve_content_source_inline_only() {
889 let s = Some("hello".to_string());
890 let resolved = resolve_content_source("r", "file_create", &s, &None).unwrap();
891 assert!(matches!(resolved, ContentSourceSpec::Inline(b) if b == "hello"));
892 }
893
894 #[test]
895 fn resolve_content_source_file_only() {
896 let p = Some(Path::new("LICENSE").into());
897 let resolved = resolve_content_source("r", "file_create", &None, &p).unwrap();
898 assert!(matches!(resolved, ContentSourceSpec::File(p) if p == Path::new("LICENSE")));
899 }
900
901 #[test]
902 fn resolve_content_source_rejects_both_set() {
903 let err = resolve_content_source(
904 "r",
905 "file_prepend",
906 &Some("x".into()),
907 &Some(Path::new("y").into()),
908 )
909 .unwrap_err();
910 assert!(err.to_string().contains("mutually exclusive"));
911 }
912
913 #[test]
914 fn resolve_content_source_rejects_neither_set() {
915 let err = resolve_content_source("r", "file_append", &None, &None).unwrap_err();
916 assert!(err.to_string().contains("required"));
917 }
918
919 #[test]
920 fn content_source_spec_from_string_variants() {
921 let from_owned: ContentSourceSpec = String::from("hi").into();
922 assert!(matches!(from_owned, ContentSourceSpec::Inline(s) if s == "hi"));
923 let from_str: ContentSourceSpec = "hi".into();
924 assert!(matches!(from_str, ContentSourceSpec::Inline(s) if s == "hi"));
925 }
926
927 #[test]
928 fn nested_rule_spec_instantiate_synthesizes_id_and_inherits_level() {
929 let nested: NestedRuleSpec = serde_yaml_ng::from_str(
930 "kind: file_exists\npaths: \"{path}/README.md\"\nmessage: missing in {path}\n",
931 )
932 .unwrap();
933 let tokens = PathTokens::from_path(Path::new("packages/foo"));
934 let spec = nested.instantiate("every-pkg-has-readme", 0, Level::Error, &tokens);
935
936 assert_eq!(spec.id, "every-pkg-has-readme.require[0]");
937 assert_eq!(spec.kind, "file_exists");
938 assert_eq!(spec.level, Level::Error);
939 match spec.paths {
942 Some(PathsSpec::Single(p)) => assert_eq!(p, "packages/foo/README.md"),
943 other => panic!("unexpected paths shape: {other:?}"),
944 }
945 assert_eq!(spec.message.as_deref(), Some("missing in packages/foo"));
946 assert!(!spec.git_tracked_only);
949 }
950}