Skip to main content

keel_core/
config.rs

1//! Configuration file loading for keel.
2//!
3//! Reads `.keel/keel.json` and provides typed access to all settings.
4//! Falls back to sensible defaults when the config file is missing or incomplete.
5
6use std::path::Path;
7
8use serde::{Deserialize, Serialize};
9
10/// Top-level keel configuration.
11#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
12pub struct KeelConfig {
13    pub version: String,
14    pub languages: Vec<String>,
15    #[serde(default)]
16    pub enforce: EnforceConfig,
17    #[serde(default)]
18    pub circuit_breaker: CircuitBreakerConfig,
19    #[serde(default)]
20    pub batch: BatchConfig,
21    #[serde(default)]
22    pub ignore_patterns: Vec<String>,
23    #[serde(default)]
24    pub tier: Tier,
25    #[serde(default)]
26    pub telemetry: TelemetryConfig,
27    #[serde(default)]
28    pub naming_conventions: NamingConventionsConfig,
29    #[serde(default)]
30    pub monorepo: MonorepoConfig,
31    #[serde(default)]
32    pub tier3: Tier3Config,
33    /// Stable random identifier for telemetry project deduplication.
34    /// Generated at `keel init` time; avoids path-based hash inflation.
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub telemetry_id: Option<String>,
37}
38
39/// Product tier — gates feature access.
40#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
41#[serde(rename_all = "lowercase")]
42pub enum Tier {
43    #[default]
44    Free,
45    Team,
46    Enterprise,
47}
48
49/// Telemetry configuration — privacy-safe event tracking.
50#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
51pub struct TelemetryConfig {
52    #[serde(default = "default_true")]
53    pub enabled: bool,
54    #[serde(default = "default_true")]
55    pub remote: bool,
56    #[serde(default)]
57    pub endpoint: Option<String>,
58}
59
60impl Default for TelemetryConfig {
61    fn default() -> Self {
62        Self {
63            enabled: true,
64            remote: true,
65            endpoint: None,
66        }
67    }
68}
69
70impl TelemetryConfig {
71    /// Returns the configured telemetry endpoint URL, falling back to the default keel API.
72    pub fn effective_endpoint(&self) -> &str {
73        self.endpoint
74            .as_deref()
75            .unwrap_or("https://keel.engineer/api/telemetry")
76    }
77}
78
79/// Naming convention configuration — stub for future online UI.
80#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
81pub struct NamingConventionsConfig {
82    #[serde(default)]
83    pub style: Option<String>,
84    #[serde(default)]
85    pub prefixes: Vec<String>,
86}
87
88/// Monorepo detection and cross-package configuration.
89#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
90pub struct MonorepoConfig {
91    #[serde(default)]
92    pub enabled: bool,
93    #[serde(default)]
94    pub kind: Option<String>,
95    #[serde(default)]
96    pub packages: Vec<String>,
97}
98
99/// Tier 3 (LSP/SCIP) resolution configuration.
100#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
101pub struct Tier3Config {
102    #[serde(default)]
103    pub enabled: bool,
104    #[serde(default)]
105    pub scip_paths: std::collections::HashMap<String, String>,
106    #[serde(default)]
107    pub lsp_commands: std::collections::HashMap<String, Vec<String>>,
108    #[serde(default = "default_true")]
109    pub prefer_scip: bool,
110}
111
112impl Default for Tier3Config {
113    fn default() -> Self {
114        Self {
115            enabled: false,
116            scip_paths: std::collections::HashMap::new(),
117            lsp_commands: std::collections::HashMap::new(),
118            prefer_scip: true,
119        }
120    }
121}
122
123/// Enforcement severity toggles.
124#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
125pub struct EnforceConfig {
126    #[serde(default = "default_true")]
127    pub type_hints: bool,
128    #[serde(default = "default_true")]
129    pub docstrings: bool,
130    #[serde(default = "default_true")]
131    pub placement: bool,
132}
133
134/// Circuit breaker tuning.
135#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
136pub struct CircuitBreakerConfig {
137    #[serde(default = "default_max_failures")]
138    pub max_failures: u32,
139}
140
141/// Batch mode tuning.
142#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
143pub struct BatchConfig {
144    #[serde(default = "default_timeout_seconds")]
145    pub timeout_seconds: u64,
146}
147
148fn default_true() -> bool {
149    true
150}
151fn default_max_failures() -> u32 {
152    3
153}
154fn default_timeout_seconds() -> u64 {
155    60
156}
157
158impl Default for EnforceConfig {
159    fn default() -> Self {
160        Self {
161            type_hints: true,
162            docstrings: true,
163            placement: true,
164        }
165    }
166}
167
168impl Default for CircuitBreakerConfig {
169    fn default() -> Self {
170        Self {
171            max_failures: default_max_failures(),
172        }
173    }
174}
175
176impl Default for BatchConfig {
177    fn default() -> Self {
178        Self {
179            timeout_seconds: default_timeout_seconds(),
180        }
181    }
182}
183
184impl Default for KeelConfig {
185    fn default() -> Self {
186        Self {
187            version: "0.1.0".to_string(),
188            languages: vec![],
189            enforce: EnforceConfig::default(),
190            circuit_breaker: CircuitBreakerConfig::default(),
191            batch: BatchConfig::default(),
192            ignore_patterns: vec![],
193            tier: Tier::default(),
194            telemetry: TelemetryConfig::default(),
195            naming_conventions: NamingConventionsConfig::default(),
196            monorepo: MonorepoConfig::default(),
197            tier3: Tier3Config::default(),
198            telemetry_id: None,
199        }
200    }
201}
202
203impl KeelConfig {
204    /// Load configuration from `.keel/keel.json` inside the given keel directory.
205    /// Returns defaults if the file doesn't exist or can't be parsed.
206    pub fn load(keel_dir: &Path) -> Self {
207        let config_path = keel_dir.join("keel.json");
208        let content = match std::fs::read_to_string(&config_path) {
209            Ok(c) => c,
210            Err(_) => return Self::default(),
211        };
212        match serde_json::from_str(&content) {
213            Ok(cfg) => cfg,
214            Err(e) => {
215                eprintln!(
216                    "keel: warning: failed to parse {}: {}, using defaults",
217                    config_path.display(),
218                    e
219                );
220                Self::default()
221            }
222        }
223    }
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229    use std::fs;
230
231    #[test]
232    fn test_default_config() {
233        let cfg = KeelConfig::default();
234        assert_eq!(cfg.version, "0.1.0");
235        assert_eq!(cfg.circuit_breaker.max_failures, 3);
236        assert_eq!(cfg.batch.timeout_seconds, 60);
237        assert!(cfg.enforce.type_hints);
238        assert!(cfg.enforce.docstrings);
239        assert!(cfg.enforce.placement);
240    }
241
242    #[test]
243    fn test_roundtrip_all_non_default_values() {
244        // Build a KeelConfig with every field set to a non-default value.
245        let original = KeelConfig {
246            version: "99.88.77".to_string(),
247            languages: vec![
248                "typescript".to_string(),
249                "python".to_string(),
250                "go".to_string(),
251                "rust".to_string(),
252            ],
253            enforce: EnforceConfig {
254                type_hints: false, // default is true
255                docstrings: false, // default is true
256                placement: false,  // default is true
257            },
258            circuit_breaker: CircuitBreakerConfig {
259                max_failures: 42, // default is 3
260            },
261            batch: BatchConfig {
262                timeout_seconds: 999, // default is 60
263            },
264            ignore_patterns: vec![
265                "vendor/**".to_string(),
266                "node_modules/**".to_string(),
267                "*.generated.ts".to_string(),
268            ],
269            tier: Tier::Enterprise,
270            telemetry: TelemetryConfig {
271                enabled: false,
272                remote: false,
273                endpoint: Some("https://custom.example.com/telemetry".to_string()),
274            },
275            naming_conventions: NamingConventionsConfig {
276                style: Some("snake_case".to_string()),
277                prefixes: vec!["keel_".to_string(), "test_".to_string()],
278            },
279            monorepo: MonorepoConfig {
280                enabled: true,
281                kind: Some("CargoWorkspace".to_string()),
282                packages: vec!["core".to_string(), "cli".to_string()],
283            },
284            tier3: Tier3Config {
285                enabled: true,
286                scip_paths: {
287                    let mut m = std::collections::HashMap::new();
288                    m.insert("typescript".to_string(), ".scip/index.scip".to_string());
289                    m
290                },
291                lsp_commands: {
292                    let mut m = std::collections::HashMap::new();
293                    m.insert(
294                        "python".to_string(),
295                        vec!["pyright-langserver".to_string(), "--stdio".to_string()],
296                    );
297                    m
298                },
299                prefer_scip: false,
300            },
301            telemetry_id: Some("a1b2c3d4e5f60718a1b2c3d4e5f60718".to_string()),
302        };
303
304        // Serialize to JSON
305        let json =
306            serde_json::to_string_pretty(&original).expect("KeelConfig should serialize to JSON");
307
308        // Deserialize back
309        let roundtripped: KeelConfig =
310            serde_json::from_str(&json).expect("KeelConfig JSON should deserialize back");
311
312        // Whole-struct equality (enabled by PartialEq derive)
313        assert_eq!(
314            original, roundtripped,
315            "Round-tripped config must match original"
316        );
317
318        // Belt-and-suspenders: also verify each field individually so failures
319        // are easy to diagnose if the PartialEq impl ever changes.
320        assert_eq!(roundtripped.version, "99.88.77");
321        assert_eq!(
322            roundtripped.languages,
323            vec!["typescript", "python", "go", "rust"]
324        );
325        assert!(!roundtripped.enforce.type_hints);
326        assert!(!roundtripped.enforce.docstrings);
327        assert!(!roundtripped.enforce.placement);
328        assert_eq!(roundtripped.circuit_breaker.max_failures, 42);
329        assert_eq!(roundtripped.batch.timeout_seconds, 999);
330        assert_eq!(
331            roundtripped.ignore_patterns,
332            vec!["vendor/**", "node_modules/**", "*.generated.ts"]
333        );
334        assert_eq!(roundtripped.tier, Tier::Enterprise);
335        assert!(!roundtripped.telemetry.enabled);
336        assert!(!roundtripped.telemetry.remote);
337        assert_eq!(
338            roundtripped.telemetry.endpoint,
339            Some("https://custom.example.com/telemetry".to_string())
340        );
341        assert_eq!(
342            roundtripped.naming_conventions.style,
343            Some("snake_case".to_string())
344        );
345        assert_eq!(
346            roundtripped.naming_conventions.prefixes,
347            vec!["keel_", "test_"]
348        );
349        assert!(roundtripped.monorepo.enabled);
350        assert_eq!(
351            roundtripped.monorepo.kind,
352            Some("CargoWorkspace".to_string())
353        );
354        assert_eq!(roundtripped.monorepo.packages, vec!["core", "cli"]);
355        assert!(roundtripped.tier3.enabled);
356        assert_eq!(
357            roundtripped.tier3.scip_paths.get("typescript").unwrap(),
358            ".scip/index.scip"
359        );
360        assert_eq!(
361            roundtripped.tier3.lsp_commands.get("python").unwrap(),
362            &vec!["pyright-langserver", "--stdio"]
363        );
364        assert!(!roundtripped.tier3.prefer_scip);
365        assert_eq!(
366            roundtripped.telemetry_id,
367            Some("a1b2c3d4e5f60718a1b2c3d4e5f60718".to_string())
368        );
369    }
370
371    #[test]
372    fn test_load_missing_file() {
373        let cfg = KeelConfig::load(Path::new("/nonexistent"));
374        assert_eq!(cfg.circuit_breaker.max_failures, 3);
375    }
376
377    #[test]
378    fn test_load_valid_config() {
379        let dir = tempfile::tempdir().unwrap();
380        let config = serde_json::json!({
381            "version": "0.2.0",
382            "languages": ["typescript", "python"],
383            "circuit_breaker": { "max_failures": 5 },
384            "batch": { "timeout_seconds": 120 }
385        });
386        fs::write(dir.path().join("keel.json"), config.to_string()).unwrap();
387        let cfg = KeelConfig::load(dir.path());
388        assert_eq!(cfg.version, "0.2.0");
389        assert_eq!(cfg.circuit_breaker.max_failures, 5);
390        assert_eq!(cfg.batch.timeout_seconds, 120);
391        assert_eq!(cfg.languages, vec!["typescript", "python"]);
392    }
393
394    #[test]
395    fn test_load_partial_config() {
396        let dir = tempfile::tempdir().unwrap();
397        let config = serde_json::json!({
398            "version": "0.1.0",
399            "languages": ["go"]
400        });
401        fs::write(dir.path().join("keel.json"), config.to_string()).unwrap();
402        let cfg = KeelConfig::load(dir.path());
403        assert_eq!(cfg.circuit_breaker.max_failures, 3); // default
404        assert_eq!(cfg.batch.timeout_seconds, 60); // default
405        assert!(cfg.enforce.type_hints); // default
406    }
407
408    #[test]
409    fn test_tier_roundtrip() {
410        for (tier, expected_json) in [
411            (Tier::Free, "\"free\""),
412            (Tier::Team, "\"team\""),
413            (Tier::Enterprise, "\"enterprise\""),
414        ] {
415            let json = serde_json::to_string(&tier).unwrap();
416            assert_eq!(json, expected_json);
417            let parsed: Tier = serde_json::from_str(&json).unwrap();
418            assert_eq!(parsed, tier);
419        }
420    }
421
422    #[test]
423    fn test_telemetry_defaults() {
424        let cfg = TelemetryConfig::default();
425        assert!(cfg.enabled);
426        assert!(cfg.remote);
427        assert!(cfg.endpoint.is_none());
428        assert_eq!(
429            cfg.effective_endpoint(),
430            "https://keel.engineer/api/telemetry"
431        );
432    }
433
434    #[test]
435    fn test_backward_compat_old_json_without_new_fields() {
436        // Old-style JSON without tier, telemetry, or naming_conventions
437        let old_json = r#"{
438            "version": "0.1.0",
439            "languages": ["typescript"],
440            "enforce": { "type_hints": true, "docstrings": true, "placement": true },
441            "circuit_breaker": { "max_failures": 3 },
442            "batch": { "timeout_seconds": 60 },
443            "ignore_patterns": []
444        }"#;
445        let cfg: KeelConfig = serde_json::from_str(old_json).unwrap();
446        assert_eq!(cfg.tier, Tier::Free);
447        assert!(cfg.telemetry.enabled);
448        assert!(cfg.telemetry.remote);
449        assert!(cfg.naming_conventions.style.is_none());
450        assert!(cfg.naming_conventions.prefixes.is_empty());
451        assert!(!cfg.monorepo.enabled);
452        assert!(cfg.monorepo.kind.is_none());
453        assert!(cfg.monorepo.packages.is_empty());
454        assert!(!cfg.tier3.enabled);
455        assert!(cfg.tier3.scip_paths.is_empty());
456        assert!(cfg.tier3.lsp_commands.is_empty());
457        assert!(cfg.tier3.prefer_scip);
458        assert!(cfg.telemetry_id.is_none());
459    }
460
461    #[test]
462    fn test_naming_conventions_roundtrip() {
463        let nc = NamingConventionsConfig {
464            style: Some("camelCase".to_string()),
465            prefixes: vec!["app_".to_string(), "lib_".to_string()],
466        };
467        let json = serde_json::to_string(&nc).unwrap();
468        let parsed: NamingConventionsConfig = serde_json::from_str(&json).unwrap();
469        assert_eq!(parsed, nc);
470    }
471}