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)]
377pub struct EvalConfig {
378 #[serde(default)]
379 pub enabled: bool,
380 #[serde(default = "default_eval_endpoint")]
381 pub endpoint: String,
382 #[serde(default)]
383 pub api_key: String,
384 #[serde(default = "default_eval_model")]
385 pub model: String,
386 #[serde(default = "default_eval_rubric")]
387 pub rubric: String,
388 #[serde(default = "default_eval_batch_size")]
389 pub batch_size: usize,
390 #[serde(default = "default_eval_min_cost")]
391 pub min_cost_usd: f64,
392}
393
394impl Default for EvalConfig {
395 fn default() -> Self {
396 Self {
397 enabled: false,
398 endpoint: default_eval_endpoint(),
399 api_key: String::new(),
400 model: default_eval_model(),
401 rubric: default_eval_rubric(),
402 batch_size: default_eval_batch_size(),
403 min_cost_usd: default_eval_min_cost(),
404 }
405 }
406}
407
408fn default_eval_endpoint() -> String {
409 "https://api.anthropic.com".into()
410}
411fn default_eval_model() -> String {
412 "claude-haiku-4-5-20251001".into()
413}
414fn default_eval_rubric() -> String {
415 "tool-efficiency-v1".into()
416}
417fn default_eval_batch_size() -> usize {
418 20
419}
420fn default_eval_min_cost() -> f64 {
421 0.01
422}
423
424#[derive(Debug, Clone, Serialize, Deserialize, Default)]
425pub struct Config {
426 #[serde(default)]
427 pub scan: ScanConfig,
428 #[serde(default)]
429 pub sources: SourcesConfig,
430 #[serde(default)]
431 pub retention: RetentionConfig,
432 #[serde(default)]
433 pub sync: SyncConfig,
434 #[serde(default)]
435 pub telemetry: TelemetryConfig,
436 #[serde(default)]
437 pub proxy: ProxyConfig,
438 #[serde(default)]
439 pub eval: EvalConfig,
440}
441
442pub fn load(workspace: &Path) -> Result<Config> {
445 let workspace_path = workspace.join(".kaizen/config.toml");
446 let user_path = home_dir()?.join(".kaizen/config.toml");
447
448 let base = load_file(&workspace_path).unwrap_or_default();
449 let user = load_file(&user_path).unwrap_or_default();
450 Ok(merge(base, user))
451}
452
453fn home_dir() -> Result<std::path::PathBuf> {
454 std::env::var("HOME")
455 .map(std::path::PathBuf::from)
456 .map_err(|e| anyhow::anyhow!("HOME not set: {e}"))
457}
458
459fn load_file(path: &Path) -> Option<Config> {
460 let text = std::fs::read_to_string(path).ok()?;
461 toml::from_str(&text).ok()
462}
463
464fn merge(base: Config, user: Config) -> Config {
465 Config {
466 scan: merge_scan(base.scan, user.scan),
467 sources: user.sources,
468 retention: merge_retention(base.retention, user.retention),
469 sync: merge_sync(base.sync, user.sync),
470 telemetry: merge_telemetry(base.telemetry, user.telemetry),
471 proxy: merge_proxy(base.proxy, user.proxy),
472 eval: merge_eval(base.eval, user.eval),
473 }
474}
475
476fn merge_eval(base: EvalConfig, user: EvalConfig) -> EvalConfig {
477 let def = EvalConfig::default();
478 EvalConfig {
479 enabled: if user.enabled != def.enabled {
480 user.enabled
481 } else {
482 base.enabled
483 },
484 endpoint: if user.endpoint != def.endpoint {
485 user.endpoint
486 } else {
487 base.endpoint
488 },
489 api_key: if !user.api_key.is_empty() {
490 user.api_key
491 } else {
492 base.api_key
493 },
494 model: if user.model != def.model {
495 user.model
496 } else {
497 base.model
498 },
499 rubric: if user.rubric != def.rubric {
500 user.rubric
501 } else {
502 base.rubric
503 },
504 batch_size: if user.batch_size != def.batch_size {
505 user.batch_size
506 } else {
507 base.batch_size
508 },
509 min_cost_usd: if user.min_cost_usd != def.min_cost_usd {
510 user.min_cost_usd
511 } else {
512 base.min_cost_usd
513 },
514 }
515}
516
517fn merge_scan(base: ScanConfig, user: ScanConfig) -> ScanConfig {
518 let def = ScanConfig::default();
519 ScanConfig {
520 roots: if user.roots != def.roots {
521 user.roots
522 } else {
523 base.roots
524 },
525 min_rescan_seconds: if user.min_rescan_seconds != def.min_rescan_seconds {
526 user.min_rescan_seconds
527 } else {
528 base.min_rescan_seconds
529 },
530 }
531}
532
533fn merge_retention(base: RetentionConfig, user: RetentionConfig) -> RetentionConfig {
534 let def = RetentionConfig::default();
535 RetentionConfig {
536 hot_days: if user.hot_days != def.hot_days {
537 user.hot_days
538 } else {
539 base.hot_days
540 },
541 warm_days: if user.warm_days != def.warm_days {
542 user.warm_days
543 } else {
544 base.warm_days
545 },
546 }
547}
548
549fn merge_proxy(base: ProxyConfig, user: ProxyConfig) -> ProxyConfig {
550 let def = ProxyConfig::default();
551 ProxyConfig {
552 listen: if user.listen != def.listen {
553 user.listen
554 } else {
555 base.listen
556 },
557 upstream: if user.upstream != def.upstream {
558 user.upstream
559 } else {
560 base.upstream
561 },
562 compress_transport: if user.compress_transport != def.compress_transport {
563 user.compress_transport
564 } else {
565 base.compress_transport
566 },
567 minify_json: if user.minify_json != def.minify_json {
568 user.minify_json
569 } else {
570 base.minify_json
571 },
572 max_response_body_mb: if user.max_response_body_mb != def.max_response_body_mb {
573 user.max_response_body_mb
574 } else {
575 base.max_response_body_mb
576 },
577 max_request_body_mb: if user.max_request_body_mb != def.max_request_body_mb {
578 user.max_request_body_mb
579 } else {
580 base.max_request_body_mb
581 },
582 context_policy: if user.context_policy != def.context_policy {
583 user.context_policy
584 } else {
585 base.context_policy
586 },
587 }
588}
589
590fn merge_telemetry(base: TelemetryConfig, user: TelemetryConfig) -> TelemetryConfig {
591 let def = TelemetryConfig::default();
592 let fail_open = if user.fail_open != def.fail_open {
593 user.fail_open
594 } else {
595 base.fail_open
596 };
597 let query = merge_telemetry_query(base.query, user.query);
598 let exporters = if !user.exporters.is_empty() {
599 user.exporters
600 } else {
601 base.exporters
602 };
603 TelemetryConfig {
604 fail_open,
605 query,
606 exporters,
607 }
608}
609
610fn merge_telemetry_query(
611 base: TelemetryQueryConfig,
612 user: TelemetryQueryConfig,
613) -> TelemetryQueryConfig {
614 let def = TelemetryQueryConfig::default();
615 TelemetryQueryConfig {
616 provider: if user.provider != def.provider {
617 user.provider
618 } else {
619 base.provider
620 },
621 cache_ttl_seconds: if user.cache_ttl_seconds != def.cache_ttl_seconds {
622 user.cache_ttl_seconds
623 } else {
624 base.cache_ttl_seconds
625 },
626 identity_allowlist: merge_identity_allowlist(
627 base.identity_allowlist,
628 user.identity_allowlist,
629 ),
630 }
631}
632
633fn merge_identity_allowlist(base: IdentityAllowlist, user: IdentityAllowlist) -> IdentityAllowlist {
634 let def = IdentityAllowlist::default();
635 IdentityAllowlist {
636 team: if user.team != def.team {
637 user.team
638 } else {
639 base.team
640 },
641 workspace_label: if user.workspace_label != def.workspace_label {
642 user.workspace_label
643 } else {
644 base.workspace_label
645 },
646 runner_label: if user.runner_label != def.runner_label {
647 user.runner_label
648 } else {
649 base.runner_label
650 },
651 actor_kind: if user.actor_kind != def.actor_kind {
652 user.actor_kind
653 } else {
654 base.actor_kind
655 },
656 actor_label: if user.actor_label != def.actor_label {
657 user.actor_label
658 } else {
659 base.actor_label
660 },
661 agent: if user.agent != def.agent {
662 user.agent
663 } else {
664 base.agent
665 },
666 model: if user.model != def.model {
667 user.model
668 } else {
669 base.model
670 },
671 env: if user.env != def.env {
672 user.env
673 } else {
674 base.env
675 },
676 job: if user.job != def.job {
677 user.job
678 } else {
679 base.job
680 },
681 branch: if user.branch != def.branch {
682 user.branch
683 } else {
684 base.branch
685 },
686 }
687}
688
689fn merge_sync(base: SyncConfig, user: SyncConfig) -> SyncConfig {
690 let def = SyncConfig::default();
691 SyncConfig {
692 endpoint: if !user.endpoint.is_empty() {
693 user.endpoint
694 } else {
695 base.endpoint
696 },
697 team_token: if !user.team_token.is_empty() {
698 user.team_token
699 } else {
700 base.team_token
701 },
702 team_id: if !user.team_id.is_empty() {
703 user.team_id
704 } else {
705 base.team_id
706 },
707 events_per_batch_max: if user.events_per_batch_max != def.events_per_batch_max {
708 user.events_per_batch_max
709 } else {
710 base.events_per_batch_max
711 },
712 max_body_bytes: if user.max_body_bytes != def.max_body_bytes {
713 user.max_body_bytes
714 } else {
715 base.max_body_bytes
716 },
717 flush_interval_ms: if user.flush_interval_ms != def.flush_interval_ms {
718 user.flush_interval_ms
719 } else {
720 base.flush_interval_ms
721 },
722 sample_rate: if (user.sample_rate - def.sample_rate).abs() > f64::EPSILON {
723 user.sample_rate
724 } else {
725 base.sample_rate
726 },
727 team_salt_hex: if !user.team_salt_hex.is_empty() {
728 user.team_salt_hex
729 } else {
730 base.team_salt_hex
731 },
732 }
733}
734
735#[cfg(test)]
736mod tests {
737 use super::*;
738 use std::io::Write;
739 use tempfile::TempDir;
740
741 #[test]
742 fn defaults_when_no_files() {
743 let dir = TempDir::new().unwrap();
744 let cfg = load(dir.path()).unwrap();
745 assert_eq!(cfg.scan.roots, ScanConfig::default().roots);
746 assert_eq!(cfg.scan.min_rescan_seconds, 300);
747 assert_eq!(cfg.retention.hot_days, 30);
748 }
749
750 #[test]
751 fn workspace_config_loaded() {
752 let dir = TempDir::new().unwrap();
753 std::fs::create_dir_all(dir.path().join(".kaizen")).unwrap();
754 let mut f = std::fs::File::create(dir.path().join(".kaizen/config.toml")).unwrap();
755 writeln!(f, "[scan]\nroots = [\"/custom/root\"]").unwrap();
756
757 let cfg = load(dir.path()).unwrap();
758 assert_eq!(cfg.scan.roots, vec!["/custom/root"]);
759 }
760
761 #[test]
762 fn invalid_toml_ignored() {
763 let dir = TempDir::new().unwrap();
764 std::fs::create_dir_all(dir.path().join(".kaizen")).unwrap();
765 std::fs::write(dir.path().join(".kaizen/config.toml"), "not valid toml :::").unwrap();
766
767 let cfg = load(dir.path()).unwrap();
768 assert_eq!(cfg.scan.roots, ScanConfig::default().roots);
769 }
770
771 #[test]
772 fn merge_user_roots_win() {
773 let base = Config {
774 scan: ScanConfig {
775 roots: vec!["/base".to_string()],
776 ..ScanConfig::default()
777 },
778 ..Default::default()
779 };
780 let user = Config {
781 scan: ScanConfig {
782 roots: vec!["/user".to_string()],
783 ..ScanConfig::default()
784 },
785 ..Default::default()
786 };
787 let merged = merge(base, user);
788 assert_eq!(merged.scan.roots, vec!["/user"]);
789 }
790
791 #[test]
792 fn merge_retention_field_by_field() {
793 let base = Config {
794 retention: RetentionConfig {
795 hot_days: 60,
796 warm_days: 90,
797 },
798 ..Default::default()
799 };
800 let user = Config {
801 retention: RetentionConfig {
802 hot_days: 30,
803 warm_days: 45,
804 },
805 ..Default::default()
806 };
807 let merged = merge(base, user);
808 assert_eq!(merged.retention.hot_days, 60);
809 assert_eq!(merged.retention.warm_days, 45);
810 }
811
812 #[test]
813 fn merge_retention_user_hot_overrides() {
814 let base = Config {
815 retention: RetentionConfig {
816 hot_days: 60,
817 warm_days: 90,
818 },
819 ..Default::default()
820 };
821 let user = Config {
822 retention: RetentionConfig {
823 hot_days: 14,
824 warm_days: 90,
825 },
826 ..Default::default()
827 };
828 let merged = merge(base, user);
829 assert_eq!(merged.retention.hot_days, 14);
830 assert_eq!(merged.retention.warm_days, 90);
831 }
832
833 #[test]
834 fn merge_telemetry_exporters_user_wins_non_empty() {
835 let base = Config {
836 telemetry: TelemetryConfig {
837 fail_open: true,
838 query: TelemetryQueryConfig::default(),
839 exporters: vec![ExporterConfig::None],
840 },
841 ..Default::default()
842 };
843 let user = Config {
844 telemetry: TelemetryConfig {
845 fail_open: false,
846 query: TelemetryQueryConfig::default(),
847 exporters: vec![ExporterConfig::Dev { enabled: true }],
848 },
849 ..Default::default()
850 };
851 let merged = merge(base, user);
852 assert!(!merged.telemetry.fail_open);
853 assert_eq!(merged.telemetry.exporters.len(), 1);
854 }
855
856 #[test]
857 fn telemetry_query_defaults() {
858 let t = TelemetryQueryConfig::default();
859 assert_eq!(t.provider, QueryAuthority::None);
860 assert_eq!(t.cache_ttl_seconds, 3600);
861 assert!(!t.identity_allowlist.team);
862 assert!(!t.has_provider_for_pull());
863 }
864
865 #[test]
866 fn telemetry_query_has_provider() {
867 let ph = TelemetryQueryConfig {
868 provider: QueryAuthority::Posthog,
869 ..Default::default()
870 };
871 assert!(ph.has_provider_for_pull());
872 let dd = TelemetryQueryConfig {
873 provider: QueryAuthority::Datadog,
874 ..Default::default()
875 };
876 assert!(dd.has_provider_for_pull());
877 }
878
879 #[test]
880 fn merge_telemetry_query_user_wins() {
881 let base = Config {
882 telemetry: TelemetryConfig {
883 query: TelemetryQueryConfig {
884 provider: QueryAuthority::Posthog,
885 cache_ttl_seconds: 3600,
886 identity_allowlist: IdentityAllowlist {
887 team: true,
888 ..Default::default()
889 },
890 },
891 ..Default::default()
892 },
893 ..Default::default()
894 };
895 let user = Config {
896 telemetry: TelemetryConfig {
897 query: TelemetryQueryConfig {
898 cache_ttl_seconds: 7200,
899 ..Default::default()
900 },
901 ..Default::default()
902 },
903 ..Default::default()
904 };
905 let merged = merge(base, user);
906 assert_eq!(merged.telemetry.query.provider, QueryAuthority::Posthog);
907 assert_eq!(merged.telemetry.query.cache_ttl_seconds, 7200);
908 assert!(merged.telemetry.query.identity_allowlist.team);
909 }
910
911 #[test]
912 fn toml_telemetry_query_roundtrip() {
913 let dir = TempDir::new().unwrap();
914 std::fs::create_dir_all(dir.path().join(".kaizen")).unwrap();
915 let toml = r#"
916[telemetry.query]
917provider = "datadog"
918cache_ttl_seconds = 1800
919
920[telemetry.query.identity_allowlist]
921team = true
922branch = true
923"#;
924 std::fs::write(dir.path().join(".kaizen/config.toml"), toml).unwrap();
925 let cfg = load(dir.path()).unwrap();
926 assert_eq!(cfg.telemetry.query.provider, QueryAuthority::Datadog);
927 assert_eq!(cfg.telemetry.query.cache_ttl_seconds, 1800);
928 assert!(cfg.telemetry.query.identity_allowlist.team);
929 assert!(cfg.telemetry.query.identity_allowlist.branch);
930 assert!(!cfg.telemetry.query.identity_allowlist.model);
931 }
932}