Skip to main content

alint_core/
config.rs

1use std::collections::{HashMap, HashSet};
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    /// Resolved `allow_out_of_root:` policy — which rules may read a
61    /// config-declared path that escapes the repo root. Set by the
62    /// loader's `finalize()` from the user's *top-level* config only
63    /// (rejected from `extends:`); `#[serde(skip)]` so a directly
64    /// deserialized or bundled `Config` can never set it. Default
65    /// [`AllowOutOfRoot::Confined`]. See
66    /// `docs/design/v0.12/allow_out_of_root.md`.
67    #[serde(skip)]
68    pub allow_out_of_root: AllowOutOfRoot,
69    /// Resolved `baseline:` path — the committed baseline file that `check`
70    /// suppresses against when no `--baseline` flag is given (the flag
71    /// overrides it). Like `allow_out_of_root`, set by the loader from the
72    /// user's *top-level* config only (rejected from `extends:` and nested
73    /// configs); `#[serde(skip)]` so a bundled/inherited `Config` can never
74    /// carry it. A trusted top-level input like `-c`, resolved relative to the
75    /// repo root and not subject to read-path confinement. See
76    /// `docs/design/baseline.md` §2.3.
77    #[serde(skip)]
78    pub baseline: Option<PathBuf>,
79}
80
81// Returning `Option<u64>` (rather than bare `u64`) keeps the
82// YAML-facing type consistent with `Config.fix_size_limit`:
83// users set `null` in YAML to mean "no limit". The Option is
84// load-bearing at the field level, so clippy's warning on the
85// default fn is noise here.
86#[allow(clippy::unnecessary_wraps)]
87fn default_fix_size_limit() -> Option<u64> {
88    Some(1 << 20)
89}
90
91fn default_respect_gitignore() -> bool {
92    true
93}
94
95impl Config {
96    pub const CURRENT_VERSION: u32 = 1;
97}
98
99/// Which rules may *read* a config-declared path that escapes the
100/// repo root — the parsed form of the top-level `allow_out_of_root:`
101/// key. Default [`Confined`](Self::Confined) (hard confinement, the
102/// secure default). Honored only from the user's own top-level config;
103/// the loader rejects it from any `extends:`'d ruleset (the same trust
104/// model as the spawning-rule gate). See
105/// `docs/design/v0.12/allow_out_of_root.md`.
106///
107/// YAML forms: `true` (all rules), or `{ kinds: [...], rules: [...] }`
108/// (a rule is permitted if its kind or id is listed). Absent / `false`
109/// → `Confined`.
110#[derive(Debug, Clone, Default, PartialEq, Eq)]
111pub enum AllowOutOfRoot {
112    /// No rule may read outside the repo root (default).
113    #[default]
114    Confined,
115    /// Every rule may (`allow_out_of_root: true`).
116    All,
117    /// Only rules whose `kind` ∈ `kinds` or `id` ∈ `rules` may.
118    Selective {
119        kinds: HashSet<String>,
120        rules: HashSet<String>,
121    },
122}
123
124impl AllowOutOfRoot {
125    /// Whether a rule with this `id` / `kind` may read out of root.
126    #[must_use]
127    pub fn allows(&self, id: &str, kind: &str) -> bool {
128        match self {
129            Self::Confined => false,
130            Self::All => true,
131            Self::Selective { kinds, rules } => kinds.contains(kind) || rules.contains(id),
132        }
133    }
134
135    /// `true` when nothing is permitted (the default). The `extends:`
136    /// trust gate rejects an inherited ruleset whose value is not this.
137    #[must_use]
138    pub fn is_confined(&self) -> bool {
139        matches!(self, Self::Confined)
140    }
141}
142
143impl<'de> Deserialize<'de> for AllowOutOfRoot {
144    fn deserialize<D: serde::Deserializer<'de>>(
145        deserializer: D,
146    ) -> std::result::Result<Self, D::Error> {
147        #[derive(Deserialize)]
148        #[serde(deny_unknown_fields)]
149        struct SelectiveSpec {
150            #[serde(default)]
151            kinds: Vec<String>,
152            #[serde(default)]
153            rules: Vec<String>,
154        }
155        #[derive(Deserialize)]
156        #[serde(untagged)]
157        enum Raw {
158            Flag(bool),
159            Selective(SelectiveSpec),
160        }
161        Ok(match Raw::deserialize(deserializer)? {
162            Raw::Flag(true) => Self::All,
163            Raw::Flag(false) => Self::Confined,
164            Raw::Selective(s) => Self::Selective {
165                kinds: s.kinds.into_iter().collect(),
166                rules: s.rules.into_iter().collect(),
167            },
168        })
169    }
170}
171
172/// A single `extends:` entry. Accepts either a bare string (the
173/// classic form — a local path, `https://` URL with SRI, or
174/// `alint://bundled/<name>@<rev>`) or a mapping that adds
175/// `only:` / `except:` filters on the inherited rule set.
176///
177/// ```yaml
178/// extends:
179///   - alint://bundled/oss-baseline@v1             # classic form
180///   - url: alint://bundled/rust@v1                # filtered form
181///     except: [rust-no-target-dir]                # drop by id
182///   - url: ./team-defaults.yml
183///     only: [team-copyright-header]               # keep by id
184/// ```
185///
186/// Filters resolve against the *fully-resolved* rule set of the
187/// entry (i.e. anything it transitively extends). `only:` and
188/// `except:` are mutually exclusive on a single entry; listing an
189/// unknown rule id is a config error so typos surface at load
190/// time.
191#[derive(Debug, Clone, Deserialize)]
192#[serde(untagged)]
193pub enum ExtendsEntry {
194    Url(String),
195    Filtered {
196        url: String,
197        #[serde(default)]
198        only: Option<Vec<String>>,
199        #[serde(default)]
200        except: Option<Vec<String>>,
201    },
202}
203
204impl ExtendsEntry {
205    /// The URL / path of the extended config. Uniform across both
206    /// enum variants.
207    pub fn url(&self) -> &str {
208        match self {
209            Self::Url(s) | Self::Filtered { url: s, .. } => s,
210        }
211    }
212
213    /// Rule ids to keep (drop everything else). `None` when no
214    /// `only:` filter is specified.
215    pub fn only(&self) -> Option<&[String]> {
216        match self {
217            Self::Filtered { only: Some(v), .. } => Some(v),
218            _ => None,
219        }
220    }
221
222    /// Rule ids to drop. `None` when no `except:` filter is
223    /// specified.
224    pub fn except(&self) -> Option<&[String]> {
225        match self {
226            Self::Filtered {
227                except: Some(v), ..
228            } => Some(v),
229            _ => None,
230        }
231    }
232}
233
234/// YAML shape for a rule's `paths:` field — a single glob, an array (with
235/// optional `!pattern` negations), or an explicit `{include, exclude}` pair.
236/// For the include/exclude form, each field accepts either a single string
237/// or a list of strings.
238#[derive(Debug, Clone, Deserialize)]
239#[serde(untagged)]
240pub enum PathsSpec {
241    Single(String),
242    Many(Vec<String>),
243    IncludeExclude {
244        #[serde(default, deserialize_with = "string_or_vec")]
245        include: Vec<String>,
246        #[serde(default, deserialize_with = "string_or_vec")]
247        exclude: Vec<String>,
248    },
249}
250
251fn string_or_vec<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
252where
253    D: serde::Deserializer<'de>,
254{
255    #[derive(Deserialize)]
256    #[serde(untagged)]
257    enum OneOrMany {
258        One(String),
259        Many(Vec<String>),
260    }
261    match OneOrMany::deserialize(deserializer)? {
262        OneOrMany::One(s) => Ok(vec![s]),
263        OneOrMany::Many(v) => Ok(v),
264    }
265}
266
267/// YAML-level description of a rule before it is instantiated into a `Box<dyn Rule>`
268/// by a [`RuleBuilder`](crate::registry::RuleBuilder).
269#[derive(Debug, Clone, Deserialize)]
270pub struct RuleSpec {
271    pub id: String,
272    pub kind: String,
273    pub level: Level,
274    #[serde(default)]
275    pub paths: Option<PathsSpec>,
276    #[serde(default)]
277    pub message: Option<String>,
278    #[serde(default)]
279    pub policy_url: Option<String>,
280    #[serde(default)]
281    pub when: Option<String>,
282    /// Optional mechanical-fix strategy. Rules whose builders understand
283    /// the chosen op attach a [`Fixer`](crate::Fixer) to the built rule;
284    /// rules whose kind is incompatible with the op return a config error
285    /// at build time.
286    #[serde(default)]
287    pub fix: Option<FixSpec>,
288    // Neither `git_tracked_only` nor `respect_gitignore` is a RuleSpec field:
289    // both are kind-specific options (ADR-0008). `git_tracked_only` lives in
290    // each existence kind's `Options` struct (`file_exists`/`file_absent`/
291    // `dir_exists`/`dir_absent`) — the engine reads it via the
292    // `Rule::git_tracked_mode()` trait method, never a RuleSpec field.
293    // `respect_gitignore` lives only in `file_exists`'s `Options` — the sole
294    // kind that honours a per-rule override (the bazel-style "tracked AND
295    // gitignored" pitfall #18). Keeping both off `rule_common` means a rule of
296    // any other kind that sets either is rejected at load by
297    // `deny_unknown_fields`, rather than silently ignored.
298    /// Per-file ancestor-manifest gate. When set, the rule
299    /// only fires on files that have at least one ancestor
300    /// directory (including the file's own directory)
301    /// containing a file matching the configured
302    /// `has_ancestor` name(s). Composes AND with `paths:`
303    /// and `git_tracked_only:`.
304    ///
305    /// Only meaningful for per-file rules; cross-file rule
306    /// builders MUST reject this field at build time
307    /// (see the design doc for the cross-file alternative
308    /// via `for_each_dir + when_iter:`).
309    ///
310    /// Default `None` (no scope filter; existing rules
311    /// preserve their pre-v0.9.6 behaviour).
312    #[serde(default)]
313    pub scope_filter: Option<crate::ScopeFilterSpec>,
314    /// The entire YAML mapping, retained so each rule builder can deserialize
315    /// its kind-specific fields without every option being represented here.
316    #[serde(flatten)]
317    pub extra: serde_yaml_ng::Mapping,
318}
319
320/// The `fix:` block on a rule. Exactly one op key must be present —
321/// alint errors at load time when the op and rule kind are incompatible.
322#[derive(Debug, Clone, Deserialize)]
323#[serde(untagged)]
324pub enum FixSpec {
325    FileCreate {
326        file_create: FileCreateFixSpec,
327    },
328    FileRemove {
329        file_remove: FileRemoveFixSpec,
330    },
331    FilePrepend {
332        file_prepend: FilePrependFixSpec,
333    },
334    FileAppend {
335        file_append: FileAppendFixSpec,
336    },
337    FileRename {
338        file_rename: FileRenameFixSpec,
339    },
340    FileTrimTrailingWhitespace {
341        file_trim_trailing_whitespace: FileTrimTrailingWhitespaceFixSpec,
342    },
343    FileAppendFinalNewline {
344        file_append_final_newline: FileAppendFinalNewlineFixSpec,
345    },
346    FileNormalizeLineEndings {
347        file_normalize_line_endings: FileNormalizeLineEndingsFixSpec,
348    },
349    FileStripBidi {
350        file_strip_bidi: FileStripBidiFixSpec,
351    },
352    FileStripZeroWidth {
353        file_strip_zero_width: FileStripZeroWidthFixSpec,
354    },
355    FileStripBom {
356        file_strip_bom: FileStripBomFixSpec,
357    },
358    FileCollapseBlankLines {
359        file_collapse_blank_lines: FileCollapseBlankLinesFixSpec,
360    },
361}
362
363impl FixSpec {
364    /// The op name as it appears in YAML — used in config-error messages.
365    pub fn op_name(&self) -> &'static str {
366        match self {
367            Self::FileCreate { .. } => "file_create",
368            Self::FileRemove { .. } => "file_remove",
369            Self::FilePrepend { .. } => "file_prepend",
370            Self::FileAppend { .. } => "file_append",
371            Self::FileRename { .. } => "file_rename",
372            Self::FileTrimTrailingWhitespace { .. } => "file_trim_trailing_whitespace",
373            Self::FileAppendFinalNewline { .. } => "file_append_final_newline",
374            Self::FileNormalizeLineEndings { .. } => "file_normalize_line_endings",
375            Self::FileStripBidi { .. } => "file_strip_bidi",
376            Self::FileStripZeroWidth { .. } => "file_strip_zero_width",
377            Self::FileStripBom { .. } => "file_strip_bom",
378            Self::FileCollapseBlankLines { .. } => "file_collapse_blank_lines",
379        }
380    }
381}
382
383#[derive(Debug, Clone, Deserialize)]
384#[serde(deny_unknown_fields)]
385pub struct FileCreateFixSpec {
386    /// Inline content to write. Mutually exclusive with
387    /// `content_from`; exactly one of the two must be set. For
388    /// an empty file, pass `content: ""` explicitly.
389    #[serde(default)]
390    pub content: Option<String>,
391    /// Path to a file (relative to the lint root) whose bytes
392    /// will be the content. Mutually exclusive with `content`.
393    /// Read at fix-apply time; missing source produces a
394    /// `Skipped` outcome rather than a panic. Useful for
395    /// LICENSE / NOTICE / CONTRIBUTING boilerplate that's too
396    /// long to inline in YAML.
397    #[serde(default)]
398    pub content_from: Option<PathBuf>,
399    /// Path to create, relative to the repo root. When omitted, the
400    /// rule builder substitutes the first literal entry from the rule's
401    /// `paths:` list.
402    #[serde(default)]
403    pub path: Option<PathBuf>,
404    /// Whether to create intermediate directories. Defaults to true.
405    #[serde(default = "default_create_parents")]
406    pub create_parents: bool,
407}
408
409fn default_create_parents() -> bool {
410    true
411}
412
413#[derive(Debug, Clone, Deserialize, Default)]
414#[serde(deny_unknown_fields)]
415pub struct FileRemoveFixSpec {}
416
417#[derive(Debug, Clone, Deserialize)]
418#[serde(deny_unknown_fields)]
419pub struct FilePrependFixSpec {
420    /// Inline bytes to insert at the beginning of each
421    /// violating file. Mutually exclusive with `content_from`.
422    /// A trailing newline is the caller's responsibility.
423    #[serde(default)]
424    pub content: Option<String>,
425    /// Path to a file (relative to the lint root) whose bytes
426    /// will be prepended. Mutually exclusive with `content`.
427    #[serde(default)]
428    pub content_from: Option<PathBuf>,
429}
430
431#[derive(Debug, Clone, Deserialize)]
432#[serde(deny_unknown_fields)]
433pub struct FileAppendFixSpec {
434    /// Inline bytes to append to each violating file. Mutually
435    /// exclusive with `content_from`. A leading newline is the
436    /// caller's responsibility.
437    #[serde(default)]
438    pub content: Option<String>,
439    /// Path to a file (relative to the lint root) whose bytes
440    /// will be appended. Mutually exclusive with `content`.
441    #[serde(default)]
442    pub content_from: Option<PathBuf>,
443}
444
445/// Resolution of an `(content, content_from)` pair to a single
446/// content source. Used by the three fixers that take either.
447/// Errors when neither or both are set.
448pub fn resolve_content_source(
449    rule_id: &str,
450    op_name: &str,
451    inline: &Option<String>,
452    from: &Option<PathBuf>,
453) -> crate::error::Result<ContentSourceSpec> {
454    match (inline, from) {
455        (Some(_), Some(_)) => Err(crate::error::Error::rule_config(
456            rule_id,
457            format!("fix.{op_name}: `content` and `content_from` are mutually exclusive"),
458        )),
459        (None, None) => Err(crate::error::Error::rule_config(
460            rule_id,
461            format!("fix.{op_name}: one of `content` or `content_from` is required"),
462        )),
463        (Some(s), None) => Ok(ContentSourceSpec::Inline(s.clone())),
464        (None, Some(p)) => Ok(ContentSourceSpec::File(p.clone())),
465    }
466}
467
468/// Pre-validated content source — exactly one of inline or
469/// from-file. Resolved at config-parse time so fixers don't
470/// need to reproduce the XOR check at apply time.
471#[derive(Debug, Clone)]
472pub enum ContentSourceSpec {
473    /// Inline string body.
474    Inline(String),
475    /// Path relative to the lint root; bytes are read at fix-
476    /// apply time.
477    File(PathBuf),
478}
479
480impl From<String> for ContentSourceSpec {
481    fn from(s: String) -> Self {
482        Self::Inline(s)
483    }
484}
485
486impl From<&str> for ContentSourceSpec {
487    fn from(s: &str) -> Self {
488        Self::Inline(s.to_string())
489    }
490}
491
492/// Empty marker: `file_rename` takes no parameters. The target name
493/// is derived from the parent rule (e.g. `filename_case` converts the
494/// stem to its configured case; the extension is preserved).
495#[derive(Debug, Clone, Deserialize, Default)]
496#[serde(deny_unknown_fields)]
497pub struct FileRenameFixSpec {}
498
499/// Empty marker. Behavior: read file (subject to `fix_size_limit`),
500/// strip trailing space/tab on every line, write back.
501#[derive(Debug, Clone, Deserialize, Default)]
502#[serde(deny_unknown_fields)]
503pub struct FileTrimTrailingWhitespaceFixSpec {}
504
505/// Empty marker. Behavior: if the file has content and does not
506/// end with `\n`, append one.
507#[derive(Debug, Clone, Deserialize, Default)]
508#[serde(deny_unknown_fields)]
509pub struct FileAppendFinalNewlineFixSpec {}
510
511/// Empty marker. Behavior: rewrite the file with every line ending
512/// replaced by the parent rule's configured target (`lf` or `crlf`).
513#[derive(Debug, Clone, Deserialize, Default)]
514#[serde(deny_unknown_fields)]
515pub struct FileNormalizeLineEndingsFixSpec {}
516
517/// Empty marker. Behavior: remove every Unicode bidi control
518/// character (U+202A–202E, U+2066–2069) from the file's content.
519#[derive(Debug, Clone, Deserialize, Default)]
520#[serde(deny_unknown_fields)]
521pub struct FileStripBidiFixSpec {}
522
523/// Empty marker. Behavior: remove every zero-width character
524/// (U+200B / U+200C / U+200D / U+2060 / U+180E / U+FEFF) from the
525/// file's content, *except* a leading BOM (U+FEFF at position 0) —
526/// that's the responsibility of the `no_bom` rule.
527#[derive(Debug, Clone, Deserialize, Default)]
528#[serde(deny_unknown_fields)]
529pub struct FileStripZeroWidthFixSpec {}
530
531/// Empty marker. Behavior: remove a leading UTF-8/UTF-16/UTF-32
532/// BOM byte sequence if present; otherwise a no-op.
533#[derive(Debug, Clone, Deserialize, Default)]
534#[serde(deny_unknown_fields)]
535pub struct FileStripBomFixSpec {}
536
537/// Empty marker. Behavior: collapse runs of blank lines longer than
538/// the parent rule's `max` down to exactly `max` blank lines.
539#[derive(Debug, Clone, Deserialize, Default)]
540#[serde(deny_unknown_fields)]
541pub struct FileCollapseBlankLinesFixSpec {}
542
543impl RuleSpec {
544    /// Deserialize the full spec (common + kind-specific fields) into a typed
545    /// options struct. Common fields are reconstructed into the mapping so
546    /// the target struct can `#[derive(Deserialize)]` against the whole shape
547    /// when convenient.
548    pub fn deserialize_options<T>(&self) -> crate::error::Result<T>
549    where
550        T: serde::de::DeserializeOwned,
551    {
552        Ok(serde_yaml_ng::from_value(serde_yaml_ng::Value::Mapping(
553            self.extra.clone(),
554        ))?)
555    }
556
557    /// Reject any leftover option key on this spec — for rule kinds that take
558    /// NO kind-specific options. Option-bearing kinds reject unknown fields via
559    /// their `deserialize_options::<Options>()` (a `deny_unknown_fields`
560    /// struct); this is the equivalent loud failure for option-less kinds,
561    /// used by `RuleRegistry::register_optionless`. Without it, a typo'd option
562    /// on an option-less rule silently no-ops.
563    pub fn deny_unknown_options(&self) -> crate::error::Result<()> {
564        #[derive(serde::Deserialize)]
565        #[serde(deny_unknown_fields)]
566        struct NoOptions {}
567        self.deserialize_options::<NoOptions>()
568            .map(|_: NoOptions| ())
569    }
570
571    /// Parse and validate this spec's optional `scope_filter:`
572    /// field into a built [`ScopeFilter`](crate::ScopeFilter).
573    /// Returns `Ok(None)` when the spec has no `scope_filter`
574    /// set (the common case).
575    ///
576    /// Per-file rule builders typically don't call this directly
577    /// since v0.9.10 — they use
578    /// [`Scope::from_spec`](crate::Scope::from_spec) instead,
579    /// which bundles `paths:` + `scope_filter:` parsing into one
580    /// call. The Scope owns the parsed filter and consults it
581    /// inside [`Scope::matches`](crate::Scope::matches), so the
582    /// engine doesn't need a separate per-rule accessor any more.
583    /// Cross-file rules MUST NOT call this — they call
584    /// [`reject_scope_filter_on_cross_file`](crate::reject_scope_filter_on_cross_file)
585    /// instead so a misconfigured `scope_filter:` on a cross-
586    /// file rule surfaces as a clear build-time error rather
587    /// than a silently-ignored field.
588    pub fn parse_scope_filter(&self) -> crate::error::Result<Option<crate::ScopeFilter>> {
589        match &self.scope_filter {
590            Some(spec) => Ok(Some(crate::ScopeFilter::from_spec(&self.id, spec.clone())?)),
591            None => Ok(None),
592        }
593    }
594}
595
596/// Rule specification for nested rules (e.g. the `require:` block of
597/// `for_each_dir`). Unlike [`RuleSpec`], `id` and `level` are synthesized
598/// from the parent rule — users just supply the `kind` plus kind-specific
599/// options, optionally with a `message` / `policy_url` / `when`.
600#[derive(Debug, Clone, Deserialize)]
601pub struct NestedRuleSpec {
602    pub kind: String,
603    #[serde(default)]
604    pub paths: Option<PathsSpec>,
605    #[serde(default)]
606    pub message: Option<String>,
607    #[serde(default)]
608    pub policy_url: Option<String>,
609    #[serde(default)]
610    pub when: Option<String>,
611    /// Per-file scope filter — see [`RuleSpec::scope_filter`]
612    /// for semantics. Inherited unchanged when
613    /// [`NestedRuleSpec::instantiate`] synthesises a full
614    /// `RuleSpec` per-iteration.
615    #[serde(default)]
616    pub scope_filter: Option<crate::ScopeFilterSpec>,
617    #[serde(flatten)]
618    pub extra: serde_yaml_ng::Mapping,
619}
620
621/// A [`NestedRuleSpec`] with its `when:` source pre-compiled
622/// into a [`crate::when::WhenExpr`] at rule-build time.
623///
624/// Mirrors the v0.9.5-era pattern for `when_iter:` on cross-
625/// file iteration rules: parse the source once at build, then
626/// evaluate per iteration with a fresh `iter` context. v0.9.12
627/// closed the gap: pre-v0.9.12 the nested `when:` source string
628/// was re-parsed inside `evaluate_for_each` on every iteration
629/// (one parse per (entry, nested-rule) pair, sometimes
630/// thousands of redundant parses per cross-file rule eval). The
631/// `Option<WhenExpr>` on this struct is parsed exactly once.
632///
633/// Build sites (`for_each_dir`, `for_each_file`,
634/// `every_matching_has` in `alint-rules`) construct a
635/// `Vec<CompiledNestedSpec>` from `Vec<NestedRuleSpec>` in
636/// their `build` impl; `evaluate_for_each` consumes the
637/// compiled form.
638#[derive(Debug)]
639pub struct CompiledNestedSpec {
640    /// The original nested-rule spec — passed to
641    /// [`NestedRuleSpec::instantiate`] per iteration to get a
642    /// per-iteration full `RuleSpec` with template tokens
643    /// substituted.
644    pub spec: NestedRuleSpec,
645    /// Pre-compiled `when:` expression. `None` when the nested
646    /// spec carried no `when:` clause.
647    pub when: Option<crate::when::WhenExpr>,
648}
649
650impl CompiledNestedSpec {
651    /// Compile a [`NestedRuleSpec`] — parsing its `when:`
652    /// source string once. Surfaces a build-time config error
653    /// (`"<parent_id>: nested rule #<idx>: invalid when: ..."`)
654    /// when the source fails to parse, so misconfigured
655    /// nested-when clauses fail at config-load time instead of
656    /// per-iteration during evaluation.
657    pub fn compile(
658        spec: NestedRuleSpec,
659        parent_id: &str,
660        idx: usize,
661    ) -> crate::error::Result<Self> {
662        let when = match spec.when.as_deref() {
663            Some(src) => Some(crate::when::parse(src).map_err(|e| {
664                crate::error::Error::rule_config(
665                    parent_id,
666                    format!("nested rule #{idx}: invalid when: {e}"),
667                )
668            })?),
669            None => None,
670        };
671        Ok(Self { spec, when })
672    }
673}
674
675impl NestedRuleSpec {
676    /// Synthesize a full [`RuleSpec`] for a single iteration, applying
677    /// path-template substitution (using the iterated entry's tokens) to
678    /// every string field. The resulting spec has `id =
679    /// "{parent_id}.require[{idx}]"` and inherits `level` from the parent.
680    pub fn instantiate(
681        &self,
682        parent_id: &str,
683        idx: usize,
684        level: Level,
685        tokens: &crate::template::PathTokens,
686    ) -> RuleSpec {
687        RuleSpec {
688            id: format!("{parent_id}.require[{idx}]"),
689            kind: self.kind.clone(),
690            level,
691            paths: self
692                .paths
693                .as_ref()
694                .map(|p| crate::template::render_paths_spec(p, tokens)),
695            message: self
696                .message
697                .as_deref()
698                .map(|m| crate::template::render_path(m, tokens)),
699            policy_url: self.policy_url.clone(),
700            when: self.when.clone(),
701            fix: None,
702            // `git_tracked_only` and `respect_gitignore` are both kind-specific
703            // options now (ADR-0008), stripped from the nested `extra` via
704            // PARENT_FIELDS below, so a nested existence rule doesn't silently
705            // inherit either. If/when nested rules need one, drop it from
706            // PARENT_FIELDS and let it flow into the leaf's option set.
707            scope_filter: self.scope_filter.clone(),
708            // `NestedRuleSpec` doesn't name `id`/`level` (synthesized from the
709            // parent) or the top-level-only `fix`/git toggles, so a nested
710            // config that supplies them leaves them in `extra`. Strip them
711            // before they reach the leaf rule's option set — they are not
712            // options, and would otherwise trip the leaf's unknown-option
713            // validation (a deny-unknown-fields `Options` struct, or an
714            // option-less rule's `deny_unknown_options`).
715            extra: {
716                const PARENT_FIELDS: &[&str] = &[
717                    "id",
718                    "level",
719                    "fix",
720                    "git_tracked_only",
721                    "respect_gitignore",
722                ];
723                crate::template::render_mapping(self.extra.clone(), tokens)
724                    .into_iter()
725                    .filter(|(k, _)| k.as_str().is_none_or(|s| !PARENT_FIELDS.contains(&s)))
726                    .collect()
727            },
728        }
729    }
730}
731
732#[cfg(test)]
733mod tests {
734    use super::*;
735    use crate::template::PathTokens;
736    use std::path::Path;
737
738    #[test]
739    fn allow_out_of_root_policy_resolves() {
740        assert!(!AllowOutOfRoot::Confined.allows("r", "k"));
741        assert!(AllowOutOfRoot::All.allows("r", "k"));
742        let sel = AllowOutOfRoot::Selective {
743            kinds: ["json_schema_passes".to_string()].into_iter().collect(),
744            rules: ["my-rule".to_string()].into_iter().collect(),
745        };
746        assert!(sel.allows("anything", "json_schema_passes"), "by kind");
747        assert!(sel.allows("my-rule", "other_kind"), "by id");
748        assert!(!sel.allows("nope", "other_kind"), "neither id nor kind");
749        assert!(AllowOutOfRoot::Confined.is_confined());
750        assert!(!AllowOutOfRoot::All.is_confined());
751    }
752
753    #[test]
754    fn allow_out_of_root_deserializes_bool_and_map() {
755        let t: AllowOutOfRoot = serde_yaml_ng::from_str("true").unwrap();
756        assert_eq!(t, AllowOutOfRoot::All);
757        let f: AllowOutOfRoot = serde_yaml_ng::from_str("false").unwrap();
758        assert_eq!(f, AllowOutOfRoot::Confined);
759        let m: AllowOutOfRoot = serde_yaml_ng::from_str("kinds: [pair_hash]\nrules: [x]").unwrap();
760        match m {
761            AllowOutOfRoot::Selective { kinds, rules } => {
762                assert!(kinds.contains("pair_hash"));
763                assert!(rules.contains("x"));
764            }
765            other => panic!("expected Selective, got {other:?}"),
766        }
767        // an unknown key in the map form is rejected.
768        assert!(serde_yaml_ng::from_str::<AllowOutOfRoot>("bogus: 1").is_err());
769    }
770
771    #[test]
772    fn config_default_respects_gitignore_and_caps_fix_size() {
773        // Round-trip the documented defaults through serde to
774        // catch silent default drift.
775        let cfg: Config = serde_yaml_ng::from_str("version: 1\n").expect("minimal config");
776        assert_eq!(cfg.version, 1);
777        assert!(cfg.respect_gitignore);
778        assert_eq!(cfg.fix_size_limit, Some(1 << 20));
779        assert!(!cfg.nested_configs);
780        assert!(cfg.extends.is_empty());
781        assert!(cfg.rules.is_empty());
782    }
783
784    #[test]
785    fn config_rejects_unknown_top_level_field() {
786        let err = serde_yaml_ng::from_str::<Config>("version: 1\nignored_typo: true\n");
787        assert!(err.is_err(), "deny_unknown_fields should reject typos");
788    }
789
790    #[test]
791    fn config_explicit_null_disables_fix_size_limit() {
792        let cfg: Config = serde_yaml_ng::from_str("version: 1\nfix_size_limit: null\n").unwrap();
793        assert_eq!(cfg.fix_size_limit, None);
794    }
795
796    #[test]
797    fn extends_entry_url_form_has_no_filters() {
798        let e = ExtendsEntry::Url("alint://bundled/oss-baseline@v1".into());
799        assert_eq!(e.url(), "alint://bundled/oss-baseline@v1");
800        assert!(e.only().is_none());
801        assert!(e.except().is_none());
802    }
803
804    #[test]
805    fn extends_entry_filtered_form_exposes_only_and_except() {
806        let e = ExtendsEntry::Filtered {
807            url: "alint://bundled/rust@v1".into(),
808            only: Some(vec!["rust-edition".into()]),
809            except: None,
810        };
811        assert_eq!(e.url(), "alint://bundled/rust@v1");
812        assert_eq!(e.only(), Some(&["rust-edition".to_string()][..]));
813        assert!(e.except().is_none());
814    }
815
816    #[test]
817    fn extends_entry_filtered_form_supports_except_only() {
818        let e = ExtendsEntry::Filtered {
819            url: "./team.yml".into(),
820            only: None,
821            except: Some(vec!["legacy-rule".into()]),
822        };
823        assert_eq!(e.except(), Some(&["legacy-rule".to_string()][..]));
824        assert!(e.only().is_none());
825    }
826
827    #[test]
828    fn paths_spec_accepts_three_shapes() {
829        let single: PathsSpec = serde_yaml_ng::from_str("\"src/**\"").unwrap();
830        assert!(matches!(single, PathsSpec::Single(s) if s == "src/**"));
831
832        let many: PathsSpec = serde_yaml_ng::from_str("[\"src/**\", \"!src/vendor/**\"]").unwrap();
833        assert!(matches!(many, PathsSpec::Many(v) if v.len() == 2));
834
835        let inc_exc: PathsSpec =
836            serde_yaml_ng::from_str("include: src/**\nexclude: src/vendor/**\n").unwrap();
837        match inc_exc {
838            PathsSpec::IncludeExclude { include, exclude } => {
839                assert_eq!(include, vec!["src/**"]);
840                assert_eq!(exclude, vec!["src/vendor/**"]);
841            }
842            _ => panic!("expected include/exclude shape"),
843        }
844    }
845
846    #[test]
847    fn paths_spec_include_accepts_string_or_vec() {
848        let from_string: PathsSpec =
849            serde_yaml_ng::from_str("include: a\nexclude:\n  - b\n  - c\n").unwrap();
850        let PathsSpec::IncludeExclude { include, exclude } = from_string else {
851            panic!("expected include/exclude shape");
852        };
853        assert_eq!(include, vec!["a"]);
854        assert_eq!(exclude, vec!["b", "c"]);
855    }
856
857    #[test]
858    fn rule_spec_deserialize_options_picks_up_kind_specific_fields() {
859        #[derive(Deserialize, Debug)]
860        struct PatternOnly {
861            pattern: String,
862        }
863        let spec: RuleSpec = serde_yaml_ng::from_str(
864            "id: r\nkind: file_content_matches\nlevel: error\npaths: src/**\npattern: TODO\n",
865        )
866        .unwrap();
867        let opts: PatternOnly = spec.deserialize_options().unwrap();
868        assert_eq!(opts.pattern, "TODO");
869    }
870
871    #[test]
872    fn fix_spec_op_name_covers_every_variant() {
873        // Round-trip every documented op name through YAML; any
874        // future fix variant added without a corresponding
875        // op_name arm will fall through serde and trip this test.
876        let cases = [
877            ("file_create:\n  content: x\n", "file_create"),
878            ("file_remove: {}", "file_remove"),
879            ("file_prepend:\n  content: x\n", "file_prepend"),
880            ("file_append:\n  content: x\n", "file_append"),
881            ("file_rename: {}", "file_rename"),
882            (
883                "file_trim_trailing_whitespace: {}",
884                "file_trim_trailing_whitespace",
885            ),
886            ("file_append_final_newline: {}", "file_append_final_newline"),
887            (
888                "file_normalize_line_endings: {}",
889                "file_normalize_line_endings",
890            ),
891            ("file_strip_bidi: {}", "file_strip_bidi"),
892            ("file_strip_zero_width: {}", "file_strip_zero_width"),
893            ("file_strip_bom: {}", "file_strip_bom"),
894            ("file_collapse_blank_lines: {}", "file_collapse_blank_lines"),
895        ];
896        for (yaml, expected) in cases {
897            let spec: FixSpec =
898                serde_yaml_ng::from_str(yaml).unwrap_or_else(|e| panic!("{yaml}: {e}"));
899            assert_eq!(spec.op_name(), expected);
900        }
901    }
902
903    #[test]
904    fn resolve_content_source_inline_only() {
905        let s = Some("hello".to_string());
906        let resolved = resolve_content_source("r", "file_create", &s, &None).unwrap();
907        assert!(matches!(resolved, ContentSourceSpec::Inline(b) if b == "hello"));
908    }
909
910    #[test]
911    fn resolve_content_source_file_only() {
912        let p = Some(Path::new("LICENSE").into());
913        let resolved = resolve_content_source("r", "file_create", &None, &p).unwrap();
914        assert!(matches!(resolved, ContentSourceSpec::File(p) if p == Path::new("LICENSE")));
915    }
916
917    #[test]
918    fn resolve_content_source_rejects_both_set() {
919        let err = resolve_content_source(
920            "r",
921            "file_prepend",
922            &Some("x".into()),
923            &Some(Path::new("y").into()),
924        )
925        .unwrap_err();
926        assert!(err.to_string().contains("mutually exclusive"));
927    }
928
929    #[test]
930    fn resolve_content_source_rejects_neither_set() {
931        let err = resolve_content_source("r", "file_append", &None, &None).unwrap_err();
932        assert!(err.to_string().contains("required"));
933    }
934
935    #[test]
936    fn content_source_spec_from_string_variants() {
937        let from_owned: ContentSourceSpec = String::from("hi").into();
938        assert!(matches!(from_owned, ContentSourceSpec::Inline(s) if s == "hi"));
939        let from_str: ContentSourceSpec = "hi".into();
940        assert!(matches!(from_str, ContentSourceSpec::Inline(s) if s == "hi"));
941    }
942
943    #[test]
944    fn nested_rule_spec_instantiate_synthesizes_id_and_inherits_level() {
945        let nested: NestedRuleSpec = serde_yaml_ng::from_str(
946            "kind: file_exists\npaths: \"{path}/README.md\"\nmessage: missing in {path}\n",
947        )
948        .unwrap();
949        let tokens = PathTokens::from_path(Path::new("packages/foo"));
950        let spec = nested.instantiate("every-pkg-has-readme", 0, Level::Error, &tokens);
951
952        assert_eq!(spec.id, "every-pkg-has-readme.require[0]");
953        assert_eq!(spec.kind, "file_exists");
954        assert_eq!(spec.level, Level::Error);
955        // Path template should have been rendered for both
956        // `paths:` and `message:` from the iterated tokens.
957        match spec.paths {
958            Some(PathsSpec::Single(p)) => assert_eq!(p, "packages/foo/README.md"),
959            other => panic!("unexpected paths shape: {other:?}"),
960        }
961        assert_eq!(spec.message.as_deref(), Some("missing in packages/foo"));
962        // Nested rules don't propagate git_tracked_only: it is a kind-specific
963        // option (ADR-0008) stripped from the nested `extra` via PARENT_FIELDS,
964        // so it never reaches the leaf rule's options.
965        assert!(
966            !spec
967                .extra
968                .keys()
969                .any(|k| k.as_str() == Some("git_tracked_only")),
970            "git_tracked_only should be stripped from nested extra"
971        );
972    }
973}