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