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