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 /// The entire YAML mapping, retained so each rule builder can deserialize
210 /// its kind-specific fields without every option being represented here.
211 #[serde(flatten)]
212 pub extra: serde_yaml_ng::Mapping,
213}
214
215/// The `fix:` block on a rule. Exactly one op key must be present —
216/// alint errors at load time when the op and rule kind are incompatible.
217#[derive(Debug, Clone, Deserialize)]
218#[serde(untagged)]
219pub enum FixSpec {
220 FileCreate {
221 file_create: FileCreateFixSpec,
222 },
223 FileRemove {
224 file_remove: FileRemoveFixSpec,
225 },
226 FilePrepend {
227 file_prepend: FilePrependFixSpec,
228 },
229 FileAppend {
230 file_append: FileAppendFixSpec,
231 },
232 FileRename {
233 file_rename: FileRenameFixSpec,
234 },
235 FileTrimTrailingWhitespace {
236 file_trim_trailing_whitespace: FileTrimTrailingWhitespaceFixSpec,
237 },
238 FileAppendFinalNewline {
239 file_append_final_newline: FileAppendFinalNewlineFixSpec,
240 },
241 FileNormalizeLineEndings {
242 file_normalize_line_endings: FileNormalizeLineEndingsFixSpec,
243 },
244 FileStripBidi {
245 file_strip_bidi: FileStripBidiFixSpec,
246 },
247 FileStripZeroWidth {
248 file_strip_zero_width: FileStripZeroWidthFixSpec,
249 },
250 FileStripBom {
251 file_strip_bom: FileStripBomFixSpec,
252 },
253 FileCollapseBlankLines {
254 file_collapse_blank_lines: FileCollapseBlankLinesFixSpec,
255 },
256}
257
258impl FixSpec {
259 /// The op name as it appears in YAML — used in config-error messages.
260 pub fn op_name(&self) -> &'static str {
261 match self {
262 Self::FileCreate { .. } => "file_create",
263 Self::FileRemove { .. } => "file_remove",
264 Self::FilePrepend { .. } => "file_prepend",
265 Self::FileAppend { .. } => "file_append",
266 Self::FileRename { .. } => "file_rename",
267 Self::FileTrimTrailingWhitespace { .. } => "file_trim_trailing_whitespace",
268 Self::FileAppendFinalNewline { .. } => "file_append_final_newline",
269 Self::FileNormalizeLineEndings { .. } => "file_normalize_line_endings",
270 Self::FileStripBidi { .. } => "file_strip_bidi",
271 Self::FileStripZeroWidth { .. } => "file_strip_zero_width",
272 Self::FileStripBom { .. } => "file_strip_bom",
273 Self::FileCollapseBlankLines { .. } => "file_collapse_blank_lines",
274 }
275 }
276}
277
278#[derive(Debug, Clone, Deserialize)]
279#[serde(deny_unknown_fields)]
280pub struct FileCreateFixSpec {
281 /// Inline content to write. Mutually exclusive with
282 /// `content_from`; exactly one of the two must be set. For
283 /// an empty file, pass `content: ""` explicitly.
284 #[serde(default)]
285 pub content: Option<String>,
286 /// Path to a file (relative to the lint root) whose bytes
287 /// will be the content. Mutually exclusive with `content`.
288 /// Read at fix-apply time; missing source produces a
289 /// `Skipped` outcome rather than a panic. Useful for
290 /// LICENSE / NOTICE / CONTRIBUTING boilerplate that's too
291 /// long to inline in YAML.
292 #[serde(default)]
293 pub content_from: Option<PathBuf>,
294 /// Path to create, relative to the repo root. When omitted, the
295 /// rule builder substitutes the first literal entry from the rule's
296 /// `paths:` list.
297 #[serde(default)]
298 pub path: Option<PathBuf>,
299 /// Whether to create intermediate directories. Defaults to true.
300 #[serde(default = "default_create_parents")]
301 pub create_parents: bool,
302}
303
304fn default_create_parents() -> bool {
305 true
306}
307
308#[derive(Debug, Clone, Deserialize, Default)]
309#[serde(deny_unknown_fields)]
310pub struct FileRemoveFixSpec {}
311
312#[derive(Debug, Clone, Deserialize)]
313#[serde(deny_unknown_fields)]
314pub struct FilePrependFixSpec {
315 /// Inline bytes to insert at the beginning of each
316 /// violating file. Mutually exclusive with `content_from`.
317 /// A trailing newline is the caller's responsibility.
318 #[serde(default)]
319 pub content: Option<String>,
320 /// Path to a file (relative to the lint root) whose bytes
321 /// will be prepended. Mutually exclusive with `content`.
322 #[serde(default)]
323 pub content_from: Option<PathBuf>,
324}
325
326#[derive(Debug, Clone, Deserialize)]
327#[serde(deny_unknown_fields)]
328pub struct FileAppendFixSpec {
329 /// Inline bytes to append to each violating file. Mutually
330 /// exclusive with `content_from`. A leading newline is the
331 /// caller's responsibility.
332 #[serde(default)]
333 pub content: Option<String>,
334 /// Path to a file (relative to the lint root) whose bytes
335 /// will be appended. Mutually exclusive with `content`.
336 #[serde(default)]
337 pub content_from: Option<PathBuf>,
338}
339
340/// Resolution of an `(content, content_from)` pair to a single
341/// content source. Used by the three fixers that take either.
342/// Errors when neither or both are set.
343pub fn resolve_content_source(
344 rule_id: &str,
345 op_name: &str,
346 inline: &Option<String>,
347 from: &Option<PathBuf>,
348) -> crate::error::Result<ContentSourceSpec> {
349 match (inline, from) {
350 (Some(_), Some(_)) => Err(crate::error::Error::rule_config(
351 rule_id,
352 format!("fix.{op_name}: `content` and `content_from` are mutually exclusive"),
353 )),
354 (None, None) => Err(crate::error::Error::rule_config(
355 rule_id,
356 format!("fix.{op_name}: one of `content` or `content_from` is required"),
357 )),
358 (Some(s), None) => Ok(ContentSourceSpec::Inline(s.clone())),
359 (None, Some(p)) => Ok(ContentSourceSpec::File(p.clone())),
360 }
361}
362
363/// Pre-validated content source — exactly one of inline or
364/// from-file. Resolved at config-parse time so fixers don't
365/// need to reproduce the XOR check at apply time.
366#[derive(Debug, Clone)]
367pub enum ContentSourceSpec {
368 /// Inline string body.
369 Inline(String),
370 /// Path relative to the lint root; bytes are read at fix-
371 /// apply time.
372 File(PathBuf),
373}
374
375impl From<String> for ContentSourceSpec {
376 fn from(s: String) -> Self {
377 Self::Inline(s)
378 }
379}
380
381impl From<&str> for ContentSourceSpec {
382 fn from(s: &str) -> Self {
383 Self::Inline(s.to_string())
384 }
385}
386
387/// Empty marker: `file_rename` takes no parameters. The target name
388/// is derived from the parent rule (e.g. `filename_case` converts the
389/// stem to its configured case; the extension is preserved).
390#[derive(Debug, Clone, Deserialize, Default)]
391#[serde(deny_unknown_fields)]
392pub struct FileRenameFixSpec {}
393
394/// Empty marker. Behavior: read file (subject to `fix_size_limit`),
395/// strip trailing space/tab on every line, write back.
396#[derive(Debug, Clone, Deserialize, Default)]
397#[serde(deny_unknown_fields)]
398pub struct FileTrimTrailingWhitespaceFixSpec {}
399
400/// Empty marker. Behavior: if the file has content and does not
401/// end with `\n`, append one.
402#[derive(Debug, Clone, Deserialize, Default)]
403#[serde(deny_unknown_fields)]
404pub struct FileAppendFinalNewlineFixSpec {}
405
406/// Empty marker. Behavior: rewrite the file with every line ending
407/// replaced by the parent rule's configured target (`lf` or `crlf`).
408#[derive(Debug, Clone, Deserialize, Default)]
409#[serde(deny_unknown_fields)]
410pub struct FileNormalizeLineEndingsFixSpec {}
411
412/// Empty marker. Behavior: remove every Unicode bidi control
413/// character (U+202A–202E, U+2066–2069) from the file's content.
414#[derive(Debug, Clone, Deserialize, Default)]
415#[serde(deny_unknown_fields)]
416pub struct FileStripBidiFixSpec {}
417
418/// Empty marker. Behavior: remove every zero-width character
419/// (U+200B / U+200C / U+200D / U+FEFF) from the file's content,
420/// *except* a leading BOM (U+FEFF at position 0) — that's the
421/// responsibility of the `no_bom` rule.
422#[derive(Debug, Clone, Deserialize, Default)]
423#[serde(deny_unknown_fields)]
424pub struct FileStripZeroWidthFixSpec {}
425
426/// Empty marker. Behavior: remove a leading UTF-8/UTF-16/UTF-32
427/// BOM byte sequence if present; otherwise a no-op.
428#[derive(Debug, Clone, Deserialize, Default)]
429#[serde(deny_unknown_fields)]
430pub struct FileStripBomFixSpec {}
431
432/// Empty marker. Behavior: collapse runs of blank lines longer than
433/// the parent rule's `max` down to exactly `max` blank lines.
434#[derive(Debug, Clone, Deserialize, Default)]
435#[serde(deny_unknown_fields)]
436pub struct FileCollapseBlankLinesFixSpec {}
437
438impl RuleSpec {
439 /// Deserialize the full spec (common + kind-specific fields) into a typed
440 /// options struct. Common fields are reconstructed into the mapping so
441 /// the target struct can `#[derive(Deserialize)]` against the whole shape
442 /// when convenient.
443 pub fn deserialize_options<T>(&self) -> crate::error::Result<T>
444 where
445 T: serde::de::DeserializeOwned,
446 {
447 Ok(serde_yaml_ng::from_value(serde_yaml_ng::Value::Mapping(
448 self.extra.clone(),
449 ))?)
450 }
451}
452
453/// Rule specification for nested rules (e.g. the `require:` block of
454/// `for_each_dir`). Unlike [`RuleSpec`], `id` and `level` are synthesized
455/// from the parent rule — users just supply the `kind` plus kind-specific
456/// options, optionally with a `message` / `policy_url` / `when`.
457#[derive(Debug, Clone, Deserialize)]
458pub struct NestedRuleSpec {
459 pub kind: String,
460 #[serde(default)]
461 pub paths: Option<PathsSpec>,
462 #[serde(default)]
463 pub message: Option<String>,
464 #[serde(default)]
465 pub policy_url: Option<String>,
466 #[serde(default)]
467 pub when: Option<String>,
468 #[serde(flatten)]
469 pub extra: serde_yaml_ng::Mapping,
470}
471
472impl NestedRuleSpec {
473 /// Synthesize a full [`RuleSpec`] for a single iteration, applying
474 /// path-template substitution (using the iterated entry's tokens) to
475 /// every string field. The resulting spec has `id =
476 /// "{parent_id}.require[{idx}]"` and inherits `level` from the parent.
477 pub fn instantiate(
478 &self,
479 parent_id: &str,
480 idx: usize,
481 level: Level,
482 tokens: &crate::template::PathTokens,
483 ) -> RuleSpec {
484 RuleSpec {
485 id: format!("{parent_id}.require[{idx}]"),
486 kind: self.kind.clone(),
487 level,
488 paths: self
489 .paths
490 .as_ref()
491 .map(|p| crate::template::render_paths_spec(p, tokens)),
492 message: self
493 .message
494 .as_deref()
495 .map(|m| crate::template::render_path(m, tokens)),
496 policy_url: self.policy_url.clone(),
497 when: self.when.clone(),
498 fix: None,
499 // Nested rules don't currently expose
500 // `git_tracked_only` from their parent's spec — the
501 // option is meaningful on top-level rules only for
502 // now. If/when `for_each_dir`'s nested rules need it,
503 // plumb it through here.
504 git_tracked_only: false,
505 extra: crate::template::render_mapping(self.extra.clone(), tokens),
506 }
507 }
508}