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")]
432 pub extra_args: Option<Vec<String>>,
433 #[serde(default, skip_serializing_if = "Option::is_none")]
435 pub timeout_secs: Option<u64>,
436}
437
438impl CliSection {
439 #[must_use]
442 pub fn is_empty(&self) -> bool {
443 *self == Self::default()
444 }
445
446 pub fn set_key(&mut self, key: &str, value: &str) -> Result<(), crate::error::RecallError> {
448 use crate::error::RecallError;
449 match key {
450 "preset" => self.preset = Some(CliPreset::from_str_loose(value)?),
451 "command" => self.command = Some(value.to_string()),
452 "args" => self.args = Some(split_args(value)),
453 "prompt_delivery" => {
454 self.prompt_delivery = Some(PromptDelivery::from_str_loose(value)?)
455 }
456 "prompt_flag" => self.prompt_flag = Some(value.to_string()),
457 "model_flag" => self.model_flag = Some(value.to_string()),
458 "output_format_flag" => self.output_format_flag = Some(value.to_string()),
459 "output_format_value" => self.output_format_value = Some(value.to_string()),
460 "output_mode" => self.output_mode = Some(OutputMode::from_str_loose(value)?),
461 "ndjson_match" => self.ndjson_match = Some(LineMatchers::parse(value)),
462 "system_prompt_flag" => self.system_prompt_flag = Some(value.to_string()),
463 "result_json_path" => self.result_json_path = Some(JsonPaths::parse(value)),
464 "extra_args" => self.extra_args = Some(split_args(value)),
465 "timeout_secs" => {
466 self.timeout_secs = Some(
467 value
468 .parse()
469 .map_err(|_| RecallError::Config(format!("invalid number: {value}")))?,
470 );
471 }
472 other => {
473 return Err(RecallError::Config(format!(
474 "unknown config key: llm.cli.{other}"
475 )))
476 }
477 }
478 Ok(())
479 }
480}
481
482fn split_args(value: &str) -> Vec<String> {
484 value.split_whitespace().map(str::to_string).collect()
485}
486
487#[derive(Debug, Default, Serialize, Deserialize)]
490pub struct Config {
491 #[serde(default)]
492 pub ephemeral: EphemeralConfig,
493 #[serde(default)]
494 pub llm: LlmSection,
495 #[serde(default)]
496 pub pipeline: Option<PipelineSection>,
497 #[serde(default)]
498 pub graph: Option<GraphSection>,
499 #[serde(default)]
500 pub serve: ServeSection,
501 #[serde(default)]
502 pub extraction: ExtractionSection,
503 #[serde(default)]
504 pub capture: CaptureSection,
505}
506
507#[derive(Debug, Serialize, Deserialize)]
508pub struct EphemeralConfig {
509 #[serde(default = "default_max_entries")]
510 pub max_entries: usize,
511}
512
513impl Default for EphemeralConfig {
514 fn default() -> Self {
515 Self {
516 max_entries: DEFAULT_MAX_ENTRIES,
517 }
518 }
519}
520
521fn default_max_entries() -> usize {
522 DEFAULT_MAX_ENTRIES
523}
524
525#[derive(Debug, Serialize, Deserialize)]
526pub struct LlmSection {
527 #[serde(default = "default_provider")]
528 pub provider: Provider,
529 #[serde(default)]
530 pub model: String,
531 #[serde(default)]
532 pub api_base: String,
533 #[serde(default, skip_serializing_if = "CliSection::is_empty")]
536 pub cli: CliSection,
537}
538
539impl Default for LlmSection {
540 fn default() -> Self {
541 Self {
542 provider: Provider::Anthropic,
543 model: String::new(),
544 api_base: String::new(),
545 cli: CliSection::default(),
546 }
547 }
548}
549
550impl LlmSection {
551 #[must_use]
553 pub fn resolved_model(&self) -> &str {
554 if self.model.is_empty() {
555 self.provider.default_model()
556 } else {
557 &self.model
558 }
559 }
560
561 #[must_use]
563 pub fn resolved_api_base(&self) -> &str {
564 if self.api_base.is_empty() {
565 self.provider.default_api_base()
566 } else {
567 &self.api_base
568 }
569 }
570}
571
572#[derive(Debug, Clone, Serialize, Deserialize)]
573pub struct PipelineSection {
574 #[serde(default)]
576 pub docs_dir: Option<String>,
577 #[serde(default)]
579 pub auto_sync: Option<bool>,
580}
581
582#[derive(Debug, Clone, Serialize, Deserialize)]
583pub struct GraphSection {
584 #[serde(default = "default_graph_mode")]
586 pub mode: String,
587 #[serde(default = "default_graph_url")]
589 pub url: String,
590 #[serde(default = "default_graph_namespace")]
592 pub namespace: String,
593 #[serde(default)]
595 pub database: String,
596 #[serde(default)]
598 pub username: String,
599 #[serde(default)]
601 pub password_file: String,
602 #[serde(default)]
608 pub scoring: GraphScoringConfig,
609 #[serde(default)]
615 pub provenance: ProvenanceWeights,
616 #[serde(default)]
621 pub dedup: GraphDedupConfig,
622}
623
624impl Default for GraphSection {
625 fn default() -> Self {
626 Self {
627 mode: default_graph_mode(),
628 url: default_graph_url(),
629 namespace: default_graph_namespace(),
630 database: String::new(),
631 username: String::new(),
632 password_file: String::new(),
633 scoring: GraphScoringConfig::default(),
634 provenance: ProvenanceWeights::default(),
635 dedup: GraphDedupConfig::default(),
636 }
637 }
638}
639
640#[derive(Debug, Clone, Serialize, Deserialize)]
646#[serde(default)]
647pub struct ServeSection {
648 pub socket_path: Option<String>,
651 pub idle_timeout_secs: u64,
654}
655
656impl Default for ServeSection {
657 fn default() -> Self {
658 Self {
659 socket_path: None,
660 idle_timeout_secs: DEFAULT_IDLE_TIMEOUT_SECS,
661 }
662 }
663}
664
665#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
682#[serde(default)]
683pub struct ExtractionSection {
684 pub background_enabled: bool,
687 pub idle_after_secs: u64,
690 pub batch_size: usize,
694}
695
696impl Default for ExtractionSection {
697 fn default() -> Self {
698 Self {
699 background_enabled: true,
700 idle_after_secs: DEFAULT_EXTRACTION_IDLE_AFTER_SECS,
701 batch_size: DEFAULT_EXTRACTION_BATCH_SIZE,
702 }
703 }
704}
705
706impl ExtractionSection {
707 #[must_use]
709 pub fn idle_after(&self) -> std::time::Duration {
710 std::time::Duration::from_secs(self.idle_after_secs)
711 }
712
713 #[must_use]
716 pub fn effective_batch_size(&self) -> usize {
717 self.batch_size.max(1)
718 }
719}
720
721#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
741#[serde(default)]
742pub struct CaptureSection {
743 pub enabled: bool,
745 #[serde(skip_serializing_if = "Option::is_none")]
748 pub sources: Option<Vec<crate::transcript::Source>>,
749 pub settle_secs: u64,
753}
754
755impl Default for CaptureSection {
756 fn default() -> Self {
757 Self {
758 enabled: true,
759 sources: None,
760 settle_secs: DEFAULT_CAPTURE_SETTLE_SECS,
761 }
762 }
763}
764
765impl CaptureSection {
766 #[must_use]
768 pub fn settle(&self) -> std::time::Duration {
769 std::time::Duration::from_secs(self.settle_secs)
770 }
771}
772
773#[derive(Debug, Clone, Serialize, Deserialize)]
798#[serde(default)]
799pub struct GraphScoringConfig {
800 pub weight_semantic: f64,
802 pub weight_hotness: f64,
804 pub weight_utility: f64,
806 pub corroboration_boost: f64,
833}
834
835impl Default for GraphScoringConfig {
836 fn default() -> Self {
837 Self {
838 weight_semantic: 0.45,
839 weight_hotness: 0.30,
840 weight_utility: 0.25,
841 corroboration_boost: 0.05,
842 }
843 }
844}
845
846#[derive(Debug, Clone, Serialize, Deserialize)]
871#[serde(default)]
872pub struct GraphDedupConfig {
873 pub certain_similarity: f64,
876 pub review_similarity: f64,
879 pub max_candidates: usize,
883}
884
885impl Default for GraphDedupConfig {
886 fn default() -> Self {
887 Self {
888 certain_similarity: 0.92,
889 review_similarity: 0.82,
890 max_candidates: 3,
891 }
892 }
893}
894
895impl GraphDedupConfig {
896 #[must_use]
898 pub fn band(&self, similarity: f64) -> DedupBand {
899 if similarity >= self.certain_similarity {
900 DedupBand::SameEntity
901 } else if similarity >= self.review_similarity {
902 DedupBand::Ambiguous
903 } else {
904 DedupBand::NewEntity
905 }
906 }
907
908 #[must_use]
911 pub fn candidate_limit(&self) -> usize {
912 self.max_candidates.max(1)
913 }
914}
915
916#[derive(Debug, Clone, Copy, PartialEq, Eq)]
918pub enum DedupBand {
919 SameEntity,
921 Ambiguous,
923 NewEntity,
925}
926
927fn default_graph_mode() -> String {
928 "embedded".to_string()
929}
930
931fn default_graph_url() -> String {
932 "ws://localhost:8787".to_string()
933}
934
935fn default_graph_namespace() -> String {
936 "nullarc".to_string()
937}
938
939fn default_provider() -> Provider {
940 Provider::Anthropic
941}
942
943#[must_use]
947pub fn config_path(base: &Path) -> std::path::PathBuf {
948 base.join(CONFIG_FILE)
949}
950
951#[must_use]
954pub fn load_from_dir(dir: &Path) -> Config {
955 load(dir)
956}
957
958#[must_use]
961pub fn load(base: &Path) -> Config {
962 let path = config_path(base);
963 if !path.exists() {
964 return Config::default();
965 }
966
967 let content = match fs::read_to_string(&path) {
968 Ok(c) => c,
969 Err(_) => return Config::default(),
970 };
971
972 match toml::from_str(&content) {
973 Ok(cfg) => validate(cfg),
974 Err(_) => Config::default(),
975 }
976}
977
978pub fn save(base: &Path, config: &Config) -> Result<(), crate::error::RecallError> {
980 let path = config_path(base);
981 let content = toml::to_string_pretty(config)?;
982 fs::write(&path, content)?;
983 Ok(())
984}
985
986#[must_use]
988pub fn exists(base: &Path) -> bool {
989 config_path(base).exists()
990}
991
992fn validate(mut cfg: Config) -> Config {
993 if !(1..=50).contains(&cfg.ephemeral.max_entries) {
994 cfg.ephemeral.max_entries = DEFAULT_MAX_ENTRIES;
995 }
996 cfg
997}
998
999impl Config {
1002 pub fn set_key(&mut self, key: &str, value: &str) -> Result<(), crate::error::RecallError> {
1004 use crate::error::RecallError;
1005 match key {
1006 "llm.provider" | "provider" => {
1007 let provider = Provider::from_str_loose(value)?;
1008 self.llm.model = String::new();
1011 self.llm.api_base = String::new();
1012 self.llm.cli = CliSection::default();
1013 self.llm.provider = provider;
1014 Ok(())
1015 }
1016 _ if key.starts_with("llm.cli.") => {
1017 self.llm.cli.set_key(&key["llm.cli.".len()..], value)
1018 }
1019 "llm.model" | "model" => {
1020 self.llm.model = value.to_string();
1021 Ok(())
1022 }
1023 "llm.api_base" | "api_base" => {
1024 self.llm.api_base = value.to_string();
1025 Ok(())
1026 }
1027 "ephemeral.max_entries" => {
1028 let n: usize = value
1029 .parse()
1030 .map_err(|_| RecallError::Config(format!("invalid number: {value}")))?;
1031 if !(1..=50).contains(&n) {
1032 return Err(RecallError::Config(
1033 "max_entries must be between 1 and 50".into(),
1034 ));
1035 }
1036 self.ephemeral.max_entries = n;
1037 Ok(())
1038 }
1039 "pipeline.docs_dir" => {
1040 let section = self.pipeline.get_or_insert(PipelineSection {
1041 docs_dir: None,
1042 auto_sync: None,
1043 });
1044 section.docs_dir = Some(value.to_string());
1045 Ok(())
1046 }
1047 "pipeline.auto_sync" => {
1048 let b: bool = value
1049 .parse()
1050 .map_err(|_| RecallError::Config(format!("invalid boolean: {value}")))?;
1051 let section = self.pipeline.get_or_insert(PipelineSection {
1052 docs_dir: None,
1053 auto_sync: None,
1054 });
1055 section.auto_sync = Some(b);
1056 Ok(())
1057 }
1058 "serve.idle_timeout_secs" => {
1059 let secs: u64 = value
1060 .parse()
1061 .map_err(|_| RecallError::Config(format!("invalid number: {value}")))?;
1062 self.serve.idle_timeout_secs = secs;
1063 Ok(())
1064 }
1065 "serve.socket_path" => {
1066 self.serve.socket_path = if value.trim().is_empty() {
1067 None
1068 } else {
1069 Some(value.to_string())
1070 };
1071 Ok(())
1072 }
1073 "extraction.background_enabled" => {
1074 self.extraction.background_enabled = value
1075 .parse()
1076 .map_err(|_| RecallError::Config(format!("invalid boolean: {value}")))?;
1077 Ok(())
1078 }
1079 "extraction.idle_after_secs" => {
1080 self.extraction.idle_after_secs = value
1081 .parse()
1082 .map_err(|_| RecallError::Config(format!("invalid number: {value}")))?;
1083 Ok(())
1084 }
1085 "extraction.batch_size" => {
1086 let size: usize = value
1087 .parse()
1088 .map_err(|_| RecallError::Config(format!("invalid number: {value}")))?;
1089 if size == 0 {
1090 return Err(RecallError::Config("batch_size must be at least 1".into()));
1091 }
1092 self.extraction.batch_size = size;
1093 Ok(())
1094 }
1095 "capture.enabled" => {
1096 self.capture.enabled = value
1097 .parse()
1098 .map_err(|_| RecallError::Config(format!("invalid boolean: {value}")))?;
1099 Ok(())
1100 }
1101 "capture.settle_secs" => {
1102 self.capture.settle_secs = value
1103 .parse()
1104 .map_err(|_| RecallError::Config(format!("invalid number: {value}")))?;
1105 Ok(())
1106 }
1107 "capture.sources" => {
1108 self.capture.sources = parse_sources(value)?;
1109 Ok(())
1110 }
1111 "graph.provenance.weight_external" => {
1112 self.graph_section().provenance.weight_external = parse_weight(value)?;
1113 Ok(())
1114 }
1115 "graph.provenance.weight_user" => {
1116 self.graph_section().provenance.weight_user = parse_weight(value)?;
1117 Ok(())
1118 }
1119 "graph.provenance.weight_self" => {
1120 self.graph_section().provenance.weight_self = parse_weight(value)?;
1121 Ok(())
1122 }
1123 "graph.dedup.certain_similarity" => {
1124 self.graph_section().dedup.certain_similarity = parse_similarity(value)?;
1125 Ok(())
1126 }
1127 "graph.dedup.review_similarity" => {
1128 self.graph_section().dedup.review_similarity = parse_similarity(value)?;
1129 Ok(())
1130 }
1131 "graph.dedup.max_candidates" => {
1132 let n: usize = value
1133 .parse()
1134 .map_err(|_| RecallError::Config(format!("invalid number: {value}")))?;
1135 if n == 0 {
1136 return Err(RecallError::Config(
1137 "max_candidates must be at least 1".into(),
1138 ));
1139 }
1140 self.graph_section().dedup.max_candidates = n;
1141 Ok(())
1142 }
1143 other => Err(RecallError::Config(format!("unknown config key: {other}"))),
1144 }
1145 }
1146
1147 fn graph_section(&mut self) -> &mut GraphSection {
1149 self.graph.get_or_insert_with(GraphSection::default)
1150 }
1151}
1152
1153fn parse_sources(
1155 value: &str,
1156) -> Result<Option<Vec<crate::transcript::Source>>, crate::error::RecallError> {
1157 let names: Vec<&str> = value
1158 .split(',')
1159 .map(str::trim)
1160 .filter(|name| !name.is_empty())
1161 .collect();
1162 if names.is_empty() {
1163 return Ok(None);
1164 }
1165 let mut sources = Vec::with_capacity(names.len());
1166 for name in names {
1167 let source = crate::transcript::Source::from_str_loose(name)?;
1168 if !sources.contains(&source) {
1169 sources.push(source);
1170 }
1171 }
1172 Ok(Some(sources))
1173}
1174
1175fn parse_weight(value: &str) -> Result<f64, crate::error::RecallError> {
1179 use crate::error::RecallError;
1180 let weight: f64 = value
1181 .parse()
1182 .map_err(|_| RecallError::Config(format!("invalid number: {value}")))?;
1183 if !weight.is_finite() || weight < 0.0 {
1184 return Err(RecallError::Config(format!(
1185 "evidence weight must be finite and non-negative, got {value}"
1186 )));
1187 }
1188 Ok(weight)
1189}
1190
1191fn parse_similarity(value: &str) -> Result<f64, crate::error::RecallError> {
1193 use crate::error::RecallError;
1194 let similarity: f64 = value
1195 .parse()
1196 .map_err(|_| RecallError::Config(format!("invalid number: {value}")))?;
1197 if !similarity.is_finite() || !(0.0..=1.0).contains(&similarity) {
1198 return Err(RecallError::Config(format!(
1199 "similarity threshold must be between 0.0 and 1.0, got {value}"
1200 )));
1201 }
1202 Ok(similarity)
1203}
1204
1205#[cfg(test)]
1206mod tests {
1207 use super::*;
1208
1209 #[test]
1210 fn default_config() {
1211 let cfg = Config::default();
1212 assert_eq!(cfg.ephemeral.max_entries, 5);
1213 assert_eq!(cfg.llm.provider, Provider::Anthropic);
1214 assert!(cfg.llm.model.is_empty());
1215 }
1216
1217 #[test]
1218 fn parse_ephemeral_only() {
1219 let cfg: Config = toml::from_str("[ephemeral]\nmax_entries = 10\n").unwrap();
1220 assert_eq!(cfg.ephemeral.max_entries, 10);
1221 assert_eq!(cfg.llm.provider, Provider::Anthropic);
1222 }
1223
1224 #[test]
1225 fn graph_mode_defaults_to_embedded() {
1226 let cfg: Config = toml::from_str("[graph]\n").unwrap();
1227 assert_eq!(cfg.graph.unwrap().mode, "embedded");
1228 }
1229
1230 #[test]
1231 fn graph_mode_parses_server() {
1232 let cfg: Config =
1233 toml::from_str("[graph]\nmode = \"server\"\nurl = \"ws://db.local:8787\"\n").unwrap();
1234 let g = cfg.graph.unwrap();
1235 assert_eq!(g.mode, "server");
1236 assert_eq!(g.url, "ws://db.local:8787");
1237 }
1238
1239 #[test]
1240 fn serve_defaults_when_section_absent() {
1241 let cfg: Config = toml::from_str("[ephemeral]\nmax_entries = 3\n").unwrap();
1242 assert_eq!(cfg.serve.idle_timeout_secs, DEFAULT_IDLE_TIMEOUT_SECS);
1243 assert!(cfg.serve.socket_path.is_none());
1244 }
1245
1246 #[test]
1247 fn serve_section_parses_overrides() {
1248 let cfg: Config = toml::from_str(
1249 "[serve]\nsocket_path = \"/run/re/graph.sock\"\nidle_timeout_secs = 60\n",
1250 )
1251 .unwrap();
1252 assert_eq!(cfg.serve.idle_timeout_secs, 60);
1253 assert_eq!(cfg.serve.socket_path.as_deref(), Some("/run/re/graph.sock"));
1254 }
1255
1256 #[test]
1257 fn set_key_serve_idle_timeout() {
1258 let mut cfg = Config::default();
1259 cfg.set_key("serve.idle_timeout_secs", "120").unwrap();
1260 assert_eq!(cfg.serve.idle_timeout_secs, 120);
1261 assert!(cfg.set_key("serve.idle_timeout_secs", "soon").is_err());
1262 }
1263
1264 #[test]
1265 fn parse_llm_section() {
1266 let cfg: Config = toml::from_str(
1267 "[llm]\nprovider = \"openai\"\nmodel = \"llama3.1\"\napi_base = \"http://myhost:11434/v1\"\n",
1268 )
1269 .unwrap();
1270 assert_eq!(cfg.llm.provider, Provider::Openai);
1271 assert_eq!(cfg.llm.model, "llama3.1");
1272 assert_eq!(cfg.llm.api_base, "http://myhost:11434/v1");
1273 }
1274
1275 #[test]
1276 fn parse_claude_code_provider() {
1277 let cfg: Config = toml::from_str("[llm]\nprovider = \"claude-code\"\n").unwrap();
1278 assert_eq!(cfg.llm.provider, Provider::ClaudeCode);
1279 }
1280
1281 #[test]
1282 fn resolved_defaults() {
1283 let llm = LlmSection::default();
1284 assert_eq!(llm.resolved_model(), "claude-haiku-4-5-20251001");
1285 assert_eq!(
1286 llm.resolved_api_base(),
1287 "https://api.anthropic.com/v1/messages"
1288 );
1289 }
1290
1291 #[test]
1292 fn resolved_custom_overrides_default() {
1293 let llm = LlmSection {
1294 provider: Provider::Openai,
1295 model: "mistral-7b".into(),
1296 ..LlmSection::default()
1297 };
1298 assert_eq!(llm.resolved_model(), "mistral-7b");
1299 assert_eq!(llm.resolved_api_base(), "http://localhost:11434/v1");
1300 }
1301
1302 #[test]
1303 fn round_trip_toml() {
1304 let cfg = Config {
1305 ephemeral: EphemeralConfig { max_entries: 3 },
1306 llm: LlmSection {
1307 provider: Provider::Openai,
1308 model: "llama3.2".into(),
1309 api_base: "http://localhost:11434/v1".into(),
1310 ..LlmSection::default()
1311 },
1312 ..Config::default()
1313 };
1314 let s = toml::to_string_pretty(&cfg).unwrap();
1315 let parsed: Config = toml::from_str(&s).unwrap();
1316 assert_eq!(parsed.ephemeral.max_entries, 3);
1317 assert_eq!(parsed.llm.provider, Provider::Openai);
1318 assert_eq!(parsed.llm.model, "llama3.2");
1319 }
1320
1321 #[test]
1322 fn set_key_provider() {
1323 let mut cfg = Config::default();
1324 cfg.set_key("llm.provider", "ollama").unwrap();
1325 assert_eq!(cfg.llm.provider, Provider::Openai);
1326 assert!(cfg.llm.model.is_empty());
1327 }
1328
1329 #[test]
1330 fn set_key_model() {
1331 let mut cfg = Config::default();
1332 cfg.set_key("llm.model", "claude-sonnet-4-6").unwrap();
1333 assert_eq!(cfg.llm.model, "claude-sonnet-4-6");
1334 }
1335
1336 #[test]
1337 fn set_key_unknown_fails() {
1338 let mut cfg = Config::default();
1339 assert!(cfg.set_key("nonexistent.key", "value").is_err());
1340 }
1341
1342 #[test]
1343 fn set_key_cli_overrides() {
1344 let mut cfg = Config::default();
1345 cfg.set_key("llm.provider", "gemini").unwrap();
1346 cfg.set_key("llm.cli.command", "/opt/bin/gemini").unwrap();
1347 cfg.set_key("llm.cli.result_json_path", "response, result")
1348 .unwrap();
1349 cfg.set_key("llm.cli.extra_args", "--yolo --quiet").unwrap();
1350 cfg.set_key("llm.cli.prompt_delivery", "stdin").unwrap();
1351 cfg.set_key("llm.cli.timeout_secs", "45").unwrap();
1352
1353 let cli = &cfg.llm.cli;
1354 assert_eq!(cli.command.as_deref(), Some("/opt/bin/gemini"));
1355 assert_eq!(
1356 cli.result_json_path.as_ref().unwrap().paths(),
1357 ["response", "result"]
1358 );
1359 assert_eq!(
1360 cli.extra_args.as_deref(),
1361 Some(["--yolo".to_string(), "--quiet".to_string()].as_slice())
1362 );
1363 assert_eq!(cli.prompt_delivery, Some(PromptDelivery::Stdin));
1364 assert_eq!(cli.timeout_secs, Some(45));
1365
1366 assert!(cfg.set_key("llm.cli.nonexistent", "x").is_err());
1367 assert!(cfg.set_key("llm.cli.timeout_secs", "soon").is_err());
1368 assert!(cfg.set_key("llm.cli.preset", "nonesuch").is_err());
1369 }
1370
1371 #[test]
1374 fn switching_provider_clears_the_cli_overrides() {
1375 let mut cfg = Config::default();
1376 cfg.set_key("llm.provider", "gemini").unwrap();
1377 cfg.set_key("llm.cli.command", "/opt/bin/gemini").unwrap();
1378 cfg.set_key("llm.provider", "grok").unwrap();
1379
1380 assert_eq!(cfg.llm.provider, Provider::Grok);
1381 assert!(cfg.llm.cli.is_empty());
1382 }
1383
1384 #[test]
1385 fn cli_section_parses_from_toml() {
1386 let cfg: Config = toml::from_str(
1387 "[llm]\nprovider = \"cli\"\n\n[llm.cli]\ncommand = \"mycli\"\n\
1388 prompt_delivery = \"flag\"\nprompt_flag = \"--ask\"\n\
1389 result_json_path = [\"data.text\", \"text\"]\nargs = [\"chat\"]\n",
1390 )
1391 .expect("parse [llm.cli]");
1392 let cli = cfg.llm.cli;
1393 assert_eq!(cfg.llm.provider, Provider::Cli);
1394 assert_eq!(cli.command.as_deref(), Some("mycli"));
1395 assert_eq!(cli.prompt_delivery, Some(PromptDelivery::Flag));
1396 assert_eq!(cli.prompt_flag.as_deref(), Some("--ask"));
1397 assert_eq!(
1398 cli.result_json_path.as_ref().unwrap().paths(),
1399 ["data.text", "text"]
1400 );
1401 assert_eq!(cli.args.as_deref(), Some(["chat".to_string()].as_slice()));
1402 }
1403
1404 #[test]
1405 fn set_key_output_mode_and_ndjson_match() {
1406 let mut cfg = Config::default();
1407 cfg.set_key("llm.provider", "cli").unwrap();
1408 cfg.set_key("llm.cli.output_mode", "ndjson").unwrap();
1409 cfg.set_key(
1410 "llm.cli.ndjson_match",
1411 "type=item.completed, item.type=agent_message",
1412 )
1413 .unwrap();
1414
1415 assert_eq!(cfg.llm.cli.output_mode, Some(OutputMode::Ndjson));
1416 assert_eq!(
1417 cfg.llm.cli.ndjson_match.as_ref().unwrap().predicates(),
1418 [("type", "item.completed"), ("item.type", "agent_message")]
1419 );
1420 assert!(cfg.set_key("llm.cli.output_mode", "yaml").is_err());
1421 }
1422
1423 #[test]
1424 fn output_mode_accepts_the_obvious_spellings() {
1425 assert_eq!(
1426 OutputMode::from_str_loose("jsonl").unwrap(),
1427 OutputMode::Ndjson
1428 );
1429 assert_eq!(
1430 OutputMode::from_str_loose("json").unwrap(),
1431 OutputMode::SingleJson
1432 );
1433 assert_eq!(OutputMode::from_str_loose("TEXT").unwrap(), OutputMode::Raw);
1434 }
1435
1436 #[test]
1439 fn line_matchers_drop_entries_without_a_value() {
1440 let matchers = LineMatchers::parse("type=item.completed, garbage, ");
1441 assert_eq!(matchers.predicates(), [("type", "item.completed")]);
1442 }
1443
1444 #[test]
1445 fn result_json_path_accepts_a_bare_string() {
1446 let cli: CliSection = toml::from_str("result_json_path = \"result\"\n").expect("parse");
1447 assert_eq!(cli.result_json_path.unwrap().paths(), ["result"]);
1448 }
1449
1450 #[test]
1451 fn an_empty_result_json_path_means_raw_stdout() {
1452 let cli: CliSection = toml::from_str("result_json_path = \"\"\n").expect("parse");
1453 assert!(cli.result_json_path.unwrap().is_empty());
1454 }
1455
1456 #[test]
1459 fn a_config_without_a_cli_section_round_trips_unchanged() {
1460 let tmp = tempfile::tempdir().unwrap();
1461 let mut cfg = Config::default();
1462 cfg.set_key("llm.provider", "claude-code").unwrap();
1463 save(tmp.path(), &cfg).unwrap();
1464
1465 let rendered = fs::read_to_string(config_path(tmp.path())).unwrap();
1466 assert!(!rendered.contains("[llm.cli]"), "{rendered}");
1467 assert_eq!(load(tmp.path()).llm.provider, Provider::ClaudeCode);
1468 }
1469
1470 #[test]
1471 fn cli_overrides_survive_a_save_and_load() {
1472 let tmp = tempfile::tempdir().unwrap();
1473 let mut cfg = Config::default();
1474 cfg.set_key("llm.provider", "cli").unwrap();
1475 cfg.set_key("llm.cli.command", "mycli").unwrap();
1476 cfg.set_key("llm.cli.result_json_path", "data.text")
1477 .unwrap();
1478 save(tmp.path(), &cfg).unwrap();
1479
1480 let loaded = load(tmp.path());
1481 assert_eq!(loaded.llm.provider, Provider::Cli);
1482 assert_eq!(loaded.llm.cli.command.as_deref(), Some("mycli"));
1483 assert_eq!(
1484 loaded.llm.cli.result_json_path.unwrap().paths(),
1485 ["data.text"]
1486 );
1487 }
1488
1489 #[test]
1490 fn cli_providers_are_distinguished_from_http_ones() {
1491 assert!(Provider::ClaudeCode.is_cli());
1492 assert!(Provider::Gemini.is_cli());
1493 assert!(Provider::Grok.is_cli());
1494 assert!(Provider::Codex.is_cli());
1495 assert!(Provider::Cli.is_cli());
1496 assert!(!Provider::Anthropic.is_cli());
1497 assert!(!Provider::Openai.is_cli());
1498 }
1499
1500 #[test]
1501 fn provider_from_str_loose_accepts_the_cli_vendors() {
1502 assert_eq!(
1503 Provider::from_str_loose("gemini").unwrap(),
1504 Provider::Gemini
1505 );
1506 assert_eq!(
1507 Provider::from_str_loose("gemini-cli").unwrap(),
1508 Provider::Gemini
1509 );
1510 assert_eq!(Provider::from_str_loose("Grok").unwrap(), Provider::Grok);
1511 assert_eq!(Provider::from_str_loose("xai").unwrap(), Provider::Grok);
1512 assert_eq!(Provider::from_str_loose("cli").unwrap(), Provider::Cli);
1513 assert_eq!(Provider::from_str_loose("custom").unwrap(), Provider::Cli);
1514 }
1515
1516 #[test]
1517 fn codex_resolves_to_its_own_preset() {
1518 assert_eq!(Provider::from_str_loose("codex").unwrap(), Provider::Codex);
1519 assert_eq!(
1520 Provider::Codex.default_cli_preset(),
1521 Some(CliPreset::Codex),
1522 "codex must not fall through to the custom preset"
1523 );
1524 }
1525
1526 #[test]
1529 fn an_unknown_vendor_is_pointed_at_the_cli_provider() {
1530 let err = Provider::from_str_loose("some-new-agent").expect_err("no such preset");
1531 assert!(err.to_string().contains("[llm.cli]"), "{err}");
1532 }
1533
1534 #[test]
1535 fn provider_display_round_trips_through_from_str_loose() {
1536 for provider in [
1537 Provider::Anthropic,
1538 Provider::Openai,
1539 Provider::ClaudeCode,
1540 Provider::Gemini,
1541 Provider::Grok,
1542 Provider::Codex,
1543 Provider::Cli,
1544 ] {
1545 let rendered = provider.to_string();
1546 assert_eq!(
1547 Provider::from_str_loose(&rendered).unwrap(),
1548 provider,
1549 "{rendered}"
1550 );
1551 }
1552 }
1553
1554 #[test]
1555 fn provider_from_str_loose() {
1556 assert_eq!(
1557 Provider::from_str_loose("ollama").unwrap(),
1558 Provider::Openai
1559 );
1560 assert_eq!(
1561 Provider::from_str_loose("claude").unwrap(),
1562 Provider::Anthropic
1563 );
1564 assert_eq!(
1565 Provider::from_str_loose("claude-code").unwrap(),
1566 Provider::ClaudeCode
1567 );
1568 assert!(Provider::from_str_loose("unknown").is_err());
1569 }
1570
1571 #[test]
1572 fn save_and_load() {
1573 let tmp = tempfile::tempdir().unwrap();
1574 let cfg = Config {
1575 ephemeral: EphemeralConfig { max_entries: 7 },
1576 llm: LlmSection {
1577 provider: Provider::ClaudeCode,
1578 ..LlmSection::default()
1579 },
1580 ..Config::default()
1581 };
1582 save(tmp.path(), &cfg).unwrap();
1583 let loaded = load(tmp.path());
1584 assert_eq!(loaded.ephemeral.max_entries, 7);
1585 assert_eq!(loaded.llm.provider, Provider::ClaudeCode);
1586 }
1587
1588 #[test]
1589 fn load_nonexistent_file() {
1590 let tmp = tempfile::tempdir().unwrap();
1591 let cfg = load(tmp.path());
1592 assert_eq!(cfg.ephemeral.max_entries, 5);
1593 }
1594
1595 #[test]
1596 fn validate_out_of_range() {
1597 let cfg = validate(Config {
1598 ephemeral: EphemeralConfig { max_entries: 100 },
1599 ..Config::default()
1600 });
1601 assert_eq!(cfg.ephemeral.max_entries, 5);
1602 }
1603
1604 #[test]
1605 fn capture_defaults_are_on_and_auto_detecting() {
1606 let capture = CaptureSection::default();
1607 assert!(capture.enabled);
1608 assert!(capture.sources.is_none());
1609 assert_eq!(capture.settle(), std::time::Duration::from_secs(300));
1610 }
1611
1612 #[test]
1615 fn a_config_without_a_capture_section_still_loads() {
1616 let cfg: Config = toml::from_str("[ephemeral]\nmax_entries = 3\n").unwrap();
1617 assert!(cfg.capture.enabled);
1618 assert!(cfg.capture.sources.is_none());
1619 }
1620
1621 #[test]
1622 fn capture_section_parses_an_explicit_source_list() {
1623 let cfg: Config = toml::from_str(
1624 "[capture]\nenabled = false\nsources = [\"codex\", \"grok\"]\nsettle_secs = 60\n",
1625 )
1626 .expect("parse [capture]");
1627 assert!(!cfg.capture.enabled);
1628 assert_eq!(cfg.capture.settle_secs, 60);
1629 assert_eq!(
1630 cfg.capture.sources.as_deref(),
1631 Some(
1632 [
1633 crate::transcript::Source::Codex,
1634 crate::transcript::Source::Grok
1635 ]
1636 .as_slice()
1637 )
1638 );
1639 }
1640
1641 #[test]
1642 fn set_key_capture_values() {
1643 let mut cfg = Config::default();
1644 cfg.set_key("capture.enabled", "false").unwrap();
1645 cfg.set_key("capture.settle_secs", "30").unwrap();
1646 cfg.set_key("capture.sources", "codex, claude").unwrap();
1647
1648 assert!(!cfg.capture.enabled);
1649 assert_eq!(cfg.capture.settle_secs, 30);
1650 assert_eq!(
1651 cfg.capture.sources.as_deref(),
1652 Some(
1653 [
1654 crate::transcript::Source::Codex,
1655 crate::transcript::Source::ClaudeCode
1656 ]
1657 .as_slice()
1658 )
1659 );
1660
1661 cfg.set_key("capture.sources", "").unwrap();
1663 assert!(cfg.capture.sources.is_none());
1664 assert!(cfg.set_key("capture.sources", "cursor").is_err());
1665 assert!(cfg.set_key("capture.enabled", "maybe").is_err());
1666 }
1667
1668 #[test]
1669 fn graph_scoring_defaults_match_legacy_hardcodes() {
1670 let scoring = GraphScoringConfig::default();
1671 assert!((scoring.weight_semantic - 0.45).abs() < f64::EPSILON);
1672 assert!((scoring.weight_hotness - 0.30).abs() < f64::EPSILON);
1673 assert!((scoring.weight_utility - 0.25).abs() < f64::EPSILON);
1674 assert!((scoring.corroboration_boost - 0.05).abs() < f64::EPSILON);
1675 }
1676
1677 #[test]
1678 fn graph_scoring_partial_toml_fills_defaults() {
1679 let scoring: GraphScoringConfig =
1680 toml::from_str("weight_utility = 0.5\n").expect("parse partial scoring");
1681 assert!((scoring.weight_semantic - 0.45).abs() < f64::EPSILON);
1682 assert!((scoring.weight_hotness - 0.30).abs() < f64::EPSILON);
1683 assert!((scoring.weight_utility - 0.5).abs() < f64::EPSILON);
1684 assert!((scoring.corroboration_boost - 0.05).abs() < f64::EPSILON);
1685 }
1686
1687 #[test]
1688 fn graph_scoring_corroboration_boost_is_configurable() {
1689 let scoring: GraphScoringConfig =
1690 toml::from_str("corroboration_boost = 0.0\n").expect("parse corroboration boost");
1691 assert!(scoring.corroboration_boost.abs() < f64::EPSILON);
1692 assert!((scoring.weight_semantic - 0.45).abs() < f64::EPSILON);
1693 }
1694
1695 #[test]
1696 fn graph_scoring_empty_section_yields_defaults() {
1697 let section: GraphSection = toml::from_str("").expect("parse empty graph section");
1698 let defaults = GraphScoringConfig::default();
1699 assert!((section.scoring.weight_semantic - defaults.weight_semantic).abs() < f64::EPSILON);
1700 assert!((section.scoring.weight_hotness - defaults.weight_hotness).abs() < f64::EPSILON);
1701 assert!((section.scoring.weight_utility - defaults.weight_utility).abs() < f64::EPSILON);
1702 }
1703
1704 #[test]
1705 fn graph_provenance_defaults_when_section_absent() {
1706 let section: GraphSection = toml::from_str("mode = \"embedded\"\n").expect("parse section");
1707 let defaults = ProvenanceWeights::default();
1708 assert_eq!(section.provenance, defaults);
1709 assert!((defaults.weight_external - 1.0).abs() < f64::EPSILON);
1710 assert!((defaults.weight_user - 0.8).abs() < f64::EPSILON);
1711 assert!((defaults.weight_self - 0.05).abs() < f64::EPSILON);
1712 }
1713
1714 #[test]
1715 fn graph_provenance_partial_toml_fills_defaults() {
1716 let cfg: Config =
1717 toml::from_str("[graph]\n\n[graph.provenance]\nweight_self = 0.5\n").expect("parse");
1718 let provenance = cfg.graph.expect("graph section present").provenance;
1719 assert!((provenance.weight_self - 0.5).abs() < f64::EPSILON);
1720 assert!((provenance.weight_external - 1.0).abs() < f64::EPSILON);
1721 assert!((provenance.weight_user - 0.8).abs() < f64::EPSILON);
1722 }
1723
1724 #[test]
1725 fn set_key_provenance_weights() {
1726 let mut cfg = Config::default();
1727 cfg.set_key("graph.provenance.weight_self", "0.2").unwrap();
1728 cfg.set_key("graph.provenance.weight_user", "0").unwrap();
1729 cfg.set_key("graph.provenance.weight_external", "1.5")
1730 .unwrap();
1731
1732 let provenance = cfg
1733 .graph
1734 .as_ref()
1735 .expect("graph section created")
1736 .provenance;
1737 assert!((provenance.weight_self - 0.2).abs() < f64::EPSILON);
1738 assert!(provenance.weight_user.abs() < f64::EPSILON);
1739 assert!((provenance.weight_external - 1.5).abs() < f64::EPSILON);
1740
1741 assert!(cfg.set_key("graph.provenance.weight_self", "-1").is_err());
1742 assert!(cfg.set_key("graph.provenance.weight_self", "lots").is_err());
1743 }
1744
1745 #[test]
1746 fn provenance_weights_round_trip_through_toml() {
1747 let mut cfg = Config::default();
1748 cfg.set_key("graph.provenance.weight_self", "0.05").unwrap();
1749 let rendered = toml::to_string_pretty(&cfg).expect("render");
1750 let parsed: Config = toml::from_str(&rendered).expect("reparse");
1751 assert_eq!(
1752 parsed.graph.expect("graph section survives").provenance,
1753 ProvenanceWeights::default()
1754 );
1755 }
1756
1757 #[test]
1758 fn dedup_defaults_leave_a_gap_between_the_bands() {
1759 let dedup = GraphDedupConfig::default();
1760 assert!((dedup.certain_similarity - 0.92).abs() < f64::EPSILON);
1761 assert!((dedup.review_similarity - 0.82).abs() < f64::EPSILON);
1762 assert_eq!(dedup.max_candidates, 3);
1763 assert!(dedup.review_similarity < dedup.certain_similarity);
1764 }
1765
1766 #[test]
1767 fn dedup_bands_are_cut_at_the_thresholds() {
1768 let dedup = GraphDedupConfig::default();
1769 assert_eq!(dedup.band(0.99), DedupBand::SameEntity);
1770 assert_eq!(dedup.band(0.92), DedupBand::SameEntity);
1771 assert_eq!(dedup.band(0.9), DedupBand::Ambiguous);
1772 assert_eq!(dedup.band(0.82), DedupBand::Ambiguous);
1773 assert_eq!(dedup.band(0.8), DedupBand::NewEntity);
1774 assert_eq!(dedup.band(0.0), DedupBand::NewEntity);
1775 }
1776
1777 #[test]
1780 fn dedup_candidate_limit_never_falls_below_one() {
1781 let dedup = GraphDedupConfig {
1782 max_candidates: 0,
1783 ..GraphDedupConfig::default()
1784 };
1785 assert_eq!(dedup.candidate_limit(), 1);
1786 }
1787
1788 #[test]
1789 fn dedup_partial_toml_fills_defaults() {
1790 let cfg: Config = toml::from_str("[graph]\n\n[graph.dedup]\ncertain_similarity = 0.95\n")
1791 .expect("parse dedup section");
1792 let dedup = cfg.graph.expect("graph section present").dedup;
1793 assert!((dedup.certain_similarity - 0.95).abs() < f64::EPSILON);
1794 assert!((dedup.review_similarity - 0.82).abs() < f64::EPSILON);
1795 assert_eq!(dedup.max_candidates, 3);
1796 }
1797
1798 #[test]
1799 fn set_key_dedup_thresholds() {
1800 let mut cfg = Config::default();
1801 cfg.set_key("graph.dedup.certain_similarity", "0.9")
1802 .unwrap();
1803 cfg.set_key("graph.dedup.review_similarity", "0.6").unwrap();
1804 cfg.set_key("graph.dedup.max_candidates", "5").unwrap();
1805
1806 let dedup = &cfg.graph.as_ref().expect("graph section created").dedup;
1807 assert!((dedup.certain_similarity - 0.9).abs() < f64::EPSILON);
1808 assert!((dedup.review_similarity - 0.6).abs() < f64::EPSILON);
1809 assert_eq!(dedup.max_candidates, 5);
1810
1811 assert!(cfg
1812 .set_key("graph.dedup.certain_similarity", "1.5")
1813 .is_err());
1814 assert!(cfg
1815 .set_key("graph.dedup.review_similarity", "-0.1")
1816 .is_err());
1817 assert!(cfg.set_key("graph.dedup.max_candidates", "0").is_err());
1818 }
1819
1820 #[test]
1821 fn graph_scoring_nested_under_graph() {
1822 let cfg: Config = toml::from_str(
1823 "[graph]\nmode = \"embedded\"\n\n[graph.scoring]\nweight_utility = 0.5\n",
1824 )
1825 .expect("parse nested scoring");
1826 let scoring = cfg.graph.expect("graph section present").scoring;
1827 assert!((scoring.weight_semantic - 0.45).abs() < f64::EPSILON);
1828 assert!((scoring.weight_hotness - 0.30).abs() < f64::EPSILON);
1829 assert!((scoring.weight_utility - 0.5).abs() < f64::EPSILON);
1830 }
1831}