Skip to main content

kardo_core/
config.rs

1//! Configuration file support for Kardo.
2//!
3//! Loads `kardo.toml` with zero-config defaults.
4//! Config discovery: CLI flag -> env var -> walk up to .git root -> defaults.
5
6use std::path::{Path, PathBuf};
7
8use serde::{Deserialize, Serialize};
9
10/// Top-level configuration.
11#[derive(Debug, Clone, Default, Serialize, Deserialize)]
12#[serde(default)]
13pub struct KardoConfig {
14    pub thresholds: ThresholdConfig,
15    pub exclude: ExcludeConfig,
16    pub checks: ChecksConfig,
17    pub output: OutputConfig,
18    pub ci: CiConfig,
19    pub custom_rules: CustomRulesConfig,
20}
21
22/// Score threshold configuration.
23#[derive(Debug, Clone, Serialize, Deserialize)]
24#[serde(default)]
25pub struct ThresholdConfig {
26    /// Score below which CI fails (0-100). Default: 0 (disabled).
27    pub fail_below: f64,
28    /// Max allowed regression from previous scan (percentage points).
29    pub regression_limit: f64,
30}
31
32impl Default for ThresholdConfig {
33    fn default() -> Self {
34        Self {
35            fail_below: 0.0,
36            regression_limit: 5.0,
37        }
38    }
39}
40
41/// File exclusion patterns.
42#[derive(Debug, Clone, Default, Serialize, Deserialize)]
43#[serde(default)]
44pub struct ExcludeConfig {
45    /// Additional glob patterns to exclude.
46    pub patterns: Vec<String>,
47}
48
49/// Toggle individual check categories.
50#[derive(Debug, Clone, Serialize, Deserialize)]
51#[serde(default)]
52pub struct ChecksConfig {
53    pub freshness: bool,
54    pub configuration: bool,
55    pub integrity: bool,
56    pub agent_setup: bool,
57    pub structure: bool,
58}
59
60impl Default for ChecksConfig {
61    fn default() -> Self {
62        Self {
63            freshness: true,
64            configuration: true,
65            integrity: true,
66            agent_setup: true,
67            structure: true,
68        }
69    }
70}
71
72/// Output configuration.
73#[derive(Debug, Clone, Serialize, Deserialize)]
74#[serde(default)]
75pub struct OutputConfig {
76    /// Default output format: "summary", "detailed", "json".
77    pub format: String,
78    /// Show verbose output by default.
79    pub verbose: bool,
80}
81
82impl Default for OutputConfig {
83    fn default() -> Self {
84        Self {
85            format: "summary".to_string(),
86            verbose: false,
87        }
88    }
89}
90
91/// CI-specific configuration.
92#[derive(Debug, Clone, Serialize, Deserialize)]
93#[serde(default)]
94pub struct CiConfig {
95    /// Enable CI mode (exit codes, no color).
96    pub enabled: bool,
97    /// Post PR comment.
98    pub pr_comment: bool,
99}
100
101impl Default for CiConfig {
102    fn default() -> Self {
103        Self {
104            enabled: false,
105            pr_comment: true,
106        }
107    }
108}
109
110/// Custom rules configuration.
111#[derive(Debug, Clone, Serialize, Deserialize)]
112#[serde(default)]
113pub struct CustomRulesConfig {
114    /// Enable loading custom rules from `path` (default: true).
115    pub enabled: bool,
116    /// Path to the custom rules YAML file, relative to the project root.
117    /// Defaults to `.kardo/rules.yml`.
118    pub path: String,
119    /// Maximum total score deduction from custom rule violations (0-100).
120    /// Default: 20 points. Prevents custom rules from dominating the score.
121    pub max_deduction: f64,
122    /// Score deducted per high-severity custom rule violation.
123    pub high_penalty: f64,
124    /// Score deducted per medium-severity custom rule violation.
125    pub medium_penalty: f64,
126    /// Score deducted per low-severity custom rule violation.
127    pub low_penalty: f64,
128}
129
130impl Default for CustomRulesConfig {
131    fn default() -> Self {
132        Self {
133            enabled: true,
134            path: ".kardo/rules.yml".to_string(),
135            max_deduction: 20.0,
136            high_penalty: 5.0,
137            medium_penalty: 2.0,
138            low_penalty: 1.0,
139        }
140    }
141}
142
143/// Walk up from `start` looking for `kardo.toml`, stopping at .git root.
144pub fn discover_config(start: &Path) -> Option<(PathBuf, KardoConfig)> {
145    let mut current = start.to_path_buf();
146    loop {
147        let candidate = current.join("kardo.toml");
148        if candidate.exists() {
149            let content = std::fs::read_to_string(&candidate).ok()?;
150            let config: KardoConfig = toml::from_str(&content).ok()?;
151            return Some((candidate, config));
152        }
153        // Stop at .git root
154        if current.join(".git").exists() {
155            break;
156        }
157        if !current.pop() {
158            break;
159        }
160    }
161    None
162}
163
164/// Load config with priority: explicit path -> env var -> discovery -> defaults.
165pub fn load_config(explicit: Option<&Path>, root: &Path) -> KardoConfig {
166    // 1. Explicit path from CLI
167    if let Some(path) = explicit {
168        if let Ok(content) = std::fs::read_to_string(path) {
169            if let Ok(config) = toml::from_str(&content) {
170                return config;
171            }
172            eprintln!("Warning: Could not parse config at {}", path.display());
173        } else {
174            eprintln!("Warning: Could not read config at {}", path.display());
175        }
176    }
177
178    // 2. Environment variable
179    if let Ok(env_path) = std::env::var("CONTEXTSCORE_CONFIG") {
180        let path = Path::new(&env_path);
181        if let Ok(content) = std::fs::read_to_string(path) {
182            if let Ok(config) = toml::from_str(&content) {
183                return config;
184            }
185        }
186    }
187
188    // 3. Discovery (walk up to .git)
189    if let Some((_, config)) = discover_config(root) {
190        return config;
191    }
192
193    // 4. Defaults
194    KardoConfig::default()
195}
196
197/// Generate a commented TOML template for `kardo init`.
198pub fn generate_template() -> String {
199    r#"# Kardo Configuration
200# https://github.com/kardo-dev/kardo
201
202[thresholds]
203# Score below which CI fails (0-100). 0 = disabled.
204# fail_below = 0
205# Max regression from previous scan (percentage points).
206# regression_limit = 5.0
207
208[exclude]
209# Additional glob patterns to exclude from scanning.
210# patterns = ["vendor/**", "*.generated.md"]
211
212[checks]
213# Toggle individual check categories (all enabled by default).
214# freshness = true
215# configuration = true
216# integrity = true
217# agent_setup = true
218# structure = true
219
220[output]
221# Default output format: "summary", "detailed", "json"
222# format = "summary"
223# Show verbose output by default.
224# verbose = false
225
226[ci]
227# Enable CI mode (strict exit codes, no color).
228# enabled = false
229# Post PR comment with results.
230# pr_comment = true
231
232[custom_rules]
233# Enable loading custom YAML rules (default: true).
234# enabled = true
235# Path to custom rules file, relative to project root.
236# path = ".kardo/rules.yml"
237# Maximum total score deduction from custom rules (0-100).
238# max_deduction = 20.0
239# Score deducted per high/medium/low severity custom rule violation.
240# high_penalty = 5.0
241# medium_penalty = 2.0
242# low_penalty = 1.0
243"#
244    .to_string()
245}
246
247/// Generate the .kardo/rules.yml template for `kardo init`.
248pub fn generate_rules_template() -> &'static str {
249    r#"# .kardo/rules.yml -- Custom Kardo Rules
250# https://github.com/kardo-dev/kardo
251#
252# Place this file at <project-root>/.kardo/rules.yml
253# It is loaded automatically when running `kardo .`
254#
255# Schema version -- must be "1"
256version: "1"
257
258# -- Optional: disable specific builtin rules -----------------------------------
259# Builtin rule IDs: freshness-stale, freshness-coupling, integrity-broken-link,
260# config-missing-claude-md, config-missing-readme, config-short, config-generic
261#
262# disable:
263#   - freshness-stale
264
265# -- Custom rules ---------------------------------------------------------------
266# Four pattern types: file_missing, content_match, file_stale, regex
267#
268# Rule ID format: "<namespace>/<slug>" -- must be non-empty and contain no spaces.
269# Scoring impact (per violation, configurable in kardo.toml):
270#   high (-5 pts) | medium (-2 pts) | low (-1 pt)
271# Maximum total deduction from custom rules: 20 pts (configurable).
272
273rules:
274  # Require a CHANGELOG.md
275  # - id: "org/changelog-required"
276  #   name: "CHANGELOG.md required"
277  #   category: configuration
278  #   severity: high
279  #   pattern:
280  #     type: file_missing
281  #     target: "CHANGELOG.md"
282  #   message: "Project is missing a CHANGELOG.md"
283  #   suggestion: "Create CHANGELOG.md to track version history for AI context"
284
285  # Require AGENTS.md for AI teams
286  # - id: "org/agents-md-required"
287  #   name: "AGENTS.md required for AI teams"
288  #   category: agent_setup
289  #   severity: high
290  #   pattern:
291  #     type: file_missing
292  #     target: "AGENTS.md"
293  #   message: "No AGENTS.md found -- AI agents lack team coordination instructions"
294  #   suggestion: "Create AGENTS.md with agent roles, permissions, and workflow rules"
295"#
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301    use tempfile::TempDir;
302
303    #[test]
304    fn test_parse_full_config() {
305        let toml_str = r#"
306[thresholds]
307fail_below = 70.0
308regression_limit = 3.0
309
310[exclude]
311patterns = ["vendor/**", "*.generated.md"]
312
313[checks]
314freshness = true
315configuration = true
316integrity = false
317
318[output]
319format = "detailed"
320verbose = true
321
322[ci]
323enabled = true
324pr_comment = false
325"#;
326        let config: KardoConfig = toml::from_str(toml_str).unwrap();
327        assert!((config.thresholds.fail_below - 70.0).abs() < f64::EPSILON);
328        assert_eq!(config.exclude.patterns.len(), 2);
329        assert!(!config.checks.integrity);
330        assert_eq!(config.output.format, "detailed");
331        assert!(config.ci.enabled);
332        assert!(!config.ci.pr_comment);
333    }
334
335    #[test]
336    fn test_parse_partial_config() {
337        let toml_str = r#"
338[thresholds]
339fail_below = 50.0
340"#;
341        let config: KardoConfig = toml::from_str(toml_str).unwrap();
342        assert!((config.thresholds.fail_below - 50.0).abs() < f64::EPSILON);
343        // Defaults for unset fields
344        assert!((config.thresholds.regression_limit - 5.0).abs() < f64::EPSILON);
345        assert!(config.checks.freshness);
346        assert_eq!(config.output.format, "summary");
347    }
348
349    #[test]
350    fn test_parse_empty_config() {
351        let config: KardoConfig = toml::from_str("").unwrap();
352        assert!((config.thresholds.fail_below - 0.0).abs() < f64::EPSILON);
353        assert!(config.checks.freshness);
354        assert_eq!(config.output.format, "summary");
355    }
356
357    #[test]
358    fn test_discover_walks_up() {
359        let dir = TempDir::new().unwrap();
360        let sub = dir.path().join("src").join("deep");
361        std::fs::create_dir_all(&sub).unwrap();
362        // Create .git marker at root
363        std::fs::create_dir_all(dir.path().join(".git")).unwrap();
364        // Create config at root
365        std::fs::write(
366            dir.path().join("kardo.toml"),
367            "[thresholds]\nfail_below = 42.0\n",
368        )
369        .unwrap();
370
371        let result = discover_config(&sub);
372        assert!(result.is_some());
373        let (path, config) = result.unwrap();
374        assert!(path.ends_with("kardo.toml"));
375        assert!((config.thresholds.fail_below - 42.0).abs() < f64::EPSILON);
376    }
377
378    #[test]
379    fn test_discover_stops_at_git() {
380        let dir = TempDir::new().unwrap();
381        let sub = dir.path().join("project");
382        std::fs::create_dir_all(&sub).unwrap();
383        std::fs::create_dir_all(sub.join(".git")).unwrap();
384        // Config only above .git (shouldn't find it)
385
386        let result = discover_config(&sub);
387        assert!(result.is_none());
388    }
389
390    #[test]
391    fn test_template_is_valid_toml() {
392        let template = generate_template();
393        // Template should be parseable as TOML (all commented out = empty config)
394        let config: Result<KardoConfig, _> = toml::from_str(&template);
395        assert!(config.is_ok(), "Template should be valid TOML");
396    }
397
398    #[test]
399    fn test_custom_rules_defaults() {
400        let config: KardoConfig = toml::from_str("").unwrap();
401        assert!(config.custom_rules.enabled);
402        assert_eq!(config.custom_rules.path, ".kardo/rules.yml");
403        assert!((config.custom_rules.max_deduction - 20.0).abs() < f64::EPSILON);
404        assert!((config.custom_rules.high_penalty - 5.0).abs() < f64::EPSILON);
405        assert!((config.custom_rules.medium_penalty - 2.0).abs() < f64::EPSILON);
406        assert!((config.custom_rules.low_penalty - 1.0).abs() < f64::EPSILON);
407    }
408
409    #[test]
410    fn test_custom_rules_config_overrides() {
411        let toml_str = r#"
412[custom_rules]
413enabled = false
414path = "my-rules/custom.yml"
415max_deduction = 10.0
416high_penalty = 3.0
417medium_penalty = 1.5
418low_penalty = 0.5
419"#;
420        let config: KardoConfig = toml::from_str(toml_str).unwrap();
421        assert!(!config.custom_rules.enabled);
422        assert_eq!(config.custom_rules.path, "my-rules/custom.yml");
423        assert!((config.custom_rules.max_deduction - 10.0).abs() < f64::EPSILON);
424        assert!((config.custom_rules.high_penalty - 3.0).abs() < f64::EPSILON);
425        assert!((config.custom_rules.medium_penalty - 1.5).abs() < f64::EPSILON);
426        assert!((config.custom_rules.low_penalty - 0.5).abs() < f64::EPSILON);
427    }
428
429    #[test]
430    fn test_full_config_with_custom_rules() {
431        let toml_str = r#"
432[thresholds]
433fail_below = 70.0
434
435[custom_rules]
436enabled = true
437path = ".kardo/rules.yml"
438max_deduction = 15.0
439"#;
440        let config: KardoConfig = toml::from_str(toml_str).unwrap();
441        assert!((config.thresholds.fail_below - 70.0).abs() < f64::EPSILON);
442        assert!(config.custom_rules.enabled);
443        assert!((config.custom_rules.max_deduction - 15.0).abs() < f64::EPSILON);
444        // Unspecified penalties retain defaults
445        assert!((config.custom_rules.high_penalty - 5.0).abs() < f64::EPSILON);
446    }
447
448    #[test]
449    fn test_rules_template_content() {
450        let template = generate_rules_template();
451        assert!(!template.is_empty());
452        assert!(template.contains("version: \"1\""));
453        assert!(template.contains("rules:"));
454    }
455}