Skip to main content

ailint_core/
config.rs

1//! Configuration loading for ailint (`.ailint.yaml`).
2//!
3//! The schema is exported as JSON Schema via [`Config::json_schema`]
4//! (`ailint schema` on the CLI). `AILINT_CONFIG` overrides discovery.
5
6use std::path::Path;
7
8use anyhow::{Context, Result};
9use serde::{Deserialize, Serialize};
10
11use crate::reporter::ReporterKind;
12use crate::rules::Severity;
13
14/// Root configuration object, mirrored from `.ailint.yaml`.
15#[derive(Debug, Clone, Default, Serialize, Deserialize, schemars::JsonSchema)]
16#[serde(default, deny_unknown_fields)]
17pub struct Config {
18    /// Rule enablement, severity, and options.
19    pub rules: RulesConfig,
20    /// Discovery include/exclude behavior.
21    pub paths: PathsConfig,
22    /// Provider settings for opt-in LLM rules.
23    pub llm: Option<LlmConfig>,
24    /// Reporter format and output destination.
25    pub output: OutputConfig,
26}
27
28/// The `rules:` block of `.ailint.yaml`.
29#[derive(Debug, Clone, Default, Serialize, Deserialize, schemars::JsonSchema)]
30#[serde(default, deny_unknown_fields)]
31pub struct RulesConfig {
32    /// Rule IDs or slugs to disable (e.g. `AIL100` or `no-vague-instruction`).
33    pub disabled: Vec<String>,
34    /// Overrides for rule severity, keyed by ID or slug.
35    #[serde(default)]
36    pub severity_overrides: std::collections::BTreeMap<String, Severity>,
37    /// Per-rule options, keyed by ID or slug.
38    #[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/// The `paths:` block of `.ailint.yaml`.
44#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
45#[serde(default, deny_unknown_fields)]
46pub struct PathsConfig {
47    /// Additional path globs to exclude from discovery.
48    pub exclude: Vec<String>,
49    /// If non-empty, only paths matching one of these globs are linted.
50    #[serde(default)]
51    pub include: Vec<String>,
52    /// Follow symbolic links during discovery.
53    #[serde(default)]
54    pub follow_symlinks: bool,
55    /// Honor `.gitignore` and other VCS ignore files during discovery.
56    #[serde(default = "default_true")]
57    pub respect_gitignore: bool,
58    /// Gitignore-style globs (relative to the repo root) marking prompt
59    /// directories. Files under a matching directory that would otherwise be
60    /// classified as generic Markdown/YAML docs are treated as
61    /// `FileType::GenericSystemPrompt` instead, since no single filename
62    /// convention for system prompts exists across agent frameworks.
63    #[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/// The `llm:` block of `.ailint.yaml`; enables the opt-in AIL9xx rules.
93#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
94#[serde(deny_unknown_fields)]
95pub struct LlmConfig {
96    /// Which provider client to use.
97    pub provider: LlmProviderKind,
98    /// Model identifier passed to the provider.
99    pub model: String,
100    /// Override endpoint URL for OpenAI-compatible providers.
101    #[serde(default)]
102    pub base_url: Option<String>,
103    /// Per-request timeout in seconds.
104    #[serde(default)]
105    pub timeout_seconds: Option<u64>,
106    /// Cap on tokens the provider may generate per request.
107    #[serde(default)]
108    pub max_tokens: Option<u32>,
109    /// Sampling temperature forwarded to the provider.
110    #[serde(default)]
111    pub temperature: Option<f32>,
112    /// Stop running LLM rules once cumulative spend exceeds this many USD.
113    #[serde(default)]
114    pub cost_cap_usd: Option<f64>,
115}
116
117/// Supported LLM provider clients.
118#[derive(Debug, Clone, Copy, Serialize, Deserialize, schemars::JsonSchema)]
119#[serde(rename_all = "lowercase")]
120pub enum LlmProviderKind {
121    /// OpenAI's hosted API.
122    Openai,
123    /// Anthropic's hosted API.
124    Anthropic,
125    /// Google's Gemini API.
126    Google,
127    /// A local Ollama server.
128    Ollama,
129    /// Any OpenAI-compatible endpoint.
130    Compatible,
131}
132
133/// The `output:` block of `.ailint.yaml`.
134#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
135#[serde(default, deny_unknown_fields)]
136pub struct OutputConfig {
137    /// Reporter used for violations.
138    pub format: ReporterKind,
139    /// When to colorize terminal output.
140    pub color: ColorMode,
141    /// Write reporter output to this file instead of stdout.
142    #[serde(default)]
143    pub output_file: Option<std::path::PathBuf>,
144    /// Suppress non-violation output (banners, progress, summary lines).
145    #[serde(default)]
146    pub quiet: bool,
147    /// Verbosity of the trailing summary block.
148    #[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/// Verbosity of the summary block after a `check` run.
165#[derive(
166    Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema,
167)]
168#[serde(rename_all = "lowercase")]
169pub enum SummaryFormat {
170    /// Per-severity counts plus file totals.
171    #[default]
172    Full,
173    /// A single totals line.
174    Compact,
175    /// No summary block.
176    None,
177}
178
179/// When to colorize terminal output.
180#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, schemars::JsonSchema)]
181#[serde(rename_all = "lowercase")]
182pub enum ColorMode {
183    /// Colorize only when stdout is a TTY.
184    #[default]
185    Auto,
186    /// Always emit color codes.
187    Always,
188    /// Never emit color codes.
189    Never,
190}
191
192impl Config {
193    /// JSON Schema for the config file, as pretty-printed JSON.
194    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    /// Load a config file from disk. Returns `Config::default()` if the file
200    /// does not exist. Dispatches on file extension: `.yaml`/`.yml` parse as
201    /// YAML, `.json` as JSON, anything else tries YAML then falls back to JSON.
202    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    /// Locate the closest config file, walking up from `start`.
231    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}