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 = isolate_selected_codex_provider(&codex_config_text_from_settings(settings)?)?;
350    if !config_toml.trim().is_empty()
351        && !config_toml.lines().any(|line| {
352            let line = line.trim();
353            !line.is_empty()
354                && !line.starts_with('#')
355                && (line.contains('=') || line.starts_with('['))
356        })
357    {
358        return Err(AppError::Config(
359            "Codex config.toml does not contain TOML assignments or tables".to_string(),
360        ));
361    }
362    let settings = settings
363        .as_object_mut()
364        .ok_or_else(|| AppError::Config("Codex 配置必须是 JSON 对象".into()))?;
365    settings.remove(CODEX_LEGACY_CONFIG_KEY);
366    settings.insert(
367        CODEX_STRUCTURED_CONFIG_KEY.to_string(),
368        codex_structured_config_from_toml(&config_toml)?,
369    );
370    Ok(())
371}
372
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
411pub fn isolate_selected_codex_provider(config_text: &str) -> Result<String, AppError> {
412    let config_text = config_text.trim();
413    if config_text.is_empty() {
414        return Ok(String::new());
415    }
416
417    let mut doc = config_text
418        .parse::<DocumentMut>()
419        .map_err(|e| AppError::Config(format!("TOML parse error: {e}")))?;
420    let selected = active_codex_model_provider_id(&doc);
421
422    let Some(selected) = selected else {
423        doc.as_table_mut().remove("model_providers");
424        return Ok(doc.to_string());
425    };
426    let Some(providers) = doc
427        .get_mut("model_providers")
428        .and_then(|item| item.as_table_like_mut())
429    else {
430        return Ok(doc.to_string());
431    };
432    if providers.get(&selected).is_none() {
433        if !is_custom_codex_model_provider_id(&selected) {
434            doc.as_table_mut().remove("model_providers");
435            return Ok(doc.to_string());
436        }
437        return Err(AppError::Config(format!(
438            "Codex model_provider `{selected}` is missing from model_providers"
439        )));
440    }
441
442    let stale_keys = providers
443        .iter()
444        .map(|(key, _)| key.to_string())
445        .filter(|key| key != &selected)
446        .collect::<Vec<_>>();
447    for key in stale_keys {
448        providers.remove(&key);
449    }
450
451    Ok(doc.to_string())
452}
453
454/// 读取并校验 `~/.codex/config.toml`,返回文本(可能为空)
455pub fn read_and_validate_codex_config_text() -> Result<String, AppError> {
456    let s = read_codex_config_text()?;
457    validate_config_toml(&s)?;
458    Ok(s)
459}
460
461fn active_codex_model_provider_id(doc: &DocumentMut) -> Option<String> {
462    doc.get("model_provider")
463        .and_then(|item| item.as_str())
464        .map(str::trim)
465        .filter(|id| !id.is_empty())
466        .map(str::to_string)
467}
468
469fn is_custom_codex_model_provider_id(id: &str) -> bool {
470    let id = id.trim();
471    !id.is_empty()
472        && !CODEX_RESERVED_MODEL_PROVIDER_IDS
473            .iter()
474            .any(|reserved| reserved.eq_ignore_ascii_case(id))
475}
476
477fn stable_codex_model_provider_id_from_config(config_text: &str) -> Option<String> {
478    let doc = config_text.parse::<DocumentMut>().ok()?;
479    let provider_id = active_codex_model_provider_id(&doc)?;
480
481    if is_custom_codex_model_provider_id(&provider_id) {
482        Some(provider_id)
483    } else {
484        None
485    }
486}
487
488fn codex_model_provider_id_with_table_from_config(
489    config_text: &str,
490) -> Result<Option<String>, AppError> {
491    if config_text.trim().is_empty() {
492        return Ok(None);
493    }
494
495    let doc = config_text
496        .parse::<DocumentMut>()
497        .map_err(|e| AppError::Message(format!("Invalid Codex config.toml: {e}")))?;
498    let Some(provider_id) = active_codex_model_provider_id(&doc) else {
499        return Ok(None);
500    };
501
502    let has_provider_table = doc
503        .get("model_providers")
504        .and_then(|item| item.as_table())
505        .and_then(|table| table.get(provider_id.as_str()))
506        .is_some();
507
508    Ok(has_provider_table.then_some(provider_id))
509}
510
511fn primary_codex_model_provider_id_with_table(doc: &DocumentMut) -> Option<String> {
512    if let Some(provider_id) = active_codex_model_provider_id(doc) {
513        let has_provider_table = doc
514            .get("model_providers")
515            .and_then(|item| item.as_table_like())
516            .and_then(|table| table.get(provider_id.as_str()))
517            .is_some();
518        if has_provider_table {
519            return Some(provider_id);
520        }
521    }
522
523    let providers = doc
524        .get("model_providers")
525        .and_then(|item| item.as_table_like())?;
526    let mut keys = providers.iter().map(|(key, _)| key.to_string());
527    let first = keys.next()?;
528    keys.next().is_none().then_some(first)
529}
530
531fn normalize_codex_live_config_model_provider_with_anchors<'a>(
532    config_text: &str,
533    anchor_config_texts: impl IntoIterator<Item = &'a str>,
534) -> Result<String, AppError> {
535    if config_text.trim().is_empty() {
536        return Ok(config_text.to_string());
537    }
538
539    let mut doc = config_text
540        .parse::<DocumentMut>()
541        .map_err(|e| AppError::Message(format!("Invalid Codex config.toml: {e}")))?;
542
543    let Some(source_provider_id) = active_codex_model_provider_id(&doc) else {
544        return Ok(config_text.to_string());
545    };
546
547    let has_source_provider_table = doc
548        .get("model_providers")
549        .and_then(|item| item.as_table())
550        .and_then(|table| table.get(source_provider_id.as_str()))
551        .is_some();
552    if !has_source_provider_table {
553        return Ok(config_text.to_string());
554    }
555
556    let stable_provider_id = anchor_config_texts
557        .into_iter()
558        .find_map(stable_codex_model_provider_id_from_config)
559        .or_else(|| {
560            is_custom_codex_model_provider_id(&source_provider_id)
561                .then(|| source_provider_id.clone())
562        })
563        .unwrap_or_else(|| CC_SWITCH_CODEX_MODEL_PROVIDER_ID.to_string());
564
565    if stable_provider_id == source_provider_id {
566        return Ok(config_text.to_string());
567    }
568
569    if let Some(model_providers) = doc
570        .get_mut("model_providers")
571        .and_then(|item| item.as_table_mut())
572    {
573        let Some(provider_table) = model_providers.remove(source_provider_id.as_str()) else {
574            return Ok(config_text.to_string());
575        };
576        model_providers[stable_provider_id.as_str()] = provider_table;
577    }
578
579    rewrite_codex_profile_model_provider_refs(&mut doc, &source_provider_id, &stable_provider_id);
580    doc["model_provider"] = toml_edit::value(stable_provider_id.as_str());
581
582    Ok(doc.to_string())
583}
584
585fn rewrite_codex_profile_model_provider_refs(
586    doc: &mut DocumentMut,
587    source_provider_id: &str,
588    stable_provider_id: &str,
589) {
590    let Some(profiles) = doc
591        .get_mut("profiles")
592        .and_then(|item| item.as_table_like_mut())
593    else {
594        return;
595    };
596
597    let profile_keys: Vec<String> = profiles.iter().map(|(key, _)| key.to_string()).collect();
598    for profile_key in profile_keys {
599        let Some(profile_table) = profiles
600            .get_mut(&profile_key)
601            .and_then(|item| item.as_table_like_mut())
602        else {
603            continue;
604        };
605
606        let references_source = profile_table
607            .get("model_provider")
608            .and_then(|item| item.as_str())
609            == Some(source_provider_id);
610        if references_source {
611            profile_table.insert("model_provider", toml_edit::value(stable_provider_id));
612        }
613    }
614}
615
616/// Rewrite a Codex snapshot to reuse an existing live custom `model_provider`.
617///
618/// This is intentionally **not** used for normal provider switches: the live
619/// `config.toml` should show the selected provider id. It remains useful for
620/// proxy takeover backup/restore flows that explicitly want a history-stable
621/// Codex `model_provider` alias.
622pub fn normalize_codex_settings_config_model_provider(
623    settings: &mut Value,
624    anchor_config_text: Option<&str>,
625) -> Result<(), AppError> {
626    let prefer_structured = settings.get(CODEX_STRUCTURED_CONFIG_KEY).is_some()
627        && settings.get(CODEX_LEGACY_CONFIG_KEY).is_none();
628    let config_text = codex_config_text_from_settings(settings)?;
629    if config_text.trim().is_empty() {
630        return Ok(());
631    }
632
633    let current_config_text = read_codex_config_text().ok();
634    let anchors = anchor_config_text
635        .into_iter()
636        .chain(current_config_text.as_deref());
637    let normalized =
638        normalize_codex_live_config_model_provider_with_anchors(&config_text, anchors)?;
639
640    set_codex_config_text_in_settings(settings, &normalized, prefer_structured)?;
641    Ok(())
642}
643
644fn restore_codex_backfill_model_provider_id(
645    config_text: &str,
646    template_config_text: &str,
647) -> Result<String, AppError> {
648    let Some(template_provider_id) =
649        codex_model_provider_id_with_table_from_config(template_config_text)?
650    else {
651        return Ok(config_text.to_string());
652    };
653
654    if config_text.trim().is_empty() {
655        return Ok(config_text.to_string());
656    }
657
658    let mut doc = config_text
659        .parse::<DocumentMut>()
660        .map_err(|e| AppError::Message(format!("Invalid Codex config.toml: {e}")))?;
661    let Some(live_provider_id) = active_codex_model_provider_id(&doc) else {
662        return Ok(config_text.to_string());
663    };
664
665    if live_provider_id == template_provider_id {
666        return Ok(config_text.to_string());
667    }
668
669    if let Some(model_providers) = doc
670        .get_mut("model_providers")
671        .and_then(|item| item.as_table_mut())
672    {
673        let Some(provider_table) = model_providers.remove(live_provider_id.as_str()) else {
674            return Ok(config_text.to_string());
675        };
676        model_providers[template_provider_id.as_str()] = provider_table;
677    } else {
678        return Ok(config_text.to_string());
679    }
680
681    rewrite_codex_profile_model_provider_refs(&mut doc, &live_provider_id, &template_provider_id);
682    doc["model_provider"] = toml_edit::value(template_provider_id.as_str());
683
684    Ok(doc.to_string())
685}
686
687/// Convert a Codex live config that was normalized for history stability back
688/// to the provider-specific id used by the stored provider template.
689pub fn restore_codex_settings_config_model_provider_for_backfill(
690    settings: &mut Value,
691    template_settings: &Value,
692) -> Result<(), AppError> {
693    let prefer_structured = settings.get(CODEX_STRUCTURED_CONFIG_KEY).is_some()
694        && settings.get(CODEX_LEGACY_CONFIG_KEY).is_none();
695    let config_text = codex_config_text_from_settings(settings)?;
696    let template_config_text = codex_config_text_from_settings(template_settings)?;
697    if template_config_text.trim().is_empty() {
698        return Ok(());
699    }
700
701    let restored = restore_codex_backfill_model_provider_id(&config_text, &template_config_text)?;
702    set_codex_config_text_in_settings(settings, &restored, prefer_structured)?;
703
704    Ok(())
705}
706
707/// Merge a stored provider snapshot into the current live config.
708///
709/// Strategy: edit the **live** document in place, overlaying only the entries the
710/// snapshot explicitly provides. Everything else stays untouched — in particular
711/// every comment the user has placed in `~/.codex/config.toml` survives a
712/// provider switch, including:
713///
714/// - whole commented-out subtables (e.g. `# [mcp_servers.x] / # command = ...`,
715///   typically used to temporarily disable an MCP server),
716/// - commented-out root-level keys (e.g. `# disable_response_storage = true`),
717/// - free-floating header notes attached to a key (toml_edit treats these as
718///   the next key's prefix decor; overwriting that key in place preserves the
719///   decor because the key already existed in live),
720/// - trailing notes at end of file (document-level trailing decor).
721///
722/// Rules:
723///
724/// - `[mcp_servers]` is **never** overwritten from the snapshot. The user's live
725///   `[mcp_servers]` content (active subtables, commented-out subtables, and any
726///   loose comments around them) is preserved verbatim.
727/// - Codex runtime trust keys (`projects`, `trusted_workspaces`) are also never
728///   overwritten from the snapshot. Trust decisions are tied to local
729///   workspaces, not to the selected model provider.
730/// - The narrow set in [`PROVIDER_SCOPED_KEYS`] is treated as **provider-scoped**:
731///   the snapshot is authoritative, live entries the snapshot doesn't cover are
732///   removed (so e.g. `[model_providers.OLD]` doesn't leak into the new one).
733/// - Every other root-level entry is treated as **user-owned**:
734///     - `preserve_user_preferences = true` (provider switch with applyCommonConfig
735///       honored): live wins when present; falls back to snapshot otherwise so an
736///       initial write still seeds keys from the snapshot (which is what carries
737///       merged common-snippet defaults).
738///     - `preserve_user_preferences = false` (common-snippet apply/clear, or
739///       provider with `applyCommonConfig=false`): snapshot drives. Live keys
740///       absent from the snapshot are removed so old snippet residue doesn't
741///       bleed through.
742///
743/// Defaulting *all* non-blacklisted root keys to user-owned is intentional: it
744/// keeps the helper forward-compatible. When Codex adds a new root-level
745/// preference (e.g. `sandbox_mode`, `verbose_logging`, …), users' live values
746/// survive switches without anyone having to update this list. Only the small
747/// set of keys that must hard-sync between providers needs to be maintained.
748///
749/// Currently provider-scoped:
750/// - `model_provider` — pointer to the active `[model_providers.X]` entry.
751/// - `model` — currently-selected model name, conventionally per-provider.
752/// - `profile` — selected profile name, paired with `[profiles]`.
753/// - `model_providers` — provider definitions; replaced wholesale per snapshot.
754/// - `profiles` — may point at provider-specific `model_provider` keys.
755pub fn merge_provider_into_codex_live_config(
756    live_text: &str,
757    provider_snapshot: &str,
758    preserve_user_preferences: bool,
759) -> Result<String, AppError> {
760    const LIVE_RUNTIME_KEYS: &[&str] = &["mcp_servers", "projects", "trusted_workspaces"];
761    /// Root-level keys whose value must strictly follow the active provider's
762    /// snapshot. Anything not listed here is treated as user-owned and follows
763    /// the `preserve_user_preferences` rules above, so adding a new preference
764    /// key in Codex does NOT require code changes here.
765    const PROVIDER_SCOPED_KEYS: &[&str] = &[
766        "model_provider",
767        "model",
768        "profile",
769        "model_providers",
770        "profiles",
771    ];
772
773    let mut live = if live_text.trim().is_empty() {
774        toml_edit::DocumentMut::new()
775    } else {
776        live_text
777            .parse::<toml_edit::DocumentMut>()
778            .map_err(|e| AppError::Message(format!("Invalid Codex live config.toml: {e}")))?
779    };
780
781    let snap = if provider_snapshot.trim().is_empty() {
782        toml_edit::DocumentMut::new()
783    } else {
784        provider_snapshot
785            .parse::<toml_edit::DocumentMut>()
786            .map_err(|e| AppError::Message(format!("Invalid Codex provider snapshot: {e}")))?
787    };
788
789    // Step 1: figure out which live entries to drop.
790    //
791    // - Live runtime keys are never touched.
792    // - Provider-scoped keys are dropped when the snapshot does not provide
793    //   them, so the previous provider's [model_providers.OLD] doesn't leak.
794    // - User-owned keys (everything else) are dropped only when
795    //   `preserve_user_preferences = false` AND the snapshot does not provide
796    //   them, so common-snippet residue gets cleared but ordinary user
797    //   preferences are kept on a normal switch.
798    let live_keys: Vec<String> = live.as_table().iter().map(|(k, _)| k.to_string()).collect();
799    for key in live_keys {
800        if LIVE_RUNTIME_KEYS.contains(&key.as_str()) {
801            continue;
802        }
803        if snap.get(&key).is_some() {
804            continue;
805        }
806        let is_provider_scoped = PROVIDER_SCOPED_KEYS.contains(&key.as_str());
807        if is_provider_scoped || !preserve_user_preferences {
808            live.as_table_mut().remove(&key);
809        }
810    }
811
812    // Step 2: overlay every snapshot entry except live runtime keys.
813    //
814    // - Provider-scoped keys always overwrite live (the snapshot is the source
815    //   of truth for these).
816    // - User-owned keys overwrite live unless we are preserving live
817    //   preferences AND the key already exists in live (in which case live
818    //   wins). When the key is missing from live we still take the snapshot's
819    //   value so initial writes get seeded.
820    let snap_keys: Vec<String> = snap.as_table().iter().map(|(k, _)| k.to_string()).collect();
821    for key in snap_keys {
822        if LIVE_RUNTIME_KEYS.contains(&key.as_str()) {
823            continue;
824        }
825        let is_provider_scoped = PROVIDER_SCOPED_KEYS.contains(&key.as_str());
826        if !is_provider_scoped && preserve_user_preferences && live.get(&key).is_some() {
827            continue;
828        }
829        if let Some(val) = snap.get(&key) {
830            live[key.as_str()] = val.clone();
831        }
832    }
833
834    Ok(live.to_string())
835}
836
837/// Rewrite a stored Codex provider snapshot to use a specific provider key.
838///
839/// This updates both the root `model_provider` and the matching
840/// `[model_providers.<key>]` table, while preserving the provider table body and
841/// profile references.
842pub fn rewrite_codex_config_model_provider_key(
843    config_text: &str,
844    target_provider_id: &str,
845) -> Result<String, AppError> {
846    if config_text.trim().is_empty() {
847        return Ok(String::new());
848    }
849
850    let target_provider_id = clean_codex_provider_display_key(target_provider_id);
851    let mut doc = config_text
852        .parse::<DocumentMut>()
853        .map_err(|e| AppError::Message(format!("Invalid Codex config.toml: {e}")))?;
854    let Some(source_provider_id) = primary_codex_model_provider_id_with_table(&doc) else {
855        return Ok(config_text.to_string());
856    };
857
858    if let Some(model_providers) = doc
859        .get_mut("model_providers")
860        .and_then(|item| item.as_table_mut())
861    {
862        if source_provider_id != target_provider_id {
863            let Some(provider_table) = model_providers.remove(source_provider_id.as_str()) else {
864                return Ok(config_text.to_string());
865            };
866            model_providers[target_provider_id.as_str()] = provider_table;
867            rewrite_codex_profile_model_provider_refs(
868                &mut doc,
869                &source_provider_id,
870                &target_provider_id,
871            );
872        }
873    } else {
874        return Ok(config_text.to_string());
875    }
876
877    doc["model_provider"] = toml_edit::value(target_provider_id.as_str());
878
879    Ok(doc.to_string())
880}
881
882pub fn rewrite_codex_config_model_provider_name(
883    config_text: &str,
884    provider_key: &str,
885    provider_name: &str,
886) -> Result<String, AppError> {
887    let mut doc = config_text
888        .parse::<DocumentMut>()
889        .map_err(|e| AppError::Message(format!("Invalid Codex config.toml: {e}")))?;
890    let provider = doc
891        .get_mut("model_providers")
892        .and_then(|item| item.as_table_like_mut())
893        .and_then(|providers| providers.get_mut(provider_key))
894        .and_then(|item| item.as_table_like_mut())
895        .ok_or_else(|| {
896            AppError::Config(format!(
897                "Codex provider `{provider_key}` is missing from model_providers"
898            ))
899        })?;
900    provider.insert("name", toml_edit::value(provider_name));
901    Ok(doc.to_string())
902}
903
904pub fn selected_codex_provider_base_url(config_text: &str) -> Result<Option<String>, AppError> {
905    let config_text = config_text.trim();
906    if config_text.is_empty() {
907        return Ok(None);
908    }
909
910    let table = toml::from_str::<toml::Table>(config_text)
911        .map_err(|source| AppError::toml(Path::new("config.toml"), source))?;
912    let selected_url = table
913        .get("model_provider")
914        .and_then(toml::Value::as_str)
915        .and_then(|key| table.get("model_providers")?.as_table()?.get(key))
916        .and_then(toml::Value::as_table)
917        .and_then(|provider| provider.get("base_url"))
918        .and_then(toml::Value::as_str)
919        .map(str::trim)
920        .filter(|url| !url.is_empty())
921        .map(str::to_string);
922
923    Ok(selected_url.or_else(|| {
924        table
925            .get("base_url")
926            .and_then(toml::Value::as_str)
927            .map(str::trim)
928            .filter(|url| !url.is_empty())
929            .map(str::to_string)
930    }))
931}
932
933/// Generate a clean TOML key from a raw string for use as `model_provider` and `[model_providers.<key>]`.
934///
935/// Lowercases ASCII alphanumerics, replaces everything else with `_`, trims leading/trailing `_`.
936/// Falls back to `"custom"` if the result is empty.
937pub fn clean_codex_provider_key(raw: &str) -> String {
938    let mut key: String = raw
939        .chars()
940        .map(|c| {
941            if c.is_ascii_alphanumeric() {
942                c.to_ascii_lowercase()
943            } else {
944                '_'
945            }
946        })
947        .collect();
948
949    while key.starts_with('_') {
950        key.remove(0);
951    }
952    while key.ends_with('_') {
953        key.pop();
954    }
955
956    if key.is_empty() {
957        "custom".to_string()
958    } else {
959        key
960    }
961}
962
963pub fn clean_codex_provider_display_key(raw: &str) -> String {
964    let mut key: String = raw
965        .chars()
966        .map(|c| {
967            if c.is_ascii_alphanumeric() {
968                c.to_ascii_lowercase()
969            } else if c == '-' || c == '_' {
970                c
971            } else {
972                '_'
973            }
974        })
975        .collect();
976
977    while key.starts_with(['-', '_']) {
978        key.remove(0);
979    }
980    while key.ends_with(['-', '_']) {
981        key.pop();
982    }
983
984    if key.is_empty() {
985        "custom".to_string()
986    } else {
987        key
988    }
989}
990
991#[cfg(test)]
992mod tests {
993    use super::*;
994
995    #[test]
996    fn merge_preserves_user_preferences_and_mcp_from_live() {
997        // Current live config has user preferences and MCP servers
998        let live = indoc::indoc! {r#"
999            model_provider = "old-provider"
1000            model = "old-model"
1001            disable_response_storage = true
1002            model_reasoning_effort = "xhigh"
1003            approval_mode = "auto-edit"
1004            check_for_update_on_startup = false
1005
1006            [model_providers.old-provider]
1007            name = "Old"
1008
1009            [projects."/tmp/work"]
1010            trusted = true
1011
1012            [mcp_servers.cargo-mcp]
1013            type = "stdio"
1014        "#};
1015
1016        // Snapshot has different provider and its own [projects], no [mcp_servers]
1017        let snapshot = indoc::indoc! {r#"
1018            model_provider = "new-provider"
1019            model = "new-model"
1020            approval_mode = "suggest"
1021
1022            [model_providers.new-provider]
1023            name = "New"
1024            api_key = "sk-test"
1025
1026            [projects."/tmp/other"]
1027            trusted = true
1028        "#};
1029
1030        let merged = merge_provider_into_codex_live_config(live, snapshot, true).unwrap();
1031        let doc: toml_edit::DocumentMut = merged.parse().unwrap();
1032
1033        // Provider fields come from snapshot
1034        assert_eq!(doc["model_provider"].as_str(), Some("new-provider"));
1035        assert_eq!(doc["model"].as_str(), Some("new-model"));
1036        assert!(doc
1037            .get("model_providers")
1038            .unwrap()
1039            .get("new-provider")
1040            .is_some());
1041        assert!(doc
1042            .get("model_providers")
1043            .unwrap()
1044            .get("old-provider")
1045            .is_none());
1046
1047        // User preferences come from live (not snapshot's approval_mode)
1048        assert_eq!(doc["disable_response_storage"].as_bool(), Some(true));
1049        assert_eq!(doc["model_reasoning_effort"].as_str(), Some("xhigh"));
1050        assert_eq!(doc["approval_mode"].as_str(), Some("auto-edit"));
1051        assert_eq!(doc["check_for_update_on_startup"].as_bool(), Some(false));
1052
1053        // [projects] comes from live because trust decisions are workspace-local.
1054        assert!(doc.get("projects").unwrap().get("/tmp/work").is_some());
1055        assert!(doc.get("projects").unwrap().get("/tmp/other").is_none());
1056
1057        // [mcp_servers] comes from live (preserves comments and manual edits)
1058        assert!(doc.get("mcp_servers").is_some());
1059        assert!(doc.get("mcp_servers").unwrap().get("cargo-mcp").is_some());
1060    }
1061
1062    #[test]
1063    fn merge_preserves_live_runtime_projects_from_provider_snapshot() {
1064        let live = indoc::indoc! {r#"
1065            model_provider = "old-provider"
1066            trusted_workspaces = ["/tmp/live-workspace"]
1067
1068            [model_providers.old-provider]
1069            name = "Old"
1070
1071            [projects."/tmp/live-project"]
1072            trust_level = "trusted"
1073        "#};
1074
1075        let snapshot = indoc::indoc! {r#"
1076            model_provider = "new-provider"
1077            trusted_workspaces = ["/tmp/snapshot-workspace"]
1078
1079            [model_providers.new-provider]
1080            name = "New"
1081
1082            [projects."/tmp/snapshot-project"]
1083            trust_level = "trusted"
1084        "#};
1085
1086        let merged = merge_provider_into_codex_live_config(live, snapshot, true).unwrap();
1087        let doc: toml_edit::DocumentMut = merged.parse().unwrap();
1088        let projects = doc.get("projects").expect("projects should be preserved");
1089        let trusted_workspaces = doc["trusted_workspaces"]
1090            .as_array()
1091            .expect("trusted_workspaces should be preserved");
1092
1093        assert!(projects.get("/tmp/live-project").is_some());
1094        assert!(projects.get("/tmp/snapshot-project").is_none());
1095        assert_eq!(trusted_workspaces.len(), 1);
1096        assert_eq!(
1097            trusted_workspaces.get(0).and_then(|value| value.as_str()),
1098            Some("/tmp/live-workspace")
1099        );
1100    }
1101
1102    #[test]
1103    fn merge_keeps_commented_out_mcp_entries_verbatim() {
1104        // The user has temporarily disabled an MCP server by commenting out
1105        // its whole subtable. Switching providers must NOT uncomment these
1106        // lines or drop them. Also exercises a comment-only line before an
1107        // active subtable, and a trailing comment after a value.
1108        let live = "\
1109model_provider = \"old\"
1110approval_mode = \"suggest\"
1111
1112# top-level note for mcp_servers section
1113[mcp_servers.active]
1114# comment before command
1115command = \"runme\" # trailing comment
1116
1117# this one is temporarily disabled
1118# [mcp_servers.disabled]
1119# command = \"nope\"
1120# args = [\"--off\"]
1121";
1122
1123        let snapshot = "\
1124model_provider = \"new\"
1125
1126[model_providers.new]
1127name = \"New\"
1128";
1129
1130        let merged = merge_provider_into_codex_live_config(live, snapshot, true).unwrap();
1131
1132        // Every comment line from the live mcp_servers region must survive verbatim.
1133        for needle in [
1134            "# top-level note for mcp_servers section",
1135            "# comment before command",
1136            "# trailing comment",
1137            "# this one is temporarily disabled",
1138            "# [mcp_servers.disabled]",
1139            "# command = \"nope\"",
1140            "# args = [\"--off\"]",
1141        ] {
1142            assert!(
1143                merged.contains(needle),
1144                "merged output is missing comment line: {needle:?}\n--- merged ---\n{merged}"
1145            );
1146        }
1147
1148        // And the structural parts still parse and resolve as expected.
1149        let doc: toml_edit::DocumentMut = merged.parse().unwrap();
1150        assert_eq!(doc["model_provider"].as_str(), Some("new"));
1151        assert!(doc
1152            .get("mcp_servers")
1153            .and_then(|t| t.get("active"))
1154            .is_some());
1155        assert!(doc
1156            .get("mcp_servers")
1157            .and_then(|t| t.get("disabled"))
1158            .is_none());
1159    }
1160
1161    #[test]
1162    fn merge_seeds_preferences_from_snapshot_when_live_is_empty() {
1163        // Initial write path: live config doesn't exist yet, snapshot carries
1164        // merged common-snippet defaults like disable_response_storage.
1165        // With preserve_user_preferences=true, the snapshot's preferences must
1166        // still land in the resulting file — otherwise the common snippet is lost.
1167        let snapshot = indoc::indoc! {r#"
1168            model_provider = "first"
1169            model = "gpt-5.2-codex"
1170            disable_response_storage = true
1171            approval_mode = "suggest"
1172
1173            [model_providers.first]
1174            base_url = "https://api.example/v1"
1175        "#};
1176
1177        let merged = merge_provider_into_codex_live_config("", snapshot, true).unwrap();
1178        assert!(
1179            merged.contains("disable_response_storage = true"),
1180            "snapshot-provided preference should seed an empty live config\n--- merged ---\n{merged}"
1181        );
1182        assert!(merged.contains("approval_mode = \"suggest\""));
1183        assert!(merged.contains("model_provider = \"first\""));
1184    }
1185
1186    #[test]
1187    fn merge_with_preserve_false_drops_live_prefs_missing_from_snapshot() {
1188        // Common-snippet "clear" path: snapshot has no preference keys, so live
1189        // preferences left behind by a previous snippet must be removed.
1190        let live = indoc::indoc! {r#"
1191            model_provider = "p1"
1192            disable_response_storage = true
1193            model_reasoning_effort = "xhigh"
1194
1195            [model_providers.p1]
1196            base_url = "https://a"
1197        "#};
1198
1199        let snapshot = indoc::indoc! {r#"
1200            model_provider = "p1"
1201
1202            [model_providers.p1]
1203            base_url = "https://a"
1204        "#};
1205
1206        let merged = merge_provider_into_codex_live_config(live, snapshot, false).unwrap();
1207        let doc: toml_edit::DocumentMut = merged.parse().unwrap();
1208        assert!(doc.get("disable_response_storage").is_none());
1209        assert!(doc.get("model_reasoning_effort").is_none());
1210        assert_eq!(doc["model_provider"].as_str(), Some("p1"));
1211    }
1212
1213    #[test]
1214    fn merge_keeps_unknown_root_keys_from_live_on_switch() {
1215        // Regression guard for forward compatibility: if Codex introduces a
1216        // new root-level preference key tomorrow (e.g. `sandbox_mode`,
1217        // `verbose_logging`, or anything else not yet listed in
1218        // PROVIDER_SCOPED_KEYS), the user's live value must survive a
1219        // provider switch without us having to update this file.
1220        let live = indoc::indoc! {r#"
1221            model_provider = "old"
1222            sandbox_mode = "danger-full-access"
1223            verbose_logging = true
1224
1225            [model_providers.old]
1226            name = "Old"
1227        "#};
1228
1229        // Snapshot is from a stored provider that doesn't know about the new
1230        // keys at all (older snapshot, or a provider configured before the
1231        // user added them).
1232        let snapshot = indoc::indoc! {r#"
1233            model_provider = "new"
1234
1235            [model_providers.new]
1236            name = "New"
1237        "#};
1238
1239        let merged = merge_provider_into_codex_live_config(live, snapshot, true).unwrap();
1240        let doc: toml_edit::DocumentMut = merged.parse().unwrap();
1241
1242        // Provider-scoped keys followed the snapshot.
1243        assert_eq!(doc["model_provider"].as_str(), Some("new"));
1244        assert!(doc
1245            .get("model_providers")
1246            .and_then(|t| t.get("new"))
1247            .is_some());
1248        assert!(doc
1249            .get("model_providers")
1250            .and_then(|t| t.get("old"))
1251            .is_none());
1252
1253        // Unknown root keys stayed put.
1254        assert_eq!(doc["sandbox_mode"].as_str(), Some("danger-full-access"));
1255        assert_eq!(doc["verbose_logging"].as_bool(), Some(true));
1256    }
1257
1258    #[test]
1259    fn merge_keeps_root_level_comments_around_overwritten_keys() {
1260        // In toml_edit's model, comment-only lines between two root-level
1261        // keys are attached to the **next** key's prefix decor. When the
1262        // snapshot overwrites that next key, the comments must still be
1263        // there afterwards — otherwise users lose any inline notes they
1264        // sprinkled into ~/.codex/config.toml.
1265        let live = "\
1266# pinned by me — do not change without checking the runbook
1267model_provider = \"old\"
1268
1269# disabled while debugging issue-1234
1270# disable_response_storage = true
1271approval_mode = \"auto-edit\"
1272
1273# trailing footnote at EOF
1274";
1275
1276        let snapshot = "\
1277model_provider = \"new\"
1278
1279[model_providers.new]
1280name = \"New\"
1281";
1282
1283        let merged = merge_provider_into_codex_live_config(live, snapshot, true).unwrap();
1284
1285        for needle in [
1286            "# pinned by me — do not change without checking the runbook",
1287            "# disabled while debugging issue-1234",
1288            "# disable_response_storage = true",
1289            "# trailing footnote at EOF",
1290        ] {
1291            assert!(
1292                merged.contains(needle),
1293                "merged output is missing comment line: {needle:?}\n--- merged ---\n{merged}"
1294            );
1295        }
1296
1297        let doc: toml_edit::DocumentMut = merged.parse().unwrap();
1298        assert_eq!(doc["model_provider"].as_str(), Some("new"));
1299        assert_eq!(doc["approval_mode"].as_str(), Some("auto-edit"));
1300    }
1301
1302    #[test]
1303    fn merge_omits_mcp_section_when_live_has_none() {
1304        let live = indoc::indoc! {r#"
1305            model_provider = "foo"
1306            approval_mode = "suggest"
1307        "#};
1308
1309        let snapshot = indoc::indoc! {r#"
1310            model_provider = "bar"
1311        "#};
1312
1313        let merged = merge_provider_into_codex_live_config(live, snapshot, true).unwrap();
1314        let doc: toml_edit::DocumentMut = merged.parse().unwrap();
1315
1316        assert_eq!(doc["model_provider"].as_str(), Some("bar"));
1317        assert!(doc.get("mcp_servers").is_none());
1318        assert_eq!(doc["approval_mode"].as_str(), Some("suggest"));
1319    }
1320    use crate::app_config::AppType;
1321    use crate::test_support::{lock_test_home_and_settings, set_test_home_override};
1322    use std::ffi::OsString;
1323    use std::path::Path;
1324
1325    struct CodexHomeEnvGuard {
1326        original: Option<OsString>,
1327    }
1328
1329    impl CodexHomeEnvGuard {
1330        fn new(value: Option<&str>) -> Self {
1331            let original = std::env::var_os("CODEX_HOME");
1332            match value {
1333                Some(v) => unsafe { std::env::set_var("CODEX_HOME", v) },
1334                None => unsafe { std::env::remove_var("CODEX_HOME") },
1335            }
1336            Self { original }
1337        }
1338    }
1339
1340    impl Drop for CodexHomeEnvGuard {
1341        fn drop(&mut self) {
1342            match self.original.as_ref() {
1343                Some(value) => unsafe { std::env::set_var("CODEX_HOME", value) },
1344                None => unsafe { std::env::remove_var("CODEX_HOME") },
1345            }
1346        }
1347    }
1348
1349    struct SettingsGuard {
1350        original: crate::settings::AppSettings,
1351    }
1352
1353    impl SettingsGuard {
1354        fn with_codex_config_dir(dir: Option<&str>) -> Self {
1355            let original = crate::settings::get_settings();
1356            let mut settings = original.clone();
1357            settings.codex_config_dir = dir.map(str::to_string);
1358            crate::settings::update_settings(settings).unwrap();
1359            Self { original }
1360        }
1361    }
1362
1363    impl Drop for SettingsGuard {
1364        fn drop(&mut self) {
1365            let _ = crate::settings::update_settings(self.original.clone());
1366        }
1367    }
1368
1369    #[test]
1370    fn get_codex_config_dir_respects_codex_home_env_var_and_tilde() {
1371        let _guard = lock_test_home_and_settings();
1372        let _settings = SettingsGuard::with_codex_config_dir(None);
1373        let _env = CodexHomeEnvGuard::new(Some("~/.config/codex"));
1374        set_test_home_override(Some(Path::new("/tmp/codex-home-tilde")));
1375
1376        assert_eq!(
1377            get_codex_config_dir(),
1378            PathBuf::from("/tmp/codex-home-tilde")
1379                .join(".config")
1380                .join("codex")
1381        );
1382
1383        set_test_home_override(None);
1384    }
1385
1386    #[test]
1387    fn get_codex_config_dir_env_overrides_settings_override() {
1388        let _guard = lock_test_home_and_settings();
1389        let _settings = SettingsGuard::with_codex_config_dir(Some("/tmp/settings-codex"));
1390        let _env = CodexHomeEnvGuard::new(Some("/tmp/env-codex"));
1391        set_test_home_override(Some(Path::new("/tmp/codex-home")));
1392
1393        assert_eq!(get_codex_config_dir(), PathBuf::from("/tmp/env-codex"));
1394
1395        set_test_home_override(None);
1396    }
1397
1398    #[test]
1399    fn codex_live_sync_detects_initialized_codex_home_from_env() {
1400        let _guard = lock_test_home_and_settings();
1401        let _settings = SettingsGuard::with_codex_config_dir(None);
1402        let env_home = PathBuf::from("/tmp/codex-live-sync-env");
1403        let _env = CodexHomeEnvGuard::new(Some(env_home.to_str().unwrap()));
1404        set_test_home_override(Some(Path::new("/tmp/codex-live-sync-home")));
1405
1406        std::fs::create_dir_all(&env_home).expect("create CODEX_HOME");
1407
1408        assert!(crate::sync_policy::should_sync_live(&AppType::Codex));
1409
1410        let _ = std::fs::remove_dir_all(&env_home);
1411        set_test_home_override(None);
1412    }
1413
1414    #[test]
1415    fn normalize_live_config_preserves_current_custom_model_provider_id() {
1416        let current = r#"model_provider = "rightcode"
1417
1418[model_providers.rightcode]
1419name = "RightCode"
1420base_url = "https://rightcode.example/v1"
1421wire_api = "responses"
1422"#;
1423        let target = r#"model_provider = "aihubmix"
1424model = "gpt-5.4"
1425
1426[model_providers.aihubmix]
1427name = "AiHubMix"
1428base_url = "https://aihubmix.example/v1"
1429wire_api = "responses"
1430requires_openai_auth = true
1431
1432[mcp_servers.context7]
1433command = "npx"
1434"#;
1435
1436        let result =
1437            normalize_codex_live_config_model_provider_with_anchors(target, Some(current)).unwrap();
1438        let parsed: toml::Value = toml::from_str(&result).unwrap();
1439
1440        assert_eq!(
1441            parsed.get("model_provider").and_then(|v| v.as_str()),
1442            Some("rightcode")
1443        );
1444
1445        let model_providers = parsed
1446            .get("model_providers")
1447            .and_then(|v| v.as_table())
1448            .expect("model_providers should exist");
1449        assert!(
1450            model_providers.get("aihubmix").is_none(),
1451            "source provider id should not remain in live config"
1452        );
1453
1454        let stable_provider = model_providers
1455            .get("rightcode")
1456            .expect("stable provider table should exist");
1457        assert_eq!(
1458            stable_provider.get("base_url").and_then(|v| v.as_str()),
1459            Some("https://aihubmix.example/v1")
1460        );
1461        assert!(
1462            parsed.get("mcp_servers").is_some(),
1463            "unrelated config should be preserved"
1464        );
1465    }
1466
1467    #[test]
1468    fn normalize_live_config_uses_target_custom_provider_when_current_is_reserved() {
1469        let current = r#"model_provider = "openai""#;
1470        let target = r#"model_provider = "aihubmix"
1471
1472[model_providers.aihubmix]
1473name = "AiHubMix"
1474base_url = "https://aihubmix.example/v1"
1475wire_api = "responses"
1476"#;
1477
1478        let result =
1479            normalize_codex_live_config_model_provider_with_anchors(target, Some(current)).unwrap();
1480        let parsed: toml::Value = toml::from_str(&result).unwrap();
1481
1482        assert_eq!(
1483            parsed.get("model_provider").and_then(|v| v.as_str()),
1484            Some("aihubmix")
1485        );
1486        assert!(
1487            parsed
1488                .get("model_providers")
1489                .and_then(|v| v.get("aihubmix"))
1490                .is_some(),
1491            "target provider id should be kept when there is no reusable live custom id"
1492        );
1493    }
1494
1495    #[test]
1496    fn normalize_live_config_leaves_official_empty_config_unchanged() {
1497        let current = r#"model_provider = "rightcode"
1498
1499[model_providers.rightcode]
1500base_url = "https://rightcode.example/v1"
1501"#;
1502
1503        let result =
1504            normalize_codex_live_config_model_provider_with_anchors("", Some(current)).unwrap();
1505
1506        assert_eq!(result, "");
1507    }
1508
1509    #[test]
1510    fn normalize_live_config_rewrites_matching_profile_model_provider_refs() {
1511        let current = r#"model_provider = "session_anchor"
1512
1513[model_providers.session_anchor]
1514name = "Session Anchor"
1515base_url = "https://anchor.example/v1"
1516wire_api = "responses"
1517"#;
1518        let target = r#"model_provider = "vendor_alpha"
1519model = "gpt-5.4"
1520profile = "work"
1521
1522[model_providers.vendor_alpha]
1523name = "Vendor Alpha"
1524base_url = "https://alpha.example/v1"
1525wire_api = "responses"
1526
1527[profiles.work]
1528model_provider = "vendor_alpha"
1529model = "gpt-5.4"
1530"#;
1531
1532        let result =
1533            normalize_codex_live_config_model_provider_with_anchors(target, Some(current)).unwrap();
1534        let parsed: toml::Value = toml::from_str(&result).unwrap();
1535
1536        assert_eq!(
1537            parsed.get("model_provider").and_then(|v| v.as_str()),
1538            Some("session_anchor")
1539        );
1540        assert_eq!(
1541            parsed
1542                .get("profiles")
1543                .and_then(|v| v.get("work"))
1544                .and_then(|v| v.get("model_provider"))
1545                .and_then(|v| v.as_str()),
1546            Some("session_anchor"),
1547            "profile override matching the rewritten provider should stay valid"
1548        );
1549    }
1550
1551    #[test]
1552    fn normalize_live_config_keeps_unrelated_profile_model_provider_refs() {
1553        let current = r#"model_provider = "session_anchor"
1554
1555[model_providers.session_anchor]
1556name = "Session Anchor"
1557base_url = "https://anchor.example/v1"
1558wire_api = "responses"
1559"#;
1560        let target = r#"model_provider = "vendor_alpha"
1561model = "gpt-5.4"
1562
1563[model_providers.vendor_alpha]
1564name = "Vendor Alpha"
1565base_url = "https://alpha.example/v1"
1566wire_api = "responses"
1567
1568[model_providers.local_profile]
1569name = "Local Profile"
1570base_url = "http://localhost:11434/v1"
1571wire_api = "responses"
1572
1573[profiles.local]
1574model_provider = "local_profile"
1575model = "local-model"
1576"#;
1577
1578        let result =
1579            normalize_codex_live_config_model_provider_with_anchors(target, Some(current)).unwrap();
1580        let parsed: toml::Value = toml::from_str(&result).unwrap();
1581
1582        assert_eq!(
1583            parsed
1584                .get("profiles")
1585                .and_then(|v| v.get("local"))
1586                .and_then(|v| v.get("model_provider"))
1587                .and_then(|v| v.as_str()),
1588            Some("local_profile"),
1589            "unrelated profile provider references should be preserved"
1590        );
1591        assert!(
1592            parsed
1593                .get("model_providers")
1594                .and_then(|v| v.get("local_profile"))
1595                .is_some(),
1596            "unrelated provider tables should also remain available"
1597        );
1598    }
1599
1600    #[test]
1601    fn normalize_live_config_keeps_stable_provider_across_repeated_switches() {
1602        let anchor = r#"model_provider = "session_anchor"
1603
1604[model_providers.session_anchor]
1605name = "Session Anchor"
1606base_url = "https://anchor.example/v1"
1607wire_api = "responses"
1608"#;
1609        let first_target = r#"model_provider = "vendor_alpha"
1610
1611[model_providers.vendor_alpha]
1612name = "Vendor Alpha"
1613base_url = "https://alpha.example/v1"
1614wire_api = "responses"
1615"#;
1616        let second_target = r#"model_provider = "vendor_beta"
1617
1618[model_providers.vendor_beta]
1619name = "Vendor Beta"
1620base_url = "https://beta.example/v1"
1621wire_api = "responses"
1622"#;
1623
1624        let first =
1625            normalize_codex_live_config_model_provider_with_anchors(first_target, Some(anchor))
1626                .unwrap();
1627        let second = normalize_codex_live_config_model_provider_with_anchors(
1628            second_target,
1629            Some(first.as_str()),
1630        )
1631        .unwrap();
1632        let parsed: toml::Value = toml::from_str(&second).unwrap();
1633
1634        assert_eq!(
1635            parsed.get("model_provider").and_then(|v| v.as_str()),
1636            Some("session_anchor"),
1637            "stable provider id should not drift across repeated switches"
1638        );
1639        assert_eq!(
1640            parsed
1641                .get("model_providers")
1642                .and_then(|v| v.get("session_anchor"))
1643                .and_then(|v| v.get("base_url"))
1644                .and_then(|v| v.as_str()),
1645            Some("https://beta.example/v1")
1646        );
1647    }
1648
1649    #[test]
1650    fn restore_backfill_config_rewrites_live_id_to_template_provider_id() {
1651        let mut settings = serde_json::json!({
1652            "config": r#"model_provider = "session_anchor"
1653model = "gpt-5.4"
1654profile = "work"
1655
1656[model_providers.session_anchor]
1657name = "AiHubMix"
1658base_url = "https://aihubmix.example/v1"
1659wire_api = "responses"
1660
1661[profiles.work]
1662model_provider = "session_anchor"
1663model = "gpt-5.4"
1664"#
1665        });
1666        let template = serde_json::json!({
1667            "config": r#"model_provider = "aihubmix"
1668
1669[model_providers.aihubmix]
1670name = "AiHubMix"
1671base_url = "https://aihubmix.example/v1"
1672"#
1673        });
1674
1675        restore_codex_settings_config_model_provider_for_backfill(&mut settings, &template)
1676            .unwrap();
1677
1678        let config = codex_config_text_from_settings(&settings).unwrap();
1679        let parsed: toml::Value = toml::from_str(&config).unwrap();
1680        assert_eq!(
1681            parsed.get("model_provider").and_then(|v| v.as_str()),
1682            Some("aihubmix")
1683        );
1684        assert!(parsed
1685            .get("model_providers")
1686            .and_then(|v| v.get("aihubmix"))
1687            .is_some());
1688        assert_eq!(
1689            parsed
1690                .get("profiles")
1691                .and_then(|v| v.get("work"))
1692                .and_then(|v| v.get("model_provider"))
1693                .and_then(|v| v.as_str()),
1694            Some("aihubmix")
1695        );
1696    }
1697
1698    #[test]
1699    fn clean_codex_provider_display_key_preserves_hyphens() {
1700        assert_eq!(
1701            clean_codex_provider_display_key("zhima-cx-pro"),
1702            "zhima-cx-pro"
1703        );
1704    }
1705}