Skip to main content

kaizen/core/
config.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2//! Config loading: project data config, then user config.
3//! Missing files → defaults. User config wins on overlap.
4
5use 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    /// Minimum seconds between full agent transcript rescans when `--refresh` is not passed.
13    #[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/// Enable tier-1 tail ingestion for agents that store data outside Cursor/Claude/Codex paths.
46#[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    /// When empty, sync is disabled (no outbox enqueue, `sync run` no-ops flush).
138    #[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    /// 64 hex chars (32 bytes). Prefer `~/.kaizen/config.toml` only; never committed workspace secrets.
153    #[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
188/// Parse `team_salt_hex` into 32 bytes. Returns `None` if missing or invalid.
189pub 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
198/// Resolve a 32-byte redaction salt for telemetry-only flows (push/test) when sync is not
199/// configured. Order: configured `[sync].team_salt_hex` → `<kaizen_home>/local_salt.hex`
200/// → freshly generated and persisted at `0o600`. Telemetry never blocks on cloud sync.
201pub 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/// Which third-party system is the single source for query-back / pull; OTLP is export-only, not a pull target.
274#[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/// Per-field allowlist: when `false` (default), the field is omitted or hashed in telemetry exports.
284#[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/// Remote pull: query authority, cache TTL, and which identity labels may leave as cleartext.
309#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
310pub struct TelemetryQueryConfig {
311    /// `posthog` or `datadog` enables provider pull when implemented; `none` or unset = no query authority.
312    #[serde(default)]
313    pub provider: QueryAuthority,
314    /// Seconds to treat remote cache rows as fresh (unless the CLI requests `--refresh`).
315    #[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    /// True when a PostHog or Datadog pull backend may be used (OTLP is not a pull target).
333    pub fn has_provider_for_pull(&self) -> bool {
334        matches!(
335            self.provider,
336            QueryAuthority::Posthog | QueryAuthority::Datadog
337        )
338    }
339}
340
341/// How to reduce billed input to the model (opt-in; default leaves requests unchanged).
342#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
343#[serde(tag = "type", rename_all = "snake_case")]
344pub enum ContextPolicy {
345    /// No transformation beyond optional JSON minify (same tokens as a direct call).
346    #[default]
347    None,
348    /// Keep the last `count` `messages` array entries; system blocks unchanged when present.
349    LastMessages { count: usize },
350    /// Drop oldest messages until a rough `chars/4` estimate stays at or below `max`.
351    MaxInputTokens { max: u32 },
352}
353
354/// Anthropic API-compatible HTTP proxy: forward + local telemetry. See `docs/llm-proxy.md`.
355#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
356pub struct ProxyConfig {
357    /// e.g. `127.0.0.1:3847` (bind address for `kaizen proxy run`).
358    #[serde(default = "default_proxy_listen")]
359    pub listen: String,
360    /// Base URL, no trailing slash, e.g. `https://api.anthropic.com`.
361    #[serde(default = "default_proxy_upstream")]
362    pub upstream: String,
363    /// `anthropic`, `openai`, or `auto`; controls launcher/env hints and default upstream.
364    #[serde(default = "default_proxy_provider")]
365    pub provider: String,
366    /// Prefer `Accept-Encoding: gzip` to upstream (response bodies may be gzip).
367    #[serde(default = "default_true")]
368    pub compress_transport: bool,
369    /// Re-encode JSON bodies to compact `serde_json` (no key reorder; whitespace only).
370    #[serde(default = "default_true")]
371    pub minify_json: bool,
372    /// Slurp cap for a single upstream response (streaming not yet teed; see doc).
373    #[serde(default = "default_proxy_max_body_mb")]
374    pub max_response_body_mb: u32,
375    /// Reject / fail incoming client bodies above this (POST bodies before forward).
376    #[serde(default = "default_proxy_max_request_body_mb")]
377    pub max_request_body_mb: u32,
378    /// Optional token-aware truncation of `messages` in JSON bodies.
379    #[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/// Optional third-party telemetry sinks; same redacted batches as Kaizen sync.
419#[derive(Debug, Clone, Serialize, Deserialize)]
420pub struct TelemetryConfig {
421    /// When `true` (default), ignore exporter errors; when `false`, `flush` fails if any secondary errors.
422    #[serde(default = "default_telemetry_fail_open")]
423    pub fail_open: bool,
424    /// Query-back / pull API: authority, cache TTL, identity allowlist.
425    #[serde(default)]
426    pub query: TelemetryQueryConfig,
427    /// Declarative list; `type = "none"` rows are accepted and ignored.
428    #[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/// One pluggable sink; TOML `type` is the tag.
443#[derive(Debug, Clone, Serialize, Deserialize)]
444#[serde(tag = "type", rename_all = "lowercase")]
445pub enum ExporterConfig {
446    /// No-op row for sparse tables / templates.
447    None,
448    /// Append summaries under project data, or to an absolute path outside the workspace.
449    File {
450        #[serde(default = "default_true")]
451        enabled: bool,
452        #[serde(default)]
453        path: Option<String>,
454    },
455    /// Echo to tracing (for wiring tests; requires the `telemetry-dev` build feature).
456    Dev {
457        #[serde(default = "default_true")]
458        enabled: bool,
459    },
460    PostHog {
461        #[serde(default = "default_true")]
462        enabled: bool,
463        /// e.g. `https://us.i.posthog.com` (default when unset)
464        host: Option<String>,
465        /// Prefer env `POSTHOG_API_KEY` or `KAIZEN_POSTHOG_API_KEY`
466        project_api_key: Option<String>,
467    },
468    Datadog {
469        #[serde(default = "default_true")]
470        enabled: bool,
471        /// e.g. `datadoghq.com`; env `DD_SITE` overrides
472        site: Option<String>,
473        /// Prefer env `DD_API_KEY` or `KAIZEN_DD_API_KEY`
474        api_key: Option<String>,
475    },
476    Otlp {
477        #[serde(default = "default_true")]
478        enabled: bool,
479        /// Env `OTEL_EXPORTER_OTLP_ENDPOINT` (or KAIZEN_ prefix) when unset here
480        endpoint: Option<String>,
481    },
482}
483
484impl ExporterConfig {
485    /// Whether this row should be considered for `load_exporters` (excludes `None`).
486    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/// Opt-in post-hook outcome measurement (Tier C).
592#[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/// Opt-in per-process sampling (Tier D).
624#[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
684/// Load config: `~/.kaizen/projects/<slug>/config.toml` then `~/.kaizen/config.toml`.
685/// User config wins on overlap. Missing files → defaults, not error.
686pub 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        // No local file written when team salt was sufficient.
1236        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}