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