1use std::path::PathBuf;
2
3use anyhow::{bail, Context, Result};
4use toml_edit::{value, DocumentMut, Item, Table};
5
6mod config_value;
7#[cfg(test)]
8mod migration_tests;
9mod model;
10mod promotion;
11mod rules;
12mod user_auto_promote;
13use config_value::cli_value;
14pub use model::{
15 model_status, model_statuses, rollback_model_config, set_model, ModelChange, ModelPreset,
16 ModelStatus, MODEL_PRESETS,
17};
18pub use promotion::{summary_gate_mode, SummaryGateMode};
19pub use rules::{rule_compilation_config, RuleCompilationConfig};
20pub use user_auto_promote::{
21 user_context_auto_promote_config, AutoPromotePolicy, UserContextAutoPromoteConfig,
22};
23
24pub const CLAUDE_HOST: &str = "claude-code";
25pub const CODEX_HOST: &str = "codex-cli";
26pub const DEFAULT_CODEX_MODEL: &str = "gpt-5.2";
27pub const MEMORY_AI_PROFILE_FIELD: &str = "remem_ai_profile";
28
29const DEFAULT_CLAUDE_MODEL: &str = "haiku";
30const ANTHROPIC_DEFAULT_BASE_URL: &str = "https://api.anthropic.com";
31pub(crate) mod env_lock;
32
33#[cfg(test)]
34pub(crate) use env_lock::EnvGuard as TestEnvGuard;
35pub(crate) static ENV_LOCK: env_lock::EnvLock = env_lock::EnvLock::new();
36#[cfg(test)]
37pub(crate) use ENV_LOCK as TEST_ENV_LOCK;
38
39#[derive(Clone, Copy, Debug, PartialEq, Eq)]
40pub enum MemoryAiExecutor {
41 Http,
42 ClaudeCli,
43 CodexCli,
44}
45
46#[derive(Clone, Debug, Default, PartialEq, Eq)]
47pub struct MemoryAiSelection<'a> {
48 pub host: Option<&'a str>,
49 pub profile: Option<&'a str>,
50}
51
52#[derive(Clone, Debug, PartialEq, Eq)]
53pub struct ResolvedMemoryAiProfile {
54 pub profile_name: String,
55 pub executor: MemoryAiExecutor,
56 pub model: Option<String>,
57 pub cli_path: Option<String>,
58 pub base_url: Option<String>,
59 pub reasoning_effort: Option<String>,
60}
61
62#[derive(Clone, Debug, PartialEq, Eq)]
63pub struct HostRuntimeConfig {
64 pub host: String,
65 pub memory_profile: String,
66 pub context_gate: Option<String>,
67 pub context_color: bool,
68 pub capture_adapter: String,
69}
70
71#[derive(Clone, Debug, PartialEq, Eq)]
72pub struct LegacyClaudeGateMigration {
73 pub config_path: PathBuf,
74 pub host: String,
75 pub old_gate: Option<String>,
76 pub new_gate: Option<String>,
77 pub changed: bool,
78 pub dry_run: bool,
79}
80
81pub fn config_path() -> PathBuf {
82 std::env::var("REMEM_CONFIG")
83 .ok()
84 .map(|value| value.trim().to_string())
85 .filter(|value| !value.is_empty())
86 .map(PathBuf::from)
87 .unwrap_or_else(|| crate::db::data_dir().join("config.toml"))
88}
89
90pub fn default_config_text() -> String {
91 let mut doc = DocumentMut::new();
92 ensure_config_defaults(&mut doc, &[CLAUDE_HOST, CODEX_HOST])
93 .expect("default runtime config should be valid");
94 doc.to_string()
95}
96
97pub fn show_config_text() -> Result<String> {
98 let mut doc = read_config_doc_or_default()?;
99 ensure_config_defaults(&mut doc, &[CLAUDE_HOST, CODEX_HOST])?;
100 Ok(doc.to_string())
101}
102
103pub fn init_config() -> Result<PathBuf> {
104 let path = config_path();
105 let mut doc = read_config_doc_or_default()?;
106 ensure_config_defaults(&mut doc, &[CLAUDE_HOST, CODEX_HOST])?;
107 write_config_doc(&path, &doc)?;
108 Ok(path)
109}
110
111pub fn ensure_config_for_hosts(hosts: &[&str]) -> Result<PathBuf> {
112 let path = config_path();
113 let mut doc = read_config_doc_or_default()?;
114 ensure_config_defaults(&mut doc, hosts)?;
115 write_config_doc(&path, &doc)?;
116 Ok(path)
117}
118
119pub fn set_config_value(key: &str, raw_value: &str) -> Result<PathBuf> {
120 let path = config_path();
121 let mut doc = read_config_doc_or_default()?;
122 ensure_config_defaults(&mut doc, &[CLAUDE_HOST, CODEX_HOST])?;
123
124 let segments = key
125 .split('.')
126 .map(str::trim)
127 .filter(|segment| !segment.is_empty())
128 .collect::<Vec<_>>();
129 if segments.is_empty() {
130 bail!("config key must not be empty");
131 }
132
133 let mut current = doc.as_table_mut();
134 for segment in &segments[..segments.len().saturating_sub(1)] {
135 current = child_table_mut(current, segment)?;
136 }
137 let leaf = segments[segments.len() - 1];
138 current[leaf] = cli_value(raw_value);
139 write_config_doc(&path, &doc)?;
140 Ok(path)
141}
142
143pub fn migrate_legacy_claude_context_gate(dry_run: bool) -> Result<LegacyClaudeGateMigration> {
144 let path = config_path();
145 let mut doc = read_config_doc_or_default()?;
146 ensure_config_defaults(&mut doc, &[CLAUDE_HOST, CODEX_HOST])?;
147
148 let old_gate = context_gate_from_doc(&doc, CLAUDE_HOST);
149 let changed = old_gate
150 .as_deref()
151 .is_some_and(|gate| gate.eq_ignore_ascii_case("off"));
152
153 if changed {
154 set_host_context_gate(&mut doc, CLAUDE_HOST, "auto")?;
155 if !dry_run {
156 write_config_doc(&path, &doc)?;
157 }
158 }
159
160 Ok(LegacyClaudeGateMigration {
161 config_path: path,
162 host: CLAUDE_HOST.to_string(),
163 old_gate,
164 new_gate: context_gate_from_doc(&doc, CLAUDE_HOST),
165 changed,
166 dry_run,
167 })
168}
169
170pub fn normalize_host(raw: &str) -> String {
171 match raw.trim().to_ascii_lowercase().as_str() {
172 "claude" | "claude-code" | "claudecode" => CLAUDE_HOST.to_string(),
173 "codex" | "codex-cli" | "codexcli" => CODEX_HOST.to_string(),
174 "unknown" => "unknown".to_string(),
175 _ => raw.trim().to_string(),
176 }
177}
178
179pub(crate) fn profile_from_payload_text(input: &str) -> Option<String> {
180 let payload: serde_json::Value = serde_json::from_str(input).ok()?;
181 payload
182 .as_object()?
183 .get(MEMORY_AI_PROFILE_FIELD)?
184 .as_str()
185 .map(str::trim)
186 .filter(|profile| !profile.is_empty())
187 .map(str::to_string)
188}
189
190pub fn default_host() -> Result<String> {
191 let mut doc = read_config_doc_or_default()?;
192 ensure_config_defaults(&mut doc, &[CLAUDE_HOST, CODEX_HOST])?;
193 Ok(configured_default_host(&doc))
194}
195
196pub fn resolve_host_runtime_config(host: Option<&str>) -> Result<HostRuntimeConfig> {
197 let mut doc = read_config_doc_or_default()?;
198 let selected_host = host
199 .map(normalize_host)
200 .filter(|host| !host.trim().is_empty());
201 match selected_host.as_deref() {
202 Some(host) => ensure_config_defaults(&mut doc, &[CLAUDE_HOST, CODEX_HOST, host])?,
203 None => ensure_config_defaults(&mut doc, &[CLAUDE_HOST, CODEX_HOST])?,
204 }
205 let host = selected_host.unwrap_or_else(|| configured_default_host(&doc));
206 host_runtime_config_from_doc(&doc, &host)
207}
208
209pub fn resolve_memory_ai_profile(
210 selection: MemoryAiSelection<'_>,
211) -> Result<ResolvedMemoryAiProfile> {
212 if selection.host.is_some() && selection.profile.is_some() {
213 bail!("--host and --profile are mutually exclusive");
214 }
215
216 let mut doc = read_config_doc_or_default()?;
217 let selected_host = selection
218 .host
219 .map(normalize_host)
220 .filter(|host| !host.trim().is_empty());
221 match selected_host.as_deref() {
222 Some(host) => ensure_config_defaults(&mut doc, &[CLAUDE_HOST, CODEX_HOST, host])?,
223 None => ensure_config_defaults(&mut doc, &[CLAUDE_HOST, CODEX_HOST])?,
224 }
225 let profile_name = match selection.profile {
226 Some(profile) if !profile.trim().is_empty() => profile.trim().to_string(),
227 _ => {
228 let host = selected_host.unwrap_or_else(|| configured_default_host(&doc));
229 host_runtime_config_from_doc(&doc, &host)?.memory_profile
230 }
231 };
232 profile_from_doc(&doc, &profile_name)
233}
234
235fn read_config_doc_or_default() -> Result<DocumentMut> {
236 let path = config_path();
237 if !path.exists() {
238 return Ok(DocumentMut::new());
239 }
240 let content =
241 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
242 content
243 .parse::<DocumentMut>()
244 .with_context(|| format!("parse {} as TOML", path.display()))
245}
246
247fn write_config_doc(path: &PathBuf, doc: &DocumentMut) -> Result<()> {
248 crate::atomic_file::write_atomic(path, doc.to_string())
249 .with_context(|| format!("write {}", path.display()))
250}
251
252fn ensure_config_defaults(doc: &mut DocumentMut, hosts: &[&str]) -> Result<()> {
253 if doc.get("version").is_none() {
254 doc["version"] = value(1);
255 }
256
257 promotion::ensure_defaults(doc)?;
258 rules::ensure_defaults(doc)?;
259 user_auto_promote::ensure_defaults(doc)?;
260
261 let memory_ai = top_table_mut(doc, "memory_ai")?;
262 set_str_if_missing(memory_ai, "default_host", CODEX_HOST);
263 let default_host = memory_ai
264 .get("default_host")
265 .and_then(Item::as_str)
266 .map(normalize_host)
267 .filter(|host| !host.is_empty())
268 .unwrap_or_else(|| CODEX_HOST.to_string());
269
270 {
271 let profiles = child_table_mut(memory_ai, "profiles")?;
272 ensure_codex_profile(profiles)?;
273 ensure_claude_profile(profiles)?;
274 ensure_http_profile(profiles)?;
275 }
276
277 {
278 let hosts_table = child_table_mut(memory_ai, "hosts")?;
279 ensure_host_config(hosts_table, &default_host)?;
280 for host in hosts {
281 let host = normalize_host(host);
282 if !host.is_empty() && host != default_host {
283 ensure_host_config(hosts_table, &host)?;
284 }
285 }
286 }
287
288 Ok(())
289}
290
291fn ensure_codex_profile(profiles: &mut Table) -> Result<()> {
292 let profile = child_table_mut(profiles, "codex")?;
293 set_str_if_missing(profile, "executor", "codex-cli");
294 set_str_if_missing(profile, "model", DEFAULT_CODEX_MODEL);
295 set_str_if_missing(profile, "path", "codex");
296 Ok(())
297}
298
299fn ensure_claude_profile(profiles: &mut Table) -> Result<()> {
300 let profile = child_table_mut(profiles, "claude")?;
301 set_str_if_missing(profile, "executor", "claude-cli");
302 set_str_if_missing(profile, "model", DEFAULT_CLAUDE_MODEL);
303 set_str_if_missing(profile, "path", "claude");
304 Ok(())
305}
306
307fn ensure_http_profile(profiles: &mut Table) -> Result<()> {
308 let profile = child_table_mut(profiles, "anthropic_http")?;
309 set_str_if_missing(profile, "executor", "http");
310 set_str_if_missing(profile, "model", DEFAULT_CLAUDE_MODEL);
311 set_str_if_missing(profile, "base_url", ANTHROPIC_DEFAULT_BASE_URL);
312 Ok(())
313}
314
315fn ensure_host_config(hosts: &mut Table, host: &str) -> Result<()> {
316 let table = child_table_mut(hosts, host)?;
317 match host {
318 CODEX_HOST => {
319 set_str_if_missing(table, "memory_profile", "codex");
320 set_str_if_missing(table, "context_gate", "strict");
321 set_bool_if_missing(table, "context_color", true);
322 set_str_if_missing(table, "capture_adapter", CODEX_HOST);
323 }
324 CLAUDE_HOST => {
325 set_str_if_missing(table, "memory_profile", "claude");
326 set_str_if_missing(table, "context_gate", "auto");
327 set_bool_if_missing(table, "context_color", true);
328 set_str_if_missing(table, "capture_adapter", CLAUDE_HOST);
329 }
330 "unknown" => {
331 set_str_if_missing(table, "memory_profile", "codex");
332 set_str_if_missing(table, "context_gate", "off");
333 set_bool_if_missing(table, "context_color", false);
334 set_str_if_missing(table, "capture_adapter", "unknown");
335 }
336 _ => {
337 set_str_if_missing(table, "memory_profile", "codex");
338 set_str_if_missing(table, "context_gate", "off");
339 set_bool_if_missing(table, "context_color", false);
340 set_str_if_missing(table, "capture_adapter", host);
341 }
342 }
343 Ok(())
344}
345
346fn context_gate_from_doc(doc: &DocumentMut, host: &str) -> Option<String> {
347 doc.get("memory_ai")
348 .and_then(Item::as_table)
349 .and_then(|table| table.get("hosts"))
350 .and_then(Item::as_table)
351 .and_then(|hosts| hosts.get(host))
352 .and_then(Item::as_table)
353 .and_then(|table| optional_str(table, "context_gate"))
354}
355
356fn set_host_context_gate(doc: &mut DocumentMut, host: &str, gate: &str) -> Result<()> {
357 let memory_ai = top_table_mut(doc, "memory_ai")?;
358 let hosts = child_table_mut(memory_ai, "hosts")?;
359 let table = child_table_mut(hosts, host)?;
360 table["context_gate"] = value(gate);
361 Ok(())
362}
363
364fn configured_default_host(doc: &DocumentMut) -> String {
365 doc.get("memory_ai")
366 .and_then(Item::as_table)
367 .and_then(|table| table.get("default_host"))
368 .and_then(Item::as_str)
369 .map(normalize_host)
370 .filter(|host| !host.is_empty())
371 .unwrap_or_else(|| CODEX_HOST.to_string())
372}
373
374fn host_runtime_config_from_doc(doc: &DocumentMut, host: &str) -> Result<HostRuntimeConfig> {
375 let Some(hosts) = doc
376 .get("memory_ai")
377 .and_then(Item::as_table)
378 .and_then(|table| table.get("hosts"))
379 .and_then(Item::as_table)
380 else {
381 bail!("missing [memory_ai.hosts] in {}", config_path().display());
382 };
383 let Some(table) = hosts.get(host).and_then(Item::as_table) else {
384 bail!(
385 "missing [memory_ai.hosts.\"{}\"] in {}",
386 host,
387 config_path().display()
388 );
389 };
390 let memory_profile = required_str(table, "memory_profile")?.to_string();
391 let context_gate = optional_str(table, "context_gate");
392 let context_color = table
393 .get("context_color")
394 .and_then(Item::as_bool)
395 .unwrap_or(false);
396 let capture_adapter =
397 optional_str(table, "capture_adapter").unwrap_or_else(|| host.to_string());
398
399 Ok(HostRuntimeConfig {
400 host: host.to_string(),
401 memory_profile,
402 context_gate,
403 context_color,
404 capture_adapter,
405 })
406}
407
408fn profile_from_doc(doc: &DocumentMut, profile_name: &str) -> Result<ResolvedMemoryAiProfile> {
409 let Some(profiles) = doc
410 .get("memory_ai")
411 .and_then(Item::as_table)
412 .and_then(|table| table.get("profiles"))
413 .and_then(Item::as_table)
414 else {
415 bail!(
416 "missing [memory_ai.profiles] in {}",
417 config_path().display()
418 );
419 };
420 let Some(table) = profiles.get(profile_name).and_then(Item::as_table) else {
421 bail!(
422 "missing [memory_ai.profiles.{}] in {}",
423 profile_name,
424 config_path().display()
425 );
426 };
427 let executor = parse_executor(required_str(table, "executor")?)?;
428 let model = optional_str(table, "model").filter(|model| !model.eq_ignore_ascii_case("auto"));
429 Ok(ResolvedMemoryAiProfile {
430 profile_name: profile_name.to_string(),
431 executor,
432 model,
433 cli_path: optional_str(table, "path"),
434 base_url: optional_str(table, "base_url"),
435 reasoning_effort: optional_str(table, "reasoning_effort"),
436 })
437}
438
439fn parse_executor(raw: &str) -> Result<MemoryAiExecutor> {
440 match raw.trim().to_ascii_lowercase().as_str() {
441 "http" | "anthropic" | "anthropic-http" => Ok(MemoryAiExecutor::Http),
442 "claude" | "cli" | "claude-cli" => Ok(MemoryAiExecutor::ClaudeCli),
443 "codex" | "codex-cli" => Ok(MemoryAiExecutor::CodexCli),
444 other => bail!("unknown memory_ai executor: {other}"),
445 }
446}
447
448fn top_table_mut<'a>(doc: &'a mut DocumentMut, key: &str) -> Result<&'a mut Table> {
449 doc.entry(key)
450 .or_insert_with(|| Item::Table(Table::new()))
451 .as_table_mut()
452 .with_context(|| format!("{key} exists but is not a table"))
453}
454
455fn child_table_mut<'a>(table: &'a mut Table, key: &str) -> Result<&'a mut Table> {
456 table
457 .entry(key)
458 .or_insert_with(|| Item::Table(Table::new()))
459 .as_table_mut()
460 .with_context(|| format!("{key} exists but is not a table"))
461}
462
463fn set_str_if_missing(table: &mut Table, key: &str, value_str: &str) {
464 if table.get(key).is_none() {
465 table[key] = value(value_str);
466 }
467}
468
469fn set_bool_if_missing(table: &mut Table, key: &str, value_bool: bool) {
470 if table.get(key).is_none() {
471 table[key] = value(value_bool);
472 }
473}
474
475fn set_i64_if_missing(table: &mut Table, key: &str, value_i64: i64) {
476 if table.get(key).is_none() {
477 table[key] = value(value_i64);
478 }
479}
480
481fn required_str<'a>(table: &'a Table, key: &str) -> Result<&'a str> {
482 table
483 .get(key)
484 .and_then(Item::as_str)
485 .with_context(|| format!("missing or invalid string key '{key}'"))
486}
487
488fn optional_str(table: &Table, key: &str) -> Option<String> {
489 table
490 .get(key)
491 .and_then(Item::as_str)
492 .map(str::trim)
493 .filter(|value| !value.is_empty())
494 .map(str::to_string)
495}
496
497#[cfg(test)]
498mod tests {
499 use super::*;
500
501 fn with_config_path<T>(path: &std::path::Path, f: impl FnOnce() -> T) -> T {
502 let _guard = TEST_ENV_LOCK.lock().expect("env lock should acquire");
503 let old = std::env::var("REMEM_CONFIG").ok();
504 unsafe { std::env::set_var("REMEM_CONFIG", path) };
505 let result = f();
506 match old {
507 Some(value) => unsafe { std::env::set_var("REMEM_CONFIG", value) },
508 None => unsafe { std::env::remove_var("REMEM_CONFIG") },
509 }
510 result
511 }
512
513 fn temp_config_path(label: &str) -> PathBuf {
514 std::env::temp_dir().join(format!(
515 "remem-{label}-{}-{}.toml",
516 std::process::id(),
517 chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default()
518 ))
519 }
520
521 #[test]
522 fn default_config_contains_codex_quality_profile() {
523 let text = default_config_text();
524 assert!(text.contains("default_host = \"codex-cli\""), "{text}");
525 assert!(text.contains("model = \"gpt-5.2\""), "{text}");
526 assert!(!text.contains("gpt-5.4-mini"), "{text}");
527 assert!(!text.contains("reasoning_effort = \"low\""), "{text}");
528 }
529
530 #[test]
531 fn host_selection_resolves_to_profile() {
532 let path = temp_config_path("runtime-resolve");
533 with_config_path(&path, || {
534 init_config().unwrap();
535 let profile = resolve_memory_ai_profile(MemoryAiSelection {
536 host: Some("codex"),
537 profile: None,
538 })
539 .unwrap();
540
541 assert_eq!(profile.profile_name, "codex");
542 assert_eq!(profile.executor, MemoryAiExecutor::CodexCli);
543 assert_eq!(profile.model.as_deref(), Some(DEFAULT_CODEX_MODEL));
544 });
545 let _ = std::fs::remove_file(path);
546 }
547
548 #[test]
549 fn explicit_profile_bypasses_host_mapping() {
550 let path = temp_config_path("runtime-profile");
551 with_config_path(&path, || {
552 init_config().unwrap();
553 let profile = resolve_memory_ai_profile(MemoryAiSelection {
554 host: None,
555 profile: Some("claude"),
556 })
557 .unwrap();
558
559 assert_eq!(profile.executor, MemoryAiExecutor::ClaudeCli);
560 assert_eq!(profile.model.as_deref(), Some("haiku"));
561 });
562 let _ = std::fs::remove_file(path);
563 }
564
565 #[test]
566 fn host_and_profile_are_mutually_exclusive() {
567 let err = resolve_memory_ai_profile(MemoryAiSelection {
568 host: Some(CODEX_HOST),
569 profile: Some("codex"),
570 })
571 .unwrap_err();
572 assert!(err.to_string().contains("mutually exclusive"), "{err}");
573 }
574
575 #[test]
576 fn set_config_value_updates_nested_key() {
577 let path = temp_config_path("runtime-set");
578 with_config_path(&path, || {
579 init_config().unwrap();
580 set_config_value("memory_ai.profiles.codex.model", "custom-mini").unwrap();
581 let profile = resolve_memory_ai_profile(MemoryAiSelection {
582 host: Some(CODEX_HOST),
583 profile: None,
584 })
585 .unwrap();
586 assert_eq!(profile.model.as_deref(), Some("custom-mini"));
587 });
588 let _ = std::fs::remove_file(path);
589 }
590
591 #[test]
592 fn config_write_failure_preserves_existing_runtime_config() -> Result<()> {
593 let path = temp_config_path("runtime-atomic-fail");
594 with_config_path(&path, || -> Result<()> {
595 let _atomic_guard = crate::atomic_file::failpoint_test_lock();
596 init_config()?;
597 let before = std::fs::read_to_string(&path)?;
598 crate::atomic_file::fail_next_rename_for_path_for_test(&path);
599
600 let err = set_config_value("memory_ai.profiles.codex.model", "custom-mini")
601 .expect_err("injected atomic write failure must abort config update");
602 assert!(format!("{err:?}").contains("injected atomic write failure"));
603 assert_eq!(std::fs::read_to_string(&path)?, before);
604 crate::atomic_file::clear_failpoints_for_test();
605 Ok(())
606 })?;
607 let _ = std::fs::remove_file(path);
608 Ok(())
609 }
610
611 #[test]
612 fn context_options_resolve_from_host_config() {
613 let path = temp_config_path("runtime-context");
614 with_config_path(&path, || {
615 init_config().unwrap();
616 let host = resolve_host_runtime_config(Some("codex")).unwrap();
617
618 assert_eq!(host.host, CODEX_HOST);
619 assert_eq!(host.context_gate.as_deref(), Some("strict"));
620 assert!(host.context_color);
621 assert_eq!(host.capture_adapter, CODEX_HOST);
622 });
623 let _ = std::fs::remove_file(path);
624 }
625
626 #[test]
627 fn claude_host_defaults_to_context_gate_auto() -> Result<()> {
628 let path = temp_config_path("runtime-claude-context");
629 with_config_path(&path, || -> Result<()> {
630 init_config()?;
631 let host = resolve_host_runtime_config(Some("claude-code"))?;
632
633 assert_eq!(host.host, CLAUDE_HOST);
634 assert_eq!(host.context_gate.as_deref(), Some("auto"));
635 assert!(host.context_color);
636 assert_eq!(host.capture_adapter, CLAUDE_HOST);
637 Ok(())
638 })?;
639 std::fs::remove_file(path)?;
640 Ok(())
641 }
642
643 #[test]
644 fn init_config_preserves_explicit_claude_context_gate_off() -> Result<()> {
645 let path = temp_config_path("runtime-claude-context-init-explicit-off");
646 with_config_path(&path, || -> Result<()> {
647 std::fs::write(
648 &path,
649 "[memory_ai.hosts.claude-code]\nmemory_profile = \"claude\"\ncontext_gate = \"off\"\n",
650 )?;
651 init_config()?;
652 let text = std::fs::read_to_string(&path)?;
653 let host = resolve_host_runtime_config(Some("claude-code"))?;
654
655 assert_eq!(host.host, CLAUDE_HOST);
656 assert_eq!(host.context_gate.as_deref(), Some("off"));
657 assert!(text.contains("context_gate = \"off\""), "{text}");
658 Ok(())
659 })?;
660 std::fs::remove_file(path)?;
661 Ok(())
662 }
663
664 #[test]
665 fn ensure_config_for_hosts_preserves_explicit_claude_context_gate_off() -> Result<()> {
666 let path = temp_config_path("runtime-claude-context-ensure-explicit-off");
667 with_config_path(&path, || -> Result<()> {
668 std::fs::write(
669 &path,
670 "[memory_ai.hosts.claude-code]\nmemory_profile = \"claude\"\ncontext_gate = \"off\"\n",
671 )?;
672 ensure_config_for_hosts(&[CLAUDE_HOST])?;
673 let text = std::fs::read_to_string(&path)?;
674 let host = resolve_host_runtime_config(Some("claude-code"))?;
675
676 assert_eq!(host.host, CLAUDE_HOST);
677 assert_eq!(host.context_gate.as_deref(), Some("off"));
678 assert!(text.contains("context_gate = \"off\""), "{text}");
679 Ok(())
680 })?;
681 std::fs::remove_file(path)?;
682 Ok(())
683 }
684
685 #[test]
686 fn unrelated_config_set_preserves_explicit_claude_context_gate_off() -> Result<()> {
687 let path = temp_config_path("runtime-claude-context-set-explicit-off");
688 with_config_path(&path, || -> Result<()> {
689 init_config()?;
690 set_config_value("memory_ai.hosts.claude-code.context_gate", "off")?;
691 set_config_value("memory_ai.profiles.codex.model", "custom-mini")?;
692 let text = std::fs::read_to_string(&path)?;
693 let host = resolve_host_runtime_config(Some("claude-code"))?;
694 let profile = resolve_memory_ai_profile(MemoryAiSelection {
695 host: Some(CODEX_HOST),
696 profile: None,
697 })?;
698
699 assert_eq!(host.host, CLAUDE_HOST);
700 assert_eq!(host.context_gate.as_deref(), Some("off"));
701 assert_eq!(profile.model.as_deref(), Some("custom-mini"));
702 assert!(text.contains("context_gate = \"off\""), "{text}");
703 Ok(())
704 })?;
705 std::fs::remove_file(path)?;
706 Ok(())
707 }
708
709 #[test]
710 fn claude_host_resolve_preserves_explicit_context_gate_off() -> Result<()> {
711 let path = temp_config_path("runtime-claude-context-explicit-off");
712 with_config_path(&path, || -> Result<()> {
713 std::fs::write(
714 &path,
715 "[memory_ai.hosts.claude-code]\nmemory_profile = \"claude\"\ncontext_gate = \"off\"\n",
716 )?;
717 let host = resolve_host_runtime_config(Some("claude-code"))?;
718
719 assert_eq!(host.host, CLAUDE_HOST);
720 assert_eq!(host.context_gate.as_deref(), Some("off"));
721 Ok(())
722 })?;
723 std::fs::remove_file(path)?;
724 Ok(())
725 }
726
727 #[test]
728 fn partial_install_still_materializes_configured_default_host() -> Result<()> {
729 let path = temp_config_path("runtime-partial-default-host");
730 with_config_path(&path, || -> Result<()> {
731 ensure_config_for_hosts(&[CLAUDE_HOST])?;
732 let text = std::fs::read_to_string(&path)?;
733
734 assert!(text.contains("[memory_ai.hosts.claude-code]"), "{text}");
735 assert!(text.contains("[memory_ai.hosts.codex-cli]"), "{text}");
736
737 let host = resolve_host_runtime_config(None)?;
738 assert_eq!(host.host, CODEX_HOST);
739 assert_eq!(host.memory_profile, "codex");
740 Ok(())
741 })?;
742 std::fs::remove_file(path)?;
743 Ok(())
744 }
745
746 #[test]
747 fn explicit_unknown_host_materializes_fallback_config() -> Result<()> {
748 let path = temp_config_path("runtime-unknown-host");
749 with_config_path(&path, || -> Result<()> {
750 init_config()?;
751 let host = resolve_host_runtime_config(Some("unknown"))?;
752 let profile = resolve_memory_ai_profile(MemoryAiSelection {
753 host: Some("unknown"),
754 profile: None,
755 })?;
756
757 assert_eq!(host.host, "unknown");
758 assert_eq!(host.memory_profile, "codex");
759 assert_eq!(host.context_gate.as_deref(), Some("off"));
760 assert_eq!(profile.profile_name, "codex");
761 Ok(())
762 })?;
763 std::fs::remove_file(path)?;
764 Ok(())
765 }
766}