1use std::fmt;
6use std::fs;
7use std::path::Path;
8
9use serde::{Deserialize, Serialize};
10
11pub use crate::graph::confidence::ProvenanceWeights;
14
15const DEFAULT_MAX_ENTRIES: usize = 5;
16const DEFAULT_IDLE_TIMEOUT_SECS: u64 = 3600;
17const CONFIG_FILE: &str = ".recall-echo.toml";
18
19const DEFAULT_EXTRACTION_IDLE_AFTER_SECS: u64 = 120;
25const DEFAULT_EXTRACTION_BATCH_SIZE: usize = 3;
27
28const DEFAULT_CAPTURE_SETTLE_SECS: u64 = 300;
34
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
46#[serde(rename_all = "kebab-case")]
47pub enum Provider {
48 Anthropic,
49 Openai,
50 ClaudeCode,
51 Gemini,
52 Grok,
53 Codex,
54 Cli,
56}
57
58impl Provider {
59 #[must_use]
60 pub fn default_model(&self) -> &'static str {
61 match self {
62 Provider::Anthropic => "claude-haiku-4-5-20251001",
63 Provider::Openai => "llama3.2",
64 _ => "",
65 }
66 }
67
68 #[must_use]
69 pub fn default_api_base(&self) -> &'static str {
70 match self {
71 Provider::Anthropic => "https://api.anthropic.com/v1/messages",
72 Provider::Openai => "http://localhost:11434/v1",
73 _ => "",
74 }
75 }
76
77 #[must_use]
79 pub fn is_cli(&self) -> bool {
80 self.default_cli_preset().is_some()
81 }
82
83 #[must_use]
86 pub fn default_cli_preset(&self) -> Option<CliPreset> {
87 match self {
88 Provider::Anthropic | Provider::Openai => None,
89 Provider::ClaudeCode => Some(CliPreset::ClaudeCode),
90 Provider::Gemini => Some(CliPreset::Gemini),
91 Provider::Grok => Some(CliPreset::Grok),
92 Provider::Codex => Some(CliPreset::Codex),
93 Provider::Cli => Some(CliPreset::Custom),
94 }
95 }
96
97 pub fn from_str_loose(s: &str) -> Result<Self, crate::error::RecallError> {
98 match s.to_lowercase().as_str() {
99 "anthropic" | "claude" => Ok(Provider::Anthropic),
100 "openai" | "ollama" | "openai-compat" => Ok(Provider::Openai),
101 "claude-code" | "claudecode" => Ok(Provider::ClaudeCode),
102 "gemini" | "gemini-cli" | "google" => Ok(Provider::Gemini),
103 "grok" | "grok-cli" | "xai" => Ok(Provider::Grok),
104 "codex" | "codex-cli" => Ok(Provider::Codex),
105 "cli" | "custom" | "custom-cli" => Ok(Provider::Cli),
106 other => Err(crate::error::RecallError::Config(format!(
107 "unknown provider: {other} (use 'anthropic', 'ollama', 'claude-code', \
108 'gemini', 'grok', 'codex', or 'cli' with a [llm.cli] section)"
109 ))),
110 }
111 }
112}
113
114impl fmt::Display for Provider {
115 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
116 let name = match self {
117 Provider::Anthropic => "anthropic",
118 Provider::Openai => "openai",
119 Provider::ClaudeCode => "claude-code",
120 Provider::Gemini => "gemini",
121 Provider::Grok => "grok",
122 Provider::Codex => "codex",
123 Provider::Cli => "cli",
124 };
125 f.write_str(name)
126 }
127}
128
129#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
137#[serde(rename_all = "kebab-case")]
138pub enum CliPreset {
139 ClaudeCode,
140 Gemini,
141 Grok,
142 Codex,
143 Custom,
144}
145
146impl fmt::Display for CliPreset {
147 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
148 let name = match self {
149 CliPreset::ClaudeCode => "claude-code",
150 CliPreset::Gemini => "gemini",
151 CliPreset::Grok => "grok",
152 CliPreset::Codex => "codex",
153 CliPreset::Custom => "custom",
154 };
155 f.write_str(name)
156 }
157}
158
159impl CliPreset {
160 pub fn from_str_loose(s: &str) -> Result<Self, crate::error::RecallError> {
161 match s.to_lowercase().as_str() {
162 "claude-code" | "claudecode" | "claude" => Ok(CliPreset::ClaudeCode),
163 "gemini" | "gemini-cli" => Ok(CliPreset::Gemini),
164 "grok" | "grok-cli" => Ok(CliPreset::Grok),
165 "codex" | "codex-cli" => Ok(CliPreset::Codex),
166 "custom" | "none" => Ok(CliPreset::Custom),
167 other => Err(crate::error::RecallError::Config(format!(
168 "unknown CLI preset: {other} (use 'claude-code', 'gemini', 'grok', \
169 'codex', or 'custom')"
170 ))),
171 }
172 }
173}
174
175#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
177#[serde(rename_all = "kebab-case")]
178pub enum PromptDelivery {
179 Stdin,
181 Flag,
183 Arg,
185}
186
187impl fmt::Display for PromptDelivery {
188 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
189 let name = match self {
190 PromptDelivery::Stdin => "stdin",
191 PromptDelivery::Flag => "flag",
192 PromptDelivery::Arg => "arg",
193 };
194 f.write_str(name)
195 }
196}
197
198impl PromptDelivery {
199 pub fn from_str_loose(s: &str) -> Result<Self, crate::error::RecallError> {
200 match s.to_lowercase().as_str() {
201 "stdin" | "pipe" => Ok(PromptDelivery::Stdin),
202 "flag" | "option" => Ok(PromptDelivery::Flag),
203 "arg" | "argument" | "positional" => Ok(PromptDelivery::Arg),
204 other => Err(crate::error::RecallError::Config(format!(
205 "unknown prompt delivery: {other} (use 'stdin', 'flag', or 'arg')"
206 ))),
207 }
208 }
209}
210
211#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
219#[serde(rename_all = "kebab-case")]
220pub enum OutputMode {
221 Raw,
223 SingleJson,
225 Ndjson,
228}
229
230impl fmt::Display for OutputMode {
231 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
232 let name = match self {
233 OutputMode::Raw => "raw",
234 OutputMode::SingleJson => "single-json",
235 OutputMode::Ndjson => "ndjson",
236 };
237 f.write_str(name)
238 }
239}
240
241impl OutputMode {
242 pub fn from_str_loose(s: &str) -> Result<Self, crate::error::RecallError> {
243 match s.to_lowercase().as_str() {
244 "raw" | "text" | "plain" => Ok(OutputMode::Raw),
245 "single-json" | "json" => Ok(OutputMode::SingleJson),
246 "ndjson" | "jsonl" | "json-lines" | "streaming-json" => Ok(OutputMode::Ndjson),
247 other => Err(crate::error::RecallError::Config(format!(
248 "unknown output mode: {other} (use 'raw', 'single-json', or 'ndjson')"
249 ))),
250 }
251 }
252}
253
254#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
261#[serde(transparent)]
262pub struct LineMatchers(Vec<String>);
263
264impl LineMatchers {
265 #[must_use]
266 pub fn new(matchers: impl IntoIterator<Item = String>) -> Self {
267 Self(
268 matchers
269 .into_iter()
270 .map(|m| m.trim().to_string())
271 .filter(|m| !m.is_empty())
272 .collect(),
273 )
274 }
275
276 #[must_use]
278 pub fn parse(value: &str) -> Self {
279 Self::new(value.split(',').map(str::to_string))
280 }
281
282 #[must_use]
285 pub fn predicates(&self) -> Vec<(&str, &str)> {
286 self.0
287 .iter()
288 .filter_map(|matcher| matcher.split_once('='))
289 .map(|(path, value)| (path.trim(), value.trim()))
290 .collect()
291 }
292
293 #[must_use]
294 pub fn is_empty(&self) -> bool {
295 self.0.is_empty()
296 }
297}
298
299impl fmt::Display for LineMatchers {
300 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
301 f.write_str(&self.0.join(", "))
302 }
303}
304
305#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
313#[serde(from = "JsonPathSpec", into = "JsonPathSpec")]
314pub struct JsonPaths(Vec<String>);
315
316#[derive(Serialize, Deserialize)]
317#[serde(untagged)]
318enum JsonPathSpec {
319 One(String),
320 Many(Vec<String>),
321}
322
323impl From<JsonPathSpec> for JsonPaths {
324 fn from(spec: JsonPathSpec) -> Self {
325 match spec {
326 JsonPathSpec::One(path) => JsonPaths::new(std::iter::once(path)),
327 JsonPathSpec::Many(paths) => JsonPaths::new(paths),
328 }
329 }
330}
331
332impl From<JsonPaths> for JsonPathSpec {
333 fn from(paths: JsonPaths) -> Self {
334 let mut paths = paths.0;
335 if paths.len() == 1 {
336 JsonPathSpec::One(paths.remove(0))
337 } else {
338 JsonPathSpec::Many(paths)
339 }
340 }
341}
342
343impl JsonPaths {
344 #[must_use]
347 pub fn new(paths: impl IntoIterator<Item = String>) -> Self {
348 Self(
349 paths
350 .into_iter()
351 .map(|p| p.trim().to_string())
352 .filter(|p| !p.is_empty())
353 .collect(),
354 )
355 }
356
357 #[must_use]
359 pub fn parse(value: &str) -> Self {
360 Self::new(value.split(',').map(str::to_string))
361 }
362
363 #[must_use]
364 pub fn paths(&self) -> &[String] {
365 &self.0
366 }
367
368 #[must_use]
369 pub fn is_empty(&self) -> bool {
370 self.0.is_empty()
371 }
372}
373
374impl fmt::Display for JsonPaths {
375 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
376 f.write_str(&self.0.join(", "))
377 }
378}
379
380#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
387pub struct CliSection {
388 #[serde(default, skip_serializing_if = "Option::is_none")]
391 pub preset: Option<CliPreset>,
392 #[serde(default, skip_serializing_if = "Option::is_none")]
394 pub command: Option<String>,
395 #[serde(default, skip_serializing_if = "Option::is_none")]
397 pub args: Option<Vec<String>>,
398 #[serde(default, skip_serializing_if = "Option::is_none")]
400 pub prompt_delivery: Option<PromptDelivery>,
401 #[serde(default, skip_serializing_if = "Option::is_none")]
403 pub prompt_flag: Option<String>,
404 #[serde(default, skip_serializing_if = "Option::is_none")]
406 pub model_flag: Option<String>,
407 #[serde(default, skip_serializing_if = "Option::is_none")]
409 pub output_format_flag: Option<String>,
410 #[serde(default, skip_serializing_if = "Option::is_none")]
413 pub output_format_value: Option<String>,
414 #[serde(default, skip_serializing_if = "Option::is_none")]
417 pub output_mode: Option<OutputMode>,
418 #[serde(default, skip_serializing_if = "Option::is_none")]
421 pub ndjson_match: Option<LineMatchers>,
422 #[serde(default, skip_serializing_if = "Option::is_none")]
425 pub system_prompt_flag: Option<String>,
426 #[serde(default, skip_serializing_if = "Option::is_none")]
429 pub result_json_path: Option<JsonPaths>,
430 #[serde(default, skip_serializing_if = "Option::is_none")]
433 pub usage_input_path: Option<JsonPaths>,
434 #[serde(default, skip_serializing_if = "Option::is_none")]
436 pub usage_output_path: Option<JsonPaths>,
437 #[serde(default, skip_serializing_if = "Option::is_none")]
439 pub extra_args: Option<Vec<String>>,
440 #[serde(default, skip_serializing_if = "Option::is_none")]
442 pub timeout_secs: Option<u64>,
443}
444
445impl CliSection {
446 #[must_use]
449 pub fn is_empty(&self) -> bool {
450 *self == Self::default()
451 }
452
453 pub fn set_key(&mut self, key: &str, value: &str) -> Result<(), crate::error::RecallError> {
455 use crate::error::RecallError;
456 match key {
457 "preset" => self.preset = Some(CliPreset::from_str_loose(value)?),
458 "command" => self.command = Some(value.to_string()),
459 "args" => self.args = Some(split_args(value)),
460 "prompt_delivery" => {
461 self.prompt_delivery = Some(PromptDelivery::from_str_loose(value)?)
462 }
463 "prompt_flag" => self.prompt_flag = Some(value.to_string()),
464 "model_flag" => self.model_flag = Some(value.to_string()),
465 "output_format_flag" => self.output_format_flag = Some(value.to_string()),
466 "output_format_value" => self.output_format_value = Some(value.to_string()),
467 "output_mode" => self.output_mode = Some(OutputMode::from_str_loose(value)?),
468 "ndjson_match" => self.ndjson_match = Some(LineMatchers::parse(value)),
469 "system_prompt_flag" => self.system_prompt_flag = Some(value.to_string()),
470 "result_json_path" => self.result_json_path = Some(JsonPaths::parse(value)),
471 "usage_input_path" => self.usage_input_path = Some(JsonPaths::parse(value)),
472 "usage_output_path" => self.usage_output_path = Some(JsonPaths::parse(value)),
473 "extra_args" => self.extra_args = Some(split_args(value)),
474 "timeout_secs" => {
475 self.timeout_secs = Some(
476 value
477 .parse()
478 .map_err(|_| RecallError::Config(format!("invalid number: {value}")))?,
479 );
480 }
481 other => {
482 return Err(RecallError::Config(format!(
483 "unknown config key: llm.cli.{other}"
484 )))
485 }
486 }
487 Ok(())
488 }
489}
490
491fn split_args(value: &str) -> Vec<String> {
493 value.split_whitespace().map(str::to_string).collect()
494}
495
496#[derive(Debug, Default, Serialize, Deserialize)]
499pub struct Config {
500 #[serde(default)]
501 pub ephemeral: EphemeralConfig,
502 #[serde(default)]
503 pub llm: LlmSection,
504 #[serde(default)]
505 pub pipeline: Option<PipelineSection>,
506 #[serde(default)]
507 pub graph: Option<GraphSection>,
508 #[serde(default)]
509 pub serve: ServeSection,
510 #[serde(default)]
511 pub extraction: ExtractionSection,
512 #[serde(default)]
513 pub capture: CaptureSection,
514}
515
516#[derive(Debug, Serialize, Deserialize)]
517pub struct EphemeralConfig {
518 #[serde(default = "default_max_entries")]
519 pub max_entries: usize,
520}
521
522impl Default for EphemeralConfig {
523 fn default() -> Self {
524 Self {
525 max_entries: DEFAULT_MAX_ENTRIES,
526 }
527 }
528}
529
530fn default_max_entries() -> usize {
531 DEFAULT_MAX_ENTRIES
532}
533
534#[derive(Debug, Serialize, Deserialize)]
535pub struct LlmSection {
536 #[serde(default = "default_provider")]
537 pub provider: Provider,
538 #[serde(default)]
539 pub model: String,
540 #[serde(default)]
541 pub api_base: String,
542 #[serde(default, skip_serializing_if = "CliSection::is_empty")]
545 pub cli: CliSection,
546}
547
548impl Default for LlmSection {
549 fn default() -> Self {
550 Self {
551 provider: Provider::Anthropic,
552 model: String::new(),
553 api_base: String::new(),
554 cli: CliSection::default(),
555 }
556 }
557}
558
559impl LlmSection {
560 #[must_use]
562 pub fn resolved_model(&self) -> &str {
563 if self.model.is_empty() {
564 self.provider.default_model()
565 } else {
566 &self.model
567 }
568 }
569
570 #[must_use]
572 pub fn resolved_api_base(&self) -> &str {
573 if self.api_base.is_empty() {
574 self.provider.default_api_base()
575 } else {
576 &self.api_base
577 }
578 }
579}
580
581#[derive(Debug, Clone, Serialize, Deserialize)]
582pub struct PipelineSection {
583 #[serde(default)]
585 pub docs_dir: Option<String>,
586 #[serde(default)]
588 pub auto_sync: Option<bool>,
589}
590
591#[derive(Debug, Clone, Serialize, Deserialize)]
592pub struct GraphSection {
593 #[serde(default = "default_graph_mode")]
595 pub mode: String,
596 #[serde(default = "default_graph_url")]
598 pub url: String,
599 #[serde(default = "default_graph_namespace")]
601 pub namespace: String,
602 #[serde(default)]
604 pub database: String,
605 #[serde(default)]
607 pub username: String,
608 #[serde(default)]
610 pub password_file: String,
611 #[serde(default)]
617 pub scoring: GraphScoringConfig,
618 #[serde(default)]
624 pub provenance: ProvenanceWeights,
625 #[serde(default)]
630 pub dedup: GraphDedupConfig,
631}
632
633impl Default for GraphSection {
634 fn default() -> Self {
635 Self {
636 mode: default_graph_mode(),
637 url: default_graph_url(),
638 namespace: default_graph_namespace(),
639 database: String::new(),
640 username: String::new(),
641 password_file: String::new(),
642 scoring: GraphScoringConfig::default(),
643 provenance: ProvenanceWeights::default(),
644 dedup: GraphDedupConfig::default(),
645 }
646 }
647}
648
649#[derive(Debug, Clone, Serialize, Deserialize)]
655#[serde(default)]
656pub struct ServeSection {
657 pub socket_path: Option<String>,
660 pub idle_timeout_secs: u64,
663}
664
665impl Default for ServeSection {
666 fn default() -> Self {
667 Self {
668 socket_path: None,
669 idle_timeout_secs: DEFAULT_IDLE_TIMEOUT_SECS,
670 }
671 }
672}
673
674#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
691#[serde(default)]
692pub struct ExtractionSection {
693 pub background_enabled: bool,
696 pub idle_after_secs: u64,
699 pub batch_size: usize,
703}
704
705impl Default for ExtractionSection {
706 fn default() -> Self {
707 Self {
708 background_enabled: true,
709 idle_after_secs: DEFAULT_EXTRACTION_IDLE_AFTER_SECS,
710 batch_size: DEFAULT_EXTRACTION_BATCH_SIZE,
711 }
712 }
713}
714
715impl ExtractionSection {
716 #[must_use]
718 pub fn idle_after(&self) -> std::time::Duration {
719 std::time::Duration::from_secs(self.idle_after_secs)
720 }
721
722 #[must_use]
725 pub fn effective_batch_size(&self) -> usize {
726 self.batch_size.max(1)
727 }
728}
729
730#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
750#[serde(default)]
751pub struct CaptureSection {
752 pub enabled: bool,
754 #[serde(skip_serializing_if = "Option::is_none")]
757 pub sources: Option<Vec<crate::transcript::Source>>,
758 pub settle_secs: u64,
762}
763
764impl Default for CaptureSection {
765 fn default() -> Self {
766 Self {
767 enabled: true,
768 sources: None,
769 settle_secs: DEFAULT_CAPTURE_SETTLE_SECS,
770 }
771 }
772}
773
774impl CaptureSection {
775 #[must_use]
777 pub fn settle(&self) -> std::time::Duration {
778 std::time::Duration::from_secs(self.settle_secs)
779 }
780}
781
782#[derive(Debug, Clone, Serialize, Deserialize)]
807#[serde(default)]
808pub struct GraphScoringConfig {
809 pub weight_semantic: f64,
811 pub weight_hotness: f64,
813 pub weight_utility: f64,
815 pub corroboration_boost: f64,
842}
843
844impl Default for GraphScoringConfig {
845 fn default() -> Self {
846 Self {
847 weight_semantic: 0.45,
848 weight_hotness: 0.30,
849 weight_utility: 0.25,
850 corroboration_boost: 0.05,
851 }
852 }
853}
854
855#[derive(Debug, Clone, Serialize, Deserialize)]
880#[serde(default)]
881pub struct GraphDedupConfig {
882 pub certain_similarity: f64,
885 pub review_similarity: f64,
888 pub max_candidates: usize,
892}
893
894impl Default for GraphDedupConfig {
895 fn default() -> Self {
896 Self {
897 certain_similarity: 0.92,
898 review_similarity: 0.82,
899 max_candidates: 3,
900 }
901 }
902}
903
904impl GraphDedupConfig {
905 #[must_use]
907 pub fn band(&self, similarity: f64) -> DedupBand {
908 if similarity >= self.certain_similarity {
909 DedupBand::SameEntity
910 } else if similarity >= self.review_similarity {
911 DedupBand::Ambiguous
912 } else {
913 DedupBand::NewEntity
914 }
915 }
916
917 #[must_use]
920 pub fn candidate_limit(&self) -> usize {
921 self.max_candidates.max(1)
922 }
923}
924
925#[derive(Debug, Clone, Copy, PartialEq, Eq)]
927pub enum DedupBand {
928 SameEntity,
930 Ambiguous,
932 NewEntity,
934}
935
936fn default_graph_mode() -> String {
937 "embedded".to_string()
938}
939
940fn default_graph_url() -> String {
941 "ws://localhost:8787".to_string()
942}
943
944fn default_graph_namespace() -> String {
945 "nullarc".to_string()
946}
947
948fn default_provider() -> Provider {
949 Provider::Anthropic
950}
951
952#[must_use]
956pub fn config_path(base: &Path) -> std::path::PathBuf {
957 base.join(CONFIG_FILE)
958}
959
960#[must_use]
963pub fn load_from_dir(dir: &Path) -> Config {
964 load(dir)
965}
966
967#[must_use]
970pub fn load(base: &Path) -> Config {
971 let path = config_path(base);
972 if !path.exists() {
973 return Config::default();
974 }
975
976 let content = match fs::read_to_string(&path) {
977 Ok(c) => c,
978 Err(_) => return Config::default(),
979 };
980
981 match toml::from_str(&content) {
982 Ok(cfg) => validate(cfg),
983 Err(_) => Config::default(),
984 }
985}
986
987pub fn save(base: &Path, config: &Config) -> Result<(), crate::error::RecallError> {
989 let path = config_path(base);
990 let content = toml::to_string_pretty(config)?;
991 fs::write(&path, content)?;
992 Ok(())
993}
994
995#[must_use]
997pub fn exists(base: &Path) -> bool {
998 config_path(base).exists()
999}
1000
1001fn validate(mut cfg: Config) -> Config {
1002 if !(1..=50).contains(&cfg.ephemeral.max_entries) {
1003 cfg.ephemeral.max_entries = DEFAULT_MAX_ENTRIES;
1004 }
1005 cfg
1006}
1007
1008impl Config {
1011 pub fn set_key(&mut self, key: &str, value: &str) -> Result<(), crate::error::RecallError> {
1013 use crate::error::RecallError;
1014 match key {
1015 "llm.provider" | "provider" => {
1016 let provider = Provider::from_str_loose(value)?;
1017 self.llm.model = String::new();
1020 self.llm.api_base = String::new();
1021 self.llm.cli = CliSection::default();
1022 self.llm.provider = provider;
1023 Ok(())
1024 }
1025 _ if key.starts_with("llm.cli.") => {
1026 self.llm.cli.set_key(&key["llm.cli.".len()..], value)
1027 }
1028 "llm.model" | "model" => {
1029 self.llm.model = value.to_string();
1030 Ok(())
1031 }
1032 "llm.api_base" | "api_base" => {
1033 self.llm.api_base = value.to_string();
1034 Ok(())
1035 }
1036 "ephemeral.max_entries" => {
1037 let n: usize = value
1038 .parse()
1039 .map_err(|_| RecallError::Config(format!("invalid number: {value}")))?;
1040 if !(1..=50).contains(&n) {
1041 return Err(RecallError::Config(
1042 "max_entries must be between 1 and 50".into(),
1043 ));
1044 }
1045 self.ephemeral.max_entries = n;
1046 Ok(())
1047 }
1048 "pipeline.docs_dir" => {
1049 let section = self.pipeline.get_or_insert(PipelineSection {
1050 docs_dir: None,
1051 auto_sync: None,
1052 });
1053 section.docs_dir = Some(value.to_string());
1054 Ok(())
1055 }
1056 "pipeline.auto_sync" => {
1057 let b: bool = value
1058 .parse()
1059 .map_err(|_| RecallError::Config(format!("invalid boolean: {value}")))?;
1060 let section = self.pipeline.get_or_insert(PipelineSection {
1061 docs_dir: None,
1062 auto_sync: None,
1063 });
1064 section.auto_sync = Some(b);
1065 Ok(())
1066 }
1067 "serve.idle_timeout_secs" => {
1068 let secs: u64 = value
1069 .parse()
1070 .map_err(|_| RecallError::Config(format!("invalid number: {value}")))?;
1071 self.serve.idle_timeout_secs = secs;
1072 Ok(())
1073 }
1074 "serve.socket_path" => {
1075 self.serve.socket_path = if value.trim().is_empty() {
1076 None
1077 } else {
1078 Some(value.to_string())
1079 };
1080 Ok(())
1081 }
1082 "extraction.background_enabled" => {
1083 self.extraction.background_enabled = value
1084 .parse()
1085 .map_err(|_| RecallError::Config(format!("invalid boolean: {value}")))?;
1086 Ok(())
1087 }
1088 "extraction.idle_after_secs" => {
1089 self.extraction.idle_after_secs = value
1090 .parse()
1091 .map_err(|_| RecallError::Config(format!("invalid number: {value}")))?;
1092 Ok(())
1093 }
1094 "extraction.batch_size" => {
1095 let size: usize = value
1096 .parse()
1097 .map_err(|_| RecallError::Config(format!("invalid number: {value}")))?;
1098 if size == 0 {
1099 return Err(RecallError::Config("batch_size must be at least 1".into()));
1100 }
1101 self.extraction.batch_size = size;
1102 Ok(())
1103 }
1104 "capture.enabled" => {
1105 self.capture.enabled = value
1106 .parse()
1107 .map_err(|_| RecallError::Config(format!("invalid boolean: {value}")))?;
1108 Ok(())
1109 }
1110 "capture.settle_secs" => {
1111 self.capture.settle_secs = value
1112 .parse()
1113 .map_err(|_| RecallError::Config(format!("invalid number: {value}")))?;
1114 Ok(())
1115 }
1116 "capture.sources" => {
1117 self.capture.sources = parse_sources(value)?;
1118 Ok(())
1119 }
1120 "graph.provenance.weight_external" => {
1121 self.graph_section().provenance.weight_external = parse_weight(value)?;
1122 Ok(())
1123 }
1124 "graph.provenance.weight_user" => {
1125 self.graph_section().provenance.weight_user = parse_weight(value)?;
1126 Ok(())
1127 }
1128 "graph.provenance.weight_self" => {
1129 self.graph_section().provenance.weight_self = parse_weight(value)?;
1130 Ok(())
1131 }
1132 "graph.dedup.certain_similarity" => {
1133 self.graph_section().dedup.certain_similarity = parse_similarity(value)?;
1134 Ok(())
1135 }
1136 "graph.dedup.review_similarity" => {
1137 self.graph_section().dedup.review_similarity = parse_similarity(value)?;
1138 Ok(())
1139 }
1140 "graph.dedup.max_candidates" => {
1141 let n: usize = value
1142 .parse()
1143 .map_err(|_| RecallError::Config(format!("invalid number: {value}")))?;
1144 if n == 0 {
1145 return Err(RecallError::Config(
1146 "max_candidates must be at least 1".into(),
1147 ));
1148 }
1149 self.graph_section().dedup.max_candidates = n;
1150 Ok(())
1151 }
1152 other => Err(RecallError::Config(format!("unknown config key: {other}"))),
1153 }
1154 }
1155
1156 fn graph_section(&mut self) -> &mut GraphSection {
1158 self.graph.get_or_insert_with(GraphSection::default)
1159 }
1160}
1161
1162fn parse_sources(
1164 value: &str,
1165) -> Result<Option<Vec<crate::transcript::Source>>, crate::error::RecallError> {
1166 let names: Vec<&str> = value
1167 .split(',')
1168 .map(str::trim)
1169 .filter(|name| !name.is_empty())
1170 .collect();
1171 if names.is_empty() {
1172 return Ok(None);
1173 }
1174 let mut sources = Vec::with_capacity(names.len());
1175 for name in names {
1176 let source = crate::transcript::Source::from_str_loose(name)?;
1177 if !sources.contains(&source) {
1178 sources.push(source);
1179 }
1180 }
1181 Ok(Some(sources))
1182}
1183
1184fn parse_weight(value: &str) -> Result<f64, crate::error::RecallError> {
1188 use crate::error::RecallError;
1189 let weight: f64 = value
1190 .parse()
1191 .map_err(|_| RecallError::Config(format!("invalid number: {value}")))?;
1192 if !weight.is_finite() || weight < 0.0 {
1193 return Err(RecallError::Config(format!(
1194 "evidence weight must be finite and non-negative, got {value}"
1195 )));
1196 }
1197 Ok(weight)
1198}
1199
1200fn parse_similarity(value: &str) -> Result<f64, crate::error::RecallError> {
1202 use crate::error::RecallError;
1203 let similarity: f64 = value
1204 .parse()
1205 .map_err(|_| RecallError::Config(format!("invalid number: {value}")))?;
1206 if !similarity.is_finite() || !(0.0..=1.0).contains(&similarity) {
1207 return Err(RecallError::Config(format!(
1208 "similarity threshold must be between 0.0 and 1.0, got {value}"
1209 )));
1210 }
1211 Ok(similarity)
1212}
1213
1214#[cfg(test)]
1215mod tests {
1216 use super::*;
1217
1218 #[test]
1219 fn default_config() {
1220 let cfg = Config::default();
1221 assert_eq!(cfg.ephemeral.max_entries, 5);
1222 assert_eq!(cfg.llm.provider, Provider::Anthropic);
1223 assert!(cfg.llm.model.is_empty());
1224 }
1225
1226 #[test]
1227 fn parse_ephemeral_only() {
1228 let cfg: Config = toml::from_str("[ephemeral]\nmax_entries = 10\n").unwrap();
1229 assert_eq!(cfg.ephemeral.max_entries, 10);
1230 assert_eq!(cfg.llm.provider, Provider::Anthropic);
1231 }
1232
1233 #[test]
1234 fn graph_mode_defaults_to_embedded() {
1235 let cfg: Config = toml::from_str("[graph]\n").unwrap();
1236 assert_eq!(cfg.graph.unwrap().mode, "embedded");
1237 }
1238
1239 #[test]
1240 fn graph_mode_parses_server() {
1241 let cfg: Config =
1242 toml::from_str("[graph]\nmode = \"server\"\nurl = \"ws://db.local:8787\"\n").unwrap();
1243 let g = cfg.graph.unwrap();
1244 assert_eq!(g.mode, "server");
1245 assert_eq!(g.url, "ws://db.local:8787");
1246 }
1247
1248 #[test]
1249 fn serve_defaults_when_section_absent() {
1250 let cfg: Config = toml::from_str("[ephemeral]\nmax_entries = 3\n").unwrap();
1251 assert_eq!(cfg.serve.idle_timeout_secs, DEFAULT_IDLE_TIMEOUT_SECS);
1252 assert!(cfg.serve.socket_path.is_none());
1253 }
1254
1255 #[test]
1256 fn serve_section_parses_overrides() {
1257 let cfg: Config = toml::from_str(
1258 "[serve]\nsocket_path = \"/run/re/graph.sock\"\nidle_timeout_secs = 60\n",
1259 )
1260 .unwrap();
1261 assert_eq!(cfg.serve.idle_timeout_secs, 60);
1262 assert_eq!(cfg.serve.socket_path.as_deref(), Some("/run/re/graph.sock"));
1263 }
1264
1265 #[test]
1266 fn set_key_serve_idle_timeout() {
1267 let mut cfg = Config::default();
1268 cfg.set_key("serve.idle_timeout_secs", "120").unwrap();
1269 assert_eq!(cfg.serve.idle_timeout_secs, 120);
1270 assert!(cfg.set_key("serve.idle_timeout_secs", "soon").is_err());
1271 }
1272
1273 #[test]
1274 fn parse_llm_section() {
1275 let cfg: Config = toml::from_str(
1276 "[llm]\nprovider = \"openai\"\nmodel = \"llama3.1\"\napi_base = \"http://myhost:11434/v1\"\n",
1277 )
1278 .unwrap();
1279 assert_eq!(cfg.llm.provider, Provider::Openai);
1280 assert_eq!(cfg.llm.model, "llama3.1");
1281 assert_eq!(cfg.llm.api_base, "http://myhost:11434/v1");
1282 }
1283
1284 #[test]
1285 fn parse_claude_code_provider() {
1286 let cfg: Config = toml::from_str("[llm]\nprovider = \"claude-code\"\n").unwrap();
1287 assert_eq!(cfg.llm.provider, Provider::ClaudeCode);
1288 }
1289
1290 #[test]
1291 fn resolved_defaults() {
1292 let llm = LlmSection::default();
1293 assert_eq!(llm.resolved_model(), "claude-haiku-4-5-20251001");
1294 assert_eq!(
1295 llm.resolved_api_base(),
1296 "https://api.anthropic.com/v1/messages"
1297 );
1298 }
1299
1300 #[test]
1301 fn resolved_custom_overrides_default() {
1302 let llm = LlmSection {
1303 provider: Provider::Openai,
1304 model: "mistral-7b".into(),
1305 ..LlmSection::default()
1306 };
1307 assert_eq!(llm.resolved_model(), "mistral-7b");
1308 assert_eq!(llm.resolved_api_base(), "http://localhost:11434/v1");
1309 }
1310
1311 #[test]
1312 fn round_trip_toml() {
1313 let cfg = Config {
1314 ephemeral: EphemeralConfig { max_entries: 3 },
1315 llm: LlmSection {
1316 provider: Provider::Openai,
1317 model: "llama3.2".into(),
1318 api_base: "http://localhost:11434/v1".into(),
1319 ..LlmSection::default()
1320 },
1321 ..Config::default()
1322 };
1323 let s = toml::to_string_pretty(&cfg).unwrap();
1324 let parsed: Config = toml::from_str(&s).unwrap();
1325 assert_eq!(parsed.ephemeral.max_entries, 3);
1326 assert_eq!(parsed.llm.provider, Provider::Openai);
1327 assert_eq!(parsed.llm.model, "llama3.2");
1328 }
1329
1330 #[test]
1331 fn set_key_provider() {
1332 let mut cfg = Config::default();
1333 cfg.set_key("llm.provider", "ollama").unwrap();
1334 assert_eq!(cfg.llm.provider, Provider::Openai);
1335 assert!(cfg.llm.model.is_empty());
1336 }
1337
1338 #[test]
1339 fn set_key_model() {
1340 let mut cfg = Config::default();
1341 cfg.set_key("llm.model", "claude-sonnet-4-6").unwrap();
1342 assert_eq!(cfg.llm.model, "claude-sonnet-4-6");
1343 }
1344
1345 #[test]
1346 fn set_key_unknown_fails() {
1347 let mut cfg = Config::default();
1348 assert!(cfg.set_key("nonexistent.key", "value").is_err());
1349 }
1350
1351 #[test]
1352 fn set_key_cli_overrides() {
1353 let mut cfg = Config::default();
1354 cfg.set_key("llm.provider", "gemini").unwrap();
1355 cfg.set_key("llm.cli.command", "/opt/bin/gemini").unwrap();
1356 cfg.set_key("llm.cli.result_json_path", "response, result")
1357 .unwrap();
1358 cfg.set_key("llm.cli.extra_args", "--yolo --quiet").unwrap();
1359 cfg.set_key("llm.cli.prompt_delivery", "stdin").unwrap();
1360 cfg.set_key("llm.cli.timeout_secs", "45").unwrap();
1361
1362 let cli = &cfg.llm.cli;
1363 assert_eq!(cli.command.as_deref(), Some("/opt/bin/gemini"));
1364 assert_eq!(
1365 cli.result_json_path.as_ref().unwrap().paths(),
1366 ["response", "result"]
1367 );
1368 assert_eq!(
1369 cli.extra_args.as_deref(),
1370 Some(["--yolo".to_string(), "--quiet".to_string()].as_slice())
1371 );
1372 assert_eq!(cli.prompt_delivery, Some(PromptDelivery::Stdin));
1373 assert_eq!(cli.timeout_secs, Some(45));
1374
1375 assert!(cfg.set_key("llm.cli.nonexistent", "x").is_err());
1376 assert!(cfg.set_key("llm.cli.timeout_secs", "soon").is_err());
1377 assert!(cfg.set_key("llm.cli.preset", "nonesuch").is_err());
1378 }
1379
1380 #[test]
1383 fn switching_provider_clears_the_cli_overrides() {
1384 let mut cfg = Config::default();
1385 cfg.set_key("llm.provider", "gemini").unwrap();
1386 cfg.set_key("llm.cli.command", "/opt/bin/gemini").unwrap();
1387 cfg.set_key("llm.provider", "grok").unwrap();
1388
1389 assert_eq!(cfg.llm.provider, Provider::Grok);
1390 assert!(cfg.llm.cli.is_empty());
1391 }
1392
1393 #[test]
1394 fn cli_section_parses_from_toml() {
1395 let cfg: Config = toml::from_str(
1396 "[llm]\nprovider = \"cli\"\n\n[llm.cli]\ncommand = \"mycli\"\n\
1397 prompt_delivery = \"flag\"\nprompt_flag = \"--ask\"\n\
1398 result_json_path = [\"data.text\", \"text\"]\nargs = [\"chat\"]\n",
1399 )
1400 .expect("parse [llm.cli]");
1401 let cli = cfg.llm.cli;
1402 assert_eq!(cfg.llm.provider, Provider::Cli);
1403 assert_eq!(cli.command.as_deref(), Some("mycli"));
1404 assert_eq!(cli.prompt_delivery, Some(PromptDelivery::Flag));
1405 assert_eq!(cli.prompt_flag.as_deref(), Some("--ask"));
1406 assert_eq!(
1407 cli.result_json_path.as_ref().unwrap().paths(),
1408 ["data.text", "text"]
1409 );
1410 assert_eq!(cli.args.as_deref(), Some(["chat".to_string()].as_slice()));
1411 }
1412
1413 #[test]
1414 fn set_key_output_mode_and_ndjson_match() {
1415 let mut cfg = Config::default();
1416 cfg.set_key("llm.provider", "cli").unwrap();
1417 cfg.set_key("llm.cli.output_mode", "ndjson").unwrap();
1418 cfg.set_key(
1419 "llm.cli.ndjson_match",
1420 "type=item.completed, item.type=agent_message",
1421 )
1422 .unwrap();
1423
1424 assert_eq!(cfg.llm.cli.output_mode, Some(OutputMode::Ndjson));
1425 assert_eq!(
1426 cfg.llm.cli.ndjson_match.as_ref().unwrap().predicates(),
1427 [("type", "item.completed"), ("item.type", "agent_message")]
1428 );
1429 assert!(cfg.set_key("llm.cli.output_mode", "yaml").is_err());
1430 }
1431
1432 #[test]
1433 fn output_mode_accepts_the_obvious_spellings() {
1434 assert_eq!(
1435 OutputMode::from_str_loose("jsonl").unwrap(),
1436 OutputMode::Ndjson
1437 );
1438 assert_eq!(
1439 OutputMode::from_str_loose("json").unwrap(),
1440 OutputMode::SingleJson
1441 );
1442 assert_eq!(OutputMode::from_str_loose("TEXT").unwrap(), OutputMode::Raw);
1443 }
1444
1445 #[test]
1448 fn line_matchers_drop_entries_without_a_value() {
1449 let matchers = LineMatchers::parse("type=item.completed, garbage, ");
1450 assert_eq!(matchers.predicates(), [("type", "item.completed")]);
1451 }
1452
1453 #[test]
1454 fn result_json_path_accepts_a_bare_string() {
1455 let cli: CliSection = toml::from_str("result_json_path = \"result\"\n").expect("parse");
1456 assert_eq!(cli.result_json_path.unwrap().paths(), ["result"]);
1457 }
1458
1459 #[test]
1460 fn an_empty_result_json_path_means_raw_stdout() {
1461 let cli: CliSection = toml::from_str("result_json_path = \"\"\n").expect("parse");
1462 assert!(cli.result_json_path.unwrap().is_empty());
1463 }
1464
1465 #[test]
1468 fn a_config_without_a_cli_section_round_trips_unchanged() {
1469 let tmp = tempfile::tempdir().unwrap();
1470 let mut cfg = Config::default();
1471 cfg.set_key("llm.provider", "claude-code").unwrap();
1472 save(tmp.path(), &cfg).unwrap();
1473
1474 let rendered = fs::read_to_string(config_path(tmp.path())).unwrap();
1475 assert!(!rendered.contains("[llm.cli]"), "{rendered}");
1476 assert_eq!(load(tmp.path()).llm.provider, Provider::ClaudeCode);
1477 }
1478
1479 #[test]
1480 fn cli_overrides_survive_a_save_and_load() {
1481 let tmp = tempfile::tempdir().unwrap();
1482 let mut cfg = Config::default();
1483 cfg.set_key("llm.provider", "cli").unwrap();
1484 cfg.set_key("llm.cli.command", "mycli").unwrap();
1485 cfg.set_key("llm.cli.result_json_path", "data.text")
1486 .unwrap();
1487 save(tmp.path(), &cfg).unwrap();
1488
1489 let loaded = load(tmp.path());
1490 assert_eq!(loaded.llm.provider, Provider::Cli);
1491 assert_eq!(loaded.llm.cli.command.as_deref(), Some("mycli"));
1492 assert_eq!(
1493 loaded.llm.cli.result_json_path.unwrap().paths(),
1494 ["data.text"]
1495 );
1496 }
1497
1498 #[test]
1499 fn cli_providers_are_distinguished_from_http_ones() {
1500 assert!(Provider::ClaudeCode.is_cli());
1501 assert!(Provider::Gemini.is_cli());
1502 assert!(Provider::Grok.is_cli());
1503 assert!(Provider::Codex.is_cli());
1504 assert!(Provider::Cli.is_cli());
1505 assert!(!Provider::Anthropic.is_cli());
1506 assert!(!Provider::Openai.is_cli());
1507 }
1508
1509 #[test]
1510 fn provider_from_str_loose_accepts_the_cli_vendors() {
1511 assert_eq!(
1512 Provider::from_str_loose("gemini").unwrap(),
1513 Provider::Gemini
1514 );
1515 assert_eq!(
1516 Provider::from_str_loose("gemini-cli").unwrap(),
1517 Provider::Gemini
1518 );
1519 assert_eq!(Provider::from_str_loose("Grok").unwrap(), Provider::Grok);
1520 assert_eq!(Provider::from_str_loose("xai").unwrap(), Provider::Grok);
1521 assert_eq!(Provider::from_str_loose("cli").unwrap(), Provider::Cli);
1522 assert_eq!(Provider::from_str_loose("custom").unwrap(), Provider::Cli);
1523 }
1524
1525 #[test]
1526 fn codex_resolves_to_its_own_preset() {
1527 assert_eq!(Provider::from_str_loose("codex").unwrap(), Provider::Codex);
1528 assert_eq!(
1529 Provider::Codex.default_cli_preset(),
1530 Some(CliPreset::Codex),
1531 "codex must not fall through to the custom preset"
1532 );
1533 }
1534
1535 #[test]
1538 fn an_unknown_vendor_is_pointed_at_the_cli_provider() {
1539 let err = Provider::from_str_loose("some-new-agent").expect_err("no such preset");
1540 assert!(err.to_string().contains("[llm.cli]"), "{err}");
1541 }
1542
1543 #[test]
1544 fn provider_display_round_trips_through_from_str_loose() {
1545 for provider in [
1546 Provider::Anthropic,
1547 Provider::Openai,
1548 Provider::ClaudeCode,
1549 Provider::Gemini,
1550 Provider::Grok,
1551 Provider::Codex,
1552 Provider::Cli,
1553 ] {
1554 let rendered = provider.to_string();
1555 assert_eq!(
1556 Provider::from_str_loose(&rendered).unwrap(),
1557 provider,
1558 "{rendered}"
1559 );
1560 }
1561 }
1562
1563 #[test]
1564 fn provider_from_str_loose() {
1565 assert_eq!(
1566 Provider::from_str_loose("ollama").unwrap(),
1567 Provider::Openai
1568 );
1569 assert_eq!(
1570 Provider::from_str_loose("claude").unwrap(),
1571 Provider::Anthropic
1572 );
1573 assert_eq!(
1574 Provider::from_str_loose("claude-code").unwrap(),
1575 Provider::ClaudeCode
1576 );
1577 assert!(Provider::from_str_loose("unknown").is_err());
1578 }
1579
1580 #[test]
1581 fn save_and_load() {
1582 let tmp = tempfile::tempdir().unwrap();
1583 let cfg = Config {
1584 ephemeral: EphemeralConfig { max_entries: 7 },
1585 llm: LlmSection {
1586 provider: Provider::ClaudeCode,
1587 ..LlmSection::default()
1588 },
1589 ..Config::default()
1590 };
1591 save(tmp.path(), &cfg).unwrap();
1592 let loaded = load(tmp.path());
1593 assert_eq!(loaded.ephemeral.max_entries, 7);
1594 assert_eq!(loaded.llm.provider, Provider::ClaudeCode);
1595 }
1596
1597 #[test]
1598 fn load_nonexistent_file() {
1599 let tmp = tempfile::tempdir().unwrap();
1600 let cfg = load(tmp.path());
1601 assert_eq!(cfg.ephemeral.max_entries, 5);
1602 }
1603
1604 #[test]
1605 fn validate_out_of_range() {
1606 let cfg = validate(Config {
1607 ephemeral: EphemeralConfig { max_entries: 100 },
1608 ..Config::default()
1609 });
1610 assert_eq!(cfg.ephemeral.max_entries, 5);
1611 }
1612
1613 #[test]
1614 fn capture_defaults_are_on_and_auto_detecting() {
1615 let capture = CaptureSection::default();
1616 assert!(capture.enabled);
1617 assert!(capture.sources.is_none());
1618 assert_eq!(capture.settle(), std::time::Duration::from_secs(300));
1619 }
1620
1621 #[test]
1624 fn a_config_without_a_capture_section_still_loads() {
1625 let cfg: Config = toml::from_str("[ephemeral]\nmax_entries = 3\n").unwrap();
1626 assert!(cfg.capture.enabled);
1627 assert!(cfg.capture.sources.is_none());
1628 }
1629
1630 #[test]
1631 fn capture_section_parses_an_explicit_source_list() {
1632 let cfg: Config = toml::from_str(
1633 "[capture]\nenabled = false\nsources = [\"codex\", \"grok\"]\nsettle_secs = 60\n",
1634 )
1635 .expect("parse [capture]");
1636 assert!(!cfg.capture.enabled);
1637 assert_eq!(cfg.capture.settle_secs, 60);
1638 assert_eq!(
1639 cfg.capture.sources.as_deref(),
1640 Some(
1641 [
1642 crate::transcript::Source::Codex,
1643 crate::transcript::Source::Grok
1644 ]
1645 .as_slice()
1646 )
1647 );
1648 }
1649
1650 #[test]
1651 fn set_key_capture_values() {
1652 let mut cfg = Config::default();
1653 cfg.set_key("capture.enabled", "false").unwrap();
1654 cfg.set_key("capture.settle_secs", "30").unwrap();
1655 cfg.set_key("capture.sources", "codex, claude").unwrap();
1656
1657 assert!(!cfg.capture.enabled);
1658 assert_eq!(cfg.capture.settle_secs, 30);
1659 assert_eq!(
1660 cfg.capture.sources.as_deref(),
1661 Some(
1662 [
1663 crate::transcript::Source::Codex,
1664 crate::transcript::Source::ClaudeCode
1665 ]
1666 .as_slice()
1667 )
1668 );
1669
1670 cfg.set_key("capture.sources", "").unwrap();
1672 assert!(cfg.capture.sources.is_none());
1673 assert!(cfg.set_key("capture.sources", "cursor").is_err());
1674 assert!(cfg.set_key("capture.enabled", "maybe").is_err());
1675 }
1676
1677 #[test]
1678 fn graph_scoring_defaults_match_legacy_hardcodes() {
1679 let scoring = GraphScoringConfig::default();
1680 assert!((scoring.weight_semantic - 0.45).abs() < f64::EPSILON);
1681 assert!((scoring.weight_hotness - 0.30).abs() < f64::EPSILON);
1682 assert!((scoring.weight_utility - 0.25).abs() < f64::EPSILON);
1683 assert!((scoring.corroboration_boost - 0.05).abs() < f64::EPSILON);
1684 }
1685
1686 #[test]
1687 fn graph_scoring_partial_toml_fills_defaults() {
1688 let scoring: GraphScoringConfig =
1689 toml::from_str("weight_utility = 0.5\n").expect("parse partial scoring");
1690 assert!((scoring.weight_semantic - 0.45).abs() < f64::EPSILON);
1691 assert!((scoring.weight_hotness - 0.30).abs() < f64::EPSILON);
1692 assert!((scoring.weight_utility - 0.5).abs() < f64::EPSILON);
1693 assert!((scoring.corroboration_boost - 0.05).abs() < f64::EPSILON);
1694 }
1695
1696 #[test]
1697 fn graph_scoring_corroboration_boost_is_configurable() {
1698 let scoring: GraphScoringConfig =
1699 toml::from_str("corroboration_boost = 0.0\n").expect("parse corroboration boost");
1700 assert!(scoring.corroboration_boost.abs() < f64::EPSILON);
1701 assert!((scoring.weight_semantic - 0.45).abs() < f64::EPSILON);
1702 }
1703
1704 #[test]
1705 fn graph_scoring_empty_section_yields_defaults() {
1706 let section: GraphSection = toml::from_str("").expect("parse empty graph section");
1707 let defaults = GraphScoringConfig::default();
1708 assert!((section.scoring.weight_semantic - defaults.weight_semantic).abs() < f64::EPSILON);
1709 assert!((section.scoring.weight_hotness - defaults.weight_hotness).abs() < f64::EPSILON);
1710 assert!((section.scoring.weight_utility - defaults.weight_utility).abs() < f64::EPSILON);
1711 }
1712
1713 #[test]
1714 fn graph_provenance_defaults_when_section_absent() {
1715 let section: GraphSection = toml::from_str("mode = \"embedded\"\n").expect("parse section");
1716 let defaults = ProvenanceWeights::default();
1717 assert_eq!(section.provenance, defaults);
1718 assert!((defaults.weight_external - 1.0).abs() < f64::EPSILON);
1719 assert!((defaults.weight_user - 0.8).abs() < f64::EPSILON);
1720 assert!((defaults.weight_self - 0.05).abs() < f64::EPSILON);
1721 }
1722
1723 #[test]
1724 fn graph_provenance_partial_toml_fills_defaults() {
1725 let cfg: Config =
1726 toml::from_str("[graph]\n\n[graph.provenance]\nweight_self = 0.5\n").expect("parse");
1727 let provenance = cfg.graph.expect("graph section present").provenance;
1728 assert!((provenance.weight_self - 0.5).abs() < f64::EPSILON);
1729 assert!((provenance.weight_external - 1.0).abs() < f64::EPSILON);
1730 assert!((provenance.weight_user - 0.8).abs() < f64::EPSILON);
1731 }
1732
1733 #[test]
1734 fn set_key_provenance_weights() {
1735 let mut cfg = Config::default();
1736 cfg.set_key("graph.provenance.weight_self", "0.2").unwrap();
1737 cfg.set_key("graph.provenance.weight_user", "0").unwrap();
1738 cfg.set_key("graph.provenance.weight_external", "1.5")
1739 .unwrap();
1740
1741 let provenance = cfg
1742 .graph
1743 .as_ref()
1744 .expect("graph section created")
1745 .provenance;
1746 assert!((provenance.weight_self - 0.2).abs() < f64::EPSILON);
1747 assert!(provenance.weight_user.abs() < f64::EPSILON);
1748 assert!((provenance.weight_external - 1.5).abs() < f64::EPSILON);
1749
1750 assert!(cfg.set_key("graph.provenance.weight_self", "-1").is_err());
1751 assert!(cfg.set_key("graph.provenance.weight_self", "lots").is_err());
1752 }
1753
1754 #[test]
1755 fn provenance_weights_round_trip_through_toml() {
1756 let mut cfg = Config::default();
1757 cfg.set_key("graph.provenance.weight_self", "0.05").unwrap();
1758 let rendered = toml::to_string_pretty(&cfg).expect("render");
1759 let parsed: Config = toml::from_str(&rendered).expect("reparse");
1760 assert_eq!(
1761 parsed.graph.expect("graph section survives").provenance,
1762 ProvenanceWeights::default()
1763 );
1764 }
1765
1766 #[test]
1767 fn dedup_defaults_leave_a_gap_between_the_bands() {
1768 let dedup = GraphDedupConfig::default();
1769 assert!((dedup.certain_similarity - 0.92).abs() < f64::EPSILON);
1770 assert!((dedup.review_similarity - 0.82).abs() < f64::EPSILON);
1771 assert_eq!(dedup.max_candidates, 3);
1772 assert!(dedup.review_similarity < dedup.certain_similarity);
1773 }
1774
1775 #[test]
1776 fn dedup_bands_are_cut_at_the_thresholds() {
1777 let dedup = GraphDedupConfig::default();
1778 assert_eq!(dedup.band(0.99), DedupBand::SameEntity);
1779 assert_eq!(dedup.band(0.92), DedupBand::SameEntity);
1780 assert_eq!(dedup.band(0.9), DedupBand::Ambiguous);
1781 assert_eq!(dedup.band(0.82), DedupBand::Ambiguous);
1782 assert_eq!(dedup.band(0.8), DedupBand::NewEntity);
1783 assert_eq!(dedup.band(0.0), DedupBand::NewEntity);
1784 }
1785
1786 #[test]
1789 fn dedup_candidate_limit_never_falls_below_one() {
1790 let dedup = GraphDedupConfig {
1791 max_candidates: 0,
1792 ..GraphDedupConfig::default()
1793 };
1794 assert_eq!(dedup.candidate_limit(), 1);
1795 }
1796
1797 #[test]
1798 fn dedup_partial_toml_fills_defaults() {
1799 let cfg: Config = toml::from_str("[graph]\n\n[graph.dedup]\ncertain_similarity = 0.95\n")
1800 .expect("parse dedup section");
1801 let dedup = cfg.graph.expect("graph section present").dedup;
1802 assert!((dedup.certain_similarity - 0.95).abs() < f64::EPSILON);
1803 assert!((dedup.review_similarity - 0.82).abs() < f64::EPSILON);
1804 assert_eq!(dedup.max_candidates, 3);
1805 }
1806
1807 #[test]
1808 fn set_key_dedup_thresholds() {
1809 let mut cfg = Config::default();
1810 cfg.set_key("graph.dedup.certain_similarity", "0.9")
1811 .unwrap();
1812 cfg.set_key("graph.dedup.review_similarity", "0.6").unwrap();
1813 cfg.set_key("graph.dedup.max_candidates", "5").unwrap();
1814
1815 let dedup = &cfg.graph.as_ref().expect("graph section created").dedup;
1816 assert!((dedup.certain_similarity - 0.9).abs() < f64::EPSILON);
1817 assert!((dedup.review_similarity - 0.6).abs() < f64::EPSILON);
1818 assert_eq!(dedup.max_candidates, 5);
1819
1820 assert!(cfg
1821 .set_key("graph.dedup.certain_similarity", "1.5")
1822 .is_err());
1823 assert!(cfg
1824 .set_key("graph.dedup.review_similarity", "-0.1")
1825 .is_err());
1826 assert!(cfg.set_key("graph.dedup.max_candidates", "0").is_err());
1827 }
1828
1829 #[test]
1830 fn graph_scoring_nested_under_graph() {
1831 let cfg: Config = toml::from_str(
1832 "[graph]\nmode = \"embedded\"\n\n[graph.scoring]\nweight_utility = 0.5\n",
1833 )
1834 .expect("parse nested scoring");
1835 let scoring = cfg.graph.expect("graph section present").scoring;
1836 assert!((scoring.weight_semantic - 0.45).abs() < f64::EPSILON);
1837 assert!((scoring.weight_hotness - 0.30).abs() < f64::EPSILON);
1838 assert!((scoring.weight_utility - 0.5).abs() < f64::EPSILON);
1839 }
1840}