Skip to main content

cc_switch_lib/
codex_config.rs

1// unused imports removed
2use 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
18/// Reserved built-in provider IDs from OpenAI Codex's config/model-provider
19/// catalog. Keep in sync with Codex `RESERVED_MODEL_PROVIDER_IDS` and legacy
20/// removed provider aliases.
21const CODEX_RESERVED_MODEL_PROVIDER_IDS: &[&str] = &[
22    "amazon-bedrock",
23    "openai",
24    "ollama",
25    "lmstudio",
26    "oss",
27    "ollama-chat",
28];
29
30/// 获取 Codex 配置目录路径
31///
32/// Priority: `CODEX_HOME` env var > cc-switch settings override > `$HOME/.codex`
33pub 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
66/// 获取 Codex auth.json 路径
67pub fn get_codex_auth_path() -> PathBuf {
68    get_codex_config_dir().join("auth.json")
69}
70
71/// 获取 Codex config.toml 路径
72pub fn get_codex_config_path() -> PathBuf {
73    get_codex_config_dir().join("config.toml")
74}
75
76/// 获取 Codex 供应商配置文件路径
77pub 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
91/// 删除 Codex 供应商配置文件
92pub 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
104/// 原子写 Codex 的 `auth.json` 与 `config.toml`,在第二步失败时回滚第一步
105pub 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    // 读取旧内容用于回滚
124    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    // 准备写入内容
136    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    // 第一步:写 auth.json
145    if let Some(auth) = auth {
146        write_json_file(&auth_path, auth)?;
147    } else {
148        delete_file(&auth_path)?;
149    }
150
151    // 第二步:写 config.toml(失败则回滚 auth.json)
152    if let Err(e) = write_text_file(&config_path, &cfg_text) {
153        // 回滚 auth.json
154        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
165/// 读取 `~/.codex/config.toml`,若不存在返回空字符串
166pub 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
175/// 对非空的 TOML 文本进行语法校验
176pub 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
373/// Remove provider-specific Codex TOML keys and keep only shared/global settings.
374///
375/// This matches upstream "OpenAI Official" snapshot semantics where the official
376/// provider does not persist a provider-local `base_url` / `model_provider`
377/// section, but may still carry root-level shared settings.
378pub 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
411/// 读取并校验 `~/.codex/config.toml`,返回文本(可能为空)
412pub 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
573/// Rewrite a Codex snapshot to reuse an existing live custom `model_provider`.
574///
575/// This is intentionally **not** used for normal provider switches: the live
576/// `config.toml` should show the selected provider id. It remains useful for
577/// proxy takeover backup/restore flows that explicitly want a history-stable
578/// Codex `model_provider` alias.
579pub 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
644/// Convert a Codex live config that was normalized for history stability back
645/// to the provider-specific id used by the stored provider template.
646pub 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
664/// Merge a stored provider snapshot into the current live config.
665///
666/// Strategy: edit the **live** document in place, overlaying only the entries the
667/// snapshot explicitly provides. Everything else stays untouched — in particular
668/// every comment the user has placed in `~/.codex/config.toml` survives a
669/// provider switch, including:
670///
671/// - whole commented-out subtables (e.g. `# [mcp_servers.x] / # command = ...`,
672///   typically used to temporarily disable an MCP server),
673/// - commented-out root-level keys (e.g. `# disable_response_storage = true`),
674/// - free-floating header notes attached to a key (toml_edit treats these as
675///   the next key's prefix decor; overwriting that key in place preserves the
676///   decor because the key already existed in live),
677/// - trailing notes at end of file (document-level trailing decor).
678///
679/// Rules:
680///
681/// - `[mcp_servers]` is **never** overwritten from the snapshot. The user's live
682///   `[mcp_servers]` content (active subtables, commented-out subtables, and any
683///   loose comments around them) is preserved verbatim.
684/// - Codex runtime trust keys (`projects`, `trusted_workspaces`) are also never
685///   overwritten from the snapshot. Trust decisions are tied to local
686///   workspaces, not to the selected model provider.
687/// - The narrow set in [`PROVIDER_SCOPED_KEYS`] is treated as **provider-scoped**:
688///   the snapshot is authoritative, live entries the snapshot doesn't cover are
689///   removed (so e.g. `[model_providers.OLD]` doesn't leak into the new one).
690/// - Every other root-level entry is treated as **user-owned**:
691///     - `preserve_user_preferences = true` (provider switch with applyCommonConfig
692///       honored): live wins when present; falls back to snapshot otherwise so an
693///       initial write still seeds keys from the snapshot (which is what carries
694///       merged common-snippet defaults).
695///     - `preserve_user_preferences = false` (common-snippet apply/clear, or
696///       provider with `applyCommonConfig=false`): snapshot drives. Live keys
697///       absent from the snapshot are removed so old snippet residue doesn't
698///       bleed through.
699///
700/// Defaulting *all* non-blacklisted root keys to user-owned is intentional: it
701/// keeps the helper forward-compatible. When Codex adds a new root-level
702/// preference (e.g. `sandbox_mode`, `verbose_logging`, …), users' live values
703/// survive switches without anyone having to update this list. Only the small
704/// set of keys that must hard-sync between providers needs to be maintained.
705///
706/// Currently provider-scoped:
707/// - `model_provider` — pointer to the active `[model_providers.X]` entry.
708/// - `model` — currently-selected model name, conventionally per-provider.
709/// - `profile` — selected profile name, paired with `[profiles]`.
710/// - `model_providers` — provider definitions; replaced wholesale per snapshot.
711/// - `profiles` — may point at provider-specific `model_provider` keys.
712pub 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    /// Root-level keys whose value must strictly follow the active provider's
719    /// snapshot. Anything not listed here is treated as user-owned and follows
720    /// the `preserve_user_preferences` rules above, so adding a new preference
721    /// key in Codex does NOT require code changes here.
722    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    // Step 1: figure out which live entries to drop.
747    //
748    // - Live runtime keys are never touched.
749    // - Provider-scoped keys are dropped when the snapshot does not provide
750    //   them, so the previous provider's [model_providers.OLD] doesn't leak.
751    // - User-owned keys (everything else) are dropped only when
752    //   `preserve_user_preferences = false` AND the snapshot does not provide
753    //   them, so common-snippet residue gets cleared but ordinary user
754    //   preferences are kept on a normal switch.
755    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    // Step 2: overlay every snapshot entry except live runtime keys.
770    //
771    // - Provider-scoped keys always overwrite live (the snapshot is the source
772    //   of truth for these).
773    // - User-owned keys overwrite live unless we are preserving live
774    //   preferences AND the key already exists in live (in which case live
775    //   wins). When the key is missing from live we still take the snapshot's
776    //   value so initial writes get seeded.
777    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
794/// Rewrite a stored Codex provider snapshot to use a specific provider key.
795///
796/// This updates both the root `model_provider` and the matching
797/// `[model_providers.<key>]` table, while preserving the provider table body and
798/// profile references.
799pub 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
839/// Generate a clean TOML key from a raw string for use as `model_provider` and `[model_providers.<key>]`.
840///
841/// Lowercases ASCII alphanumerics, replaces everything else with `_`, trims leading/trailing `_`.
842/// Falls back to `"custom"` if the result is empty.
843pub 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        // Current live config has user preferences and MCP servers
876        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        // Snapshot has different provider and its own [projects], no [mcp_servers]
895        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        // Provider fields come from snapshot
912        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        // User preferences come from live (not snapshot's approval_mode)
926        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        // [projects] comes from live because trust decisions are workspace-local.
932        assert!(doc.get("projects").unwrap().get("/tmp/work").is_some());
933        assert!(doc.get("projects").unwrap().get("/tmp/other").is_none());
934
935        // [mcp_servers] comes from live (preserves comments and manual edits)
936        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        // The user has temporarily disabled an MCP server by commenting out
983        // its whole subtable. Switching providers must NOT uncomment these
984        // lines or drop them. Also exercises a comment-only line before an
985        // active subtable, and a trailing comment after a value.
986        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        // Every comment line from the live mcp_servers region must survive verbatim.
1011        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        // And the structural parts still parse and resolve as expected.
1027        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        // Initial write path: live config doesn't exist yet, snapshot carries
1042        // merged common-snippet defaults like disable_response_storage.
1043        // With preserve_user_preferences=true, the snapshot's preferences must
1044        // still land in the resulting file — otherwise the common snippet is lost.
1045        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        // Common-snippet "clear" path: snapshot has no preference keys, so live
1067        // preferences left behind by a previous snippet must be removed.
1068        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        // Regression guard for forward compatibility: if Codex introduces a
1094        // new root-level preference key tomorrow (e.g. `sandbox_mode`,
1095        // `verbose_logging`, or anything else not yet listed in
1096        // PROVIDER_SCOPED_KEYS), the user's live value must survive a
1097        // provider switch without us having to update this file.
1098        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        // Snapshot is from a stored provider that doesn't know about the new
1108        // keys at all (older snapshot, or a provider configured before the
1109        // user added them).
1110        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        // Provider-scoped keys followed the snapshot.
1121        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        // Unknown root keys stayed put.
1132        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        // In toml_edit's model, comment-only lines between two root-level
1139        // keys are attached to the **next** key's prefix decor. When the
1140        // snapshot overwrites that next key, the comments must still be
1141        // there afterwards — otherwise users lose any inline notes they
1142        // sprinkled into ~/.codex/config.toml.
1143        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}