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