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 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}