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}