Skip to main content

agentlint/
config.rs

1use std::collections::BTreeMap;
2use std::fs;
3use std::path::Path;
4
5use anyhow::{Context, Result, bail};
6use serde::Deserialize;
7
8use crate::rules;
9use crate::{Diagnostic, Severity};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
12#[serde(rename_all = "lowercase")]
13pub enum SeveritySetting {
14    Off,
15    Info,
16    #[serde(alias = "warn")]
17    Warning,
18    Error,
19}
20
21impl SeveritySetting {
22    pub const fn severity(self) -> Option<Severity> {
23        match self {
24            Self::Off => None,
25            Self::Info => Some(Severity::Info),
26            Self::Warning => Some(Severity::Warning),
27            Self::Error => Some(Severity::Error),
28        }
29    }
30}
31
32#[derive(Debug, Clone, Deserialize)]
33#[serde(default, deny_unknown_fields)]
34pub struct Config {
35    pub rules: BTreeMap<String, SeveritySetting>,
36    pub ignore: Vec<String>,
37}
38
39impl Default for Config {
40    fn default() -> Self {
41        Self {
42            rules: BTreeMap::new(),
43            ignore: vec![
44                ".git/**".into(),
45                "node_modules/**".into(),
46                "vendor/**".into(),
47                "dist/**".into(),
48                "build/**".into(),
49                "target/**".into(),
50                ".next/**".into(),
51                ".cache/**".into(),
52                "venv/**".into(),
53                ".venv/**".into(),
54            ],
55        }
56    }
57}
58
59impl Config {
60    /// Load an explicit config or discover one at the repository root.
61    ///
62    /// # Errors
63    ///
64    /// Returns an error for unreadable files, malformed syntax, unsupported fields,
65    /// or unknown rule identifiers.
66    pub fn load(root: &Path, explicit: Option<&Path>) -> Result<Self> {
67        let path = if let Some(path) = explicit {
68            Some(if path.is_absolute() {
69                path.to_path_buf()
70            } else {
71                root.join(path)
72            })
73        } else {
74            [".agentlint.json", ".agentlint.yaml", ".agentlint.yml"]
75                .iter()
76                .map(|name| root.join(name))
77                .find(|candidate| candidate.is_file())
78        };
79
80        let Some(path) = path else {
81            return Ok(Self::default());
82        };
83        let text = fs::read_to_string(&path)
84            .with_context(|| format!("failed to read configuration {}", path.display()))?;
85        let parsed: Self = match path.extension().and_then(|value| value.to_str()) {
86            Some("json") => serde_json::from_str(&text)
87                .with_context(|| format!("invalid JSON configuration {}", path.display()))?,
88            Some("yaml" | "yml") => serde_yaml::from_str(&text)
89                .with_context(|| format!("invalid YAML configuration {}", path.display()))?,
90            _ => bail!("configuration must use .json, .yaml, or .yml"),
91        };
92        for id in parsed.rules.keys() {
93            if rules::definition(id).is_none() {
94                bail!("configuration references unknown rule `{id}`");
95            }
96        }
97        Ok(parsed.with_default_ignores())
98    }
99
100    fn with_default_ignores(mut self) -> Self {
101        let defaults = Self::default().ignore;
102        for pattern in defaults {
103            if !self.ignore.contains(&pattern) {
104                self.ignore.push(pattern);
105            }
106        }
107        self
108    }
109
110    pub fn severity(&self, rule: &str) -> Option<Severity> {
111        self.rules.get(rule).map_or_else(
112            || Some(rules::default_severity(rule)),
113            |value| value.severity(),
114        )
115    }
116
117    pub fn apply(&self, mut diagnostic: Diagnostic) -> Option<Diagnostic> {
118        diagnostic.severity = self.severity(&diagnostic.rule)?;
119        Some(diagnostic)
120    }
121}
122
123pub fn starter_yaml() -> &'static str {
124    r"# agentlint configuration
125rules:
126  AGL301: warning
127  AGL401: error
128  AGL502: warning
129ignore:
130  - examples/**
131"
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137
138    #[test]
139    fn disabled_rule_has_no_severity() {
140        let mut config = Config::default();
141        config.rules.insert("AGL301".into(), SeveritySetting::Off);
142        assert_eq!(config.severity("AGL301"), None);
143    }
144}