1use anyhow::Result;
6use serde::{Deserialize, Serialize};
7use std::path::Path;
8
9#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
10pub struct ScanConfig {
11 pub roots: Vec<String>,
12 #[serde(default = "default_min_rescan_seconds")]
14 pub min_rescan_seconds: u64,
15}
16
17fn default_min_rescan_seconds() -> u64 {
18 300
19}
20
21impl Default for ScanConfig {
22 fn default() -> Self {
23 Self {
24 roots: vec!["~/.cursor/projects".to_string()],
25 min_rescan_seconds: default_min_rescan_seconds(),
26 }
27 }
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct CursorSourceConfig {
32 pub enabled: bool,
33 pub transcript_glob: String,
34}
35
36impl Default for CursorSourceConfig {
37 fn default() -> Self {
38 Self {
39 enabled: true,
40 transcript_glob: "*/agent-transcripts".to_string(),
41 }
42 }
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct TailAgentToggles {
48 #[serde(default = "default_true")]
49 pub goose: bool,
50 #[serde(default = "default_true")]
51 pub opencode: bool,
52 #[serde(default = "default_true")]
53 pub copilot_cli: bool,
54 #[serde(default = "default_true")]
55 pub copilot_vscode: bool,
56}
57
58impl Default for TailAgentToggles {
59 fn default() -> Self {
60 Self {
61 goose: true,
62 opencode: true,
63 copilot_cli: true,
64 copilot_vscode: true,
65 }
66 }
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize, Default)]
70pub struct SourcesConfig {
71 #[serde(default)]
72 pub cursor: CursorSourceConfig,
73 #[serde(default)]
74 pub tail: TailAgentToggles,
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
78pub struct RetentionConfig {
79 pub hot_days: u32,
80 pub warm_days: u32,
81}
82
83impl Default for RetentionConfig {
84 fn default() -> Self {
85 Self {
86 hot_days: 30,
87 warm_days: 90,
88 }
89 }
90}
91
92#[derive(Debug, Clone, Serialize, Deserialize)]
93pub struct SyncConfig {
94 #[serde(default)]
96 pub endpoint: String,
97 #[serde(default)]
98 pub team_token: String,
99 #[serde(default)]
100 pub team_id: String,
101 #[serde(default = "default_events_per_batch")]
102 pub events_per_batch_max: usize,
103 #[serde(default = "default_max_body_bytes")]
104 pub max_body_bytes: usize,
105 #[serde(default = "default_flush_interval_ms")]
106 pub flush_interval_ms: u64,
107 #[serde(default = "default_sample_rate")]
108 pub sample_rate: f64,
109 #[serde(default)]
111 pub team_salt_hex: String,
112}
113
114fn default_events_per_batch() -> usize {
115 500
116}
117
118fn default_max_body_bytes() -> usize {
119 1_000_000
120}
121
122fn default_flush_interval_ms() -> u64 {
123 10_000
124}
125
126fn default_sample_rate() -> f64 {
127 1.0
128}
129
130impl Default for SyncConfig {
131 fn default() -> Self {
132 Self {
133 endpoint: String::new(),
134 team_token: String::new(),
135 team_id: String::new(),
136 events_per_batch_max: default_events_per_batch(),
137 max_body_bytes: default_max_body_bytes(),
138 flush_interval_ms: default_flush_interval_ms(),
139 sample_rate: default_sample_rate(),
140 team_salt_hex: String::new(),
141 }
142 }
143}
144
145pub fn try_team_salt(cfg: &SyncConfig) -> Option<[u8; 32]> {
147 let h = cfg.team_salt_hex.trim();
148 if h.len() != 64 {
149 return None;
150 }
151 let bytes = hex::decode(h).ok()?;
152 bytes.try_into().ok()
153}
154
155fn default_true() -> bool {
156 true
157}
158
159fn default_telemetry_fail_open() -> bool {
160 true
161}
162
163fn default_cache_ttl_seconds() -> u64 {
164 3600
165}
166
167#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
169#[serde(rename_all = "lowercase")]
170pub enum QueryAuthority {
171 #[default]
172 None,
173 Posthog,
174 Datadog,
175}
176
177#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
179pub struct IdentityAllowlist {
180 #[serde(default)]
181 pub team: bool,
182 #[serde(default)]
183 pub workspace_label: bool,
184 #[serde(default)]
185 pub runner_label: bool,
186 #[serde(default)]
187 pub actor_kind: bool,
188 #[serde(default)]
189 pub actor_label: bool,
190 #[serde(default)]
191 pub agent: bool,
192 #[serde(default)]
193 pub model: bool,
194 #[serde(default)]
195 pub env: bool,
196 #[serde(default)]
197 pub job: bool,
198 #[serde(default)]
199 pub branch: bool,
200}
201
202#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
204pub struct TelemetryQueryConfig {
205 #[serde(default)]
207 pub provider: QueryAuthority,
208 #[serde(default = "default_cache_ttl_seconds")]
210 pub cache_ttl_seconds: u64,
211 #[serde(default)]
212 pub identity_allowlist: IdentityAllowlist,
213}
214
215impl Default for TelemetryQueryConfig {
216 fn default() -> Self {
217 Self {
218 provider: QueryAuthority::default(),
219 cache_ttl_seconds: default_cache_ttl_seconds(),
220 identity_allowlist: IdentityAllowlist::default(),
221 }
222 }
223}
224
225impl TelemetryQueryConfig {
226 pub fn has_provider_for_pull(&self) -> bool {
228 matches!(
229 self.provider,
230 QueryAuthority::Posthog | QueryAuthority::Datadog
231 )
232 }
233}
234
235#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
237#[serde(tag = "type", rename_all = "snake_case")]
238pub enum ContextPolicy {
239 #[default]
241 None,
242 LastMessages { count: usize },
244 MaxInputTokens { max: u32 },
246}
247
248#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
250pub struct ProxyConfig {
251 #[serde(default = "default_proxy_listen")]
253 pub listen: String,
254 #[serde(default = "default_proxy_upstream")]
256 pub upstream: String,
257 #[serde(default = "default_true")]
259 pub compress_transport: bool,
260 #[serde(default = "default_true")]
262 pub minify_json: bool,
263 #[serde(default = "default_proxy_max_body_mb")]
265 pub max_response_body_mb: u32,
266 #[serde(default = "default_proxy_max_request_body_mb")]
268 pub max_request_body_mb: u32,
269 #[serde(default)]
271 pub context_policy: ContextPolicy,
272}
273
274fn default_proxy_listen() -> String {
275 "127.0.0.1:3847".to_string()
276}
277
278fn default_proxy_upstream() -> String {
279 "https://api.anthropic.com".to_string()
280}
281
282fn default_proxy_max_body_mb() -> u32 {
283 256
284}
285
286fn default_proxy_max_request_body_mb() -> u32 {
287 32
288}
289
290impl Default for ProxyConfig {
291 fn default() -> Self {
292 Self {
293 listen: default_proxy_listen(),
294 upstream: default_proxy_upstream(),
295 compress_transport: true,
296 minify_json: true,
297 max_response_body_mb: default_proxy_max_body_mb(),
298 max_request_body_mb: default_proxy_max_request_body_mb(),
299 context_policy: ContextPolicy::default(),
300 }
301 }
302}
303
304#[derive(Debug, Clone, Serialize, Deserialize)]
306pub struct TelemetryConfig {
307 #[serde(default = "default_telemetry_fail_open")]
309 pub fail_open: bool,
310 #[serde(default)]
312 pub query: TelemetryQueryConfig,
313 #[serde(default)]
315 pub exporters: Vec<ExporterConfig>,
316}
317
318impl Default for TelemetryConfig {
319 fn default() -> Self {
320 Self {
321 fail_open: default_telemetry_fail_open(),
322 query: TelemetryQueryConfig::default(),
323 exporters: Vec::new(),
324 }
325 }
326}
327
328#[derive(Debug, Clone, Serialize, Deserialize)]
330#[serde(tag = "type", rename_all = "lowercase")]
331pub enum ExporterConfig {
332 None,
334 Dev {
336 #[serde(default = "default_true")]
337 enabled: bool,
338 },
339 PostHog {
340 #[serde(default = "default_true")]
341 enabled: bool,
342 host: Option<String>,
344 project_api_key: Option<String>,
346 },
347 Datadog {
348 #[serde(default = "default_true")]
349 enabled: bool,
350 site: Option<String>,
352 api_key: Option<String>,
354 },
355 Otlp {
356 #[serde(default = "default_true")]
357 enabled: bool,
358 endpoint: Option<String>,
360 },
361}
362
363impl ExporterConfig {
364 pub fn is_enabled(&self) -> bool {
366 match self {
367 ExporterConfig::None => false,
368 ExporterConfig::Dev { enabled, .. } => *enabled,
369 ExporterConfig::PostHog { enabled, .. } => *enabled,
370 ExporterConfig::Datadog { enabled, .. } => *enabled,
371 ExporterConfig::Otlp { enabled, .. } => *enabled,
372 }
373 }
374}
375
376#[derive(Debug, Clone, Serialize, Deserialize, Default)]
377pub struct Config {
378 #[serde(default)]
379 pub scan: ScanConfig,
380 #[serde(default)]
381 pub sources: SourcesConfig,
382 #[serde(default)]
383 pub retention: RetentionConfig,
384 #[serde(default)]
385 pub sync: SyncConfig,
386 #[serde(default)]
387 pub telemetry: TelemetryConfig,
388 #[serde(default)]
389 pub proxy: ProxyConfig,
390}
391
392pub fn load(workspace: &Path) -> Result<Config> {
395 let workspace_path = workspace.join(".kaizen/config.toml");
396 let user_path = home_dir()?.join(".kaizen/config.toml");
397
398 let base = load_file(&workspace_path).unwrap_or_default();
399 let user = load_file(&user_path).unwrap_or_default();
400 Ok(merge(base, user))
401}
402
403fn home_dir() -> Result<std::path::PathBuf> {
404 std::env::var("HOME")
405 .map(std::path::PathBuf::from)
406 .map_err(|e| anyhow::anyhow!("HOME not set: {e}"))
407}
408
409fn load_file(path: &Path) -> Option<Config> {
410 let text = std::fs::read_to_string(path).ok()?;
411 toml::from_str(&text).ok()
412}
413
414fn merge(base: Config, user: Config) -> Config {
415 Config {
416 scan: merge_scan(base.scan, user.scan),
417 sources: user.sources,
418 retention: merge_retention(base.retention, user.retention),
419 sync: merge_sync(base.sync, user.sync),
420 telemetry: merge_telemetry(base.telemetry, user.telemetry),
421 proxy: merge_proxy(base.proxy, user.proxy),
422 }
423}
424
425fn merge_scan(base: ScanConfig, user: ScanConfig) -> ScanConfig {
426 let def = ScanConfig::default();
427 ScanConfig {
428 roots: if user.roots != def.roots {
429 user.roots
430 } else {
431 base.roots
432 },
433 min_rescan_seconds: if user.min_rescan_seconds != def.min_rescan_seconds {
434 user.min_rescan_seconds
435 } else {
436 base.min_rescan_seconds
437 },
438 }
439}
440
441fn merge_retention(base: RetentionConfig, user: RetentionConfig) -> RetentionConfig {
442 let def = RetentionConfig::default();
443 RetentionConfig {
444 hot_days: if user.hot_days != def.hot_days {
445 user.hot_days
446 } else {
447 base.hot_days
448 },
449 warm_days: if user.warm_days != def.warm_days {
450 user.warm_days
451 } else {
452 base.warm_days
453 },
454 }
455}
456
457fn merge_proxy(base: ProxyConfig, user: ProxyConfig) -> ProxyConfig {
458 let def = ProxyConfig::default();
459 ProxyConfig {
460 listen: if user.listen != def.listen {
461 user.listen
462 } else {
463 base.listen
464 },
465 upstream: if user.upstream != def.upstream {
466 user.upstream
467 } else {
468 base.upstream
469 },
470 compress_transport: if user.compress_transport != def.compress_transport {
471 user.compress_transport
472 } else {
473 base.compress_transport
474 },
475 minify_json: if user.minify_json != def.minify_json {
476 user.minify_json
477 } else {
478 base.minify_json
479 },
480 max_response_body_mb: if user.max_response_body_mb != def.max_response_body_mb {
481 user.max_response_body_mb
482 } else {
483 base.max_response_body_mb
484 },
485 max_request_body_mb: if user.max_request_body_mb != def.max_request_body_mb {
486 user.max_request_body_mb
487 } else {
488 base.max_request_body_mb
489 },
490 context_policy: if user.context_policy != def.context_policy {
491 user.context_policy
492 } else {
493 base.context_policy
494 },
495 }
496}
497
498fn merge_telemetry(base: TelemetryConfig, user: TelemetryConfig) -> TelemetryConfig {
499 let def = TelemetryConfig::default();
500 let fail_open = if user.fail_open != def.fail_open {
501 user.fail_open
502 } else {
503 base.fail_open
504 };
505 let query = merge_telemetry_query(base.query, user.query);
506 let exporters = if !user.exporters.is_empty() {
507 user.exporters
508 } else {
509 base.exporters
510 };
511 TelemetryConfig {
512 fail_open,
513 query,
514 exporters,
515 }
516}
517
518fn merge_telemetry_query(
519 base: TelemetryQueryConfig,
520 user: TelemetryQueryConfig,
521) -> TelemetryQueryConfig {
522 let def = TelemetryQueryConfig::default();
523 TelemetryQueryConfig {
524 provider: if user.provider != def.provider {
525 user.provider
526 } else {
527 base.provider
528 },
529 cache_ttl_seconds: if user.cache_ttl_seconds != def.cache_ttl_seconds {
530 user.cache_ttl_seconds
531 } else {
532 base.cache_ttl_seconds
533 },
534 identity_allowlist: merge_identity_allowlist(
535 base.identity_allowlist,
536 user.identity_allowlist,
537 ),
538 }
539}
540
541fn merge_identity_allowlist(base: IdentityAllowlist, user: IdentityAllowlist) -> IdentityAllowlist {
542 let def = IdentityAllowlist::default();
543 IdentityAllowlist {
544 team: if user.team != def.team {
545 user.team
546 } else {
547 base.team
548 },
549 workspace_label: if user.workspace_label != def.workspace_label {
550 user.workspace_label
551 } else {
552 base.workspace_label
553 },
554 runner_label: if user.runner_label != def.runner_label {
555 user.runner_label
556 } else {
557 base.runner_label
558 },
559 actor_kind: if user.actor_kind != def.actor_kind {
560 user.actor_kind
561 } else {
562 base.actor_kind
563 },
564 actor_label: if user.actor_label != def.actor_label {
565 user.actor_label
566 } else {
567 base.actor_label
568 },
569 agent: if user.agent != def.agent {
570 user.agent
571 } else {
572 base.agent
573 },
574 model: if user.model != def.model {
575 user.model
576 } else {
577 base.model
578 },
579 env: if user.env != def.env {
580 user.env
581 } else {
582 base.env
583 },
584 job: if user.job != def.job {
585 user.job
586 } else {
587 base.job
588 },
589 branch: if user.branch != def.branch {
590 user.branch
591 } else {
592 base.branch
593 },
594 }
595}
596
597fn merge_sync(base: SyncConfig, user: SyncConfig) -> SyncConfig {
598 let def = SyncConfig::default();
599 SyncConfig {
600 endpoint: if !user.endpoint.is_empty() {
601 user.endpoint
602 } else {
603 base.endpoint
604 },
605 team_token: if !user.team_token.is_empty() {
606 user.team_token
607 } else {
608 base.team_token
609 },
610 team_id: if !user.team_id.is_empty() {
611 user.team_id
612 } else {
613 base.team_id
614 },
615 events_per_batch_max: if user.events_per_batch_max != def.events_per_batch_max {
616 user.events_per_batch_max
617 } else {
618 base.events_per_batch_max
619 },
620 max_body_bytes: if user.max_body_bytes != def.max_body_bytes {
621 user.max_body_bytes
622 } else {
623 base.max_body_bytes
624 },
625 flush_interval_ms: if user.flush_interval_ms != def.flush_interval_ms {
626 user.flush_interval_ms
627 } else {
628 base.flush_interval_ms
629 },
630 sample_rate: if (user.sample_rate - def.sample_rate).abs() > f64::EPSILON {
631 user.sample_rate
632 } else {
633 base.sample_rate
634 },
635 team_salt_hex: if !user.team_salt_hex.is_empty() {
636 user.team_salt_hex
637 } else {
638 base.team_salt_hex
639 },
640 }
641}
642
643#[cfg(test)]
644mod tests {
645 use super::*;
646 use std::io::Write;
647 use tempfile::TempDir;
648
649 #[test]
650 fn defaults_when_no_files() {
651 let dir = TempDir::new().unwrap();
652 let cfg = load(dir.path()).unwrap();
653 assert_eq!(cfg.scan.roots, ScanConfig::default().roots);
654 assert_eq!(cfg.scan.min_rescan_seconds, 300);
655 assert_eq!(cfg.retention.hot_days, 30);
656 }
657
658 #[test]
659 fn workspace_config_loaded() {
660 let dir = TempDir::new().unwrap();
661 std::fs::create_dir_all(dir.path().join(".kaizen")).unwrap();
662 let mut f = std::fs::File::create(dir.path().join(".kaizen/config.toml")).unwrap();
663 writeln!(f, "[scan]\nroots = [\"/custom/root\"]").unwrap();
664
665 let cfg = load(dir.path()).unwrap();
666 assert_eq!(cfg.scan.roots, vec!["/custom/root"]);
667 }
668
669 #[test]
670 fn invalid_toml_ignored() {
671 let dir = TempDir::new().unwrap();
672 std::fs::create_dir_all(dir.path().join(".kaizen")).unwrap();
673 std::fs::write(dir.path().join(".kaizen/config.toml"), "not valid toml :::").unwrap();
674
675 let cfg = load(dir.path()).unwrap();
676 assert_eq!(cfg.scan.roots, ScanConfig::default().roots);
677 }
678
679 #[test]
680 fn merge_user_roots_win() {
681 let base = Config {
682 scan: ScanConfig {
683 roots: vec!["/base".to_string()],
684 ..ScanConfig::default()
685 },
686 ..Default::default()
687 };
688 let user = Config {
689 scan: ScanConfig {
690 roots: vec!["/user".to_string()],
691 ..ScanConfig::default()
692 },
693 ..Default::default()
694 };
695 let merged = merge(base, user);
696 assert_eq!(merged.scan.roots, vec!["/user"]);
697 }
698
699 #[test]
700 fn merge_retention_field_by_field() {
701 let base = Config {
702 retention: RetentionConfig {
703 hot_days: 60,
704 warm_days: 90,
705 },
706 ..Default::default()
707 };
708 let user = Config {
709 retention: RetentionConfig {
710 hot_days: 30,
711 warm_days: 45,
712 },
713 ..Default::default()
714 };
715 let merged = merge(base, user);
716 assert_eq!(merged.retention.hot_days, 60);
717 assert_eq!(merged.retention.warm_days, 45);
718 }
719
720 #[test]
721 fn merge_retention_user_hot_overrides() {
722 let base = Config {
723 retention: RetentionConfig {
724 hot_days: 60,
725 warm_days: 90,
726 },
727 ..Default::default()
728 };
729 let user = Config {
730 retention: RetentionConfig {
731 hot_days: 14,
732 warm_days: 90,
733 },
734 ..Default::default()
735 };
736 let merged = merge(base, user);
737 assert_eq!(merged.retention.hot_days, 14);
738 assert_eq!(merged.retention.warm_days, 90);
739 }
740
741 #[test]
742 fn merge_telemetry_exporters_user_wins_non_empty() {
743 let base = Config {
744 telemetry: TelemetryConfig {
745 fail_open: true,
746 query: TelemetryQueryConfig::default(),
747 exporters: vec![ExporterConfig::None],
748 },
749 ..Default::default()
750 };
751 let user = Config {
752 telemetry: TelemetryConfig {
753 fail_open: false,
754 query: TelemetryQueryConfig::default(),
755 exporters: vec![ExporterConfig::Dev { enabled: true }],
756 },
757 ..Default::default()
758 };
759 let merged = merge(base, user);
760 assert!(!merged.telemetry.fail_open);
761 assert_eq!(merged.telemetry.exporters.len(), 1);
762 }
763
764 #[test]
765 fn telemetry_query_defaults() {
766 let t = TelemetryQueryConfig::default();
767 assert_eq!(t.provider, QueryAuthority::None);
768 assert_eq!(t.cache_ttl_seconds, 3600);
769 assert!(!t.identity_allowlist.team);
770 assert!(!t.has_provider_for_pull());
771 }
772
773 #[test]
774 fn telemetry_query_has_provider() {
775 let ph = TelemetryQueryConfig {
776 provider: QueryAuthority::Posthog,
777 ..Default::default()
778 };
779 assert!(ph.has_provider_for_pull());
780 let dd = TelemetryQueryConfig {
781 provider: QueryAuthority::Datadog,
782 ..Default::default()
783 };
784 assert!(dd.has_provider_for_pull());
785 }
786
787 #[test]
788 fn merge_telemetry_query_user_wins() {
789 let base = Config {
790 telemetry: TelemetryConfig {
791 query: TelemetryQueryConfig {
792 provider: QueryAuthority::Posthog,
793 cache_ttl_seconds: 3600,
794 identity_allowlist: IdentityAllowlist {
795 team: true,
796 ..Default::default()
797 },
798 },
799 ..Default::default()
800 },
801 ..Default::default()
802 };
803 let user = Config {
804 telemetry: TelemetryConfig {
805 query: TelemetryQueryConfig {
806 cache_ttl_seconds: 7200,
807 ..Default::default()
808 },
809 ..Default::default()
810 },
811 ..Default::default()
812 };
813 let merged = merge(base, user);
814 assert_eq!(merged.telemetry.query.provider, QueryAuthority::Posthog);
815 assert_eq!(merged.telemetry.query.cache_ttl_seconds, 7200);
816 assert!(merged.telemetry.query.identity_allowlist.team);
817 }
818
819 #[test]
820 fn toml_telemetry_query_roundtrip() {
821 let dir = TempDir::new().unwrap();
822 std::fs::create_dir_all(dir.path().join(".kaizen")).unwrap();
823 let toml = r#"
824[telemetry.query]
825provider = "datadog"
826cache_ttl_seconds = 1800
827
828[telemetry.query.identity_allowlist]
829team = true
830branch = true
831"#;
832 std::fs::write(dir.path().join(".kaizen/config.toml"), toml).unwrap();
833 let cfg = load(dir.path()).unwrap();
834 assert_eq!(cfg.telemetry.query.provider, QueryAuthority::Datadog);
835 assert_eq!(cfg.telemetry.query.cache_ttl_seconds, 1800);
836 assert!(cfg.telemetry.query.identity_allowlist.team);
837 assert!(cfg.telemetry.query.identity_allowlist.branch);
838 assert!(!cfg.telemetry.query.identity_allowlist.model);
839 }
840}