Skip to main content

mur_common/
config.rs

1use serde::{Deserialize, Serialize};
2use std::path::PathBuf;
3
4pub const DEFAULT_LOCAL_LLM_MODEL: &str = "qwen3.5:4b";
5
6/// Default model id seeded for the built-in "Mur" agent and used to name the
7/// bundled MLX weights. This is the DEFAULT VALUE only — it is written into the
8/// seed agent's profile and can be changed by the user afterwards; it is not a
9/// behavioural constant baked into logic.
10pub const DEFAULT_BUNDLED_MODEL_ID: &str = "Qwen3.5-2B-MLX-4bit";
11
12/// Global MUR configuration (~/.mur/config.yaml)
13#[derive(Debug, Clone, Serialize, Deserialize, Default)]
14pub struct Config {
15    #[serde(default)]
16    pub embedding: EmbeddingConfig,
17
18    #[serde(default)]
19    pub llm: LlmConfig,
20
21    #[serde(default)]
22    pub retrieval: RetrievalConfig,
23
24    #[serde(default)]
25    pub paths: PathConfig,
26
27    #[serde(default)]
28    pub server: ServerConfig,
29
30    #[serde(default)]
31    pub community: CommunityConfig,
32
33    #[serde(default)]
34    pub conversations: ConversationsConfig,
35
36    #[serde(default)]
37    pub sync: SyncConfig,
38
39    // --- P1.1 additions ---
40    #[serde(default)]
41    pub storage: StorageConfig,
42
43    #[serde(default)]
44    pub sources_global: SourcesGlobalConfig,
45
46    // --- E3 additions ---
47    #[serde(default)]
48    pub sleep_cycle: SleepCycleConfig,
49
50    // --- M2 additions ---
51    #[serde(default)]
52    pub skills: SkillsConfig,
53
54    // --- M6c additions ---
55    #[serde(default)]
56    pub skill_llm: SkillLlmConfig,
57
58    // --- M7a additions ---
59    #[serde(default)]
60    pub cross_agent: CrossAgentConfig,
61
62    // --- nudge additions ---
63    #[serde(default)]
64    pub nudge: NudgeConfig,
65
66    // --- mobile P4 additions ---
67    #[serde(default)]
68    pub mobile_relay: MobileRelayConfig,
69
70    // --- Ambient capture & harvest (2026-06-11 spec) ---
71    #[serde(default)]
72    pub session: SessionCfg,
73
74    #[serde(default)]
75    pub harvest: HarvestCfg,
76
77    // --- OAuth bridge (cc-proxy) routing for subscription tokens ---
78    #[serde(default)]
79    pub cc_proxy: CcProxyConfig,
80
81    // --- Agent CLI TUI ---
82    #[serde(default)]
83    pub cli: CliConfig,
84}
85
86/// Routing for Anthropic subscription-OAuth (`sk-ant-oat*`) tokens through a
87/// local bridge — cc-proxy — that swaps `x-api-key` for the Bearer +
88/// claude-code betas disguise the upstream requires.
89///
90/// The Hub injects `ANTHROPIC_BASE_URL` pointing at [`url`](Self::url) when it
91/// spawns an agent runtime, but only when [`enabled`](Self::enabled) is set and
92/// the bridge is actually listening; otherwise it leaves the runtime on the
93/// direct `api.anthropic.com` path (where an oat token would 401). A runtime
94/// launched with `ANTHROPIC_BASE_URL` already in its environment is never
95/// overridden.
96#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct CcProxyConfig {
98    /// Bridge base URL. Defaults to cc-proxy's default bind.
99    #[serde(default = "default_cc_proxy_url")]
100    pub url: String,
101
102    /// Master switch. When false the Hub never routes runtimes through the
103    /// bridge, regardless of reachability.
104    #[serde(default = "default_true")]
105    pub enabled: bool,
106}
107
108fn default_cc_proxy_url() -> String {
109    "http://127.0.0.1:8088".to_string()
110}
111
112fn default_true() -> bool {
113    true
114}
115
116impl Default for CcProxyConfig {
117    fn default() -> Self {
118        Self {
119            url: default_cc_proxy_url(),
120            enabled: true,
121        }
122    }
123}
124
125/// Configuration for the agent CLI TUI.
126/// Stored in ~/.mur/config.yaml under the `cli:` key.
127#[derive(Debug, Clone, Serialize, Deserialize, Default)]
128pub struct CliConfig {
129    /// Default visual skin for `mur agent cli`. Overridable with --skin.
130    /// Valid values: "dark" (default), "light", "mur".
131    pub skin: Option<String>,
132}
133
134/// Configuration for the mobile relay (P4).
135/// Stored in ~/.mur/config.yaml under the `mobile_relay:` key.
136#[derive(Debug, Clone, Serialize, Deserialize, Default)]
137pub struct MobileRelayConfig {
138    /// Base URL of the mur-server relay, e.g. "wss://relay.mur.run".
139    /// Leave blank to disable relay forwarding on the Mac daemon side.
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    pub relay_url: Option<String>,
142
143    /// API key or JWT used by the Mac daemon to authenticate with the relay.
144    /// The value is typically a `mur_...` API key from app.mur.run.
145    #[serde(default, skip_serializing_if = "Option::is_none")]
146    pub api_key: Option<String>,
147}
148
149impl Config {
150    /// Read from disk, falling back to defaults.
151    pub fn load_or_default(path: &std::path::Path) -> Self {
152        std::fs::read_to_string(path)
153            .ok()
154            .and_then(|s| serde_yaml_ng::from_str(&s).ok())
155            .unwrap_or_default()
156    }
157}
158
159#[derive(Debug, Clone, Serialize, Deserialize, Default)]
160pub struct SyncConfig {
161    /// Sync method: "cloud", "git", or "local"
162    #[serde(default = "default_sync_method")]
163    pub method: String,
164
165    /// Git remote URL for git sync
166    #[serde(default, skip_serializing_if = "Option::is_none")]
167    pub git_remote: Option<String>,
168
169    /// Auto-sync on context pull / session stop
170    #[serde(default)]
171    pub auto: bool,
172
173    /// Default team ID for cloud sync (set on first successful sync)
174    #[serde(default, skip_serializing_if = "Option::is_none")]
175    pub team_id: Option<String>,
176}
177
178fn default_sync_method() -> String {
179    "local".to_string()
180}
181
182#[derive(Debug, Clone, Serialize, Deserialize)]
183pub struct ServerConfig {
184    /// Server URL (default: https://mur-server.fly.dev)
185    #[serde(default = "default_server_url")]
186    pub url: String,
187}
188
189impl Default for ServerConfig {
190    fn default() -> Self {
191        Self {
192            url: default_server_url(),
193        }
194    }
195}
196
197#[derive(Debug, Clone, Serialize, Deserialize, Default)]
198pub struct CommunityConfig {
199    /// Whether community pattern sharing is enabled
200    #[serde(default)]
201    pub enabled: bool,
202}
203
204#[derive(Debug, Clone, Serialize, Deserialize)]
205pub struct EmbeddingConfig {
206    /// "ollama", "openai", "gemini", or "anthropic"
207    #[serde(default = "default_embedding_provider")]
208    pub provider: String,
209
210    /// Model name (e.g. "nomic-embed-text", "text-embedding-3-small")
211    #[serde(default = "default_embedding_model")]
212    pub model: String,
213
214    /// Vector dimensions (fixed after first index build)
215    #[serde(default = "default_dimensions")]
216    pub dimensions: usize,
217
218    /// Ollama endpoint
219    #[serde(default = "default_ollama_endpoint")]
220    pub ollama_endpoint: String,
221
222    /// API key env var name (e.g. "OPENAI_API_KEY")
223    #[serde(default, skip_serializing_if = "Option::is_none")]
224    pub api_key_env: Option<String>,
225
226    /// Custom OpenAI-compatible API URL (e.g. for OpenRouter)
227    #[serde(default, skip_serializing_if = "Option::is_none")]
228    pub openai_url: Option<String>,
229}
230
231impl Default for EmbeddingConfig {
232    fn default() -> Self {
233        Self {
234            provider: default_embedding_provider(),
235            model: default_embedding_model(),
236            dimensions: default_dimensions(),
237            ollama_endpoint: default_ollama_endpoint(),
238            api_key_env: None,
239            openai_url: None,
240        }
241    }
242}
243
244#[derive(Debug, Clone, Serialize, Deserialize)]
245pub struct LlmConfig {
246    /// "anthropic", "openai", "gemini", or "ollama"
247    #[serde(default = "default_llm_provider")]
248    pub provider: String,
249
250    #[serde(default = "default_llm_model")]
251    pub model: String,
252
253    /// API key env var name (e.g. "ANTHROPIC_API_KEY")
254    #[serde(default, skip_serializing_if = "Option::is_none")]
255    pub api_key_env: Option<String>,
256
257    /// Custom OpenAI-compatible API URL (e.g. for OpenRouter)
258    #[serde(default, skip_serializing_if = "Option::is_none")]
259    pub openai_url: Option<String>,
260}
261
262impl Default for LlmConfig {
263    fn default() -> Self {
264        Self {
265            provider: default_llm_provider(),
266            model: default_llm_model(),
267            api_key_env: Some("ANTHROPIC_API_KEY".to_string()),
268            openai_url: None,
269        }
270    }
271}
272
273impl LlmConfig {
274    /// Convert legacy LlmConfig (used by extract_llm, learn, capture/starter)
275    /// into a BackendConfig that the new ChatBackend factory consumes.
276    /// Mapping:
277    /// - `provider` 1:1, except: unknown providers WITH openai_url become "openai"
278    ///   (preserves the historical LlmConfig::llm_complete fall-through for
279    ///   OpenAI-compatible passthrough proxies).
280    /// - `model` 1:1.
281    /// - `api_key_env` 1:1 (factory's resolve_api_key falls back to
282    ///   default_key_env(provider) when None — preserves LlmConfig behavior).
283    /// - `openai_url` → `endpoint` (semantic rename; same string semantics).
284    /// - `timeout_secs` always None (factory defaults to 120s — matches
285    ///   the historical 60s reqwest default behavior closely enough).
286    pub fn to_backend_config(&self) -> BackendConfig {
287        let provider = match self.provider.as_str() {
288            "anthropic" | "openai" | "openrouter" | "gemini" | "ollama" => self.provider.clone(),
289            _ if self.openai_url.is_some() => "openai".into(),
290            other => other.into(), // factory will reject with "unsupported provider"
291        };
292        BackendConfig {
293            provider,
294            model: self.model.clone(),
295            endpoint: self.openai_url.clone(),
296            api_key_env: self.api_key_env.clone(),
297            timeout_secs: None,
298        }
299    }
300}
301
302/// Backend selection for a single chat-completion call site.
303///
304/// Per spec §6 of cloud-LLM-backend design. Used by `CompactConfig`
305/// (per-stage) and `AskConfig` (per-stage) to override the legacy
306/// Ollama-only path. None of the `Option` fields are required;
307/// resolution falls back to provider defaults
308/// (ollama: http://localhost:11434, anthropic: https://api.anthropic.com).
309///
310/// Stays in mur-common (not mur-core) because it is pure data and
311/// will be reused by mur-agent-runtime in a future phase.
312#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
313#[serde(default)]
314pub struct BackendConfig {
315    /// "ollama" | "anthropic". Defaults to "ollama" for backward compat.
316    pub provider: String,
317    /// Model name as the provider sees it ("claude-haiku-4-5", "qwen3:4b", …).
318    pub model: String,
319    /// Provider endpoint. None = provider default
320    /// (ollama: http://localhost:11434, anthropic: https://api.anthropic.com).
321    pub endpoint: Option<String>,
322    /// Env var holding the API key. None = no auth (ollama).
323    pub api_key_env: Option<String>,
324    /// Per-call timeout in seconds. None = 120s.
325    pub timeout_secs: Option<u64>,
326}
327
328impl Default for BackendConfig {
329    fn default() -> Self {
330        Self {
331            provider: "ollama".into(),
332            model: DEFAULT_LOCAL_LLM_MODEL.into(),
333            endpoint: None,
334            api_key_env: None,
335            timeout_secs: None,
336        }
337    }
338}
339
340#[derive(Debug, Clone, Serialize, Deserialize)]
341pub struct RetrievalConfig {
342    /// Max patterns to inject per query
343    #[serde(default = "default_max_patterns")]
344    pub max_patterns: usize,
345
346    /// Max tokens for injected content
347    #[serde(default = "default_max_tokens")]
348    pub max_tokens: usize,
349
350    /// Minimum score threshold
351    #[serde(default = "default_min_score")]
352    pub min_score: f64,
353
354    /// MMR diversity threshold (cosine > this = too similar)
355    #[serde(default = "default_mmr_threshold")]
356    pub mmr_threshold: f64,
357}
358
359impl Default for RetrievalConfig {
360    fn default() -> Self {
361        Self {
362            max_patterns: default_max_patterns(),
363            max_tokens: default_max_tokens(),
364            min_score: default_min_score(),
365            mmr_threshold: default_mmr_threshold(),
366        }
367    }
368}
369
370#[derive(Debug, Clone, Serialize, Deserialize)]
371pub struct PathConfig {
372    /// Root MUR directory (default: ~/.mur)
373    #[serde(default = "default_mur_dir")]
374    pub mur_dir: PathBuf,
375}
376
377impl Default for PathConfig {
378    fn default() -> Self {
379        Self {
380            mur_dir: default_mur_dir(),
381        }
382    }
383}
384
385#[derive(Debug, Clone, Serialize, Deserialize)]
386pub struct StorageConfig {
387    /// Vector backend identifier: "lancedb" (default) or "qdrant".
388    #[serde(default = "default_vector_backend")]
389    pub vector_backend: String,
390
391    /// Qdrant connection URL (only used when vector_backend = "qdrant").
392    #[serde(default, skip_serializing_if = "Option::is_none")]
393    pub qdrant_url: Option<String>,
394
395    /// Keyring account name holding the Qdrant API key, if any.
396    #[serde(default, skip_serializing_if = "Option::is_none")]
397    pub qdrant_api_key_ref: Option<String>,
398}
399
400impl Default for StorageConfig {
401    fn default() -> Self {
402        Self {
403            vector_backend: default_vector_backend(),
404            qdrant_url: None,
405            qdrant_api_key_ref: None,
406        }
407    }
408}
409
410fn default_vector_backend() -> String {
411    "lancedb".to_string()
412}
413
414#[derive(Debug, Clone, Serialize, Deserialize)]
415pub struct SourcesGlobalConfig {
416    /// Polling interval for cloud sources (seconds).
417    #[serde(default = "default_poll_interval_secs")]
418    pub poll_interval_secs: u64,
419
420    /// Safety cap: do not sync more than this many chunks per run.
421    #[serde(default = "default_max_chunks_per_sync")]
422    pub max_chunks_per_sync: usize,
423
424    /// Upper bound on parallel source sync tasks.
425    #[serde(default = "default_max_parallel_sources")]
426    pub max_parallel_sources: usize,
427
428    /// Weight applied to new sources unless overridden.
429    #[serde(default = "default_source_weight")]
430    pub default_weight: f32,
431
432    /// Embedding request batch size.
433    #[serde(default = "default_embedding_batch_size")]
434    pub embedding_batch_size: usize,
435}
436
437impl Default for SourcesGlobalConfig {
438    fn default() -> Self {
439        Self {
440            poll_interval_secs: default_poll_interval_secs(),
441            max_chunks_per_sync: default_max_chunks_per_sync(),
442            max_parallel_sources: default_max_parallel_sources(),
443            default_weight: default_source_weight(),
444            embedding_batch_size: default_embedding_batch_size(),
445        }
446    }
447}
448
449fn default_poll_interval_secs() -> u64 {
450    600
451}
452fn default_max_chunks_per_sync() -> usize {
453    10_000
454}
455fn default_max_parallel_sources() -> usize {
456    3
457}
458fn default_source_weight() -> f32 {
459    1.0
460}
461fn default_embedding_batch_size() -> usize {
462    32
463}
464
465fn default_embedding_provider() -> String {
466    "ollama".to_string()
467}
468fn default_embedding_model() -> String {
469    "qwen3-embedding:0.6b".to_string()
470}
471fn default_dimensions() -> usize {
472    1024
473}
474fn default_ollama_endpoint() -> String {
475    "http://localhost:11434".to_string()
476}
477fn default_llm_provider() -> String {
478    "anthropic".to_string()
479}
480fn default_llm_model() -> String {
481    "claude-opus-4-6".to_string()
482}
483fn default_max_patterns() -> usize {
484    5
485}
486fn default_max_tokens() -> usize {
487    2000
488}
489fn default_min_score() -> f64 {
490    0.35
491}
492fn default_mmr_threshold() -> f64 {
493    0.85
494}
495fn default_mur_dir() -> PathBuf {
496    // Use HOME env var directly to avoid the `dirs` dependency in mur-common.
497    // Callers in mur-core that need the real home dir should use `dirs` there.
498    let home = std::env::var("HOME")
499        .map(PathBuf::from)
500        .unwrap_or_else(|_| PathBuf::from("/tmp"));
501    home.join(".mur")
502}
503fn default_server_url() -> String {
504    "https://mur-server.fly.dev".to_string()
505}
506
507// ── Ask config (Phase 2B, Task 18) ───────────────────────────────────────────
508
509#[derive(Debug, Clone, Serialize, Deserialize)]
510pub struct AskConfig {
511    #[serde(default = "ask_default_model")]
512    pub model: String,
513    #[serde(default = "compact_default_ollama_endpoint")]
514    pub ollama_endpoint: String,
515    #[serde(default = "ask_default_k_summary")]
516    pub k_summary: u32,
517    #[serde(default = "ask_default_k_raw")]
518    pub k_raw: u32,
519    #[serde(default = "ask_default_esc")]
520    pub escalation_threshold: f64,
521    #[serde(default = "ask_default_mmr")]
522    pub mmr_threshold: f64,
523    #[serde(default = "ask_default_max_ctx")]
524    pub max_context_tokens: u32,
525    #[serde(default = "ask_default_resp_tok")]
526    pub response_tokens: u32,
527    #[serde(default = "ask_default_timeout")]
528    pub timeout_secs: u32,
529    #[serde(default = "ask_default_min_score")]
530    pub min_score: f64,
531    #[serde(default = "ask_default_continue_history_turns")]
532    pub continue_history_turns: u32,
533    /// Separate, shorter timeout for the rewriter LLM call (Phase 3.3).
534    /// Rewriter output is small (~80 tokens) and falling back to the raw
535    /// question on failure is non-fatal, so we don't want to burn the full
536    /// `timeout_secs` budget waiting on a slow/unreachable Ollama before
537    /// the user sees any response.
538    #[serde(default = "ask_default_rewriter_timeout")]
539    pub rewriter_timeout_secs: u32,
540    #[serde(default = "ask_default_compress_hits_enabled")]
541    pub compress_hits_enabled: bool,
542    #[serde(default = "ask_default_summarize_hits_enabled")]
543    pub summarize_hits_enabled: bool,
544    #[serde(default)]
545    pub summarize_model: Option<String>,
546    /// Per-stage backend override for the answer-generation model.
547    /// None = synthesize from legacy `model` + `ollama_endpoint`.
548    #[serde(default)]
549    pub backend: Option<BackendConfig>,
550    /// Per-stage backend override for the query rewriter.
551    /// None = synthesize an Ollama BackendConfig over the legacy `model` +
552    /// `ollama_endpoint` with `rewriter_timeout_secs` baked in.
553    #[serde(default)]
554    pub rewriter_backend: Option<BackendConfig>,
555}
556
557impl AskConfig {
558    /// Returns the effective backend for the answer-generation model.
559    /// Per-stage `backend` override wins; otherwise synthesize from legacy
560    /// fields (`model`, `ollama_endpoint`) into an Ollama BackendConfig.
561    ///
562    /// `timeout_secs` is baked from `self.timeout_secs` so the answer call
563    /// inherits the user's per-call budget (rather than factory's 120s
564    /// default). When the user supplied an explicit `backend` override
565    /// with its own `timeout_secs`, that wins — we only synthesize when
566    /// `self.backend` is None.
567    pub fn synthesize_backend(&self) -> BackendConfig {
568        self.backend.clone().unwrap_or_else(|| BackendConfig {
569            provider: "ollama".into(),
570            model: self.model.clone(),
571            endpoint: Some(self.ollama_endpoint.clone()),
572            api_key_env: None,
573            timeout_secs: Some(self.timeout_secs as u64),
574        })
575    }
576
577    /// Returns the effective backend for the query rewriter.
578    ///
579    /// When `self.rewriter_backend` is None, this synthesizes its OWN
580    /// Ollama BackendConfig with `self.rewriter_timeout_secs` baked in —
581    /// it does NOT fall through to `synthesize_backend()`. The rewriter
582    /// has a much tighter latency budget than the answer call (rewriter
583    /// output is small and falling back to the raw question on timeout
584    /// is non-fatal), so we don't want a slow Ollama burning the full
585    /// `timeout_secs` budget before the user sees any response.
586    pub fn synthesize_rewriter_backend(&self) -> BackendConfig {
587        self.rewriter_backend
588            .clone()
589            .unwrap_or_else(|| BackendConfig {
590                provider: "ollama".into(),
591                model: self.model.clone(),
592                endpoint: Some(self.ollama_endpoint.clone()),
593                api_key_env: None,
594                timeout_secs: Some(self.rewriter_timeout_secs as u64),
595            })
596    }
597}
598
599impl Default for AskConfig {
600    fn default() -> Self {
601        Self {
602            model: ask_default_model(),
603            ollama_endpoint: compact_default_ollama_endpoint(),
604            k_summary: ask_default_k_summary(),
605            k_raw: ask_default_k_raw(),
606            escalation_threshold: ask_default_esc(),
607            mmr_threshold: ask_default_mmr(),
608            max_context_tokens: ask_default_max_ctx(),
609            response_tokens: ask_default_resp_tok(),
610            timeout_secs: ask_default_timeout(),
611            min_score: ask_default_min_score(),
612            continue_history_turns: ask_default_continue_history_turns(),
613            rewriter_timeout_secs: ask_default_rewriter_timeout(),
614            compress_hits_enabled: ask_default_compress_hits_enabled(),
615            summarize_hits_enabled: ask_default_summarize_hits_enabled(),
616            summarize_model: None,
617            backend: None,
618            rewriter_backend: None,
619        }
620    }
621}
622
623fn ask_default_model() -> String {
624    DEFAULT_LOCAL_LLM_MODEL.into()
625}
626fn ask_default_k_summary() -> u32 {
627    5
628}
629fn ask_default_k_raw() -> u32 {
630    10
631}
632fn ask_default_esc() -> f64 {
633    0.5
634}
635fn ask_default_mmr() -> f64 {
636    0.88
637}
638fn ask_default_max_ctx() -> u32 {
639    6000
640}
641fn ask_default_resp_tok() -> u32 {
642    1024
643}
644fn ask_default_timeout() -> u32 {
645    120
646}
647fn ask_default_min_score() -> f64 {
648    0.35
649}
650fn ask_default_rewriter_timeout() -> u32 {
651    8
652}
653fn ask_default_continue_history_turns() -> u32 {
654    3
655}
656fn ask_default_compress_hits_enabled() -> bool {
657    true
658}
659fn ask_default_summarize_hits_enabled() -> bool {
660    true
661}
662
663// ── Conversations archive config (Task 23) ────────────────────────────────────
664
665/// Phase 1 conversations archive config (Task 23).
666///
667/// Hard defaults: off-by-default (`enabled: false`), 30-day retention,
668/// 5-minute poll interval, all sources enabled, Mem0-style REJECT filters on,
669/// dedup threshold 0.85. Every sub-field is serde-default so a config.yaml
670/// without a `conversations:` section still parses.
671#[derive(Debug, Clone, Serialize, Deserialize)]
672pub struct ConversationsConfig {
673    #[serde(default)]
674    pub enabled: bool,
675    #[serde(default = "conv_default_retention_days")]
676    pub retention_days: u32,
677    #[serde(default = "conv_default_poll_interval")]
678    pub poll_interval_secs: u64,
679    #[serde(default)]
680    pub sources: ConversationsSources,
681    #[serde(default)]
682    pub filter: ConversationsFilter,
683    #[serde(default)]
684    pub compact: CompactConfig,
685    #[serde(default)]
686    pub ask: AskConfig,
687    #[serde(default)]
688    pub rollup: RollupConfig,
689}
690
691impl Default for ConversationsConfig {
692    fn default() -> Self {
693        Self {
694            enabled: false,
695            retention_days: conv_default_retention_days(),
696            poll_interval_secs: conv_default_poll_interval(),
697            sources: ConversationsSources::default(),
698            filter: ConversationsFilter::default(),
699            compact: CompactConfig::default(),
700            ask: AskConfig::default(),
701            rollup: RollupConfig::default(),
702        }
703    }
704}
705
706fn conv_default_retention_days() -> u32 {
707    30
708}
709fn conv_default_poll_interval() -> u64 {
710    300
711}
712fn conv_truthy() -> bool {
713    true
714}
715fn conv_default_dedup() -> f64 {
716    0.85
717}
718
719#[derive(Debug, Clone, Serialize, Deserialize)]
720pub struct CompactConfig {
721    #[serde(default = "conv_truthy")]
722    pub enabled_in_daemon: bool,
723    #[serde(default = "compact_default_max_days")]
724    pub max_days_per_run: u32,
725    #[serde(default = "compact_default_model")]
726    pub extractive_model: String,
727    #[serde(default = "compact_default_model")]
728    pub abstractive_model: String,
729    #[serde(default = "compact_default_ollama_endpoint")]
730    pub ollama_endpoint: String,
731    #[serde(default = "compact_default_max_spans")]
732    pub max_extractive_spans: u32,
733    #[serde(default = "compact_default_max_words")]
734    pub max_abstractive_words: u32,
735    #[serde(default = "compact_default_chunk_tokens")]
736    pub chunk_tokens: u32,
737    #[serde(default = "compact_default_history_retain")]
738    pub history_retain: u32,
739    #[serde(default = "compact_default_cron")]
740    pub daemon_cron: String,
741    /// Per-stage backend override for extractive summarization.
742    /// None = synthesize from legacy `extractive_model` + `ollama_endpoint`.
743    #[serde(default)]
744    pub extractive_backend: Option<BackendConfig>,
745    /// Per-stage backend override for abstractive summarization.
746    /// None = synthesize from legacy `abstractive_model` + `ollama_endpoint`.
747    #[serde(default)]
748    pub abstractive_backend: Option<BackendConfig>,
749}
750
751impl CompactConfig {
752    /// Returns the effective backend for the extractive stage.
753    /// Per-stage `extractive_backend` override wins; otherwise synthesize
754    /// from legacy fields into an Ollama BackendConfig.
755    ///
756    /// CompactConfig has no per-stage timeout field, so synthesis bakes
757    /// the conservative 120s default — matching the previously-hardcoded
758    /// `Duration::from_secs(120)` at the call sites (byte-identical to
759    /// the pre-trait OllamaClient construction).
760    pub fn synthesize_extractive_backend(&self) -> BackendConfig {
761        self.extractive_backend
762            .clone()
763            .unwrap_or_else(|| BackendConfig {
764                provider: "ollama".into(),
765                model: self.extractive_model.clone(),
766                endpoint: Some(self.ollama_endpoint.clone()),
767                api_key_env: None,
768                timeout_secs: Some(120),
769            })
770    }
771
772    /// Returns the effective backend for the abstractive stage.
773    /// See `synthesize_extractive_backend` for the timeout rationale.
774    pub fn synthesize_abstractive_backend(&self) -> BackendConfig {
775        self.abstractive_backend
776            .clone()
777            .unwrap_or_else(|| BackendConfig {
778                provider: "ollama".into(),
779                model: self.abstractive_model.clone(),
780                endpoint: Some(self.ollama_endpoint.clone()),
781                api_key_env: None,
782                timeout_secs: Some(120),
783            })
784    }
785}
786
787impl Default for CompactConfig {
788    fn default() -> Self {
789        Self {
790            enabled_in_daemon: true,
791            max_days_per_run: compact_default_max_days(),
792            extractive_model: compact_default_model(),
793            abstractive_model: compact_default_model(),
794            ollama_endpoint: compact_default_ollama_endpoint(),
795            max_extractive_spans: compact_default_max_spans(),
796            max_abstractive_words: compact_default_max_words(),
797            chunk_tokens: compact_default_chunk_tokens(),
798            history_retain: compact_default_history_retain(),
799            daemon_cron: compact_default_cron(),
800            extractive_backend: None,
801            abstractive_backend: None,
802        }
803    }
804}
805
806fn compact_default_max_days() -> u32 {
807    7
808}
809fn compact_default_model() -> String {
810    DEFAULT_LOCAL_LLM_MODEL.into()
811}
812fn compact_default_ollama_endpoint() -> String {
813    "http://localhost:11434".into()
814}
815fn compact_default_max_spans() -> u32 {
816    20
817}
818fn compact_default_max_words() -> u32 {
819    400
820}
821fn compact_default_chunk_tokens() -> u32 {
822    6000
823}
824fn compact_default_history_retain() -> u32 {
825    5
826}
827fn compact_default_cron() -> String {
828    "0 0 3 * * * *".into()
829}
830
831// ── Rollup config (Phase 3.2, Task 1) ─────────────────────────────────────────
832
833#[derive(Debug, Clone, Serialize, Deserialize)]
834pub struct RollupConfig {
835    #[serde(default = "rollup_default_enabled")]
836    pub enabled: bool,
837    #[serde(default = "rollup_default_max_weeks")]
838    pub max_weeks_per_run: u32,
839    #[serde(default = "rollup_default_max_months")]
840    pub max_months_per_run: u32,
841    #[serde(default = "rollup_default_max_spans_week")]
842    pub max_extractive_spans_per_week: u32,
843    #[serde(default = "rollup_default_max_words_week")]
844    pub max_abstractive_words_per_week: u32,
845    #[serde(default = "rollup_default_max_spans_month")]
846    pub max_extractive_spans_per_month: u32,
847    #[serde(default = "rollup_default_max_words_month")]
848    pub max_abstractive_words_per_month: u32,
849    #[serde(default = "rollup_default_week_mmr")]
850    pub week_mmr_threshold: f64,
851    #[serde(default = "rollup_default_month_mmr")]
852    pub month_mmr_threshold: f64,
853    #[serde(default = "compact_default_model")]
854    pub extractive_model: String,
855    #[serde(default = "compact_default_model")]
856    pub abstractive_model: String,
857    #[serde(default = "compact_default_ollama_endpoint")]
858    pub ollama_endpoint: String,
859}
860
861impl Default for RollupConfig {
862    fn default() -> Self {
863        Self {
864            enabled: rollup_default_enabled(),
865            max_weeks_per_run: rollup_default_max_weeks(),
866            max_months_per_run: rollup_default_max_months(),
867            max_extractive_spans_per_week: rollup_default_max_spans_week(),
868            max_abstractive_words_per_week: rollup_default_max_words_week(),
869            max_extractive_spans_per_month: rollup_default_max_spans_month(),
870            max_abstractive_words_per_month: rollup_default_max_words_month(),
871            week_mmr_threshold: rollup_default_week_mmr(),
872            month_mmr_threshold: rollup_default_month_mmr(),
873            extractive_model: compact_default_model(),
874            abstractive_model: compact_default_model(),
875            ollama_endpoint: compact_default_ollama_endpoint(),
876        }
877    }
878}
879
880fn rollup_default_enabled() -> bool {
881    true
882}
883fn rollup_default_max_weeks() -> u32 {
884    4
885}
886fn rollup_default_max_months() -> u32 {
887    2
888}
889fn rollup_default_max_spans_week() -> u32 {
890    20
891}
892fn rollup_default_max_words_week() -> u32 {
893    500
894}
895fn rollup_default_max_spans_month() -> u32 {
896    20
897}
898fn rollup_default_max_words_month() -> u32 {
899    700
900}
901fn rollup_default_week_mmr() -> f64 {
902    0.85
903}
904fn rollup_default_month_mmr() -> f64 {
905    0.82
906}
907
908#[derive(Debug, Clone, Serialize, Deserialize)]
909pub struct ConversationsSources {
910    #[serde(default = "conv_truthy")]
911    pub claude_code: bool,
912    #[serde(default = "conv_truthy")]
913    pub cursor: bool,
914    #[serde(default = "conv_truthy")]
915    pub gemini: bool,
916    #[serde(default)]
917    pub aider: AiderSourceConfig,
918}
919
920impl Default for ConversationsSources {
921    fn default() -> Self {
922        Self {
923            claude_code: true,
924            cursor: true,
925            gemini: true,
926            aider: AiderSourceConfig::default(),
927        }
928    }
929}
930
931#[derive(Debug, Clone, Serialize, Deserialize)]
932pub struct AiderSourceConfig {
933    #[serde(default = "conv_truthy")]
934    pub enabled: bool,
935    #[serde(default)]
936    pub watched_dirs: Vec<String>,
937}
938
939impl Default for AiderSourceConfig {
940    fn default() -> Self {
941        Self {
942            enabled: true,
943            watched_dirs: Vec::new(),
944        }
945    }
946}
947
948#[derive(Debug, Clone, Serialize, Deserialize)]
949pub struct ConversationsFilter {
950    #[serde(default = "conv_default_dedup")]
951    pub dedup_threshold: f64,
952    #[serde(default = "conv_truthy")]
953    pub reject_heartbeat: bool,
954    #[serde(default = "conv_truthy")]
955    pub reject_system_restatement: bool,
956}
957
958impl Default for ConversationsFilter {
959    fn default() -> Self {
960        Self {
961            dedup_threshold: conv_default_dedup(),
962            reject_heartbeat: true,
963            reject_system_restatement: true,
964        }
965    }
966}
967
968#[cfg(test)]
969mod conversations_tests {
970    use super::*;
971
972    #[test]
973    fn conversations_section_defaults() {
974        let c = ConversationsConfig::default();
975        assert!(!c.enabled);
976        assert_eq!(c.retention_days, 30);
977        assert_eq!(c.poll_interval_secs, 300);
978        assert!(c.sources.claude_code);
979        assert!(c.sources.cursor);
980        assert!(c.sources.gemini);
981        assert!(c.sources.aider.enabled);
982        assert!(c.sources.aider.watched_dirs.is_empty());
983        assert_eq!(c.filter.dedup_threshold, 0.85);
984        assert!(c.filter.reject_heartbeat);
985        assert!(c.filter.reject_system_restatement);
986    }
987
988    #[test]
989    fn parse_from_yaml_with_overrides() {
990        let y = r#"
991conversations:
992  enabled: true
993  retention_days: 45
994  poll_interval_secs: 120
995  sources:
996    cursor: false
997    aider:
998      watched_dirs: ["~/Projects/a", "~/Projects/b"]
999  filter:
1000    dedup_threshold: 0.9
1001"#;
1002        let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
1003        let conv: ConversationsConfig = serde_yaml::from_value(v["conversations"].clone()).unwrap();
1004        assert!(conv.enabled);
1005        assert_eq!(conv.retention_days, 45);
1006        assert_eq!(conv.poll_interval_secs, 120);
1007        assert!(conv.sources.claude_code); // defaulted true
1008        assert!(!conv.sources.cursor); // override
1009        assert!(conv.sources.gemini); // defaulted true
1010        assert_eq!(conv.sources.aider.watched_dirs.len(), 2);
1011        assert_eq!(conv.filter.dedup_threshold, 0.9);
1012        assert!(conv.filter.reject_heartbeat); // defaulted true
1013    }
1014
1015    #[test]
1016    fn missing_conversations_section_is_fine() {
1017        let y = r#"
1018# No conversations section at all
1019foo: bar
1020"#;
1021        let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
1022        // Default when absent
1023        let conv: ConversationsConfig = v
1024            .get("conversations")
1025            .cloned()
1026            .map(|x| serde_yaml::from_value(x).unwrap_or_default())
1027            .unwrap_or_default();
1028        assert_eq!(conv.retention_days, 30);
1029    }
1030
1031    #[test]
1032    fn compact_config_defaults() {
1033        let c = CompactConfig::default();
1034        assert!(c.enabled_in_daemon);
1035        assert_eq!(c.max_days_per_run, 7);
1036        assert_eq!(c.extractive_model, "qwen3.5:4b");
1037        assert_eq!(c.abstractive_model, "qwen3.5:4b");
1038        assert_eq!(c.ollama_endpoint, "http://localhost:11434");
1039        assert_eq!(c.max_extractive_spans, 20);
1040        assert_eq!(c.chunk_tokens, 6000);
1041        assert_eq!(c.history_retain, 5);
1042        assert_eq!(c.daemon_cron, "0 0 3 * * * *");
1043    }
1044
1045    #[test]
1046    fn compact_parses_partial_overrides() {
1047        let y = r#"
1048conversations:
1049  compact:
1050    max_days_per_run: 3
1051    extractive_model: qwen3:4b
1052"#;
1053        let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
1054        let conv: ConversationsConfig = serde_yaml::from_value(v["conversations"].clone()).unwrap();
1055        assert_eq!(conv.compact.max_days_per_run, 3);
1056        assert_eq!(conv.compact.extractive_model, "qwen3:4b");
1057        assert!(conv.compact.enabled_in_daemon); // default preserved
1058        assert_eq!(conv.compact.abstractive_model, "qwen3.5:4b"); // default preserved
1059    }
1060
1061    #[test]
1062    fn ask_config_defaults() {
1063        let c = AskConfig::default();
1064        assert_eq!(c.model, "qwen3.5:4b");
1065        assert_eq!(c.ollama_endpoint, "http://localhost:11434");
1066        assert_eq!(c.k_raw, 10);
1067        assert_eq!(c.escalation_threshold, 0.5);
1068        assert_eq!(c.mmr_threshold, 0.88);
1069        assert_eq!(c.max_context_tokens, 6000);
1070        assert_eq!(c.response_tokens, 1024);
1071        assert_eq!(c.timeout_secs, 120);
1072        assert_eq!(c.min_score, 0.35);
1073    }
1074
1075    #[test]
1076    fn ask_config_mmr_threshold_default_is_cosine_scaled() {
1077        // Phase 3.1: default shifts from 0.85 (word-Jaccard) to 0.88 (cosine).
1078        let c = AskConfig::default();
1079        assert!(
1080            (c.mmr_threshold - 0.88).abs() < 1e-9,
1081            "expected 0.88, got {}",
1082            c.mmr_threshold
1083        );
1084    }
1085
1086    #[test]
1087    fn rollup_config_defaults() {
1088        let c = RollupConfig::default();
1089        assert!(c.enabled);
1090        assert_eq!(c.max_weeks_per_run, 4);
1091        assert_eq!(c.max_months_per_run, 2);
1092        assert_eq!(c.max_extractive_spans_per_week, 20);
1093        assert_eq!(c.max_abstractive_words_per_week, 500);
1094        assert_eq!(c.max_extractive_spans_per_month, 20);
1095        assert_eq!(c.max_abstractive_words_per_month, 700);
1096        assert!((c.week_mmr_threshold - 0.85).abs() < 1e-9);
1097        assert!((c.month_mmr_threshold - 0.82).abs() < 1e-9);
1098        assert_eq!(c.extractive_model, "qwen3.5:4b");
1099        assert_eq!(c.abstractive_model, "qwen3.5:4b");
1100        assert_eq!(c.ollama_endpoint, "http://localhost:11434");
1101    }
1102
1103    #[test]
1104    fn rollup_config_plumbed_into_conversations_config() {
1105        let c = ConversationsConfig::default();
1106        assert!(c.rollup.enabled);
1107    }
1108
1109    #[test]
1110    fn ask_config_default_continue_history_turns_is_3() {
1111        let c = AskConfig::default();
1112        assert_eq!(c.continue_history_turns, 3);
1113    }
1114
1115    #[test]
1116    fn ask_config_default_compress_hits_enabled_is_true() {
1117        let c = AskConfig::default();
1118        assert!(c.compress_hits_enabled);
1119    }
1120
1121    #[test]
1122    fn ask_config_default_summarize_hits_enabled_is_true() {
1123        let c = AskConfig::default();
1124        assert!(c.summarize_hits_enabled);
1125    }
1126
1127    #[test]
1128    fn ask_config_default_summarize_model_is_none() {
1129        let c = AskConfig::default();
1130        assert!(c.summarize_model.is_none());
1131    }
1132
1133    #[test]
1134    fn ask_config_yaml_roundtrip_preserves_summarize_fields() {
1135        let y = r#"
1136conversations:
1137  ask:
1138    summarize_hits_enabled: false
1139    summarize_model: qwen3:4b
1140"#;
1141        let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
1142        let conv: ConversationsConfig = serde_yaml::from_value(v["conversations"].clone()).unwrap();
1143        assert!(!conv.ask.summarize_hits_enabled);
1144        assert_eq!(conv.ask.summarize_model.as_deref(), Some("qwen3:4b"));
1145    }
1146
1147    #[test]
1148    fn ask_config_yaml_without_summarize_fields_uses_defaults() {
1149        // Phase 3.5 must be additive: an existing config.yaml with NO
1150        // summarize_* keys must still parse and default to enabled=true,
1151        // model=None.
1152        let y = r#"
1153conversations:
1154  ask:
1155    model: qwen3:14b
1156"#;
1157        let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
1158        let conv: ConversationsConfig = serde_yaml::from_value(v["conversations"].clone()).unwrap();
1159        assert!(conv.ask.summarize_hits_enabled);
1160        assert!(conv.ask.summarize_model.is_none());
1161    }
1162}
1163
1164#[cfg(test)]
1165mod tests {
1166    use super::*;
1167
1168    #[test]
1169    fn default_bundled_model_id_is_qwen35_2b() {
1170        assert_eq!(
1171            crate::config::DEFAULT_BUNDLED_MODEL_ID,
1172            "Qwen3.5-2B-MLX-4bit"
1173        );
1174    }
1175
1176    #[test]
1177    fn nudge_config_defaults() {
1178        let c = NudgeConfig::default();
1179        assert!(c.enabled);
1180        assert_eq!(c.daily_cap, 3);
1181        assert_eq!(c.snooze_days, 7);
1182        assert_eq!(c.threshold, 3);
1183    }
1184
1185    #[test]
1186    fn config_has_nudge_section_with_defaults() {
1187        let c: Config = serde_yaml_ng::from_str("{}").unwrap();
1188        assert_eq!(c.nudge.daily_cap, 3);
1189    }
1190
1191    #[test]
1192    fn storage_config_default_is_lancedb() {
1193        let c = StorageConfig::default();
1194        assert_eq!(c.vector_backend, "lancedb");
1195        assert_eq!(c.qdrant_url, None);
1196        assert_eq!(c.qdrant_api_key_ref, None);
1197    }
1198
1199    #[test]
1200    fn sources_global_config_has_sensible_defaults() {
1201        let c = SourcesGlobalConfig::default();
1202        assert_eq!(c.poll_interval_secs, 600);
1203        assert_eq!(c.max_chunks_per_sync, 10_000);
1204        assert_eq!(c.max_parallel_sources, 3);
1205        assert_eq!(c.default_weight, 1.0);
1206        assert_eq!(c.embedding_batch_size, 32);
1207    }
1208
1209    #[test]
1210    fn config_default_has_storage_and_sources_global() {
1211        let c = Config::default();
1212        assert_eq!(c.storage.vector_backend, "lancedb");
1213        assert_eq!(c.sources_global.default_weight, 1.0);
1214    }
1215
1216    #[test]
1217    fn config_loads_yaml_without_new_fields() {
1218        // Existing users' config.yaml won't mention storage or sources_global.
1219        // It must still parse.
1220        let yaml = r#"
1221embedding:
1222  provider: ollama
1223  model: test-model
1224  dimensions: 512
1225  ollama_endpoint: http://localhost:11434
1226"#;
1227        let c: Config = serde_yaml::from_str(yaml).expect("parses");
1228        assert_eq!(c.storage.vector_backend, "lancedb");
1229        assert_eq!(c.sources_global.max_parallel_sources, 3);
1230    }
1231
1232    #[test]
1233    fn llm_config_to_backend_config_anthropic_passthrough() {
1234        let cfg = LlmConfig {
1235            provider: "anthropic".into(),
1236            model: "claude-haiku-4-5".into(),
1237            api_key_env: Some("ANTHROPIC_API_KEY".into()),
1238            openai_url: None,
1239        };
1240        let b = cfg.to_backend_config();
1241        assert_eq!(b.provider, "anthropic");
1242        assert_eq!(b.model, "claude-haiku-4-5");
1243        assert_eq!(b.api_key_env.as_deref(), Some("ANTHROPIC_API_KEY"));
1244        assert_eq!(b.endpoint, None);
1245        assert_eq!(b.timeout_secs, None);
1246    }
1247
1248    #[test]
1249    fn llm_config_to_backend_config_openai_url_maps_to_endpoint() {
1250        let cfg = LlmConfig {
1251            provider: "openai".into(),
1252            model: "gpt-4o-mini".into(),
1253            api_key_env: None,
1254            openai_url: Some("https://api.together.xyz/v1".into()),
1255        };
1256        let b = cfg.to_backend_config();
1257        assert_eq!(b.provider, "openai");
1258        assert_eq!(b.endpoint.as_deref(), Some("https://api.together.xyz/v1"));
1259        assert_eq!(b.api_key_env, None); // factory will fall back to OPENAI_API_KEY
1260    }
1261
1262    #[test]
1263    fn llm_config_to_backend_config_ollama_openai_url_maps_to_endpoint() {
1264        let cfg = LlmConfig {
1265            provider: "ollama".into(),
1266            model: "qwen3:14b".into(),
1267            api_key_env: None,
1268            openai_url: Some("http://192.168.1.10:11434".into()),
1269        };
1270        let b = cfg.to_backend_config();
1271        assert_eq!(b.provider, "ollama");
1272        assert_eq!(b.endpoint.as_deref(), Some("http://192.168.1.10:11434"));
1273    }
1274
1275    #[test]
1276    fn llm_config_to_backend_config_unknown_with_openai_url_aliases_to_openai() {
1277        // Historical LlmConfig allowed provider="custom" + openai_url to act as
1278        // an OpenAI-compatible passthrough. Preserve that by re-tagging as
1279        // "openai" so factory dispatches to OpenAIBackend.
1280        let cfg = LlmConfig {
1281            provider: "custom-name".into(),
1282            model: "some-model".into(),
1283            api_key_env: Some("CUSTOM_KEY".into()),
1284            openai_url: Some("https://my-proxy.local/v1".into()),
1285        };
1286        let b = cfg.to_backend_config();
1287        assert_eq!(
1288            b.provider, "openai",
1289            "unknown provider + openai_url should alias to openai"
1290        );
1291        assert_eq!(b.endpoint.as_deref(), Some("https://my-proxy.local/v1"));
1292    }
1293}
1294
1295#[cfg(test)]
1296mod backend_config_tests {
1297    use super::*;
1298
1299    #[test]
1300    fn default_is_ollama_qwen3() {
1301        let cfg = BackendConfig::default();
1302        assert_eq!(cfg.provider, "ollama");
1303        assert_eq!(cfg.model, "qwen3.5:4b");
1304        assert_eq!(cfg.endpoint, None);
1305        assert_eq!(cfg.api_key_env, None);
1306        assert_eq!(cfg.timeout_secs, None);
1307    }
1308
1309    #[test]
1310    fn deserializes_anthropic_full() {
1311        let yaml = "\
1312provider: anthropic
1313model: claude-haiku-4-5
1314api_key_env: ANTHROPIC_API_KEY
1315timeout_secs: 60
1316";
1317        let cfg: BackendConfig = serde_yaml::from_str(yaml).unwrap();
1318        assert_eq!(cfg.provider, "anthropic");
1319        assert_eq!(cfg.model, "claude-haiku-4-5");
1320        assert_eq!(cfg.api_key_env, Some("ANTHROPIC_API_KEY".into()));
1321        assert_eq!(cfg.timeout_secs, Some(60));
1322        assert_eq!(cfg.endpoint, None);
1323    }
1324
1325    #[test]
1326    fn deserializes_partial_fills_defaults() {
1327        let yaml = "provider: anthropic\nmodel: claude-sonnet-4-6\n";
1328        let cfg: BackendConfig = serde_yaml::from_str(yaml).unwrap();
1329        assert_eq!(cfg.provider, "anthropic");
1330        assert_eq!(cfg.model, "claude-sonnet-4-6");
1331        assert_eq!(cfg.api_key_env, None);
1332        assert_eq!(cfg.timeout_secs, None);
1333    }
1334
1335    #[test]
1336    fn round_trips_through_yaml() {
1337        let original = BackendConfig {
1338            provider: "anthropic".into(),
1339            model: "claude-haiku-4-5".into(),
1340            endpoint: Some("https://api.anthropic.com".into()),
1341            api_key_env: Some("ANTHROPIC_API_KEY".into()),
1342            timeout_secs: Some(60),
1343        };
1344        let yaml = serde_yaml::to_string(&original).unwrap();
1345        let parsed: BackendConfig = serde_yaml::from_str(&yaml).unwrap();
1346        assert_eq!(parsed, original);
1347    }
1348
1349    #[test]
1350    fn skills_config_curation_gate_defaults_on() {
1351        let c = SkillsConfig::default();
1352        assert!(c.require_human_curation_before_stable);
1353    }
1354}
1355
1356/// Configuration for the daemon-side sleep cycle (idle background learning).
1357///
1358/// Skill injection configuration (M2 — runtime injection).
1359#[derive(Debug, Clone, Serialize, Deserialize)]
1360#[serde(default)]
1361pub struct SkillsConfig {
1362    pub max_skills_in_prompt: usize,
1363    pub max_total_tokens: usize,
1364    pub priority_order: Vec<String>,
1365    pub adaptive: Option<AdaptiveSkillsConfig>,
1366
1367    /// When true (default), LLM-authored skills cannot auto-promote past
1368    /// `Emerging` until a human curates them (amendment A1). Set false to
1369    /// let LLM-extracted skills promote on run stats alone.
1370    #[serde(default = "default_require_human_curation")]
1371    pub require_human_curation_before_stable: bool,
1372
1373    /// Lifecycle scoring thresholds (W3b-P4). All fields default to the
1374    /// compile-time constants in `mur_common::skill::lifecycle` so existing
1375    /// deployments see no behaviour change without an explicit config entry.
1376    #[serde(default)]
1377    pub lifecycle: SkillLifecycleConfig,
1378}
1379
1380fn default_require_human_curation() -> bool {
1381    true
1382}
1383
1384impl Default for SkillsConfig {
1385    fn default() -> Self {
1386        Self {
1387            max_skills_in_prompt: 5,
1388            max_total_tokens: 2000,
1389            priority_order: vec!["agent".into(), "global".into()],
1390            adaptive: Some(AdaptiveSkillsConfig::default()),
1391            require_human_curation_before_stable: default_require_human_curation(),
1392            lifecycle: SkillLifecycleConfig::default(),
1393        }
1394    }
1395}
1396
1397/// Per-skill lifecycle scoring thresholds.
1398///
1399/// Stored under `skill.lifecycle.*` in `~/.mur/config.yaml`.
1400/// All fields are optional on disk — missing keys fall back to the
1401/// compile-time defaults so a partial config is always valid.
1402#[derive(Debug, Clone, Serialize, Deserialize)]
1403#[serde(default)]
1404pub struct SkillLifecycleConfig {
1405    // ── Promotion thresholds (must be exceeded) ──────────────────────────
1406    pub promote_draft_uses: u64,
1407    pub promote_emerging_uses: u64,
1408    pub promote_emerging_success_rate: f64,
1409    pub promote_emerging_age_days: i64,
1410    pub promote_stable_uses: u64,
1411    pub promote_stable_success_rate: f64,
1412    pub promote_stable_age_days: i64,
1413
1414    // ── Demotion thresholds (must drop below) ────────────────────────────
1415    pub demote_emerging_uses: u64,
1416    pub demote_emerging_success_rate: f64,
1417    pub demote_stable_uses: u64,
1418    pub demote_stable_success_rate: f64,
1419    pub deprecated_success_rate: f64,
1420    pub deprecated_no_success_days: i64,
1421
1422    // ── Auto-archive thresholds ───────────────────────────────────────────
1423    pub auto_archive_confidence: f64,
1424    pub auto_archive_age_days: i64,
1425
1426    // ── P4: broken fast-path ─────────────────────────────────────────────
1427    /// Number of consecutive `Execution` events with `env_class == "workflow"`
1428    /// that immediately triggers a `Deprecated` transition, bypassing the
1429    /// normal scoring path. Set to 0 to disable the fast-path.
1430    pub broken_workflow_streak: u32,
1431
1432    // ── P4: archived hard-delete ─────────────────────────────────────────
1433    /// Days a skill must remain in `Archived` state before `mur skill sweep`
1434    /// transitions it to `Destroyed` and removes its directory from disk.
1435    /// Set to 0 to disable hard-delete.
1436    pub archive_destroy_grace_days: i64,
1437}
1438
1439impl Default for SkillLifecycleConfig {
1440    fn default() -> Self {
1441        Self {
1442            promote_draft_uses: 3,
1443            promote_emerging_uses: 10,
1444            promote_emerging_success_rate: 0.6,
1445            promote_emerging_age_days: 7,
1446            promote_stable_uses: 30,
1447            promote_stable_success_rate: 0.8,
1448            promote_stable_age_days: 30,
1449            demote_emerging_uses: 8,
1450            demote_emerging_success_rate: 0.55,
1451            demote_stable_uses: 25,
1452            demote_stable_success_rate: 0.75,
1453            deprecated_success_rate: 0.3,
1454            deprecated_no_success_days: 90,
1455            auto_archive_confidence: 0.10,
1456            auto_archive_age_days: 180,
1457            broken_workflow_streak: 3,
1458            archive_destroy_grace_days: 30,
1459        }
1460    }
1461}
1462
1463#[derive(Debug, Clone, Serialize, Deserialize)]
1464#[serde(default)]
1465pub struct AdaptiveSkillsConfig {
1466    pub context_fill_decay: f64,
1467    pub min_remaining_context_ratio: f64,
1468    pub recent_fire_boost_turns: usize,
1469    /// Model max context window in tokens. Used to compute
1470    /// `context_fill_ratio = cumulative_input_tokens / model_max_context_tokens`.
1471    /// Default 200_000 (Claude 3.5/4.x).
1472    pub model_max_context_tokens: u64,
1473}
1474
1475impl Default for AdaptiveSkillsConfig {
1476    fn default() -> Self {
1477        Self {
1478            context_fill_decay: 1.5,
1479            min_remaining_context_ratio: 0.20,
1480            recent_fire_boost_turns: 5,
1481            model_max_context_tokens: 200_000,
1482        }
1483    }
1484}
1485
1486/// When enabled, the daemon fires a consolidation pipeline after the user has been
1487/// idle for `idle_threshold_minutes` minutes (default 15). Opt-in only — off by default.
1488#[derive(Debug, Clone, Serialize, Deserialize)]
1489pub struct SleepCycleConfig {
1490    /// Master switch. False by default (opt-in).
1491    #[serde(default)]
1492    pub enabled: bool,
1493
1494    /// Minutes of idle (no events) before triggering the daemon sleep cycle.
1495    #[serde(default = "default_idle_threshold_minutes")]
1496    pub idle_threshold_minutes: u64,
1497
1498    /// Minutes of agent idle before the agent-side cycle fires (outbox flush + snapshot pull).
1499    #[serde(default = "default_agent_idle_minutes")]
1500    pub agent_idle_minutes: u64,
1501}
1502
1503fn default_idle_threshold_minutes() -> u64 {
1504    15
1505}
1506
1507fn default_agent_idle_minutes() -> u64 {
1508    5
1509}
1510
1511impl Default for SleepCycleConfig {
1512    fn default() -> Self {
1513        Self {
1514            enabled: false,
1515            idle_threshold_minutes: default_idle_threshold_minutes(),
1516            agent_idle_minutes: default_agent_idle_minutes(),
1517        }
1518    }
1519}
1520
1521// ── Nudge config ───────────────────────────────────────────────────
1522
1523#[derive(Debug, Clone, Serialize, Deserialize)]
1524pub struct NudgeConfig {
1525    /// Master switch. Default on — Phase 2 companion surface is live.
1526    #[serde(default = "default_nudge_enabled")]
1527    pub enabled: bool,
1528    #[serde(default = "default_nudge_daily_cap")]
1529    pub daily_cap: u32,
1530    #[serde(default = "default_nudge_snooze_days")]
1531    pub snooze_days: u32,
1532    #[serde(default = "default_nudge_threshold")]
1533    pub threshold: usize,
1534}
1535
1536fn default_nudge_enabled() -> bool {
1537    true
1538}
1539fn default_nudge_daily_cap() -> u32 {
1540    3
1541}
1542fn default_nudge_snooze_days() -> u32 {
1543    7
1544}
1545fn default_nudge_threshold() -> usize {
1546    3
1547}
1548
1549impl Default for NudgeConfig {
1550    fn default() -> Self {
1551        Self {
1552            enabled: true,
1553            daily_cap: default_nudge_daily_cap(),
1554            snooze_days: default_nudge_snooze_days(),
1555            threshold: default_nudge_threshold(),
1556        }
1557    }
1558}
1559
1560// ── Ambient capture & harvest (2026-06-11 spec) ────────────────────
1561
1562/// Ambient session capture (spec 2026-06-11-mur-ambient-capture-and-harvest §3.1).
1563#[derive(Debug, Clone, Serialize, Deserialize)]
1564pub struct SessionCfg {
1565    /// "ambient" (hooks always record) | "manual" (legacy `mur session in` gate) | "off"
1566    #[serde(default = "default_capture_mode")]
1567    pub capture: String,
1568    /// Recordings older than this many days are removed by `mur session gc`.
1569    #[serde(default = "default_retention_days")]
1570    pub retention_days: u32,
1571}
1572
1573impl Default for SessionCfg {
1574    fn default() -> Self {
1575        Self {
1576            capture: default_capture_mode(),
1577            retention_days: default_retention_days(),
1578        }
1579    }
1580}
1581
1582fn default_capture_mode() -> String {
1583    "ambient".to_string()
1584}
1585fn default_retention_days() -> u32 {
1586    14
1587}
1588
1589/// Harvest gate + token-budget defenses (spec §3.2, §3.7).
1590#[derive(Debug, Clone, Serialize, Deserialize)]
1591pub struct HarvestCfg {
1592    /// Run the heuristic gate automatically (from `mur session gc` / `mur out`).
1593    #[serde(default = "default_harvest_enabled")]
1594    pub auto_gate: bool,
1595    /// "local-first" | "cloud" | "off" — W1/W2 only persist this; LLM wiring lands with v2 P5a.
1596    #[serde(default = "default_harvest_llm")]
1597    pub llm: String,
1598    /// Gate thresholds — a session must clear at least one of these (see harvest::gate).
1599    #[serde(default = "default_min_events")]
1600    pub min_events: usize,
1601    #[serde(default = "default_min_user_turns")]
1602    pub min_user_turns: usize,
1603    #[serde(default = "default_min_duration_secs")]
1604    pub min_duration_secs: i64,
1605    /// A session is considered ended when its last event is older than this.
1606    #[serde(default = "default_idle_minutes")]
1607    pub idle_minutes: i64,
1608    /// §3.7 hard caps (persisted now; enforced when the LLM extract path lands in v2 P5a).
1609    #[serde(default = "default_max_llm_calls_per_day")]
1610    pub max_llm_calls_per_day: u32,
1611    #[serde(default = "default_max_extract_input_tokens")]
1612    pub max_extract_input_tokens: usize,
1613    /// §3.8 tier-1: one-line pending-proposals hint at SessionStart.
1614    #[serde(default = "default_harvest_enabled")]
1615    pub session_start_hint: bool,
1616    /// Step-skeleton Jaccard similarity at/above which a proposal becomes a merge suggestion.
1617    #[serde(default = "default_similarity_merge_threshold")]
1618    pub similarity_merge_threshold: f32,
1619}
1620
1621impl Default for HarvestCfg {
1622    fn default() -> Self {
1623        serde_yaml::from_str("{}").expect("HarvestCfg defaults")
1624    }
1625}
1626
1627fn default_harvest_enabled() -> bool {
1628    true
1629}
1630fn default_harvest_llm() -> String {
1631    "local-first".to_string()
1632}
1633fn default_min_events() -> usize {
1634    5
1635}
1636fn default_min_user_turns() -> usize {
1637    2
1638}
1639fn default_min_duration_secs() -> i64 {
1640    120
1641}
1642fn default_idle_minutes() -> i64 {
1643    30
1644}
1645fn default_max_llm_calls_per_day() -> u32 {
1646    10
1647}
1648fn default_max_extract_input_tokens() -> usize {
1649    12000
1650}
1651fn default_similarity_merge_threshold() -> f32 {
1652    0.6
1653}
1654
1655// ── M7a: Cross-agent observability ─────────────────────────────────
1656
1657#[derive(Debug, Clone, Serialize, Deserialize)]
1658#[serde(default)]
1659pub struct CrossAgentConfig {
1660    #[serde(default = "default_half_life_days")]
1661    pub fitness_half_life_days: u32,
1662    #[serde(default = "default_fitness_floor")]
1663    pub fitness_floor: f64,
1664}
1665
1666fn default_half_life_days() -> u32 {
1667    7
1668}
1669fn default_fitness_floor() -> f64 {
1670    0.1
1671}
1672
1673impl Default for CrossAgentConfig {
1674    fn default() -> Self {
1675        Self {
1676            fitness_half_life_days: default_half_life_days(),
1677            fitness_floor: default_fitness_floor(),
1678        }
1679    }
1680}
1681
1682// ── M6c: LLM-augmented skill maintenance ─────────────────────────────
1683
1684#[derive(Debug, Clone, Serialize, Deserialize)]
1685#[serde(default)]
1686pub struct SkillLlmConfig {
1687    /// Per-call output token cap.
1688    #[serde(default = "default_per_call_token_cap")]
1689    pub per_call_token_cap: u32,
1690
1691    /// Per-day USD cap for all maintenance LLM calls.
1692    #[serde(default = "default_per_day_usd_cap")]
1693    pub per_day_usd_cap: f64,
1694
1695    /// Cache TTL in days.
1696    #[serde(default = "default_cache_ttl_days")]
1697    pub cache_ttl_days: u32,
1698
1699    /// Optional explicit model key override. When `None`, role resolution picks.
1700    #[serde(default, skip_serializing_if = "Option::is_none")]
1701    pub model_ref: Option<String>,
1702}
1703
1704fn default_per_call_token_cap() -> u32 {
1705    1500
1706}
1707fn default_per_day_usd_cap() -> f64 {
1708    0.50
1709}
1710fn default_cache_ttl_days() -> u32 {
1711    30
1712}
1713
1714impl Default for SkillLlmConfig {
1715    fn default() -> Self {
1716        Self {
1717            per_call_token_cap: default_per_call_token_cap(),
1718            per_day_usd_cap: default_per_day_usd_cap(),
1719            cache_ttl_days: default_cache_ttl_days(),
1720            model_ref: None,
1721        }
1722    }
1723}
1724#[cfg(test)]
1725mod per_stage_backend_tests {
1726    use super::*;
1727
1728    #[test]
1729    fn legacy_compact_config_has_no_per_stage_overrides() {
1730        let yaml = "\
1731extractive_model: qwen3:14b
1732abstractive_model: qwen3:14b
1733ollama_endpoint: http://localhost:11434
1734";
1735        let cfg: CompactConfig = serde_yaml::from_str(yaml).unwrap();
1736        assert!(cfg.extractive_backend.is_none());
1737        assert!(cfg.abstractive_backend.is_none());
1738        assert_eq!(cfg.extractive_model, "qwen3:14b");
1739        assert_eq!(cfg.abstractive_model, "qwen3:14b");
1740        assert_eq!(cfg.ollama_endpoint, "http://localhost:11434");
1741    }
1742
1743    #[test]
1744    fn legacy_ask_config_has_no_per_stage_overrides() {
1745        let yaml = "model: qwen3:14b\nollama_endpoint: http://localhost:11434\n";
1746        let cfg: AskConfig = serde_yaml::from_str(yaml).unwrap();
1747        assert!(cfg.backend.is_none());
1748        assert!(cfg.rewriter_backend.is_none());
1749        assert_eq!(cfg.model, "qwen3:14b");
1750    }
1751
1752    #[test]
1753    fn compact_extractive_backend_override_parses() {
1754        let yaml = "\
1755extractive_backend:
1756  provider: anthropic
1757  model: claude-haiku-4-5
1758  api_key_env: ANTHROPIC_API_KEY
1759abstractive_model: qwen3:14b
1760";
1761        let cfg: CompactConfig = serde_yaml::from_str(yaml).unwrap();
1762        let extractive = cfg
1763            .extractive_backend
1764            .as_ref()
1765            .expect("override should parse");
1766        assert_eq!(extractive.provider, "anthropic");
1767        assert_eq!(extractive.model, "claude-haiku-4-5");
1768        assert!(cfg.abstractive_backend.is_none());
1769    }
1770
1771    #[test]
1772    fn ask_rewriter_backend_can_override_to_local_while_answer_is_cloud() {
1773        let yaml = "\
1774backend:
1775  provider: anthropic
1776  model: claude-sonnet-4-6
1777  api_key_env: ANTHROPIC_API_KEY
1778rewriter_backend:
1779  provider: ollama
1780  model: llama3.2:3b
1781";
1782        let cfg: AskConfig = serde_yaml::from_str(yaml).unwrap();
1783        assert_eq!(cfg.backend.as_ref().unwrap().provider, "anthropic");
1784        assert_eq!(cfg.rewriter_backend.as_ref().unwrap().provider, "ollama");
1785    }
1786
1787    #[test]
1788    fn synthesize_legacy_to_backend_config_for_compact_extractive() {
1789        let yaml = "\
1790extractive_model: qwen3:14b
1791ollama_endpoint: http://192.168.1.10:11434
1792";
1793        let cfg: CompactConfig = serde_yaml::from_str(yaml).unwrap();
1794        let synth = cfg.synthesize_extractive_backend();
1795        assert_eq!(synth.provider, "ollama");
1796        assert_eq!(synth.model, "qwen3:14b");
1797        assert_eq!(synth.endpoint.as_deref(), Some("http://192.168.1.10:11434"));
1798        assert_eq!(synth.api_key_env, None);
1799    }
1800
1801    #[test]
1802    fn synthesize_legacy_to_backend_config_for_ask() {
1803        let yaml = "model: qwen3:14b\nollama_endpoint: http://localhost:11434\n";
1804        let cfg: AskConfig = serde_yaml::from_str(yaml).unwrap();
1805        let synth = cfg.synthesize_backend();
1806        assert_eq!(synth.provider, "ollama");
1807        assert_eq!(synth.model, "qwen3:14b");
1808        assert_eq!(synth.endpoint.as_deref(), Some("http://localhost:11434"));
1809    }
1810
1811    #[test]
1812    fn synthesize_rewriter_uses_legacy_ollama_when_no_rewriter_override() {
1813        // Rewriter no longer falls through to synthesize_backend() when
1814        // `rewriter_backend` is unset (see I2 fix in P3 task 1). It now
1815        // always synthesizes its own ollama BackendConfig over the legacy
1816        // model + endpoint with `rewriter_timeout_secs` baked in, so a
1817        // slow rewriter call doesn't burn the full ask budget. The
1818        // per-stage `ask.backend` override therefore does NOT propagate to
1819        // the rewriter — set `ask.rewriter_backend` explicitly if you want
1820        // a non-Ollama rewriter.
1821        let yaml = "\
1822backend:
1823  provider: anthropic
1824  model: claude-sonnet-4-6
1825  api_key_env: ANTHROPIC_API_KEY
1826";
1827        let cfg: AskConfig = serde_yaml::from_str(yaml).unwrap();
1828        let rewriter = cfg.synthesize_rewriter_backend();
1829        assert_eq!(rewriter.provider, "ollama");
1830        assert_eq!(rewriter.model, ask_default_model());
1831        assert_eq!(
1832            rewriter.timeout_secs,
1833            Some(ask_default_rewriter_timeout() as u64)
1834        );
1835    }
1836
1837    #[test]
1838    fn ask_synthesize_backend_inherits_timeout_secs_from_legacy_field() {
1839        let cfg = AskConfig {
1840            timeout_secs: 45,
1841            ..AskConfig::default()
1842        };
1843        let b = cfg.synthesize_backend();
1844        assert_eq!(
1845            b.timeout_secs,
1846            Some(45),
1847            "synthesize_backend() must propagate ask.timeout_secs into the synthesized BackendConfig"
1848        );
1849    }
1850
1851    #[test]
1852    fn ask_synthesize_backend_does_not_override_explicit_per_stage_timeout() {
1853        let mut cfg = AskConfig {
1854            timeout_secs: 45,
1855            ..AskConfig::default()
1856        };
1857        cfg.backend = Some(BackendConfig {
1858            provider: "anthropic".into(),
1859            model: "claude-haiku-4-5".into(),
1860            endpoint: None,
1861            api_key_env: Some("ANTHROPIC_API_KEY".into()),
1862            timeout_secs: Some(10),
1863        });
1864        let b = cfg.synthesize_backend();
1865        assert_eq!(
1866            b.timeout_secs,
1867            Some(10),
1868            "explicit per-stage timeout_secs must NOT be overridden by ask.timeout_secs"
1869        );
1870    }
1871
1872    #[test]
1873    fn ask_synthesize_rewriter_backend_uses_rewriter_timeout_secs_when_synthesizing() {
1874        let cfg = AskConfig {
1875            timeout_secs: 120,
1876            rewriter_timeout_secs: 8,
1877            ..AskConfig::default()
1878        };
1879        let b = cfg.synthesize_rewriter_backend();
1880        assert_eq!(
1881            b.timeout_secs,
1882            Some(8),
1883            "rewriter synthesis must use rewriter_timeout_secs (not the answer-call timeout)"
1884        );
1885    }
1886
1887    #[test]
1888    fn ask_synthesize_rewriter_backend_does_not_override_explicit_per_stage_timeout() {
1889        let mut cfg = AskConfig {
1890            rewriter_timeout_secs: 8,
1891            ..AskConfig::default()
1892        };
1893        cfg.rewriter_backend = Some(BackendConfig {
1894            provider: "anthropic".into(),
1895            model: "claude-haiku-4-5".into(),
1896            endpoint: None,
1897            api_key_env: Some("ANTHROPIC_API_KEY".into()),
1898            timeout_secs: Some(30),
1899        });
1900        let b = cfg.synthesize_rewriter_backend();
1901        assert_eq!(
1902            b.timeout_secs,
1903            Some(30),
1904            "explicit per-stage rewriter timeout_secs must NOT be overridden by ask.rewriter_timeout_secs"
1905        );
1906    }
1907
1908    #[test]
1909    fn compact_synthesize_extractive_backend_inherits_default_timeout_when_no_override() {
1910        // CompactConfig has no per-stage timeout field — extractive synthesis
1911        // should fall back to the conservative 120s default.
1912        let cfg = CompactConfig::default();
1913        let b = cfg.synthesize_extractive_backend();
1914        assert_eq!(
1915            b.timeout_secs,
1916            Some(120),
1917            "compact synthesis without per-stage override must produce 120s timeout"
1918        );
1919    }
1920
1921    #[test]
1922    fn compact_synthesize_abstractive_backend_inherits_default_timeout_when_no_override() {
1923        let cfg = CompactConfig::default();
1924        let b = cfg.synthesize_abstractive_backend();
1925        assert_eq!(b.timeout_secs, Some(120));
1926    }
1927}
1928
1929#[cfg(test)]
1930mod skills_config_tests {
1931    use super::*;
1932
1933    #[test]
1934    fn empty_yaml_hydrates_defaults() {
1935        let cfg: Config = serde_yaml_ng::from_str("{}").unwrap();
1936        assert_eq!(cfg.skills.max_skills_in_prompt, 5);
1937        assert_eq!(cfg.skills.max_total_tokens, 2000);
1938        assert!(cfg.skills.adaptive.is_some());
1939    }
1940
1941    #[test]
1942    fn load_or_default_missing_file_returns_default() {
1943        let cfg = Config::load_or_default(std::path::Path::new("/nonexistent/config.yaml"));
1944        assert_eq!(cfg.skills.max_skills_in_prompt, 5);
1945    }
1946}
1947
1948#[cfg(test)]
1949mod ambient_capture_cfg_tests {
1950    use super::*;
1951
1952    #[test]
1953    fn session_and_harvest_defaults() {
1954        let cfg: Config = serde_yaml::from_str("{}").unwrap();
1955        assert_eq!(cfg.session.capture, "ambient");
1956        assert_eq!(cfg.session.retention_days, 14);
1957        assert!(cfg.harvest.auto_gate);
1958        assert_eq!(cfg.harvest.llm, "local-first");
1959        assert_eq!(cfg.harvest.min_events, 5);
1960        assert_eq!(cfg.harvest.min_user_turns, 2);
1961        assert_eq!(cfg.harvest.min_duration_secs, 120);
1962        assert_eq!(cfg.harvest.idle_minutes, 30);
1963        assert_eq!(cfg.harvest.max_llm_calls_per_day, 10);
1964        assert_eq!(cfg.harvest.max_extract_input_tokens, 12000);
1965        assert!(cfg.harvest.session_start_hint);
1966        assert!((cfg.harvest.similarity_merge_threshold - 0.6).abs() < f32::EPSILON);
1967    }
1968
1969    #[test]
1970    fn session_capture_override_parses() {
1971        let cfg: Config =
1972            serde_yaml::from_str("session:\n  capture: off\n  retention_days: 3\n").unwrap();
1973        assert_eq!(cfg.session.capture, "off");
1974        assert_eq!(cfg.session.retention_days, 3);
1975    }
1976}
1977
1978#[cfg(test)]
1979mod cc_proxy_cfg_tests {
1980    use super::*;
1981
1982    #[test]
1983    fn defaults_to_local_cc_proxy_enabled() {
1984        let cfg: Config = serde_yaml_ng::from_str("{}").unwrap();
1985        assert_eq!(cfg.cc_proxy.url, "http://127.0.0.1:8088");
1986        assert!(cfg.cc_proxy.enabled);
1987    }
1988
1989    #[test]
1990    fn url_and_enabled_override_parse() {
1991        let cfg: Config =
1992            serde_yaml_ng::from_str("cc_proxy:\n  url: http://127.0.0.1:9999\n  enabled: false\n")
1993                .unwrap();
1994        assert_eq!(cfg.cc_proxy.url, "http://127.0.0.1:9999");
1995        assert!(!cfg.cc_proxy.enabled);
1996    }
1997
1998    #[test]
1999    fn partial_section_keeps_other_default() {
2000        // Only `enabled` given → url stays at the default.
2001        let cfg: Config = serde_yaml_ng::from_str("cc_proxy:\n  enabled: false\n").unwrap();
2002        assert_eq!(cfg.cc_proxy.url, "http://127.0.0.1:8088");
2003        assert!(!cfg.cc_proxy.enabled);
2004    }
2005}