Skip to main content

remem/
runtime_config.rs

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}