1use std::path::Path;
7
8use serde::{Deserialize, Serialize};
9
10#[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 #[serde(default, skip_serializing_if = "Option::is_none")]
36 pub telemetry_id: Option<String>,
37}
38
39#[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#[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 pub fn effective_endpoint(&self) -> &str {
73 self.endpoint
74 .as_deref()
75 .unwrap_or("https://keel.engineer/api/telemetry")
76 }
77}
78
79#[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#[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#[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#[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
136pub struct CircuitBreakerConfig {
137 #[serde(default = "default_max_failures")]
138 pub max_failures: u32,
139}
140
141#[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 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 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, docstrings: false, placement: false, },
258 circuit_breaker: CircuitBreakerConfig {
259 max_failures: 42, },
261 batch: BatchConfig {
262 timeout_seconds: 999, },
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 let json =
306 serde_json::to_string_pretty(&original).expect("KeelConfig should serialize to JSON");
307
308 let roundtripped: KeelConfig =
310 serde_json::from_str(&json).expect("KeelConfig JSON should deserialize back");
311
312 assert_eq!(
314 original, roundtripped,
315 "Round-tripped config must match original"
316 );
317
318 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); assert_eq!(cfg.batch.timeout_seconds, 60); assert!(cfg.enforce.type_hints); }
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 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}