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::Value;
9use std::fs;
10use std::path::Path;
11use toml_edit::DocumentMut;
12
13pub const CC_SWITCH_CODEX_MODEL_PROVIDER_ID: &str = "ccswitch";
14
15/// Reserved built-in provider IDs from OpenAI Codex's config/model-provider
16/// catalog. Keep in sync with Codex `RESERVED_MODEL_PROVIDER_IDS` and legacy
17/// removed provider aliases.
18const CODEX_RESERVED_MODEL_PROVIDER_IDS: &[&str] = &[
19    "amazon-bedrock",
20    "openai",
21    "ollama",
22    "lmstudio",
23    "oss",
24    "ollama-chat",
25];
26
27/// 获取 Codex 配置目录路径
28///
29/// Priority: `CODEX_HOME` env var > cc-switch settings override > `$HOME/.codex`
30pub fn get_codex_config_dir() -> PathBuf {
31    if let Some(dir) = std::env::var_os("CODEX_HOME") {
32        let dir = PathBuf::from(dir);
33        if !dir.as_os_str().is_empty() && !dir.to_string_lossy().trim().is_empty() {
34            return expand_codex_config_dir(dir);
35        }
36    }
37
38    if let Some(custom) = crate::settings::get_codex_override_dir() {
39        return custom;
40    }
41
42    home_dir().expect("无法获取用户主目录").join(".codex")
43}
44
45fn expand_codex_config_dir(path: PathBuf) -> PathBuf {
46    let lossy = path.to_string_lossy();
47    if lossy == "~" {
48        return home_dir().unwrap_or(path);
49    }
50
51    if let Some(rest) = lossy
52        .strip_prefix("~/")
53        .or_else(|| lossy.strip_prefix("~\\"))
54    {
55        if let Some(home) = home_dir() {
56            return home.join(rest);
57        }
58    }
59
60    path
61}
62
63/// 获取 Codex auth.json 路径
64pub fn get_codex_auth_path() -> PathBuf {
65    get_codex_config_dir().join("auth.json")
66}
67
68/// 获取 Codex config.toml 路径
69pub fn get_codex_config_path() -> PathBuf {
70    get_codex_config_dir().join("config.toml")
71}
72
73/// 获取 Codex 供应商配置文件路径
74pub fn get_codex_provider_paths(
75    provider_id: &str,
76    provider_name: Option<&str>,
77) -> (PathBuf, PathBuf) {
78    let base_name = provider_name
79        .map(sanitize_provider_name)
80        .unwrap_or_else(|| sanitize_provider_name(provider_id));
81
82    let auth_path = get_codex_config_dir().join(format!("auth-{base_name}.json"));
83    let config_path = get_codex_config_dir().join(format!("config-{base_name}.toml"));
84
85    (auth_path, config_path)
86}
87
88/// 删除 Codex 供应商配置文件
89pub fn delete_codex_provider_config(
90    provider_id: &str,
91    provider_name: &str,
92) -> Result<(), AppError> {
93    let (auth_path, config_path) = get_codex_provider_paths(provider_id, Some(provider_name));
94
95    delete_file(&auth_path).ok();
96    delete_file(&config_path).ok();
97
98    Ok(())
99}
100
101/// 原子写 Codex 的 `auth.json` 与 `config.toml`,在第二步失败时回滚第一步
102pub fn write_codex_live_atomic(
103    auth: &Value,
104    config_text_opt: Option<&str>,
105) -> Result<(), AppError> {
106    write_codex_live_atomic_optional_auth(Some(auth), config_text_opt)
107}
108
109pub fn write_codex_live_atomic_optional_auth(
110    auth: Option<&Value>,
111    config_text_opt: Option<&str>,
112) -> Result<(), AppError> {
113    let auth_path = get_codex_auth_path();
114    let config_path = get_codex_config_path();
115
116    if let Some(parent) = auth_path.parent() {
117        std::fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
118    }
119
120    // 读取旧内容用于回滚
121    let old_auth = if auth_path.exists() {
122        Some(fs::read(&auth_path).map_err(|e| AppError::io(&auth_path, e))?)
123    } else {
124        None
125    };
126    let _old_config = if config_path.exists() {
127        Some(fs::read(&config_path).map_err(|e| AppError::io(&config_path, e))?)
128    } else {
129        None
130    };
131
132    // 准备写入内容
133    let cfg_text = match config_text_opt {
134        Some(s) => s.to_string(),
135        None => String::new(),
136    };
137    if !cfg_text.trim().is_empty() {
138        toml::from_str::<toml::Table>(&cfg_text).map_err(|e| AppError::toml(&config_path, e))?;
139    }
140
141    // 第一步:写 auth.json
142    if let Some(auth) = auth {
143        write_json_file(&auth_path, auth)?;
144    } else {
145        delete_file(&auth_path)?;
146    }
147
148    // 第二步:写 config.toml(失败则回滚 auth.json)
149    if let Err(e) = write_text_file(&config_path, &cfg_text) {
150        // 回滚 auth.json
151        if let Some(bytes) = old_auth {
152            let _ = atomic_write(&auth_path, &bytes);
153        } else {
154            let _ = delete_file(&auth_path);
155        }
156        return Err(e);
157    }
158
159    Ok(())
160}
161
162/// 读取 `~/.codex/config.toml`,若不存在返回空字符串
163pub fn read_codex_config_text() -> Result<String, AppError> {
164    let path = get_codex_config_path();
165    if path.exists() {
166        std::fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))
167    } else {
168        Ok(String::new())
169    }
170}
171
172/// 对非空的 TOML 文本进行语法校验
173pub fn validate_config_toml(text: &str) -> Result<(), AppError> {
174    if text.trim().is_empty() {
175        return Ok(());
176    }
177    toml::from_str::<toml::Table>(text)
178        .map(|_| ())
179        .map_err(|e| AppError::toml(Path::new("config.toml"), e))
180}
181
182/// Remove provider-specific Codex TOML keys and keep only shared/global settings.
183///
184/// This matches upstream "OpenAI Official" snapshot semantics where the official
185/// provider does not persist a provider-local `base_url` / `model_provider`
186/// section, but may still carry root-level shared settings.
187pub fn strip_codex_provider_config_text(config_toml: &str) -> Result<String, AppError> {
188    let config_toml = config_toml.trim();
189    if config_toml.is_empty() {
190        return Ok(String::new());
191    }
192
193    let mut doc = config_toml
194        .parse::<toml_edit::DocumentMut>()
195        .map_err(|e| AppError::Config(format!("TOML parse error: {e}")))?;
196    let root = doc.as_table_mut();
197    root.remove("model");
198    root.remove("model_provider");
199    root.remove("base_url");
200    root.remove("model_providers");
201
202    let mut cleaned = String::new();
203    let mut blank_run = 0usize;
204    for line in doc.to_string().lines() {
205        if line.trim().is_empty() {
206            blank_run += 1;
207            if blank_run <= 1 {
208                cleaned.push('\n');
209            }
210            continue;
211        }
212        blank_run = 0;
213        cleaned.push_str(line);
214        cleaned.push('\n');
215    }
216
217    Ok(cleaned.trim().to_string())
218}
219
220/// 读取并校验 `~/.codex/config.toml`,返回文本(可能为空)
221pub fn read_and_validate_codex_config_text() -> Result<String, AppError> {
222    let s = read_codex_config_text()?;
223    validate_config_toml(&s)?;
224    Ok(s)
225}
226
227fn active_codex_model_provider_id(doc: &DocumentMut) -> Option<String> {
228    doc.get("model_provider")
229        .and_then(|item| item.as_str())
230        .map(str::trim)
231        .filter(|id| !id.is_empty())
232        .map(str::to_string)
233}
234
235fn is_custom_codex_model_provider_id(id: &str) -> bool {
236    let id = id.trim();
237    !id.is_empty()
238        && !CODEX_RESERVED_MODEL_PROVIDER_IDS
239            .iter()
240            .any(|reserved| reserved.eq_ignore_ascii_case(id))
241}
242
243fn stable_codex_model_provider_id_from_config(config_text: &str) -> Option<String> {
244    let doc = config_text.parse::<DocumentMut>().ok()?;
245    let provider_id = active_codex_model_provider_id(&doc)?;
246
247    if is_custom_codex_model_provider_id(&provider_id) {
248        Some(provider_id)
249    } else {
250        None
251    }
252}
253
254fn codex_model_provider_id_with_table_from_config(
255    config_text: &str,
256) -> Result<Option<String>, AppError> {
257    if config_text.trim().is_empty() {
258        return Ok(None);
259    }
260
261    let doc = config_text
262        .parse::<DocumentMut>()
263        .map_err(|e| AppError::Message(format!("Invalid Codex config.toml: {e}")))?;
264    let Some(provider_id) = active_codex_model_provider_id(&doc) else {
265        return Ok(None);
266    };
267
268    let has_provider_table = doc
269        .get("model_providers")
270        .and_then(|item| item.as_table())
271        .and_then(|table| table.get(provider_id.as_str()))
272        .is_some();
273
274    Ok(has_provider_table.then_some(provider_id))
275}
276
277fn primary_codex_model_provider_id_with_table(doc: &DocumentMut) -> Option<String> {
278    if let Some(provider_id) = active_codex_model_provider_id(doc) {
279        let has_provider_table = doc
280            .get("model_providers")
281            .and_then(|item| item.as_table_like())
282            .and_then(|table| table.get(provider_id.as_str()))
283            .is_some();
284        if has_provider_table {
285            return Some(provider_id);
286        }
287    }
288
289    let providers = doc
290        .get("model_providers")
291        .and_then(|item| item.as_table_like())?;
292    let mut keys = providers.iter().map(|(key, _)| key.to_string());
293    let first = keys.next()?;
294    keys.next().is_none().then_some(first)
295}
296
297fn normalize_codex_live_config_model_provider_with_anchors<'a>(
298    config_text: &str,
299    anchor_config_texts: impl IntoIterator<Item = &'a str>,
300) -> Result<String, AppError> {
301    if config_text.trim().is_empty() {
302        return Ok(config_text.to_string());
303    }
304
305    let mut doc = config_text
306        .parse::<DocumentMut>()
307        .map_err(|e| AppError::Message(format!("Invalid Codex config.toml: {e}")))?;
308
309    let Some(source_provider_id) = active_codex_model_provider_id(&doc) else {
310        return Ok(config_text.to_string());
311    };
312
313    let has_source_provider_table = doc
314        .get("model_providers")
315        .and_then(|item| item.as_table())
316        .and_then(|table| table.get(source_provider_id.as_str()))
317        .is_some();
318    if !has_source_provider_table {
319        return Ok(config_text.to_string());
320    }
321
322    let stable_provider_id = anchor_config_texts
323        .into_iter()
324        .find_map(stable_codex_model_provider_id_from_config)
325        .or_else(|| {
326            is_custom_codex_model_provider_id(&source_provider_id)
327                .then(|| source_provider_id.clone())
328        })
329        .unwrap_or_else(|| CC_SWITCH_CODEX_MODEL_PROVIDER_ID.to_string());
330
331    if stable_provider_id == source_provider_id {
332        return Ok(config_text.to_string());
333    }
334
335    if let Some(model_providers) = doc
336        .get_mut("model_providers")
337        .and_then(|item| item.as_table_mut())
338    {
339        let Some(provider_table) = model_providers.remove(source_provider_id.as_str()) else {
340            return Ok(config_text.to_string());
341        };
342        model_providers[stable_provider_id.as_str()] = provider_table;
343    }
344
345    rewrite_codex_profile_model_provider_refs(&mut doc, &source_provider_id, &stable_provider_id);
346    doc["model_provider"] = toml_edit::value(stable_provider_id.as_str());
347
348    Ok(doc.to_string())
349}
350
351fn rewrite_codex_profile_model_provider_refs(
352    doc: &mut DocumentMut,
353    source_provider_id: &str,
354    stable_provider_id: &str,
355) {
356    let Some(profiles) = doc
357        .get_mut("profiles")
358        .and_then(|item| item.as_table_like_mut())
359    else {
360        return;
361    };
362
363    let profile_keys: Vec<String> = profiles.iter().map(|(key, _)| key.to_string()).collect();
364    for profile_key in profile_keys {
365        let Some(profile_table) = profiles
366            .get_mut(&profile_key)
367            .and_then(|item| item.as_table_like_mut())
368        else {
369            continue;
370        };
371
372        let references_source = profile_table
373            .get("model_provider")
374            .and_then(|item| item.as_str())
375            == Some(source_provider_id);
376        if references_source {
377            profile_table.insert("model_provider", toml_edit::value(stable_provider_id));
378        }
379    }
380}
381
382/// Rewrite a Codex snapshot to reuse an existing live custom `model_provider`.
383///
384/// This is intentionally **not** used for normal provider switches: the live
385/// `config.toml` should show the selected provider id. It remains useful for
386/// proxy takeover backup/restore flows that explicitly want a history-stable
387/// Codex `model_provider` alias.
388pub fn normalize_codex_settings_config_model_provider(
389    settings: &mut Value,
390    anchor_config_text: Option<&str>,
391) -> Result<(), AppError> {
392    let Some(config_text) = settings
393        .get("config")
394        .and_then(|value| value.as_str())
395        .map(str::to_string)
396    else {
397        return Ok(());
398    };
399
400    let current_config_text = read_codex_config_text().ok();
401    let anchors = anchor_config_text
402        .into_iter()
403        .chain(current_config_text.as_deref());
404    let normalized =
405        normalize_codex_live_config_model_provider_with_anchors(&config_text, anchors)?;
406
407    if let Some(obj) = settings.as_object_mut() {
408        obj.insert("config".to_string(), Value::String(normalized));
409    }
410
411    Ok(())
412}
413
414fn restore_codex_backfill_model_provider_id(
415    config_text: &str,
416    template_config_text: &str,
417) -> Result<String, AppError> {
418    let Some(template_provider_id) =
419        codex_model_provider_id_with_table_from_config(template_config_text)?
420    else {
421        return Ok(config_text.to_string());
422    };
423
424    if config_text.trim().is_empty() {
425        return Ok(config_text.to_string());
426    }
427
428    let mut doc = config_text
429        .parse::<DocumentMut>()
430        .map_err(|e| AppError::Message(format!("Invalid Codex config.toml: {e}")))?;
431    let Some(live_provider_id) = active_codex_model_provider_id(&doc) else {
432        return Ok(config_text.to_string());
433    };
434
435    if live_provider_id == template_provider_id {
436        return Ok(config_text.to_string());
437    }
438
439    if let Some(model_providers) = doc
440        .get_mut("model_providers")
441        .and_then(|item| item.as_table_mut())
442    {
443        let Some(provider_table) = model_providers.remove(live_provider_id.as_str()) else {
444            return Ok(config_text.to_string());
445        };
446        model_providers[template_provider_id.as_str()] = provider_table;
447    } else {
448        return Ok(config_text.to_string());
449    }
450
451    rewrite_codex_profile_model_provider_refs(&mut doc, &live_provider_id, &template_provider_id);
452    doc["model_provider"] = toml_edit::value(template_provider_id.as_str());
453
454    Ok(doc.to_string())
455}
456
457/// Convert a Codex live config that was normalized for history stability back
458/// to the provider-specific id used by the stored provider template.
459pub fn restore_codex_settings_config_model_provider_for_backfill(
460    settings: &mut Value,
461    template_settings: &Value,
462) -> Result<(), AppError> {
463    let Some(config_text) = settings
464        .get("config")
465        .and_then(|value| value.as_str())
466        .map(str::to_string)
467    else {
468        return Ok(());
469    };
470    let Some(template_config_text) = template_settings
471        .get("config")
472        .and_then(|value| value.as_str())
473    else {
474        return Ok(());
475    };
476
477    let restored = restore_codex_backfill_model_provider_id(&config_text, template_config_text)?;
478    if let Some(obj) = settings.as_object_mut() {
479        obj.insert("config".to_string(), Value::String(restored));
480    }
481
482    Ok(())
483}
484
485/// Merge a stored provider snapshot into the current live config.
486///
487/// Strategy: edit the **live** document in place, overlaying only the entries the
488/// snapshot explicitly provides. Everything else stays untouched — in particular
489/// every comment the user has placed in `~/.codex/config.toml` survives a
490/// provider switch, including:
491///
492/// - whole commented-out subtables (e.g. `# [mcp_servers.x] / # command = ...`,
493///   typically used to temporarily disable an MCP server),
494/// - commented-out root-level keys (e.g. `# disable_response_storage = true`),
495/// - free-floating header notes attached to a key (toml_edit treats these as
496///   the next key's prefix decor; overwriting that key in place preserves the
497///   decor because the key already existed in live),
498/// - trailing notes at end of file (document-level trailing decor).
499///
500/// Rules:
501///
502/// - `[mcp_servers]` is **never** overwritten from the snapshot. The user's live
503///   `[mcp_servers]` content (active subtables, commented-out subtables, and any
504///   loose comments around them) is preserved verbatim.
505/// - The narrow set in [`PROVIDER_SCOPED_KEYS`] is treated as **provider-scoped**:
506///   the snapshot is authoritative, live entries the snapshot doesn't cover are
507///   removed (so e.g. runtime trust under `[projects]` from the previous provider
508///   doesn't leak into the new one).
509/// - Every other root-level entry is treated as **user-owned**:
510///     - `preserve_user_preferences = true` (provider switch with applyCommonConfig
511///       honored): live wins when present; falls back to snapshot otherwise so an
512///       initial write still seeds keys from the snapshot (which is what carries
513///       merged common-snippet defaults).
514///     - `preserve_user_preferences = false` (common-snippet apply/clear, or
515///       provider with `applyCommonConfig=false`): snapshot drives. Live keys
516///       absent from the snapshot are removed so old snippet residue doesn't
517///       bleed through.
518///
519/// Defaulting *all* non-blacklisted root keys to user-owned is intentional: it
520/// keeps the helper forward-compatible. When Codex adds a new root-level
521/// preference (e.g. `sandbox_mode`, `verbose_logging`, …), users' live values
522/// survive switches without anyone having to update this list. Only the small
523/// set of keys that must hard-sync between providers needs to be maintained.
524///
525/// Currently provider-scoped:
526/// - `model_provider` — pointer to the active `[model_providers.X]` entry.
527/// - `model` — currently-selected model name, conventionally per-provider.
528/// - `profile` — selected profile name, paired with `[profiles]`.
529/// - `model_providers` — provider definitions; replaced wholesale per snapshot.
530/// - `projects` — per-provider runtime trust list.
531/// - `profiles` — may point at provider-specific `model_provider` keys.
532pub fn merge_provider_into_codex_live_config(
533    live_text: &str,
534    provider_snapshot: &str,
535    preserve_user_preferences: bool,
536) -> Result<String, AppError> {
537    /// Root-level keys whose value must strictly follow the active provider's
538    /// snapshot. Anything not listed here is treated as user-owned and follows
539    /// the `preserve_user_preferences` rules above, so adding a new preference
540    /// key in Codex does NOT require code changes here.
541    const PROVIDER_SCOPED_KEYS: &[&str] = &[
542        "model_provider",
543        "model",
544        "profile",
545        "model_providers",
546        "projects",
547        "profiles",
548    ];
549
550    let mut live = if live_text.trim().is_empty() {
551        toml_edit::DocumentMut::new()
552    } else {
553        live_text
554            .parse::<toml_edit::DocumentMut>()
555            .map_err(|e| AppError::Message(format!("Invalid Codex live config.toml: {e}")))?
556    };
557
558    let snap = if provider_snapshot.trim().is_empty() {
559        toml_edit::DocumentMut::new()
560    } else {
561        provider_snapshot
562            .parse::<toml_edit::DocumentMut>()
563            .map_err(|e| AppError::Message(format!("Invalid Codex provider snapshot: {e}")))?
564    };
565
566    // Step 1: figure out which live entries to drop.
567    //
568    // - `[mcp_servers]` is never touched.
569    // - Provider-scoped keys are dropped when the snapshot does not provide
570    //   them, so the previous provider's [projects] / [model_providers.OLD]
571    //   don't leak.
572    // - User-owned keys (everything else) are dropped only when
573    //   `preserve_user_preferences = false` AND the snapshot does not provide
574    //   them, so common-snippet residue gets cleared but ordinary user
575    //   preferences are kept on a normal switch.
576    let live_keys: Vec<String> = live.as_table().iter().map(|(k, _)| k.to_string()).collect();
577    for key in live_keys {
578        if key == "mcp_servers" {
579            continue;
580        }
581        if snap.get(&key).is_some() {
582            continue;
583        }
584        let is_provider_scoped = PROVIDER_SCOPED_KEYS.contains(&key.as_str());
585        if is_provider_scoped || !preserve_user_preferences {
586            live.as_table_mut().remove(&key);
587        }
588    }
589
590    // Step 2: overlay every snapshot entry except [mcp_servers].
591    //
592    // - Provider-scoped keys always overwrite live (the snapshot is the source
593    //   of truth for these).
594    // - User-owned keys overwrite live unless we are preserving live
595    //   preferences AND the key already exists in live (in which case live
596    //   wins). When the key is missing from live we still take the snapshot's
597    //   value so initial writes get seeded.
598    let snap_keys: Vec<String> = snap.as_table().iter().map(|(k, _)| k.to_string()).collect();
599    for key in snap_keys {
600        if key == "mcp_servers" {
601            continue;
602        }
603        let is_provider_scoped = PROVIDER_SCOPED_KEYS.contains(&key.as_str());
604        if !is_provider_scoped && preserve_user_preferences && live.get(&key).is_some() {
605            continue;
606        }
607        if let Some(val) = snap.get(&key) {
608            live[key.as_str()] = val.clone();
609        }
610    }
611
612    Ok(live.to_string())
613}
614
615/// Rewrite a stored Codex provider snapshot to use a specific provider key.
616///
617/// This updates both the root `model_provider` and the matching
618/// `[model_providers.<key>]` table, while preserving the provider table body and
619/// profile references.
620pub fn rewrite_codex_config_model_provider_key(
621    config_text: &str,
622    target_provider_id: &str,
623) -> Result<String, AppError> {
624    if config_text.trim().is_empty() {
625        return Ok(String::new());
626    }
627
628    let target_provider_id = clean_codex_provider_key(target_provider_id);
629    let mut doc = config_text
630        .parse::<DocumentMut>()
631        .map_err(|e| AppError::Message(format!("Invalid Codex config.toml: {e}")))?;
632    let Some(source_provider_id) = primary_codex_model_provider_id_with_table(&doc) else {
633        return Ok(config_text.to_string());
634    };
635
636    if let Some(model_providers) = doc
637        .get_mut("model_providers")
638        .and_then(|item| item.as_table_mut())
639    {
640        if source_provider_id != target_provider_id {
641            let Some(provider_table) = model_providers.remove(source_provider_id.as_str()) else {
642                return Ok(config_text.to_string());
643            };
644            model_providers[target_provider_id.as_str()] = provider_table;
645            rewrite_codex_profile_model_provider_refs(
646                &mut doc,
647                &source_provider_id,
648                &target_provider_id,
649            );
650        }
651    } else {
652        return Ok(config_text.to_string());
653    }
654
655    doc["model_provider"] = toml_edit::value(target_provider_id.as_str());
656
657    Ok(doc.to_string())
658}
659
660/// Generate a clean TOML key from a raw string for use as `model_provider` and `[model_providers.<key>]`.
661///
662/// Lowercases ASCII alphanumerics, replaces everything else with `_`, trims leading/trailing `_`.
663/// Falls back to `"custom"` if the result is empty.
664pub fn clean_codex_provider_key(raw: &str) -> String {
665    let mut key: String = raw
666        .chars()
667        .map(|c| {
668            if c.is_ascii_alphanumeric() {
669                c.to_ascii_lowercase()
670            } else {
671                '_'
672            }
673        })
674        .collect();
675
676    while key.starts_with('_') {
677        key.remove(0);
678    }
679    while key.ends_with('_') {
680        key.pop();
681    }
682
683    if key.is_empty() {
684        "custom".to_string()
685    } else {
686        key
687    }
688}
689
690#[cfg(test)]
691mod tests {
692    use super::*;
693
694    #[test]
695    fn merge_preserves_user_preferences_and_mcp_from_live() {
696        // Current live config has user preferences and MCP servers
697        let live = indoc::indoc! {r#"
698            model_provider = "old-provider"
699            model = "old-model"
700            disable_response_storage = true
701            model_reasoning_effort = "xhigh"
702            approval_mode = "auto-edit"
703            check_for_update_on_startup = false
704
705            [model_providers.old-provider]
706            name = "Old"
707
708            [projects."/tmp/work"]
709            trusted = true
710
711            [mcp_servers.cargo-mcp]
712            type = "stdio"
713        "#};
714
715        // Snapshot has different provider and its own [projects], no [mcp_servers]
716        let snapshot = indoc::indoc! {r#"
717            model_provider = "new-provider"
718            model = "new-model"
719            approval_mode = "suggest"
720
721            [model_providers.new-provider]
722            name = "New"
723            api_key = "sk-test"
724
725            [projects."/tmp/other"]
726            trusted = true
727        "#};
728
729        let merged = merge_provider_into_codex_live_config(live, snapshot, true).unwrap();
730        let doc: toml_edit::DocumentMut = merged.parse().unwrap();
731
732        // Provider fields come from snapshot
733        assert_eq!(doc["model_provider"].as_str(), Some("new-provider"));
734        assert_eq!(doc["model"].as_str(), Some("new-model"));
735        assert!(doc
736            .get("model_providers")
737            .unwrap()
738            .get("new-provider")
739            .is_some());
740        assert!(doc
741            .get("model_providers")
742            .unwrap()
743            .get("old-provider")
744            .is_none());
745
746        // User preferences come from live (not snapshot's approval_mode)
747        assert_eq!(doc["disable_response_storage"].as_bool(), Some(true));
748        assert_eq!(doc["model_reasoning_effort"].as_str(), Some("xhigh"));
749        assert_eq!(doc["approval_mode"].as_str(), Some("auto-edit"));
750        assert_eq!(doc["check_for_update_on_startup"].as_bool(), Some(false));
751
752        // [projects] comes from snapshot (provider isolation)
753        assert!(doc.get("projects").unwrap().get("/tmp/other").is_some());
754        assert!(doc.get("projects").unwrap().get("/tmp/work").is_none());
755
756        // [mcp_servers] comes from live (preserves comments and manual edits)
757        assert!(doc.get("mcp_servers").is_some());
758        assert!(doc.get("mcp_servers").unwrap().get("cargo-mcp").is_some());
759    }
760
761    #[test]
762    fn merge_keeps_commented_out_mcp_entries_verbatim() {
763        // The user has temporarily disabled an MCP server by commenting out
764        // its whole subtable. Switching providers must NOT uncomment these
765        // lines or drop them. Also exercises a comment-only line before an
766        // active subtable, and a trailing comment after a value.
767        let live = "\
768model_provider = \"old\"
769approval_mode = \"suggest\"
770
771# top-level note for mcp_servers section
772[mcp_servers.active]
773# comment before command
774command = \"runme\" # trailing comment
775
776# this one is temporarily disabled
777# [mcp_servers.disabled]
778# command = \"nope\"
779# args = [\"--off\"]
780";
781
782        let snapshot = "\
783model_provider = \"new\"
784
785[model_providers.new]
786name = \"New\"
787";
788
789        let merged = merge_provider_into_codex_live_config(live, snapshot, true).unwrap();
790
791        // Every comment line from the live mcp_servers region must survive verbatim.
792        for needle in [
793            "# top-level note for mcp_servers section",
794            "# comment before command",
795            "# trailing comment",
796            "# this one is temporarily disabled",
797            "# [mcp_servers.disabled]",
798            "# command = \"nope\"",
799            "# args = [\"--off\"]",
800        ] {
801            assert!(
802                merged.contains(needle),
803                "merged output is missing comment line: {needle:?}\n--- merged ---\n{merged}"
804            );
805        }
806
807        // And the structural parts still parse and resolve as expected.
808        let doc: toml_edit::DocumentMut = merged.parse().unwrap();
809        assert_eq!(doc["model_provider"].as_str(), Some("new"));
810        assert!(doc
811            .get("mcp_servers")
812            .and_then(|t| t.get("active"))
813            .is_some());
814        assert!(doc
815            .get("mcp_servers")
816            .and_then(|t| t.get("disabled"))
817            .is_none());
818    }
819
820    #[test]
821    fn merge_seeds_preferences_from_snapshot_when_live_is_empty() {
822        // Initial write path: live config doesn't exist yet, snapshot carries
823        // merged common-snippet defaults like disable_response_storage.
824        // With preserve_user_preferences=true, the snapshot's preferences must
825        // still land in the resulting file — otherwise the common snippet is lost.
826        let snapshot = indoc::indoc! {r#"
827            model_provider = "first"
828            model = "gpt-5.2-codex"
829            disable_response_storage = true
830            approval_mode = "suggest"
831
832            [model_providers.first]
833            base_url = "https://api.example/v1"
834        "#};
835
836        let merged = merge_provider_into_codex_live_config("", snapshot, true).unwrap();
837        assert!(
838            merged.contains("disable_response_storage = true"),
839            "snapshot-provided preference should seed an empty live config\n--- merged ---\n{merged}"
840        );
841        assert!(merged.contains("approval_mode = \"suggest\""));
842        assert!(merged.contains("model_provider = \"first\""));
843    }
844
845    #[test]
846    fn merge_with_preserve_false_drops_live_prefs_missing_from_snapshot() {
847        // Common-snippet "clear" path: snapshot has no preference keys, so live
848        // preferences left behind by a previous snippet must be removed.
849        let live = indoc::indoc! {r#"
850            model_provider = "p1"
851            disable_response_storage = true
852            model_reasoning_effort = "xhigh"
853
854            [model_providers.p1]
855            base_url = "https://a"
856        "#};
857
858        let snapshot = indoc::indoc! {r#"
859            model_provider = "p1"
860
861            [model_providers.p1]
862            base_url = "https://a"
863        "#};
864
865        let merged = merge_provider_into_codex_live_config(live, snapshot, false).unwrap();
866        let doc: toml_edit::DocumentMut = merged.parse().unwrap();
867        assert!(doc.get("disable_response_storage").is_none());
868        assert!(doc.get("model_reasoning_effort").is_none());
869        assert_eq!(doc["model_provider"].as_str(), Some("p1"));
870    }
871
872    #[test]
873    fn merge_keeps_unknown_root_keys_from_live_on_switch() {
874        // Regression guard for forward compatibility: if Codex introduces a
875        // new root-level preference key tomorrow (e.g. `sandbox_mode`,
876        // `verbose_logging`, or anything else not yet listed in
877        // PROVIDER_SCOPED_KEYS), the user's live value must survive a
878        // provider switch without us having to update this file.
879        let live = indoc::indoc! {r#"
880            model_provider = "old"
881            sandbox_mode = "danger-full-access"
882            verbose_logging = true
883
884            [model_providers.old]
885            name = "Old"
886        "#};
887
888        // Snapshot is from a stored provider that doesn't know about the new
889        // keys at all (older snapshot, or a provider configured before the
890        // user added them).
891        let snapshot = indoc::indoc! {r#"
892            model_provider = "new"
893
894            [model_providers.new]
895            name = "New"
896        "#};
897
898        let merged = merge_provider_into_codex_live_config(live, snapshot, true).unwrap();
899        let doc: toml_edit::DocumentMut = merged.parse().unwrap();
900
901        // Provider-scoped keys followed the snapshot.
902        assert_eq!(doc["model_provider"].as_str(), Some("new"));
903        assert!(doc
904            .get("model_providers")
905            .and_then(|t| t.get("new"))
906            .is_some());
907        assert!(doc
908            .get("model_providers")
909            .and_then(|t| t.get("old"))
910            .is_none());
911
912        // Unknown root keys stayed put.
913        assert_eq!(doc["sandbox_mode"].as_str(), Some("danger-full-access"));
914        assert_eq!(doc["verbose_logging"].as_bool(), Some(true));
915    }
916
917    #[test]
918    fn merge_keeps_root_level_comments_around_overwritten_keys() {
919        // In toml_edit's model, comment-only lines between two root-level
920        // keys are attached to the **next** key's prefix decor. When the
921        // snapshot overwrites that next key, the comments must still be
922        // there afterwards — otherwise users lose any inline notes they
923        // sprinkled into ~/.codex/config.toml.
924        let live = "\
925# pinned by me — do not change without checking the runbook
926model_provider = \"old\"
927
928# disabled while debugging issue-1234
929# disable_response_storage = true
930approval_mode = \"auto-edit\"
931
932# trailing footnote at EOF
933";
934
935        let snapshot = "\
936model_provider = \"new\"
937
938[model_providers.new]
939name = \"New\"
940";
941
942        let merged = merge_provider_into_codex_live_config(live, snapshot, true).unwrap();
943
944        for needle in [
945            "# pinned by me — do not change without checking the runbook",
946            "# disabled while debugging issue-1234",
947            "# disable_response_storage = true",
948            "# trailing footnote at EOF",
949        ] {
950            assert!(
951                merged.contains(needle),
952                "merged output is missing comment line: {needle:?}\n--- merged ---\n{merged}"
953            );
954        }
955
956        let doc: toml_edit::DocumentMut = merged.parse().unwrap();
957        assert_eq!(doc["model_provider"].as_str(), Some("new"));
958        assert_eq!(doc["approval_mode"].as_str(), Some("auto-edit"));
959    }
960
961    #[test]
962    fn merge_omits_mcp_section_when_live_has_none() {
963        let live = indoc::indoc! {r#"
964            model_provider = "foo"
965            approval_mode = "suggest"
966        "#};
967
968        let snapshot = indoc::indoc! {r#"
969            model_provider = "bar"
970        "#};
971
972        let merged = merge_provider_into_codex_live_config(live, snapshot, true).unwrap();
973        let doc: toml_edit::DocumentMut = merged.parse().unwrap();
974
975        assert_eq!(doc["model_provider"].as_str(), Some("bar"));
976        assert!(doc.get("mcp_servers").is_none());
977        assert_eq!(doc["approval_mode"].as_str(), Some("suggest"));
978    }
979    use crate::app_config::AppType;
980    use crate::test_support::{lock_test_home_and_settings, set_test_home_override};
981    use std::ffi::OsString;
982    use std::path::Path;
983
984    struct CodexHomeEnvGuard {
985        original: Option<OsString>,
986    }
987
988    impl CodexHomeEnvGuard {
989        fn new(value: Option<&str>) -> Self {
990            let original = std::env::var_os("CODEX_HOME");
991            match value {
992                Some(v) => unsafe { std::env::set_var("CODEX_HOME", v) },
993                None => unsafe { std::env::remove_var("CODEX_HOME") },
994            }
995            Self { original }
996        }
997    }
998
999    impl Drop for CodexHomeEnvGuard {
1000        fn drop(&mut self) {
1001            match self.original.as_ref() {
1002                Some(value) => unsafe { std::env::set_var("CODEX_HOME", value) },
1003                None => unsafe { std::env::remove_var("CODEX_HOME") },
1004            }
1005        }
1006    }
1007
1008    struct SettingsGuard {
1009        original: crate::settings::AppSettings,
1010    }
1011
1012    impl SettingsGuard {
1013        fn with_codex_config_dir(dir: Option<&str>) -> Self {
1014            let original = crate::settings::get_settings();
1015            let mut settings = original.clone();
1016            settings.codex_config_dir = dir.map(str::to_string);
1017            crate::settings::update_settings(settings).unwrap();
1018            Self { original }
1019        }
1020    }
1021
1022    impl Drop for SettingsGuard {
1023        fn drop(&mut self) {
1024            let _ = crate::settings::update_settings(self.original.clone());
1025        }
1026    }
1027
1028    #[test]
1029    fn get_codex_config_dir_respects_codex_home_env_var_and_tilde() {
1030        let _guard = lock_test_home_and_settings();
1031        let _settings = SettingsGuard::with_codex_config_dir(None);
1032        let _env = CodexHomeEnvGuard::new(Some("~/.config/codex"));
1033        set_test_home_override(Some(Path::new("/tmp/codex-home-tilde")));
1034
1035        assert_eq!(
1036            get_codex_config_dir(),
1037            PathBuf::from("/tmp/codex-home-tilde")
1038                .join(".config")
1039                .join("codex")
1040        );
1041
1042        set_test_home_override(None);
1043    }
1044
1045    #[test]
1046    fn get_codex_config_dir_env_overrides_settings_override() {
1047        let _guard = lock_test_home_and_settings();
1048        let _settings = SettingsGuard::with_codex_config_dir(Some("/tmp/settings-codex"));
1049        let _env = CodexHomeEnvGuard::new(Some("/tmp/env-codex"));
1050        set_test_home_override(Some(Path::new("/tmp/codex-home")));
1051
1052        assert_eq!(get_codex_config_dir(), PathBuf::from("/tmp/env-codex"));
1053
1054        set_test_home_override(None);
1055    }
1056
1057    #[test]
1058    fn codex_live_sync_detects_initialized_codex_home_from_env() {
1059        let _guard = lock_test_home_and_settings();
1060        let _settings = SettingsGuard::with_codex_config_dir(None);
1061        let env_home = PathBuf::from("/tmp/codex-live-sync-env");
1062        let _env = CodexHomeEnvGuard::new(Some(env_home.to_str().unwrap()));
1063        set_test_home_override(Some(Path::new("/tmp/codex-live-sync-home")));
1064
1065        std::fs::create_dir_all(&env_home).expect("create CODEX_HOME");
1066
1067        assert!(crate::sync_policy::should_sync_live(&AppType::Codex));
1068
1069        let _ = std::fs::remove_dir_all(&env_home);
1070        set_test_home_override(None);
1071    }
1072
1073    #[test]
1074    fn normalize_live_config_preserves_current_custom_model_provider_id() {
1075        let current = r#"model_provider = "rightcode"
1076
1077[model_providers.rightcode]
1078name = "RightCode"
1079base_url = "https://rightcode.example/v1"
1080wire_api = "responses"
1081"#;
1082        let target = r#"model_provider = "aihubmix"
1083model = "gpt-5.4"
1084
1085[model_providers.aihubmix]
1086name = "AiHubMix"
1087base_url = "https://aihubmix.example/v1"
1088wire_api = "responses"
1089requires_openai_auth = true
1090
1091[mcp_servers.context7]
1092command = "npx"
1093"#;
1094
1095        let result =
1096            normalize_codex_live_config_model_provider_with_anchors(target, Some(current)).unwrap();
1097        let parsed: toml::Value = toml::from_str(&result).unwrap();
1098
1099        assert_eq!(
1100            parsed.get("model_provider").and_then(|v| v.as_str()),
1101            Some("rightcode")
1102        );
1103
1104        let model_providers = parsed
1105            .get("model_providers")
1106            .and_then(|v| v.as_table())
1107            .expect("model_providers should exist");
1108        assert!(
1109            model_providers.get("aihubmix").is_none(),
1110            "source provider id should not remain in live config"
1111        );
1112
1113        let stable_provider = model_providers
1114            .get("rightcode")
1115            .expect("stable provider table should exist");
1116        assert_eq!(
1117            stable_provider.get("base_url").and_then(|v| v.as_str()),
1118            Some("https://aihubmix.example/v1")
1119        );
1120        assert!(
1121            parsed.get("mcp_servers").is_some(),
1122            "unrelated config should be preserved"
1123        );
1124    }
1125
1126    #[test]
1127    fn normalize_live_config_uses_target_custom_provider_when_current_is_reserved() {
1128        let current = r#"model_provider = "openai""#;
1129        let target = r#"model_provider = "aihubmix"
1130
1131[model_providers.aihubmix]
1132name = "AiHubMix"
1133base_url = "https://aihubmix.example/v1"
1134wire_api = "responses"
1135"#;
1136
1137        let result =
1138            normalize_codex_live_config_model_provider_with_anchors(target, Some(current)).unwrap();
1139        let parsed: toml::Value = toml::from_str(&result).unwrap();
1140
1141        assert_eq!(
1142            parsed.get("model_provider").and_then(|v| v.as_str()),
1143            Some("aihubmix")
1144        );
1145        assert!(
1146            parsed
1147                .get("model_providers")
1148                .and_then(|v| v.get("aihubmix"))
1149                .is_some(),
1150            "target provider id should be kept when there is no reusable live custom id"
1151        );
1152    }
1153
1154    #[test]
1155    fn normalize_live_config_leaves_official_empty_config_unchanged() {
1156        let current = r#"model_provider = "rightcode"
1157
1158[model_providers.rightcode]
1159base_url = "https://rightcode.example/v1"
1160"#;
1161
1162        let result =
1163            normalize_codex_live_config_model_provider_with_anchors("", Some(current)).unwrap();
1164
1165        assert_eq!(result, "");
1166    }
1167
1168    #[test]
1169    fn normalize_live_config_rewrites_matching_profile_model_provider_refs() {
1170        let current = r#"model_provider = "session_anchor"
1171
1172[model_providers.session_anchor]
1173name = "Session Anchor"
1174base_url = "https://anchor.example/v1"
1175wire_api = "responses"
1176"#;
1177        let target = r#"model_provider = "vendor_alpha"
1178model = "gpt-5.4"
1179profile = "work"
1180
1181[model_providers.vendor_alpha]
1182name = "Vendor Alpha"
1183base_url = "https://alpha.example/v1"
1184wire_api = "responses"
1185
1186[profiles.work]
1187model_provider = "vendor_alpha"
1188model = "gpt-5.4"
1189"#;
1190
1191        let result =
1192            normalize_codex_live_config_model_provider_with_anchors(target, Some(current)).unwrap();
1193        let parsed: toml::Value = toml::from_str(&result).unwrap();
1194
1195        assert_eq!(
1196            parsed.get("model_provider").and_then(|v| v.as_str()),
1197            Some("session_anchor")
1198        );
1199        assert_eq!(
1200            parsed
1201                .get("profiles")
1202                .and_then(|v| v.get("work"))
1203                .and_then(|v| v.get("model_provider"))
1204                .and_then(|v| v.as_str()),
1205            Some("session_anchor"),
1206            "profile override matching the rewritten provider should stay valid"
1207        );
1208    }
1209
1210    #[test]
1211    fn normalize_live_config_keeps_unrelated_profile_model_provider_refs() {
1212        let current = r#"model_provider = "session_anchor"
1213
1214[model_providers.session_anchor]
1215name = "Session Anchor"
1216base_url = "https://anchor.example/v1"
1217wire_api = "responses"
1218"#;
1219        let target = r#"model_provider = "vendor_alpha"
1220model = "gpt-5.4"
1221
1222[model_providers.vendor_alpha]
1223name = "Vendor Alpha"
1224base_url = "https://alpha.example/v1"
1225wire_api = "responses"
1226
1227[model_providers.local_profile]
1228name = "Local Profile"
1229base_url = "http://localhost:11434/v1"
1230wire_api = "responses"
1231
1232[profiles.local]
1233model_provider = "local_profile"
1234model = "local-model"
1235"#;
1236
1237        let result =
1238            normalize_codex_live_config_model_provider_with_anchors(target, Some(current)).unwrap();
1239        let parsed: toml::Value = toml::from_str(&result).unwrap();
1240
1241        assert_eq!(
1242            parsed
1243                .get("profiles")
1244                .and_then(|v| v.get("local"))
1245                .and_then(|v| v.get("model_provider"))
1246                .and_then(|v| v.as_str()),
1247            Some("local_profile"),
1248            "unrelated profile provider references should be preserved"
1249        );
1250        assert!(
1251            parsed
1252                .get("model_providers")
1253                .and_then(|v| v.get("local_profile"))
1254                .is_some(),
1255            "unrelated provider tables should also remain available"
1256        );
1257    }
1258
1259    #[test]
1260    fn normalize_live_config_keeps_stable_provider_across_repeated_switches() {
1261        let anchor = r#"model_provider = "session_anchor"
1262
1263[model_providers.session_anchor]
1264name = "Session Anchor"
1265base_url = "https://anchor.example/v1"
1266wire_api = "responses"
1267"#;
1268        let first_target = r#"model_provider = "vendor_alpha"
1269
1270[model_providers.vendor_alpha]
1271name = "Vendor Alpha"
1272base_url = "https://alpha.example/v1"
1273wire_api = "responses"
1274"#;
1275        let second_target = r#"model_provider = "vendor_beta"
1276
1277[model_providers.vendor_beta]
1278name = "Vendor Beta"
1279base_url = "https://beta.example/v1"
1280wire_api = "responses"
1281"#;
1282
1283        let first =
1284            normalize_codex_live_config_model_provider_with_anchors(first_target, Some(anchor))
1285                .unwrap();
1286        let second = normalize_codex_live_config_model_provider_with_anchors(
1287            second_target,
1288            Some(first.as_str()),
1289        )
1290        .unwrap();
1291        let parsed: toml::Value = toml::from_str(&second).unwrap();
1292
1293        assert_eq!(
1294            parsed.get("model_provider").and_then(|v| v.as_str()),
1295            Some("session_anchor"),
1296            "stable provider id should not drift across repeated switches"
1297        );
1298        assert_eq!(
1299            parsed
1300                .get("model_providers")
1301                .and_then(|v| v.get("session_anchor"))
1302                .and_then(|v| v.get("base_url"))
1303                .and_then(|v| v.as_str()),
1304            Some("https://beta.example/v1")
1305        );
1306    }
1307
1308    #[test]
1309    fn restore_backfill_config_rewrites_live_id_to_template_provider_id() {
1310        let mut settings = serde_json::json!({
1311            "config": r#"model_provider = "session_anchor"
1312model = "gpt-5.4"
1313profile = "work"
1314
1315[model_providers.session_anchor]
1316name = "AiHubMix"
1317base_url = "https://aihubmix.example/v1"
1318wire_api = "responses"
1319
1320[profiles.work]
1321model_provider = "session_anchor"
1322model = "gpt-5.4"
1323"#
1324        });
1325        let template = serde_json::json!({
1326            "config": r#"model_provider = "aihubmix"
1327
1328[model_providers.aihubmix]
1329name = "AiHubMix"
1330base_url = "https://aihubmix.example/v1"
1331"#
1332        });
1333
1334        restore_codex_settings_config_model_provider_for_backfill(&mut settings, &template)
1335            .unwrap();
1336
1337        let config = settings.get("config").and_then(Value::as_str).unwrap();
1338        let parsed: toml::Value = toml::from_str(config).unwrap();
1339        assert_eq!(
1340            parsed.get("model_provider").and_then(|v| v.as_str()),
1341            Some("aihubmix")
1342        );
1343        assert!(parsed
1344            .get("model_providers")
1345            .and_then(|v| v.get("aihubmix"))
1346            .is_some());
1347        assert_eq!(
1348            parsed
1349                .get("profiles")
1350                .and_then(|v| v.get("work"))
1351                .and_then(|v| v.get("model_provider"))
1352                .and_then(|v| v.as_str()),
1353            Some("aihubmix")
1354        );
1355    }
1356}