Skip to main content

kaizen/core/
config.rs

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