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    /// Source-code comment extraction settings.
23    pub sources: SourcesConfig,
24    /// Provider settings for opt-in LLM rules.
25    pub llm: Option<LlmConfig>,
26    /// Reporter format and output destination.
27    pub output: OutputConfig,
28}
29
30/// The `rules:` block of `.ailint.yaml`.
31#[derive(Debug, Clone, Default, Serialize, Deserialize, schemars::JsonSchema)]
32#[serde(default, deny_unknown_fields)]
33pub struct RulesConfig {
34    /// Rule IDs or slugs to disable (e.g. `AIL100` or `no-vague-instruction`).
35    pub disabled: Vec<String>,
36    /// Overrides for rule severity, keyed by ID or slug.
37    #[serde(default)]
38    pub severity_overrides: std::collections::BTreeMap<String, Severity>,
39    /// Per-rule options, keyed by ID or slug.
40    #[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/// The `paths:` block of `.ailint.yaml`.
46#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
47#[serde(default, deny_unknown_fields)]
48pub struct PathsConfig {
49    /// Additional path globs to exclude from discovery.
50    pub exclude: Vec<String>,
51    /// If non-empty, only paths matching one of these globs are linted.
52    #[serde(default)]
53    pub include: Vec<String>,
54    /// Follow symbolic links during discovery.
55    #[serde(default)]
56    pub follow_symlinks: bool,
57    /// Honor `.gitignore` and other VCS ignore files during discovery.
58    #[serde(default = "default_true")]
59    pub respect_gitignore: bool,
60    /// Gitignore-style globs (relative to the repo root) marking prompt
61    /// directories. Files under a matching directory that would otherwise be
62    /// classified as generic Markdown/YAML docs are treated as
63    /// `FileType::GenericSystemPrompt` instead, since no single filename
64    /// convention for system prompts exists across agent frameworks.
65    #[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/// The `sources:` block of `.ailint.yaml`. Opt-in scanning of source-code
95/// comments extracted by `ailint-extractor`.
96#[derive(Debug, Clone, Default, Serialize, Deserialize, schemars::JsonSchema)]
97#[serde(default, deny_unknown_fields)]
98pub struct SourcesConfig {
99    /// When true, discovery walks source files (Rust, TypeScript, JavaScript,
100    /// Python, Go, Java, C#) and feeds their extracted comments to
101    /// prose-oriented rules. Off by default: existing users see no change.
102    pub enabled: bool,
103    /// If non-empty, only these languages are scanned. Values are the
104    /// language ids from [`SourceLanguage::as_str`] (`"rust"`, `"typescript"`,
105    /// `"javascript"`, `"python"`, `"go"`, `"java"`, `"csharp"`). Empty means
106    /// all supported languages.
107    #[serde(default)]
108    pub languages: Vec<String>,
109}
110
111/// The `llm:` block of `.ailint.yaml`; enables the opt-in AIL9xx rules.
112#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
113#[serde(deny_unknown_fields)]
114pub struct LlmConfig {
115    /// Which provider client to use.
116    pub provider: LlmProviderKind,
117    /// Model identifier passed to the provider.
118    pub model: String,
119    /// Override endpoint URL for OpenAI-compatible providers.
120    #[serde(default)]
121    pub base_url: Option<String>,
122    /// Per-request timeout in seconds.
123    #[serde(default)]
124    pub timeout_seconds: Option<u64>,
125    /// Cap on tokens the provider may generate per request.
126    #[serde(default)]
127    pub max_tokens: Option<u32>,
128    /// Sampling temperature forwarded to the provider.
129    #[serde(default)]
130    pub temperature: Option<f32>,
131    /// Stop running LLM rules once cumulative spend exceeds this many USD.
132    #[serde(default)]
133    pub cost_cap_usd: Option<f64>,
134}
135
136/// Supported LLM provider clients.
137#[derive(Debug, Clone, Copy, Serialize, Deserialize, schemars::JsonSchema)]
138#[serde(rename_all = "lowercase")]
139pub enum LlmProviderKind {
140    /// OpenAI's hosted API.
141    Openai,
142    /// Anthropic's hosted API.
143    Anthropic,
144    /// Google's Gemini API.
145    Google,
146    /// A local Ollama server.
147    Ollama,
148    /// Any OpenAI-compatible endpoint.
149    Compatible,
150}
151
152/// The `output:` block of `.ailint.yaml`.
153#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
154#[serde(default, deny_unknown_fields)]
155pub struct OutputConfig {
156    /// Reporter used for violations.
157    pub format: ReporterKind,
158    /// When to colorize terminal output.
159    pub color: ColorMode,
160    /// Write reporter output to this file instead of stdout.
161    #[serde(default)]
162    pub output_file: Option<std::path::PathBuf>,
163    /// Suppress non-violation output (banners, progress, summary lines).
164    #[serde(default)]
165    pub quiet: bool,
166    /// Verbosity of the trailing summary block.
167    #[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/// Verbosity of the summary block after a `check` run.
184#[derive(
185    Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema,
186)]
187#[serde(rename_all = "lowercase")]
188pub enum SummaryFormat {
189    /// Per-severity counts plus file totals.
190    #[default]
191    Full,
192    /// A single totals line.
193    Compact,
194    /// No summary block.
195    None,
196}
197
198/// When to colorize terminal output.
199#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, schemars::JsonSchema)]
200#[serde(rename_all = "lowercase")]
201pub enum ColorMode {
202    /// Colorize only when stdout is a TTY.
203    #[default]
204    Auto,
205    /// Always emit color codes.
206    Always,
207    /// Never emit color codes.
208    Never,
209}
210
211impl Config {
212    /// JSON Schema for the config file, as pretty-printed JSON.
213    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    /// Load a config file from disk. Returns `Config::default()` if the file
219    /// does not exist. Dispatches on file extension: `.yaml`/`.yml` parse as
220    /// YAML, `.json` as JSON, anything else tries YAML then falls back to JSON.
221    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    /// Locate the closest config file, walking up from `start`.
250    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}