Skip to main content

mur_common/
config.rs

1use serde::{Deserialize, Serialize};
2use std::path::PathBuf;
3
4/// Global MUR configuration (~/.mur/config.yaml)
5#[derive(Debug, Clone, Serialize, Deserialize, Default)]
6pub struct Config {
7    #[serde(default)]
8    pub embedding: EmbeddingConfig,
9
10    #[serde(default)]
11    pub llm: LlmConfig,
12
13    #[serde(default)]
14    pub retrieval: RetrievalConfig,
15
16    #[serde(default)]
17    pub paths: PathConfig,
18
19    #[serde(default)]
20    pub server: ServerConfig,
21
22    #[serde(default)]
23    pub community: CommunityConfig,
24
25    #[serde(default)]
26    pub conversations: ConversationsConfig,
27
28    #[serde(default)]
29    pub sync: SyncConfig,
30
31    // --- P1.1 additions ---
32    #[serde(default)]
33    pub storage: StorageConfig,
34
35    #[serde(default)]
36    pub sources_global: SourcesGlobalConfig,
37
38    // --- E3 additions ---
39    #[serde(default)]
40    pub sleep_cycle: SleepCycleConfig,
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize, Default)]
44pub struct SyncConfig {
45    /// Sync method: "cloud", "git", or "local"
46    #[serde(default = "default_sync_method")]
47    pub method: String,
48
49    /// Git remote URL for git sync
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub git_remote: Option<String>,
52
53    /// Auto-sync on context pull / session stop
54    #[serde(default)]
55    pub auto: bool,
56}
57
58fn default_sync_method() -> String {
59    "local".to_string()
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct ServerConfig {
64    /// Server URL (default: https://mur-server.fly.dev)
65    #[serde(default = "default_server_url")]
66    pub url: String,
67}
68
69impl Default for ServerConfig {
70    fn default() -> Self {
71        Self {
72            url: default_server_url(),
73        }
74    }
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize, Default)]
78pub struct CommunityConfig {
79    /// Whether community pattern sharing is enabled
80    #[serde(default)]
81    pub enabled: bool,
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct EmbeddingConfig {
86    /// "ollama", "openai", "gemini", or "anthropic"
87    #[serde(default = "default_embedding_provider")]
88    pub provider: String,
89
90    /// Model name (e.g. "nomic-embed-text", "text-embedding-3-small")
91    #[serde(default = "default_embedding_model")]
92    pub model: String,
93
94    /// Vector dimensions (fixed after first index build)
95    #[serde(default = "default_dimensions")]
96    pub dimensions: usize,
97
98    /// Ollama endpoint
99    #[serde(default = "default_ollama_endpoint")]
100    pub ollama_endpoint: String,
101
102    /// API key env var name (e.g. "OPENAI_API_KEY")
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub api_key_env: Option<String>,
105
106    /// Custom OpenAI-compatible API URL (e.g. for OpenRouter)
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub openai_url: Option<String>,
109}
110
111impl Default for EmbeddingConfig {
112    fn default() -> Self {
113        Self {
114            provider: default_embedding_provider(),
115            model: default_embedding_model(),
116            dimensions: default_dimensions(),
117            ollama_endpoint: default_ollama_endpoint(),
118            api_key_env: None,
119            openai_url: None,
120        }
121    }
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct LlmConfig {
126    /// "anthropic", "openai", "gemini", or "ollama"
127    #[serde(default = "default_llm_provider")]
128    pub provider: String,
129
130    #[serde(default = "default_llm_model")]
131    pub model: String,
132
133    /// API key env var name (e.g. "ANTHROPIC_API_KEY")
134    #[serde(default, skip_serializing_if = "Option::is_none")]
135    pub api_key_env: Option<String>,
136
137    /// Custom OpenAI-compatible API URL (e.g. for OpenRouter)
138    #[serde(default, skip_serializing_if = "Option::is_none")]
139    pub openai_url: Option<String>,
140}
141
142impl Default for LlmConfig {
143    fn default() -> Self {
144        Self {
145            provider: default_llm_provider(),
146            model: default_llm_model(),
147            api_key_env: Some("ANTHROPIC_API_KEY".to_string()),
148            openai_url: None,
149        }
150    }
151}
152
153impl LlmConfig {
154    /// Convert legacy LlmConfig (used by extract_llm, learn, capture/starter)
155    /// into a BackendConfig that the new ChatBackend factory consumes.
156    /// Mapping:
157    /// - `provider` 1:1, except: unknown providers WITH openai_url become "openai"
158    ///   (preserves the historical LlmConfig::llm_complete fall-through for
159    ///   OpenAI-compatible passthrough proxies).
160    /// - `model` 1:1.
161    /// - `api_key_env` 1:1 (factory's resolve_api_key falls back to
162    ///   default_key_env(provider) when None — preserves LlmConfig behavior).
163    /// - `openai_url` → `endpoint` (semantic rename; same string semantics).
164    /// - `timeout_secs` always None (factory defaults to 120s — matches
165    ///   the historical 60s reqwest default behavior closely enough).
166    pub fn to_backend_config(&self) -> BackendConfig {
167        let provider = match self.provider.as_str() {
168            "anthropic" | "openai" | "openrouter" | "gemini" | "ollama" => self.provider.clone(),
169            _ if self.openai_url.is_some() => "openai".into(),
170            other => other.into(), // factory will reject with "unsupported provider"
171        };
172        BackendConfig {
173            provider,
174            model: self.model.clone(),
175            endpoint: self.openai_url.clone(),
176            api_key_env: self.api_key_env.clone(),
177            timeout_secs: None,
178        }
179    }
180}
181
182/// Backend selection for a single chat-completion call site.
183///
184/// Per spec §6 of cloud-LLM-backend design. Used by `CompactConfig`
185/// (per-stage) and `AskConfig` (per-stage) to override the legacy
186/// Ollama-only path. None of the `Option` fields are required;
187/// resolution falls back to provider defaults
188/// (ollama: http://localhost:11434, anthropic: https://api.anthropic.com).
189///
190/// Stays in mur-common (not mur-core) because it is pure data and
191/// will be reused by mur-agent-runtime in a future phase.
192#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
193#[serde(default)]
194pub struct BackendConfig {
195    /// "ollama" | "anthropic". Defaults to "ollama" for backward compat.
196    pub provider: String,
197    /// Model name as the provider sees it ("claude-haiku-4-5", "qwen3:4b", …).
198    pub model: String,
199    /// Provider endpoint. None = provider default
200    /// (ollama: http://localhost:11434, anthropic: https://api.anthropic.com).
201    pub endpoint: Option<String>,
202    /// Env var holding the API key. None = no auth (ollama).
203    pub api_key_env: Option<String>,
204    /// Per-call timeout in seconds. None = 120s.
205    pub timeout_secs: Option<u64>,
206}
207
208impl Default for BackendConfig {
209    fn default() -> Self {
210        Self {
211            provider: "ollama".into(),
212            model: "qwen3:4b".into(),
213            endpoint: None,
214            api_key_env: None,
215            timeout_secs: None,
216        }
217    }
218}
219
220#[derive(Debug, Clone, Serialize, Deserialize)]
221pub struct RetrievalConfig {
222    /// Max patterns to inject per query
223    #[serde(default = "default_max_patterns")]
224    pub max_patterns: usize,
225
226    /// Max tokens for injected content
227    #[serde(default = "default_max_tokens")]
228    pub max_tokens: usize,
229
230    /// Minimum score threshold
231    #[serde(default = "default_min_score")]
232    pub min_score: f64,
233
234    /// MMR diversity threshold (cosine > this = too similar)
235    #[serde(default = "default_mmr_threshold")]
236    pub mmr_threshold: f64,
237}
238
239impl Default for RetrievalConfig {
240    fn default() -> Self {
241        Self {
242            max_patterns: default_max_patterns(),
243            max_tokens: default_max_tokens(),
244            min_score: default_min_score(),
245            mmr_threshold: default_mmr_threshold(),
246        }
247    }
248}
249
250#[derive(Debug, Clone, Serialize, Deserialize)]
251pub struct PathConfig {
252    /// Root MUR directory (default: ~/.mur)
253    #[serde(default = "default_mur_dir")]
254    pub mur_dir: PathBuf,
255}
256
257impl Default for PathConfig {
258    fn default() -> Self {
259        Self {
260            mur_dir: default_mur_dir(),
261        }
262    }
263}
264
265#[derive(Debug, Clone, Serialize, Deserialize)]
266pub struct StorageConfig {
267    /// Vector backend identifier: "lancedb" (default) or "qdrant".
268    #[serde(default = "default_vector_backend")]
269    pub vector_backend: String,
270
271    /// Qdrant connection URL (only used when vector_backend = "qdrant").
272    #[serde(default, skip_serializing_if = "Option::is_none")]
273    pub qdrant_url: Option<String>,
274
275    /// Keyring account name holding the Qdrant API key, if any.
276    #[serde(default, skip_serializing_if = "Option::is_none")]
277    pub qdrant_api_key_ref: Option<String>,
278}
279
280impl Default for StorageConfig {
281    fn default() -> Self {
282        Self {
283            vector_backend: default_vector_backend(),
284            qdrant_url: None,
285            qdrant_api_key_ref: None,
286        }
287    }
288}
289
290fn default_vector_backend() -> String {
291    "lancedb".to_string()
292}
293
294#[derive(Debug, Clone, Serialize, Deserialize)]
295pub struct SourcesGlobalConfig {
296    /// Polling interval for cloud sources (seconds).
297    #[serde(default = "default_poll_interval_secs")]
298    pub poll_interval_secs: u64,
299
300    /// Safety cap: do not sync more than this many chunks per run.
301    #[serde(default = "default_max_chunks_per_sync")]
302    pub max_chunks_per_sync: usize,
303
304    /// Upper bound on parallel source sync tasks.
305    #[serde(default = "default_max_parallel_sources")]
306    pub max_parallel_sources: usize,
307
308    /// Weight applied to new sources unless overridden.
309    #[serde(default = "default_source_weight")]
310    pub default_weight: f32,
311
312    /// Embedding request batch size.
313    #[serde(default = "default_embedding_batch_size")]
314    pub embedding_batch_size: usize,
315}
316
317impl Default for SourcesGlobalConfig {
318    fn default() -> Self {
319        Self {
320            poll_interval_secs: default_poll_interval_secs(),
321            max_chunks_per_sync: default_max_chunks_per_sync(),
322            max_parallel_sources: default_max_parallel_sources(),
323            default_weight: default_source_weight(),
324            embedding_batch_size: default_embedding_batch_size(),
325        }
326    }
327}
328
329fn default_poll_interval_secs() -> u64 {
330    600
331}
332fn default_max_chunks_per_sync() -> usize {
333    10_000
334}
335fn default_max_parallel_sources() -> usize {
336    3
337}
338fn default_source_weight() -> f32 {
339    1.0
340}
341fn default_embedding_batch_size() -> usize {
342    32
343}
344
345fn default_embedding_provider() -> String {
346    "ollama".to_string()
347}
348fn default_embedding_model() -> String {
349    "qwen3-embedding:0.6b".to_string()
350}
351fn default_dimensions() -> usize {
352    1024
353}
354fn default_ollama_endpoint() -> String {
355    "http://localhost:11434".to_string()
356}
357fn default_llm_provider() -> String {
358    "anthropic".to_string()
359}
360fn default_llm_model() -> String {
361    "claude-opus-4-6".to_string()
362}
363fn default_max_patterns() -> usize {
364    5
365}
366fn default_max_tokens() -> usize {
367    2000
368}
369fn default_min_score() -> f64 {
370    0.35
371}
372fn default_mmr_threshold() -> f64 {
373    0.85
374}
375fn default_mur_dir() -> PathBuf {
376    // Use HOME env var directly to avoid the `dirs` dependency in mur-common.
377    // Callers in mur-core that need the real home dir should use `dirs` there.
378    let home = std::env::var("HOME")
379        .map(PathBuf::from)
380        .unwrap_or_else(|_| PathBuf::from("/tmp"));
381    home.join(".mur")
382}
383fn default_server_url() -> String {
384    "https://mur-server.fly.dev".to_string()
385}
386
387// ── Ask config (Phase 2B, Task 18) ───────────────────────────────────────────
388
389#[derive(Debug, Clone, Serialize, Deserialize)]
390pub struct AskConfig {
391    #[serde(default = "ask_default_model")]
392    pub model: String,
393    #[serde(default = "compact_default_ollama_endpoint")]
394    pub ollama_endpoint: String,
395    #[serde(default = "ask_default_k_summary")]
396    pub k_summary: u32,
397    #[serde(default = "ask_default_k_raw")]
398    pub k_raw: u32,
399    #[serde(default = "ask_default_esc")]
400    pub escalation_threshold: f64,
401    #[serde(default = "ask_default_mmr")]
402    pub mmr_threshold: f64,
403    #[serde(default = "ask_default_max_ctx")]
404    pub max_context_tokens: u32,
405    #[serde(default = "ask_default_resp_tok")]
406    pub response_tokens: u32,
407    #[serde(default = "ask_default_timeout")]
408    pub timeout_secs: u32,
409    #[serde(default = "ask_default_min_score")]
410    pub min_score: f64,
411    #[serde(default = "ask_default_continue_history_turns")]
412    pub continue_history_turns: u32,
413    /// Separate, shorter timeout for the rewriter LLM call (Phase 3.3).
414    /// Rewriter output is small (~80 tokens) and falling back to the raw
415    /// question on failure is non-fatal, so we don't want to burn the full
416    /// `timeout_secs` budget waiting on a slow/unreachable Ollama before
417    /// the user sees any response.
418    #[serde(default = "ask_default_rewriter_timeout")]
419    pub rewriter_timeout_secs: u32,
420    #[serde(default = "ask_default_compress_hits_enabled")]
421    pub compress_hits_enabled: bool,
422    #[serde(default = "ask_default_summarize_hits_enabled")]
423    pub summarize_hits_enabled: bool,
424    #[serde(default)]
425    pub summarize_model: Option<String>,
426    /// Per-stage backend override for the answer-generation model.
427    /// None = synthesize from legacy `model` + `ollama_endpoint`.
428    #[serde(default)]
429    pub backend: Option<BackendConfig>,
430    /// Per-stage backend override for the query rewriter.
431    /// None = synthesize an Ollama BackendConfig over the legacy `model` +
432    /// `ollama_endpoint` with `rewriter_timeout_secs` baked in.
433    #[serde(default)]
434    pub rewriter_backend: Option<BackendConfig>,
435}
436
437impl AskConfig {
438    /// Returns the effective backend for the answer-generation model.
439    /// Per-stage `backend` override wins; otherwise synthesize from legacy
440    /// fields (`model`, `ollama_endpoint`) into an Ollama BackendConfig.
441    ///
442    /// `timeout_secs` is baked from `self.timeout_secs` so the answer call
443    /// inherits the user's per-call budget (rather than factory's 120s
444    /// default). When the user supplied an explicit `backend` override
445    /// with its own `timeout_secs`, that wins — we only synthesize when
446    /// `self.backend` is None.
447    pub fn synthesize_backend(&self) -> BackendConfig {
448        self.backend.clone().unwrap_or_else(|| BackendConfig {
449            provider: "ollama".into(),
450            model: self.model.clone(),
451            endpoint: Some(self.ollama_endpoint.clone()),
452            api_key_env: None,
453            timeout_secs: Some(self.timeout_secs as u64),
454        })
455    }
456
457    /// Returns the effective backend for the query rewriter.
458    ///
459    /// When `self.rewriter_backend` is None, this synthesizes its OWN
460    /// Ollama BackendConfig with `self.rewriter_timeout_secs` baked in —
461    /// it does NOT fall through to `synthesize_backend()`. The rewriter
462    /// has a much tighter latency budget than the answer call (rewriter
463    /// output is small and falling back to the raw question on timeout
464    /// is non-fatal), so we don't want a slow Ollama burning the full
465    /// `timeout_secs` budget before the user sees any response.
466    pub fn synthesize_rewriter_backend(&self) -> BackendConfig {
467        self.rewriter_backend
468            .clone()
469            .unwrap_or_else(|| BackendConfig {
470                provider: "ollama".into(),
471                model: self.model.clone(),
472                endpoint: Some(self.ollama_endpoint.clone()),
473                api_key_env: None,
474                timeout_secs: Some(self.rewriter_timeout_secs as u64),
475            })
476    }
477}
478
479impl Default for AskConfig {
480    fn default() -> Self {
481        Self {
482            model: ask_default_model(),
483            ollama_endpoint: compact_default_ollama_endpoint(),
484            k_summary: ask_default_k_summary(),
485            k_raw: ask_default_k_raw(),
486            escalation_threshold: ask_default_esc(),
487            mmr_threshold: ask_default_mmr(),
488            max_context_tokens: ask_default_max_ctx(),
489            response_tokens: ask_default_resp_tok(),
490            timeout_secs: ask_default_timeout(),
491            min_score: ask_default_min_score(),
492            continue_history_turns: ask_default_continue_history_turns(),
493            rewriter_timeout_secs: ask_default_rewriter_timeout(),
494            compress_hits_enabled: ask_default_compress_hits_enabled(),
495            summarize_hits_enabled: ask_default_summarize_hits_enabled(),
496            summarize_model: None,
497            backend: None,
498            rewriter_backend: None,
499        }
500    }
501}
502
503fn ask_default_model() -> String {
504    "qwen3:4b".into()
505}
506fn ask_default_k_summary() -> u32 {
507    5
508}
509fn ask_default_k_raw() -> u32 {
510    10
511}
512fn ask_default_esc() -> f64 {
513    0.5
514}
515fn ask_default_mmr() -> f64 {
516    0.88
517}
518fn ask_default_max_ctx() -> u32 {
519    6000
520}
521fn ask_default_resp_tok() -> u32 {
522    1024
523}
524fn ask_default_timeout() -> u32 {
525    120
526}
527fn ask_default_min_score() -> f64 {
528    0.35
529}
530fn ask_default_rewriter_timeout() -> u32 {
531    8
532}
533fn ask_default_continue_history_turns() -> u32 {
534    3
535}
536fn ask_default_compress_hits_enabled() -> bool {
537    true
538}
539fn ask_default_summarize_hits_enabled() -> bool {
540    true
541}
542
543// ── Conversations archive config (Task 23) ────────────────────────────────────
544
545/// Phase 1 conversations archive config (Task 23).
546///
547/// Hard defaults: off-by-default (`enabled: false`), 30-day retention,
548/// 5-minute poll interval, all sources enabled, Mem0-style REJECT filters on,
549/// dedup threshold 0.85. Every sub-field is serde-default so a config.yaml
550/// without a `conversations:` section still parses.
551#[derive(Debug, Clone, Serialize, Deserialize)]
552pub struct ConversationsConfig {
553    #[serde(default)]
554    pub enabled: bool,
555    #[serde(default = "conv_default_retention_days")]
556    pub retention_days: u32,
557    #[serde(default = "conv_default_poll_interval")]
558    pub poll_interval_secs: u64,
559    #[serde(default)]
560    pub sources: ConversationsSources,
561    #[serde(default)]
562    pub filter: ConversationsFilter,
563    #[serde(default)]
564    pub compact: CompactConfig,
565    #[serde(default)]
566    pub ask: AskConfig,
567    #[serde(default)]
568    pub rollup: RollupConfig,
569}
570
571impl Default for ConversationsConfig {
572    fn default() -> Self {
573        Self {
574            enabled: false,
575            retention_days: conv_default_retention_days(),
576            poll_interval_secs: conv_default_poll_interval(),
577            sources: ConversationsSources::default(),
578            filter: ConversationsFilter::default(),
579            compact: CompactConfig::default(),
580            ask: AskConfig::default(),
581            rollup: RollupConfig::default(),
582        }
583    }
584}
585
586fn conv_default_retention_days() -> u32 {
587    30
588}
589fn conv_default_poll_interval() -> u64 {
590    300
591}
592fn conv_truthy() -> bool {
593    true
594}
595fn conv_default_dedup() -> f64 {
596    0.85
597}
598
599#[derive(Debug, Clone, Serialize, Deserialize)]
600pub struct CompactConfig {
601    #[serde(default = "conv_truthy")]
602    pub enabled_in_daemon: bool,
603    #[serde(default = "compact_default_max_days")]
604    pub max_days_per_run: u32,
605    #[serde(default = "compact_default_model")]
606    pub extractive_model: String,
607    #[serde(default = "compact_default_model")]
608    pub abstractive_model: String,
609    #[serde(default = "compact_default_ollama_endpoint")]
610    pub ollama_endpoint: String,
611    #[serde(default = "compact_default_max_spans")]
612    pub max_extractive_spans: u32,
613    #[serde(default = "compact_default_max_words")]
614    pub max_abstractive_words: u32,
615    #[serde(default = "compact_default_chunk_tokens")]
616    pub chunk_tokens: u32,
617    #[serde(default = "compact_default_history_retain")]
618    pub history_retain: u32,
619    #[serde(default = "compact_default_cron")]
620    pub daemon_cron: String,
621    /// Per-stage backend override for extractive summarization.
622    /// None = synthesize from legacy `extractive_model` + `ollama_endpoint`.
623    #[serde(default)]
624    pub extractive_backend: Option<BackendConfig>,
625    /// Per-stage backend override for abstractive summarization.
626    /// None = synthesize from legacy `abstractive_model` + `ollama_endpoint`.
627    #[serde(default)]
628    pub abstractive_backend: Option<BackendConfig>,
629}
630
631impl CompactConfig {
632    /// Returns the effective backend for the extractive stage.
633    /// Per-stage `extractive_backend` override wins; otherwise synthesize
634    /// from legacy fields into an Ollama BackendConfig.
635    ///
636    /// CompactConfig has no per-stage timeout field, so synthesis bakes
637    /// the conservative 120s default — matching the previously-hardcoded
638    /// `Duration::from_secs(120)` at the call sites (byte-identical to
639    /// the pre-trait OllamaClient construction).
640    pub fn synthesize_extractive_backend(&self) -> BackendConfig {
641        self.extractive_backend
642            .clone()
643            .unwrap_or_else(|| BackendConfig {
644                provider: "ollama".into(),
645                model: self.extractive_model.clone(),
646                endpoint: Some(self.ollama_endpoint.clone()),
647                api_key_env: None,
648                timeout_secs: Some(120),
649            })
650    }
651
652    /// Returns the effective backend for the abstractive stage.
653    /// See `synthesize_extractive_backend` for the timeout rationale.
654    pub fn synthesize_abstractive_backend(&self) -> BackendConfig {
655        self.abstractive_backend
656            .clone()
657            .unwrap_or_else(|| BackendConfig {
658                provider: "ollama".into(),
659                model: self.abstractive_model.clone(),
660                endpoint: Some(self.ollama_endpoint.clone()),
661                api_key_env: None,
662                timeout_secs: Some(120),
663            })
664    }
665}
666
667impl Default for CompactConfig {
668    fn default() -> Self {
669        Self {
670            enabled_in_daemon: true,
671            max_days_per_run: compact_default_max_days(),
672            extractive_model: compact_default_model(),
673            abstractive_model: compact_default_model(),
674            ollama_endpoint: compact_default_ollama_endpoint(),
675            max_extractive_spans: compact_default_max_spans(),
676            max_abstractive_words: compact_default_max_words(),
677            chunk_tokens: compact_default_chunk_tokens(),
678            history_retain: compact_default_history_retain(),
679            daemon_cron: compact_default_cron(),
680            extractive_backend: None,
681            abstractive_backend: None,
682        }
683    }
684}
685
686fn compact_default_max_days() -> u32 {
687    7
688}
689fn compact_default_model() -> String {
690    "qwen3:4b".into()
691}
692fn compact_default_ollama_endpoint() -> String {
693    "http://localhost:11434".into()
694}
695fn compact_default_max_spans() -> u32 {
696    20
697}
698fn compact_default_max_words() -> u32 {
699    400
700}
701fn compact_default_chunk_tokens() -> u32 {
702    6000
703}
704fn compact_default_history_retain() -> u32 {
705    5
706}
707fn compact_default_cron() -> String {
708    "0 0 3 * * * *".into()
709}
710
711// ── Rollup config (Phase 3.2, Task 1) ─────────────────────────────────────────
712
713#[derive(Debug, Clone, Serialize, Deserialize)]
714pub struct RollupConfig {
715    #[serde(default = "rollup_default_enabled")]
716    pub enabled: bool,
717    #[serde(default = "rollup_default_max_weeks")]
718    pub max_weeks_per_run: u32,
719    #[serde(default = "rollup_default_max_months")]
720    pub max_months_per_run: u32,
721    #[serde(default = "rollup_default_max_spans_week")]
722    pub max_extractive_spans_per_week: u32,
723    #[serde(default = "rollup_default_max_words_week")]
724    pub max_abstractive_words_per_week: u32,
725    #[serde(default = "rollup_default_max_spans_month")]
726    pub max_extractive_spans_per_month: u32,
727    #[serde(default = "rollup_default_max_words_month")]
728    pub max_abstractive_words_per_month: u32,
729    #[serde(default = "rollup_default_week_mmr")]
730    pub week_mmr_threshold: f64,
731    #[serde(default = "rollup_default_month_mmr")]
732    pub month_mmr_threshold: f64,
733    #[serde(default = "compact_default_model")]
734    pub extractive_model: String,
735    #[serde(default = "compact_default_model")]
736    pub abstractive_model: String,
737    #[serde(default = "compact_default_ollama_endpoint")]
738    pub ollama_endpoint: String,
739}
740
741impl Default for RollupConfig {
742    fn default() -> Self {
743        Self {
744            enabled: rollup_default_enabled(),
745            max_weeks_per_run: rollup_default_max_weeks(),
746            max_months_per_run: rollup_default_max_months(),
747            max_extractive_spans_per_week: rollup_default_max_spans_week(),
748            max_abstractive_words_per_week: rollup_default_max_words_week(),
749            max_extractive_spans_per_month: rollup_default_max_spans_month(),
750            max_abstractive_words_per_month: rollup_default_max_words_month(),
751            week_mmr_threshold: rollup_default_week_mmr(),
752            month_mmr_threshold: rollup_default_month_mmr(),
753            extractive_model: compact_default_model(),
754            abstractive_model: compact_default_model(),
755            ollama_endpoint: compact_default_ollama_endpoint(),
756        }
757    }
758}
759
760fn rollup_default_enabled() -> bool {
761    true
762}
763fn rollup_default_max_weeks() -> u32 {
764    4
765}
766fn rollup_default_max_months() -> u32 {
767    2
768}
769fn rollup_default_max_spans_week() -> u32 {
770    20
771}
772fn rollup_default_max_words_week() -> u32 {
773    500
774}
775fn rollup_default_max_spans_month() -> u32 {
776    20
777}
778fn rollup_default_max_words_month() -> u32 {
779    700
780}
781fn rollup_default_week_mmr() -> f64 {
782    0.85
783}
784fn rollup_default_month_mmr() -> f64 {
785    0.82
786}
787
788#[derive(Debug, Clone, Serialize, Deserialize)]
789pub struct ConversationsSources {
790    #[serde(default = "conv_truthy")]
791    pub claude_code: bool,
792    #[serde(default = "conv_truthy")]
793    pub cursor: bool,
794    #[serde(default = "conv_truthy")]
795    pub gemini: bool,
796    #[serde(default)]
797    pub aider: AiderSourceConfig,
798}
799
800impl Default for ConversationsSources {
801    fn default() -> Self {
802        Self {
803            claude_code: true,
804            cursor: true,
805            gemini: true,
806            aider: AiderSourceConfig::default(),
807        }
808    }
809}
810
811#[derive(Debug, Clone, Serialize, Deserialize)]
812pub struct AiderSourceConfig {
813    #[serde(default = "conv_truthy")]
814    pub enabled: bool,
815    #[serde(default)]
816    pub watched_dirs: Vec<String>,
817}
818
819impl Default for AiderSourceConfig {
820    fn default() -> Self {
821        Self {
822            enabled: true,
823            watched_dirs: Vec::new(),
824        }
825    }
826}
827
828#[derive(Debug, Clone, Serialize, Deserialize)]
829pub struct ConversationsFilter {
830    #[serde(default = "conv_default_dedup")]
831    pub dedup_threshold: f64,
832    #[serde(default = "conv_truthy")]
833    pub reject_heartbeat: bool,
834    #[serde(default = "conv_truthy")]
835    pub reject_system_restatement: bool,
836}
837
838impl Default for ConversationsFilter {
839    fn default() -> Self {
840        Self {
841            dedup_threshold: conv_default_dedup(),
842            reject_heartbeat: true,
843            reject_system_restatement: true,
844        }
845    }
846}
847
848#[cfg(test)]
849mod conversations_tests {
850    use super::*;
851
852    #[test]
853    fn conversations_section_defaults() {
854        let c = ConversationsConfig::default();
855        assert!(!c.enabled);
856        assert_eq!(c.retention_days, 30);
857        assert_eq!(c.poll_interval_secs, 300);
858        assert!(c.sources.claude_code);
859        assert!(c.sources.cursor);
860        assert!(c.sources.gemini);
861        assert!(c.sources.aider.enabled);
862        assert!(c.sources.aider.watched_dirs.is_empty());
863        assert_eq!(c.filter.dedup_threshold, 0.85);
864        assert!(c.filter.reject_heartbeat);
865        assert!(c.filter.reject_system_restatement);
866    }
867
868    #[test]
869    fn parse_from_yaml_with_overrides() {
870        let y = r#"
871conversations:
872  enabled: true
873  retention_days: 45
874  poll_interval_secs: 120
875  sources:
876    cursor: false
877    aider:
878      watched_dirs: ["~/Projects/a", "~/Projects/b"]
879  filter:
880    dedup_threshold: 0.9
881"#;
882        let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
883        let conv: ConversationsConfig = serde_yaml::from_value(v["conversations"].clone()).unwrap();
884        assert!(conv.enabled);
885        assert_eq!(conv.retention_days, 45);
886        assert_eq!(conv.poll_interval_secs, 120);
887        assert!(conv.sources.claude_code); // defaulted true
888        assert!(!conv.sources.cursor); // override
889        assert!(conv.sources.gemini); // defaulted true
890        assert_eq!(conv.sources.aider.watched_dirs.len(), 2);
891        assert_eq!(conv.filter.dedup_threshold, 0.9);
892        assert!(conv.filter.reject_heartbeat); // defaulted true
893    }
894
895    #[test]
896    fn missing_conversations_section_is_fine() {
897        let y = r#"
898# No conversations section at all
899foo: bar
900"#;
901        let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
902        // Default when absent
903        let conv: ConversationsConfig = v
904            .get("conversations")
905            .cloned()
906            .map(|x| serde_yaml::from_value(x).unwrap_or_default())
907            .unwrap_or_default();
908        assert_eq!(conv.retention_days, 30);
909    }
910
911    #[test]
912    fn compact_config_defaults() {
913        let c = CompactConfig::default();
914        assert!(c.enabled_in_daemon);
915        assert_eq!(c.max_days_per_run, 7);
916        assert_eq!(c.extractive_model, "qwen3:4b");
917        assert_eq!(c.abstractive_model, "qwen3:4b");
918        assert_eq!(c.ollama_endpoint, "http://localhost:11434");
919        assert_eq!(c.max_extractive_spans, 20);
920        assert_eq!(c.max_abstractive_words, 400);
921        assert_eq!(c.chunk_tokens, 6000);
922        assert_eq!(c.history_retain, 5);
923        assert_eq!(c.daemon_cron, "0 0 3 * * * *");
924    }
925
926    #[test]
927    fn compact_parses_partial_overrides() {
928        let y = r#"
929conversations:
930  compact:
931    max_days_per_run: 3
932    extractive_model: qwen3:4b
933"#;
934        let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
935        let conv: ConversationsConfig = serde_yaml::from_value(v["conversations"].clone()).unwrap();
936        assert_eq!(conv.compact.max_days_per_run, 3);
937        assert_eq!(conv.compact.extractive_model, "qwen3:4b");
938        assert!(conv.compact.enabled_in_daemon); // default preserved
939        assert_eq!(conv.compact.abstractive_model, "qwen3:4b"); // default preserved
940    }
941
942    #[test]
943    fn ask_config_defaults() {
944        let c = AskConfig::default();
945        assert_eq!(c.model, "qwen3:4b");
946        assert_eq!(c.ollama_endpoint, "http://localhost:11434");
947        assert_eq!(c.k_summary, 5);
948        assert_eq!(c.k_raw, 10);
949        assert_eq!(c.escalation_threshold, 0.5);
950        assert_eq!(c.mmr_threshold, 0.88);
951        assert_eq!(c.max_context_tokens, 6000);
952        assert_eq!(c.response_tokens, 1024);
953        assert_eq!(c.timeout_secs, 120);
954        assert_eq!(c.min_score, 0.35);
955    }
956
957    #[test]
958    fn ask_config_mmr_threshold_default_is_cosine_scaled() {
959        // Phase 3.1: default shifts from 0.85 (word-Jaccard) to 0.88 (cosine).
960        let c = AskConfig::default();
961        assert!(
962            (c.mmr_threshold - 0.88).abs() < 1e-9,
963            "expected 0.88, got {}",
964            c.mmr_threshold
965        );
966    }
967
968    #[test]
969    fn rollup_config_defaults() {
970        let c = RollupConfig::default();
971        assert!(c.enabled);
972        assert_eq!(c.max_weeks_per_run, 4);
973        assert_eq!(c.max_months_per_run, 2);
974        assert_eq!(c.max_extractive_spans_per_week, 20);
975        assert_eq!(c.max_abstractive_words_per_week, 500);
976        assert_eq!(c.max_extractive_spans_per_month, 20);
977        assert_eq!(c.max_abstractive_words_per_month, 700);
978        assert!((c.week_mmr_threshold - 0.85).abs() < 1e-9);
979        assert!((c.month_mmr_threshold - 0.82).abs() < 1e-9);
980        assert_eq!(c.extractive_model, "qwen3:4b");
981        assert_eq!(c.abstractive_model, "qwen3:4b");
982        assert_eq!(c.ollama_endpoint, "http://localhost:11434");
983    }
984
985    #[test]
986    fn rollup_config_plumbed_into_conversations_config() {
987        let c = ConversationsConfig::default();
988        assert!(c.rollup.enabled);
989    }
990
991    #[test]
992    fn ask_config_default_continue_history_turns_is_3() {
993        let c = AskConfig::default();
994        assert_eq!(c.continue_history_turns, 3);
995    }
996
997    #[test]
998    fn ask_config_default_compress_hits_enabled_is_true() {
999        let c = AskConfig::default();
1000        assert!(c.compress_hits_enabled);
1001    }
1002
1003    #[test]
1004    fn ask_config_default_summarize_hits_enabled_is_true() {
1005        let c = AskConfig::default();
1006        assert!(c.summarize_hits_enabled);
1007    }
1008
1009    #[test]
1010    fn ask_config_default_summarize_model_is_none() {
1011        let c = AskConfig::default();
1012        assert!(c.summarize_model.is_none());
1013    }
1014
1015    #[test]
1016    fn ask_config_yaml_roundtrip_preserves_summarize_fields() {
1017        let y = r#"
1018conversations:
1019  ask:
1020    summarize_hits_enabled: false
1021    summarize_model: qwen3:4b
1022"#;
1023        let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
1024        let conv: ConversationsConfig = serde_yaml::from_value(v["conversations"].clone()).unwrap();
1025        assert!(!conv.ask.summarize_hits_enabled);
1026        assert_eq!(conv.ask.summarize_model.as_deref(), Some("qwen3:4b"));
1027    }
1028
1029    #[test]
1030    fn ask_config_yaml_without_summarize_fields_uses_defaults() {
1031        // Phase 3.5 must be additive: an existing config.yaml with NO
1032        // summarize_* keys must still parse and default to enabled=true,
1033        // model=None.
1034        let y = r#"
1035conversations:
1036  ask:
1037    model: qwen3:14b
1038"#;
1039        let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
1040        let conv: ConversationsConfig = serde_yaml::from_value(v["conversations"].clone()).unwrap();
1041        assert!(conv.ask.summarize_hits_enabled);
1042        assert!(conv.ask.summarize_model.is_none());
1043    }
1044}
1045
1046#[cfg(test)]
1047mod tests {
1048    use super::*;
1049
1050    #[test]
1051    fn storage_config_default_is_lancedb() {
1052        let c = StorageConfig::default();
1053        assert_eq!(c.vector_backend, "lancedb");
1054        assert_eq!(c.qdrant_url, None);
1055        assert_eq!(c.qdrant_api_key_ref, None);
1056    }
1057
1058    #[test]
1059    fn sources_global_config_has_sensible_defaults() {
1060        let c = SourcesGlobalConfig::default();
1061        assert_eq!(c.poll_interval_secs, 600);
1062        assert_eq!(c.max_chunks_per_sync, 10_000);
1063        assert_eq!(c.max_parallel_sources, 3);
1064        assert_eq!(c.default_weight, 1.0);
1065        assert_eq!(c.embedding_batch_size, 32);
1066    }
1067
1068    #[test]
1069    fn config_default_has_storage_and_sources_global() {
1070        let c = Config::default();
1071        assert_eq!(c.storage.vector_backend, "lancedb");
1072        assert_eq!(c.sources_global.default_weight, 1.0);
1073    }
1074
1075    #[test]
1076    fn config_loads_yaml_without_new_fields() {
1077        // Existing users' config.yaml won't mention storage or sources_global.
1078        // It must still parse.
1079        let yaml = r#"
1080embedding:
1081  provider: ollama
1082  model: test-model
1083  dimensions: 512
1084  ollama_endpoint: http://localhost:11434
1085"#;
1086        let c: Config = serde_yaml::from_str(yaml).expect("parses");
1087        assert_eq!(c.storage.vector_backend, "lancedb");
1088        assert_eq!(c.sources_global.max_parallel_sources, 3);
1089    }
1090
1091    #[test]
1092    fn llm_config_to_backend_config_anthropic_passthrough() {
1093        let cfg = LlmConfig {
1094            provider: "anthropic".into(),
1095            model: "claude-haiku-4-5".into(),
1096            api_key_env: Some("ANTHROPIC_API_KEY".into()),
1097            openai_url: None,
1098        };
1099        let b = cfg.to_backend_config();
1100        assert_eq!(b.provider, "anthropic");
1101        assert_eq!(b.model, "claude-haiku-4-5");
1102        assert_eq!(b.api_key_env.as_deref(), Some("ANTHROPIC_API_KEY"));
1103        assert_eq!(b.endpoint, None);
1104        assert_eq!(b.timeout_secs, None);
1105    }
1106
1107    #[test]
1108    fn llm_config_to_backend_config_openai_url_maps_to_endpoint() {
1109        let cfg = LlmConfig {
1110            provider: "openai".into(),
1111            model: "gpt-4o-mini".into(),
1112            api_key_env: None,
1113            openai_url: Some("https://api.together.xyz/v1".into()),
1114        };
1115        let b = cfg.to_backend_config();
1116        assert_eq!(b.provider, "openai");
1117        assert_eq!(b.endpoint.as_deref(), Some("https://api.together.xyz/v1"));
1118        assert_eq!(b.api_key_env, None); // factory will fall back to OPENAI_API_KEY
1119    }
1120
1121    #[test]
1122    fn llm_config_to_backend_config_ollama_openai_url_maps_to_endpoint() {
1123        let cfg = LlmConfig {
1124            provider: "ollama".into(),
1125            model: "qwen3:14b".into(),
1126            api_key_env: None,
1127            openai_url: Some("http://192.168.1.10:11434".into()),
1128        };
1129        let b = cfg.to_backend_config();
1130        assert_eq!(b.provider, "ollama");
1131        assert_eq!(b.endpoint.as_deref(), Some("http://192.168.1.10:11434"));
1132    }
1133
1134    #[test]
1135    fn llm_config_to_backend_config_unknown_with_openai_url_aliases_to_openai() {
1136        // Historical LlmConfig allowed provider="custom" + openai_url to act as
1137        // an OpenAI-compatible passthrough. Preserve that by re-tagging as
1138        // "openai" so factory dispatches to OpenAIBackend.
1139        let cfg = LlmConfig {
1140            provider: "custom-name".into(),
1141            model: "some-model".into(),
1142            api_key_env: Some("CUSTOM_KEY".into()),
1143            openai_url: Some("https://my-proxy.local/v1".into()),
1144        };
1145        let b = cfg.to_backend_config();
1146        assert_eq!(
1147            b.provider, "openai",
1148            "unknown provider + openai_url should alias to openai"
1149        );
1150        assert_eq!(b.endpoint.as_deref(), Some("https://my-proxy.local/v1"));
1151    }
1152}
1153
1154#[cfg(test)]
1155mod backend_config_tests {
1156    use super::*;
1157
1158    #[test]
1159    fn default_is_ollama_qwen3() {
1160        let cfg = BackendConfig::default();
1161        assert_eq!(cfg.provider, "ollama");
1162        assert_eq!(cfg.model, "qwen3:4b");
1163        assert_eq!(cfg.endpoint, None);
1164        assert_eq!(cfg.api_key_env, None);
1165        assert_eq!(cfg.timeout_secs, None);
1166    }
1167
1168    #[test]
1169    fn deserializes_anthropic_full() {
1170        let yaml = "\
1171provider: anthropic
1172model: claude-haiku-4-5
1173api_key_env: ANTHROPIC_API_KEY
1174timeout_secs: 60
1175";
1176        let cfg: BackendConfig = serde_yaml::from_str(yaml).unwrap();
1177        assert_eq!(cfg.provider, "anthropic");
1178        assert_eq!(cfg.model, "claude-haiku-4-5");
1179        assert_eq!(cfg.api_key_env, Some("ANTHROPIC_API_KEY".into()));
1180        assert_eq!(cfg.timeout_secs, Some(60));
1181        assert_eq!(cfg.endpoint, None);
1182    }
1183
1184    #[test]
1185    fn deserializes_partial_fills_defaults() {
1186        let yaml = "provider: anthropic\nmodel: claude-sonnet-4-6\n";
1187        let cfg: BackendConfig = serde_yaml::from_str(yaml).unwrap();
1188        assert_eq!(cfg.provider, "anthropic");
1189        assert_eq!(cfg.model, "claude-sonnet-4-6");
1190        assert_eq!(cfg.api_key_env, None);
1191        assert_eq!(cfg.timeout_secs, None);
1192    }
1193
1194    #[test]
1195    fn round_trips_through_yaml() {
1196        let original = BackendConfig {
1197            provider: "anthropic".into(),
1198            model: "claude-haiku-4-5".into(),
1199            endpoint: Some("https://api.anthropic.com".into()),
1200            api_key_env: Some("ANTHROPIC_API_KEY".into()),
1201            timeout_secs: Some(60),
1202        };
1203        let yaml = serde_yaml::to_string(&original).unwrap();
1204        let parsed: BackendConfig = serde_yaml::from_str(&yaml).unwrap();
1205        assert_eq!(parsed, original);
1206    }
1207}
1208
1209/// Configuration for the daemon-side sleep cycle (idle background learning).
1210///
1211/// When enabled, the daemon fires a consolidation pipeline after the user has been
1212/// idle for `idle_threshold_minutes` minutes (default 15). Opt-in only — off by default.
1213#[derive(Debug, Clone, Serialize, Deserialize)]
1214pub struct SleepCycleConfig {
1215    /// Master switch. False by default (opt-in).
1216    #[serde(default)]
1217    pub enabled: bool,
1218
1219    /// Minutes of idle (no events) before triggering the daemon sleep cycle.
1220    #[serde(default = "default_idle_threshold_minutes")]
1221    pub idle_threshold_minutes: u64,
1222
1223    /// Minutes of agent idle before the agent-side cycle fires (outbox flush + snapshot pull).
1224    #[serde(default = "default_agent_idle_minutes")]
1225    pub agent_idle_minutes: u64,
1226}
1227
1228fn default_idle_threshold_minutes() -> u64 {
1229    15
1230}
1231
1232fn default_agent_idle_minutes() -> u64 {
1233    5
1234}
1235
1236impl Default for SleepCycleConfig {
1237    fn default() -> Self {
1238        Self {
1239            enabled: false,
1240            idle_threshold_minutes: default_idle_threshold_minutes(),
1241            agent_idle_minutes: default_agent_idle_minutes(),
1242        }
1243    }
1244}
1245
1246#[cfg(test)]
1247mod per_stage_backend_tests {
1248    use super::*;
1249
1250    #[test]
1251    fn legacy_compact_config_has_no_per_stage_overrides() {
1252        let yaml = "\
1253extractive_model: qwen3:14b
1254abstractive_model: qwen3:14b
1255ollama_endpoint: http://localhost:11434
1256";
1257        let cfg: CompactConfig = serde_yaml::from_str(yaml).unwrap();
1258        assert!(cfg.extractive_backend.is_none());
1259        assert!(cfg.abstractive_backend.is_none());
1260        assert_eq!(cfg.extractive_model, "qwen3:14b");
1261        assert_eq!(cfg.abstractive_model, "qwen3:14b");
1262        assert_eq!(cfg.ollama_endpoint, "http://localhost:11434");
1263    }
1264
1265    #[test]
1266    fn legacy_ask_config_has_no_per_stage_overrides() {
1267        let yaml = "model: qwen3:14b\nollama_endpoint: http://localhost:11434\n";
1268        let cfg: AskConfig = serde_yaml::from_str(yaml).unwrap();
1269        assert!(cfg.backend.is_none());
1270        assert!(cfg.rewriter_backend.is_none());
1271        assert_eq!(cfg.model, "qwen3:14b");
1272    }
1273
1274    #[test]
1275    fn compact_extractive_backend_override_parses() {
1276        let yaml = "\
1277extractive_backend:
1278  provider: anthropic
1279  model: claude-haiku-4-5
1280  api_key_env: ANTHROPIC_API_KEY
1281abstractive_model: qwen3:14b
1282";
1283        let cfg: CompactConfig = serde_yaml::from_str(yaml).unwrap();
1284        let extractive = cfg
1285            .extractive_backend
1286            .as_ref()
1287            .expect("override should parse");
1288        assert_eq!(extractive.provider, "anthropic");
1289        assert_eq!(extractive.model, "claude-haiku-4-5");
1290        assert!(cfg.abstractive_backend.is_none());
1291    }
1292
1293    #[test]
1294    fn ask_rewriter_backend_can_override_to_local_while_answer_is_cloud() {
1295        let yaml = "\
1296backend:
1297  provider: anthropic
1298  model: claude-sonnet-4-6
1299  api_key_env: ANTHROPIC_API_KEY
1300rewriter_backend:
1301  provider: ollama
1302  model: llama3.2:3b
1303";
1304        let cfg: AskConfig = serde_yaml::from_str(yaml).unwrap();
1305        assert_eq!(cfg.backend.as_ref().unwrap().provider, "anthropic");
1306        assert_eq!(cfg.rewriter_backend.as_ref().unwrap().provider, "ollama");
1307    }
1308
1309    #[test]
1310    fn synthesize_legacy_to_backend_config_for_compact_extractive() {
1311        let yaml = "\
1312extractive_model: qwen3:14b
1313ollama_endpoint: http://192.168.1.10:11434
1314";
1315        let cfg: CompactConfig = serde_yaml::from_str(yaml).unwrap();
1316        let synth = cfg.synthesize_extractive_backend();
1317        assert_eq!(synth.provider, "ollama");
1318        assert_eq!(synth.model, "qwen3:14b");
1319        assert_eq!(synth.endpoint.as_deref(), Some("http://192.168.1.10:11434"));
1320        assert_eq!(synth.api_key_env, None);
1321    }
1322
1323    #[test]
1324    fn synthesize_legacy_to_backend_config_for_ask() {
1325        let yaml = "model: qwen3:14b\nollama_endpoint: http://localhost:11434\n";
1326        let cfg: AskConfig = serde_yaml::from_str(yaml).unwrap();
1327        let synth = cfg.synthesize_backend();
1328        assert_eq!(synth.provider, "ollama");
1329        assert_eq!(synth.model, "qwen3:14b");
1330        assert_eq!(synth.endpoint.as_deref(), Some("http://localhost:11434"));
1331    }
1332
1333    #[test]
1334    fn synthesize_rewriter_uses_legacy_ollama_when_no_rewriter_override() {
1335        // Rewriter no longer falls through to synthesize_backend() when
1336        // `rewriter_backend` is unset (see I2 fix in P3 task 1). It now
1337        // always synthesizes its own ollama BackendConfig over the legacy
1338        // model + endpoint with `rewriter_timeout_secs` baked in, so a
1339        // slow rewriter call doesn't burn the full ask budget. The
1340        // per-stage `ask.backend` override therefore does NOT propagate to
1341        // the rewriter — set `ask.rewriter_backend` explicitly if you want
1342        // a non-Ollama rewriter.
1343        let yaml = "\
1344backend:
1345  provider: anthropic
1346  model: claude-sonnet-4-6
1347  api_key_env: ANTHROPIC_API_KEY
1348";
1349        let cfg: AskConfig = serde_yaml::from_str(yaml).unwrap();
1350        let rewriter = cfg.synthesize_rewriter_backend();
1351        assert_eq!(rewriter.provider, "ollama");
1352        assert_eq!(rewriter.model, ask_default_model());
1353        assert_eq!(
1354            rewriter.timeout_secs,
1355            Some(ask_default_rewriter_timeout() as u64)
1356        );
1357    }
1358
1359    #[test]
1360    fn ask_synthesize_backend_inherits_timeout_secs_from_legacy_field() {
1361        let cfg = AskConfig {
1362            timeout_secs: 45,
1363            ..AskConfig::default()
1364        };
1365        let b = cfg.synthesize_backend();
1366        assert_eq!(
1367            b.timeout_secs,
1368            Some(45),
1369            "synthesize_backend() must propagate ask.timeout_secs into the synthesized BackendConfig"
1370        );
1371    }
1372
1373    #[test]
1374    fn ask_synthesize_backend_does_not_override_explicit_per_stage_timeout() {
1375        let mut cfg = AskConfig {
1376            timeout_secs: 45,
1377            ..AskConfig::default()
1378        };
1379        cfg.backend = Some(BackendConfig {
1380            provider: "anthropic".into(),
1381            model: "claude-haiku-4-5".into(),
1382            endpoint: None,
1383            api_key_env: Some("ANTHROPIC_API_KEY".into()),
1384            timeout_secs: Some(10),
1385        });
1386        let b = cfg.synthesize_backend();
1387        assert_eq!(
1388            b.timeout_secs,
1389            Some(10),
1390            "explicit per-stage timeout_secs must NOT be overridden by ask.timeout_secs"
1391        );
1392    }
1393
1394    #[test]
1395    fn ask_synthesize_rewriter_backend_uses_rewriter_timeout_secs_when_synthesizing() {
1396        let cfg = AskConfig {
1397            timeout_secs: 120,
1398            rewriter_timeout_secs: 8,
1399            ..AskConfig::default()
1400        };
1401        let b = cfg.synthesize_rewriter_backend();
1402        assert_eq!(
1403            b.timeout_secs,
1404            Some(8),
1405            "rewriter synthesis must use rewriter_timeout_secs (not the answer-call timeout)"
1406        );
1407    }
1408
1409    #[test]
1410    fn ask_synthesize_rewriter_backend_does_not_override_explicit_per_stage_timeout() {
1411        let mut cfg = AskConfig {
1412            rewriter_timeout_secs: 8,
1413            ..AskConfig::default()
1414        };
1415        cfg.rewriter_backend = Some(BackendConfig {
1416            provider: "anthropic".into(),
1417            model: "claude-haiku-4-5".into(),
1418            endpoint: None,
1419            api_key_env: Some("ANTHROPIC_API_KEY".into()),
1420            timeout_secs: Some(30),
1421        });
1422        let b = cfg.synthesize_rewriter_backend();
1423        assert_eq!(
1424            b.timeout_secs,
1425            Some(30),
1426            "explicit per-stage rewriter timeout_secs must NOT be overridden by ask.rewriter_timeout_secs"
1427        );
1428    }
1429
1430    #[test]
1431    fn compact_synthesize_extractive_backend_inherits_default_timeout_when_no_override() {
1432        // CompactConfig has no per-stage timeout field — extractive synthesis
1433        // should fall back to the conservative 120s default.
1434        let cfg = CompactConfig::default();
1435        let b = cfg.synthesize_extractive_backend();
1436        assert_eq!(
1437            b.timeout_secs,
1438            Some(120),
1439            "compact synthesis without per-stage override must produce 120s timeout"
1440        );
1441    }
1442
1443    #[test]
1444    fn compact_synthesize_abstractive_backend_inherits_default_timeout_when_no_override() {
1445        let cfg = CompactConfig::default();
1446        let b = cfg.synthesize_abstractive_backend();
1447        assert_eq!(b.timeout_secs, Some(120));
1448    }
1449}