1use std::path::PathBuf;
3
4use crate::config::{
5 atomic_write, delete_file, home_dir, sanitize_provider_name, write_json_file, write_text_file,
6};
7use crate::error::AppError;
8use serde_json::json;
9use serde_json::Value;
10use std::fs;
11use std::path::Path;
12use toml_edit::DocumentMut;
13
14pub const CC_SWITCH_CODEX_MODEL_PROVIDER_ID: &str = "ccswitch";
15pub const CODEX_STRUCTURED_CONFIG_KEY: &str = "codex";
16pub const CODEX_LEGACY_CONFIG_KEY: &str = "config";
17
18const CODEX_RESERVED_MODEL_PROVIDER_IDS: &[&str] = &[
22 "amazon-bedrock",
23 "openai",
24 "ollama",
25 "lmstudio",
26 "oss",
27 "ollama-chat",
28];
29
30pub fn get_codex_config_dir() -> PathBuf {
34 if let Some(dir) = std::env::var_os("CODEX_HOME") {
35 let dir = PathBuf::from(dir);
36 if !dir.as_os_str().is_empty() && !dir.to_string_lossy().trim().is_empty() {
37 return expand_codex_config_dir(dir);
38 }
39 }
40
41 if let Some(custom) = crate::settings::get_codex_override_dir() {
42 return custom;
43 }
44
45 home_dir().expect("无法获取用户主目录").join(".codex")
46}
47
48fn expand_codex_config_dir(path: PathBuf) -> PathBuf {
49 let lossy = path.to_string_lossy();
50 if lossy == "~" {
51 return home_dir().unwrap_or(path);
52 }
53
54 if let Some(rest) = lossy
55 .strip_prefix("~/")
56 .or_else(|| lossy.strip_prefix("~\\"))
57 {
58 if let Some(home) = home_dir() {
59 return home.join(rest);
60 }
61 }
62
63 path
64}
65
66pub fn get_codex_auth_path() -> PathBuf {
68 get_codex_config_dir().join("auth.json")
69}
70
71pub fn get_codex_config_path() -> PathBuf {
73 get_codex_config_dir().join("config.toml")
74}
75
76pub fn get_codex_provider_paths(
78 provider_id: &str,
79 provider_name: Option<&str>,
80) -> (PathBuf, PathBuf) {
81 let base_name = provider_name
82 .map(sanitize_provider_name)
83 .unwrap_or_else(|| sanitize_provider_name(provider_id));
84
85 let auth_path = get_codex_config_dir().join(format!("auth-{base_name}.json"));
86 let config_path = get_codex_config_dir().join(format!("config-{base_name}.toml"));
87
88 (auth_path, config_path)
89}
90
91pub fn delete_codex_provider_config(
93 provider_id: &str,
94 provider_name: &str,
95) -> Result<(), AppError> {
96 let (auth_path, config_path) = get_codex_provider_paths(provider_id, Some(provider_name));
97
98 delete_file(&auth_path).ok();
99 delete_file(&config_path).ok();
100
101 Ok(())
102}
103
104pub fn write_codex_live_atomic(
106 auth: &Value,
107 config_text_opt: Option<&str>,
108) -> Result<(), AppError> {
109 write_codex_live_atomic_optional_auth(Some(auth), config_text_opt)
110}
111
112pub fn write_codex_live_atomic_optional_auth(
113 auth: Option<&Value>,
114 config_text_opt: Option<&str>,
115) -> Result<(), AppError> {
116 let auth_path = get_codex_auth_path();
117 let config_path = get_codex_config_path();
118
119 if let Some(parent) = auth_path.parent() {
120 std::fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
121 }
122
123 let old_auth = if auth_path.exists() {
125 Some(fs::read(&auth_path).map_err(|e| AppError::io(&auth_path, e))?)
126 } else {
127 None
128 };
129 let _old_config = if config_path.exists() {
130 Some(fs::read(&config_path).map_err(|e| AppError::io(&config_path, e))?)
131 } else {
132 None
133 };
134
135 let cfg_text = match config_text_opt {
137 Some(s) => s.to_string(),
138 None => String::new(),
139 };
140 if !cfg_text.trim().is_empty() {
141 toml::from_str::<toml::Table>(&cfg_text).map_err(|e| AppError::toml(&config_path, e))?;
142 }
143
144 if let Some(auth) = auth {
146 write_json_file(&auth_path, auth)?;
147 } else {
148 delete_file(&auth_path)?;
149 }
150
151 if let Err(e) = write_text_file(&config_path, &cfg_text) {
153 if let Some(bytes) = old_auth {
155 let _ = atomic_write(&auth_path, &bytes);
156 } else {
157 let _ = delete_file(&auth_path);
158 }
159 return Err(e);
160 }
161
162 Ok(())
163}
164
165pub fn read_codex_config_text() -> Result<String, AppError> {
167 let path = get_codex_config_path();
168 if path.exists() {
169 std::fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))
170 } else {
171 Ok(String::new())
172 }
173}
174
175pub fn validate_config_toml(text: &str) -> Result<(), AppError> {
177 if text.trim().is_empty() {
178 return Ok(());
179 }
180 toml::from_str::<toml::Table>(text)
181 .map(|_| ())
182 .map_err(|e| AppError::toml(Path::new("config.toml"), e))
183}
184
185fn toml_value_to_json(value: toml::Value) -> Value {
186 match value {
187 toml::Value::String(value) => Value::String(value),
188 toml::Value::Integer(value) => json!(value),
189 toml::Value::Float(value) => json!(value),
190 toml::Value::Boolean(value) => Value::Bool(value),
191 toml::Value::Datetime(value) => Value::String(value.to_string()),
192 toml::Value::Array(values) => {
193 Value::Array(values.into_iter().map(toml_value_to_json).collect())
194 }
195 toml::Value::Table(table) => {
196 let mut obj = serde_json::Map::new();
197 for (key, value) in table {
198 obj.insert(key, toml_value_to_json(value));
199 }
200 Value::Object(obj)
201 }
202 }
203}
204
205fn json_value_to_toml(value: &Value, path: &str) -> Result<toml::Value, AppError> {
206 match value {
207 Value::Null => Err(AppError::Config(format!(
208 "Codex structured config contains null at `{path}`; TOML has no null value"
209 ))),
210 Value::Bool(value) => Ok(toml::Value::Boolean(*value)),
211 Value::Number(value) => {
212 if let Some(value) = value.as_i64() {
213 Ok(toml::Value::Integer(value))
214 } else if let Some(value) = value.as_u64() {
215 let value = i64::try_from(value).map_err(|_| {
216 AppError::Config(format!(
217 "Codex structured config integer at `{path}` is too large for TOML"
218 ))
219 })?;
220 Ok(toml::Value::Integer(value))
221 } else if let Some(value) = value.as_f64() {
222 Ok(toml::Value::Float(value))
223 } else {
224 Err(AppError::Config(format!(
225 "Codex structured config number at `{path}` is not representable as TOML"
226 )))
227 }
228 }
229 Value::String(value) => Ok(toml::Value::String(value.clone())),
230 Value::Array(values) => {
231 let mut out = Vec::with_capacity(values.len());
232 for (index, value) in values.iter().enumerate() {
233 out.push(json_value_to_toml(value, &format!("{path}[{index}]"))?);
234 }
235 Ok(toml::Value::Array(out))
236 }
237 Value::Object(values) => {
238 let mut out = toml::map::Map::new();
239 for (key, value) in values {
240 let child_path = if path.is_empty() {
241 key.to_string()
242 } else {
243 format!("{path}.{key}")
244 };
245 out.insert(key.clone(), json_value_to_toml(value, &child_path)?);
246 }
247 Ok(toml::Value::Table(out))
248 }
249 }
250}
251
252pub fn codex_structured_config_from_toml(config_toml: &str) -> Result<Value, AppError> {
253 let trimmed = config_toml.trim();
254 if trimmed.is_empty() {
255 return Ok(Value::Object(serde_json::Map::new()));
256 }
257
258 trimmed
259 .parse::<DocumentMut>()
260 .map_err(|e| AppError::Config(format!("TOML parse error: {e}")))?;
261 let value = toml::from_str::<toml::Value>(trimmed)
262 .map_err(|e| AppError::toml(Path::new("config.toml"), e))?;
263 match toml_value_to_json(value) {
264 Value::Object(obj) => Ok(Value::Object(obj)),
265 _ => Err(AppError::Config(
266 "Codex config.toml root must be a TOML table".to_string(),
267 )),
268 }
269}
270
271pub fn codex_structured_config_to_toml(config: &Value) -> Result<String, AppError> {
272 match config {
273 Value::Null => Ok(String::new()),
274 Value::Object(_) => {
275 let value = json_value_to_toml(config, CODEX_STRUCTURED_CONFIG_KEY)?;
276 toml::to_string(&value)
277 .map_err(|source| {
278 AppError::Config(format!(
279 "Codex structured config TOML serialize error: {source}"
280 ))
281 })
282 .map(|text| text.trim().to_string())
283 }
284 _ => Err(AppError::Config(
285 "Codex structured config must be a JSON object".to_string(),
286 )),
287 }
288}
289
290pub fn codex_config_text_from_settings(settings: &Value) -> Result<String, AppError> {
291 let settings = settings
292 .as_object()
293 .ok_or_else(|| AppError::Config("Codex 配置必须是 JSON 对象".into()))?;
294
295 if let Some(config) = settings.get(CODEX_STRUCTURED_CONFIG_KEY) {
296 return codex_structured_config_to_toml(config);
297 }
298
299 match settings.get(CODEX_LEGACY_CONFIG_KEY) {
300 Some(Value::String(text)) => Ok(text.clone()),
301 Some(Value::Null) | None => Ok(String::new()),
302 Some(_) => Err(AppError::Config(
303 "Codex config 字段必须是字符串,codex 字段必须是对象".to_string(),
304 )),
305 }
306}
307
308pub fn codex_settings_snapshot_from_toml(
309 auth: Option<Value>,
310 config_toml: &str,
311) -> Result<Value, AppError> {
312 let mut settings = serde_json::Map::new();
313 if let Some(auth) = auth {
314 settings.insert("auth".to_string(), auth);
315 }
316 settings.insert(
317 CODEX_STRUCTURED_CONFIG_KEY.to_string(),
318 codex_structured_config_from_toml(config_toml)?,
319 );
320 Ok(Value::Object(settings))
321}
322
323pub fn set_codex_config_text_in_settings(
324 settings: &mut Value,
325 config_toml: &str,
326 prefer_structured: bool,
327) -> Result<(), AppError> {
328 let settings = settings
329 .as_object_mut()
330 .ok_or_else(|| AppError::Config("Codex 配置必须是 JSON 对象".into()))?;
331
332 if prefer_structured {
333 settings.remove(CODEX_LEGACY_CONFIG_KEY);
334 settings.insert(
335 CODEX_STRUCTURED_CONFIG_KEY.to_string(),
336 codex_structured_config_from_toml(config_toml)?,
337 );
338 } else {
339 settings.insert(
340 CODEX_LEGACY_CONFIG_KEY.to_string(),
341 Value::String(config_toml.to_string()),
342 );
343 }
344
345 Ok(())
346}
347
348pub fn normalize_codex_settings_config_for_storage(settings: &mut Value) -> Result<(), AppError> {
349 let config_toml = isolate_selected_codex_provider(&codex_config_text_from_settings(settings)?)?;
350 if !config_toml.trim().is_empty()
351 && !config_toml.lines().any(|line| {
352 let line = line.trim();
353 !line.is_empty()
354 && !line.starts_with('#')
355 && (line.contains('=') || line.starts_with('['))
356 })
357 {
358 return Err(AppError::Config(
359 "Codex config.toml does not contain TOML assignments or tables".to_string(),
360 ));
361 }
362 let settings = settings
363 .as_object_mut()
364 .ok_or_else(|| AppError::Config("Codex 配置必须是 JSON 对象".into()))?;
365 settings.remove(CODEX_LEGACY_CONFIG_KEY);
366 settings.insert(
367 CODEX_STRUCTURED_CONFIG_KEY.to_string(),
368 codex_structured_config_from_toml(&config_toml)?,
369 );
370 Ok(())
371}
372
373pub fn strip_codex_provider_config_text(config_toml: &str) -> Result<String, AppError> {
379 let config_toml = config_toml.trim();
380 if config_toml.is_empty() {
381 return Ok(String::new());
382 }
383
384 let mut doc = config_toml
385 .parse::<toml_edit::DocumentMut>()
386 .map_err(|e| AppError::Config(format!("TOML parse error: {e}")))?;
387 let root = doc.as_table_mut();
388 root.remove("model");
389 root.remove("model_provider");
390 root.remove("base_url");
391 root.remove("model_providers");
392
393 let mut cleaned = String::new();
394 let mut blank_run = 0usize;
395 for line in doc.to_string().lines() {
396 if line.trim().is_empty() {
397 blank_run += 1;
398 if blank_run <= 1 {
399 cleaned.push('\n');
400 }
401 continue;
402 }
403 blank_run = 0;
404 cleaned.push_str(line);
405 cleaned.push('\n');
406 }
407
408 Ok(cleaned.trim().to_string())
409}
410
411pub fn isolate_selected_codex_provider(config_text: &str) -> Result<String, AppError> {
412 let config_text = config_text.trim();
413 if config_text.is_empty() {
414 return Ok(String::new());
415 }
416
417 let mut doc = config_text
418 .parse::<DocumentMut>()
419 .map_err(|e| AppError::Config(format!("TOML parse error: {e}")))?;
420 let selected = active_codex_model_provider_id(&doc);
421
422 let Some(selected) = selected else {
423 doc.as_table_mut().remove("model_providers");
424 return Ok(doc.to_string());
425 };
426 let Some(providers) = doc
427 .get_mut("model_providers")
428 .and_then(|item| item.as_table_like_mut())
429 else {
430 return Ok(doc.to_string());
431 };
432 if providers.get(&selected).is_none() {
433 if !is_custom_codex_model_provider_id(&selected) {
434 doc.as_table_mut().remove("model_providers");
435 return Ok(doc.to_string());
436 }
437 return Err(AppError::Config(format!(
438 "Codex model_provider `{selected}` is missing from model_providers"
439 )));
440 }
441
442 let stale_keys = providers
443 .iter()
444 .map(|(key, _)| key.to_string())
445 .filter(|key| key != &selected)
446 .collect::<Vec<_>>();
447 for key in stale_keys {
448 providers.remove(&key);
449 }
450
451 Ok(doc.to_string())
452}
453
454pub fn read_and_validate_codex_config_text() -> Result<String, AppError> {
456 let s = read_codex_config_text()?;
457 validate_config_toml(&s)?;
458 Ok(s)
459}
460
461fn active_codex_model_provider_id(doc: &DocumentMut) -> Option<String> {
462 doc.get("model_provider")
463 .and_then(|item| item.as_str())
464 .map(str::trim)
465 .filter(|id| !id.is_empty())
466 .map(str::to_string)
467}
468
469fn is_custom_codex_model_provider_id(id: &str) -> bool {
470 let id = id.trim();
471 !id.is_empty()
472 && !CODEX_RESERVED_MODEL_PROVIDER_IDS
473 .iter()
474 .any(|reserved| reserved.eq_ignore_ascii_case(id))
475}
476
477fn stable_codex_model_provider_id_from_config(config_text: &str) -> Option<String> {
478 let doc = config_text.parse::<DocumentMut>().ok()?;
479 let provider_id = active_codex_model_provider_id(&doc)?;
480
481 if is_custom_codex_model_provider_id(&provider_id) {
482 Some(provider_id)
483 } else {
484 None
485 }
486}
487
488fn codex_model_provider_id_with_table_from_config(
489 config_text: &str,
490) -> Result<Option<String>, AppError> {
491 if config_text.trim().is_empty() {
492 return Ok(None);
493 }
494
495 let doc = config_text
496 .parse::<DocumentMut>()
497 .map_err(|e| AppError::Message(format!("Invalid Codex config.toml: {e}")))?;
498 let Some(provider_id) = active_codex_model_provider_id(&doc) else {
499 return Ok(None);
500 };
501
502 let has_provider_table = doc
503 .get("model_providers")
504 .and_then(|item| item.as_table())
505 .and_then(|table| table.get(provider_id.as_str()))
506 .is_some();
507
508 Ok(has_provider_table.then_some(provider_id))
509}
510
511fn primary_codex_model_provider_id_with_table(doc: &DocumentMut) -> Option<String> {
512 if let Some(provider_id) = active_codex_model_provider_id(doc) {
513 let has_provider_table = doc
514 .get("model_providers")
515 .and_then(|item| item.as_table_like())
516 .and_then(|table| table.get(provider_id.as_str()))
517 .is_some();
518 if has_provider_table {
519 return Some(provider_id);
520 }
521 }
522
523 let providers = doc
524 .get("model_providers")
525 .and_then(|item| item.as_table_like())?;
526 let mut keys = providers.iter().map(|(key, _)| key.to_string());
527 let first = keys.next()?;
528 keys.next().is_none().then_some(first)
529}
530
531fn normalize_codex_live_config_model_provider_with_anchors<'a>(
532 config_text: &str,
533 anchor_config_texts: impl IntoIterator<Item = &'a str>,
534) -> Result<String, AppError> {
535 if config_text.trim().is_empty() {
536 return Ok(config_text.to_string());
537 }
538
539 let mut doc = config_text
540 .parse::<DocumentMut>()
541 .map_err(|e| AppError::Message(format!("Invalid Codex config.toml: {e}")))?;
542
543 let Some(source_provider_id) = active_codex_model_provider_id(&doc) else {
544 return Ok(config_text.to_string());
545 };
546
547 let has_source_provider_table = doc
548 .get("model_providers")
549 .and_then(|item| item.as_table())
550 .and_then(|table| table.get(source_provider_id.as_str()))
551 .is_some();
552 if !has_source_provider_table {
553 return Ok(config_text.to_string());
554 }
555
556 let stable_provider_id = anchor_config_texts
557 .into_iter()
558 .find_map(stable_codex_model_provider_id_from_config)
559 .or_else(|| {
560 is_custom_codex_model_provider_id(&source_provider_id)
561 .then(|| source_provider_id.clone())
562 })
563 .unwrap_or_else(|| CC_SWITCH_CODEX_MODEL_PROVIDER_ID.to_string());
564
565 if stable_provider_id == source_provider_id {
566 return Ok(config_text.to_string());
567 }
568
569 if let Some(model_providers) = doc
570 .get_mut("model_providers")
571 .and_then(|item| item.as_table_mut())
572 {
573 let Some(provider_table) = model_providers.remove(source_provider_id.as_str()) else {
574 return Ok(config_text.to_string());
575 };
576 model_providers[stable_provider_id.as_str()] = provider_table;
577 }
578
579 rewrite_codex_profile_model_provider_refs(&mut doc, &source_provider_id, &stable_provider_id);
580 doc["model_provider"] = toml_edit::value(stable_provider_id.as_str());
581
582 Ok(doc.to_string())
583}
584
585fn rewrite_codex_profile_model_provider_refs(
586 doc: &mut DocumentMut,
587 source_provider_id: &str,
588 stable_provider_id: &str,
589) {
590 let Some(profiles) = doc
591 .get_mut("profiles")
592 .and_then(|item| item.as_table_like_mut())
593 else {
594 return;
595 };
596
597 let profile_keys: Vec<String> = profiles.iter().map(|(key, _)| key.to_string()).collect();
598 for profile_key in profile_keys {
599 let Some(profile_table) = profiles
600 .get_mut(&profile_key)
601 .and_then(|item| item.as_table_like_mut())
602 else {
603 continue;
604 };
605
606 let references_source = profile_table
607 .get("model_provider")
608 .and_then(|item| item.as_str())
609 == Some(source_provider_id);
610 if references_source {
611 profile_table.insert("model_provider", toml_edit::value(stable_provider_id));
612 }
613 }
614}
615
616pub fn normalize_codex_settings_config_model_provider(
623 settings: &mut Value,
624 anchor_config_text: Option<&str>,
625) -> Result<(), AppError> {
626 let prefer_structured = settings.get(CODEX_STRUCTURED_CONFIG_KEY).is_some()
627 && settings.get(CODEX_LEGACY_CONFIG_KEY).is_none();
628 let config_text = codex_config_text_from_settings(settings)?;
629 if config_text.trim().is_empty() {
630 return Ok(());
631 }
632
633 let current_config_text = read_codex_config_text().ok();
634 let anchors = anchor_config_text
635 .into_iter()
636 .chain(current_config_text.as_deref());
637 let normalized =
638 normalize_codex_live_config_model_provider_with_anchors(&config_text, anchors)?;
639
640 set_codex_config_text_in_settings(settings, &normalized, prefer_structured)?;
641 Ok(())
642}
643
644fn restore_codex_backfill_model_provider_id(
645 config_text: &str,
646 template_config_text: &str,
647) -> Result<String, AppError> {
648 let Some(template_provider_id) =
649 codex_model_provider_id_with_table_from_config(template_config_text)?
650 else {
651 return Ok(config_text.to_string());
652 };
653
654 if config_text.trim().is_empty() {
655 return Ok(config_text.to_string());
656 }
657
658 let mut doc = config_text
659 .parse::<DocumentMut>()
660 .map_err(|e| AppError::Message(format!("Invalid Codex config.toml: {e}")))?;
661 let Some(live_provider_id) = active_codex_model_provider_id(&doc) else {
662 return Ok(config_text.to_string());
663 };
664
665 if live_provider_id == template_provider_id {
666 return Ok(config_text.to_string());
667 }
668
669 if let Some(model_providers) = doc
670 .get_mut("model_providers")
671 .and_then(|item| item.as_table_mut())
672 {
673 let Some(provider_table) = model_providers.remove(live_provider_id.as_str()) else {
674 return Ok(config_text.to_string());
675 };
676 model_providers[template_provider_id.as_str()] = provider_table;
677 } else {
678 return Ok(config_text.to_string());
679 }
680
681 rewrite_codex_profile_model_provider_refs(&mut doc, &live_provider_id, &template_provider_id);
682 doc["model_provider"] = toml_edit::value(template_provider_id.as_str());
683
684 Ok(doc.to_string())
685}
686
687pub fn restore_codex_settings_config_model_provider_for_backfill(
690 settings: &mut Value,
691 template_settings: &Value,
692) -> Result<(), AppError> {
693 let prefer_structured = settings.get(CODEX_STRUCTURED_CONFIG_KEY).is_some()
694 && settings.get(CODEX_LEGACY_CONFIG_KEY).is_none();
695 let config_text = codex_config_text_from_settings(settings)?;
696 let template_config_text = codex_config_text_from_settings(template_settings)?;
697 if template_config_text.trim().is_empty() {
698 return Ok(());
699 }
700
701 let restored = restore_codex_backfill_model_provider_id(&config_text, &template_config_text)?;
702 set_codex_config_text_in_settings(settings, &restored, prefer_structured)?;
703
704 Ok(())
705}
706
707pub fn merge_provider_into_codex_live_config(
756 live_text: &str,
757 provider_snapshot: &str,
758 preserve_user_preferences: bool,
759) -> Result<String, AppError> {
760 const LIVE_RUNTIME_KEYS: &[&str] = &["mcp_servers", "projects", "trusted_workspaces"];
761 const PROVIDER_SCOPED_KEYS: &[&str] = &[
766 "model_provider",
767 "model",
768 "profile",
769 "model_providers",
770 "profiles",
771 ];
772
773 let mut live = if live_text.trim().is_empty() {
774 toml_edit::DocumentMut::new()
775 } else {
776 live_text
777 .parse::<toml_edit::DocumentMut>()
778 .map_err(|e| AppError::Message(format!("Invalid Codex live config.toml: {e}")))?
779 };
780
781 let snap = if provider_snapshot.trim().is_empty() {
782 toml_edit::DocumentMut::new()
783 } else {
784 provider_snapshot
785 .parse::<toml_edit::DocumentMut>()
786 .map_err(|e| AppError::Message(format!("Invalid Codex provider snapshot: {e}")))?
787 };
788
789 let live_keys: Vec<String> = live.as_table().iter().map(|(k, _)| k.to_string()).collect();
799 for key in live_keys {
800 if LIVE_RUNTIME_KEYS.contains(&key.as_str()) {
801 continue;
802 }
803 if snap.get(&key).is_some() {
804 continue;
805 }
806 let is_provider_scoped = PROVIDER_SCOPED_KEYS.contains(&key.as_str());
807 if is_provider_scoped || !preserve_user_preferences {
808 live.as_table_mut().remove(&key);
809 }
810 }
811
812 let snap_keys: Vec<String> = snap.as_table().iter().map(|(k, _)| k.to_string()).collect();
821 for key in snap_keys {
822 if LIVE_RUNTIME_KEYS.contains(&key.as_str()) {
823 continue;
824 }
825 let is_provider_scoped = PROVIDER_SCOPED_KEYS.contains(&key.as_str());
826 if !is_provider_scoped && preserve_user_preferences && live.get(&key).is_some() {
827 continue;
828 }
829 if let Some(val) = snap.get(&key) {
830 live[key.as_str()] = val.clone();
831 }
832 }
833
834 Ok(live.to_string())
835}
836
837pub fn rewrite_codex_config_model_provider_key(
843 config_text: &str,
844 target_provider_id: &str,
845) -> Result<String, AppError> {
846 if config_text.trim().is_empty() {
847 return Ok(String::new());
848 }
849
850 let target_provider_id = clean_codex_provider_display_key(target_provider_id);
851 let mut doc = config_text
852 .parse::<DocumentMut>()
853 .map_err(|e| AppError::Message(format!("Invalid Codex config.toml: {e}")))?;
854 let Some(source_provider_id) = primary_codex_model_provider_id_with_table(&doc) else {
855 return Ok(config_text.to_string());
856 };
857
858 if let Some(model_providers) = doc
859 .get_mut("model_providers")
860 .and_then(|item| item.as_table_mut())
861 {
862 if source_provider_id != target_provider_id {
863 let Some(provider_table) = model_providers.remove(source_provider_id.as_str()) else {
864 return Ok(config_text.to_string());
865 };
866 model_providers[target_provider_id.as_str()] = provider_table;
867 rewrite_codex_profile_model_provider_refs(
868 &mut doc,
869 &source_provider_id,
870 &target_provider_id,
871 );
872 }
873 } else {
874 return Ok(config_text.to_string());
875 }
876
877 doc["model_provider"] = toml_edit::value(target_provider_id.as_str());
878
879 Ok(doc.to_string())
880}
881
882pub fn rewrite_codex_config_model_provider_name(
883 config_text: &str,
884 provider_key: &str,
885 provider_name: &str,
886) -> Result<String, AppError> {
887 let mut doc = config_text
888 .parse::<DocumentMut>()
889 .map_err(|e| AppError::Message(format!("Invalid Codex config.toml: {e}")))?;
890 let provider = doc
891 .get_mut("model_providers")
892 .and_then(|item| item.as_table_like_mut())
893 .and_then(|providers| providers.get_mut(provider_key))
894 .and_then(|item| item.as_table_like_mut())
895 .ok_or_else(|| {
896 AppError::Config(format!(
897 "Codex provider `{provider_key}` is missing from model_providers"
898 ))
899 })?;
900 provider.insert("name", toml_edit::value(provider_name));
901 Ok(doc.to_string())
902}
903
904pub fn selected_codex_provider_base_url(config_text: &str) -> Result<Option<String>, AppError> {
905 let config_text = config_text.trim();
906 if config_text.is_empty() {
907 return Ok(None);
908 }
909
910 let table = toml::from_str::<toml::Table>(config_text)
911 .map_err(|source| AppError::toml(Path::new("config.toml"), source))?;
912 let selected_url = table
913 .get("model_provider")
914 .and_then(toml::Value::as_str)
915 .and_then(|key| table.get("model_providers")?.as_table()?.get(key))
916 .and_then(toml::Value::as_table)
917 .and_then(|provider| provider.get("base_url"))
918 .and_then(toml::Value::as_str)
919 .map(str::trim)
920 .filter(|url| !url.is_empty())
921 .map(str::to_string);
922
923 Ok(selected_url.or_else(|| {
924 table
925 .get("base_url")
926 .and_then(toml::Value::as_str)
927 .map(str::trim)
928 .filter(|url| !url.is_empty())
929 .map(str::to_string)
930 }))
931}
932
933pub fn clean_codex_provider_key(raw: &str) -> String {
938 let mut key: String = raw
939 .chars()
940 .map(|c| {
941 if c.is_ascii_alphanumeric() {
942 c.to_ascii_lowercase()
943 } else {
944 '_'
945 }
946 })
947 .collect();
948
949 while key.starts_with('_') {
950 key.remove(0);
951 }
952 while key.ends_with('_') {
953 key.pop();
954 }
955
956 if key.is_empty() {
957 "custom".to_string()
958 } else {
959 key
960 }
961}
962
963pub fn clean_codex_provider_display_key(raw: &str) -> String {
964 let mut key: String = raw
965 .chars()
966 .map(|c| {
967 if c.is_ascii_alphanumeric() {
968 c.to_ascii_lowercase()
969 } else if c == '-' || c == '_' {
970 c
971 } else {
972 '_'
973 }
974 })
975 .collect();
976
977 while key.starts_with(['-', '_']) {
978 key.remove(0);
979 }
980 while key.ends_with(['-', '_']) {
981 key.pop();
982 }
983
984 if key.is_empty() {
985 "custom".to_string()
986 } else {
987 key
988 }
989}
990
991#[cfg(test)]
992mod tests {
993 use super::*;
994
995 #[test]
996 fn merge_preserves_user_preferences_and_mcp_from_live() {
997 let live = indoc::indoc! {r#"
999 model_provider = "old-provider"
1000 model = "old-model"
1001 disable_response_storage = true
1002 model_reasoning_effort = "xhigh"
1003 approval_mode = "auto-edit"
1004 check_for_update_on_startup = false
1005
1006 [model_providers.old-provider]
1007 name = "Old"
1008
1009 [projects."/tmp/work"]
1010 trusted = true
1011
1012 [mcp_servers.cargo-mcp]
1013 type = "stdio"
1014 "#};
1015
1016 let snapshot = indoc::indoc! {r#"
1018 model_provider = "new-provider"
1019 model = "new-model"
1020 approval_mode = "suggest"
1021
1022 [model_providers.new-provider]
1023 name = "New"
1024 api_key = "sk-test"
1025
1026 [projects."/tmp/other"]
1027 trusted = true
1028 "#};
1029
1030 let merged = merge_provider_into_codex_live_config(live, snapshot, true).unwrap();
1031 let doc: toml_edit::DocumentMut = merged.parse().unwrap();
1032
1033 assert_eq!(doc["model_provider"].as_str(), Some("new-provider"));
1035 assert_eq!(doc["model"].as_str(), Some("new-model"));
1036 assert!(doc
1037 .get("model_providers")
1038 .unwrap()
1039 .get("new-provider")
1040 .is_some());
1041 assert!(doc
1042 .get("model_providers")
1043 .unwrap()
1044 .get("old-provider")
1045 .is_none());
1046
1047 assert_eq!(doc["disable_response_storage"].as_bool(), Some(true));
1049 assert_eq!(doc["model_reasoning_effort"].as_str(), Some("xhigh"));
1050 assert_eq!(doc["approval_mode"].as_str(), Some("auto-edit"));
1051 assert_eq!(doc["check_for_update_on_startup"].as_bool(), Some(false));
1052
1053 assert!(doc.get("projects").unwrap().get("/tmp/work").is_some());
1055 assert!(doc.get("projects").unwrap().get("/tmp/other").is_none());
1056
1057 assert!(doc.get("mcp_servers").is_some());
1059 assert!(doc.get("mcp_servers").unwrap().get("cargo-mcp").is_some());
1060 }
1061
1062 #[test]
1063 fn merge_preserves_live_runtime_projects_from_provider_snapshot() {
1064 let live = indoc::indoc! {r#"
1065 model_provider = "old-provider"
1066 trusted_workspaces = ["/tmp/live-workspace"]
1067
1068 [model_providers.old-provider]
1069 name = "Old"
1070
1071 [projects."/tmp/live-project"]
1072 trust_level = "trusted"
1073 "#};
1074
1075 let snapshot = indoc::indoc! {r#"
1076 model_provider = "new-provider"
1077 trusted_workspaces = ["/tmp/snapshot-workspace"]
1078
1079 [model_providers.new-provider]
1080 name = "New"
1081
1082 [projects."/tmp/snapshot-project"]
1083 trust_level = "trusted"
1084 "#};
1085
1086 let merged = merge_provider_into_codex_live_config(live, snapshot, true).unwrap();
1087 let doc: toml_edit::DocumentMut = merged.parse().unwrap();
1088 let projects = doc.get("projects").expect("projects should be preserved");
1089 let trusted_workspaces = doc["trusted_workspaces"]
1090 .as_array()
1091 .expect("trusted_workspaces should be preserved");
1092
1093 assert!(projects.get("/tmp/live-project").is_some());
1094 assert!(projects.get("/tmp/snapshot-project").is_none());
1095 assert_eq!(trusted_workspaces.len(), 1);
1096 assert_eq!(
1097 trusted_workspaces.get(0).and_then(|value| value.as_str()),
1098 Some("/tmp/live-workspace")
1099 );
1100 }
1101
1102 #[test]
1103 fn merge_keeps_commented_out_mcp_entries_verbatim() {
1104 let live = "\
1109model_provider = \"old\"
1110approval_mode = \"suggest\"
1111
1112# top-level note for mcp_servers section
1113[mcp_servers.active]
1114# comment before command
1115command = \"runme\" # trailing comment
1116
1117# this one is temporarily disabled
1118# [mcp_servers.disabled]
1119# command = \"nope\"
1120# args = [\"--off\"]
1121";
1122
1123 let snapshot = "\
1124model_provider = \"new\"
1125
1126[model_providers.new]
1127name = \"New\"
1128";
1129
1130 let merged = merge_provider_into_codex_live_config(live, snapshot, true).unwrap();
1131
1132 for needle in [
1134 "# top-level note for mcp_servers section",
1135 "# comment before command",
1136 "# trailing comment",
1137 "# this one is temporarily disabled",
1138 "# [mcp_servers.disabled]",
1139 "# command = \"nope\"",
1140 "# args = [\"--off\"]",
1141 ] {
1142 assert!(
1143 merged.contains(needle),
1144 "merged output is missing comment line: {needle:?}\n--- merged ---\n{merged}"
1145 );
1146 }
1147
1148 let doc: toml_edit::DocumentMut = merged.parse().unwrap();
1150 assert_eq!(doc["model_provider"].as_str(), Some("new"));
1151 assert!(doc
1152 .get("mcp_servers")
1153 .and_then(|t| t.get("active"))
1154 .is_some());
1155 assert!(doc
1156 .get("mcp_servers")
1157 .and_then(|t| t.get("disabled"))
1158 .is_none());
1159 }
1160
1161 #[test]
1162 fn merge_seeds_preferences_from_snapshot_when_live_is_empty() {
1163 let snapshot = indoc::indoc! {r#"
1168 model_provider = "first"
1169 model = "gpt-5.2-codex"
1170 disable_response_storage = true
1171 approval_mode = "suggest"
1172
1173 [model_providers.first]
1174 base_url = "https://api.example/v1"
1175 "#};
1176
1177 let merged = merge_provider_into_codex_live_config("", snapshot, true).unwrap();
1178 assert!(
1179 merged.contains("disable_response_storage = true"),
1180 "snapshot-provided preference should seed an empty live config\n--- merged ---\n{merged}"
1181 );
1182 assert!(merged.contains("approval_mode = \"suggest\""));
1183 assert!(merged.contains("model_provider = \"first\""));
1184 }
1185
1186 #[test]
1187 fn merge_with_preserve_false_drops_live_prefs_missing_from_snapshot() {
1188 let live = indoc::indoc! {r#"
1191 model_provider = "p1"
1192 disable_response_storage = true
1193 model_reasoning_effort = "xhigh"
1194
1195 [model_providers.p1]
1196 base_url = "https://a"
1197 "#};
1198
1199 let snapshot = indoc::indoc! {r#"
1200 model_provider = "p1"
1201
1202 [model_providers.p1]
1203 base_url = "https://a"
1204 "#};
1205
1206 let merged = merge_provider_into_codex_live_config(live, snapshot, false).unwrap();
1207 let doc: toml_edit::DocumentMut = merged.parse().unwrap();
1208 assert!(doc.get("disable_response_storage").is_none());
1209 assert!(doc.get("model_reasoning_effort").is_none());
1210 assert_eq!(doc["model_provider"].as_str(), Some("p1"));
1211 }
1212
1213 #[test]
1214 fn merge_keeps_unknown_root_keys_from_live_on_switch() {
1215 let live = indoc::indoc! {r#"
1221 model_provider = "old"
1222 sandbox_mode = "danger-full-access"
1223 verbose_logging = true
1224
1225 [model_providers.old]
1226 name = "Old"
1227 "#};
1228
1229 let snapshot = indoc::indoc! {r#"
1233 model_provider = "new"
1234
1235 [model_providers.new]
1236 name = "New"
1237 "#};
1238
1239 let merged = merge_provider_into_codex_live_config(live, snapshot, true).unwrap();
1240 let doc: toml_edit::DocumentMut = merged.parse().unwrap();
1241
1242 assert_eq!(doc["model_provider"].as_str(), Some("new"));
1244 assert!(doc
1245 .get("model_providers")
1246 .and_then(|t| t.get("new"))
1247 .is_some());
1248 assert!(doc
1249 .get("model_providers")
1250 .and_then(|t| t.get("old"))
1251 .is_none());
1252
1253 assert_eq!(doc["sandbox_mode"].as_str(), Some("danger-full-access"));
1255 assert_eq!(doc["verbose_logging"].as_bool(), Some(true));
1256 }
1257
1258 #[test]
1259 fn merge_keeps_root_level_comments_around_overwritten_keys() {
1260 let live = "\
1266# pinned by me — do not change without checking the runbook
1267model_provider = \"old\"
1268
1269# disabled while debugging issue-1234
1270# disable_response_storage = true
1271approval_mode = \"auto-edit\"
1272
1273# trailing footnote at EOF
1274";
1275
1276 let snapshot = "\
1277model_provider = \"new\"
1278
1279[model_providers.new]
1280name = \"New\"
1281";
1282
1283 let merged = merge_provider_into_codex_live_config(live, snapshot, true).unwrap();
1284
1285 for needle in [
1286 "# pinned by me — do not change without checking the runbook",
1287 "# disabled while debugging issue-1234",
1288 "# disable_response_storage = true",
1289 "# trailing footnote at EOF",
1290 ] {
1291 assert!(
1292 merged.contains(needle),
1293 "merged output is missing comment line: {needle:?}\n--- merged ---\n{merged}"
1294 );
1295 }
1296
1297 let doc: toml_edit::DocumentMut = merged.parse().unwrap();
1298 assert_eq!(doc["model_provider"].as_str(), Some("new"));
1299 assert_eq!(doc["approval_mode"].as_str(), Some("auto-edit"));
1300 }
1301
1302 #[test]
1303 fn merge_omits_mcp_section_when_live_has_none() {
1304 let live = indoc::indoc! {r#"
1305 model_provider = "foo"
1306 approval_mode = "suggest"
1307 "#};
1308
1309 let snapshot = indoc::indoc! {r#"
1310 model_provider = "bar"
1311 "#};
1312
1313 let merged = merge_provider_into_codex_live_config(live, snapshot, true).unwrap();
1314 let doc: toml_edit::DocumentMut = merged.parse().unwrap();
1315
1316 assert_eq!(doc["model_provider"].as_str(), Some("bar"));
1317 assert!(doc.get("mcp_servers").is_none());
1318 assert_eq!(doc["approval_mode"].as_str(), Some("suggest"));
1319 }
1320 use crate::app_config::AppType;
1321 use crate::test_support::{lock_test_home_and_settings, set_test_home_override};
1322 use std::ffi::OsString;
1323 use std::path::Path;
1324
1325 struct CodexHomeEnvGuard {
1326 original: Option<OsString>,
1327 }
1328
1329 impl CodexHomeEnvGuard {
1330 fn new(value: Option<&str>) -> Self {
1331 let original = std::env::var_os("CODEX_HOME");
1332 match value {
1333 Some(v) => unsafe { std::env::set_var("CODEX_HOME", v) },
1334 None => unsafe { std::env::remove_var("CODEX_HOME") },
1335 }
1336 Self { original }
1337 }
1338 }
1339
1340 impl Drop for CodexHomeEnvGuard {
1341 fn drop(&mut self) {
1342 match self.original.as_ref() {
1343 Some(value) => unsafe { std::env::set_var("CODEX_HOME", value) },
1344 None => unsafe { std::env::remove_var("CODEX_HOME") },
1345 }
1346 }
1347 }
1348
1349 struct SettingsGuard {
1350 original: crate::settings::AppSettings,
1351 }
1352
1353 impl SettingsGuard {
1354 fn with_codex_config_dir(dir: Option<&str>) -> Self {
1355 let original = crate::settings::get_settings();
1356 let mut settings = original.clone();
1357 settings.codex_config_dir = dir.map(str::to_string);
1358 crate::settings::update_settings(settings).unwrap();
1359 Self { original }
1360 }
1361 }
1362
1363 impl Drop for SettingsGuard {
1364 fn drop(&mut self) {
1365 let _ = crate::settings::update_settings(self.original.clone());
1366 }
1367 }
1368
1369 #[test]
1370 fn get_codex_config_dir_respects_codex_home_env_var_and_tilde() {
1371 let _guard = lock_test_home_and_settings();
1372 let _settings = SettingsGuard::with_codex_config_dir(None);
1373 let _env = CodexHomeEnvGuard::new(Some("~/.config/codex"));
1374 set_test_home_override(Some(Path::new("/tmp/codex-home-tilde")));
1375
1376 assert_eq!(
1377 get_codex_config_dir(),
1378 PathBuf::from("/tmp/codex-home-tilde")
1379 .join(".config")
1380 .join("codex")
1381 );
1382
1383 set_test_home_override(None);
1384 }
1385
1386 #[test]
1387 fn get_codex_config_dir_env_overrides_settings_override() {
1388 let _guard = lock_test_home_and_settings();
1389 let _settings = SettingsGuard::with_codex_config_dir(Some("/tmp/settings-codex"));
1390 let _env = CodexHomeEnvGuard::new(Some("/tmp/env-codex"));
1391 set_test_home_override(Some(Path::new("/tmp/codex-home")));
1392
1393 assert_eq!(get_codex_config_dir(), PathBuf::from("/tmp/env-codex"));
1394
1395 set_test_home_override(None);
1396 }
1397
1398 #[test]
1399 fn codex_live_sync_detects_initialized_codex_home_from_env() {
1400 let _guard = lock_test_home_and_settings();
1401 let _settings = SettingsGuard::with_codex_config_dir(None);
1402 let env_home = PathBuf::from("/tmp/codex-live-sync-env");
1403 let _env = CodexHomeEnvGuard::new(Some(env_home.to_str().unwrap()));
1404 set_test_home_override(Some(Path::new("/tmp/codex-live-sync-home")));
1405
1406 std::fs::create_dir_all(&env_home).expect("create CODEX_HOME");
1407
1408 assert!(crate::sync_policy::should_sync_live(&AppType::Codex));
1409
1410 let _ = std::fs::remove_dir_all(&env_home);
1411 set_test_home_override(None);
1412 }
1413
1414 #[test]
1415 fn normalize_live_config_preserves_current_custom_model_provider_id() {
1416 let current = r#"model_provider = "rightcode"
1417
1418[model_providers.rightcode]
1419name = "RightCode"
1420base_url = "https://rightcode.example/v1"
1421wire_api = "responses"
1422"#;
1423 let target = r#"model_provider = "aihubmix"
1424model = "gpt-5.4"
1425
1426[model_providers.aihubmix]
1427name = "AiHubMix"
1428base_url = "https://aihubmix.example/v1"
1429wire_api = "responses"
1430requires_openai_auth = true
1431
1432[mcp_servers.context7]
1433command = "npx"
1434"#;
1435
1436 let result =
1437 normalize_codex_live_config_model_provider_with_anchors(target, Some(current)).unwrap();
1438 let parsed: toml::Value = toml::from_str(&result).unwrap();
1439
1440 assert_eq!(
1441 parsed.get("model_provider").and_then(|v| v.as_str()),
1442 Some("rightcode")
1443 );
1444
1445 let model_providers = parsed
1446 .get("model_providers")
1447 .and_then(|v| v.as_table())
1448 .expect("model_providers should exist");
1449 assert!(
1450 model_providers.get("aihubmix").is_none(),
1451 "source provider id should not remain in live config"
1452 );
1453
1454 let stable_provider = model_providers
1455 .get("rightcode")
1456 .expect("stable provider table should exist");
1457 assert_eq!(
1458 stable_provider.get("base_url").and_then(|v| v.as_str()),
1459 Some("https://aihubmix.example/v1")
1460 );
1461 assert!(
1462 parsed.get("mcp_servers").is_some(),
1463 "unrelated config should be preserved"
1464 );
1465 }
1466
1467 #[test]
1468 fn normalize_live_config_uses_target_custom_provider_when_current_is_reserved() {
1469 let current = r#"model_provider = "openai""#;
1470 let target = r#"model_provider = "aihubmix"
1471
1472[model_providers.aihubmix]
1473name = "AiHubMix"
1474base_url = "https://aihubmix.example/v1"
1475wire_api = "responses"
1476"#;
1477
1478 let result =
1479 normalize_codex_live_config_model_provider_with_anchors(target, Some(current)).unwrap();
1480 let parsed: toml::Value = toml::from_str(&result).unwrap();
1481
1482 assert_eq!(
1483 parsed.get("model_provider").and_then(|v| v.as_str()),
1484 Some("aihubmix")
1485 );
1486 assert!(
1487 parsed
1488 .get("model_providers")
1489 .and_then(|v| v.get("aihubmix"))
1490 .is_some(),
1491 "target provider id should be kept when there is no reusable live custom id"
1492 );
1493 }
1494
1495 #[test]
1496 fn normalize_live_config_leaves_official_empty_config_unchanged() {
1497 let current = r#"model_provider = "rightcode"
1498
1499[model_providers.rightcode]
1500base_url = "https://rightcode.example/v1"
1501"#;
1502
1503 let result =
1504 normalize_codex_live_config_model_provider_with_anchors("", Some(current)).unwrap();
1505
1506 assert_eq!(result, "");
1507 }
1508
1509 #[test]
1510 fn normalize_live_config_rewrites_matching_profile_model_provider_refs() {
1511 let current = r#"model_provider = "session_anchor"
1512
1513[model_providers.session_anchor]
1514name = "Session Anchor"
1515base_url = "https://anchor.example/v1"
1516wire_api = "responses"
1517"#;
1518 let target = r#"model_provider = "vendor_alpha"
1519model = "gpt-5.4"
1520profile = "work"
1521
1522[model_providers.vendor_alpha]
1523name = "Vendor Alpha"
1524base_url = "https://alpha.example/v1"
1525wire_api = "responses"
1526
1527[profiles.work]
1528model_provider = "vendor_alpha"
1529model = "gpt-5.4"
1530"#;
1531
1532 let result =
1533 normalize_codex_live_config_model_provider_with_anchors(target, Some(current)).unwrap();
1534 let parsed: toml::Value = toml::from_str(&result).unwrap();
1535
1536 assert_eq!(
1537 parsed.get("model_provider").and_then(|v| v.as_str()),
1538 Some("session_anchor")
1539 );
1540 assert_eq!(
1541 parsed
1542 .get("profiles")
1543 .and_then(|v| v.get("work"))
1544 .and_then(|v| v.get("model_provider"))
1545 .and_then(|v| v.as_str()),
1546 Some("session_anchor"),
1547 "profile override matching the rewritten provider should stay valid"
1548 );
1549 }
1550
1551 #[test]
1552 fn normalize_live_config_keeps_unrelated_profile_model_provider_refs() {
1553 let current = r#"model_provider = "session_anchor"
1554
1555[model_providers.session_anchor]
1556name = "Session Anchor"
1557base_url = "https://anchor.example/v1"
1558wire_api = "responses"
1559"#;
1560 let target = r#"model_provider = "vendor_alpha"
1561model = "gpt-5.4"
1562
1563[model_providers.vendor_alpha]
1564name = "Vendor Alpha"
1565base_url = "https://alpha.example/v1"
1566wire_api = "responses"
1567
1568[model_providers.local_profile]
1569name = "Local Profile"
1570base_url = "http://localhost:11434/v1"
1571wire_api = "responses"
1572
1573[profiles.local]
1574model_provider = "local_profile"
1575model = "local-model"
1576"#;
1577
1578 let result =
1579 normalize_codex_live_config_model_provider_with_anchors(target, Some(current)).unwrap();
1580 let parsed: toml::Value = toml::from_str(&result).unwrap();
1581
1582 assert_eq!(
1583 parsed
1584 .get("profiles")
1585 .and_then(|v| v.get("local"))
1586 .and_then(|v| v.get("model_provider"))
1587 .and_then(|v| v.as_str()),
1588 Some("local_profile"),
1589 "unrelated profile provider references should be preserved"
1590 );
1591 assert!(
1592 parsed
1593 .get("model_providers")
1594 .and_then(|v| v.get("local_profile"))
1595 .is_some(),
1596 "unrelated provider tables should also remain available"
1597 );
1598 }
1599
1600 #[test]
1601 fn normalize_live_config_keeps_stable_provider_across_repeated_switches() {
1602 let anchor = r#"model_provider = "session_anchor"
1603
1604[model_providers.session_anchor]
1605name = "Session Anchor"
1606base_url = "https://anchor.example/v1"
1607wire_api = "responses"
1608"#;
1609 let first_target = r#"model_provider = "vendor_alpha"
1610
1611[model_providers.vendor_alpha]
1612name = "Vendor Alpha"
1613base_url = "https://alpha.example/v1"
1614wire_api = "responses"
1615"#;
1616 let second_target = r#"model_provider = "vendor_beta"
1617
1618[model_providers.vendor_beta]
1619name = "Vendor Beta"
1620base_url = "https://beta.example/v1"
1621wire_api = "responses"
1622"#;
1623
1624 let first =
1625 normalize_codex_live_config_model_provider_with_anchors(first_target, Some(anchor))
1626 .unwrap();
1627 let second = normalize_codex_live_config_model_provider_with_anchors(
1628 second_target,
1629 Some(first.as_str()),
1630 )
1631 .unwrap();
1632 let parsed: toml::Value = toml::from_str(&second).unwrap();
1633
1634 assert_eq!(
1635 parsed.get("model_provider").and_then(|v| v.as_str()),
1636 Some("session_anchor"),
1637 "stable provider id should not drift across repeated switches"
1638 );
1639 assert_eq!(
1640 parsed
1641 .get("model_providers")
1642 .and_then(|v| v.get("session_anchor"))
1643 .and_then(|v| v.get("base_url"))
1644 .and_then(|v| v.as_str()),
1645 Some("https://beta.example/v1")
1646 );
1647 }
1648
1649 #[test]
1650 fn restore_backfill_config_rewrites_live_id_to_template_provider_id() {
1651 let mut settings = serde_json::json!({
1652 "config": r#"model_provider = "session_anchor"
1653model = "gpt-5.4"
1654profile = "work"
1655
1656[model_providers.session_anchor]
1657name = "AiHubMix"
1658base_url = "https://aihubmix.example/v1"
1659wire_api = "responses"
1660
1661[profiles.work]
1662model_provider = "session_anchor"
1663model = "gpt-5.4"
1664"#
1665 });
1666 let template = serde_json::json!({
1667 "config": r#"model_provider = "aihubmix"
1668
1669[model_providers.aihubmix]
1670name = "AiHubMix"
1671base_url = "https://aihubmix.example/v1"
1672"#
1673 });
1674
1675 restore_codex_settings_config_model_provider_for_backfill(&mut settings, &template)
1676 .unwrap();
1677
1678 let config = codex_config_text_from_settings(&settings).unwrap();
1679 let parsed: toml::Value = toml::from_str(&config).unwrap();
1680 assert_eq!(
1681 parsed.get("model_provider").and_then(|v| v.as_str()),
1682 Some("aihubmix")
1683 );
1684 assert!(parsed
1685 .get("model_providers")
1686 .and_then(|v| v.get("aihubmix"))
1687 .is_some());
1688 assert_eq!(
1689 parsed
1690 .get("profiles")
1691 .and_then(|v| v.get("work"))
1692 .and_then(|v| v.get("model_provider"))
1693 .and_then(|v| v.as_str()),
1694 Some("aihubmix")
1695 );
1696 }
1697
1698 #[test]
1699 fn clean_codex_provider_display_key_preserves_hyphens() {
1700 assert_eq!(
1701 clean_codex_provider_display_key("zhima-cx-pro"),
1702 "zhima-cx-pro"
1703 );
1704 }
1705}