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
469#[derive(Debug, Clone, Deserialize)]
474pub struct NestedRuleSpec {
475 pub kind: String,
476 #[serde(default)]
477 pub paths: Option<PathsSpec>,
478 #[serde(default)]
479 pub message: Option<String>,
480 #[serde(default)]
481 pub policy_url: Option<String>,
482 #[serde(default)]
483 pub when: Option<String>,
484 #[serde(default)]
489 pub scope_filter: Option<crate::ScopeFilterSpec>,
490 #[serde(flatten)]
491 pub extra: serde_yaml_ng::Mapping,
492}
493
494impl NestedRuleSpec {
495 pub fn instantiate(
500 &self,
501 parent_id: &str,
502 idx: usize,
503 level: Level,
504 tokens: &crate::template::PathTokens,
505 ) -> RuleSpec {
506 RuleSpec {
507 id: format!("{parent_id}.require[{idx}]"),
508 kind: self.kind.clone(),
509 level,
510 paths: self
511 .paths
512 .as_ref()
513 .map(|p| crate::template::render_paths_spec(p, tokens)),
514 message: self
515 .message
516 .as_deref()
517 .map(|m| crate::template::render_path(m, tokens)),
518 policy_url: self.policy_url.clone(),
519 when: self.when.clone(),
520 fix: None,
521 git_tracked_only: false,
527 scope_filter: self.scope_filter.clone(),
528 extra: crate::template::render_mapping(self.extra.clone(), tokens),
529 }
530 }
531}
532
533#[cfg(test)]
534mod tests {
535 use super::*;
536 use crate::template::PathTokens;
537 use std::path::Path;
538
539 #[test]
540 fn config_default_respects_gitignore_and_caps_fix_size() {
541 let cfg: Config = serde_yaml_ng::from_str("version: 1\n").expect("minimal config");
544 assert_eq!(cfg.version, 1);
545 assert!(cfg.respect_gitignore);
546 assert_eq!(cfg.fix_size_limit, Some(1 << 20));
547 assert!(!cfg.nested_configs);
548 assert!(cfg.extends.is_empty());
549 assert!(cfg.rules.is_empty());
550 }
551
552 #[test]
553 fn config_rejects_unknown_top_level_field() {
554 let err = serde_yaml_ng::from_str::<Config>("version: 1\nignored_typo: true\n");
555 assert!(err.is_err(), "deny_unknown_fields should reject typos");
556 }
557
558 #[test]
559 fn config_explicit_null_disables_fix_size_limit() {
560 let cfg: Config = serde_yaml_ng::from_str("version: 1\nfix_size_limit: null\n").unwrap();
561 assert_eq!(cfg.fix_size_limit, None);
562 }
563
564 #[test]
565 fn extends_entry_url_form_has_no_filters() {
566 let e = ExtendsEntry::Url("alint://bundled/oss-baseline@v1".into());
567 assert_eq!(e.url(), "alint://bundled/oss-baseline@v1");
568 assert!(e.only().is_none());
569 assert!(e.except().is_none());
570 }
571
572 #[test]
573 fn extends_entry_filtered_form_exposes_only_and_except() {
574 let e = ExtendsEntry::Filtered {
575 url: "alint://bundled/rust@v1".into(),
576 only: Some(vec!["rust-edition".into()]),
577 except: None,
578 };
579 assert_eq!(e.url(), "alint://bundled/rust@v1");
580 assert_eq!(e.only(), Some(&["rust-edition".to_string()][..]));
581 assert!(e.except().is_none());
582 }
583
584 #[test]
585 fn extends_entry_filtered_form_supports_except_only() {
586 let e = ExtendsEntry::Filtered {
587 url: "./team.yml".into(),
588 only: None,
589 except: Some(vec!["legacy-rule".into()]),
590 };
591 assert_eq!(e.except(), Some(&["legacy-rule".to_string()][..]));
592 assert!(e.only().is_none());
593 }
594
595 #[test]
596 fn paths_spec_accepts_three_shapes() {
597 let single: PathsSpec = serde_yaml_ng::from_str("\"src/**\"").unwrap();
598 assert!(matches!(single, PathsSpec::Single(s) if s == "src/**"));
599
600 let many: PathsSpec = serde_yaml_ng::from_str("[\"src/**\", \"!src/vendor/**\"]").unwrap();
601 assert!(matches!(many, PathsSpec::Many(v) if v.len() == 2));
602
603 let inc_exc: PathsSpec =
604 serde_yaml_ng::from_str("include: src/**\nexclude: src/vendor/**\n").unwrap();
605 match inc_exc {
606 PathsSpec::IncludeExclude { include, exclude } => {
607 assert_eq!(include, vec!["src/**"]);
608 assert_eq!(exclude, vec!["src/vendor/**"]);
609 }
610 _ => panic!("expected include/exclude shape"),
611 }
612 }
613
614 #[test]
615 fn paths_spec_include_accepts_string_or_vec() {
616 let from_string: PathsSpec =
617 serde_yaml_ng::from_str("include: a\nexclude:\n - b\n - c\n").unwrap();
618 let PathsSpec::IncludeExclude { include, exclude } = from_string else {
619 panic!("expected include/exclude shape");
620 };
621 assert_eq!(include, vec!["a"]);
622 assert_eq!(exclude, vec!["b", "c"]);
623 }
624
625 #[test]
626 fn rule_spec_deserialize_options_picks_up_kind_specific_fields() {
627 #[derive(Deserialize, Debug)]
628 struct PatternOnly {
629 pattern: String,
630 }
631 let spec: RuleSpec = serde_yaml_ng::from_str(
632 "id: r\nkind: file_content_matches\nlevel: error\npaths: src/**\npattern: TODO\n",
633 )
634 .unwrap();
635 let opts: PatternOnly = spec.deserialize_options().unwrap();
636 assert_eq!(opts.pattern, "TODO");
637 }
638
639 #[test]
640 fn fix_spec_op_name_covers_every_variant() {
641 let cases = [
645 ("file_create:\n content: x\n", "file_create"),
646 ("file_remove: {}", "file_remove"),
647 ("file_prepend:\n content: x\n", "file_prepend"),
648 ("file_append:\n content: x\n", "file_append"),
649 ("file_rename: {}", "file_rename"),
650 (
651 "file_trim_trailing_whitespace: {}",
652 "file_trim_trailing_whitespace",
653 ),
654 ("file_append_final_newline: {}", "file_append_final_newline"),
655 (
656 "file_normalize_line_endings: {}",
657 "file_normalize_line_endings",
658 ),
659 ("file_strip_bidi: {}", "file_strip_bidi"),
660 ("file_strip_zero_width: {}", "file_strip_zero_width"),
661 ("file_strip_bom: {}", "file_strip_bom"),
662 ("file_collapse_blank_lines: {}", "file_collapse_blank_lines"),
663 ];
664 for (yaml, expected) in cases {
665 let spec: FixSpec =
666 serde_yaml_ng::from_str(yaml).unwrap_or_else(|e| panic!("{yaml}: {e}"));
667 assert_eq!(spec.op_name(), expected);
668 }
669 }
670
671 #[test]
672 fn resolve_content_source_inline_only() {
673 let s = Some("hello".to_string());
674 let resolved = resolve_content_source("r", "file_create", &s, &None).unwrap();
675 assert!(matches!(resolved, ContentSourceSpec::Inline(b) if b == "hello"));
676 }
677
678 #[test]
679 fn resolve_content_source_file_only() {
680 let p = Some(Path::new("LICENSE").into());
681 let resolved = resolve_content_source("r", "file_create", &None, &p).unwrap();
682 assert!(matches!(resolved, ContentSourceSpec::File(p) if p == Path::new("LICENSE")));
683 }
684
685 #[test]
686 fn resolve_content_source_rejects_both_set() {
687 let err = resolve_content_source(
688 "r",
689 "file_prepend",
690 &Some("x".into()),
691 &Some(Path::new("y").into()),
692 )
693 .unwrap_err();
694 assert!(err.to_string().contains("mutually exclusive"));
695 }
696
697 #[test]
698 fn resolve_content_source_rejects_neither_set() {
699 let err = resolve_content_source("r", "file_append", &None, &None).unwrap_err();
700 assert!(err.to_string().contains("required"));
701 }
702
703 #[test]
704 fn content_source_spec_from_string_variants() {
705 let from_owned: ContentSourceSpec = String::from("hi").into();
706 assert!(matches!(from_owned, ContentSourceSpec::Inline(s) if s == "hi"));
707 let from_str: ContentSourceSpec = "hi".into();
708 assert!(matches!(from_str, ContentSourceSpec::Inline(s) if s == "hi"));
709 }
710
711 #[test]
712 fn nested_rule_spec_instantiate_synthesizes_id_and_inherits_level() {
713 let nested: NestedRuleSpec = serde_yaml_ng::from_str(
714 "kind: file_exists\npaths: \"{path}/README.md\"\nmessage: missing in {path}\n",
715 )
716 .unwrap();
717 let tokens = PathTokens::from_path(Path::new("packages/foo"));
718 let spec = nested.instantiate("every-pkg-has-readme", 0, Level::Error, &tokens);
719
720 assert_eq!(spec.id, "every-pkg-has-readme.require[0]");
721 assert_eq!(spec.kind, "file_exists");
722 assert_eq!(spec.level, Level::Error);
723 match spec.paths {
726 Some(PathsSpec::Single(p)) => assert_eq!(p, "packages/foo/README.md"),
727 other => panic!("unexpected paths shape: {other:?}"),
728 }
729 assert_eq!(spec.message.as_deref(), Some("missing in packages/foo"));
730 assert!(!spec.git_tracked_only);
733 }
734}