1use std::collections::HashMap;
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}
61
62#[allow(clippy::unnecessary_wraps)]
68fn default_fix_size_limit() -> Option<u64> {
69 Some(1 << 20)
70}
71
72fn default_respect_gitignore() -> bool {
73 true
74}
75
76impl Config {
77 pub const CURRENT_VERSION: u32 = 1;
78}
79
80#[derive(Debug, Clone, Deserialize)]
100#[serde(untagged)]
101pub enum ExtendsEntry {
102 Url(String),
103 Filtered {
104 url: String,
105 #[serde(default)]
106 only: Option<Vec<String>>,
107 #[serde(default)]
108 except: Option<Vec<String>>,
109 },
110}
111
112impl ExtendsEntry {
113 pub fn url(&self) -> &str {
116 match self {
117 Self::Url(s) | Self::Filtered { url: s, .. } => s,
118 }
119 }
120
121 pub fn only(&self) -> Option<&[String]> {
124 match self {
125 Self::Filtered { only: Some(v), .. } => Some(v),
126 _ => None,
127 }
128 }
129
130 pub fn except(&self) -> Option<&[String]> {
133 match self {
134 Self::Filtered {
135 except: Some(v), ..
136 } => Some(v),
137 _ => None,
138 }
139 }
140}
141
142#[derive(Debug, Clone, Deserialize)]
147#[serde(untagged)]
148pub enum PathsSpec {
149 Single(String),
150 Many(Vec<String>),
151 IncludeExclude {
152 #[serde(default, deserialize_with = "string_or_vec")]
153 include: Vec<String>,
154 #[serde(default, deserialize_with = "string_or_vec")]
155 exclude: Vec<String>,
156 },
157}
158
159fn string_or_vec<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
160where
161 D: serde::Deserializer<'de>,
162{
163 #[derive(Deserialize)]
164 #[serde(untagged)]
165 enum OneOrMany {
166 One(String),
167 Many(Vec<String>),
168 }
169 match OneOrMany::deserialize(deserializer)? {
170 OneOrMany::One(s) => Ok(vec![s]),
171 OneOrMany::Many(v) => Ok(v),
172 }
173}
174
175#[derive(Debug, Clone, Deserialize)]
178pub struct RuleSpec {
179 pub id: String,
180 pub kind: String,
181 pub level: Level,
182 #[serde(default)]
183 pub paths: Option<PathsSpec>,
184 #[serde(default)]
185 pub message: Option<String>,
186 #[serde(default)]
187 pub policy_url: Option<String>,
188 #[serde(default)]
189 pub when: Option<String>,
190 #[serde(default)]
195 pub fix: Option<FixSpec>,
196 #[serde(default)]
208 pub git_tracked_only: bool,
209 #[serde(default)]
224 pub scope_filter: Option<crate::ScopeFilterSpec>,
225 #[serde(flatten)]
228 pub extra: serde_yaml_ng::Mapping,
229}
230
231#[derive(Debug, Clone, Deserialize)]
234#[serde(untagged)]
235pub enum FixSpec {
236 FileCreate {
237 file_create: FileCreateFixSpec,
238 },
239 FileRemove {
240 file_remove: FileRemoveFixSpec,
241 },
242 FilePrepend {
243 file_prepend: FilePrependFixSpec,
244 },
245 FileAppend {
246 file_append: FileAppendFixSpec,
247 },
248 FileRename {
249 file_rename: FileRenameFixSpec,
250 },
251 FileTrimTrailingWhitespace {
252 file_trim_trailing_whitespace: FileTrimTrailingWhitespaceFixSpec,
253 },
254 FileAppendFinalNewline {
255 file_append_final_newline: FileAppendFinalNewlineFixSpec,
256 },
257 FileNormalizeLineEndings {
258 file_normalize_line_endings: FileNormalizeLineEndingsFixSpec,
259 },
260 FileStripBidi {
261 file_strip_bidi: FileStripBidiFixSpec,
262 },
263 FileStripZeroWidth {
264 file_strip_zero_width: FileStripZeroWidthFixSpec,
265 },
266 FileStripBom {
267 file_strip_bom: FileStripBomFixSpec,
268 },
269 FileCollapseBlankLines {
270 file_collapse_blank_lines: FileCollapseBlankLinesFixSpec,
271 },
272}
273
274impl FixSpec {
275 pub fn op_name(&self) -> &'static str {
277 match self {
278 Self::FileCreate { .. } => "file_create",
279 Self::FileRemove { .. } => "file_remove",
280 Self::FilePrepend { .. } => "file_prepend",
281 Self::FileAppend { .. } => "file_append",
282 Self::FileRename { .. } => "file_rename",
283 Self::FileTrimTrailingWhitespace { .. } => "file_trim_trailing_whitespace",
284 Self::FileAppendFinalNewline { .. } => "file_append_final_newline",
285 Self::FileNormalizeLineEndings { .. } => "file_normalize_line_endings",
286 Self::FileStripBidi { .. } => "file_strip_bidi",
287 Self::FileStripZeroWidth { .. } => "file_strip_zero_width",
288 Self::FileStripBom { .. } => "file_strip_bom",
289 Self::FileCollapseBlankLines { .. } => "file_collapse_blank_lines",
290 }
291 }
292}
293
294#[derive(Debug, Clone, Deserialize)]
295#[serde(deny_unknown_fields)]
296pub struct FileCreateFixSpec {
297 #[serde(default)]
301 pub content: Option<String>,
302 #[serde(default)]
309 pub content_from: Option<PathBuf>,
310 #[serde(default)]
314 pub path: Option<PathBuf>,
315 #[serde(default = "default_create_parents")]
317 pub create_parents: bool,
318}
319
320fn default_create_parents() -> bool {
321 true
322}
323
324#[derive(Debug, Clone, Deserialize, Default)]
325#[serde(deny_unknown_fields)]
326pub struct FileRemoveFixSpec {}
327
328#[derive(Debug, Clone, Deserialize)]
329#[serde(deny_unknown_fields)]
330pub struct FilePrependFixSpec {
331 #[serde(default)]
335 pub content: Option<String>,
336 #[serde(default)]
339 pub content_from: Option<PathBuf>,
340}
341
342#[derive(Debug, Clone, Deserialize)]
343#[serde(deny_unknown_fields)]
344pub struct FileAppendFixSpec {
345 #[serde(default)]
349 pub content: Option<String>,
350 #[serde(default)]
353 pub content_from: Option<PathBuf>,
354}
355
356pub fn resolve_content_source(
360 rule_id: &str,
361 op_name: &str,
362 inline: &Option<String>,
363 from: &Option<PathBuf>,
364) -> crate::error::Result<ContentSourceSpec> {
365 match (inline, from) {
366 (Some(_), Some(_)) => Err(crate::error::Error::rule_config(
367 rule_id,
368 format!("fix.{op_name}: `content` and `content_from` are mutually exclusive"),
369 )),
370 (None, None) => Err(crate::error::Error::rule_config(
371 rule_id,
372 format!("fix.{op_name}: one of `content` or `content_from` is required"),
373 )),
374 (Some(s), None) => Ok(ContentSourceSpec::Inline(s.clone())),
375 (None, Some(p)) => Ok(ContentSourceSpec::File(p.clone())),
376 }
377}
378
379#[derive(Debug, Clone)]
383pub enum ContentSourceSpec {
384 Inline(String),
386 File(PathBuf),
389}
390
391impl From<String> for ContentSourceSpec {
392 fn from(s: String) -> Self {
393 Self::Inline(s)
394 }
395}
396
397impl From<&str> for ContentSourceSpec {
398 fn from(s: &str) -> Self {
399 Self::Inline(s.to_string())
400 }
401}
402
403#[derive(Debug, Clone, Deserialize, Default)]
407#[serde(deny_unknown_fields)]
408pub struct FileRenameFixSpec {}
409
410#[derive(Debug, Clone, Deserialize, Default)]
413#[serde(deny_unknown_fields)]
414pub struct FileTrimTrailingWhitespaceFixSpec {}
415
416#[derive(Debug, Clone, Deserialize, Default)]
419#[serde(deny_unknown_fields)]
420pub struct FileAppendFinalNewlineFixSpec {}
421
422#[derive(Debug, Clone, Deserialize, Default)]
425#[serde(deny_unknown_fields)]
426pub struct FileNormalizeLineEndingsFixSpec {}
427
428#[derive(Debug, Clone, Deserialize, Default)]
431#[serde(deny_unknown_fields)]
432pub struct FileStripBidiFixSpec {}
433
434#[derive(Debug, Clone, Deserialize, Default)]
439#[serde(deny_unknown_fields)]
440pub struct FileStripZeroWidthFixSpec {}
441
442#[derive(Debug, Clone, Deserialize, Default)]
445#[serde(deny_unknown_fields)]
446pub struct FileStripBomFixSpec {}
447
448#[derive(Debug, Clone, Deserialize, Default)]
451#[serde(deny_unknown_fields)]
452pub struct FileCollapseBlankLinesFixSpec {}
453
454impl RuleSpec {
455 pub fn deserialize_options<T>(&self) -> crate::error::Result<T>
460 where
461 T: serde::de::DeserializeOwned,
462 {
463 Ok(serde_yaml_ng::from_value(serde_yaml_ng::Value::Mapping(
464 self.extra.clone(),
465 ))?)
466 }
467
468 pub fn parse_scope_filter(&self) -> crate::error::Result<Option<crate::ScopeFilter>> {
486 match &self.scope_filter {
487 Some(spec) => Ok(Some(crate::ScopeFilter::from_spec(&self.id, spec.clone())?)),
488 None => Ok(None),
489 }
490 }
491}
492
493#[derive(Debug, Clone, Deserialize)]
498pub struct NestedRuleSpec {
499 pub kind: String,
500 #[serde(default)]
501 pub paths: Option<PathsSpec>,
502 #[serde(default)]
503 pub message: Option<String>,
504 #[serde(default)]
505 pub policy_url: Option<String>,
506 #[serde(default)]
507 pub when: Option<String>,
508 #[serde(default)]
513 pub scope_filter: Option<crate::ScopeFilterSpec>,
514 #[serde(flatten)]
515 pub extra: serde_yaml_ng::Mapping,
516}
517
518#[derive(Debug)]
536pub struct CompiledNestedSpec {
537 pub spec: NestedRuleSpec,
542 pub when: Option<crate::when::WhenExpr>,
545}
546
547impl CompiledNestedSpec {
548 pub fn compile(
555 spec: NestedRuleSpec,
556 parent_id: &str,
557 idx: usize,
558 ) -> crate::error::Result<Self> {
559 let when = match spec.when.as_deref() {
560 Some(src) => Some(crate::when::parse(src).map_err(|e| {
561 crate::error::Error::rule_config(
562 parent_id,
563 format!("nested rule #{idx}: invalid when: {e}"),
564 )
565 })?),
566 None => None,
567 };
568 Ok(Self { spec, when })
569 }
570}
571
572impl NestedRuleSpec {
573 pub fn instantiate(
578 &self,
579 parent_id: &str,
580 idx: usize,
581 level: Level,
582 tokens: &crate::template::PathTokens,
583 ) -> RuleSpec {
584 RuleSpec {
585 id: format!("{parent_id}.require[{idx}]"),
586 kind: self.kind.clone(),
587 level,
588 paths: self
589 .paths
590 .as_ref()
591 .map(|p| crate::template::render_paths_spec(p, tokens)),
592 message: self
593 .message
594 .as_deref()
595 .map(|m| crate::template::render_path(m, tokens)),
596 policy_url: self.policy_url.clone(),
597 when: self.when.clone(),
598 fix: None,
599 git_tracked_only: false,
605 scope_filter: self.scope_filter.clone(),
606 extra: crate::template::render_mapping(self.extra.clone(), tokens),
607 }
608 }
609}
610
611#[cfg(test)]
612mod tests {
613 use super::*;
614 use crate::template::PathTokens;
615 use std::path::Path;
616
617 #[test]
618 fn config_default_respects_gitignore_and_caps_fix_size() {
619 let cfg: Config = serde_yaml_ng::from_str("version: 1\n").expect("minimal config");
622 assert_eq!(cfg.version, 1);
623 assert!(cfg.respect_gitignore);
624 assert_eq!(cfg.fix_size_limit, Some(1 << 20));
625 assert!(!cfg.nested_configs);
626 assert!(cfg.extends.is_empty());
627 assert!(cfg.rules.is_empty());
628 }
629
630 #[test]
631 fn config_rejects_unknown_top_level_field() {
632 let err = serde_yaml_ng::from_str::<Config>("version: 1\nignored_typo: true\n");
633 assert!(err.is_err(), "deny_unknown_fields should reject typos");
634 }
635
636 #[test]
637 fn config_explicit_null_disables_fix_size_limit() {
638 let cfg: Config = serde_yaml_ng::from_str("version: 1\nfix_size_limit: null\n").unwrap();
639 assert_eq!(cfg.fix_size_limit, None);
640 }
641
642 #[test]
643 fn extends_entry_url_form_has_no_filters() {
644 let e = ExtendsEntry::Url("alint://bundled/oss-baseline@v1".into());
645 assert_eq!(e.url(), "alint://bundled/oss-baseline@v1");
646 assert!(e.only().is_none());
647 assert!(e.except().is_none());
648 }
649
650 #[test]
651 fn extends_entry_filtered_form_exposes_only_and_except() {
652 let e = ExtendsEntry::Filtered {
653 url: "alint://bundled/rust@v1".into(),
654 only: Some(vec!["rust-edition".into()]),
655 except: None,
656 };
657 assert_eq!(e.url(), "alint://bundled/rust@v1");
658 assert_eq!(e.only(), Some(&["rust-edition".to_string()][..]));
659 assert!(e.except().is_none());
660 }
661
662 #[test]
663 fn extends_entry_filtered_form_supports_except_only() {
664 let e = ExtendsEntry::Filtered {
665 url: "./team.yml".into(),
666 only: None,
667 except: Some(vec!["legacy-rule".into()]),
668 };
669 assert_eq!(e.except(), Some(&["legacy-rule".to_string()][..]));
670 assert!(e.only().is_none());
671 }
672
673 #[test]
674 fn paths_spec_accepts_three_shapes() {
675 let single: PathsSpec = serde_yaml_ng::from_str("\"src/**\"").unwrap();
676 assert!(matches!(single, PathsSpec::Single(s) if s == "src/**"));
677
678 let many: PathsSpec = serde_yaml_ng::from_str("[\"src/**\", \"!src/vendor/**\"]").unwrap();
679 assert!(matches!(many, PathsSpec::Many(v) if v.len() == 2));
680
681 let inc_exc: PathsSpec =
682 serde_yaml_ng::from_str("include: src/**\nexclude: src/vendor/**\n").unwrap();
683 match inc_exc {
684 PathsSpec::IncludeExclude { include, exclude } => {
685 assert_eq!(include, vec!["src/**"]);
686 assert_eq!(exclude, vec!["src/vendor/**"]);
687 }
688 _ => panic!("expected include/exclude shape"),
689 }
690 }
691
692 #[test]
693 fn paths_spec_include_accepts_string_or_vec() {
694 let from_string: PathsSpec =
695 serde_yaml_ng::from_str("include: a\nexclude:\n - b\n - c\n").unwrap();
696 let PathsSpec::IncludeExclude { include, exclude } = from_string else {
697 panic!("expected include/exclude shape");
698 };
699 assert_eq!(include, vec!["a"]);
700 assert_eq!(exclude, vec!["b", "c"]);
701 }
702
703 #[test]
704 fn rule_spec_deserialize_options_picks_up_kind_specific_fields() {
705 #[derive(Deserialize, Debug)]
706 struct PatternOnly {
707 pattern: String,
708 }
709 let spec: RuleSpec = serde_yaml_ng::from_str(
710 "id: r\nkind: file_content_matches\nlevel: error\npaths: src/**\npattern: TODO\n",
711 )
712 .unwrap();
713 let opts: PatternOnly = spec.deserialize_options().unwrap();
714 assert_eq!(opts.pattern, "TODO");
715 }
716
717 #[test]
718 fn fix_spec_op_name_covers_every_variant() {
719 let cases = [
723 ("file_create:\n content: x\n", "file_create"),
724 ("file_remove: {}", "file_remove"),
725 ("file_prepend:\n content: x\n", "file_prepend"),
726 ("file_append:\n content: x\n", "file_append"),
727 ("file_rename: {}", "file_rename"),
728 (
729 "file_trim_trailing_whitespace: {}",
730 "file_trim_trailing_whitespace",
731 ),
732 ("file_append_final_newline: {}", "file_append_final_newline"),
733 (
734 "file_normalize_line_endings: {}",
735 "file_normalize_line_endings",
736 ),
737 ("file_strip_bidi: {}", "file_strip_bidi"),
738 ("file_strip_zero_width: {}", "file_strip_zero_width"),
739 ("file_strip_bom: {}", "file_strip_bom"),
740 ("file_collapse_blank_lines: {}", "file_collapse_blank_lines"),
741 ];
742 for (yaml, expected) in cases {
743 let spec: FixSpec =
744 serde_yaml_ng::from_str(yaml).unwrap_or_else(|e| panic!("{yaml}: {e}"));
745 assert_eq!(spec.op_name(), expected);
746 }
747 }
748
749 #[test]
750 fn resolve_content_source_inline_only() {
751 let s = Some("hello".to_string());
752 let resolved = resolve_content_source("r", "file_create", &s, &None).unwrap();
753 assert!(matches!(resolved, ContentSourceSpec::Inline(b) if b == "hello"));
754 }
755
756 #[test]
757 fn resolve_content_source_file_only() {
758 let p = Some(Path::new("LICENSE").into());
759 let resolved = resolve_content_source("r", "file_create", &None, &p).unwrap();
760 assert!(matches!(resolved, ContentSourceSpec::File(p) if p == Path::new("LICENSE")));
761 }
762
763 #[test]
764 fn resolve_content_source_rejects_both_set() {
765 let err = resolve_content_source(
766 "r",
767 "file_prepend",
768 &Some("x".into()),
769 &Some(Path::new("y").into()),
770 )
771 .unwrap_err();
772 assert!(err.to_string().contains("mutually exclusive"));
773 }
774
775 #[test]
776 fn resolve_content_source_rejects_neither_set() {
777 let err = resolve_content_source("r", "file_append", &None, &None).unwrap_err();
778 assert!(err.to_string().contains("required"));
779 }
780
781 #[test]
782 fn content_source_spec_from_string_variants() {
783 let from_owned: ContentSourceSpec = String::from("hi").into();
784 assert!(matches!(from_owned, ContentSourceSpec::Inline(s) if s == "hi"));
785 let from_str: ContentSourceSpec = "hi".into();
786 assert!(matches!(from_str, ContentSourceSpec::Inline(s) if s == "hi"));
787 }
788
789 #[test]
790 fn nested_rule_spec_instantiate_synthesizes_id_and_inherits_level() {
791 let nested: NestedRuleSpec = serde_yaml_ng::from_str(
792 "kind: file_exists\npaths: \"{path}/README.md\"\nmessage: missing in {path}\n",
793 )
794 .unwrap();
795 let tokens = PathTokens::from_path(Path::new("packages/foo"));
796 let spec = nested.instantiate("every-pkg-has-readme", 0, Level::Error, &tokens);
797
798 assert_eq!(spec.id, "every-pkg-has-readme.require[0]");
799 assert_eq!(spec.kind, "file_exists");
800 assert_eq!(spec.level, Level::Error);
801 match spec.paths {
804 Some(PathsSpec::Single(p)) => assert_eq!(p, "packages/foo/README.md"),
805 other => panic!("unexpected paths shape: {other:?}"),
806 }
807 assert_eq!(spec.message.as_deref(), Some("missing in packages/foo"));
808 assert!(!spec.git_tracked_only);
811 }
812}