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::Value;
9use std::fs;
10use std::path::Path;
11use toml_edit::DocumentMut;
12
13pub const CC_SWITCH_CODEX_MODEL_PROVIDER_ID: &str = "ccswitch";
14
15const CODEX_RESERVED_MODEL_PROVIDER_IDS: &[&str] = &[
19 "amazon-bedrock",
20 "openai",
21 "ollama",
22 "lmstudio",
23 "oss",
24 "ollama-chat",
25];
26
27pub fn get_codex_config_dir() -> PathBuf {
31 if let Some(dir) = std::env::var_os("CODEX_HOME") {
32 let dir = PathBuf::from(dir);
33 if !dir.as_os_str().is_empty() && !dir.to_string_lossy().trim().is_empty() {
34 return expand_codex_config_dir(dir);
35 }
36 }
37
38 if let Some(custom) = crate::settings::get_codex_override_dir() {
39 return custom;
40 }
41
42 home_dir().expect("无法获取用户主目录").join(".codex")
43}
44
45fn expand_codex_config_dir(path: PathBuf) -> PathBuf {
46 let lossy = path.to_string_lossy();
47 if lossy == "~" {
48 return home_dir().unwrap_or(path);
49 }
50
51 if let Some(rest) = lossy
52 .strip_prefix("~/")
53 .or_else(|| lossy.strip_prefix("~\\"))
54 {
55 if let Some(home) = home_dir() {
56 return home.join(rest);
57 }
58 }
59
60 path
61}
62
63pub fn get_codex_auth_path() -> PathBuf {
65 get_codex_config_dir().join("auth.json")
66}
67
68pub fn get_codex_config_path() -> PathBuf {
70 get_codex_config_dir().join("config.toml")
71}
72
73pub fn get_codex_provider_paths(
75 provider_id: &str,
76 provider_name: Option<&str>,
77) -> (PathBuf, PathBuf) {
78 let base_name = provider_name
79 .map(sanitize_provider_name)
80 .unwrap_or_else(|| sanitize_provider_name(provider_id));
81
82 let auth_path = get_codex_config_dir().join(format!("auth-{base_name}.json"));
83 let config_path = get_codex_config_dir().join(format!("config-{base_name}.toml"));
84
85 (auth_path, config_path)
86}
87
88pub fn delete_codex_provider_config(
90 provider_id: &str,
91 provider_name: &str,
92) -> Result<(), AppError> {
93 let (auth_path, config_path) = get_codex_provider_paths(provider_id, Some(provider_name));
94
95 delete_file(&auth_path).ok();
96 delete_file(&config_path).ok();
97
98 Ok(())
99}
100
101pub fn write_codex_live_atomic(
103 auth: &Value,
104 config_text_opt: Option<&str>,
105) -> Result<(), AppError> {
106 write_codex_live_atomic_optional_auth(Some(auth), config_text_opt)
107}
108
109pub fn write_codex_live_atomic_optional_auth(
110 auth: Option<&Value>,
111 config_text_opt: Option<&str>,
112) -> Result<(), AppError> {
113 let auth_path = get_codex_auth_path();
114 let config_path = get_codex_config_path();
115
116 if let Some(parent) = auth_path.parent() {
117 std::fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
118 }
119
120 let old_auth = if auth_path.exists() {
122 Some(fs::read(&auth_path).map_err(|e| AppError::io(&auth_path, e))?)
123 } else {
124 None
125 };
126 let _old_config = if config_path.exists() {
127 Some(fs::read(&config_path).map_err(|e| AppError::io(&config_path, e))?)
128 } else {
129 None
130 };
131
132 let cfg_text = match config_text_opt {
134 Some(s) => s.to_string(),
135 None => String::new(),
136 };
137 if !cfg_text.trim().is_empty() {
138 toml::from_str::<toml::Table>(&cfg_text).map_err(|e| AppError::toml(&config_path, e))?;
139 }
140
141 if let Some(auth) = auth {
143 write_json_file(&auth_path, auth)?;
144 } else {
145 delete_file(&auth_path)?;
146 }
147
148 if let Err(e) = write_text_file(&config_path, &cfg_text) {
150 if let Some(bytes) = old_auth {
152 let _ = atomic_write(&auth_path, &bytes);
153 } else {
154 let _ = delete_file(&auth_path);
155 }
156 return Err(e);
157 }
158
159 Ok(())
160}
161
162pub fn read_codex_config_text() -> Result<String, AppError> {
164 let path = get_codex_config_path();
165 if path.exists() {
166 std::fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))
167 } else {
168 Ok(String::new())
169 }
170}
171
172pub fn validate_config_toml(text: &str) -> Result<(), AppError> {
174 if text.trim().is_empty() {
175 return Ok(());
176 }
177 toml::from_str::<toml::Table>(text)
178 .map(|_| ())
179 .map_err(|e| AppError::toml(Path::new("config.toml"), e))
180}
181
182pub fn strip_codex_provider_config_text(config_toml: &str) -> Result<String, AppError> {
188 let config_toml = config_toml.trim();
189 if config_toml.is_empty() {
190 return Ok(String::new());
191 }
192
193 let mut doc = config_toml
194 .parse::<toml_edit::DocumentMut>()
195 .map_err(|e| AppError::Config(format!("TOML parse error: {e}")))?;
196 let root = doc.as_table_mut();
197 root.remove("model");
198 root.remove("model_provider");
199 root.remove("base_url");
200 root.remove("model_providers");
201
202 let mut cleaned = String::new();
203 let mut blank_run = 0usize;
204 for line in doc.to_string().lines() {
205 if line.trim().is_empty() {
206 blank_run += 1;
207 if blank_run <= 1 {
208 cleaned.push('\n');
209 }
210 continue;
211 }
212 blank_run = 0;
213 cleaned.push_str(line);
214 cleaned.push('\n');
215 }
216
217 Ok(cleaned.trim().to_string())
218}
219
220pub fn read_and_validate_codex_config_text() -> Result<String, AppError> {
222 let s = read_codex_config_text()?;
223 validate_config_toml(&s)?;
224 Ok(s)
225}
226
227fn active_codex_model_provider_id(doc: &DocumentMut) -> Option<String> {
228 doc.get("model_provider")
229 .and_then(|item| item.as_str())
230 .map(str::trim)
231 .filter(|id| !id.is_empty())
232 .map(str::to_string)
233}
234
235fn is_custom_codex_model_provider_id(id: &str) -> bool {
236 let id = id.trim();
237 !id.is_empty()
238 && !CODEX_RESERVED_MODEL_PROVIDER_IDS
239 .iter()
240 .any(|reserved| reserved.eq_ignore_ascii_case(id))
241}
242
243fn stable_codex_model_provider_id_from_config(config_text: &str) -> Option<String> {
244 let doc = config_text.parse::<DocumentMut>().ok()?;
245 let provider_id = active_codex_model_provider_id(&doc)?;
246
247 if is_custom_codex_model_provider_id(&provider_id) {
248 Some(provider_id)
249 } else {
250 None
251 }
252}
253
254fn codex_model_provider_id_with_table_from_config(
255 config_text: &str,
256) -> Result<Option<String>, AppError> {
257 if config_text.trim().is_empty() {
258 return Ok(None);
259 }
260
261 let doc = config_text
262 .parse::<DocumentMut>()
263 .map_err(|e| AppError::Message(format!("Invalid Codex config.toml: {e}")))?;
264 let Some(provider_id) = active_codex_model_provider_id(&doc) else {
265 return Ok(None);
266 };
267
268 let has_provider_table = doc
269 .get("model_providers")
270 .and_then(|item| item.as_table())
271 .and_then(|table| table.get(provider_id.as_str()))
272 .is_some();
273
274 Ok(has_provider_table.then_some(provider_id))
275}
276
277fn primary_codex_model_provider_id_with_table(doc: &DocumentMut) -> Option<String> {
278 if let Some(provider_id) = active_codex_model_provider_id(doc) {
279 let has_provider_table = doc
280 .get("model_providers")
281 .and_then(|item| item.as_table_like())
282 .and_then(|table| table.get(provider_id.as_str()))
283 .is_some();
284 if has_provider_table {
285 return Some(provider_id);
286 }
287 }
288
289 let providers = doc
290 .get("model_providers")
291 .and_then(|item| item.as_table_like())?;
292 let mut keys = providers.iter().map(|(key, _)| key.to_string());
293 let first = keys.next()?;
294 keys.next().is_none().then_some(first)
295}
296
297fn normalize_codex_live_config_model_provider_with_anchors<'a>(
298 config_text: &str,
299 anchor_config_texts: impl IntoIterator<Item = &'a str>,
300) -> Result<String, AppError> {
301 if config_text.trim().is_empty() {
302 return Ok(config_text.to_string());
303 }
304
305 let mut doc = config_text
306 .parse::<DocumentMut>()
307 .map_err(|e| AppError::Message(format!("Invalid Codex config.toml: {e}")))?;
308
309 let Some(source_provider_id) = active_codex_model_provider_id(&doc) else {
310 return Ok(config_text.to_string());
311 };
312
313 let has_source_provider_table = doc
314 .get("model_providers")
315 .and_then(|item| item.as_table())
316 .and_then(|table| table.get(source_provider_id.as_str()))
317 .is_some();
318 if !has_source_provider_table {
319 return Ok(config_text.to_string());
320 }
321
322 let stable_provider_id = anchor_config_texts
323 .into_iter()
324 .find_map(stable_codex_model_provider_id_from_config)
325 .or_else(|| {
326 is_custom_codex_model_provider_id(&source_provider_id)
327 .then(|| source_provider_id.clone())
328 })
329 .unwrap_or_else(|| CC_SWITCH_CODEX_MODEL_PROVIDER_ID.to_string());
330
331 if stable_provider_id == source_provider_id {
332 return Ok(config_text.to_string());
333 }
334
335 if let Some(model_providers) = doc
336 .get_mut("model_providers")
337 .and_then(|item| item.as_table_mut())
338 {
339 let Some(provider_table) = model_providers.remove(source_provider_id.as_str()) else {
340 return Ok(config_text.to_string());
341 };
342 model_providers[stable_provider_id.as_str()] = provider_table;
343 }
344
345 rewrite_codex_profile_model_provider_refs(&mut doc, &source_provider_id, &stable_provider_id);
346 doc["model_provider"] = toml_edit::value(stable_provider_id.as_str());
347
348 Ok(doc.to_string())
349}
350
351fn rewrite_codex_profile_model_provider_refs(
352 doc: &mut DocumentMut,
353 source_provider_id: &str,
354 stable_provider_id: &str,
355) {
356 let Some(profiles) = doc
357 .get_mut("profiles")
358 .and_then(|item| item.as_table_like_mut())
359 else {
360 return;
361 };
362
363 let profile_keys: Vec<String> = profiles.iter().map(|(key, _)| key.to_string()).collect();
364 for profile_key in profile_keys {
365 let Some(profile_table) = profiles
366 .get_mut(&profile_key)
367 .and_then(|item| item.as_table_like_mut())
368 else {
369 continue;
370 };
371
372 let references_source = profile_table
373 .get("model_provider")
374 .and_then(|item| item.as_str())
375 == Some(source_provider_id);
376 if references_source {
377 profile_table.insert("model_provider", toml_edit::value(stable_provider_id));
378 }
379 }
380}
381
382pub fn normalize_codex_settings_config_model_provider(
389 settings: &mut Value,
390 anchor_config_text: Option<&str>,
391) -> Result<(), AppError> {
392 let Some(config_text) = settings
393 .get("config")
394 .and_then(|value| value.as_str())
395 .map(str::to_string)
396 else {
397 return Ok(());
398 };
399
400 let current_config_text = read_codex_config_text().ok();
401 let anchors = anchor_config_text
402 .into_iter()
403 .chain(current_config_text.as_deref());
404 let normalized =
405 normalize_codex_live_config_model_provider_with_anchors(&config_text, anchors)?;
406
407 if let Some(obj) = settings.as_object_mut() {
408 obj.insert("config".to_string(), Value::String(normalized));
409 }
410
411 Ok(())
412}
413
414fn restore_codex_backfill_model_provider_id(
415 config_text: &str,
416 template_config_text: &str,
417) -> Result<String, AppError> {
418 let Some(template_provider_id) =
419 codex_model_provider_id_with_table_from_config(template_config_text)?
420 else {
421 return Ok(config_text.to_string());
422 };
423
424 if config_text.trim().is_empty() {
425 return Ok(config_text.to_string());
426 }
427
428 let mut doc = config_text
429 .parse::<DocumentMut>()
430 .map_err(|e| AppError::Message(format!("Invalid Codex config.toml: {e}")))?;
431 let Some(live_provider_id) = active_codex_model_provider_id(&doc) else {
432 return Ok(config_text.to_string());
433 };
434
435 if live_provider_id == template_provider_id {
436 return Ok(config_text.to_string());
437 }
438
439 if let Some(model_providers) = doc
440 .get_mut("model_providers")
441 .and_then(|item| item.as_table_mut())
442 {
443 let Some(provider_table) = model_providers.remove(live_provider_id.as_str()) else {
444 return Ok(config_text.to_string());
445 };
446 model_providers[template_provider_id.as_str()] = provider_table;
447 } else {
448 return Ok(config_text.to_string());
449 }
450
451 rewrite_codex_profile_model_provider_refs(&mut doc, &live_provider_id, &template_provider_id);
452 doc["model_provider"] = toml_edit::value(template_provider_id.as_str());
453
454 Ok(doc.to_string())
455}
456
457pub fn restore_codex_settings_config_model_provider_for_backfill(
460 settings: &mut Value,
461 template_settings: &Value,
462) -> Result<(), AppError> {
463 let Some(config_text) = settings
464 .get("config")
465 .and_then(|value| value.as_str())
466 .map(str::to_string)
467 else {
468 return Ok(());
469 };
470 let Some(template_config_text) = template_settings
471 .get("config")
472 .and_then(|value| value.as_str())
473 else {
474 return Ok(());
475 };
476
477 let restored = restore_codex_backfill_model_provider_id(&config_text, template_config_text)?;
478 if let Some(obj) = settings.as_object_mut() {
479 obj.insert("config".to_string(), Value::String(restored));
480 }
481
482 Ok(())
483}
484
485pub fn merge_provider_into_codex_live_config(
533 live_text: &str,
534 provider_snapshot: &str,
535 preserve_user_preferences: bool,
536) -> Result<String, AppError> {
537 const PROVIDER_SCOPED_KEYS: &[&str] = &[
542 "model_provider",
543 "model",
544 "profile",
545 "model_providers",
546 "projects",
547 "profiles",
548 ];
549
550 let mut live = if live_text.trim().is_empty() {
551 toml_edit::DocumentMut::new()
552 } else {
553 live_text
554 .parse::<toml_edit::DocumentMut>()
555 .map_err(|e| AppError::Message(format!("Invalid Codex live config.toml: {e}")))?
556 };
557
558 let snap = if provider_snapshot.trim().is_empty() {
559 toml_edit::DocumentMut::new()
560 } else {
561 provider_snapshot
562 .parse::<toml_edit::DocumentMut>()
563 .map_err(|e| AppError::Message(format!("Invalid Codex provider snapshot: {e}")))?
564 };
565
566 let live_keys: Vec<String> = live.as_table().iter().map(|(k, _)| k.to_string()).collect();
577 for key in live_keys {
578 if key == "mcp_servers" {
579 continue;
580 }
581 if snap.get(&key).is_some() {
582 continue;
583 }
584 let is_provider_scoped = PROVIDER_SCOPED_KEYS.contains(&key.as_str());
585 if is_provider_scoped || !preserve_user_preferences {
586 live.as_table_mut().remove(&key);
587 }
588 }
589
590 let snap_keys: Vec<String> = snap.as_table().iter().map(|(k, _)| k.to_string()).collect();
599 for key in snap_keys {
600 if key == "mcp_servers" {
601 continue;
602 }
603 let is_provider_scoped = PROVIDER_SCOPED_KEYS.contains(&key.as_str());
604 if !is_provider_scoped && preserve_user_preferences && live.get(&key).is_some() {
605 continue;
606 }
607 if let Some(val) = snap.get(&key) {
608 live[key.as_str()] = val.clone();
609 }
610 }
611
612 Ok(live.to_string())
613}
614
615pub fn rewrite_codex_config_model_provider_key(
621 config_text: &str,
622 target_provider_id: &str,
623) -> Result<String, AppError> {
624 if config_text.trim().is_empty() {
625 return Ok(String::new());
626 }
627
628 let target_provider_id = clean_codex_provider_key(target_provider_id);
629 let mut doc = config_text
630 .parse::<DocumentMut>()
631 .map_err(|e| AppError::Message(format!("Invalid Codex config.toml: {e}")))?;
632 let Some(source_provider_id) = primary_codex_model_provider_id_with_table(&doc) else {
633 return Ok(config_text.to_string());
634 };
635
636 if let Some(model_providers) = doc
637 .get_mut("model_providers")
638 .and_then(|item| item.as_table_mut())
639 {
640 if source_provider_id != target_provider_id {
641 let Some(provider_table) = model_providers.remove(source_provider_id.as_str()) else {
642 return Ok(config_text.to_string());
643 };
644 model_providers[target_provider_id.as_str()] = provider_table;
645 rewrite_codex_profile_model_provider_refs(
646 &mut doc,
647 &source_provider_id,
648 &target_provider_id,
649 );
650 }
651 } else {
652 return Ok(config_text.to_string());
653 }
654
655 doc["model_provider"] = toml_edit::value(target_provider_id.as_str());
656
657 Ok(doc.to_string())
658}
659
660pub fn clean_codex_provider_key(raw: &str) -> String {
665 let mut key: String = raw
666 .chars()
667 .map(|c| {
668 if c.is_ascii_alphanumeric() {
669 c.to_ascii_lowercase()
670 } else {
671 '_'
672 }
673 })
674 .collect();
675
676 while key.starts_with('_') {
677 key.remove(0);
678 }
679 while key.ends_with('_') {
680 key.pop();
681 }
682
683 if key.is_empty() {
684 "custom".to_string()
685 } else {
686 key
687 }
688}
689
690#[cfg(test)]
691mod tests {
692 use super::*;
693
694 #[test]
695 fn merge_preserves_user_preferences_and_mcp_from_live() {
696 let live = indoc::indoc! {r#"
698 model_provider = "old-provider"
699 model = "old-model"
700 disable_response_storage = true
701 model_reasoning_effort = "xhigh"
702 approval_mode = "auto-edit"
703 check_for_update_on_startup = false
704
705 [model_providers.old-provider]
706 name = "Old"
707
708 [projects."/tmp/work"]
709 trusted = true
710
711 [mcp_servers.cargo-mcp]
712 type = "stdio"
713 "#};
714
715 let snapshot = indoc::indoc! {r#"
717 model_provider = "new-provider"
718 model = "new-model"
719 approval_mode = "suggest"
720
721 [model_providers.new-provider]
722 name = "New"
723 api_key = "sk-test"
724
725 [projects."/tmp/other"]
726 trusted = true
727 "#};
728
729 let merged = merge_provider_into_codex_live_config(live, snapshot, true).unwrap();
730 let doc: toml_edit::DocumentMut = merged.parse().unwrap();
731
732 assert_eq!(doc["model_provider"].as_str(), Some("new-provider"));
734 assert_eq!(doc["model"].as_str(), Some("new-model"));
735 assert!(doc
736 .get("model_providers")
737 .unwrap()
738 .get("new-provider")
739 .is_some());
740 assert!(doc
741 .get("model_providers")
742 .unwrap()
743 .get("old-provider")
744 .is_none());
745
746 assert_eq!(doc["disable_response_storage"].as_bool(), Some(true));
748 assert_eq!(doc["model_reasoning_effort"].as_str(), Some("xhigh"));
749 assert_eq!(doc["approval_mode"].as_str(), Some("auto-edit"));
750 assert_eq!(doc["check_for_update_on_startup"].as_bool(), Some(false));
751
752 assert!(doc.get("projects").unwrap().get("/tmp/other").is_some());
754 assert!(doc.get("projects").unwrap().get("/tmp/work").is_none());
755
756 assert!(doc.get("mcp_servers").is_some());
758 assert!(doc.get("mcp_servers").unwrap().get("cargo-mcp").is_some());
759 }
760
761 #[test]
762 fn merge_keeps_commented_out_mcp_entries_verbatim() {
763 let live = "\
768model_provider = \"old\"
769approval_mode = \"suggest\"
770
771# top-level note for mcp_servers section
772[mcp_servers.active]
773# comment before command
774command = \"runme\" # trailing comment
775
776# this one is temporarily disabled
777# [mcp_servers.disabled]
778# command = \"nope\"
779# args = [\"--off\"]
780";
781
782 let snapshot = "\
783model_provider = \"new\"
784
785[model_providers.new]
786name = \"New\"
787";
788
789 let merged = merge_provider_into_codex_live_config(live, snapshot, true).unwrap();
790
791 for needle in [
793 "# top-level note for mcp_servers section",
794 "# comment before command",
795 "# trailing comment",
796 "# this one is temporarily disabled",
797 "# [mcp_servers.disabled]",
798 "# command = \"nope\"",
799 "# args = [\"--off\"]",
800 ] {
801 assert!(
802 merged.contains(needle),
803 "merged output is missing comment line: {needle:?}\n--- merged ---\n{merged}"
804 );
805 }
806
807 let doc: toml_edit::DocumentMut = merged.parse().unwrap();
809 assert_eq!(doc["model_provider"].as_str(), Some("new"));
810 assert!(doc
811 .get("mcp_servers")
812 .and_then(|t| t.get("active"))
813 .is_some());
814 assert!(doc
815 .get("mcp_servers")
816 .and_then(|t| t.get("disabled"))
817 .is_none());
818 }
819
820 #[test]
821 fn merge_seeds_preferences_from_snapshot_when_live_is_empty() {
822 let snapshot = indoc::indoc! {r#"
827 model_provider = "first"
828 model = "gpt-5.2-codex"
829 disable_response_storage = true
830 approval_mode = "suggest"
831
832 [model_providers.first]
833 base_url = "https://api.example/v1"
834 "#};
835
836 let merged = merge_provider_into_codex_live_config("", snapshot, true).unwrap();
837 assert!(
838 merged.contains("disable_response_storage = true"),
839 "snapshot-provided preference should seed an empty live config\n--- merged ---\n{merged}"
840 );
841 assert!(merged.contains("approval_mode = \"suggest\""));
842 assert!(merged.contains("model_provider = \"first\""));
843 }
844
845 #[test]
846 fn merge_with_preserve_false_drops_live_prefs_missing_from_snapshot() {
847 let live = indoc::indoc! {r#"
850 model_provider = "p1"
851 disable_response_storage = true
852 model_reasoning_effort = "xhigh"
853
854 [model_providers.p1]
855 base_url = "https://a"
856 "#};
857
858 let snapshot = indoc::indoc! {r#"
859 model_provider = "p1"
860
861 [model_providers.p1]
862 base_url = "https://a"
863 "#};
864
865 let merged = merge_provider_into_codex_live_config(live, snapshot, false).unwrap();
866 let doc: toml_edit::DocumentMut = merged.parse().unwrap();
867 assert!(doc.get("disable_response_storage").is_none());
868 assert!(doc.get("model_reasoning_effort").is_none());
869 assert_eq!(doc["model_provider"].as_str(), Some("p1"));
870 }
871
872 #[test]
873 fn merge_keeps_unknown_root_keys_from_live_on_switch() {
874 let live = indoc::indoc! {r#"
880 model_provider = "old"
881 sandbox_mode = "danger-full-access"
882 verbose_logging = true
883
884 [model_providers.old]
885 name = "Old"
886 "#};
887
888 let snapshot = indoc::indoc! {r#"
892 model_provider = "new"
893
894 [model_providers.new]
895 name = "New"
896 "#};
897
898 let merged = merge_provider_into_codex_live_config(live, snapshot, true).unwrap();
899 let doc: toml_edit::DocumentMut = merged.parse().unwrap();
900
901 assert_eq!(doc["model_provider"].as_str(), Some("new"));
903 assert!(doc
904 .get("model_providers")
905 .and_then(|t| t.get("new"))
906 .is_some());
907 assert!(doc
908 .get("model_providers")
909 .and_then(|t| t.get("old"))
910 .is_none());
911
912 assert_eq!(doc["sandbox_mode"].as_str(), Some("danger-full-access"));
914 assert_eq!(doc["verbose_logging"].as_bool(), Some(true));
915 }
916
917 #[test]
918 fn merge_keeps_root_level_comments_around_overwritten_keys() {
919 let live = "\
925# pinned by me — do not change without checking the runbook
926model_provider = \"old\"
927
928# disabled while debugging issue-1234
929# disable_response_storage = true
930approval_mode = \"auto-edit\"
931
932# trailing footnote at EOF
933";
934
935 let snapshot = "\
936model_provider = \"new\"
937
938[model_providers.new]
939name = \"New\"
940";
941
942 let merged = merge_provider_into_codex_live_config(live, snapshot, true).unwrap();
943
944 for needle in [
945 "# pinned by me — do not change without checking the runbook",
946 "# disabled while debugging issue-1234",
947 "# disable_response_storage = true",
948 "# trailing footnote at EOF",
949 ] {
950 assert!(
951 merged.contains(needle),
952 "merged output is missing comment line: {needle:?}\n--- merged ---\n{merged}"
953 );
954 }
955
956 let doc: toml_edit::DocumentMut = merged.parse().unwrap();
957 assert_eq!(doc["model_provider"].as_str(), Some("new"));
958 assert_eq!(doc["approval_mode"].as_str(), Some("auto-edit"));
959 }
960
961 #[test]
962 fn merge_omits_mcp_section_when_live_has_none() {
963 let live = indoc::indoc! {r#"
964 model_provider = "foo"
965 approval_mode = "suggest"
966 "#};
967
968 let snapshot = indoc::indoc! {r#"
969 model_provider = "bar"
970 "#};
971
972 let merged = merge_provider_into_codex_live_config(live, snapshot, true).unwrap();
973 let doc: toml_edit::DocumentMut = merged.parse().unwrap();
974
975 assert_eq!(doc["model_provider"].as_str(), Some("bar"));
976 assert!(doc.get("mcp_servers").is_none());
977 assert_eq!(doc["approval_mode"].as_str(), Some("suggest"));
978 }
979 use crate::app_config::AppType;
980 use crate::test_support::{lock_test_home_and_settings, set_test_home_override};
981 use std::ffi::OsString;
982 use std::path::Path;
983
984 struct CodexHomeEnvGuard {
985 original: Option<OsString>,
986 }
987
988 impl CodexHomeEnvGuard {
989 fn new(value: Option<&str>) -> Self {
990 let original = std::env::var_os("CODEX_HOME");
991 match value {
992 Some(v) => unsafe { std::env::set_var("CODEX_HOME", v) },
993 None => unsafe { std::env::remove_var("CODEX_HOME") },
994 }
995 Self { original }
996 }
997 }
998
999 impl Drop for CodexHomeEnvGuard {
1000 fn drop(&mut self) {
1001 match self.original.as_ref() {
1002 Some(value) => unsafe { std::env::set_var("CODEX_HOME", value) },
1003 None => unsafe { std::env::remove_var("CODEX_HOME") },
1004 }
1005 }
1006 }
1007
1008 struct SettingsGuard {
1009 original: crate::settings::AppSettings,
1010 }
1011
1012 impl SettingsGuard {
1013 fn with_codex_config_dir(dir: Option<&str>) -> Self {
1014 let original = crate::settings::get_settings();
1015 let mut settings = original.clone();
1016 settings.codex_config_dir = dir.map(str::to_string);
1017 crate::settings::update_settings(settings).unwrap();
1018 Self { original }
1019 }
1020 }
1021
1022 impl Drop for SettingsGuard {
1023 fn drop(&mut self) {
1024 let _ = crate::settings::update_settings(self.original.clone());
1025 }
1026 }
1027
1028 #[test]
1029 fn get_codex_config_dir_respects_codex_home_env_var_and_tilde() {
1030 let _guard = lock_test_home_and_settings();
1031 let _settings = SettingsGuard::with_codex_config_dir(None);
1032 let _env = CodexHomeEnvGuard::new(Some("~/.config/codex"));
1033 set_test_home_override(Some(Path::new("/tmp/codex-home-tilde")));
1034
1035 assert_eq!(
1036 get_codex_config_dir(),
1037 PathBuf::from("/tmp/codex-home-tilde")
1038 .join(".config")
1039 .join("codex")
1040 );
1041
1042 set_test_home_override(None);
1043 }
1044
1045 #[test]
1046 fn get_codex_config_dir_env_overrides_settings_override() {
1047 let _guard = lock_test_home_and_settings();
1048 let _settings = SettingsGuard::with_codex_config_dir(Some("/tmp/settings-codex"));
1049 let _env = CodexHomeEnvGuard::new(Some("/tmp/env-codex"));
1050 set_test_home_override(Some(Path::new("/tmp/codex-home")));
1051
1052 assert_eq!(get_codex_config_dir(), PathBuf::from("/tmp/env-codex"));
1053
1054 set_test_home_override(None);
1055 }
1056
1057 #[test]
1058 fn codex_live_sync_detects_initialized_codex_home_from_env() {
1059 let _guard = lock_test_home_and_settings();
1060 let _settings = SettingsGuard::with_codex_config_dir(None);
1061 let env_home = PathBuf::from("/tmp/codex-live-sync-env");
1062 let _env = CodexHomeEnvGuard::new(Some(env_home.to_str().unwrap()));
1063 set_test_home_override(Some(Path::new("/tmp/codex-live-sync-home")));
1064
1065 std::fs::create_dir_all(&env_home).expect("create CODEX_HOME");
1066
1067 assert!(crate::sync_policy::should_sync_live(&AppType::Codex));
1068
1069 let _ = std::fs::remove_dir_all(&env_home);
1070 set_test_home_override(None);
1071 }
1072
1073 #[test]
1074 fn normalize_live_config_preserves_current_custom_model_provider_id() {
1075 let current = r#"model_provider = "rightcode"
1076
1077[model_providers.rightcode]
1078name = "RightCode"
1079base_url = "https://rightcode.example/v1"
1080wire_api = "responses"
1081"#;
1082 let target = r#"model_provider = "aihubmix"
1083model = "gpt-5.4"
1084
1085[model_providers.aihubmix]
1086name = "AiHubMix"
1087base_url = "https://aihubmix.example/v1"
1088wire_api = "responses"
1089requires_openai_auth = true
1090
1091[mcp_servers.context7]
1092command = "npx"
1093"#;
1094
1095 let result =
1096 normalize_codex_live_config_model_provider_with_anchors(target, Some(current)).unwrap();
1097 let parsed: toml::Value = toml::from_str(&result).unwrap();
1098
1099 assert_eq!(
1100 parsed.get("model_provider").and_then(|v| v.as_str()),
1101 Some("rightcode")
1102 );
1103
1104 let model_providers = parsed
1105 .get("model_providers")
1106 .and_then(|v| v.as_table())
1107 .expect("model_providers should exist");
1108 assert!(
1109 model_providers.get("aihubmix").is_none(),
1110 "source provider id should not remain in live config"
1111 );
1112
1113 let stable_provider = model_providers
1114 .get("rightcode")
1115 .expect("stable provider table should exist");
1116 assert_eq!(
1117 stable_provider.get("base_url").and_then(|v| v.as_str()),
1118 Some("https://aihubmix.example/v1")
1119 );
1120 assert!(
1121 parsed.get("mcp_servers").is_some(),
1122 "unrelated config should be preserved"
1123 );
1124 }
1125
1126 #[test]
1127 fn normalize_live_config_uses_target_custom_provider_when_current_is_reserved() {
1128 let current = r#"model_provider = "openai""#;
1129 let target = r#"model_provider = "aihubmix"
1130
1131[model_providers.aihubmix]
1132name = "AiHubMix"
1133base_url = "https://aihubmix.example/v1"
1134wire_api = "responses"
1135"#;
1136
1137 let result =
1138 normalize_codex_live_config_model_provider_with_anchors(target, Some(current)).unwrap();
1139 let parsed: toml::Value = toml::from_str(&result).unwrap();
1140
1141 assert_eq!(
1142 parsed.get("model_provider").and_then(|v| v.as_str()),
1143 Some("aihubmix")
1144 );
1145 assert!(
1146 parsed
1147 .get("model_providers")
1148 .and_then(|v| v.get("aihubmix"))
1149 .is_some(),
1150 "target provider id should be kept when there is no reusable live custom id"
1151 );
1152 }
1153
1154 #[test]
1155 fn normalize_live_config_leaves_official_empty_config_unchanged() {
1156 let current = r#"model_provider = "rightcode"
1157
1158[model_providers.rightcode]
1159base_url = "https://rightcode.example/v1"
1160"#;
1161
1162 let result =
1163 normalize_codex_live_config_model_provider_with_anchors("", Some(current)).unwrap();
1164
1165 assert_eq!(result, "");
1166 }
1167
1168 #[test]
1169 fn normalize_live_config_rewrites_matching_profile_model_provider_refs() {
1170 let current = r#"model_provider = "session_anchor"
1171
1172[model_providers.session_anchor]
1173name = "Session Anchor"
1174base_url = "https://anchor.example/v1"
1175wire_api = "responses"
1176"#;
1177 let target = r#"model_provider = "vendor_alpha"
1178model = "gpt-5.4"
1179profile = "work"
1180
1181[model_providers.vendor_alpha]
1182name = "Vendor Alpha"
1183base_url = "https://alpha.example/v1"
1184wire_api = "responses"
1185
1186[profiles.work]
1187model_provider = "vendor_alpha"
1188model = "gpt-5.4"
1189"#;
1190
1191 let result =
1192 normalize_codex_live_config_model_provider_with_anchors(target, Some(current)).unwrap();
1193 let parsed: toml::Value = toml::from_str(&result).unwrap();
1194
1195 assert_eq!(
1196 parsed.get("model_provider").and_then(|v| v.as_str()),
1197 Some("session_anchor")
1198 );
1199 assert_eq!(
1200 parsed
1201 .get("profiles")
1202 .and_then(|v| v.get("work"))
1203 .and_then(|v| v.get("model_provider"))
1204 .and_then(|v| v.as_str()),
1205 Some("session_anchor"),
1206 "profile override matching the rewritten provider should stay valid"
1207 );
1208 }
1209
1210 #[test]
1211 fn normalize_live_config_keeps_unrelated_profile_model_provider_refs() {
1212 let current = r#"model_provider = "session_anchor"
1213
1214[model_providers.session_anchor]
1215name = "Session Anchor"
1216base_url = "https://anchor.example/v1"
1217wire_api = "responses"
1218"#;
1219 let target = r#"model_provider = "vendor_alpha"
1220model = "gpt-5.4"
1221
1222[model_providers.vendor_alpha]
1223name = "Vendor Alpha"
1224base_url = "https://alpha.example/v1"
1225wire_api = "responses"
1226
1227[model_providers.local_profile]
1228name = "Local Profile"
1229base_url = "http://localhost:11434/v1"
1230wire_api = "responses"
1231
1232[profiles.local]
1233model_provider = "local_profile"
1234model = "local-model"
1235"#;
1236
1237 let result =
1238 normalize_codex_live_config_model_provider_with_anchors(target, Some(current)).unwrap();
1239 let parsed: toml::Value = toml::from_str(&result).unwrap();
1240
1241 assert_eq!(
1242 parsed
1243 .get("profiles")
1244 .and_then(|v| v.get("local"))
1245 .and_then(|v| v.get("model_provider"))
1246 .and_then(|v| v.as_str()),
1247 Some("local_profile"),
1248 "unrelated profile provider references should be preserved"
1249 );
1250 assert!(
1251 parsed
1252 .get("model_providers")
1253 .and_then(|v| v.get("local_profile"))
1254 .is_some(),
1255 "unrelated provider tables should also remain available"
1256 );
1257 }
1258
1259 #[test]
1260 fn normalize_live_config_keeps_stable_provider_across_repeated_switches() {
1261 let anchor = r#"model_provider = "session_anchor"
1262
1263[model_providers.session_anchor]
1264name = "Session Anchor"
1265base_url = "https://anchor.example/v1"
1266wire_api = "responses"
1267"#;
1268 let first_target = r#"model_provider = "vendor_alpha"
1269
1270[model_providers.vendor_alpha]
1271name = "Vendor Alpha"
1272base_url = "https://alpha.example/v1"
1273wire_api = "responses"
1274"#;
1275 let second_target = r#"model_provider = "vendor_beta"
1276
1277[model_providers.vendor_beta]
1278name = "Vendor Beta"
1279base_url = "https://beta.example/v1"
1280wire_api = "responses"
1281"#;
1282
1283 let first =
1284 normalize_codex_live_config_model_provider_with_anchors(first_target, Some(anchor))
1285 .unwrap();
1286 let second = normalize_codex_live_config_model_provider_with_anchors(
1287 second_target,
1288 Some(first.as_str()),
1289 )
1290 .unwrap();
1291 let parsed: toml::Value = toml::from_str(&second).unwrap();
1292
1293 assert_eq!(
1294 parsed.get("model_provider").and_then(|v| v.as_str()),
1295 Some("session_anchor"),
1296 "stable provider id should not drift across repeated switches"
1297 );
1298 assert_eq!(
1299 parsed
1300 .get("model_providers")
1301 .and_then(|v| v.get("session_anchor"))
1302 .and_then(|v| v.get("base_url"))
1303 .and_then(|v| v.as_str()),
1304 Some("https://beta.example/v1")
1305 );
1306 }
1307
1308 #[test]
1309 fn restore_backfill_config_rewrites_live_id_to_template_provider_id() {
1310 let mut settings = serde_json::json!({
1311 "config": r#"model_provider = "session_anchor"
1312model = "gpt-5.4"
1313profile = "work"
1314
1315[model_providers.session_anchor]
1316name = "AiHubMix"
1317base_url = "https://aihubmix.example/v1"
1318wire_api = "responses"
1319
1320[profiles.work]
1321model_provider = "session_anchor"
1322model = "gpt-5.4"
1323"#
1324 });
1325 let template = serde_json::json!({
1326 "config": r#"model_provider = "aihubmix"
1327
1328[model_providers.aihubmix]
1329name = "AiHubMix"
1330base_url = "https://aihubmix.example/v1"
1331"#
1332 });
1333
1334 restore_codex_settings_config_model_provider_for_backfill(&mut settings, &template)
1335 .unwrap();
1336
1337 let config = settings.get("config").and_then(Value::as_str).unwrap();
1338 let parsed: toml::Value = toml::from_str(config).unwrap();
1339 assert_eq!(
1340 parsed.get("model_provider").and_then(|v| v.as_str()),
1341 Some("aihubmix")
1342 );
1343 assert!(parsed
1344 .get("model_providers")
1345 .and_then(|v| v.get("aihubmix"))
1346 .is_some());
1347 assert_eq!(
1348 parsed
1349 .get("profiles")
1350 .and_then(|v| v.get("work"))
1351 .and_then(|v| v.get("model_provider"))
1352 .and_then(|v| v.as_str()),
1353 Some("aihubmix")
1354 );
1355 }
1356}