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