1use std::path::{Path, PathBuf};
7
8use serde::{Deserialize, Serialize};
9
10#[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#[derive(Debug, Clone, Serialize, Deserialize)]
24#[serde(default)]
25pub struct ThresholdConfig {
26 pub fail_below: f64,
28 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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
43#[serde(default)]
44pub struct ExcludeConfig {
45 pub patterns: Vec<String>,
47}
48
49#[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#[derive(Debug, Clone, Serialize, Deserialize)]
74#[serde(default)]
75pub struct OutputConfig {
76 pub format: String,
78 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#[derive(Debug, Clone, Serialize, Deserialize)]
93#[serde(default)]
94pub struct CiConfig {
95 pub enabled: bool,
97 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#[derive(Debug, Clone, Serialize, Deserialize)]
112#[serde(default)]
113pub struct CustomRulesConfig {
114 pub enabled: bool,
116 pub path: String,
119 pub max_deduction: f64,
122 pub high_penalty: f64,
124 pub medium_penalty: f64,
126 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
143pub 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 if current.join(".git").exists() {
155 break;
156 }
157 if !current.pop() {
158 break;
159 }
160 }
161 None
162}
163
164pub fn load_config(explicit: Option<&Path>, root: &Path) -> KardoConfig {
166 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 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 if let Some((_, config)) = discover_config(root) {
190 return config;
191 }
192
193 KardoConfig::default()
195}
196
197pub 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
247pub 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 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 std::fs::create_dir_all(dir.path().join(".git")).unwrap();
364 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 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 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 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}