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";
32pub 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 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}