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 gemini: bool,
50 #[serde(default = "default_true")]
51 pub pi: bool,
52 #[serde(default = "default_true")]
53 pub kimi: bool,
54 #[serde(default = "default_true")]
55 pub antigravity: bool,
56 #[serde(default = "default_true")]
57 pub cursor_state_db: bool,
58 #[serde(default = "default_true")]
59 pub goose: bool,
60 #[serde(default = "default_true")]
61 pub openclaw: bool,
62 #[serde(default = "default_true")]
63 pub opencode: bool,
64 #[serde(default = "default_true")]
65 pub copilot_cli: bool,
66 #[serde(default = "default_true")]
67 pub copilot_vscode: bool,
68}
69
70impl Default for TailAgentToggles {
71 fn default() -> Self {
72 Self {
73 gemini: true,
74 pi: true,
75 kimi: true,
76 antigravity: true,
77 cursor_state_db: true,
78 goose: true,
79 openclaw: true,
80 opencode: true,
81 copilot_cli: true,
82 copilot_vscode: true,
83 }
84 }
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize, Default)]
88pub struct SourcesConfig {
89 #[serde(default)]
90 pub cursor: CursorSourceConfig,
91 #[serde(default)]
92 pub tail: TailAgentToggles,
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
96pub struct RetentionConfig {
97 pub hot_days: u32,
98 pub warm_days: u32,
99}
100
101impl Default for RetentionConfig {
102 fn default() -> Self {
103 Self {
104 hot_days: 30,
105 warm_days: 90,
106 }
107 }
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
111pub struct StorageConfig {
112 pub hot_max_bytes: String,
113 pub cold_after_days: u32,
114 pub retention_days: u32,
115 pub flush_hour_utc: u8,
116}
117
118impl Default for StorageConfig {
119 fn default() -> Self {
120 Self {
121 hot_max_bytes: "1GB".into(),
122 cold_after_days: 7,
123 retention_days: 90,
124 flush_hour_utc: 0,
125 }
126 }
127}
128
129impl StorageConfig {
130 pub fn hot_max_bytes_value(&self) -> u64 {
131 parse_byte_size(&self.hot_max_bytes).unwrap_or(1_073_741_824)
132 }
133}
134
135#[derive(Debug, Clone, Serialize, Deserialize)]
136pub struct SyncConfig {
137 #[serde(default)]
139 pub endpoint: String,
140 #[serde(default)]
141 pub team_token: String,
142 #[serde(default)]
143 pub team_id: String,
144 #[serde(default = "default_events_per_batch")]
145 pub events_per_batch_max: usize,
146 #[serde(default = "default_max_body_bytes")]
147 pub max_body_bytes: usize,
148 #[serde(default = "default_flush_interval_ms")]
149 pub flush_interval_ms: u64,
150 #[serde(default = "default_sample_rate")]
151 pub sample_rate: f64,
152 #[serde(default)]
154 pub team_salt_hex: String,
155}
156
157fn default_events_per_batch() -> usize {
158 500
159}
160
161fn default_max_body_bytes() -> usize {
162 1_000_000
163}
164
165fn default_flush_interval_ms() -> u64 {
166 10_000
167}
168
169fn default_sample_rate() -> f64 {
170 1.0
171}
172
173impl Default for SyncConfig {
174 fn default() -> Self {
175 Self {
176 endpoint: String::new(),
177 team_token: String::new(),
178 team_id: String::new(),
179 events_per_batch_max: default_events_per_batch(),
180 max_body_bytes: default_max_body_bytes(),
181 flush_interval_ms: default_flush_interval_ms(),
182 sample_rate: default_sample_rate(),
183 team_salt_hex: String::new(),
184 }
185 }
186}
187
188pub fn try_team_salt(cfg: &SyncConfig) -> Option<[u8; 32]> {
190 let h = cfg.team_salt_hex.trim();
191 if h.len() != 64 {
192 return None;
193 }
194 let bytes = hex::decode(h).ok()?;
195 bytes.try_into().ok()
196}
197
198pub fn effective_redaction_salt(
202 cfg: &SyncConfig,
203 kaizen_home: &std::path::Path,
204) -> Result<[u8; 32]> {
205 if let Some(s) = try_team_salt(cfg) {
206 return Ok(s);
207 }
208 let path = kaizen_home.join("local_salt.hex");
209 if let Some(s) = read_local_salt(&path)? {
210 return Ok(s);
211 }
212 let bytes = generate_local_salt();
213 write_local_salt(&path, &bytes)?;
214 Ok(bytes)
215}
216
217fn read_local_salt(path: &std::path::Path) -> Result<Option<[u8; 32]>> {
218 use std::io::ErrorKind;
219 match std::fs::read_to_string(path) {
220 Ok(s) => Ok(parse_salt_hex(s.trim())),
221 Err(e) if e.kind() == ErrorKind::NotFound => Ok(None),
222 Err(e) => Err(e.into()),
223 }
224}
225
226fn parse_salt_hex(h: &str) -> Option<[u8; 32]> {
227 if h.len() != 64 {
228 return None;
229 }
230 hex::decode(h).ok()?.try_into().ok()
231}
232
233fn generate_local_salt() -> [u8; 32] {
234 use rand::Rng;
235 let mut bytes = [0u8; 32];
236 rand::rng().fill_bytes(&mut bytes);
237 bytes
238}
239
240fn write_local_salt(path: &std::path::Path, bytes: &[u8; 32]) -> Result<()> {
241 let hex_s = hex::encode(bytes);
242 crate::core::safe_fs::write_atomic(path, hex_s.as_bytes())?;
243 set_user_only_perms(path)?;
244 Ok(())
245}
246
247#[cfg(unix)]
248fn set_user_only_perms(path: &std::path::Path) -> Result<()> {
249 use std::os::unix::fs::PermissionsExt;
250 let mut perms = std::fs::metadata(path)?.permissions();
251 perms.set_mode(0o600);
252 std::fs::set_permissions(path, perms)?;
253 Ok(())
254}
255
256#[cfg(not(unix))]
257fn set_user_only_perms(_path: &std::path::Path) -> Result<()> {
258 Ok(())
259}
260
261fn default_true() -> bool {
262 true
263}
264
265fn default_telemetry_fail_open() -> bool {
266 true
267}
268
269fn default_cache_ttl_seconds() -> u64 {
270 3600
271}
272
273#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
275#[serde(rename_all = "lowercase")]
276pub enum QueryAuthority {
277 #[default]
278 None,
279 Posthog,
280 Datadog,
281}
282
283#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
285pub struct IdentityAllowlist {
286 #[serde(default)]
287 pub team: bool,
288 #[serde(default)]
289 pub workspace_label: bool,
290 #[serde(default)]
291 pub runner_label: bool,
292 #[serde(default)]
293 pub actor_kind: bool,
294 #[serde(default)]
295 pub actor_label: bool,
296 #[serde(default)]
297 pub agent: bool,
298 #[serde(default)]
299 pub model: bool,
300 #[serde(default)]
301 pub env: bool,
302 #[serde(default)]
303 pub job: bool,
304 #[serde(default)]
305 pub branch: bool,
306}
307
308#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
310pub struct TelemetryQueryConfig {
311 #[serde(default)]
313 pub provider: QueryAuthority,
314 #[serde(default = "default_cache_ttl_seconds")]
316 pub cache_ttl_seconds: u64,
317 #[serde(default)]
318 pub identity_allowlist: IdentityAllowlist,
319}
320
321impl Default for TelemetryQueryConfig {
322 fn default() -> Self {
323 Self {
324 provider: QueryAuthority::default(),
325 cache_ttl_seconds: default_cache_ttl_seconds(),
326 identity_allowlist: IdentityAllowlist::default(),
327 }
328 }
329}
330
331impl TelemetryQueryConfig {
332 pub fn has_provider_for_pull(&self) -> bool {
334 matches!(
335 self.provider,
336 QueryAuthority::Posthog | QueryAuthority::Datadog
337 )
338 }
339}
340
341#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
343#[serde(tag = "type", rename_all = "snake_case")]
344pub enum ContextPolicy {
345 #[default]
347 None,
348 LastMessages { count: usize },
350 MaxInputTokens { max: u32 },
352}
353
354#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
356pub struct ProxyConfig {
357 #[serde(default = "default_proxy_listen")]
359 pub listen: String,
360 #[serde(default = "default_proxy_upstream")]
362 pub upstream: String,
363 #[serde(default = "default_proxy_provider")]
365 pub provider: String,
366 #[serde(default = "default_true")]
368 pub compress_transport: bool,
369 #[serde(default = "default_true")]
371 pub minify_json: bool,
372 #[serde(default = "default_proxy_max_body_mb")]
374 pub max_response_body_mb: u32,
375 #[serde(default = "default_proxy_max_request_body_mb")]
377 pub max_request_body_mb: u32,
378 #[serde(default)]
380 pub context_policy: ContextPolicy,
381}
382
383fn default_proxy_listen() -> String {
384 "127.0.0.1:3847".to_string()
385}
386
387fn default_proxy_upstream() -> String {
388 "https://api.anthropic.com".to_string()
389}
390
391fn default_proxy_provider() -> String {
392 "anthropic".to_string()
393}
394
395fn default_proxy_max_body_mb() -> u32 {
396 256
397}
398
399fn default_proxy_max_request_body_mb() -> u32 {
400 32
401}
402
403impl Default for ProxyConfig {
404 fn default() -> Self {
405 Self {
406 listen: default_proxy_listen(),
407 upstream: default_proxy_upstream(),
408 provider: default_proxy_provider(),
409 compress_transport: true,
410 minify_json: true,
411 max_response_body_mb: default_proxy_max_body_mb(),
412 max_request_body_mb: default_proxy_max_request_body_mb(),
413 context_policy: ContextPolicy::default(),
414 }
415 }
416}
417
418#[derive(Debug, Clone, Serialize, Deserialize)]
420pub struct TelemetryConfig {
421 #[serde(default = "default_telemetry_fail_open")]
423 pub fail_open: bool,
424 #[serde(default)]
426 pub query: TelemetryQueryConfig,
427 #[serde(default)]
429 pub exporters: Vec<ExporterConfig>,
430}
431
432impl Default for TelemetryConfig {
433 fn default() -> Self {
434 Self {
435 fail_open: default_telemetry_fail_open(),
436 query: TelemetryQueryConfig::default(),
437 exporters: Vec::new(),
438 }
439 }
440}
441
442#[derive(Debug, Clone, Serialize, Deserialize)]
444#[serde(tag = "type", rename_all = "lowercase")]
445pub enum ExporterConfig {
446 None,
448 File {
450 #[serde(default = "default_true")]
451 enabled: bool,
452 #[serde(default)]
453 path: Option<String>,
454 },
455 Dev {
457 #[serde(default = "default_true")]
458 enabled: bool,
459 },
460 PostHog {
461 #[serde(default = "default_true")]
462 enabled: bool,
463 host: Option<String>,
465 project_api_key: Option<String>,
467 },
468 Datadog {
469 #[serde(default = "default_true")]
470 enabled: bool,
471 site: Option<String>,
473 api_key: Option<String>,
475 },
476 Otlp {
477 #[serde(default = "default_true")]
478 enabled: bool,
479 endpoint: Option<String>,
481 },
482}
483
484impl ExporterConfig {
485 pub fn is_enabled(&self) -> bool {
487 match self {
488 ExporterConfig::None => false,
489 ExporterConfig::File { enabled, .. } => *enabled,
490 ExporterConfig::Dev { enabled, .. } => *enabled,
491 ExporterConfig::PostHog { enabled, .. } => *enabled,
492 ExporterConfig::Datadog { enabled, .. } => *enabled,
493 ExporterConfig::Otlp { enabled, .. } => *enabled,
494 }
495 }
496}
497
498#[derive(Debug, Clone, Serialize, Deserialize)]
499pub struct EvalConfig {
500 #[serde(default)]
501 pub enabled: bool,
502 #[serde(default = "default_eval_endpoint")]
503 pub endpoint: String,
504 #[serde(default)]
505 pub api_key: String,
506 #[serde(default = "default_eval_model")]
507 pub model: String,
508 #[serde(default = "default_eval_rubric")]
509 pub rubric: String,
510 #[serde(default = "default_eval_batch_size")]
511 pub batch_size: usize,
512 #[serde(default = "default_eval_min_cost")]
513 pub min_cost_usd: f64,
514}
515
516impl Default for EvalConfig {
517 fn default() -> Self {
518 Self {
519 enabled: false,
520 endpoint: default_eval_endpoint(),
521 api_key: String::new(),
522 model: default_eval_model(),
523 rubric: default_eval_rubric(),
524 batch_size: default_eval_batch_size(),
525 min_cost_usd: default_eval_min_cost(),
526 }
527 }
528}
529
530fn default_eval_endpoint() -> String {
531 "https://api.anthropic.com".into()
532}
533fn default_eval_model() -> String {
534 "claude-haiku-4-5-20251001".into()
535}
536fn default_eval_rubric() -> String {
537 "tool-efficiency-v1".into()
538}
539fn default_eval_batch_size() -> usize {
540 20
541}
542fn default_eval_min_cost() -> f64 {
543 0.01
544}
545
546#[derive(Debug, Clone, Serialize, Deserialize)]
547pub struct GuidanceProposalConfig {
548 #[serde(default)]
549 pub enabled: bool,
550 #[serde(default = "default_guidance_endpoint")]
551 pub endpoint: String,
552 #[serde(default)]
553 pub api_key: String,
554 #[serde(default = "default_guidance_model")]
555 pub model: String,
556 #[serde(default = "default_guidance_max_ops")]
557 pub max_ops: usize,
558 #[serde(default = "default_true")]
559 pub redact: bool,
560}
561
562#[derive(Debug, Clone, Serialize, Deserialize, Default)]
563pub struct GuidanceConfig {
564 #[serde(default)]
565 pub proposals: GuidanceProposalConfig,
566}
567
568impl Default for GuidanceProposalConfig {
569 fn default() -> Self {
570 Self {
571 enabled: false,
572 endpoint: default_guidance_endpoint(),
573 api_key: String::new(),
574 model: default_guidance_model(),
575 max_ops: default_guidance_max_ops(),
576 redact: true,
577 }
578 }
579}
580
581fn default_guidance_endpoint() -> String {
582 default_eval_endpoint()
583}
584fn default_guidance_model() -> String {
585 default_eval_model()
586}
587fn default_guidance_max_ops() -> usize {
588 3
589}
590
591#[derive(Debug, Clone, Serialize, Deserialize)]
593pub struct CollectOutcomesConfig {
594 #[serde(default)]
595 pub enabled: bool,
596 #[serde(default = "default_outcomes_test_cmd")]
597 pub test_cmd: String,
598 #[serde(default = "default_outcomes_timeout_secs")]
599 pub timeout_secs: u64,
600 #[serde(default)]
601 pub lint_cmd: Option<String>,
602}
603
604fn default_outcomes_test_cmd() -> String {
605 "cargo test --quiet".to_string()
606}
607
608fn default_outcomes_timeout_secs() -> u64 {
609 600
610}
611
612impl Default for CollectOutcomesConfig {
613 fn default() -> Self {
614 Self {
615 enabled: false,
616 test_cmd: default_outcomes_test_cmd(),
617 timeout_secs: default_outcomes_timeout_secs(),
618 lint_cmd: None,
619 }
620 }
621}
622
623#[derive(Debug, Clone, Serialize, Deserialize)]
625pub struct CollectSystemSamplerConfig {
626 #[serde(default)]
627 pub enabled: bool,
628 #[serde(default = "default_sampler_sample_ms")]
629 pub sample_ms: u64,
630 #[serde(default = "default_sampler_max_samples")]
631 pub max_samples_per_session: u32,
632}
633
634fn default_sampler_sample_ms() -> u64 {
635 2000
636}
637
638fn default_sampler_max_samples() -> u32 {
639 3600
640}
641
642impl Default for CollectSystemSamplerConfig {
643 fn default() -> Self {
644 Self {
645 enabled: false,
646 sample_ms: default_sampler_sample_ms(),
647 max_samples_per_session: default_sampler_max_samples(),
648 }
649 }
650}
651
652#[derive(Debug, Clone, Serialize, Deserialize, Default)]
653pub struct CollectConfig {
654 #[serde(default)]
655 pub outcomes: CollectOutcomesConfig,
656 #[serde(default)]
657 pub system_sampler: CollectSystemSamplerConfig,
658}
659
660#[derive(Debug, Clone, Serialize, Deserialize, Default)]
661pub struct Config {
662 #[serde(default)]
663 pub scan: ScanConfig,
664 #[serde(default)]
665 pub sources: SourcesConfig,
666 #[serde(default)]
667 pub retention: RetentionConfig,
668 #[serde(default)]
669 pub storage: StorageConfig,
670 #[serde(default)]
671 pub sync: SyncConfig,
672 #[serde(default)]
673 pub telemetry: TelemetryConfig,
674 #[serde(default)]
675 pub proxy: ProxyConfig,
676 #[serde(default)]
677 pub eval: EvalConfig,
678 #[serde(default)]
679 pub guidance: GuidanceConfig,
680 #[serde(default)]
681 pub collect: CollectConfig,
682}
683
684pub fn load(workspace: &Path) -> Result<Config> {
687 let project_cfg = crate::core::paths::project_data_path(workspace)
688 .ok()
689 .map(|d| d.join("config.toml"));
690 let user_path = crate::core::paths::kaizen_dir()
691 .ok_or_else(|| anyhow::anyhow!("KAIZEN_HOME / HOME unset"))?
692 .join("config.toml");
693
694 let base = project_cfg
695 .as_deref()
696 .and_then(load_file)
697 .unwrap_or_default();
698 let user = load_file(&user_path).unwrap_or_default();
699 Ok(merge(base, user))
700}
701
702fn load_file(path: &Path) -> Option<Config> {
703 let text = std::fs::read_to_string(path).ok()?;
704 toml::from_str(&text).ok()
705}
706
707fn merge(base: Config, user: Config) -> Config {
708 Config {
709 scan: merge_scan(base.scan, user.scan),
710 sources: merge_sources(base.sources, user.sources),
711 retention: merge_retention(base.retention, user.retention),
712 storage: merge_storage(base.storage, user.storage),
713 sync: merge_sync(base.sync, user.sync),
714 telemetry: merge_telemetry(base.telemetry, user.telemetry),
715 proxy: merge_proxy(base.proxy, user.proxy),
716 eval: merge_eval(base.eval, user.eval),
717 guidance: merge_guidance(base.guidance, user.guidance),
718 collect: merge_collect(base.collect, user.collect),
719 }
720}
721
722fn merge_guidance(base: GuidanceConfig, user: GuidanceConfig) -> GuidanceConfig {
723 GuidanceConfig {
724 proposals: merge_guidance_proposals(base.proposals, user.proposals),
725 }
726}
727
728fn merge_guidance_proposals(
729 base: GuidanceProposalConfig,
730 user: GuidanceProposalConfig,
731) -> GuidanceProposalConfig {
732 let def = GuidanceProposalConfig::default();
733 GuidanceProposalConfig {
734 enabled: pick_bool(user.enabled, base.enabled, def.enabled),
735 endpoint: pick_string(user.endpoint, base.endpoint, def.endpoint),
736 api_key: if user.api_key.is_empty() {
737 base.api_key
738 } else {
739 user.api_key
740 },
741 model: pick_string(user.model, base.model, def.model),
742 max_ops: if user.max_ops != def.max_ops {
743 user.max_ops
744 } else {
745 base.max_ops
746 },
747 redact: pick_bool(user.redact, base.redact, def.redact),
748 }
749}
750
751fn pick_bool(user: bool, base: bool, def: bool) -> bool {
752 if user != def { user } else { base }
753}
754
755fn pick_string(user: String, base: String, def: String) -> String {
756 if user != def { user } else { base }
757}
758
759fn merge_collect(base: CollectConfig, user: CollectConfig) -> CollectConfig {
760 let def = CollectConfig::default();
761 CollectConfig {
762 outcomes: merge_collect_outcomes(base.outcomes, user.outcomes, def.outcomes),
763 system_sampler: merge_collect_sampler(
764 base.system_sampler,
765 user.system_sampler,
766 def.system_sampler,
767 ),
768 }
769}
770
771fn merge_collect_outcomes(
772 base: CollectOutcomesConfig,
773 user: CollectOutcomesConfig,
774 def: CollectOutcomesConfig,
775) -> CollectOutcomesConfig {
776 CollectOutcomesConfig {
777 enabled: if user.enabled != def.enabled {
778 user.enabled
779 } else {
780 base.enabled
781 },
782 test_cmd: if user.test_cmd != def.test_cmd {
783 user.test_cmd
784 } else {
785 base.test_cmd
786 },
787 timeout_secs: if user.timeout_secs != def.timeout_secs {
788 user.timeout_secs
789 } else {
790 base.timeout_secs
791 },
792 lint_cmd: user.lint_cmd.or(base.lint_cmd),
793 }
794}
795
796fn merge_collect_sampler(
797 base: CollectSystemSamplerConfig,
798 user: CollectSystemSamplerConfig,
799 def: CollectSystemSamplerConfig,
800) -> CollectSystemSamplerConfig {
801 CollectSystemSamplerConfig {
802 enabled: if user.enabled != def.enabled {
803 user.enabled
804 } else {
805 base.enabled
806 },
807 sample_ms: if user.sample_ms != def.sample_ms {
808 user.sample_ms
809 } else {
810 base.sample_ms
811 },
812 max_samples_per_session: if user.max_samples_per_session != def.max_samples_per_session {
813 user.max_samples_per_session
814 } else {
815 base.max_samples_per_session
816 },
817 }
818}
819
820fn merge_sources(base: SourcesConfig, user: SourcesConfig) -> SourcesConfig {
821 let def = SourcesConfig::default();
822 SourcesConfig {
823 cursor: merge_cursor_source(base.cursor, user.cursor, def.cursor),
824 tail: merge_tail_toggles(base.tail, user.tail, def.tail),
825 }
826}
827
828fn merge_cursor_source(
829 base: CursorSourceConfig,
830 user: CursorSourceConfig,
831 def: CursorSourceConfig,
832) -> CursorSourceConfig {
833 CursorSourceConfig {
834 enabled: if user.enabled != def.enabled {
835 user.enabled
836 } else {
837 base.enabled
838 },
839 transcript_glob: if user.transcript_glob != def.transcript_glob {
840 user.transcript_glob
841 } else {
842 base.transcript_glob
843 },
844 }
845}
846
847fn merge_tail_toggles(
848 base: TailAgentToggles,
849 user: TailAgentToggles,
850 def: TailAgentToggles,
851) -> TailAgentToggles {
852 TailAgentToggles {
853 gemini: if user.gemini != def.gemini {
854 user.gemini
855 } else {
856 base.gemini
857 },
858 pi: if user.pi != def.pi { user.pi } else { base.pi },
859 kimi: if user.kimi != def.kimi {
860 user.kimi
861 } else {
862 base.kimi
863 },
864 antigravity: if user.antigravity != def.antigravity {
865 user.antigravity
866 } else {
867 base.antigravity
868 },
869 cursor_state_db: if user.cursor_state_db != def.cursor_state_db {
870 user.cursor_state_db
871 } else {
872 base.cursor_state_db
873 },
874 goose: if user.goose != def.goose {
875 user.goose
876 } else {
877 base.goose
878 },
879 openclaw: if user.openclaw != def.openclaw {
880 user.openclaw
881 } else {
882 base.openclaw
883 },
884 opencode: if user.opencode != def.opencode {
885 user.opencode
886 } else {
887 base.opencode
888 },
889 copilot_cli: if user.copilot_cli != def.copilot_cli {
890 user.copilot_cli
891 } else {
892 base.copilot_cli
893 },
894 copilot_vscode: if user.copilot_vscode != def.copilot_vscode {
895 user.copilot_vscode
896 } else {
897 base.copilot_vscode
898 },
899 }
900}
901
902fn merge_eval(base: EvalConfig, user: EvalConfig) -> EvalConfig {
903 let def = EvalConfig::default();
904 EvalConfig {
905 enabled: if user.enabled != def.enabled {
906 user.enabled
907 } else {
908 base.enabled
909 },
910 endpoint: if user.endpoint != def.endpoint {
911 user.endpoint
912 } else {
913 base.endpoint
914 },
915 api_key: if !user.api_key.is_empty() {
916 user.api_key
917 } else {
918 base.api_key
919 },
920 model: if user.model != def.model {
921 user.model
922 } else {
923 base.model
924 },
925 rubric: if user.rubric != def.rubric {
926 user.rubric
927 } else {
928 base.rubric
929 },
930 batch_size: if user.batch_size != def.batch_size {
931 user.batch_size
932 } else {
933 base.batch_size
934 },
935 min_cost_usd: if user.min_cost_usd != def.min_cost_usd {
936 user.min_cost_usd
937 } else {
938 base.min_cost_usd
939 },
940 }
941}
942
943fn merge_scan(base: ScanConfig, user: ScanConfig) -> ScanConfig {
944 let def = ScanConfig::default();
945 ScanConfig {
946 roots: if user.roots != def.roots {
947 user.roots
948 } else {
949 base.roots
950 },
951 min_rescan_seconds: if user.min_rescan_seconds != def.min_rescan_seconds {
952 user.min_rescan_seconds
953 } else {
954 base.min_rescan_seconds
955 },
956 }
957}
958
959fn merge_retention(base: RetentionConfig, user: RetentionConfig) -> RetentionConfig {
960 let def = RetentionConfig::default();
961 RetentionConfig {
962 hot_days: if user.hot_days != def.hot_days {
963 user.hot_days
964 } else {
965 base.hot_days
966 },
967 warm_days: if user.warm_days != def.warm_days {
968 user.warm_days
969 } else {
970 base.warm_days
971 },
972 }
973}
974
975fn merge_storage(base: StorageConfig, user: StorageConfig) -> StorageConfig {
976 let def = StorageConfig::default();
977 StorageConfig {
978 hot_max_bytes: if user.hot_max_bytes != def.hot_max_bytes {
979 user.hot_max_bytes
980 } else {
981 base.hot_max_bytes
982 },
983 cold_after_days: if user.cold_after_days != def.cold_after_days {
984 user.cold_after_days
985 } else {
986 base.cold_after_days
987 },
988 retention_days: if user.retention_days != def.retention_days {
989 user.retention_days
990 } else {
991 base.retention_days
992 },
993 flush_hour_utc: if user.flush_hour_utc != def.flush_hour_utc {
994 user.flush_hour_utc
995 } else {
996 base.flush_hour_utc
997 },
998 }
999}
1000
1001fn parse_byte_size(raw: &str) -> Option<u64> {
1002 let s = raw.trim();
1003 let digits = s
1004 .chars()
1005 .take_while(|c| c.is_ascii_digit())
1006 .collect::<String>();
1007 let n = digits.parse::<u64>().ok()?;
1008 let unit = s[digits.len()..].trim().to_ascii_lowercase();
1009 Some(match unit.as_str() {
1010 "" | "b" => n,
1011 "kb" | "kib" => n.saturating_mul(1024),
1012 "mb" | "mib" => n.saturating_mul(1024 * 1024),
1013 "gb" | "gib" => n.saturating_mul(1024 * 1024 * 1024),
1014 _ => return None,
1015 })
1016}
1017
1018fn merge_proxy(base: ProxyConfig, user: ProxyConfig) -> ProxyConfig {
1019 let def = ProxyConfig::default();
1020 ProxyConfig {
1021 listen: if user.listen != def.listen {
1022 user.listen
1023 } else {
1024 base.listen
1025 },
1026 upstream: if user.upstream != def.upstream {
1027 user.upstream
1028 } else {
1029 base.upstream
1030 },
1031 provider: if user.provider != def.provider {
1032 user.provider
1033 } else {
1034 base.provider
1035 },
1036 compress_transport: if user.compress_transport != def.compress_transport {
1037 user.compress_transport
1038 } else {
1039 base.compress_transport
1040 },
1041 minify_json: if user.minify_json != def.minify_json {
1042 user.minify_json
1043 } else {
1044 base.minify_json
1045 },
1046 max_response_body_mb: if user.max_response_body_mb != def.max_response_body_mb {
1047 user.max_response_body_mb
1048 } else {
1049 base.max_response_body_mb
1050 },
1051 max_request_body_mb: if user.max_request_body_mb != def.max_request_body_mb {
1052 user.max_request_body_mb
1053 } else {
1054 base.max_request_body_mb
1055 },
1056 context_policy: if user.context_policy != def.context_policy {
1057 user.context_policy
1058 } else {
1059 base.context_policy
1060 },
1061 }
1062}
1063
1064fn merge_telemetry(base: TelemetryConfig, user: TelemetryConfig) -> TelemetryConfig {
1065 let def = TelemetryConfig::default();
1066 let fail_open = if user.fail_open != def.fail_open {
1067 user.fail_open
1068 } else {
1069 base.fail_open
1070 };
1071 let query = merge_telemetry_query(base.query, user.query);
1072 let exporters = if !user.exporters.is_empty() {
1073 user.exporters
1074 } else {
1075 base.exporters
1076 };
1077 TelemetryConfig {
1078 fail_open,
1079 query,
1080 exporters,
1081 }
1082}
1083
1084fn merge_telemetry_query(
1085 base: TelemetryQueryConfig,
1086 user: TelemetryQueryConfig,
1087) -> TelemetryQueryConfig {
1088 let def = TelemetryQueryConfig::default();
1089 TelemetryQueryConfig {
1090 provider: if user.provider != def.provider {
1091 user.provider
1092 } else {
1093 base.provider
1094 },
1095 cache_ttl_seconds: if user.cache_ttl_seconds != def.cache_ttl_seconds {
1096 user.cache_ttl_seconds
1097 } else {
1098 base.cache_ttl_seconds
1099 },
1100 identity_allowlist: merge_identity_allowlist(
1101 base.identity_allowlist,
1102 user.identity_allowlist,
1103 ),
1104 }
1105}
1106
1107fn merge_identity_allowlist(base: IdentityAllowlist, user: IdentityAllowlist) -> IdentityAllowlist {
1108 let def = IdentityAllowlist::default();
1109 IdentityAllowlist {
1110 team: if user.team != def.team {
1111 user.team
1112 } else {
1113 base.team
1114 },
1115 workspace_label: if user.workspace_label != def.workspace_label {
1116 user.workspace_label
1117 } else {
1118 base.workspace_label
1119 },
1120 runner_label: if user.runner_label != def.runner_label {
1121 user.runner_label
1122 } else {
1123 base.runner_label
1124 },
1125 actor_kind: if user.actor_kind != def.actor_kind {
1126 user.actor_kind
1127 } else {
1128 base.actor_kind
1129 },
1130 actor_label: if user.actor_label != def.actor_label {
1131 user.actor_label
1132 } else {
1133 base.actor_label
1134 },
1135 agent: if user.agent != def.agent {
1136 user.agent
1137 } else {
1138 base.agent
1139 },
1140 model: if user.model != def.model {
1141 user.model
1142 } else {
1143 base.model
1144 },
1145 env: if user.env != def.env {
1146 user.env
1147 } else {
1148 base.env
1149 },
1150 job: if user.job != def.job {
1151 user.job
1152 } else {
1153 base.job
1154 },
1155 branch: if user.branch != def.branch {
1156 user.branch
1157 } else {
1158 base.branch
1159 },
1160 }
1161}
1162
1163fn merge_sync(base: SyncConfig, user: SyncConfig) -> SyncConfig {
1164 let def = SyncConfig::default();
1165 SyncConfig {
1166 endpoint: if !user.endpoint.is_empty() {
1167 user.endpoint
1168 } else {
1169 base.endpoint
1170 },
1171 team_token: if !user.team_token.is_empty() {
1172 user.team_token
1173 } else {
1174 base.team_token
1175 },
1176 team_id: if !user.team_id.is_empty() {
1177 user.team_id
1178 } else {
1179 base.team_id
1180 },
1181 events_per_batch_max: if user.events_per_batch_max != def.events_per_batch_max {
1182 user.events_per_batch_max
1183 } else {
1184 base.events_per_batch_max
1185 },
1186 max_body_bytes: if user.max_body_bytes != def.max_body_bytes {
1187 user.max_body_bytes
1188 } else {
1189 base.max_body_bytes
1190 },
1191 flush_interval_ms: if user.flush_interval_ms != def.flush_interval_ms {
1192 user.flush_interval_ms
1193 } else {
1194 base.flush_interval_ms
1195 },
1196 sample_rate: if (user.sample_rate - def.sample_rate).abs() > f64::EPSILON {
1197 user.sample_rate
1198 } else {
1199 base.sample_rate
1200 },
1201 team_salt_hex: if !user.team_salt_hex.is_empty() {
1202 user.team_salt_hex
1203 } else {
1204 base.team_salt_hex
1205 },
1206 }
1207}
1208
1209#[cfg(test)]
1210mod tests {
1211 use super::*;
1212 use std::io::Write;
1213 use tempfile::TempDir;
1214
1215 #[test]
1216 fn defaults_when_no_files() {
1217 let dir = TempDir::new().unwrap();
1218 let cfg = load(dir.path()).unwrap();
1219 assert_eq!(cfg.scan.roots, ScanConfig::default().roots);
1220 assert_eq!(cfg.scan.min_rescan_seconds, 300);
1221 assert_eq!(cfg.retention.hot_days, 30);
1222 assert_eq!(cfg.storage.cold_after_days, 7);
1223 assert_eq!(cfg.storage.hot_max_bytes_value(), 1_073_741_824);
1224 }
1225
1226 #[test]
1227 fn effective_redaction_salt_prefers_configured_team_salt() {
1228 let home = TempDir::new().unwrap();
1229 let sync = SyncConfig {
1230 team_salt_hex: "ab".repeat(32),
1231 ..Default::default()
1232 };
1233 let salt = effective_redaction_salt(&sync, home.path()).unwrap();
1234 assert_eq!(salt, [0xab_u8; 32]);
1235 assert!(!home.path().join("local_salt.hex").exists());
1237 }
1238
1239 #[test]
1240 fn effective_redaction_salt_generates_and_persists_local_salt() {
1241 let home = TempDir::new().unwrap();
1242 let sync = SyncConfig::default();
1243 let a = effective_redaction_salt(&sync, home.path()).unwrap();
1244 let b = effective_redaction_salt(&sync, home.path()).unwrap();
1245 assert_eq!(a, b, "second call must reuse the persisted local salt");
1246 assert!(home.path().join("local_salt.hex").exists());
1247 #[cfg(unix)]
1248 {
1249 use std::os::unix::fs::PermissionsExt;
1250 let mode = std::fs::metadata(home.path().join("local_salt.hex"))
1251 .unwrap()
1252 .permissions()
1253 .mode()
1254 & 0o777;
1255 assert_eq!(mode, 0o600);
1256 }
1257 }
1258
1259 #[test]
1260 fn workspace_config_loaded() {
1261 let _guard = crate::core::paths::test_lock::global().lock().unwrap();
1262 let home = TempDir::new().unwrap();
1263 let ws = TempDir::new().unwrap();
1264 unsafe { std::env::set_var("KAIZEN_HOME", home.path()) };
1265 let data_dir = crate::core::paths::project_data_dir(ws.path()).unwrap();
1266 let mut f = std::fs::File::create(data_dir.join("config.toml")).unwrap();
1267 writeln!(f, "[scan]\nroots = [\"/custom/root\"]").unwrap();
1268 let cfg = load(ws.path()).unwrap();
1269 unsafe { std::env::remove_var("KAIZEN_HOME") };
1270 assert_eq!(cfg.scan.roots, vec!["/custom/root"]);
1271 }
1272
1273 #[test]
1274 fn invalid_toml_ignored() {
1275 let _guard = crate::core::paths::test_lock::global().lock().unwrap();
1276 let home = TempDir::new().unwrap();
1277 let ws = TempDir::new().unwrap();
1278 unsafe { std::env::set_var("KAIZEN_HOME", home.path()) };
1279 let data_dir = crate::core::paths::project_data_dir(ws.path()).unwrap();
1280 std::fs::write(data_dir.join("config.toml"), "not valid toml :::").unwrap();
1281 let cfg = load(ws.path()).unwrap();
1282 unsafe { std::env::remove_var("KAIZEN_HOME") };
1283 assert_eq!(cfg.scan.roots, ScanConfig::default().roots);
1284 }
1285
1286 #[test]
1287 fn merge_user_roots_win() {
1288 let base = Config {
1289 scan: ScanConfig {
1290 roots: vec!["/base".to_string()],
1291 ..ScanConfig::default()
1292 },
1293 ..Default::default()
1294 };
1295 let user = Config {
1296 scan: ScanConfig {
1297 roots: vec!["/user".to_string()],
1298 ..ScanConfig::default()
1299 },
1300 ..Default::default()
1301 };
1302 let merged = merge(base, user);
1303 assert_eq!(merged.scan.roots, vec!["/user"]);
1304 }
1305
1306 #[test]
1307 fn merge_sources_user_default_keeps_workspace_cursor() {
1308 let base = Config {
1309 sources: SourcesConfig {
1310 cursor: CursorSourceConfig {
1311 enabled: false,
1312 transcript_glob: "/workspace/glob/**".into(),
1313 },
1314 ..Default::default()
1315 },
1316 ..Default::default()
1317 };
1318 let user = Config::default();
1319 let merged = merge(base, user);
1320 assert!(!merged.sources.cursor.enabled);
1321 assert_eq!(merged.sources.cursor.transcript_glob, "/workspace/glob/**");
1322 }
1323
1324 #[test]
1325 fn merge_retention_field_by_field() {
1326 let base = Config {
1327 retention: RetentionConfig {
1328 hot_days: 60,
1329 warm_days: 90,
1330 },
1331 ..Default::default()
1332 };
1333 let user = Config {
1334 retention: RetentionConfig {
1335 hot_days: 30,
1336 warm_days: 45,
1337 },
1338 ..Default::default()
1339 };
1340 let merged = merge(base, user);
1341 assert_eq!(merged.retention.hot_days, 60);
1342 assert_eq!(merged.retention.warm_days, 45);
1343 }
1344
1345 #[test]
1346 fn merge_retention_user_hot_overrides() {
1347 let base = Config {
1348 retention: RetentionConfig {
1349 hot_days: 60,
1350 warm_days: 90,
1351 },
1352 ..Default::default()
1353 };
1354 let user = Config {
1355 retention: RetentionConfig {
1356 hot_days: 14,
1357 warm_days: 90,
1358 },
1359 ..Default::default()
1360 };
1361 let merged = merge(base, user);
1362 assert_eq!(merged.retention.hot_days, 14);
1363 assert_eq!(merged.retention.warm_days, 90);
1364 }
1365
1366 #[test]
1367 fn merge_storage_user_overrides() {
1368 let base = Config {
1369 storage: StorageConfig {
1370 hot_max_bytes: "2GB".into(),
1371 cold_after_days: 14,
1372 retention_days: 120,
1373 flush_hour_utc: 3,
1374 },
1375 ..Default::default()
1376 };
1377 let user = Config {
1378 storage: StorageConfig {
1379 cold_after_days: 3,
1380 ..StorageConfig::default()
1381 },
1382 ..Default::default()
1383 };
1384 let merged = merge(base, user);
1385 assert_eq!(merged.storage.hot_max_bytes, "2GB");
1386 assert_eq!(merged.storage.cold_after_days, 3);
1387 assert_eq!(merged.storage.retention_days, 120);
1388 assert_eq!(merged.storage.flush_hour_utc, 3);
1389 }
1390
1391 #[test]
1392 fn merge_telemetry_exporters_user_wins_non_empty() {
1393 let base = Config {
1394 telemetry: TelemetryConfig {
1395 fail_open: true,
1396 query: TelemetryQueryConfig::default(),
1397 exporters: vec![ExporterConfig::None],
1398 },
1399 ..Default::default()
1400 };
1401 let user = Config {
1402 telemetry: TelemetryConfig {
1403 fail_open: false,
1404 query: TelemetryQueryConfig::default(),
1405 exporters: vec![ExporterConfig::Dev { enabled: true }],
1406 },
1407 ..Default::default()
1408 };
1409 let merged = merge(base, user);
1410 assert!(!merged.telemetry.fail_open);
1411 assert_eq!(merged.telemetry.exporters.len(), 1);
1412 }
1413
1414 #[test]
1415 fn telemetry_query_defaults() {
1416 let t = TelemetryQueryConfig::default();
1417 assert_eq!(t.provider, QueryAuthority::None);
1418 assert_eq!(t.cache_ttl_seconds, 3600);
1419 assert!(!t.identity_allowlist.team);
1420 assert!(!t.has_provider_for_pull());
1421 }
1422
1423 #[test]
1424 fn telemetry_query_has_provider() {
1425 let ph = TelemetryQueryConfig {
1426 provider: QueryAuthority::Posthog,
1427 ..Default::default()
1428 };
1429 assert!(ph.has_provider_for_pull());
1430 let dd = TelemetryQueryConfig {
1431 provider: QueryAuthority::Datadog,
1432 ..Default::default()
1433 };
1434 assert!(dd.has_provider_for_pull());
1435 }
1436
1437 #[test]
1438 fn merge_telemetry_query_user_wins() {
1439 let base = Config {
1440 telemetry: TelemetryConfig {
1441 query: TelemetryQueryConfig {
1442 provider: QueryAuthority::Posthog,
1443 cache_ttl_seconds: 3600,
1444 identity_allowlist: IdentityAllowlist {
1445 team: true,
1446 ..Default::default()
1447 },
1448 },
1449 ..Default::default()
1450 },
1451 ..Default::default()
1452 };
1453 let user = Config {
1454 telemetry: TelemetryConfig {
1455 query: TelemetryQueryConfig {
1456 cache_ttl_seconds: 7200,
1457 ..Default::default()
1458 },
1459 ..Default::default()
1460 },
1461 ..Default::default()
1462 };
1463 let merged = merge(base, user);
1464 assert_eq!(merged.telemetry.query.provider, QueryAuthority::Posthog);
1465 assert_eq!(merged.telemetry.query.cache_ttl_seconds, 7200);
1466 assert!(merged.telemetry.query.identity_allowlist.team);
1467 }
1468
1469 #[test]
1470 fn toml_telemetry_query_roundtrip() {
1471 let _guard = crate::core::paths::test_lock::global().lock().unwrap();
1472 let home = TempDir::new().unwrap();
1473 let ws = TempDir::new().unwrap();
1474 unsafe { std::env::set_var("KAIZEN_HOME", home.path()) };
1475 let data_dir = crate::core::paths::project_data_dir(ws.path()).unwrap();
1476 let toml = r#"
1477[telemetry.query]
1478provider = "datadog"
1479cache_ttl_seconds = 1800
1480
1481[telemetry.query.identity_allowlist]
1482team = true
1483branch = true
1484"#;
1485 std::fs::write(data_dir.join("config.toml"), toml).unwrap();
1486 let cfg = load(ws.path()).unwrap();
1487 unsafe { std::env::remove_var("KAIZEN_HOME") };
1488 assert_eq!(cfg.telemetry.query.provider, QueryAuthority::Datadog);
1489 assert_eq!(cfg.telemetry.query.cache_ttl_seconds, 1800);
1490 assert!(cfg.telemetry.query.identity_allowlist.team);
1491 assert!(cfg.telemetry.query.identity_allowlist.branch);
1492 assert!(!cfg.telemetry.query.identity_allowlist.model);
1493 }
1494}