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, PartialEq, Eq)]
96pub struct StorageConfig {
97 pub hot_max_bytes: String,
98 pub cold_after_days: u32,
99 pub retention_days: u32,
100 pub flush_hour_utc: u8,
101}
102
103impl Default for StorageConfig {
104 fn default() -> Self {
105 Self {
106 hot_max_bytes: "1GB".into(),
107 cold_after_days: 7,
108 retention_days: 90,
109 flush_hour_utc: 0,
110 }
111 }
112}
113
114impl StorageConfig {
115 pub fn hot_max_bytes_value(&self) -> u64 {
116 parse_byte_size(&self.hot_max_bytes).unwrap_or(1_073_741_824)
117 }
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize)]
121pub struct SyncConfig {
122 #[serde(default)]
124 pub endpoint: String,
125 #[serde(default)]
126 pub team_token: String,
127 #[serde(default)]
128 pub team_id: String,
129 #[serde(default = "default_events_per_batch")]
130 pub events_per_batch_max: usize,
131 #[serde(default = "default_max_body_bytes")]
132 pub max_body_bytes: usize,
133 #[serde(default = "default_flush_interval_ms")]
134 pub flush_interval_ms: u64,
135 #[serde(default = "default_sample_rate")]
136 pub sample_rate: f64,
137 #[serde(default)]
139 pub team_salt_hex: String,
140}
141
142fn default_events_per_batch() -> usize {
143 500
144}
145
146fn default_max_body_bytes() -> usize {
147 1_000_000
148}
149
150fn default_flush_interval_ms() -> u64 {
151 10_000
152}
153
154fn default_sample_rate() -> f64 {
155 1.0
156}
157
158impl Default for SyncConfig {
159 fn default() -> Self {
160 Self {
161 endpoint: String::new(),
162 team_token: String::new(),
163 team_id: String::new(),
164 events_per_batch_max: default_events_per_batch(),
165 max_body_bytes: default_max_body_bytes(),
166 flush_interval_ms: default_flush_interval_ms(),
167 sample_rate: default_sample_rate(),
168 team_salt_hex: String::new(),
169 }
170 }
171}
172
173pub fn try_team_salt(cfg: &SyncConfig) -> Option<[u8; 32]> {
175 let h = cfg.team_salt_hex.trim();
176 if h.len() != 64 {
177 return None;
178 }
179 let bytes = hex::decode(h).ok()?;
180 bytes.try_into().ok()
181}
182
183fn default_true() -> bool {
184 true
185}
186
187fn default_telemetry_fail_open() -> bool {
188 true
189}
190
191fn default_cache_ttl_seconds() -> u64 {
192 3600
193}
194
195#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
197#[serde(rename_all = "lowercase")]
198pub enum QueryAuthority {
199 #[default]
200 None,
201 Posthog,
202 Datadog,
203}
204
205#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
207pub struct IdentityAllowlist {
208 #[serde(default)]
209 pub team: bool,
210 #[serde(default)]
211 pub workspace_label: bool,
212 #[serde(default)]
213 pub runner_label: bool,
214 #[serde(default)]
215 pub actor_kind: bool,
216 #[serde(default)]
217 pub actor_label: bool,
218 #[serde(default)]
219 pub agent: bool,
220 #[serde(default)]
221 pub model: bool,
222 #[serde(default)]
223 pub env: bool,
224 #[serde(default)]
225 pub job: bool,
226 #[serde(default)]
227 pub branch: bool,
228}
229
230#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
232pub struct TelemetryQueryConfig {
233 #[serde(default)]
235 pub provider: QueryAuthority,
236 #[serde(default = "default_cache_ttl_seconds")]
238 pub cache_ttl_seconds: u64,
239 #[serde(default)]
240 pub identity_allowlist: IdentityAllowlist,
241}
242
243impl Default for TelemetryQueryConfig {
244 fn default() -> Self {
245 Self {
246 provider: QueryAuthority::default(),
247 cache_ttl_seconds: default_cache_ttl_seconds(),
248 identity_allowlist: IdentityAllowlist::default(),
249 }
250 }
251}
252
253impl TelemetryQueryConfig {
254 pub fn has_provider_for_pull(&self) -> bool {
256 matches!(
257 self.provider,
258 QueryAuthority::Posthog | QueryAuthority::Datadog
259 )
260 }
261}
262
263#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
265#[serde(tag = "type", rename_all = "snake_case")]
266pub enum ContextPolicy {
267 #[default]
269 None,
270 LastMessages { count: usize },
272 MaxInputTokens { max: u32 },
274}
275
276#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
278pub struct ProxyConfig {
279 #[serde(default = "default_proxy_listen")]
281 pub listen: String,
282 #[serde(default = "default_proxy_upstream")]
284 pub upstream: String,
285 #[serde(default = "default_true")]
287 pub compress_transport: bool,
288 #[serde(default = "default_true")]
290 pub minify_json: bool,
291 #[serde(default = "default_proxy_max_body_mb")]
293 pub max_response_body_mb: u32,
294 #[serde(default = "default_proxy_max_request_body_mb")]
296 pub max_request_body_mb: u32,
297 #[serde(default)]
299 pub context_policy: ContextPolicy,
300}
301
302fn default_proxy_listen() -> String {
303 "127.0.0.1:3847".to_string()
304}
305
306fn default_proxy_upstream() -> String {
307 "https://api.anthropic.com".to_string()
308}
309
310fn default_proxy_max_body_mb() -> u32 {
311 256
312}
313
314fn default_proxy_max_request_body_mb() -> u32 {
315 32
316}
317
318impl Default for ProxyConfig {
319 fn default() -> Self {
320 Self {
321 listen: default_proxy_listen(),
322 upstream: default_proxy_upstream(),
323 compress_transport: true,
324 minify_json: true,
325 max_response_body_mb: default_proxy_max_body_mb(),
326 max_request_body_mb: default_proxy_max_request_body_mb(),
327 context_policy: ContextPolicy::default(),
328 }
329 }
330}
331
332#[derive(Debug, Clone, Serialize, Deserialize)]
334pub struct TelemetryConfig {
335 #[serde(default = "default_telemetry_fail_open")]
337 pub fail_open: bool,
338 #[serde(default)]
340 pub query: TelemetryQueryConfig,
341 #[serde(default)]
343 pub exporters: Vec<ExporterConfig>,
344}
345
346impl Default for TelemetryConfig {
347 fn default() -> Self {
348 Self {
349 fail_open: default_telemetry_fail_open(),
350 query: TelemetryQueryConfig::default(),
351 exporters: Vec::new(),
352 }
353 }
354}
355
356#[derive(Debug, Clone, Serialize, Deserialize)]
358#[serde(tag = "type", rename_all = "lowercase")]
359pub enum ExporterConfig {
360 None,
362 File {
364 #[serde(default = "default_true")]
365 enabled: bool,
366 #[serde(default)]
367 path: Option<String>,
368 },
369 Dev {
371 #[serde(default = "default_true")]
372 enabled: bool,
373 },
374 PostHog {
375 #[serde(default = "default_true")]
376 enabled: bool,
377 host: Option<String>,
379 project_api_key: Option<String>,
381 },
382 Datadog {
383 #[serde(default = "default_true")]
384 enabled: bool,
385 site: Option<String>,
387 api_key: Option<String>,
389 },
390 Otlp {
391 #[serde(default = "default_true")]
392 enabled: bool,
393 endpoint: Option<String>,
395 },
396}
397
398impl ExporterConfig {
399 pub fn is_enabled(&self) -> bool {
401 match self {
402 ExporterConfig::None => false,
403 ExporterConfig::File { enabled, .. } => *enabled,
404 ExporterConfig::Dev { enabled, .. } => *enabled,
405 ExporterConfig::PostHog { enabled, .. } => *enabled,
406 ExporterConfig::Datadog { enabled, .. } => *enabled,
407 ExporterConfig::Otlp { enabled, .. } => *enabled,
408 }
409 }
410}
411
412#[derive(Debug, Clone, Serialize, Deserialize)]
413pub struct EvalConfig {
414 #[serde(default)]
415 pub enabled: bool,
416 #[serde(default = "default_eval_endpoint")]
417 pub endpoint: String,
418 #[serde(default)]
419 pub api_key: String,
420 #[serde(default = "default_eval_model")]
421 pub model: String,
422 #[serde(default = "default_eval_rubric")]
423 pub rubric: String,
424 #[serde(default = "default_eval_batch_size")]
425 pub batch_size: usize,
426 #[serde(default = "default_eval_min_cost")]
427 pub min_cost_usd: f64,
428}
429
430impl Default for EvalConfig {
431 fn default() -> Self {
432 Self {
433 enabled: false,
434 endpoint: default_eval_endpoint(),
435 api_key: String::new(),
436 model: default_eval_model(),
437 rubric: default_eval_rubric(),
438 batch_size: default_eval_batch_size(),
439 min_cost_usd: default_eval_min_cost(),
440 }
441 }
442}
443
444fn default_eval_endpoint() -> String {
445 "https://api.anthropic.com".into()
446}
447fn default_eval_model() -> String {
448 "claude-haiku-4-5-20251001".into()
449}
450fn default_eval_rubric() -> String {
451 "tool-efficiency-v1".into()
452}
453fn default_eval_batch_size() -> usize {
454 20
455}
456fn default_eval_min_cost() -> f64 {
457 0.01
458}
459
460#[derive(Debug, Clone, Serialize, Deserialize)]
462pub struct CollectOutcomesConfig {
463 #[serde(default)]
464 pub enabled: bool,
465 #[serde(default = "default_outcomes_test_cmd")]
466 pub test_cmd: String,
467 #[serde(default = "default_outcomes_timeout_secs")]
468 pub timeout_secs: u64,
469 #[serde(default)]
470 pub lint_cmd: Option<String>,
471}
472
473fn default_outcomes_test_cmd() -> String {
474 "cargo test --quiet".to_string()
475}
476
477fn default_outcomes_timeout_secs() -> u64 {
478 600
479}
480
481impl Default for CollectOutcomesConfig {
482 fn default() -> Self {
483 Self {
484 enabled: false,
485 test_cmd: default_outcomes_test_cmd(),
486 timeout_secs: default_outcomes_timeout_secs(),
487 lint_cmd: None,
488 }
489 }
490}
491
492#[derive(Debug, Clone, Serialize, Deserialize)]
494pub struct CollectSystemSamplerConfig {
495 #[serde(default)]
496 pub enabled: bool,
497 #[serde(default = "default_sampler_sample_ms")]
498 pub sample_ms: u64,
499 #[serde(default = "default_sampler_max_samples")]
500 pub max_samples_per_session: u32,
501}
502
503fn default_sampler_sample_ms() -> u64 {
504 2000
505}
506
507fn default_sampler_max_samples() -> u32 {
508 3600
509}
510
511impl Default for CollectSystemSamplerConfig {
512 fn default() -> Self {
513 Self {
514 enabled: false,
515 sample_ms: default_sampler_sample_ms(),
516 max_samples_per_session: default_sampler_max_samples(),
517 }
518 }
519}
520
521#[derive(Debug, Clone, Serialize, Deserialize, Default)]
522pub struct CollectConfig {
523 #[serde(default)]
524 pub outcomes: CollectOutcomesConfig,
525 #[serde(default)]
526 pub system_sampler: CollectSystemSamplerConfig,
527}
528
529#[derive(Debug, Clone, Serialize, Deserialize, Default)]
530pub struct Config {
531 #[serde(default)]
532 pub scan: ScanConfig,
533 #[serde(default)]
534 pub sources: SourcesConfig,
535 #[serde(default)]
536 pub retention: RetentionConfig,
537 #[serde(default)]
538 pub storage: StorageConfig,
539 #[serde(default)]
540 pub sync: SyncConfig,
541 #[serde(default)]
542 pub telemetry: TelemetryConfig,
543 #[serde(default)]
544 pub proxy: ProxyConfig,
545 #[serde(default)]
546 pub eval: EvalConfig,
547 #[serde(default)]
548 pub collect: CollectConfig,
549}
550
551pub fn load(workspace: &Path) -> Result<Config> {
554 let workspace_path = workspace.join(".kaizen/config.toml");
555 let user_path = home_dir()?.join(".kaizen/config.toml");
556
557 let base = load_file(&workspace_path).unwrap_or_default();
558 let user = load_file(&user_path).unwrap_or_default();
559 Ok(merge(base, user))
560}
561
562fn home_dir() -> Result<std::path::PathBuf> {
563 std::env::var("HOME")
564 .map(std::path::PathBuf::from)
565 .map_err(|e| anyhow::anyhow!("HOME not set: {e}"))
566}
567
568fn load_file(path: &Path) -> Option<Config> {
569 let text = std::fs::read_to_string(path).ok()?;
570 toml::from_str(&text).ok()
571}
572
573fn merge(base: Config, user: Config) -> Config {
574 Config {
575 scan: merge_scan(base.scan, user.scan),
576 sources: merge_sources(base.sources, user.sources),
577 retention: merge_retention(base.retention, user.retention),
578 storage: merge_storage(base.storage, user.storage),
579 sync: merge_sync(base.sync, user.sync),
580 telemetry: merge_telemetry(base.telemetry, user.telemetry),
581 proxy: merge_proxy(base.proxy, user.proxy),
582 eval: merge_eval(base.eval, user.eval),
583 collect: merge_collect(base.collect, user.collect),
584 }
585}
586
587fn merge_collect(base: CollectConfig, user: CollectConfig) -> CollectConfig {
588 let def = CollectConfig::default();
589 CollectConfig {
590 outcomes: merge_collect_outcomes(base.outcomes, user.outcomes, def.outcomes),
591 system_sampler: merge_collect_sampler(
592 base.system_sampler,
593 user.system_sampler,
594 def.system_sampler,
595 ),
596 }
597}
598
599fn merge_collect_outcomes(
600 base: CollectOutcomesConfig,
601 user: CollectOutcomesConfig,
602 def: CollectOutcomesConfig,
603) -> CollectOutcomesConfig {
604 CollectOutcomesConfig {
605 enabled: if user.enabled != def.enabled {
606 user.enabled
607 } else {
608 base.enabled
609 },
610 test_cmd: if user.test_cmd != def.test_cmd {
611 user.test_cmd
612 } else {
613 base.test_cmd
614 },
615 timeout_secs: if user.timeout_secs != def.timeout_secs {
616 user.timeout_secs
617 } else {
618 base.timeout_secs
619 },
620 lint_cmd: user.lint_cmd.or(base.lint_cmd),
621 }
622}
623
624fn merge_collect_sampler(
625 base: CollectSystemSamplerConfig,
626 user: CollectSystemSamplerConfig,
627 def: CollectSystemSamplerConfig,
628) -> CollectSystemSamplerConfig {
629 CollectSystemSamplerConfig {
630 enabled: if user.enabled != def.enabled {
631 user.enabled
632 } else {
633 base.enabled
634 },
635 sample_ms: if user.sample_ms != def.sample_ms {
636 user.sample_ms
637 } else {
638 base.sample_ms
639 },
640 max_samples_per_session: if user.max_samples_per_session != def.max_samples_per_session {
641 user.max_samples_per_session
642 } else {
643 base.max_samples_per_session
644 },
645 }
646}
647
648fn merge_sources(base: SourcesConfig, user: SourcesConfig) -> SourcesConfig {
649 let def = SourcesConfig::default();
650 SourcesConfig {
651 cursor: merge_cursor_source(base.cursor, user.cursor, def.cursor),
652 tail: merge_tail_toggles(base.tail, user.tail, def.tail),
653 }
654}
655
656fn merge_cursor_source(
657 base: CursorSourceConfig,
658 user: CursorSourceConfig,
659 def: CursorSourceConfig,
660) -> CursorSourceConfig {
661 CursorSourceConfig {
662 enabled: if user.enabled != def.enabled {
663 user.enabled
664 } else {
665 base.enabled
666 },
667 transcript_glob: if user.transcript_glob != def.transcript_glob {
668 user.transcript_glob
669 } else {
670 base.transcript_glob
671 },
672 }
673}
674
675fn merge_tail_toggles(
676 base: TailAgentToggles,
677 user: TailAgentToggles,
678 def: TailAgentToggles,
679) -> TailAgentToggles {
680 TailAgentToggles {
681 goose: if user.goose != def.goose {
682 user.goose
683 } else {
684 base.goose
685 },
686 openclaw: if user.openclaw != def.openclaw {
687 user.openclaw
688 } else {
689 base.openclaw
690 },
691 opencode: if user.opencode != def.opencode {
692 user.opencode
693 } else {
694 base.opencode
695 },
696 copilot_cli: if user.copilot_cli != def.copilot_cli {
697 user.copilot_cli
698 } else {
699 base.copilot_cli
700 },
701 copilot_vscode: if user.copilot_vscode != def.copilot_vscode {
702 user.copilot_vscode
703 } else {
704 base.copilot_vscode
705 },
706 }
707}
708
709fn merge_eval(base: EvalConfig, user: EvalConfig) -> EvalConfig {
710 let def = EvalConfig::default();
711 EvalConfig {
712 enabled: if user.enabled != def.enabled {
713 user.enabled
714 } else {
715 base.enabled
716 },
717 endpoint: if user.endpoint != def.endpoint {
718 user.endpoint
719 } else {
720 base.endpoint
721 },
722 api_key: if !user.api_key.is_empty() {
723 user.api_key
724 } else {
725 base.api_key
726 },
727 model: if user.model != def.model {
728 user.model
729 } else {
730 base.model
731 },
732 rubric: if user.rubric != def.rubric {
733 user.rubric
734 } else {
735 base.rubric
736 },
737 batch_size: if user.batch_size != def.batch_size {
738 user.batch_size
739 } else {
740 base.batch_size
741 },
742 min_cost_usd: if user.min_cost_usd != def.min_cost_usd {
743 user.min_cost_usd
744 } else {
745 base.min_cost_usd
746 },
747 }
748}
749
750fn merge_scan(base: ScanConfig, user: ScanConfig) -> ScanConfig {
751 let def = ScanConfig::default();
752 ScanConfig {
753 roots: if user.roots != def.roots {
754 user.roots
755 } else {
756 base.roots
757 },
758 min_rescan_seconds: if user.min_rescan_seconds != def.min_rescan_seconds {
759 user.min_rescan_seconds
760 } else {
761 base.min_rescan_seconds
762 },
763 }
764}
765
766fn merge_retention(base: RetentionConfig, user: RetentionConfig) -> RetentionConfig {
767 let def = RetentionConfig::default();
768 RetentionConfig {
769 hot_days: if user.hot_days != def.hot_days {
770 user.hot_days
771 } else {
772 base.hot_days
773 },
774 warm_days: if user.warm_days != def.warm_days {
775 user.warm_days
776 } else {
777 base.warm_days
778 },
779 }
780}
781
782fn merge_storage(base: StorageConfig, user: StorageConfig) -> StorageConfig {
783 let def = StorageConfig::default();
784 StorageConfig {
785 hot_max_bytes: if user.hot_max_bytes != def.hot_max_bytes {
786 user.hot_max_bytes
787 } else {
788 base.hot_max_bytes
789 },
790 cold_after_days: if user.cold_after_days != def.cold_after_days {
791 user.cold_after_days
792 } else {
793 base.cold_after_days
794 },
795 retention_days: if user.retention_days != def.retention_days {
796 user.retention_days
797 } else {
798 base.retention_days
799 },
800 flush_hour_utc: if user.flush_hour_utc != def.flush_hour_utc {
801 user.flush_hour_utc
802 } else {
803 base.flush_hour_utc
804 },
805 }
806}
807
808fn parse_byte_size(raw: &str) -> Option<u64> {
809 let s = raw.trim();
810 let digits = s
811 .chars()
812 .take_while(|c| c.is_ascii_digit())
813 .collect::<String>();
814 let n = digits.parse::<u64>().ok()?;
815 let unit = s[digits.len()..].trim().to_ascii_lowercase();
816 Some(match unit.as_str() {
817 "" | "b" => n,
818 "kb" | "kib" => n.saturating_mul(1024),
819 "mb" | "mib" => n.saturating_mul(1024 * 1024),
820 "gb" | "gib" => n.saturating_mul(1024 * 1024 * 1024),
821 _ => return None,
822 })
823}
824
825fn merge_proxy(base: ProxyConfig, user: ProxyConfig) -> ProxyConfig {
826 let def = ProxyConfig::default();
827 ProxyConfig {
828 listen: if user.listen != def.listen {
829 user.listen
830 } else {
831 base.listen
832 },
833 upstream: if user.upstream != def.upstream {
834 user.upstream
835 } else {
836 base.upstream
837 },
838 compress_transport: if user.compress_transport != def.compress_transport {
839 user.compress_transport
840 } else {
841 base.compress_transport
842 },
843 minify_json: if user.minify_json != def.minify_json {
844 user.minify_json
845 } else {
846 base.minify_json
847 },
848 max_response_body_mb: if user.max_response_body_mb != def.max_response_body_mb {
849 user.max_response_body_mb
850 } else {
851 base.max_response_body_mb
852 },
853 max_request_body_mb: if user.max_request_body_mb != def.max_request_body_mb {
854 user.max_request_body_mb
855 } else {
856 base.max_request_body_mb
857 },
858 context_policy: if user.context_policy != def.context_policy {
859 user.context_policy
860 } else {
861 base.context_policy
862 },
863 }
864}
865
866fn merge_telemetry(base: TelemetryConfig, user: TelemetryConfig) -> TelemetryConfig {
867 let def = TelemetryConfig::default();
868 let fail_open = if user.fail_open != def.fail_open {
869 user.fail_open
870 } else {
871 base.fail_open
872 };
873 let query = merge_telemetry_query(base.query, user.query);
874 let exporters = if !user.exporters.is_empty() {
875 user.exporters
876 } else {
877 base.exporters
878 };
879 TelemetryConfig {
880 fail_open,
881 query,
882 exporters,
883 }
884}
885
886fn merge_telemetry_query(
887 base: TelemetryQueryConfig,
888 user: TelemetryQueryConfig,
889) -> TelemetryQueryConfig {
890 let def = TelemetryQueryConfig::default();
891 TelemetryQueryConfig {
892 provider: if user.provider != def.provider {
893 user.provider
894 } else {
895 base.provider
896 },
897 cache_ttl_seconds: if user.cache_ttl_seconds != def.cache_ttl_seconds {
898 user.cache_ttl_seconds
899 } else {
900 base.cache_ttl_seconds
901 },
902 identity_allowlist: merge_identity_allowlist(
903 base.identity_allowlist,
904 user.identity_allowlist,
905 ),
906 }
907}
908
909fn merge_identity_allowlist(base: IdentityAllowlist, user: IdentityAllowlist) -> IdentityAllowlist {
910 let def = IdentityAllowlist::default();
911 IdentityAllowlist {
912 team: if user.team != def.team {
913 user.team
914 } else {
915 base.team
916 },
917 workspace_label: if user.workspace_label != def.workspace_label {
918 user.workspace_label
919 } else {
920 base.workspace_label
921 },
922 runner_label: if user.runner_label != def.runner_label {
923 user.runner_label
924 } else {
925 base.runner_label
926 },
927 actor_kind: if user.actor_kind != def.actor_kind {
928 user.actor_kind
929 } else {
930 base.actor_kind
931 },
932 actor_label: if user.actor_label != def.actor_label {
933 user.actor_label
934 } else {
935 base.actor_label
936 },
937 agent: if user.agent != def.agent {
938 user.agent
939 } else {
940 base.agent
941 },
942 model: if user.model != def.model {
943 user.model
944 } else {
945 base.model
946 },
947 env: if user.env != def.env {
948 user.env
949 } else {
950 base.env
951 },
952 job: if user.job != def.job {
953 user.job
954 } else {
955 base.job
956 },
957 branch: if user.branch != def.branch {
958 user.branch
959 } else {
960 base.branch
961 },
962 }
963}
964
965fn merge_sync(base: SyncConfig, user: SyncConfig) -> SyncConfig {
966 let def = SyncConfig::default();
967 SyncConfig {
968 endpoint: if !user.endpoint.is_empty() {
969 user.endpoint
970 } else {
971 base.endpoint
972 },
973 team_token: if !user.team_token.is_empty() {
974 user.team_token
975 } else {
976 base.team_token
977 },
978 team_id: if !user.team_id.is_empty() {
979 user.team_id
980 } else {
981 base.team_id
982 },
983 events_per_batch_max: if user.events_per_batch_max != def.events_per_batch_max {
984 user.events_per_batch_max
985 } else {
986 base.events_per_batch_max
987 },
988 max_body_bytes: if user.max_body_bytes != def.max_body_bytes {
989 user.max_body_bytes
990 } else {
991 base.max_body_bytes
992 },
993 flush_interval_ms: if user.flush_interval_ms != def.flush_interval_ms {
994 user.flush_interval_ms
995 } else {
996 base.flush_interval_ms
997 },
998 sample_rate: if (user.sample_rate - def.sample_rate).abs() > f64::EPSILON {
999 user.sample_rate
1000 } else {
1001 base.sample_rate
1002 },
1003 team_salt_hex: if !user.team_salt_hex.is_empty() {
1004 user.team_salt_hex
1005 } else {
1006 base.team_salt_hex
1007 },
1008 }
1009}
1010
1011#[cfg(test)]
1012mod tests {
1013 use super::*;
1014 use std::io::Write;
1015 use tempfile::TempDir;
1016
1017 #[test]
1018 fn defaults_when_no_files() {
1019 let dir = TempDir::new().unwrap();
1020 let cfg = load(dir.path()).unwrap();
1021 assert_eq!(cfg.scan.roots, ScanConfig::default().roots);
1022 assert_eq!(cfg.scan.min_rescan_seconds, 300);
1023 assert_eq!(cfg.retention.hot_days, 30);
1024 assert_eq!(cfg.storage.cold_after_days, 7);
1025 assert_eq!(cfg.storage.hot_max_bytes_value(), 1_073_741_824);
1026 }
1027
1028 #[test]
1029 fn workspace_config_loaded() {
1030 let dir = TempDir::new().unwrap();
1031 std::fs::create_dir_all(dir.path().join(".kaizen")).unwrap();
1032 let mut f = std::fs::File::create(dir.path().join(".kaizen/config.toml")).unwrap();
1033 writeln!(f, "[scan]\nroots = [\"/custom/root\"]").unwrap();
1034
1035 let cfg = load(dir.path()).unwrap();
1036 assert_eq!(cfg.scan.roots, vec!["/custom/root"]);
1037 }
1038
1039 #[test]
1040 fn invalid_toml_ignored() {
1041 let dir = TempDir::new().unwrap();
1042 std::fs::create_dir_all(dir.path().join(".kaizen")).unwrap();
1043 std::fs::write(dir.path().join(".kaizen/config.toml"), "not valid toml :::").unwrap();
1044
1045 let cfg = load(dir.path()).unwrap();
1046 assert_eq!(cfg.scan.roots, ScanConfig::default().roots);
1047 }
1048
1049 #[test]
1050 fn merge_user_roots_win() {
1051 let base = Config {
1052 scan: ScanConfig {
1053 roots: vec!["/base".to_string()],
1054 ..ScanConfig::default()
1055 },
1056 ..Default::default()
1057 };
1058 let user = Config {
1059 scan: ScanConfig {
1060 roots: vec!["/user".to_string()],
1061 ..ScanConfig::default()
1062 },
1063 ..Default::default()
1064 };
1065 let merged = merge(base, user);
1066 assert_eq!(merged.scan.roots, vec!["/user"]);
1067 }
1068
1069 #[test]
1070 fn merge_sources_user_default_keeps_workspace_cursor() {
1071 let base = Config {
1072 sources: SourcesConfig {
1073 cursor: CursorSourceConfig {
1074 enabled: false,
1075 transcript_glob: "/workspace/glob/**".into(),
1076 },
1077 ..Default::default()
1078 },
1079 ..Default::default()
1080 };
1081 let user = Config::default();
1082 let merged = merge(base, user);
1083 assert!(!merged.sources.cursor.enabled);
1084 assert_eq!(merged.sources.cursor.transcript_glob, "/workspace/glob/**");
1085 }
1086
1087 #[test]
1088 fn merge_retention_field_by_field() {
1089 let base = Config {
1090 retention: RetentionConfig {
1091 hot_days: 60,
1092 warm_days: 90,
1093 },
1094 ..Default::default()
1095 };
1096 let user = Config {
1097 retention: RetentionConfig {
1098 hot_days: 30,
1099 warm_days: 45,
1100 },
1101 ..Default::default()
1102 };
1103 let merged = merge(base, user);
1104 assert_eq!(merged.retention.hot_days, 60);
1105 assert_eq!(merged.retention.warm_days, 45);
1106 }
1107
1108 #[test]
1109 fn merge_retention_user_hot_overrides() {
1110 let base = Config {
1111 retention: RetentionConfig {
1112 hot_days: 60,
1113 warm_days: 90,
1114 },
1115 ..Default::default()
1116 };
1117 let user = Config {
1118 retention: RetentionConfig {
1119 hot_days: 14,
1120 warm_days: 90,
1121 },
1122 ..Default::default()
1123 };
1124 let merged = merge(base, user);
1125 assert_eq!(merged.retention.hot_days, 14);
1126 assert_eq!(merged.retention.warm_days, 90);
1127 }
1128
1129 #[test]
1130 fn merge_storage_user_overrides() {
1131 let base = Config {
1132 storage: StorageConfig {
1133 hot_max_bytes: "2GB".into(),
1134 cold_after_days: 14,
1135 retention_days: 120,
1136 flush_hour_utc: 3,
1137 },
1138 ..Default::default()
1139 };
1140 let user = Config {
1141 storage: StorageConfig {
1142 cold_after_days: 3,
1143 ..StorageConfig::default()
1144 },
1145 ..Default::default()
1146 };
1147 let merged = merge(base, user);
1148 assert_eq!(merged.storage.hot_max_bytes, "2GB");
1149 assert_eq!(merged.storage.cold_after_days, 3);
1150 assert_eq!(merged.storage.retention_days, 120);
1151 assert_eq!(merged.storage.flush_hour_utc, 3);
1152 }
1153
1154 #[test]
1155 fn merge_telemetry_exporters_user_wins_non_empty() {
1156 let base = Config {
1157 telemetry: TelemetryConfig {
1158 fail_open: true,
1159 query: TelemetryQueryConfig::default(),
1160 exporters: vec![ExporterConfig::None],
1161 },
1162 ..Default::default()
1163 };
1164 let user = Config {
1165 telemetry: TelemetryConfig {
1166 fail_open: false,
1167 query: TelemetryQueryConfig::default(),
1168 exporters: vec![ExporterConfig::Dev { enabled: true }],
1169 },
1170 ..Default::default()
1171 };
1172 let merged = merge(base, user);
1173 assert!(!merged.telemetry.fail_open);
1174 assert_eq!(merged.telemetry.exporters.len(), 1);
1175 }
1176
1177 #[test]
1178 fn telemetry_query_defaults() {
1179 let t = TelemetryQueryConfig::default();
1180 assert_eq!(t.provider, QueryAuthority::None);
1181 assert_eq!(t.cache_ttl_seconds, 3600);
1182 assert!(!t.identity_allowlist.team);
1183 assert!(!t.has_provider_for_pull());
1184 }
1185
1186 #[test]
1187 fn telemetry_query_has_provider() {
1188 let ph = TelemetryQueryConfig {
1189 provider: QueryAuthority::Posthog,
1190 ..Default::default()
1191 };
1192 assert!(ph.has_provider_for_pull());
1193 let dd = TelemetryQueryConfig {
1194 provider: QueryAuthority::Datadog,
1195 ..Default::default()
1196 };
1197 assert!(dd.has_provider_for_pull());
1198 }
1199
1200 #[test]
1201 fn merge_telemetry_query_user_wins() {
1202 let base = Config {
1203 telemetry: TelemetryConfig {
1204 query: TelemetryQueryConfig {
1205 provider: QueryAuthority::Posthog,
1206 cache_ttl_seconds: 3600,
1207 identity_allowlist: IdentityAllowlist {
1208 team: true,
1209 ..Default::default()
1210 },
1211 },
1212 ..Default::default()
1213 },
1214 ..Default::default()
1215 };
1216 let user = Config {
1217 telemetry: TelemetryConfig {
1218 query: TelemetryQueryConfig {
1219 cache_ttl_seconds: 7200,
1220 ..Default::default()
1221 },
1222 ..Default::default()
1223 },
1224 ..Default::default()
1225 };
1226 let merged = merge(base, user);
1227 assert_eq!(merged.telemetry.query.provider, QueryAuthority::Posthog);
1228 assert_eq!(merged.telemetry.query.cache_ttl_seconds, 7200);
1229 assert!(merged.telemetry.query.identity_allowlist.team);
1230 }
1231
1232 #[test]
1233 fn toml_telemetry_query_roundtrip() {
1234 let dir = TempDir::new().unwrap();
1235 std::fs::create_dir_all(dir.path().join(".kaizen")).unwrap();
1236 let toml = r#"
1237[telemetry.query]
1238provider = "datadog"
1239cache_ttl_seconds = 1800
1240
1241[telemetry.query.identity_allowlist]
1242team = true
1243branch = true
1244"#;
1245 std::fs::write(dir.path().join(".kaizen/config.toml"), toml).unwrap();
1246 let cfg = load(dir.path()).unwrap();
1247 assert_eq!(cfg.telemetry.query.provider, QueryAuthority::Datadog);
1248 assert_eq!(cfg.telemetry.query.cache_ttl_seconds, 1800);
1249 assert!(cfg.telemetry.query.identity_allowlist.team);
1250 assert!(cfg.telemetry.query.identity_allowlist.branch);
1251 assert!(!cfg.telemetry.query.identity_allowlist.model);
1252 }
1253}