Skip to main content

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    /// The entire YAML mapping, retained so each rule builder can deserialize
197    /// its kind-specific fields without every option being represented here.
198    #[serde(flatten)]
199    pub extra: serde_yaml_ng::Mapping,
200}
201
202/// The `fix:` block on a rule. Exactly one op key must be present —
203/// alint errors at load time when the op and rule kind are incompatible.
204#[derive(Debug, Clone, Deserialize)]
205#[serde(untagged)]
206pub enum FixSpec {
207    FileCreate {
208        file_create: FileCreateFixSpec,
209    },
210    FileRemove {
211        file_remove: FileRemoveFixSpec,
212    },
213    FilePrepend {
214        file_prepend: FilePrependFixSpec,
215    },
216    FileAppend {
217        file_append: FileAppendFixSpec,
218    },
219    FileRename {
220        file_rename: FileRenameFixSpec,
221    },
222    FileTrimTrailingWhitespace {
223        file_trim_trailing_whitespace: FileTrimTrailingWhitespaceFixSpec,
224    },
225    FileAppendFinalNewline {
226        file_append_final_newline: FileAppendFinalNewlineFixSpec,
227    },
228    FileNormalizeLineEndings {
229        file_normalize_line_endings: FileNormalizeLineEndingsFixSpec,
230    },
231    FileStripBidi {
232        file_strip_bidi: FileStripBidiFixSpec,
233    },
234    FileStripZeroWidth {
235        file_strip_zero_width: FileStripZeroWidthFixSpec,
236    },
237    FileStripBom {
238        file_strip_bom: FileStripBomFixSpec,
239    },
240    FileCollapseBlankLines {
241        file_collapse_blank_lines: FileCollapseBlankLinesFixSpec,
242    },
243}
244
245impl FixSpec {
246    /// The op name as it appears in YAML — used in config-error messages.
247    pub fn op_name(&self) -> &'static str {
248        match self {
249            Self::FileCreate { .. } => "file_create",
250            Self::FileRemove { .. } => "file_remove",
251            Self::FilePrepend { .. } => "file_prepend",
252            Self::FileAppend { .. } => "file_append",
253            Self::FileRename { .. } => "file_rename",
254            Self::FileTrimTrailingWhitespace { .. } => "file_trim_trailing_whitespace",
255            Self::FileAppendFinalNewline { .. } => "file_append_final_newline",
256            Self::FileNormalizeLineEndings { .. } => "file_normalize_line_endings",
257            Self::FileStripBidi { .. } => "file_strip_bidi",
258            Self::FileStripZeroWidth { .. } => "file_strip_zero_width",
259            Self::FileStripBom { .. } => "file_strip_bom",
260            Self::FileCollapseBlankLines { .. } => "file_collapse_blank_lines",
261        }
262    }
263}
264
265#[derive(Debug, Clone, Deserialize)]
266#[serde(deny_unknown_fields)]
267pub struct FileCreateFixSpec {
268    /// Content to write. Required — there is no implicit empty default;
269    /// for an empty file, pass `content: ""` explicitly.
270    pub content: String,
271    /// Path to create, relative to the repo root. When omitted, the
272    /// rule builder substitutes the first literal entry from the rule's
273    /// `paths:` list.
274    #[serde(default)]
275    pub path: Option<PathBuf>,
276    /// Whether to create intermediate directories. Defaults to true.
277    #[serde(default = "default_create_parents")]
278    pub create_parents: bool,
279}
280
281fn default_create_parents() -> bool {
282    true
283}
284
285#[derive(Debug, Clone, Deserialize, Default)]
286#[serde(deny_unknown_fields)]
287pub struct FileRemoveFixSpec {}
288
289#[derive(Debug, Clone, Deserialize)]
290#[serde(deny_unknown_fields)]
291pub struct FilePrependFixSpec {
292    /// Bytes to insert at the beginning of each violating file. A
293    /// trailing newline in `content` is the caller's responsibility.
294    pub content: String,
295}
296
297#[derive(Debug, Clone, Deserialize)]
298#[serde(deny_unknown_fields)]
299pub struct FileAppendFixSpec {
300    /// Bytes to append to each violating file. A leading newline in
301    /// `content` is the caller's responsibility.
302    pub content: String,
303}
304
305/// Empty marker: `file_rename` takes no parameters. The target name
306/// is derived from the parent rule (e.g. `filename_case` converts the
307/// stem to its configured case; the extension is preserved).
308#[derive(Debug, Clone, Deserialize, Default)]
309#[serde(deny_unknown_fields)]
310pub struct FileRenameFixSpec {}
311
312/// Empty marker. Behavior: read file (subject to `fix_size_limit`),
313/// strip trailing space/tab on every line, write back.
314#[derive(Debug, Clone, Deserialize, Default)]
315#[serde(deny_unknown_fields)]
316pub struct FileTrimTrailingWhitespaceFixSpec {}
317
318/// Empty marker. Behavior: if the file has content and does not
319/// end with `\n`, append one.
320#[derive(Debug, Clone, Deserialize, Default)]
321#[serde(deny_unknown_fields)]
322pub struct FileAppendFinalNewlineFixSpec {}
323
324/// Empty marker. Behavior: rewrite the file with every line ending
325/// replaced by the parent rule's configured target (`lf` or `crlf`).
326#[derive(Debug, Clone, Deserialize, Default)]
327#[serde(deny_unknown_fields)]
328pub struct FileNormalizeLineEndingsFixSpec {}
329
330/// Empty marker. Behavior: remove every Unicode bidi control
331/// character (U+202A–202E, U+2066–2069) from the file's content.
332#[derive(Debug, Clone, Deserialize, Default)]
333#[serde(deny_unknown_fields)]
334pub struct FileStripBidiFixSpec {}
335
336/// Empty marker. Behavior: remove every zero-width character
337/// (U+200B / U+200C / U+200D / U+FEFF) from the file's content,
338/// *except* a leading BOM (U+FEFF at position 0) — that's the
339/// responsibility of the `no_bom` rule.
340#[derive(Debug, Clone, Deserialize, Default)]
341#[serde(deny_unknown_fields)]
342pub struct FileStripZeroWidthFixSpec {}
343
344/// Empty marker. Behavior: remove a leading UTF-8/UTF-16/UTF-32
345/// BOM byte sequence if present; otherwise a no-op.
346#[derive(Debug, Clone, Deserialize, Default)]
347#[serde(deny_unknown_fields)]
348pub struct FileStripBomFixSpec {}
349
350/// Empty marker. Behavior: collapse runs of blank lines longer than
351/// the parent rule's `max` down to exactly `max` blank lines.
352#[derive(Debug, Clone, Deserialize, Default)]
353#[serde(deny_unknown_fields)]
354pub struct FileCollapseBlankLinesFixSpec {}
355
356impl RuleSpec {
357    /// Deserialize the full spec (common + kind-specific fields) into a typed
358    /// options struct. Common fields are reconstructed into the mapping so
359    /// the target struct can `#[derive(Deserialize)]` against the whole shape
360    /// when convenient.
361    pub fn deserialize_options<T>(&self) -> crate::error::Result<T>
362    where
363        T: serde::de::DeserializeOwned,
364    {
365        Ok(serde_yaml_ng::from_value(serde_yaml_ng::Value::Mapping(
366            self.extra.clone(),
367        ))?)
368    }
369}
370
371/// Rule specification for nested rules (e.g. the `require:` block of
372/// `for_each_dir`). Unlike [`RuleSpec`], `id` and `level` are synthesized
373/// from the parent rule — users just supply the `kind` plus kind-specific
374/// options, optionally with a `message` / `policy_url` / `when`.
375#[derive(Debug, Clone, Deserialize)]
376pub struct NestedRuleSpec {
377    pub kind: String,
378    #[serde(default)]
379    pub paths: Option<PathsSpec>,
380    #[serde(default)]
381    pub message: Option<String>,
382    #[serde(default)]
383    pub policy_url: Option<String>,
384    #[serde(default)]
385    pub when: Option<String>,
386    #[serde(flatten)]
387    pub extra: serde_yaml_ng::Mapping,
388}
389
390impl NestedRuleSpec {
391    /// Synthesize a full [`RuleSpec`] for a single iteration, applying
392    /// path-template substitution (using the iterated entry's tokens) to
393    /// every string field. The resulting spec has `id =
394    /// "{parent_id}.require[{idx}]"` and inherits `level` from the parent.
395    pub fn instantiate(
396        &self,
397        parent_id: &str,
398        idx: usize,
399        level: Level,
400        tokens: &crate::template::PathTokens,
401    ) -> RuleSpec {
402        RuleSpec {
403            id: format!("{parent_id}.require[{idx}]"),
404            kind: self.kind.clone(),
405            level,
406            paths: self
407                .paths
408                .as_ref()
409                .map(|p| crate::template::render_paths_spec(p, tokens)),
410            message: self
411                .message
412                .as_deref()
413                .map(|m| crate::template::render_path(m, tokens)),
414            policy_url: self.policy_url.clone(),
415            when: self.when.clone(),
416            fix: None,
417            extra: crate::template::render_mapping(self.extra.clone(), tokens),
418        }
419    }
420}