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