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)]
11#[serde(deny_unknown_fields)]
12pub struct Config {
13    pub version: u32,
14    #[serde(default)]
15    pub ignore: Vec<String>,
16    #[serde(default = "default_respect_gitignore")]
17    pub respect_gitignore: bool,
18    /// Free-form string variables referenced from rule messages and
19    /// `when` expressions as `{{vars.<name>}}` and `vars.<name>`.
20    #[serde(default)]
21    pub vars: HashMap<String, String>,
22    /// Repository properties evaluated once per run and referenced from
23    /// `when` clauses as `facts.<id>`.
24    #[serde(default)]
25    pub facts: Vec<FactSpec>,
26    #[serde(default)]
27    pub rules: Vec<RuleSpec>,
28}
29
30fn default_respect_gitignore() -> bool {
31    true
32}
33
34impl Config {
35    pub const CURRENT_VERSION: u32 = 1;
36}
37
38/// YAML shape for a rule's `paths:` field — a single glob, an array (with
39/// optional `!pattern` negations), or an explicit `{include, exclude}` pair.
40/// For the include/exclude form, each field accepts either a single string
41/// or a list of strings.
42#[derive(Debug, Clone, Deserialize)]
43#[serde(untagged)]
44pub enum PathsSpec {
45    Single(String),
46    Many(Vec<String>),
47    IncludeExclude {
48        #[serde(default, deserialize_with = "string_or_vec")]
49        include: Vec<String>,
50        #[serde(default, deserialize_with = "string_or_vec")]
51        exclude: Vec<String>,
52    },
53}
54
55fn string_or_vec<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
56where
57    D: serde::Deserializer<'de>,
58{
59    #[derive(Deserialize)]
60    #[serde(untagged)]
61    enum OneOrMany {
62        One(String),
63        Many(Vec<String>),
64    }
65    match OneOrMany::deserialize(deserializer)? {
66        OneOrMany::One(s) => Ok(vec![s]),
67        OneOrMany::Many(v) => Ok(v),
68    }
69}
70
71/// YAML-level description of a rule before it is instantiated into a `Box<dyn Rule>`
72/// by a [`RuleBuilder`](crate::registry::RuleBuilder).
73#[derive(Debug, Clone, Deserialize)]
74pub struct RuleSpec {
75    pub id: String,
76    pub kind: String,
77    pub level: Level,
78    #[serde(default)]
79    pub paths: Option<PathsSpec>,
80    #[serde(default)]
81    pub message: Option<String>,
82    #[serde(default)]
83    pub policy_url: Option<String>,
84    #[serde(default)]
85    pub when: Option<String>,
86    /// Optional mechanical-fix strategy. Rules whose builders understand
87    /// the chosen op attach a [`Fixer`](crate::Fixer) to the built rule;
88    /// rules whose kind is incompatible with the op return a config error
89    /// at build time.
90    #[serde(default)]
91    pub fix: Option<FixSpec>,
92    /// The entire YAML mapping, retained so each rule builder can deserialize
93    /// its kind-specific fields without every option being represented here.
94    #[serde(flatten)]
95    pub extra: serde_yaml_ng::Mapping,
96}
97
98/// The `fix:` block on a rule. Exactly one op key must be present —
99/// alint errors at load time when the op and rule kind are incompatible.
100#[derive(Debug, Clone, Deserialize)]
101#[serde(untagged)]
102pub enum FixSpec {
103    FileCreate { file_create: FileCreateFixSpec },
104    FileRemove { file_remove: FileRemoveFixSpec },
105    FilePrepend { file_prepend: FilePrependFixSpec },
106    FileAppend { file_append: FileAppendFixSpec },
107    FileRename { file_rename: FileRenameFixSpec },
108}
109
110impl FixSpec {
111    /// The op name as it appears in YAML — used in config-error messages.
112    pub fn op_name(&self) -> &'static str {
113        match self {
114            Self::FileCreate { .. } => "file_create",
115            Self::FileRemove { .. } => "file_remove",
116            Self::FilePrepend { .. } => "file_prepend",
117            Self::FileAppend { .. } => "file_append",
118            Self::FileRename { .. } => "file_rename",
119        }
120    }
121}
122
123#[derive(Debug, Clone, Deserialize)]
124#[serde(deny_unknown_fields)]
125pub struct FileCreateFixSpec {
126    /// Content to write. Required — there is no implicit empty default;
127    /// for an empty file, pass `content: ""` explicitly.
128    pub content: String,
129    /// Path to create, relative to the repo root. When omitted, the
130    /// rule builder substitutes the first literal entry from the rule's
131    /// `paths:` list.
132    #[serde(default)]
133    pub path: Option<PathBuf>,
134    /// Whether to create intermediate directories. Defaults to true.
135    #[serde(default = "default_create_parents")]
136    pub create_parents: bool,
137}
138
139fn default_create_parents() -> bool {
140    true
141}
142
143#[derive(Debug, Clone, Deserialize, Default)]
144#[serde(deny_unknown_fields)]
145pub struct FileRemoveFixSpec {}
146
147#[derive(Debug, Clone, Deserialize)]
148#[serde(deny_unknown_fields)]
149pub struct FilePrependFixSpec {
150    /// Bytes to insert at the beginning of each violating file. A
151    /// trailing newline in `content` is the caller's responsibility.
152    pub content: String,
153}
154
155#[derive(Debug, Clone, Deserialize)]
156#[serde(deny_unknown_fields)]
157pub struct FileAppendFixSpec {
158    /// Bytes to append to each violating file. A leading newline in
159    /// `content` is the caller's responsibility.
160    pub content: String,
161}
162
163/// Empty marker: `file_rename` takes no parameters. The target name
164/// is derived from the parent rule (e.g. `filename_case` converts the
165/// stem to its configured case; the extension is preserved).
166#[derive(Debug, Clone, Deserialize, Default)]
167#[serde(deny_unknown_fields)]
168pub struct FileRenameFixSpec {}
169
170impl RuleSpec {
171    /// Deserialize the full spec (common + kind-specific fields) into a typed
172    /// options struct. Common fields are reconstructed into the mapping so
173    /// the target struct can `#[derive(Deserialize)]` against the whole shape
174    /// when convenient.
175    pub fn deserialize_options<T>(&self) -> crate::error::Result<T>
176    where
177        T: serde::de::DeserializeOwned,
178    {
179        Ok(serde_yaml_ng::from_value(serde_yaml_ng::Value::Mapping(
180            self.extra.clone(),
181        ))?)
182    }
183}
184
185/// Rule specification for nested rules (e.g. the `require:` block of
186/// `for_each_dir`). Unlike [`RuleSpec`], `id` and `level` are synthesized
187/// from the parent rule — users just supply the `kind` plus kind-specific
188/// options, optionally with a `message` / `policy_url` / `when`.
189#[derive(Debug, Clone, Deserialize)]
190pub struct NestedRuleSpec {
191    pub kind: String,
192    #[serde(default)]
193    pub paths: Option<PathsSpec>,
194    #[serde(default)]
195    pub message: Option<String>,
196    #[serde(default)]
197    pub policy_url: Option<String>,
198    #[serde(default)]
199    pub when: Option<String>,
200    #[serde(flatten)]
201    pub extra: serde_yaml_ng::Mapping,
202}
203
204impl NestedRuleSpec {
205    /// Synthesize a full [`RuleSpec`] for a single iteration, applying
206    /// path-template substitution (using the iterated entry's tokens) to
207    /// every string field. The resulting spec has `id =
208    /// "{parent_id}.require[{idx}]"` and inherits `level` from the parent.
209    pub fn instantiate(
210        &self,
211        parent_id: &str,
212        idx: usize,
213        level: Level,
214        tokens: &crate::template::PathTokens,
215    ) -> RuleSpec {
216        RuleSpec {
217            id: format!("{parent_id}.require[{idx}]"),
218            kind: self.kind.clone(),
219            level,
220            paths: self
221                .paths
222                .as_ref()
223                .map(|p| crate::template::render_paths_spec(p, tokens)),
224            message: self
225                .message
226                .as_deref()
227                .map(|m| crate::template::render_path(m, tokens)),
228            policy_url: self.policy_url.clone(),
229            when: self.when.clone(),
230            fix: None,
231            extra: crate::template::render_mapping(self.extra.clone(), tokens),
232        }
233    }
234}