1use std::path::Path;
7
8use anyhow::{Context, Result};
9use serde::{Deserialize, Serialize};
10
11use crate::reporter::ReporterKind;
12use crate::rules::Severity;
13
14#[derive(Debug, Clone, Default, Serialize, Deserialize, schemars::JsonSchema)]
16#[serde(default, deny_unknown_fields)]
17pub struct Config {
18 pub rules: RulesConfig,
20 pub paths: PathsConfig,
22 pub llm: Option<LlmConfig>,
24 pub output: OutputConfig,
26}
27
28#[derive(Debug, Clone, Default, Serialize, Deserialize, schemars::JsonSchema)]
30#[serde(default, deny_unknown_fields)]
31pub struct RulesConfig {
32 pub disabled: Vec<String>,
34 #[serde(default)]
36 pub severity_overrides: std::collections::BTreeMap<String, Severity>,
37 #[serde(default)]
39 #[schemars(with = "std::collections::BTreeMap<String, serde_json::Value>")]
40 pub options: std::collections::BTreeMap<String, serde_yaml::Value>,
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
45#[serde(default, deny_unknown_fields)]
46pub struct PathsConfig {
47 pub exclude: Vec<String>,
49 #[serde(default)]
51 pub include: Vec<String>,
52 #[serde(default)]
54 pub follow_symlinks: bool,
55 #[serde(default = "default_true")]
57 pub respect_gitignore: bool,
58 #[serde(default = "default_prompt_dirs")]
64 pub prompt_dirs: Vec<String>,
65}
66
67impl Default for PathsConfig {
68 fn default() -> Self {
69 Self {
70 exclude: vec![
71 "node_modules".into(),
72 ".git".into(),
73 "target".into(),
74 "dist".into(),
75 ],
76 include: Vec::new(),
77 follow_symlinks: false,
78 respect_gitignore: true,
79 prompt_dirs: default_prompt_dirs(),
80 }
81 }
82}
83
84fn default_prompt_dirs() -> Vec<String> {
85 vec!["prompts/**".into()]
86}
87
88fn default_true() -> bool {
89 true
90}
91
92#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
94#[serde(deny_unknown_fields)]
95pub struct LlmConfig {
96 pub provider: LlmProviderKind,
98 pub model: String,
100 #[serde(default)]
102 pub base_url: Option<String>,
103 #[serde(default)]
105 pub timeout_seconds: Option<u64>,
106 #[serde(default)]
108 pub max_tokens: Option<u32>,
109 #[serde(default)]
111 pub temperature: Option<f32>,
112 #[serde(default)]
114 pub cost_cap_usd: Option<f64>,
115}
116
117#[derive(Debug, Clone, Copy, Serialize, Deserialize, schemars::JsonSchema)]
119#[serde(rename_all = "lowercase")]
120pub enum LlmProviderKind {
121 Openai,
123 Anthropic,
125 Google,
127 Ollama,
129 Compatible,
131}
132
133#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
135#[serde(default, deny_unknown_fields)]
136pub struct OutputConfig {
137 pub format: ReporterKind,
139 pub color: ColorMode,
141 #[serde(default)]
143 pub output_file: Option<std::path::PathBuf>,
144 #[serde(default)]
146 pub quiet: bool,
147 #[serde(default)]
149 pub summary_format: SummaryFormat,
150}
151
152impl Default for OutputConfig {
153 fn default() -> Self {
154 Self {
155 format: ReporterKind::Terminal,
156 color: ColorMode::Auto,
157 output_file: None,
158 quiet: false,
159 summary_format: SummaryFormat::default(),
160 }
161 }
162}
163
164#[derive(
166 Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema,
167)]
168#[serde(rename_all = "lowercase")]
169pub enum SummaryFormat {
170 #[default]
172 Full,
173 Compact,
175 None,
177}
178
179#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, schemars::JsonSchema)]
181#[serde(rename_all = "lowercase")]
182pub enum ColorMode {
183 #[default]
185 Auto,
186 Always,
188 Never,
190}
191
192impl Config {
193 pub fn json_schema() -> Result<String> {
195 let schema = schemars::schema_for!(Config);
196 serde_json::to_string_pretty(&schema).context("failed to serialize config schema")
197 }
198
199 pub fn load(path: &Path) -> Result<Self> {
203 if !path.exists() {
204 return Ok(Self::default());
205 }
206 let raw = std::fs::read_to_string(path)
207 .with_context(|| format!("failed to read {}", path.display()))?;
208 let ext = path
209 .extension()
210 .and_then(|s| s.to_str())
211 .map(str::to_ascii_lowercase);
212 let cfg: Self = match ext.as_deref() {
213 Some("yaml") | Some("yml") => serde_yaml::from_str(&raw)
214 .with_context(|| format!("failed to parse {}", path.display()))?,
215 Some("json") => serde_json::from_str(&raw)
216 .with_context(|| format!("failed to parse {}", path.display()))?,
217 _ => match serde_yaml::from_str(&raw) {
218 Ok(cfg) => cfg,
219 Err(yaml_err) => serde_json::from_str(&raw).with_context(|| {
220 format!(
221 "failed to parse {} as YAML ({yaml_err}) or JSON",
222 path.display()
223 )
224 })?,
225 },
226 };
227 Ok(cfg)
228 }
229
230 pub fn discover(start: &Path) -> Option<std::path::PathBuf> {
232 let abs = start.canonicalize().ok()?;
233 let start_dir: &Path = if abs.is_file() { abs.parent()? } else { &abs };
234 const NAMES: &[&str] = &[".ailint.yaml", ".ailint.yml", ".ailint.json"];
235 for dir in start_dir.ancestors() {
236 for name in NAMES {
237 let candidate = dir.join(name);
238 if candidate.is_file() {
239 return Some(candidate);
240 }
241 }
242 }
243 None
244 }
245}
246
247#[cfg(test)]
248mod tests {
249 use super::*;
250
251 fn tmp_path(name: &str) -> std::path::PathBuf {
252 let mut p = std::env::temp_dir();
253 let pid = std::process::id();
254 p.push(format!("ailint-cfg-{pid}-{name}"));
255 p
256 }
257
258 #[test]
259 fn load_yaml_config_parses_defaults() {
260 let path = tmp_path("defaults.yaml");
261 std::fs::write(&path, "rules:\n disabled: []\n").expect("write");
262 let cfg = Config::load(&path).expect("load");
263 assert!(cfg.rules.disabled.is_empty());
264 assert!(cfg.paths.respect_gitignore);
265 assert!(!cfg.paths.follow_symlinks);
266 assert!(cfg.paths.include.is_empty());
267 assert_eq!(cfg.paths.prompt_dirs, vec!["prompts/**".to_string()]);
268 assert_eq!(cfg.output.summary_format, SummaryFormat::Full);
269 assert!(!cfg.output.quiet);
270 assert!(cfg.output.output_file.is_none());
271 let _ = std::fs::remove_file(&path);
272 }
273
274 #[test]
275 fn load_json_config_parses_defaults() {
276 let path = tmp_path("defaults.json");
277 std::fs::write(&path, r#"{"rules": {"disabled": []}}"#).expect("write");
278 let cfg = Config::load(&path).expect("load");
279 assert!(cfg.rules.disabled.is_empty());
280 assert!(cfg.paths.respect_gitignore);
281 assert_eq!(cfg.output.summary_format, SummaryFormat::Full);
282 let _ = std::fs::remove_file(&path);
283 }
284
285 #[test]
286 fn load_yaml_with_all_fields() {
287 let path = tmp_path("full.yaml");
288 let body = r#"
289rules:
290 disabled: [AIL100]
291 severity_overrides:
292 AIL101: warning
293 options:
294 AIL100:
295 phrases: ["maybe"]
296paths:
297 exclude: [node_modules]
298 include: ["docs/**/*.md"]
299 follow_symlinks: true
300 respect_gitignore: false
301 prompt_dirs: ["prompts/**", "assistants/*/prompts/**"]
302llm:
303 provider: openai
304 model: gpt-4o
305 base_url: https://example.com/v1
306 timeout_seconds: 30
307 max_tokens: 1024
308 temperature: 0.2
309 cost_cap_usd: 5.0
310output:
311 format: json
312 color: never
313 output_file: /tmp/out.json
314 quiet: true
315 summary_format: compact
316"#;
317 std::fs::write(&path, body).expect("write");
318 let cfg = Config::load(&path).expect("load");
319 assert_eq!(cfg.rules.disabled, vec!["AIL100".to_string()]);
320 assert_eq!(cfg.paths.include, vec!["docs/**/*.md".to_string()]);
321 assert!(cfg.paths.follow_symlinks);
322 assert!(!cfg.paths.respect_gitignore);
323 assert_eq!(
324 cfg.paths.prompt_dirs,
325 vec![
326 "prompts/**".to_string(),
327 "assistants/*/prompts/**".to_string()
328 ]
329 );
330 let llm = cfg.llm.expect("llm block");
331 assert_eq!(llm.base_url.as_deref(), Some("https://example.com/v1"));
332 assert_eq!(llm.timeout_seconds, Some(30));
333 assert_eq!(llm.max_tokens, Some(1024));
334 assert_eq!(llm.temperature, Some(0.2));
335 assert_eq!(llm.cost_cap_usd, Some(5.0));
336 assert_eq!(cfg.output.summary_format, SummaryFormat::Compact);
337 assert!(cfg.output.quiet);
338 assert_eq!(
339 cfg.output.output_file.as_deref(),
340 Some(std::path::Path::new("/tmp/out.json"))
341 );
342 let _ = std::fs::remove_file(&path);
343 }
344
345 #[test]
346 fn unknown_field_rejected() {
347 let path = tmp_path("unknown.yaml");
348 std::fs::write(&path, "not_a_real_field: true\n").expect("write");
349 let err = Config::load(&path).expect_err("should reject unknown field");
350 let msg = format!("{err:#}");
351 assert!(
352 msg.contains("not_a_real_field") || msg.contains("unknown field"),
353 "error was: {msg}"
354 );
355 let _ = std::fs::remove_file(&path);
356 }
357
358 #[test]
359 fn json_schema_exports() {
360 let schema = Config::json_schema().expect("schema");
361 assert!(schema.contains("\"prompt_dirs\""));
362 assert!(schema.contains("\"severity_overrides\""));
363 assert!(schema.contains("\"cost_cap_usd\""));
364 }
365
366 #[test]
367 fn template_yaml_parses_cleanly() {
368 let raw = include_str!("../../../.ailint.yaml.template");
369 let cfg: Config =
370 serde_yaml::from_str(raw).unwrap_or_else(|e| panic!("template failed to parse: {e}"));
371 assert!(cfg.paths.respect_gitignore);
372 }
373}