// unused imports removed use std::path::PathBuf; use crate::config::{ atomic_write, delete_file, get_home_dir, sanitize_provider_name, write_json_file, write_text_file, }; use crate::error::AppError; use serde_json::Value; use std::fs; use std::path::Path; use toml_edit::DocumentMut; /// 获取 Codex 配置目录路径 pub fn get_codex_config_dir() -> PathBuf { if let Some(custom) = crate::settings::get_codex_override_dir() { return custom; } get_home_dir().join(".codex") } /// 获取 Codex auth.json 路径 pub fn get_codex_auth_path() -> PathBuf { get_codex_config_dir().join("auth.json") } /// 获取 Codex config.toml 路径 pub fn get_codex_config_path() -> PathBuf { get_codex_config_dir().join("config.toml") } /// 获取 Codex 供应商配置文件路径 #[allow(dead_code)] pub fn get_codex_provider_paths( provider_id: &str, provider_name: Option<&str>, ) -> (PathBuf, PathBuf) { let base_name = provider_name .map(sanitize_provider_name) .unwrap_or_else(|| sanitize_provider_name(provider_id)); let auth_path = get_codex_config_dir().join(format!("auth-{base_name}.json")); let config_path = get_codex_config_dir().join(format!("config-{base_name}.toml")); (auth_path, config_path) } /// 删除 Codex 供应商配置文件 #[allow(dead_code)] pub fn delete_codex_provider_config( provider_id: &str, provider_name: &str, ) -> Result<(), AppError> { let (auth_path, config_path) = get_codex_provider_paths(provider_id, Some(provider_name)); delete_file(&auth_path).ok(); delete_file(&config_path).ok(); Ok(()) } /// 原子写 Codex 的 `auth.json` 与 `config.toml`,在第二步失败时回滚第一步 pub fn write_codex_live_atomic( auth: &Value, config_text_opt: Option<&str>, ) -> Result<(), AppError> { let auth_path = get_codex_auth_path(); let config_path = get_codex_config_path(); if let Some(parent) = auth_path.parent() { std::fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; } // 读取旧内容用于回滚 let old_auth = if auth_path.exists() { Some(fs::read(&auth_path).map_err(|e| AppError::io(&auth_path, e))?) } else { None }; let _old_config = if config_path.exists() { Some(fs::read(&config_path).map_err(|e| AppError::io(&config_path, e))?) } else { None }; // 准备写入内容 let cfg_text = match config_text_opt { Some(s) => s.to_string(), None => String::new(), }; if !cfg_text.trim().is_empty() { toml::from_str::(&cfg_text).map_err(|e| AppError::toml(&config_path, e))?; } // 第一步:写 auth.json write_json_file(&auth_path, auth)?; // 第二步:写 config.toml(失败则回滚 auth.json) if let Err(e) = write_text_file(&config_path, &cfg_text) { // 回滚 auth.json if let Some(bytes) = old_auth { let _ = atomic_write(&auth_path, &bytes); } else { let _ = delete_file(&auth_path); } return Err(e); } Ok(()) } /// 读取 `~/.codex/config.toml`,若不存在返回空字符串 pub fn read_codex_config_text() -> Result { let path = get_codex_config_path(); if path.exists() { std::fs::read_to_string(&path).map_err(|e| AppError::io(&path, e)) } else { Ok(String::new()) } } /// 对非空的 TOML 文本进行语法校验 pub fn validate_config_toml(text: &str) -> Result<(), AppError> { if text.trim().is_empty() { return Ok(()); } toml::from_str::(text) .map(|_| ()) .map_err(|e| AppError::toml(Path::new("config.toml"), e)) } /// 读取并校验 `~/.codex/config.toml`,返回文本(可能为空) pub fn read_and_validate_codex_config_text() -> Result { let s = read_codex_config_text()?; validate_config_toml(&s)?; Ok(s) } /// Update a field in Codex config.toml using toml_edit (syntax-preserving). /// /// Supported fields: /// - `"base_url"`: writes to `[model_providers.].base_url` if `model_provider` exists, /// otherwise falls back to top-level `base_url`. /// - `"model"`: writes to top-level `model` field. /// /// Empty value removes the field. pub fn update_codex_toml_field(toml_str: &str, field: &str, value: &str) -> Result { let mut doc = toml_str .parse::() .map_err(|e| format!("TOML parse error: {e}"))?; let trimmed = value.trim(); match field { "base_url" => { let model_provider = doc .get("model_provider") .and_then(|item| item.as_str()) .map(str::to_string); if let Some(provider_key) = model_provider { // Ensure [model_providers] table exists if doc.get("model_providers").is_none() { doc["model_providers"] = toml_edit::table(); } if let Some(model_providers) = doc["model_providers"].as_table_mut() { // Ensure [model_providers.] table exists if !model_providers.contains_key(&provider_key) { model_providers[&provider_key] = toml_edit::table(); } if let Some(provider_table) = model_providers[&provider_key].as_table_mut() { if trimmed.is_empty() { provider_table.remove("base_url"); } else { provider_table["base_url"] = toml_edit::value(trimmed); } return Ok(doc.to_string()); } } } // Fallback: no model_provider or structure mismatch → top-level base_url if trimmed.is_empty() { doc.as_table_mut().remove("base_url"); } else { doc["base_url"] = toml_edit::value(trimmed); } } "model" => { if trimmed.is_empty() { doc.as_table_mut().remove("model"); } else { doc["model"] = toml_edit::value(trimmed); } } _ => return Err(format!("unsupported field: {field}")), } Ok(doc.to_string()) } /// Remove `base_url` from the active model_provider section only if it matches `predicate`. /// Also removes top-level `base_url` if it matches. /// Used by proxy cleanup to strip local proxy URLs without touching user-configured URLs. pub fn remove_codex_toml_base_url_if(toml_str: &str, predicate: impl Fn(&str) -> bool) -> String { let mut doc = match toml_str.parse::() { Ok(doc) => doc, Err(_) => return toml_str.to_string(), }; let model_provider = doc .get("model_provider") .and_then(|item| item.as_str()) .map(str::to_string); if let Some(provider_key) = model_provider { if let Some(model_providers) = doc .get_mut("model_providers") .and_then(|v| v.as_table_mut()) { if let Some(provider_table) = model_providers .get_mut(provider_key.as_str()) .and_then(|v| v.as_table_mut()) { let should_remove = provider_table .get("base_url") .and_then(|item| item.as_str()) .map(&predicate) .unwrap_or(false); if should_remove { provider_table.remove("base_url"); } } } } // Fallback: also clean up top-level base_url if it matches let should_remove_root = doc .get("base_url") .and_then(|item| item.as_str()) .map(&predicate) .unwrap_or(false); if should_remove_root { doc.as_table_mut().remove("base_url"); } doc.to_string() } #[cfg(test)] mod tests { use super::*; #[test] fn base_url_writes_into_correct_model_provider_section() { let input = r#"model_provider = "any" model = "gpt-5.1-codex" [model_providers.any] name = "any" wire_api = "responses" "#; let result = update_codex_toml_field(input, "base_url", "https://example.com/v1").unwrap(); let parsed: toml::Value = toml::from_str(&result).unwrap(); let base_url = parsed .get("model_providers") .and_then(|v| v.get("any")) .and_then(|v| v.get("base_url")) .and_then(|v| v.as_str()) .expect("base_url should be in model_providers.any"); assert_eq!(base_url, "https://example.com/v1"); // Should NOT have top-level base_url assert!(parsed.get("base_url").is_none()); // wire_api preserved let wire_api = parsed .get("model_providers") .and_then(|v| v.get("any")) .and_then(|v| v.get("wire_api")) .and_then(|v| v.as_str()); assert_eq!(wire_api, Some("responses")); } #[test] fn base_url_creates_section_when_missing() { let input = r#"model_provider = "custom" model = "gpt-4" "#; let result = update_codex_toml_field(input, "base_url", "https://custom.api/v1").unwrap(); let parsed: toml::Value = toml::from_str(&result).unwrap(); let base_url = parsed .get("model_providers") .and_then(|v| v.get("custom")) .and_then(|v| v.get("base_url")) .and_then(|v| v.as_str()) .expect("should create section and set base_url"); assert_eq!(base_url, "https://custom.api/v1"); } #[test] fn base_url_falls_back_to_top_level_without_model_provider() { let input = r#"model = "gpt-4" "#; let result = update_codex_toml_field(input, "base_url", "https://fallback.api/v1").unwrap(); let parsed: toml::Value = toml::from_str(&result).unwrap(); let base_url = parsed .get("base_url") .and_then(|v| v.as_str()) .expect("should set top-level base_url"); assert_eq!(base_url, "https://fallback.api/v1"); } #[test] fn clearing_base_url_removes_only_from_correct_section() { let input = r#"model_provider = "any" [model_providers.any] name = "any" base_url = "https://old.api/v1" wire_api = "responses" [mcp_servers.context7] command = "npx" "#; let result = update_codex_toml_field(input, "base_url", "").unwrap(); let parsed: toml::Value = toml::from_str(&result).unwrap(); // base_url removed from model_providers.any let any_section = parsed .get("model_providers") .and_then(|v| v.get("any")) .expect("model_providers.any should exist"); assert!(any_section.get("base_url").is_none()); // wire_api preserved assert_eq!( any_section.get("wire_api").and_then(|v| v.as_str()), Some("responses") ); // mcp_servers untouched assert!(parsed.get("mcp_servers").is_some()); } #[test] fn model_field_operates_on_top_level() { let input = r#"model_provider = "any" model = "gpt-4" [model_providers.any] name = "any" "#; let result = update_codex_toml_field(input, "model", "gpt-5").unwrap(); let parsed: toml::Value = toml::from_str(&result).unwrap(); assert_eq!(parsed.get("model").and_then(|v| v.as_str()), Some("gpt-5")); // Clear model let result2 = update_codex_toml_field(&result, "model", "").unwrap(); let parsed2: toml::Value = toml::from_str(&result2).unwrap(); assert!(parsed2.get("model").is_none()); } #[test] fn preserves_comments_and_whitespace() { let input = r#"# My Codex config model_provider = "any" model = "gpt-4" # Provider section [model_providers.any] name = "any" base_url = "https://old.api/v1" "#; let result = update_codex_toml_field(input, "base_url", "https://new.api/v1").unwrap(); // Comments should be preserved assert!(result.contains("# My Codex config")); assert!(result.contains("# Provider section")); } #[test] fn does_not_misplace_when_profiles_section_follows() { let input = r#"model_provider = "any" [model_providers.any] name = "any" base_url = "https://old.api/v1" [profiles.default] model = "gpt-4" "#; let result = update_codex_toml_field(input, "base_url", "https://new.api/v1").unwrap(); let parsed: toml::Value = toml::from_str(&result).unwrap(); // base_url in correct section let base_url = parsed .get("model_providers") .and_then(|v| v.get("any")) .and_then(|v| v.get("base_url")) .and_then(|v| v.as_str()); assert_eq!(base_url, Some("https://new.api/v1")); // profiles section untouched let profile_model = parsed .get("profiles") .and_then(|v| v.get("default")) .and_then(|v| v.get("model")) .and_then(|v| v.as_str()); assert_eq!(profile_model, Some("gpt-4")); } #[test] fn remove_base_url_if_predicate() { let input = r#"model_provider = "any" [model_providers.any] name = "any" base_url = "http://127.0.0.1:5000/v1" wire_api = "responses" "#; let result = remove_codex_toml_base_url_if(input, |url| url.starts_with("http://127.0.0.1")); let parsed: toml::Value = toml::from_str(&result).unwrap(); let any_section = parsed .get("model_providers") .and_then(|v| v.get("any")) .unwrap(); assert!(any_section.get("base_url").is_none()); assert_eq!( any_section.get("wire_api").and_then(|v| v.as_str()), Some("responses") ); } #[test] fn remove_base_url_if_keeps_non_matching() { let input = r#"model_provider = "any" [model_providers.any] base_url = "https://production.api/v1" "#; let result = remove_codex_toml_base_url_if(input, |url| url.starts_with("http://127.0.0.1")); let parsed: toml::Value = toml::from_str(&result).unwrap(); let base_url = parsed .get("model_providers") .and_then(|v| v.get("any")) .and_then(|v| v.get("base_url")) .and_then(|v| v.as_str()); assert_eq!(base_url, Some("https://production.api/v1")); } }