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, Default)]
377pub struct Config {
378    #[serde(default)]
379    pub scan: ScanConfig,
380    #[serde(default)]
381    pub sources: SourcesConfig,
382    #[serde(default)]
383    pub retention: RetentionConfig,
384    #[serde(default)]
385    pub sync: SyncConfig,
386    #[serde(default)]
387    pub telemetry: TelemetryConfig,
388    #[serde(default)]
389    pub proxy: ProxyConfig,
390}
391
392/// Load config: workspace `.kaizen/config.toml` then `~/.kaizen/config.toml`.
393/// User config wins on overlap. Missing files → defaults, not error.
394pub fn load(workspace: &Path) -> Result<Config> {
395    let workspace_path = workspace.join(".kaizen/config.toml");
396    let user_path = home_dir()?.join(".kaizen/config.toml");
397
398    let base = load_file(&workspace_path).unwrap_or_default();
399    let user = load_file(&user_path).unwrap_or_default();
400    Ok(merge(base, user))
401}
402
403fn home_dir() -> Result<std::path::PathBuf> {
404    std::env::var("HOME")
405        .map(std::path::PathBuf::from)
406        .map_err(|e| anyhow::anyhow!("HOME not set: {e}"))
407}
408
409fn load_file(path: &Path) -> Option<Config> {
410    let text = std::fs::read_to_string(path).ok()?;
411    toml::from_str(&text).ok()
412}
413
414fn merge(base: Config, user: Config) -> Config {
415    Config {
416        scan: merge_scan(base.scan, user.scan),
417        sources: user.sources,
418        retention: merge_retention(base.retention, user.retention),
419        sync: merge_sync(base.sync, user.sync),
420        telemetry: merge_telemetry(base.telemetry, user.telemetry),
421        proxy: merge_proxy(base.proxy, user.proxy),
422    }
423}
424
425fn merge_scan(base: ScanConfig, user: ScanConfig) -> ScanConfig {
426    let def = ScanConfig::default();
427    ScanConfig {
428        roots: if user.roots != def.roots {
429            user.roots
430        } else {
431            base.roots
432        },
433        min_rescan_seconds: if user.min_rescan_seconds != def.min_rescan_seconds {
434            user.min_rescan_seconds
435        } else {
436            base.min_rescan_seconds
437        },
438    }
439}
440
441fn merge_retention(base: RetentionConfig, user: RetentionConfig) -> RetentionConfig {
442    let def = RetentionConfig::default();
443    RetentionConfig {
444        hot_days: if user.hot_days != def.hot_days {
445            user.hot_days
446        } else {
447            base.hot_days
448        },
449        warm_days: if user.warm_days != def.warm_days {
450            user.warm_days
451        } else {
452            base.warm_days
453        },
454    }
455}
456
457fn merge_proxy(base: ProxyConfig, user: ProxyConfig) -> ProxyConfig {
458    let def = ProxyConfig::default();
459    ProxyConfig {
460        listen: if user.listen != def.listen {
461            user.listen
462        } else {
463            base.listen
464        },
465        upstream: if user.upstream != def.upstream {
466            user.upstream
467        } else {
468            base.upstream
469        },
470        compress_transport: if user.compress_transport != def.compress_transport {
471            user.compress_transport
472        } else {
473            base.compress_transport
474        },
475        minify_json: if user.minify_json != def.minify_json {
476            user.minify_json
477        } else {
478            base.minify_json
479        },
480        max_response_body_mb: if user.max_response_body_mb != def.max_response_body_mb {
481            user.max_response_body_mb
482        } else {
483            base.max_response_body_mb
484        },
485        max_request_body_mb: if user.max_request_body_mb != def.max_request_body_mb {
486            user.max_request_body_mb
487        } else {
488            base.max_request_body_mb
489        },
490        context_policy: if user.context_policy != def.context_policy {
491            user.context_policy
492        } else {
493            base.context_policy
494        },
495    }
496}
497
498fn merge_telemetry(base: TelemetryConfig, user: TelemetryConfig) -> TelemetryConfig {
499    let def = TelemetryConfig::default();
500    let fail_open = if user.fail_open != def.fail_open {
501        user.fail_open
502    } else {
503        base.fail_open
504    };
505    let query = merge_telemetry_query(base.query, user.query);
506    let exporters = if !user.exporters.is_empty() {
507        user.exporters
508    } else {
509        base.exporters
510    };
511    TelemetryConfig {
512        fail_open,
513        query,
514        exporters,
515    }
516}
517
518fn merge_telemetry_query(
519    base: TelemetryQueryConfig,
520    user: TelemetryQueryConfig,
521) -> TelemetryQueryConfig {
522    let def = TelemetryQueryConfig::default();
523    TelemetryQueryConfig {
524        provider: if user.provider != def.provider {
525            user.provider
526        } else {
527            base.provider
528        },
529        cache_ttl_seconds: if user.cache_ttl_seconds != def.cache_ttl_seconds {
530            user.cache_ttl_seconds
531        } else {
532            base.cache_ttl_seconds
533        },
534        identity_allowlist: merge_identity_allowlist(
535            base.identity_allowlist,
536            user.identity_allowlist,
537        ),
538    }
539}
540
541fn merge_identity_allowlist(base: IdentityAllowlist, user: IdentityAllowlist) -> IdentityAllowlist {
542    let def = IdentityAllowlist::default();
543    IdentityAllowlist {
544        team: if user.team != def.team {
545            user.team
546        } else {
547            base.team
548        },
549        workspace_label: if user.workspace_label != def.workspace_label {
550            user.workspace_label
551        } else {
552            base.workspace_label
553        },
554        runner_label: if user.runner_label != def.runner_label {
555            user.runner_label
556        } else {
557            base.runner_label
558        },
559        actor_kind: if user.actor_kind != def.actor_kind {
560            user.actor_kind
561        } else {
562            base.actor_kind
563        },
564        actor_label: if user.actor_label != def.actor_label {
565            user.actor_label
566        } else {
567            base.actor_label
568        },
569        agent: if user.agent != def.agent {
570            user.agent
571        } else {
572            base.agent
573        },
574        model: if user.model != def.model {
575            user.model
576        } else {
577            base.model
578        },
579        env: if user.env != def.env {
580            user.env
581        } else {
582            base.env
583        },
584        job: if user.job != def.job {
585            user.job
586        } else {
587            base.job
588        },
589        branch: if user.branch != def.branch {
590            user.branch
591        } else {
592            base.branch
593        },
594    }
595}
596
597fn merge_sync(base: SyncConfig, user: SyncConfig) -> SyncConfig {
598    let def = SyncConfig::default();
599    SyncConfig {
600        endpoint: if !user.endpoint.is_empty() {
601            user.endpoint
602        } else {
603            base.endpoint
604        },
605        team_token: if !user.team_token.is_empty() {
606            user.team_token
607        } else {
608            base.team_token
609        },
610        team_id: if !user.team_id.is_empty() {
611            user.team_id
612        } else {
613            base.team_id
614        },
615        events_per_batch_max: if user.events_per_batch_max != def.events_per_batch_max {
616            user.events_per_batch_max
617        } else {
618            base.events_per_batch_max
619        },
620        max_body_bytes: if user.max_body_bytes != def.max_body_bytes {
621            user.max_body_bytes
622        } else {
623            base.max_body_bytes
624        },
625        flush_interval_ms: if user.flush_interval_ms != def.flush_interval_ms {
626            user.flush_interval_ms
627        } else {
628            base.flush_interval_ms
629        },
630        sample_rate: if (user.sample_rate - def.sample_rate).abs() > f64::EPSILON {
631            user.sample_rate
632        } else {
633            base.sample_rate
634        },
635        team_salt_hex: if !user.team_salt_hex.is_empty() {
636            user.team_salt_hex
637        } else {
638            base.team_salt_hex
639        },
640    }
641}
642
643#[cfg(test)]
644mod tests {
645    use super::*;
646    use std::io::Write;
647    use tempfile::TempDir;
648
649    #[test]
650    fn defaults_when_no_files() {
651        let dir = TempDir::new().unwrap();
652        let cfg = load(dir.path()).unwrap();
653        assert_eq!(cfg.scan.roots, ScanConfig::default().roots);
654        assert_eq!(cfg.scan.min_rescan_seconds, 300);
655        assert_eq!(cfg.retention.hot_days, 30);
656    }
657
658    #[test]
659    fn workspace_config_loaded() {
660        let dir = TempDir::new().unwrap();
661        std::fs::create_dir_all(dir.path().join(".kaizen")).unwrap();
662        let mut f = std::fs::File::create(dir.path().join(".kaizen/config.toml")).unwrap();
663        writeln!(f, "[scan]\nroots = [\"/custom/root\"]").unwrap();
664
665        let cfg = load(dir.path()).unwrap();
666        assert_eq!(cfg.scan.roots, vec!["/custom/root"]);
667    }
668
669    #[test]
670    fn invalid_toml_ignored() {
671        let dir = TempDir::new().unwrap();
672        std::fs::create_dir_all(dir.path().join(".kaizen")).unwrap();
673        std::fs::write(dir.path().join(".kaizen/config.toml"), "not valid toml :::").unwrap();
674
675        let cfg = load(dir.path()).unwrap();
676        assert_eq!(cfg.scan.roots, ScanConfig::default().roots);
677    }
678
679    #[test]
680    fn merge_user_roots_win() {
681        let base = Config {
682            scan: ScanConfig {
683                roots: vec!["/base".to_string()],
684                ..ScanConfig::default()
685            },
686            ..Default::default()
687        };
688        let user = Config {
689            scan: ScanConfig {
690                roots: vec!["/user".to_string()],
691                ..ScanConfig::default()
692            },
693            ..Default::default()
694        };
695        let merged = merge(base, user);
696        assert_eq!(merged.scan.roots, vec!["/user"]);
697    }
698
699    #[test]
700    fn merge_retention_field_by_field() {
701        let base = Config {
702            retention: RetentionConfig {
703                hot_days: 60,
704                warm_days: 90,
705            },
706            ..Default::default()
707        };
708        let user = Config {
709            retention: RetentionConfig {
710                hot_days: 30,
711                warm_days: 45,
712            },
713            ..Default::default()
714        };
715        let merged = merge(base, user);
716        assert_eq!(merged.retention.hot_days, 60);
717        assert_eq!(merged.retention.warm_days, 45);
718    }
719
720    #[test]
721    fn merge_retention_user_hot_overrides() {
722        let base = Config {
723            retention: RetentionConfig {
724                hot_days: 60,
725                warm_days: 90,
726            },
727            ..Default::default()
728        };
729        let user = Config {
730            retention: RetentionConfig {
731                hot_days: 14,
732                warm_days: 90,
733            },
734            ..Default::default()
735        };
736        let merged = merge(base, user);
737        assert_eq!(merged.retention.hot_days, 14);
738        assert_eq!(merged.retention.warm_days, 90);
739    }
740
741    #[test]
742    fn merge_telemetry_exporters_user_wins_non_empty() {
743        let base = Config {
744            telemetry: TelemetryConfig {
745                fail_open: true,
746                query: TelemetryQueryConfig::default(),
747                exporters: vec![ExporterConfig::None],
748            },
749            ..Default::default()
750        };
751        let user = Config {
752            telemetry: TelemetryConfig {
753                fail_open: false,
754                query: TelemetryQueryConfig::default(),
755                exporters: vec![ExporterConfig::Dev { enabled: true }],
756            },
757            ..Default::default()
758        };
759        let merged = merge(base, user);
760        assert!(!merged.telemetry.fail_open);
761        assert_eq!(merged.telemetry.exporters.len(), 1);
762    }
763
764    #[test]
765    fn telemetry_query_defaults() {
766        let t = TelemetryQueryConfig::default();
767        assert_eq!(t.provider, QueryAuthority::None);
768        assert_eq!(t.cache_ttl_seconds, 3600);
769        assert!(!t.identity_allowlist.team);
770        assert!(!t.has_provider_for_pull());
771    }
772
773    #[test]
774    fn telemetry_query_has_provider() {
775        let ph = TelemetryQueryConfig {
776            provider: QueryAuthority::Posthog,
777            ..Default::default()
778        };
779        assert!(ph.has_provider_for_pull());
780        let dd = TelemetryQueryConfig {
781            provider: QueryAuthority::Datadog,
782            ..Default::default()
783        };
784        assert!(dd.has_provider_for_pull());
785    }
786
787    #[test]
788    fn merge_telemetry_query_user_wins() {
789        let base = Config {
790            telemetry: TelemetryConfig {
791                query: TelemetryQueryConfig {
792                    provider: QueryAuthority::Posthog,
793                    cache_ttl_seconds: 3600,
794                    identity_allowlist: IdentityAllowlist {
795                        team: true,
796                        ..Default::default()
797                    },
798                },
799                ..Default::default()
800            },
801            ..Default::default()
802        };
803        let user = Config {
804            telemetry: TelemetryConfig {
805                query: TelemetryQueryConfig {
806                    cache_ttl_seconds: 7200,
807                    ..Default::default()
808                },
809                ..Default::default()
810            },
811            ..Default::default()
812        };
813        let merged = merge(base, user);
814        assert_eq!(merged.telemetry.query.provider, QueryAuthority::Posthog);
815        assert_eq!(merged.telemetry.query.cache_ttl_seconds, 7200);
816        assert!(merged.telemetry.query.identity_allowlist.team);
817    }
818
819    #[test]
820    fn toml_telemetry_query_roundtrip() {
821        let dir = TempDir::new().unwrap();
822        std::fs::create_dir_all(dir.path().join(".kaizen")).unwrap();
823        let toml = r#"
824[telemetry.query]
825provider = "datadog"
826cache_ttl_seconds = 1800
827
828[telemetry.query.identity_allowlist]
829team = true
830branch = true
831"#;
832        std::fs::write(dir.path().join(".kaizen/config.toml"), toml).unwrap();
833        let cfg = load(dir.path()).unwrap();
834        assert_eq!(cfg.telemetry.query.provider, QueryAuthority::Datadog);
835        assert_eq!(cfg.telemetry.query.cache_ttl_seconds, 1800);
836        assert!(cfg.telemetry.query.identity_allowlist.team);
837        assert!(cfg.telemetry.query.identity_allowlist.branch);
838        assert!(!cfg.telemetry.query.identity_allowlist.model);
839    }
840}