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