1use super::*;
2
3#[derive(Debug, Clone, Serialize, Deserialize, Default)]
4pub struct SyncConfig {
5 #[serde(default = "default_sync_method")]
7 pub method: String,
8
9 #[serde(default, skip_serializing_if = "Option::is_none")]
11 pub git_remote: Option<String>,
12
13 #[serde(default)]
15 pub auto: bool,
16
17 #[serde(default, skip_serializing_if = "Option::is_none")]
19 pub team_id: Option<String>,
20}
21
22fn default_sync_method() -> String {
23 "local".to_string()
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct ServerConfig {
28 #[serde(default = "default_server_url")]
30 pub url: String,
31}
32
33impl Default for ServerConfig {
34 fn default() -> Self {
35 Self {
36 url: default_server_url(),
37 }
38 }
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize, Default)]
42pub struct CommunityConfig {
43 #[serde(default)]
45 pub enabled: bool,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct EmbeddingConfig {
50 #[serde(default = "default_embedding_provider")]
52 pub provider: String,
53
54 #[serde(default = "default_embedding_model")]
56 pub model: String,
57
58 #[serde(default = "default_dimensions")]
60 pub dimensions: usize,
61
62 #[serde(default, skip_serializing_if = "Option::is_none")]
66 pub ollama_endpoint: Option<String>,
67
68 #[serde(default, skip_serializing_if = "Option::is_none")]
70 pub api_key_env: Option<String>,
71
72 #[serde(default, skip_serializing_if = "Option::is_none")]
75 pub api_key_ref: Option<String>,
76
77 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub openai_url: Option<String>,
80}
81
82impl Default for EmbeddingConfig {
83 fn default() -> Self {
84 Self {
85 provider: default_embedding_provider(),
86 model: default_embedding_model(),
87 dimensions: default_dimensions(),
88 ollama_endpoint: Some(default_ollama_endpoint()),
89 api_key_env: None,
90 api_key_ref: None,
91 openai_url: None,
92 }
93 }
94}
95
96#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct LlmConfig {
98 #[serde(default = "default_llm_provider")]
100 pub provider: String,
101
102 #[serde(default = "default_llm_model")]
103 pub model: String,
104
105 #[serde(default, skip_serializing_if = "Option::is_none")]
107 pub api_key_env: Option<String>,
108
109 #[serde(default, skip_serializing_if = "Option::is_none")]
112 pub api_key_ref: Option<String>,
113
114 #[serde(default, skip_serializing_if = "Option::is_none")]
116 pub openai_url: Option<String>,
117}
118
119impl Default for LlmConfig {
120 fn default() -> Self {
121 Self {
122 provider: default_llm_provider(),
123 model: default_llm_model(),
124 api_key_env: Some("ANTHROPIC_API_KEY".to_string()),
125 api_key_ref: None,
126 openai_url: None,
127 }
128 }
129}
130
131impl LlmConfig {
132 pub fn to_backend_config(&self) -> BackendConfig {
145 let provider = match self.provider.as_str() {
146 "anthropic" | "openai" | "openrouter" | "gemini" | "ollama" => self.provider.clone(),
147 _ if self.openai_url.is_some() => "openai".into(),
148 other => other.into(), };
150 BackendConfig {
151 provider,
152 model: self.model.clone(),
153 endpoint: self.openai_url.clone(),
154 api_key_env: self.api_key_env.clone(),
155 api_key_ref: self.api_key_ref.clone(),
156 timeout_secs: None,
157 }
158 }
159}
160
161#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
172#[serde(default)]
173pub struct BackendConfig {
174 pub provider: String,
176 pub model: String,
178 pub endpoint: Option<String>,
181 pub api_key_env: Option<String>,
183 pub api_key_ref: Option<String>,
185 pub timeout_secs: Option<u64>,
187}
188
189impl Default for BackendConfig {
190 fn default() -> Self {
191 Self {
192 provider: "ollama".into(),
193 model: DEFAULT_LOCAL_LLM_MODEL.into(),
194 endpoint: None,
195 api_key_env: None,
196 api_key_ref: None,
197 timeout_secs: None,
198 }
199 }
200}
201
202#[derive(Debug, Clone, Serialize, Deserialize)]
203pub struct RetrievalConfig {
204 #[serde(default = "default_max_patterns")]
206 pub max_patterns: usize,
207
208 #[serde(default = "default_max_tokens")]
210 pub max_tokens: usize,
211
212 #[serde(default = "default_min_score")]
214 pub min_score: f64,
215
216 #[serde(default = "default_mmr_threshold")]
218 pub mmr_threshold: f64,
219
220 #[serde(default = "default_reserved_note_slots")]
223 pub reserved_note_slots: usize,
224}
225
226impl Default for RetrievalConfig {
227 fn default() -> Self {
228 Self {
229 max_patterns: default_max_patterns(),
230 max_tokens: default_max_tokens(),
231 min_score: default_min_score(),
232 mmr_threshold: default_mmr_threshold(),
233 reserved_note_slots: default_reserved_note_slots(),
234 }
235 }
236}
237
238fn default_reserved_note_slots() -> usize {
239 1
240}
241
242#[derive(Debug, Clone, Serialize, Deserialize)]
243pub struct PathConfig {
244 #[serde(default = "default_mur_dir")]
246 pub mur_dir: PathBuf,
247}
248
249impl Default for PathConfig {
250 fn default() -> Self {
251 Self {
252 mur_dir: default_mur_dir(),
253 }
254 }
255}
256
257#[derive(Debug, Clone, Serialize, Deserialize)]
258pub struct StorageConfig {
259 #[serde(default = "default_vector_backend")]
261 pub vector_backend: String,
262
263 #[serde(default, skip_serializing_if = "Option::is_none")]
265 pub qdrant_url: Option<String>,
266
267 #[serde(default, skip_serializing_if = "Option::is_none")]
269 pub qdrant_api_key_ref: Option<String>,
270}
271
272impl Default for StorageConfig {
273 fn default() -> Self {
274 Self {
275 vector_backend: default_vector_backend(),
276 qdrant_url: None,
277 qdrant_api_key_ref: None,
278 }
279 }
280}
281
282fn default_vector_backend() -> String {
283 "lancedb".to_string()
284}
285
286#[derive(Debug, Clone, Serialize, Deserialize)]
287pub struct SourcesGlobalConfig {
288 #[serde(default = "default_poll_interval_secs")]
290 pub poll_interval_secs: u64,
291
292 #[serde(default = "default_max_chunks_per_sync")]
294 pub max_chunks_per_sync: usize,
295
296 #[serde(default = "default_max_parallel_sources")]
298 pub max_parallel_sources: usize,
299
300 #[serde(default = "default_source_weight")]
302 pub default_weight: f32,
303
304 #[serde(default = "default_embedding_batch_size")]
306 pub embedding_batch_size: usize,
307}
308
309impl Default for SourcesGlobalConfig {
310 fn default() -> Self {
311 Self {
312 poll_interval_secs: default_poll_interval_secs(),
313 max_chunks_per_sync: default_max_chunks_per_sync(),
314 max_parallel_sources: default_max_parallel_sources(),
315 default_weight: default_source_weight(),
316 embedding_batch_size: default_embedding_batch_size(),
317 }
318 }
319}
320
321fn default_poll_interval_secs() -> u64 {
322 600
323}
324fn default_max_chunks_per_sync() -> usize {
325 10_000
326}
327fn default_max_parallel_sources() -> usize {
328 3
329}
330fn default_source_weight() -> f32 {
331 1.0
332}
333fn default_embedding_batch_size() -> usize {
334 32
335}
336
337fn default_embedding_provider() -> String {
338 "ollama".to_string()
339}
340fn default_embedding_model() -> String {
341 "qwen3-embedding:0.6b".to_string()
342}
343fn default_dimensions() -> usize {
344 1024
345}
346fn default_ollama_endpoint() -> String {
347 DEFAULT_OLLAMA_ENDPOINT.to_string()
348}
349fn default_llm_provider() -> String {
350 "anthropic".to_string()
351}
352fn default_llm_model() -> String {
353 "claude-opus-5".to_string()
354}
355fn default_max_patterns() -> usize {
356 5
357}
358fn default_max_tokens() -> usize {
359 2000
360}
361fn default_min_score() -> f64 {
362 0.35
363}
364fn default_mmr_threshold() -> f64 {
365 0.85
366}
367fn default_mur_dir() -> PathBuf {
368 let home = std::env::var("HOME")
371 .map(PathBuf::from)
372 .unwrap_or_else(|_| PathBuf::from("/tmp"));
373 home.join(".mur")
374}
375fn default_server_url() -> String {
376 "https://mur-server.fly.dev".to_string()
377}
378
379#[cfg(test)]
380mod backend_config_tests {
381 use super::*;
382
383 #[test]
384 fn default_is_ollama_qwen3() {
385 let cfg = BackendConfig::default();
386 assert_eq!(cfg.provider, "ollama");
387 assert_eq!(cfg.model, "qwen3.5:4b");
388 assert_eq!(cfg.endpoint, None);
389 assert_eq!(cfg.api_key_env, None);
390 assert_eq!(cfg.timeout_secs, None);
391 }
392
393 #[test]
394 fn deserializes_anthropic_full() {
395 let yaml = "\
396provider: anthropic
397model: claude-haiku-4-5
398api_key_env: ANTHROPIC_API_KEY
399timeout_secs: 60
400";
401 let cfg: BackendConfig = serde_yaml::from_str(yaml).unwrap();
402 assert_eq!(cfg.provider, "anthropic");
403 assert_eq!(cfg.model, "claude-haiku-4-5");
404 assert_eq!(cfg.api_key_env, Some("ANTHROPIC_API_KEY".into()));
405 assert_eq!(cfg.timeout_secs, Some(60));
406 assert_eq!(cfg.endpoint, None);
407 }
408
409 #[test]
410 fn deserializes_partial_fills_defaults() {
411 let yaml = "provider: anthropic\nmodel: claude-sonnet-5\n";
412 let cfg: BackendConfig = serde_yaml::from_str(yaml).unwrap();
413 assert_eq!(cfg.provider, "anthropic");
414 assert_eq!(cfg.model, "claude-sonnet-5");
415 assert_eq!(cfg.api_key_env, None);
416 assert_eq!(cfg.timeout_secs, None);
417 }
418
419 #[test]
420 fn round_trips_through_yaml() {
421 let original = BackendConfig {
422 provider: "anthropic".into(),
423 model: "claude-haiku-4-5".into(),
424 endpoint: Some("https://api.anthropic.com".into()),
425 api_key_env: Some("ANTHROPIC_API_KEY".into()),
426 api_key_ref: None,
427 timeout_secs: Some(60),
428 };
429 let yaml = serde_yaml::to_string(&original).unwrap();
430 let parsed: BackendConfig = serde_yaml::from_str(&yaml).unwrap();
431 assert_eq!(parsed, original);
432 }
433
434 #[test]
435 fn skills_config_curation_gate_defaults_on() {
436 let c = SkillsConfig::default();
437 assert!(c.require_human_curation_before_stable);
438 }
439}
440
441#[cfg(test)]
442mod per_stage_backend_tests {
443 use super::*;
444
445 #[test]
446 fn compact_extractive_backend_override_parses() {
447 let yaml = "\
448extractive_backend:
449 provider: anthropic
450 model: claude-haiku-4-5
451 api_key_env: ANTHROPIC_API_KEY
452";
453 let cfg: CompactConfig = serde_yaml::from_str(yaml).unwrap();
454 let extractive = cfg
455 .extractive_backend
456 .as_ref()
457 .expect("override should parse");
458 assert_eq!(extractive.provider, "anthropic");
459 assert_eq!(extractive.model, "claude-haiku-4-5");
460 assert!(cfg.abstractive_backend.is_none());
461 }
462
463 #[test]
464 fn ask_rewriter_backend_can_override_to_local_while_answer_is_cloud() {
465 let yaml = "\
466backend:
467 provider: anthropic
468 model: claude-sonnet-5
469 api_key_env: ANTHROPIC_API_KEY
470rewriter_backend:
471 provider: ollama
472 model: llama3.2:3b
473";
474 let cfg: AskConfig = serde_yaml::from_str(yaml).unwrap();
475 assert_eq!(cfg.backend.as_ref().unwrap().provider, "anthropic");
476 assert_eq!(cfg.rewriter_backend.as_ref().unwrap().provider, "ollama");
477 }
478
479 #[test]
480 fn rewriter_falls_through_to_answer_stage_backend_before_the_smart_slot() {
481 let cfg = AskConfig {
492 backend: Some(BackendConfig {
493 provider: "anthropic".into(),
494 model: "claude-sonnet-5".into(),
495 endpoint: None,
496 api_key_env: Some("ANTHROPIC_API_KEY".into()),
497 api_key_ref: None,
498 timeout_secs: None,
499 }),
500 ..Default::default()
501 };
502 let rewriter = cfg.effective_rewriter_backend(&omlx_llm());
503 assert_eq!(rewriter.provider, "anthropic");
504 assert_eq!(rewriter.model, "claude-sonnet-5");
505 assert_eq!(
506 rewriter.timeout_secs,
507 Some(cfg.rewriter_timeout_secs as u64),
508 "rewriter must keep its own tighter timeout even while following the answer stage's backend"
509 );
510 }
511
512 #[test]
513 fn rewriter_explicit_override_timeout_wins_over_rewriter_timeout_secs() {
514 let mut cfg = AskConfig {
515 rewriter_timeout_secs: 8,
516 ..AskConfig::default()
517 };
518 cfg.rewriter_backend = Some(BackendConfig {
519 provider: "anthropic".into(),
520 model: "claude-haiku-4-5".into(),
521 endpoint: None,
522 api_key_env: Some("ANTHROPIC_API_KEY".into()),
523 api_key_ref: None,
524 timeout_secs: Some(30),
525 });
526 let b = cfg.effective_rewriter_backend(&omlx_llm());
527 assert_eq!(
528 b.timeout_secs,
529 Some(30),
530 "explicit per-stage rewriter_backend override must NOT be overridden by ask.rewriter_timeout_secs"
531 );
532 }
533
534 fn omlx_llm() -> LlmConfig {
535 LlmConfig {
536 provider: "omlx".into(),
537 model: "Qwen3.5-4B-MLX-4bit".into(),
538 api_key_env: None,
539 api_key_ref: Some("env:OMLX_API_KEY".into()),
540 openai_url: Some("http://127.0.0.1:8000/v1".into()),
541 }
542 }
543
544 #[test]
545 fn ask_without_override_inherits_smart_slot_and_maps_omlx_to_openai() {
546 let ask = AskConfig::default();
547 let b = ask.effective_backend(&omlx_llm());
548 assert_eq!(b.provider, "openai");
549 assert_eq!(b.model, "Qwen3.5-4B-MLX-4bit");
550 assert_eq!(b.endpoint.as_deref(), Some("http://127.0.0.1:8000/v1"));
551 assert_eq!(b.api_key_ref.as_deref(), Some("env:OMLX_API_KEY"));
552 assert_eq!(b.timeout_secs, Some(ask.timeout_secs as u64));
554 }
555
556 #[test]
557 fn ask_rewriter_inherits_its_own_shorter_timeout_not_the_answer_one() {
558 let ask = AskConfig::default();
559 let b = ask.effective_rewriter_backend(&omlx_llm());
560 assert_eq!(b.timeout_secs, Some(ask.rewriter_timeout_secs as u64));
561 assert_ne!(b.timeout_secs, Some(ask.timeout_secs as u64));
562 }
563
564 #[test]
565 fn explicit_override_wins_over_the_smart_slot() {
566 let ask = AskConfig {
567 backend: Some(BackendConfig {
568 provider: "anthropic".into(),
569 model: "claude-haiku-4-5".into(),
570 endpoint: None,
571 api_key_env: None,
572 api_key_ref: None,
573 timeout_secs: Some(42),
574 }),
575 ..Default::default()
576 };
577 let b = ask.effective_backend(&omlx_llm());
578 assert_eq!(b.provider, "anthropic");
579 assert_eq!(b.timeout_secs, Some(42));
580 }
581
582 #[test]
583 fn compact_and_rollup_inherit_smart_slot_with_the_120s_budget() {
584 let llm = omlx_llm();
585 for b in [
586 CompactConfig::default().effective_extractive_backend(&llm),
587 CompactConfig::default().effective_abstractive_backend(&llm),
588 RollupConfig::default().effective_extractive_backend(&llm),
589 RollupConfig::default().effective_abstractive_backend(&llm),
590 ] {
591 assert_eq!(b.provider, "openai");
592 assert_eq!(b.endpoint.as_deref(), Some("http://127.0.0.1:8000/v1"));
593 assert_eq!(b.timeout_secs, Some(120));
594 }
595 }
596
597 #[test]
598 fn rollup_override_is_honored() {
599 let r = RollupConfig {
600 abstractive_backend: Some(BackendConfig {
601 provider: "ollama".into(),
602 model: "qwen3:4b".into(),
603 endpoint: Some("http://box.local:11434".into()),
604 api_key_env: None,
605 api_key_ref: None,
606 timeout_secs: None,
607 }),
608 ..Default::default()
609 };
610 let b = r.effective_abstractive_backend(&omlx_llm());
611 assert_eq!(b.provider, "ollama");
612 assert_eq!(b.endpoint.as_deref(), Some("http://box.local:11434"));
613 }
614}