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 = 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 read_and_validate_codex_config_text() -> Result<String, AppError> {
413 let s = read_codex_config_text()?;
414 validate_config_toml(&s)?;
415 Ok(s)
416}
417
418fn active_codex_model_provider_id(doc: &DocumentMut) -> Option<String> {
419 doc.get("model_provider")
420 .and_then(|item| item.as_str())
421 .map(str::trim)
422 .filter(|id| !id.is_empty())
423 .map(str::to_string)
424}
425
426fn is_custom_codex_model_provider_id(id: &str) -> bool {
427 let id = id.trim();
428 !id.is_empty()
429 && !CODEX_RESERVED_MODEL_PROVIDER_IDS
430 .iter()
431 .any(|reserved| reserved.eq_ignore_ascii_case(id))
432}
433
434fn stable_codex_model_provider_id_from_config(config_text: &str) -> Option<String> {
435 let doc = config_text.parse::<DocumentMut>().ok()?;
436 let provider_id = active_codex_model_provider_id(&doc)?;
437
438 if is_custom_codex_model_provider_id(&provider_id) {
439 Some(provider_id)
440 } else {
441 None
442 }
443}
444
445fn codex_model_provider_id_with_table_from_config(
446 config_text: &str,
447) -> Result<Option<String>, AppError> {
448 if config_text.trim().is_empty() {
449 return Ok(None);
450 }
451
452 let doc = config_text
453 .parse::<DocumentMut>()
454 .map_err(|e| AppError::Message(format!("Invalid Codex config.toml: {e}")))?;
455 let Some(provider_id) = active_codex_model_provider_id(&doc) else {
456 return Ok(None);
457 };
458
459 let has_provider_table = doc
460 .get("model_providers")
461 .and_then(|item| item.as_table())
462 .and_then(|table| table.get(provider_id.as_str()))
463 .is_some();
464
465 Ok(has_provider_table.then_some(provider_id))
466}
467
468fn primary_codex_model_provider_id_with_table(doc: &DocumentMut) -> Option<String> {
469 if let Some(provider_id) = active_codex_model_provider_id(doc) {
470 let has_provider_table = doc
471 .get("model_providers")
472 .and_then(|item| item.as_table_like())
473 .and_then(|table| table.get(provider_id.as_str()))
474 .is_some();
475 if has_provider_table {
476 return Some(provider_id);
477 }
478 }
479
480 let providers = doc
481 .get("model_providers")
482 .and_then(|item| item.as_table_like())?;
483 let mut keys = providers.iter().map(|(key, _)| key.to_string());
484 let first = keys.next()?;
485 keys.next().is_none().then_some(first)
486}
487
488fn normalize_codex_live_config_model_provider_with_anchors<'a>(
489 config_text: &str,
490 anchor_config_texts: impl IntoIterator<Item = &'a str>,
491) -> Result<String, AppError> {
492 if config_text.trim().is_empty() {
493 return Ok(config_text.to_string());
494 }
495
496 let mut doc = config_text
497 .parse::<DocumentMut>()
498 .map_err(|e| AppError::Message(format!("Invalid Codex config.toml: {e}")))?;
499
500 let Some(source_provider_id) = active_codex_model_provider_id(&doc) else {
501 return Ok(config_text.to_string());
502 };
503
504 let has_source_provider_table = doc
505 .get("model_providers")
506 .and_then(|item| item.as_table())
507 .and_then(|table| table.get(source_provider_id.as_str()))
508 .is_some();
509 if !has_source_provider_table {
510 return Ok(config_text.to_string());
511 }
512
513 let stable_provider_id = anchor_config_texts
514 .into_iter()
515 .find_map(stable_codex_model_provider_id_from_config)
516 .or_else(|| {
517 is_custom_codex_model_provider_id(&source_provider_id)
518 .then(|| source_provider_id.clone())
519 })
520 .unwrap_or_else(|| CC_SWITCH_CODEX_MODEL_PROVIDER_ID.to_string());
521
522 if stable_provider_id == source_provider_id {
523 return Ok(config_text.to_string());
524 }
525
526 if let Some(model_providers) = doc
527 .get_mut("model_providers")
528 .and_then(|item| item.as_table_mut())
529 {
530 let Some(provider_table) = model_providers.remove(source_provider_id.as_str()) else {
531 return Ok(config_text.to_string());
532 };
533 model_providers[stable_provider_id.as_str()] = provider_table;
534 }
535
536 rewrite_codex_profile_model_provider_refs(&mut doc, &source_provider_id, &stable_provider_id);
537 doc["model_provider"] = toml_edit::value(stable_provider_id.as_str());
538
539 Ok(doc.to_string())
540}
541
542fn rewrite_codex_profile_model_provider_refs(
543 doc: &mut DocumentMut,
544 source_provider_id: &str,
545 stable_provider_id: &str,
546) {
547 let Some(profiles) = doc
548 .get_mut("profiles")
549 .and_then(|item| item.as_table_like_mut())
550 else {
551 return;
552 };
553
554 let profile_keys: Vec<String> = profiles.iter().map(|(key, _)| key.to_string()).collect();
555 for profile_key in profile_keys {
556 let Some(profile_table) = profiles
557 .get_mut(&profile_key)
558 .and_then(|item| item.as_table_like_mut())
559 else {
560 continue;
561 };
562
563 let references_source = profile_table
564 .get("model_provider")
565 .and_then(|item| item.as_str())
566 == Some(source_provider_id);
567 if references_source {
568 profile_table.insert("model_provider", toml_edit::value(stable_provider_id));
569 }
570 }
571}
572
573pub fn normalize_codex_settings_config_model_provider(
580 settings: &mut Value,
581 anchor_config_text: Option<&str>,
582) -> Result<(), AppError> {
583 let prefer_structured = settings.get(CODEX_STRUCTURED_CONFIG_KEY).is_some()
584 && settings.get(CODEX_LEGACY_CONFIG_KEY).is_none();
585 let config_text = codex_config_text_from_settings(settings)?;
586 if config_text.trim().is_empty() {
587 return Ok(());
588 }
589
590 let current_config_text = read_codex_config_text().ok();
591 let anchors = anchor_config_text
592 .into_iter()
593 .chain(current_config_text.as_deref());
594 let normalized =
595 normalize_codex_live_config_model_provider_with_anchors(&config_text, anchors)?;
596
597 set_codex_config_text_in_settings(settings, &normalized, prefer_structured)?;
598 Ok(())
599}
600
601fn restore_codex_backfill_model_provider_id(
602 config_text: &str,
603 template_config_text: &str,
604) -> Result<String, AppError> {
605 let Some(template_provider_id) =
606 codex_model_provider_id_with_table_from_config(template_config_text)?
607 else {
608 return Ok(config_text.to_string());
609 };
610
611 if config_text.trim().is_empty() {
612 return Ok(config_text.to_string());
613 }
614
615 let mut doc = config_text
616 .parse::<DocumentMut>()
617 .map_err(|e| AppError::Message(format!("Invalid Codex config.toml: {e}")))?;
618 let Some(live_provider_id) = active_codex_model_provider_id(&doc) else {
619 return Ok(config_text.to_string());
620 };
621
622 if live_provider_id == template_provider_id {
623 return Ok(config_text.to_string());
624 }
625
626 if let Some(model_providers) = doc
627 .get_mut("model_providers")
628 .and_then(|item| item.as_table_mut())
629 {
630 let Some(provider_table) = model_providers.remove(live_provider_id.as_str()) else {
631 return Ok(config_text.to_string());
632 };
633 model_providers[template_provider_id.as_str()] = provider_table;
634 } else {
635 return Ok(config_text.to_string());
636 }
637
638 rewrite_codex_profile_model_provider_refs(&mut doc, &live_provider_id, &template_provider_id);
639 doc["model_provider"] = toml_edit::value(template_provider_id.as_str());
640
641 Ok(doc.to_string())
642}
643
644pub fn restore_codex_settings_config_model_provider_for_backfill(
647 settings: &mut Value,
648 template_settings: &Value,
649) -> Result<(), AppError> {
650 let prefer_structured = settings.get(CODEX_STRUCTURED_CONFIG_KEY).is_some()
651 && settings.get(CODEX_LEGACY_CONFIG_KEY).is_none();
652 let config_text = codex_config_text_from_settings(settings)?;
653 let template_config_text = codex_config_text_from_settings(template_settings)?;
654 if template_config_text.trim().is_empty() {
655 return Ok(());
656 }
657
658 let restored = restore_codex_backfill_model_provider_id(&config_text, &template_config_text)?;
659 set_codex_config_text_in_settings(settings, &restored, prefer_structured)?;
660
661 Ok(())
662}
663
664pub fn merge_provider_into_codex_live_config(
713 live_text: &str,
714 provider_snapshot: &str,
715 preserve_user_preferences: bool,
716) -> Result<String, AppError> {
717 const LIVE_RUNTIME_KEYS: &[&str] = &["mcp_servers", "projects", "trusted_workspaces"];
718 const PROVIDER_SCOPED_KEYS: &[&str] = &[
723 "model_provider",
724 "model",
725 "profile",
726 "model_providers",
727 "profiles",
728 ];
729
730 let mut live = if live_text.trim().is_empty() {
731 toml_edit::DocumentMut::new()
732 } else {
733 live_text
734 .parse::<toml_edit::DocumentMut>()
735 .map_err(|e| AppError::Message(format!("Invalid Codex live config.toml: {e}")))?
736 };
737
738 let snap = if provider_snapshot.trim().is_empty() {
739 toml_edit::DocumentMut::new()
740 } else {
741 provider_snapshot
742 .parse::<toml_edit::DocumentMut>()
743 .map_err(|e| AppError::Message(format!("Invalid Codex provider snapshot: {e}")))?
744 };
745
746 let live_keys: Vec<String> = live.as_table().iter().map(|(k, _)| k.to_string()).collect();
756 for key in live_keys {
757 if LIVE_RUNTIME_KEYS.contains(&key.as_str()) {
758 continue;
759 }
760 if snap.get(&key).is_some() {
761 continue;
762 }
763 let is_provider_scoped = PROVIDER_SCOPED_KEYS.contains(&key.as_str());
764 if is_provider_scoped || !preserve_user_preferences {
765 live.as_table_mut().remove(&key);
766 }
767 }
768
769 let snap_keys: Vec<String> = snap.as_table().iter().map(|(k, _)| k.to_string()).collect();
778 for key in snap_keys {
779 if LIVE_RUNTIME_KEYS.contains(&key.as_str()) {
780 continue;
781 }
782 let is_provider_scoped = PROVIDER_SCOPED_KEYS.contains(&key.as_str());
783 if !is_provider_scoped && preserve_user_preferences && live.get(&key).is_some() {
784 continue;
785 }
786 if let Some(val) = snap.get(&key) {
787 live[key.as_str()] = val.clone();
788 }
789 }
790
791 Ok(live.to_string())
792}
793
794pub fn rewrite_codex_config_model_provider_key(
800 config_text: &str,
801 target_provider_id: &str,
802) -> Result<String, AppError> {
803 if config_text.trim().is_empty() {
804 return Ok(String::new());
805 }
806
807 let target_provider_id = clean_codex_provider_key(target_provider_id);
808 let mut doc = config_text
809 .parse::<DocumentMut>()
810 .map_err(|e| AppError::Message(format!("Invalid Codex config.toml: {e}")))?;
811 let Some(source_provider_id) = primary_codex_model_provider_id_with_table(&doc) else {
812 return Ok(config_text.to_string());
813 };
814
815 if let Some(model_providers) = doc
816 .get_mut("model_providers")
817 .and_then(|item| item.as_table_mut())
818 {
819 if source_provider_id != target_provider_id {
820 let Some(provider_table) = model_providers.remove(source_provider_id.as_str()) else {
821 return Ok(config_text.to_string());
822 };
823 model_providers[target_provider_id.as_str()] = provider_table;
824 rewrite_codex_profile_model_provider_refs(
825 &mut doc,
826 &source_provider_id,
827 &target_provider_id,
828 );
829 }
830 } else {
831 return Ok(config_text.to_string());
832 }
833
834 doc["model_provider"] = toml_edit::value(target_provider_id.as_str());
835
836 Ok(doc.to_string())
837}
838
839pub fn clean_codex_provider_key(raw: &str) -> String {
844 let mut key: String = raw
845 .chars()
846 .map(|c| {
847 if c.is_ascii_alphanumeric() {
848 c.to_ascii_lowercase()
849 } else {
850 '_'
851 }
852 })
853 .collect();
854
855 while key.starts_with('_') {
856 key.remove(0);
857 }
858 while key.ends_with('_') {
859 key.pop();
860 }
861
862 if key.is_empty() {
863 "custom".to_string()
864 } else {
865 key
866 }
867}
868
869#[cfg(test)]
870mod tests {
871 use super::*;
872
873 #[test]
874 fn merge_preserves_user_preferences_and_mcp_from_live() {
875 let live = indoc::indoc! {r#"
877 model_provider = "old-provider"
878 model = "old-model"
879 disable_response_storage = true
880 model_reasoning_effort = "xhigh"
881 approval_mode = "auto-edit"
882 check_for_update_on_startup = false
883
884 [model_providers.old-provider]
885 name = "Old"
886
887 [projects."/tmp/work"]
888 trusted = true
889
890 [mcp_servers.cargo-mcp]
891 type = "stdio"
892 "#};
893
894 let snapshot = indoc::indoc! {r#"
896 model_provider = "new-provider"
897 model = "new-model"
898 approval_mode = "suggest"
899
900 [model_providers.new-provider]
901 name = "New"
902 api_key = "sk-test"
903
904 [projects."/tmp/other"]
905 trusted = true
906 "#};
907
908 let merged = merge_provider_into_codex_live_config(live, snapshot, true).unwrap();
909 let doc: toml_edit::DocumentMut = merged.parse().unwrap();
910
911 assert_eq!(doc["model_provider"].as_str(), Some("new-provider"));
913 assert_eq!(doc["model"].as_str(), Some("new-model"));
914 assert!(doc
915 .get("model_providers")
916 .unwrap()
917 .get("new-provider")
918 .is_some());
919 assert!(doc
920 .get("model_providers")
921 .unwrap()
922 .get("old-provider")
923 .is_none());
924
925 assert_eq!(doc["disable_response_storage"].as_bool(), Some(true));
927 assert_eq!(doc["model_reasoning_effort"].as_str(), Some("xhigh"));
928 assert_eq!(doc["approval_mode"].as_str(), Some("auto-edit"));
929 assert_eq!(doc["check_for_update_on_startup"].as_bool(), Some(false));
930
931 assert!(doc.get("projects").unwrap().get("/tmp/work").is_some());
933 assert!(doc.get("projects").unwrap().get("/tmp/other").is_none());
934
935 assert!(doc.get("mcp_servers").is_some());
937 assert!(doc.get("mcp_servers").unwrap().get("cargo-mcp").is_some());
938 }
939
940 #[test]
941 fn merge_preserves_live_runtime_projects_from_provider_snapshot() {
942 let live = indoc::indoc! {r#"
943 model_provider = "old-provider"
944 trusted_workspaces = ["/tmp/live-workspace"]
945
946 [model_providers.old-provider]
947 name = "Old"
948
949 [projects."/tmp/live-project"]
950 trust_level = "trusted"
951 "#};
952
953 let snapshot = indoc::indoc! {r#"
954 model_provider = "new-provider"
955 trusted_workspaces = ["/tmp/snapshot-workspace"]
956
957 [model_providers.new-provider]
958 name = "New"
959
960 [projects."/tmp/snapshot-project"]
961 trust_level = "trusted"
962 "#};
963
964 let merged = merge_provider_into_codex_live_config(live, snapshot, true).unwrap();
965 let doc: toml_edit::DocumentMut = merged.parse().unwrap();
966 let projects = doc.get("projects").expect("projects should be preserved");
967 let trusted_workspaces = doc["trusted_workspaces"]
968 .as_array()
969 .expect("trusted_workspaces should be preserved");
970
971 assert!(projects.get("/tmp/live-project").is_some());
972 assert!(projects.get("/tmp/snapshot-project").is_none());
973 assert_eq!(trusted_workspaces.len(), 1);
974 assert_eq!(
975 trusted_workspaces.get(0).and_then(|value| value.as_str()),
976 Some("/tmp/live-workspace")
977 );
978 }
979
980 #[test]
981 fn merge_keeps_commented_out_mcp_entries_verbatim() {
982 let live = "\
987model_provider = \"old\"
988approval_mode = \"suggest\"
989
990# top-level note for mcp_servers section
991[mcp_servers.active]
992# comment before command
993command = \"runme\" # trailing comment
994
995# this one is temporarily disabled
996# [mcp_servers.disabled]
997# command = \"nope\"
998# args = [\"--off\"]
999";
1000
1001 let snapshot = "\
1002model_provider = \"new\"
1003
1004[model_providers.new]
1005name = \"New\"
1006";
1007
1008 let merged = merge_provider_into_codex_live_config(live, snapshot, true).unwrap();
1009
1010 for needle in [
1012 "# top-level note for mcp_servers section",
1013 "# comment before command",
1014 "# trailing comment",
1015 "# this one is temporarily disabled",
1016 "# [mcp_servers.disabled]",
1017 "# command = \"nope\"",
1018 "# args = [\"--off\"]",
1019 ] {
1020 assert!(
1021 merged.contains(needle),
1022 "merged output is missing comment line: {needle:?}\n--- merged ---\n{merged}"
1023 );
1024 }
1025
1026 let doc: toml_edit::DocumentMut = merged.parse().unwrap();
1028 assert_eq!(doc["model_provider"].as_str(), Some("new"));
1029 assert!(doc
1030 .get("mcp_servers")
1031 .and_then(|t| t.get("active"))
1032 .is_some());
1033 assert!(doc
1034 .get("mcp_servers")
1035 .and_then(|t| t.get("disabled"))
1036 .is_none());
1037 }
1038
1039 #[test]
1040 fn merge_seeds_preferences_from_snapshot_when_live_is_empty() {
1041 let snapshot = indoc::indoc! {r#"
1046 model_provider = "first"
1047 model = "gpt-5.2-codex"
1048 disable_response_storage = true
1049 approval_mode = "suggest"
1050
1051 [model_providers.first]
1052 base_url = "https://api.example/v1"
1053 "#};
1054
1055 let merged = merge_provider_into_codex_live_config("", snapshot, true).unwrap();
1056 assert!(
1057 merged.contains("disable_response_storage = true"),
1058 "snapshot-provided preference should seed an empty live config\n--- merged ---\n{merged}"
1059 );
1060 assert!(merged.contains("approval_mode = \"suggest\""));
1061 assert!(merged.contains("model_provider = \"first\""));
1062 }
1063
1064 #[test]
1065 fn merge_with_preserve_false_drops_live_prefs_missing_from_snapshot() {
1066 let live = indoc::indoc! {r#"
1069 model_provider = "p1"
1070 disable_response_storage = true
1071 model_reasoning_effort = "xhigh"
1072
1073 [model_providers.p1]
1074 base_url = "https://a"
1075 "#};
1076
1077 let snapshot = indoc::indoc! {r#"
1078 model_provider = "p1"
1079
1080 [model_providers.p1]
1081 base_url = "https://a"
1082 "#};
1083
1084 let merged = merge_provider_into_codex_live_config(live, snapshot, false).unwrap();
1085 let doc: toml_edit::DocumentMut = merged.parse().unwrap();
1086 assert!(doc.get("disable_response_storage").is_none());
1087 assert!(doc.get("model_reasoning_effort").is_none());
1088 assert_eq!(doc["model_provider"].as_str(), Some("p1"));
1089 }
1090
1091 #[test]
1092 fn merge_keeps_unknown_root_keys_from_live_on_switch() {
1093 let live = indoc::indoc! {r#"
1099 model_provider = "old"
1100 sandbox_mode = "danger-full-access"
1101 verbose_logging = true
1102
1103 [model_providers.old]
1104 name = "Old"
1105 "#};
1106
1107 let snapshot = indoc::indoc! {r#"
1111 model_provider = "new"
1112
1113 [model_providers.new]
1114 name = "New"
1115 "#};
1116
1117 let merged = merge_provider_into_codex_live_config(live, snapshot, true).unwrap();
1118 let doc: toml_edit::DocumentMut = merged.parse().unwrap();
1119
1120 assert_eq!(doc["model_provider"].as_str(), Some("new"));
1122 assert!(doc
1123 .get("model_providers")
1124 .and_then(|t| t.get("new"))
1125 .is_some());
1126 assert!(doc
1127 .get("model_providers")
1128 .and_then(|t| t.get("old"))
1129 .is_none());
1130
1131 assert_eq!(doc["sandbox_mode"].as_str(), Some("danger-full-access"));
1133 assert_eq!(doc["verbose_logging"].as_bool(), Some(true));
1134 }
1135
1136 #[test]
1137 fn merge_keeps_root_level_comments_around_overwritten_keys() {
1138 let live = "\
1144# pinned by me — do not change without checking the runbook
1145model_provider = \"old\"
1146
1147# disabled while debugging issue-1234
1148# disable_response_storage = true
1149approval_mode = \"auto-edit\"
1150
1151# trailing footnote at EOF
1152";
1153
1154 let snapshot = "\
1155model_provider = \"new\"
1156
1157[model_providers.new]
1158name = \"New\"
1159";
1160
1161 let merged = merge_provider_into_codex_live_config(live, snapshot, true).unwrap();
1162
1163 for needle in [
1164 "# pinned by me — do not change without checking the runbook",
1165 "# disabled while debugging issue-1234",
1166 "# disable_response_storage = true",
1167 "# trailing footnote at EOF",
1168 ] {
1169 assert!(
1170 merged.contains(needle),
1171 "merged output is missing comment line: {needle:?}\n--- merged ---\n{merged}"
1172 );
1173 }
1174
1175 let doc: toml_edit::DocumentMut = merged.parse().unwrap();
1176 assert_eq!(doc["model_provider"].as_str(), Some("new"));
1177 assert_eq!(doc["approval_mode"].as_str(), Some("auto-edit"));
1178 }
1179
1180 #[test]
1181 fn merge_omits_mcp_section_when_live_has_none() {
1182 let live = indoc::indoc! {r#"
1183 model_provider = "foo"
1184 approval_mode = "suggest"
1185 "#};
1186
1187 let snapshot = indoc::indoc! {r#"
1188 model_provider = "bar"
1189 "#};
1190
1191 let merged = merge_provider_into_codex_live_config(live, snapshot, true).unwrap();
1192 let doc: toml_edit::DocumentMut = merged.parse().unwrap();
1193
1194 assert_eq!(doc["model_provider"].as_str(), Some("bar"));
1195 assert!(doc.get("mcp_servers").is_none());
1196 assert_eq!(doc["approval_mode"].as_str(), Some("suggest"));
1197 }
1198 use crate::app_config::AppType;
1199 use crate::test_support::{lock_test_home_and_settings, set_test_home_override};
1200 use std::ffi::OsString;
1201 use std::path::Path;
1202
1203 struct CodexHomeEnvGuard {
1204 original: Option<OsString>,
1205 }
1206
1207 impl CodexHomeEnvGuard {
1208 fn new(value: Option<&str>) -> Self {
1209 let original = std::env::var_os("CODEX_HOME");
1210 match value {
1211 Some(v) => unsafe { std::env::set_var("CODEX_HOME", v) },
1212 None => unsafe { std::env::remove_var("CODEX_HOME") },
1213 }
1214 Self { original }
1215 }
1216 }
1217
1218 impl Drop for CodexHomeEnvGuard {
1219 fn drop(&mut self) {
1220 match self.original.as_ref() {
1221 Some(value) => unsafe { std::env::set_var("CODEX_HOME", value) },
1222 None => unsafe { std::env::remove_var("CODEX_HOME") },
1223 }
1224 }
1225 }
1226
1227 struct SettingsGuard {
1228 original: crate::settings::AppSettings,
1229 }
1230
1231 impl SettingsGuard {
1232 fn with_codex_config_dir(dir: Option<&str>) -> Self {
1233 let original = crate::settings::get_settings();
1234 let mut settings = original.clone();
1235 settings.codex_config_dir = dir.map(str::to_string);
1236 crate::settings::update_settings(settings).unwrap();
1237 Self { original }
1238 }
1239 }
1240
1241 impl Drop for SettingsGuard {
1242 fn drop(&mut self) {
1243 let _ = crate::settings::update_settings(self.original.clone());
1244 }
1245 }
1246
1247 #[test]
1248 fn get_codex_config_dir_respects_codex_home_env_var_and_tilde() {
1249 let _guard = lock_test_home_and_settings();
1250 let _settings = SettingsGuard::with_codex_config_dir(None);
1251 let _env = CodexHomeEnvGuard::new(Some("~/.config/codex"));
1252 set_test_home_override(Some(Path::new("/tmp/codex-home-tilde")));
1253
1254 assert_eq!(
1255 get_codex_config_dir(),
1256 PathBuf::from("/tmp/codex-home-tilde")
1257 .join(".config")
1258 .join("codex")
1259 );
1260
1261 set_test_home_override(None);
1262 }
1263
1264 #[test]
1265 fn get_codex_config_dir_env_overrides_settings_override() {
1266 let _guard = lock_test_home_and_settings();
1267 let _settings = SettingsGuard::with_codex_config_dir(Some("/tmp/settings-codex"));
1268 let _env = CodexHomeEnvGuard::new(Some("/tmp/env-codex"));
1269 set_test_home_override(Some(Path::new("/tmp/codex-home")));
1270
1271 assert_eq!(get_codex_config_dir(), PathBuf::from("/tmp/env-codex"));
1272
1273 set_test_home_override(None);
1274 }
1275
1276 #[test]
1277 fn codex_live_sync_detects_initialized_codex_home_from_env() {
1278 let _guard = lock_test_home_and_settings();
1279 let _settings = SettingsGuard::with_codex_config_dir(None);
1280 let env_home = PathBuf::from("/tmp/codex-live-sync-env");
1281 let _env = CodexHomeEnvGuard::new(Some(env_home.to_str().unwrap()));
1282 set_test_home_override(Some(Path::new("/tmp/codex-live-sync-home")));
1283
1284 std::fs::create_dir_all(&env_home).expect("create CODEX_HOME");
1285
1286 assert!(crate::sync_policy::should_sync_live(&AppType::Codex));
1287
1288 let _ = std::fs::remove_dir_all(&env_home);
1289 set_test_home_override(None);
1290 }
1291
1292 #[test]
1293 fn normalize_live_config_preserves_current_custom_model_provider_id() {
1294 let current = r#"model_provider = "rightcode"
1295
1296[model_providers.rightcode]
1297name = "RightCode"
1298base_url = "https://rightcode.example/v1"
1299wire_api = "responses"
1300"#;
1301 let target = r#"model_provider = "aihubmix"
1302model = "gpt-5.4"
1303
1304[model_providers.aihubmix]
1305name = "AiHubMix"
1306base_url = "https://aihubmix.example/v1"
1307wire_api = "responses"
1308requires_openai_auth = true
1309
1310[mcp_servers.context7]
1311command = "npx"
1312"#;
1313
1314 let result =
1315 normalize_codex_live_config_model_provider_with_anchors(target, Some(current)).unwrap();
1316 let parsed: toml::Value = toml::from_str(&result).unwrap();
1317
1318 assert_eq!(
1319 parsed.get("model_provider").and_then(|v| v.as_str()),
1320 Some("rightcode")
1321 );
1322
1323 let model_providers = parsed
1324 .get("model_providers")
1325 .and_then(|v| v.as_table())
1326 .expect("model_providers should exist");
1327 assert!(
1328 model_providers.get("aihubmix").is_none(),
1329 "source provider id should not remain in live config"
1330 );
1331
1332 let stable_provider = model_providers
1333 .get("rightcode")
1334 .expect("stable provider table should exist");
1335 assert_eq!(
1336 stable_provider.get("base_url").and_then(|v| v.as_str()),
1337 Some("https://aihubmix.example/v1")
1338 );
1339 assert!(
1340 parsed.get("mcp_servers").is_some(),
1341 "unrelated config should be preserved"
1342 );
1343 }
1344
1345 #[test]
1346 fn normalize_live_config_uses_target_custom_provider_when_current_is_reserved() {
1347 let current = r#"model_provider = "openai""#;
1348 let target = r#"model_provider = "aihubmix"
1349
1350[model_providers.aihubmix]
1351name = "AiHubMix"
1352base_url = "https://aihubmix.example/v1"
1353wire_api = "responses"
1354"#;
1355
1356 let result =
1357 normalize_codex_live_config_model_provider_with_anchors(target, Some(current)).unwrap();
1358 let parsed: toml::Value = toml::from_str(&result).unwrap();
1359
1360 assert_eq!(
1361 parsed.get("model_provider").and_then(|v| v.as_str()),
1362 Some("aihubmix")
1363 );
1364 assert!(
1365 parsed
1366 .get("model_providers")
1367 .and_then(|v| v.get("aihubmix"))
1368 .is_some(),
1369 "target provider id should be kept when there is no reusable live custom id"
1370 );
1371 }
1372
1373 #[test]
1374 fn normalize_live_config_leaves_official_empty_config_unchanged() {
1375 let current = r#"model_provider = "rightcode"
1376
1377[model_providers.rightcode]
1378base_url = "https://rightcode.example/v1"
1379"#;
1380
1381 let result =
1382 normalize_codex_live_config_model_provider_with_anchors("", Some(current)).unwrap();
1383
1384 assert_eq!(result, "");
1385 }
1386
1387 #[test]
1388 fn normalize_live_config_rewrites_matching_profile_model_provider_refs() {
1389 let current = r#"model_provider = "session_anchor"
1390
1391[model_providers.session_anchor]
1392name = "Session Anchor"
1393base_url = "https://anchor.example/v1"
1394wire_api = "responses"
1395"#;
1396 let target = r#"model_provider = "vendor_alpha"
1397model = "gpt-5.4"
1398profile = "work"
1399
1400[model_providers.vendor_alpha]
1401name = "Vendor Alpha"
1402base_url = "https://alpha.example/v1"
1403wire_api = "responses"
1404
1405[profiles.work]
1406model_provider = "vendor_alpha"
1407model = "gpt-5.4"
1408"#;
1409
1410 let result =
1411 normalize_codex_live_config_model_provider_with_anchors(target, Some(current)).unwrap();
1412 let parsed: toml::Value = toml::from_str(&result).unwrap();
1413
1414 assert_eq!(
1415 parsed.get("model_provider").and_then(|v| v.as_str()),
1416 Some("session_anchor")
1417 );
1418 assert_eq!(
1419 parsed
1420 .get("profiles")
1421 .and_then(|v| v.get("work"))
1422 .and_then(|v| v.get("model_provider"))
1423 .and_then(|v| v.as_str()),
1424 Some("session_anchor"),
1425 "profile override matching the rewritten provider should stay valid"
1426 );
1427 }
1428
1429 #[test]
1430 fn normalize_live_config_keeps_unrelated_profile_model_provider_refs() {
1431 let current = r#"model_provider = "session_anchor"
1432
1433[model_providers.session_anchor]
1434name = "Session Anchor"
1435base_url = "https://anchor.example/v1"
1436wire_api = "responses"
1437"#;
1438 let target = r#"model_provider = "vendor_alpha"
1439model = "gpt-5.4"
1440
1441[model_providers.vendor_alpha]
1442name = "Vendor Alpha"
1443base_url = "https://alpha.example/v1"
1444wire_api = "responses"
1445
1446[model_providers.local_profile]
1447name = "Local Profile"
1448base_url = "http://localhost:11434/v1"
1449wire_api = "responses"
1450
1451[profiles.local]
1452model_provider = "local_profile"
1453model = "local-model"
1454"#;
1455
1456 let result =
1457 normalize_codex_live_config_model_provider_with_anchors(target, Some(current)).unwrap();
1458 let parsed: toml::Value = toml::from_str(&result).unwrap();
1459
1460 assert_eq!(
1461 parsed
1462 .get("profiles")
1463 .and_then(|v| v.get("local"))
1464 .and_then(|v| v.get("model_provider"))
1465 .and_then(|v| v.as_str()),
1466 Some("local_profile"),
1467 "unrelated profile provider references should be preserved"
1468 );
1469 assert!(
1470 parsed
1471 .get("model_providers")
1472 .and_then(|v| v.get("local_profile"))
1473 .is_some(),
1474 "unrelated provider tables should also remain available"
1475 );
1476 }
1477
1478 #[test]
1479 fn normalize_live_config_keeps_stable_provider_across_repeated_switches() {
1480 let anchor = r#"model_provider = "session_anchor"
1481
1482[model_providers.session_anchor]
1483name = "Session Anchor"
1484base_url = "https://anchor.example/v1"
1485wire_api = "responses"
1486"#;
1487 let first_target = r#"model_provider = "vendor_alpha"
1488
1489[model_providers.vendor_alpha]
1490name = "Vendor Alpha"
1491base_url = "https://alpha.example/v1"
1492wire_api = "responses"
1493"#;
1494 let second_target = r#"model_provider = "vendor_beta"
1495
1496[model_providers.vendor_beta]
1497name = "Vendor Beta"
1498base_url = "https://beta.example/v1"
1499wire_api = "responses"
1500"#;
1501
1502 let first =
1503 normalize_codex_live_config_model_provider_with_anchors(first_target, Some(anchor))
1504 .unwrap();
1505 let second = normalize_codex_live_config_model_provider_with_anchors(
1506 second_target,
1507 Some(first.as_str()),
1508 )
1509 .unwrap();
1510 let parsed: toml::Value = toml::from_str(&second).unwrap();
1511
1512 assert_eq!(
1513 parsed.get("model_provider").and_then(|v| v.as_str()),
1514 Some("session_anchor"),
1515 "stable provider id should not drift across repeated switches"
1516 );
1517 assert_eq!(
1518 parsed
1519 .get("model_providers")
1520 .and_then(|v| v.get("session_anchor"))
1521 .and_then(|v| v.get("base_url"))
1522 .and_then(|v| v.as_str()),
1523 Some("https://beta.example/v1")
1524 );
1525 }
1526
1527 #[test]
1528 fn restore_backfill_config_rewrites_live_id_to_template_provider_id() {
1529 let mut settings = serde_json::json!({
1530 "config": r#"model_provider = "session_anchor"
1531model = "gpt-5.4"
1532profile = "work"
1533
1534[model_providers.session_anchor]
1535name = "AiHubMix"
1536base_url = "https://aihubmix.example/v1"
1537wire_api = "responses"
1538
1539[profiles.work]
1540model_provider = "session_anchor"
1541model = "gpt-5.4"
1542"#
1543 });
1544 let template = serde_json::json!({
1545 "config": r#"model_provider = "aihubmix"
1546
1547[model_providers.aihubmix]
1548name = "AiHubMix"
1549base_url = "https://aihubmix.example/v1"
1550"#
1551 });
1552
1553 restore_codex_settings_config_model_provider_for_backfill(&mut settings, &template)
1554 .unwrap();
1555
1556 let config = codex_config_text_from_settings(&settings).unwrap();
1557 let parsed: toml::Value = toml::from_str(&config).unwrap();
1558 assert_eq!(
1559 parsed.get("model_provider").and_then(|v| v.as_str()),
1560 Some("aihubmix")
1561 );
1562 assert!(parsed
1563 .get("model_providers")
1564 .and_then(|v| v.get("aihubmix"))
1565 .is_some());
1566 assert_eq!(
1567 parsed
1568 .get("profiles")
1569 .and_then(|v| v.get("work"))
1570 .and_then(|v| v.get("model_provider"))
1571 .and_then(|v| v.as_str()),
1572 Some("aihubmix")
1573 );
1574 }
1575}