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