Skip to main content

alint_core/
config.rs

1use std::collections::HashMap;
2use std::path::PathBuf;
3
4use serde::Deserialize;
5
6use crate::facts::FactSpec;
7use crate::level::Level;
8
9/// Parsed form of a `.alint.yml` file.
10#[derive(Debug, Clone, Deserialize, Default)]
11#[serde(deny_unknown_fields)]
12pub struct Config {
13    pub version: u32,
14    /// Other config files this one inherits from. Entries resolved
15    /// left-to-right; later entries override earlier ones; the
16    /// current file's own definitions override everything it extends.
17    ///
18    /// Each entry is either a bare string (local path, `https://`
19    /// URL with SRI, or `alint://bundled/...`) or a mapping with
20    /// `url:` and optional `only:` / `except:` filters.
21    #[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    /// Free-form string variables referenced from rule messages and
28    /// `when` expressions as `{{vars.<name>}}` and `vars.<name>`.
29    #[serde(default)]
30    pub vars: HashMap<String, String>,
31    /// Repository properties evaluated once per run and referenced from
32    /// `when` clauses as `facts.<id>`.
33    #[serde(default)]
34    pub facts: Vec<FactSpec>,
35    #[serde(default)]
36    pub rules: Vec<RuleSpec>,
37    /// Maximum file size, in bytes, that content-editing fixes
38    /// will read and rewrite. Files over this limit are reported
39    /// as `Skipped` in the fix report and a one-line warning is
40    /// printed to stderr. Defaults to 1 MiB; set explicitly to
41    /// `null` to disable the cap entirely.
42    ///
43    /// Path-only fixes (`file_create`, `file_remove`,
44    /// `file_rename`) ignore the cap — they don't read content.
45    #[serde(default = "default_fix_size_limit")]
46    pub fix_size_limit: Option<u64>,
47    /// Opt in to discovery of `.alint.yml` / `.alint.yaml` files
48    /// in subdirectories. When `true`, the loader walks the
49    /// repository tree (from the root config's directory,
50    /// respecting `.gitignore` and `ignore:`) and finds any
51    /// nested config files; each nested rule's path-like fields
52    /// (`paths`, `select`, `primary`) are prefixed with the
53    /// directory that nested config lives in, so the rule
54    /// auto-scopes to that subtree. Default `false`.
55    ///
56    /// Only the user's top-level config may set this — nested
57    /// configs themselves cannot spawn further nested discovery.
58    #[serde(default)]
59    pub nested_configs: bool,
60}
61
62// Returning `Option<u64>` (rather than bare `u64`) keeps the
63// YAML-facing type consistent with `Config.fix_size_limit`:
64// users set `null` in YAML to mean "no limit". The Option is
65// load-bearing at the field level, so clippy's warning on the
66// default fn is noise here.
67#[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/// A single `extends:` entry. Accepts either a bare string (the
81/// classic form — a local path, `https://` URL with SRI, or
82/// `alint://bundled/<name>@<rev>`) or a mapping that adds
83/// `only:` / `except:` filters on the inherited rule set.
84///
85/// ```yaml
86/// extends:
87///   - alint://bundled/oss-baseline@v1             # classic form
88///   - url: alint://bundled/rust@v1                # filtered form
89///     except: [rust-no-target-dir]                # drop by id
90///   - url: ./team-defaults.yml
91///     only: [team-copyright-header]               # keep by id
92/// ```
93///
94/// Filters resolve against the *fully-resolved* rule set of the
95/// entry (i.e. anything it transitively extends). `only:` and
96/// `except:` are mutually exclusive on a single entry; listing an
97/// unknown rule id is a config error so typos surface at load
98/// time.
99#[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    /// The URL / path of the extended config. Uniform across both
114    /// enum variants.
115    pub fn url(&self) -> &str {
116        match self {
117            Self::Url(s) | Self::Filtered { url: s, .. } => s,
118        }
119    }
120
121    /// Rule ids to keep (drop everything else). `None` when no
122    /// `only:` filter is specified.
123    pub fn only(&self) -> Option<&[String]> {
124        match self {
125            Self::Filtered { only: Some(v), .. } => Some(v),
126            _ => None,
127        }
128    }
129
130    /// Rule ids to drop. `None` when no `except:` filter is
131    /// specified.
132    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/// YAML shape for a rule's `paths:` field — a single glob, an array (with
143/// optional `!pattern` negations), or an explicit `{include, exclude}` pair.
144/// For the include/exclude form, each field accepts either a single string
145/// or a list of strings.
146#[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/// YAML-level description of a rule before it is instantiated into a `Box<dyn Rule>`
176/// by a [`RuleBuilder`](crate::registry::RuleBuilder).
177#[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    /// Optional mechanical-fix strategy. Rules whose builders understand
191    /// the chosen op attach a [`Fixer`](crate::Fixer) to the built rule;
192    /// rules whose kind is incompatible with the op return a config error
193    /// at build time.
194    #[serde(default)]
195    pub fix: Option<FixSpec>,
196    /// Restrict the rule to files / directories tracked in git's index.
197    /// When `true`, the rule's `paths`-matched entries are intersected
198    /// with the set of git-tracked files; entries that exist in the
199    /// walked tree but aren't in `git ls-files` output are skipped.
200    /// Only meaningful for rule kinds that opt in (currently the
201    /// existence family — `file_exists`, `file_absent`, `dir_exists`,
202    /// `dir_absent`); rule kinds that don't support it surface a clean
203    /// config error when this is `true` so silent mis-configuration
204    /// doesn't slip through.
205    ///
206    /// Default `false`. Has no effect outside a git repo.
207    #[serde(default)]
208    pub git_tracked_only: bool,
209    /// Per-file ancestor-manifest gate. When set, the rule
210    /// only fires on files that have at least one ancestor
211    /// directory (including the file's own directory)
212    /// containing a file matching the configured
213    /// `has_ancestor` name(s). Composes AND with `paths:`
214    /// and `git_tracked_only:`.
215    ///
216    /// Only meaningful for per-file rules; cross-file rule
217    /// builders MUST reject this field at build time
218    /// (see the design doc for the cross-file alternative
219    /// via `for_each_dir + when_iter:`).
220    ///
221    /// Default `None` (no scope filter; existing rules
222    /// preserve their pre-v0.9.6 behaviour).
223    #[serde(default)]
224    pub scope_filter: Option<crate::ScopeFilterSpec>,
225    /// The entire YAML mapping, retained so each rule builder can deserialize
226    /// its kind-specific fields without every option being represented here.
227    #[serde(flatten)]
228    pub extra: serde_yaml_ng::Mapping,
229}
230
231/// The `fix:` block on a rule. Exactly one op key must be present —
232/// alint errors at load time when the op and rule kind are incompatible.
233#[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    /// The op name as it appears in YAML — used in config-error messages.
276    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    /// Inline content to write. Mutually exclusive with
298    /// `content_from`; exactly one of the two must be set. For
299    /// an empty file, pass `content: ""` explicitly.
300    #[serde(default)]
301    pub content: Option<String>,
302    /// Path to a file (relative to the lint root) whose bytes
303    /// will be the content. Mutually exclusive with `content`.
304    /// Read at fix-apply time; missing source produces a
305    /// `Skipped` outcome rather than a panic. Useful for
306    /// LICENSE / NOTICE / CONTRIBUTING boilerplate that's too
307    /// long to inline in YAML.
308    #[serde(default)]
309    pub content_from: Option<PathBuf>,
310    /// Path to create, relative to the repo root. When omitted, the
311    /// rule builder substitutes the first literal entry from the rule's
312    /// `paths:` list.
313    #[serde(default)]
314    pub path: Option<PathBuf>,
315    /// Whether to create intermediate directories. Defaults to true.
316    #[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    /// Inline bytes to insert at the beginning of each
332    /// violating file. Mutually exclusive with `content_from`.
333    /// A trailing newline is the caller's responsibility.
334    #[serde(default)]
335    pub content: Option<String>,
336    /// Path to a file (relative to the lint root) whose bytes
337    /// will be prepended. Mutually exclusive with `content`.
338    #[serde(default)]
339    pub content_from: Option<PathBuf>,
340}
341
342#[derive(Debug, Clone, Deserialize)]
343#[serde(deny_unknown_fields)]
344pub struct FileAppendFixSpec {
345    /// Inline bytes to append to each violating file. Mutually
346    /// exclusive with `content_from`. A leading newline is the
347    /// caller's responsibility.
348    #[serde(default)]
349    pub content: Option<String>,
350    /// Path to a file (relative to the lint root) whose bytes
351    /// will be appended. Mutually exclusive with `content`.
352    #[serde(default)]
353    pub content_from: Option<PathBuf>,
354}
355
356/// Resolution of an `(content, content_from)` pair to a single
357/// content source. Used by the three fixers that take either.
358/// Errors when neither or both are set.
359pub 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/// Pre-validated content source — exactly one of inline or
380/// from-file. Resolved at config-parse time so fixers don't
381/// need to reproduce the XOR check at apply time.
382#[derive(Debug, Clone)]
383pub enum ContentSourceSpec {
384    /// Inline string body.
385    Inline(String),
386    /// Path relative to the lint root; bytes are read at fix-
387    /// apply time.
388    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/// Empty marker: `file_rename` takes no parameters. The target name
404/// is derived from the parent rule (e.g. `filename_case` converts the
405/// stem to its configured case; the extension is preserved).
406#[derive(Debug, Clone, Deserialize, Default)]
407#[serde(deny_unknown_fields)]
408pub struct FileRenameFixSpec {}
409
410/// Empty marker. Behavior: read file (subject to `fix_size_limit`),
411/// strip trailing space/tab on every line, write back.
412#[derive(Debug, Clone, Deserialize, Default)]
413#[serde(deny_unknown_fields)]
414pub struct FileTrimTrailingWhitespaceFixSpec {}
415
416/// Empty marker. Behavior: if the file has content and does not
417/// end with `\n`, append one.
418#[derive(Debug, Clone, Deserialize, Default)]
419#[serde(deny_unknown_fields)]
420pub struct FileAppendFinalNewlineFixSpec {}
421
422/// Empty marker. Behavior: rewrite the file with every line ending
423/// replaced by the parent rule's configured target (`lf` or `crlf`).
424#[derive(Debug, Clone, Deserialize, Default)]
425#[serde(deny_unknown_fields)]
426pub struct FileNormalizeLineEndingsFixSpec {}
427
428/// Empty marker. Behavior: remove every Unicode bidi control
429/// character (U+202A–202E, U+2066–2069) from the file's content.
430#[derive(Debug, Clone, Deserialize, Default)]
431#[serde(deny_unknown_fields)]
432pub struct FileStripBidiFixSpec {}
433
434/// Empty marker. Behavior: remove every zero-width character
435/// (U+200B / U+200C / U+200D / U+FEFF) from the file's content,
436/// *except* a leading BOM (U+FEFF at position 0) — that's the
437/// responsibility of the `no_bom` rule.
438#[derive(Debug, Clone, Deserialize, Default)]
439#[serde(deny_unknown_fields)]
440pub struct FileStripZeroWidthFixSpec {}
441
442/// Empty marker. Behavior: remove a leading UTF-8/UTF-16/UTF-32
443/// BOM byte sequence if present; otherwise a no-op.
444#[derive(Debug, Clone, Deserialize, Default)]
445#[serde(deny_unknown_fields)]
446pub struct FileStripBomFixSpec {}
447
448/// Empty marker. Behavior: collapse runs of blank lines longer than
449/// the parent rule's `max` down to exactly `max` blank lines.
450#[derive(Debug, Clone, Deserialize, Default)]
451#[serde(deny_unknown_fields)]
452pub struct FileCollapseBlankLinesFixSpec {}
453
454impl RuleSpec {
455    /// Deserialize the full spec (common + kind-specific fields) into a typed
456    /// options struct. Common fields are reconstructed into the mapping so
457    /// the target struct can `#[derive(Deserialize)]` against the whole shape
458    /// when convenient.
459    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    /// Parse and validate this spec's optional `scope_filter:`
469    /// field into a built [`ScopeFilter`](crate::ScopeFilter).
470    /// Returns `Ok(None)` when the spec has no `scope_filter`
471    /// set (the common case).
472    ///
473    /// Per-file rule builders typically don't call this directly
474    /// since v0.9.10 — they use
475    /// [`Scope::from_spec`](crate::Scope::from_spec) instead,
476    /// which bundles `paths:` + `scope_filter:` parsing into one
477    /// call. The Scope owns the parsed filter and consults it
478    /// inside [`Scope::matches`](crate::Scope::matches), so the
479    /// engine doesn't need a separate per-rule accessor any more.
480    /// Cross-file rules MUST NOT call this — they call
481    /// [`reject_scope_filter_on_cross_file`](crate::reject_scope_filter_on_cross_file)
482    /// instead so a misconfigured `scope_filter:` on a cross-
483    /// file rule surfaces as a clear build-time error rather
484    /// than a silently-ignored field.
485    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/// Rule specification for nested rules (e.g. the `require:` block of
494/// `for_each_dir`). Unlike [`RuleSpec`], `id` and `level` are synthesized
495/// from the parent rule — users just supply the `kind` plus kind-specific
496/// options, optionally with a `message` / `policy_url` / `when`.
497#[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    /// Per-file scope filter — see [`RuleSpec::scope_filter`]
509    /// for semantics. Inherited unchanged when
510    /// [`NestedRuleSpec::instantiate`] synthesises a full
511    /// `RuleSpec` per-iteration.
512    #[serde(default)]
513    pub scope_filter: Option<crate::ScopeFilterSpec>,
514    #[serde(flatten)]
515    pub extra: serde_yaml_ng::Mapping,
516}
517
518/// A [`NestedRuleSpec`] with its `when:` source pre-compiled
519/// into a [`crate::when::WhenExpr`] at rule-build time.
520///
521/// Mirrors the v0.9.5-era pattern for `when_iter:` on cross-
522/// file iteration rules: parse the source once at build, then
523/// evaluate per iteration with a fresh `iter` context. v0.9.12
524/// closed the gap: pre-v0.9.12 the nested `when:` source string
525/// was re-parsed inside `evaluate_for_each` on every iteration
526/// (one parse per (entry, nested-rule) pair, sometimes
527/// thousands of redundant parses per cross-file rule eval). The
528/// `Option<WhenExpr>` on this struct is parsed exactly once.
529///
530/// Build sites (`for_each_dir`, `for_each_file`,
531/// `every_matching_has` in `alint-rules`) construct a
532/// `Vec<CompiledNestedSpec>` from `Vec<NestedRuleSpec>` in
533/// their `build` impl; `evaluate_for_each` consumes the
534/// compiled form.
535#[derive(Debug)]
536pub struct CompiledNestedSpec {
537    /// The original nested-rule spec — passed to
538    /// [`NestedRuleSpec::instantiate`] per iteration to get a
539    /// per-iteration full `RuleSpec` with template tokens
540    /// substituted.
541    pub spec: NestedRuleSpec,
542    /// Pre-compiled `when:` expression. `None` when the nested
543    /// spec carried no `when:` clause.
544    pub when: Option<crate::when::WhenExpr>,
545}
546
547impl CompiledNestedSpec {
548    /// Compile a [`NestedRuleSpec`] — parsing its `when:`
549    /// source string once. Surfaces a build-time config error
550    /// (`"<parent_id>: nested rule #<idx>: invalid when: ..."`)
551    /// when the source fails to parse, so misconfigured
552    /// nested-when clauses fail at config-load time instead of
553    /// per-iteration during evaluation.
554    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    /// Synthesize a full [`RuleSpec`] for a single iteration, applying
574    /// path-template substitution (using the iterated entry's tokens) to
575    /// every string field. The resulting spec has `id =
576    /// "{parent_id}.require[{idx}]"` and inherits `level` from the parent.
577    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            // Nested rules don't currently expose
600            // `git_tracked_only` from their parent's spec — the
601            // option is meaningful on top-level rules only for
602            // now. If/when `for_each_dir`'s nested rules need it,
603            // plumb it through here.
604            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        // Round-trip the documented defaults through serde to
620        // catch silent default drift.
621        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        // Round-trip every documented op name through YAML; any
720        // future fix variant added without a corresponding
721        // op_name arm will fall through serde and trip this test.
722        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        // Path template should have been rendered for both
802        // `paths:` and `message:` from the iterated tokens.
803        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        // Nested rules don't propagate git_tracked_only — the
809        // option is meaningful on top-level rules only.
810        assert!(!spec.git_tracked_only);
811    }
812}