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 a local path (relative to the containing file
19    /// or absolute). Remote `https://` URLs are reserved but not yet
20    /// supported; the loader rejects them with a clear error.
21    #[serde(default)]
22    pub extends: Vec<String>,
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/// YAML shape for a rule's `paths:` field — a single glob, an array (with
81/// optional `!pattern` negations), or an explicit `{include, exclude}` pair.
82/// For the include/exclude form, each field accepts either a single string
83/// or a list of strings.
84#[derive(Debug, Clone, Deserialize)]
85#[serde(untagged)]
86pub enum PathsSpec {
87    Single(String),
88    Many(Vec<String>),
89    IncludeExclude {
90        #[serde(default, deserialize_with = "string_or_vec")]
91        include: Vec<String>,
92        #[serde(default, deserialize_with = "string_or_vec")]
93        exclude: Vec<String>,
94    },
95}
96
97fn string_or_vec<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
98where
99    D: serde::Deserializer<'de>,
100{
101    #[derive(Deserialize)]
102    #[serde(untagged)]
103    enum OneOrMany {
104        One(String),
105        Many(Vec<String>),
106    }
107    match OneOrMany::deserialize(deserializer)? {
108        OneOrMany::One(s) => Ok(vec![s]),
109        OneOrMany::Many(v) => Ok(v),
110    }
111}
112
113/// YAML-level description of a rule before it is instantiated into a `Box<dyn Rule>`
114/// by a [`RuleBuilder`](crate::registry::RuleBuilder).
115#[derive(Debug, Clone, Deserialize)]
116pub struct RuleSpec {
117    pub id: String,
118    pub kind: String,
119    pub level: Level,
120    #[serde(default)]
121    pub paths: Option<PathsSpec>,
122    #[serde(default)]
123    pub message: Option<String>,
124    #[serde(default)]
125    pub policy_url: Option<String>,
126    #[serde(default)]
127    pub when: Option<String>,
128    /// Optional mechanical-fix strategy. Rules whose builders understand
129    /// the chosen op attach a [`Fixer`](crate::Fixer) to the built rule;
130    /// rules whose kind is incompatible with the op return a config error
131    /// at build time.
132    #[serde(default)]
133    pub fix: Option<FixSpec>,
134    /// The entire YAML mapping, retained so each rule builder can deserialize
135    /// its kind-specific fields without every option being represented here.
136    #[serde(flatten)]
137    pub extra: serde_yaml_ng::Mapping,
138}
139
140/// The `fix:` block on a rule. Exactly one op key must be present —
141/// alint errors at load time when the op and rule kind are incompatible.
142#[derive(Debug, Clone, Deserialize)]
143#[serde(untagged)]
144pub enum FixSpec {
145    FileCreate {
146        file_create: FileCreateFixSpec,
147    },
148    FileRemove {
149        file_remove: FileRemoveFixSpec,
150    },
151    FilePrepend {
152        file_prepend: FilePrependFixSpec,
153    },
154    FileAppend {
155        file_append: FileAppendFixSpec,
156    },
157    FileRename {
158        file_rename: FileRenameFixSpec,
159    },
160    FileTrimTrailingWhitespace {
161        file_trim_trailing_whitespace: FileTrimTrailingWhitespaceFixSpec,
162    },
163    FileAppendFinalNewline {
164        file_append_final_newline: FileAppendFinalNewlineFixSpec,
165    },
166    FileNormalizeLineEndings {
167        file_normalize_line_endings: FileNormalizeLineEndingsFixSpec,
168    },
169    FileStripBidi {
170        file_strip_bidi: FileStripBidiFixSpec,
171    },
172    FileStripZeroWidth {
173        file_strip_zero_width: FileStripZeroWidthFixSpec,
174    },
175    FileStripBom {
176        file_strip_bom: FileStripBomFixSpec,
177    },
178    FileCollapseBlankLines {
179        file_collapse_blank_lines: FileCollapseBlankLinesFixSpec,
180    },
181}
182
183impl FixSpec {
184    /// The op name as it appears in YAML — used in config-error messages.
185    pub fn op_name(&self) -> &'static str {
186        match self {
187            Self::FileCreate { .. } => "file_create",
188            Self::FileRemove { .. } => "file_remove",
189            Self::FilePrepend { .. } => "file_prepend",
190            Self::FileAppend { .. } => "file_append",
191            Self::FileRename { .. } => "file_rename",
192            Self::FileTrimTrailingWhitespace { .. } => "file_trim_trailing_whitespace",
193            Self::FileAppendFinalNewline { .. } => "file_append_final_newline",
194            Self::FileNormalizeLineEndings { .. } => "file_normalize_line_endings",
195            Self::FileStripBidi { .. } => "file_strip_bidi",
196            Self::FileStripZeroWidth { .. } => "file_strip_zero_width",
197            Self::FileStripBom { .. } => "file_strip_bom",
198            Self::FileCollapseBlankLines { .. } => "file_collapse_blank_lines",
199        }
200    }
201}
202
203#[derive(Debug, Clone, Deserialize)]
204#[serde(deny_unknown_fields)]
205pub struct FileCreateFixSpec {
206    /// Content to write. Required — there is no implicit empty default;
207    /// for an empty file, pass `content: ""` explicitly.
208    pub content: String,
209    /// Path to create, relative to the repo root. When omitted, the
210    /// rule builder substitutes the first literal entry from the rule's
211    /// `paths:` list.
212    #[serde(default)]
213    pub path: Option<PathBuf>,
214    /// Whether to create intermediate directories. Defaults to true.
215    #[serde(default = "default_create_parents")]
216    pub create_parents: bool,
217}
218
219fn default_create_parents() -> bool {
220    true
221}
222
223#[derive(Debug, Clone, Deserialize, Default)]
224#[serde(deny_unknown_fields)]
225pub struct FileRemoveFixSpec {}
226
227#[derive(Debug, Clone, Deserialize)]
228#[serde(deny_unknown_fields)]
229pub struct FilePrependFixSpec {
230    /// Bytes to insert at the beginning of each violating file. A
231    /// trailing newline in `content` is the caller's responsibility.
232    pub content: String,
233}
234
235#[derive(Debug, Clone, Deserialize)]
236#[serde(deny_unknown_fields)]
237pub struct FileAppendFixSpec {
238    /// Bytes to append to each violating file. A leading newline in
239    /// `content` is the caller's responsibility.
240    pub content: String,
241}
242
243/// Empty marker: `file_rename` takes no parameters. The target name
244/// is derived from the parent rule (e.g. `filename_case` converts the
245/// stem to its configured case; the extension is preserved).
246#[derive(Debug, Clone, Deserialize, Default)]
247#[serde(deny_unknown_fields)]
248pub struct FileRenameFixSpec {}
249
250/// Empty marker. Behavior: read file (subject to `fix_size_limit`),
251/// strip trailing space/tab on every line, write back.
252#[derive(Debug, Clone, Deserialize, Default)]
253#[serde(deny_unknown_fields)]
254pub struct FileTrimTrailingWhitespaceFixSpec {}
255
256/// Empty marker. Behavior: if the file has content and does not
257/// end with `\n`, append one.
258#[derive(Debug, Clone, Deserialize, Default)]
259#[serde(deny_unknown_fields)]
260pub struct FileAppendFinalNewlineFixSpec {}
261
262/// Empty marker. Behavior: rewrite the file with every line ending
263/// replaced by the parent rule's configured target (`lf` or `crlf`).
264#[derive(Debug, Clone, Deserialize, Default)]
265#[serde(deny_unknown_fields)]
266pub struct FileNormalizeLineEndingsFixSpec {}
267
268/// Empty marker. Behavior: remove every Unicode bidi control
269/// character (U+202A–202E, U+2066–2069) from the file's content.
270#[derive(Debug, Clone, Deserialize, Default)]
271#[serde(deny_unknown_fields)]
272pub struct FileStripBidiFixSpec {}
273
274/// Empty marker. Behavior: remove every zero-width character
275/// (U+200B / U+200C / U+200D / U+FEFF) from the file's content,
276/// *except* a leading BOM (U+FEFF at position 0) — that's the
277/// responsibility of the `no_bom` rule.
278#[derive(Debug, Clone, Deserialize, Default)]
279#[serde(deny_unknown_fields)]
280pub struct FileStripZeroWidthFixSpec {}
281
282/// Empty marker. Behavior: remove a leading UTF-8/UTF-16/UTF-32
283/// BOM byte sequence if present; otherwise a no-op.
284#[derive(Debug, Clone, Deserialize, Default)]
285#[serde(deny_unknown_fields)]
286pub struct FileStripBomFixSpec {}
287
288/// Empty marker. Behavior: collapse runs of blank lines longer than
289/// the parent rule's `max` down to exactly `max` blank lines.
290#[derive(Debug, Clone, Deserialize, Default)]
291#[serde(deny_unknown_fields)]
292pub struct FileCollapseBlankLinesFixSpec {}
293
294impl RuleSpec {
295    /// Deserialize the full spec (common + kind-specific fields) into a typed
296    /// options struct. Common fields are reconstructed into the mapping so
297    /// the target struct can `#[derive(Deserialize)]` against the whole shape
298    /// when convenient.
299    pub fn deserialize_options<T>(&self) -> crate::error::Result<T>
300    where
301        T: serde::de::DeserializeOwned,
302    {
303        Ok(serde_yaml_ng::from_value(serde_yaml_ng::Value::Mapping(
304            self.extra.clone(),
305        ))?)
306    }
307}
308
309/// Rule specification for nested rules (e.g. the `require:` block of
310/// `for_each_dir`). Unlike [`RuleSpec`], `id` and `level` are synthesized
311/// from the parent rule — users just supply the `kind` plus kind-specific
312/// options, optionally with a `message` / `policy_url` / `when`.
313#[derive(Debug, Clone, Deserialize)]
314pub struct NestedRuleSpec {
315    pub kind: String,
316    #[serde(default)]
317    pub paths: Option<PathsSpec>,
318    #[serde(default)]
319    pub message: Option<String>,
320    #[serde(default)]
321    pub policy_url: Option<String>,
322    #[serde(default)]
323    pub when: Option<String>,
324    #[serde(flatten)]
325    pub extra: serde_yaml_ng::Mapping,
326}
327
328impl NestedRuleSpec {
329    /// Synthesize a full [`RuleSpec`] for a single iteration, applying
330    /// path-template substitution (using the iterated entry's tokens) to
331    /// every string field. The resulting spec has `id =
332    /// "{parent_id}.require[{idx}]"` and inherits `level` from the parent.
333    pub fn instantiate(
334        &self,
335        parent_id: &str,
336        idx: usize,
337        level: Level,
338        tokens: &crate::template::PathTokens,
339    ) -> RuleSpec {
340        RuleSpec {
341            id: format!("{parent_id}.require[{idx}]"),
342            kind: self.kind.clone(),
343            level,
344            paths: self
345                .paths
346                .as_ref()
347                .map(|p| crate::template::render_paths_spec(p, tokens)),
348            message: self
349                .message
350                .as_deref()
351                .map(|m| crate::template::render_path(m, tokens)),
352            policy_url: self.policy_url.clone(),
353            when: self.when.clone(),
354            fix: None,
355            extra: crate::template::render_mapping(self.extra.clone(), tokens),
356        }
357    }
358}