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(flatten)]
212 pub extra: serde_yaml_ng::Mapping,
213}
214
215#[derive(Debug, Clone, Deserialize)]
218#[serde(untagged)]
219pub enum FixSpec {
220 FileCreate {
221 file_create: FileCreateFixSpec,
222 },
223 FileRemove {
224 file_remove: FileRemoveFixSpec,
225 },
226 FilePrepend {
227 file_prepend: FilePrependFixSpec,
228 },
229 FileAppend {
230 file_append: FileAppendFixSpec,
231 },
232 FileRename {
233 file_rename: FileRenameFixSpec,
234 },
235 FileTrimTrailingWhitespace {
236 file_trim_trailing_whitespace: FileTrimTrailingWhitespaceFixSpec,
237 },
238 FileAppendFinalNewline {
239 file_append_final_newline: FileAppendFinalNewlineFixSpec,
240 },
241 FileNormalizeLineEndings {
242 file_normalize_line_endings: FileNormalizeLineEndingsFixSpec,
243 },
244 FileStripBidi {
245 file_strip_bidi: FileStripBidiFixSpec,
246 },
247 FileStripZeroWidth {
248 file_strip_zero_width: FileStripZeroWidthFixSpec,
249 },
250 FileStripBom {
251 file_strip_bom: FileStripBomFixSpec,
252 },
253 FileCollapseBlankLines {
254 file_collapse_blank_lines: FileCollapseBlankLinesFixSpec,
255 },
256}
257
258impl FixSpec {
259 pub fn op_name(&self) -> &'static str {
261 match self {
262 Self::FileCreate { .. } => "file_create",
263 Self::FileRemove { .. } => "file_remove",
264 Self::FilePrepend { .. } => "file_prepend",
265 Self::FileAppend { .. } => "file_append",
266 Self::FileRename { .. } => "file_rename",
267 Self::FileTrimTrailingWhitespace { .. } => "file_trim_trailing_whitespace",
268 Self::FileAppendFinalNewline { .. } => "file_append_final_newline",
269 Self::FileNormalizeLineEndings { .. } => "file_normalize_line_endings",
270 Self::FileStripBidi { .. } => "file_strip_bidi",
271 Self::FileStripZeroWidth { .. } => "file_strip_zero_width",
272 Self::FileStripBom { .. } => "file_strip_bom",
273 Self::FileCollapseBlankLines { .. } => "file_collapse_blank_lines",
274 }
275 }
276}
277
278#[derive(Debug, Clone, Deserialize)]
279#[serde(deny_unknown_fields)]
280pub struct FileCreateFixSpec {
281 #[serde(default)]
285 pub content: Option<String>,
286 #[serde(default)]
293 pub content_from: Option<PathBuf>,
294 #[serde(default)]
298 pub path: Option<PathBuf>,
299 #[serde(default = "default_create_parents")]
301 pub create_parents: bool,
302}
303
304fn default_create_parents() -> bool {
305 true
306}
307
308#[derive(Debug, Clone, Deserialize, Default)]
309#[serde(deny_unknown_fields)]
310pub struct FileRemoveFixSpec {}
311
312#[derive(Debug, Clone, Deserialize)]
313#[serde(deny_unknown_fields)]
314pub struct FilePrependFixSpec {
315 #[serde(default)]
319 pub content: Option<String>,
320 #[serde(default)]
323 pub content_from: Option<PathBuf>,
324}
325
326#[derive(Debug, Clone, Deserialize)]
327#[serde(deny_unknown_fields)]
328pub struct FileAppendFixSpec {
329 #[serde(default)]
333 pub content: Option<String>,
334 #[serde(default)]
337 pub content_from: Option<PathBuf>,
338}
339
340pub fn resolve_content_source(
344 rule_id: &str,
345 op_name: &str,
346 inline: &Option<String>,
347 from: &Option<PathBuf>,
348) -> crate::error::Result<ContentSourceSpec> {
349 match (inline, from) {
350 (Some(_), Some(_)) => Err(crate::error::Error::rule_config(
351 rule_id,
352 format!("fix.{op_name}: `content` and `content_from` are mutually exclusive"),
353 )),
354 (None, None) => Err(crate::error::Error::rule_config(
355 rule_id,
356 format!("fix.{op_name}: one of `content` or `content_from` is required"),
357 )),
358 (Some(s), None) => Ok(ContentSourceSpec::Inline(s.clone())),
359 (None, Some(p)) => Ok(ContentSourceSpec::File(p.clone())),
360 }
361}
362
363#[derive(Debug, Clone)]
367pub enum ContentSourceSpec {
368 Inline(String),
370 File(PathBuf),
373}
374
375impl From<String> for ContentSourceSpec {
376 fn from(s: String) -> Self {
377 Self::Inline(s)
378 }
379}
380
381impl From<&str> for ContentSourceSpec {
382 fn from(s: &str) -> Self {
383 Self::Inline(s.to_string())
384 }
385}
386
387#[derive(Debug, Clone, Deserialize, Default)]
391#[serde(deny_unknown_fields)]
392pub struct FileRenameFixSpec {}
393
394#[derive(Debug, Clone, Deserialize, Default)]
397#[serde(deny_unknown_fields)]
398pub struct FileTrimTrailingWhitespaceFixSpec {}
399
400#[derive(Debug, Clone, Deserialize, Default)]
403#[serde(deny_unknown_fields)]
404pub struct FileAppendFinalNewlineFixSpec {}
405
406#[derive(Debug, Clone, Deserialize, Default)]
409#[serde(deny_unknown_fields)]
410pub struct FileNormalizeLineEndingsFixSpec {}
411
412#[derive(Debug, Clone, Deserialize, Default)]
415#[serde(deny_unknown_fields)]
416pub struct FileStripBidiFixSpec {}
417
418#[derive(Debug, Clone, Deserialize, Default)]
423#[serde(deny_unknown_fields)]
424pub struct FileStripZeroWidthFixSpec {}
425
426#[derive(Debug, Clone, Deserialize, Default)]
429#[serde(deny_unknown_fields)]
430pub struct FileStripBomFixSpec {}
431
432#[derive(Debug, Clone, Deserialize, Default)]
435#[serde(deny_unknown_fields)]
436pub struct FileCollapseBlankLinesFixSpec {}
437
438impl RuleSpec {
439 pub fn deserialize_options<T>(&self) -> crate::error::Result<T>
444 where
445 T: serde::de::DeserializeOwned,
446 {
447 Ok(serde_yaml_ng::from_value(serde_yaml_ng::Value::Mapping(
448 self.extra.clone(),
449 ))?)
450 }
451}
452
453#[derive(Debug, Clone, Deserialize)]
458pub struct NestedRuleSpec {
459 pub kind: String,
460 #[serde(default)]
461 pub paths: Option<PathsSpec>,
462 #[serde(default)]
463 pub message: Option<String>,
464 #[serde(default)]
465 pub policy_url: Option<String>,
466 #[serde(default)]
467 pub when: Option<String>,
468 #[serde(flatten)]
469 pub extra: serde_yaml_ng::Mapping,
470}
471
472impl NestedRuleSpec {
473 pub fn instantiate(
478 &self,
479 parent_id: &str,
480 idx: usize,
481 level: Level,
482 tokens: &crate::template::PathTokens,
483 ) -> RuleSpec {
484 RuleSpec {
485 id: format!("{parent_id}.require[{idx}]"),
486 kind: self.kind.clone(),
487 level,
488 paths: self
489 .paths
490 .as_ref()
491 .map(|p| crate::template::render_paths_spec(p, tokens)),
492 message: self
493 .message
494 .as_deref()
495 .map(|m| crate::template::render_path(m, tokens)),
496 policy_url: self.policy_url.clone(),
497 when: self.when.clone(),
498 fix: None,
499 git_tracked_only: false,
505 extra: crate::template::render_mapping(self.extra.clone(), tokens),
506 }
507 }
508}
509
510#[cfg(test)]
511mod tests {
512 use super::*;
513 use crate::template::PathTokens;
514 use std::path::Path;
515
516 #[test]
517 fn config_default_respects_gitignore_and_caps_fix_size() {
518 let cfg: Config = serde_yaml_ng::from_str("version: 1\n").expect("minimal config");
521 assert_eq!(cfg.version, 1);
522 assert!(cfg.respect_gitignore);
523 assert_eq!(cfg.fix_size_limit, Some(1 << 20));
524 assert!(!cfg.nested_configs);
525 assert!(cfg.extends.is_empty());
526 assert!(cfg.rules.is_empty());
527 }
528
529 #[test]
530 fn config_rejects_unknown_top_level_field() {
531 let err = serde_yaml_ng::from_str::<Config>("version: 1\nignored_typo: true\n");
532 assert!(err.is_err(), "deny_unknown_fields should reject typos");
533 }
534
535 #[test]
536 fn config_explicit_null_disables_fix_size_limit() {
537 let cfg: Config = serde_yaml_ng::from_str("version: 1\nfix_size_limit: null\n").unwrap();
538 assert_eq!(cfg.fix_size_limit, None);
539 }
540
541 #[test]
542 fn extends_entry_url_form_has_no_filters() {
543 let e = ExtendsEntry::Url("alint://bundled/oss-baseline@v1".into());
544 assert_eq!(e.url(), "alint://bundled/oss-baseline@v1");
545 assert!(e.only().is_none());
546 assert!(e.except().is_none());
547 }
548
549 #[test]
550 fn extends_entry_filtered_form_exposes_only_and_except() {
551 let e = ExtendsEntry::Filtered {
552 url: "alint://bundled/rust@v1".into(),
553 only: Some(vec!["rust-edition".into()]),
554 except: None,
555 };
556 assert_eq!(e.url(), "alint://bundled/rust@v1");
557 assert_eq!(e.only(), Some(&["rust-edition".to_string()][..]));
558 assert!(e.except().is_none());
559 }
560
561 #[test]
562 fn extends_entry_filtered_form_supports_except_only() {
563 let e = ExtendsEntry::Filtered {
564 url: "./team.yml".into(),
565 only: None,
566 except: Some(vec!["legacy-rule".into()]),
567 };
568 assert_eq!(e.except(), Some(&["legacy-rule".to_string()][..]));
569 assert!(e.only().is_none());
570 }
571
572 #[test]
573 fn paths_spec_accepts_three_shapes() {
574 let single: PathsSpec = serde_yaml_ng::from_str("\"src/**\"").unwrap();
575 assert!(matches!(single, PathsSpec::Single(s) if s == "src/**"));
576
577 let many: PathsSpec = serde_yaml_ng::from_str("[\"src/**\", \"!src/vendor/**\"]").unwrap();
578 assert!(matches!(many, PathsSpec::Many(v) if v.len() == 2));
579
580 let inc_exc: PathsSpec =
581 serde_yaml_ng::from_str("include: src/**\nexclude: src/vendor/**\n").unwrap();
582 match inc_exc {
583 PathsSpec::IncludeExclude { include, exclude } => {
584 assert_eq!(include, vec!["src/**"]);
585 assert_eq!(exclude, vec!["src/vendor/**"]);
586 }
587 _ => panic!("expected include/exclude shape"),
588 }
589 }
590
591 #[test]
592 fn paths_spec_include_accepts_string_or_vec() {
593 let from_string: PathsSpec =
594 serde_yaml_ng::from_str("include: a\nexclude:\n - b\n - c\n").unwrap();
595 let PathsSpec::IncludeExclude { include, exclude } = from_string else {
596 panic!("expected include/exclude shape");
597 };
598 assert_eq!(include, vec!["a"]);
599 assert_eq!(exclude, vec!["b", "c"]);
600 }
601
602 #[test]
603 fn rule_spec_deserialize_options_picks_up_kind_specific_fields() {
604 #[derive(Deserialize, Debug)]
605 struct PatternOnly {
606 pattern: String,
607 }
608 let spec: RuleSpec = serde_yaml_ng::from_str(
609 "id: r\nkind: file_content_matches\nlevel: error\npaths: src/**\npattern: TODO\n",
610 )
611 .unwrap();
612 let opts: PatternOnly = spec.deserialize_options().unwrap();
613 assert_eq!(opts.pattern, "TODO");
614 }
615
616 #[test]
617 fn fix_spec_op_name_covers_every_variant() {
618 let cases = [
622 ("file_create:\n content: x\n", "file_create"),
623 ("file_remove: {}", "file_remove"),
624 ("file_prepend:\n content: x\n", "file_prepend"),
625 ("file_append:\n content: x\n", "file_append"),
626 ("file_rename: {}", "file_rename"),
627 (
628 "file_trim_trailing_whitespace: {}",
629 "file_trim_trailing_whitespace",
630 ),
631 ("file_append_final_newline: {}", "file_append_final_newline"),
632 (
633 "file_normalize_line_endings: {}",
634 "file_normalize_line_endings",
635 ),
636 ("file_strip_bidi: {}", "file_strip_bidi"),
637 ("file_strip_zero_width: {}", "file_strip_zero_width"),
638 ("file_strip_bom: {}", "file_strip_bom"),
639 ("file_collapse_blank_lines: {}", "file_collapse_blank_lines"),
640 ];
641 for (yaml, expected) in cases {
642 let spec: FixSpec =
643 serde_yaml_ng::from_str(yaml).unwrap_or_else(|e| panic!("{yaml}: {e}"));
644 assert_eq!(spec.op_name(), expected);
645 }
646 }
647
648 #[test]
649 fn resolve_content_source_inline_only() {
650 let s = Some("hello".to_string());
651 let resolved = resolve_content_source("r", "file_create", &s, &None).unwrap();
652 assert!(matches!(resolved, ContentSourceSpec::Inline(b) if b == "hello"));
653 }
654
655 #[test]
656 fn resolve_content_source_file_only() {
657 let p = Some(Path::new("LICENSE").into());
658 let resolved = resolve_content_source("r", "file_create", &None, &p).unwrap();
659 assert!(matches!(resolved, ContentSourceSpec::File(p) if p == Path::new("LICENSE")));
660 }
661
662 #[test]
663 fn resolve_content_source_rejects_both_set() {
664 let err = resolve_content_source(
665 "r",
666 "file_prepend",
667 &Some("x".into()),
668 &Some(Path::new("y").into()),
669 )
670 .unwrap_err();
671 assert!(err.to_string().contains("mutually exclusive"));
672 }
673
674 #[test]
675 fn resolve_content_source_rejects_neither_set() {
676 let err = resolve_content_source("r", "file_append", &None, &None).unwrap_err();
677 assert!(err.to_string().contains("required"));
678 }
679
680 #[test]
681 fn content_source_spec_from_string_variants() {
682 let from_owned: ContentSourceSpec = String::from("hi").into();
683 assert!(matches!(from_owned, ContentSourceSpec::Inline(s) if s == "hi"));
684 let from_str: ContentSourceSpec = "hi".into();
685 assert!(matches!(from_str, ContentSourceSpec::Inline(s) if s == "hi"));
686 }
687
688 #[test]
689 fn nested_rule_spec_instantiate_synthesizes_id_and_inherits_level() {
690 let nested: NestedRuleSpec = serde_yaml_ng::from_str(
691 "kind: file_exists\npaths: \"{path}/README.md\"\nmessage: missing in {path}\n",
692 )
693 .unwrap();
694 let tokens = PathTokens::from_path(Path::new("packages/foo"));
695 let spec = nested.instantiate("every-pkg-has-readme", 0, Level::Error, &tokens);
696
697 assert_eq!(spec.id, "every-pkg-has-readme.require[0]");
698 assert_eq!(spec.kind, "file_exists");
699 assert_eq!(spec.level, Level::Error);
700 match spec.paths {
703 Some(PathsSpec::Single(p)) => assert_eq!(p, "packages/foo/README.md"),
704 other => panic!("unexpected paths shape: {other:?}"),
705 }
706 assert_eq!(spec.message.as_deref(), Some("missing in packages/foo"));
707 assert!(!spec.git_tracked_only);
710 }
711}