Files
Jason 0e6430abdb feat(codex): switch Codex by replacing only key fields
Switching, adding, editing, sync, universal providers and entering or
leaving local routing now write Codex through the write engine. Only
routing, credential, model, reasoning, context-window and compatibility
fields are replaced; MCP servers, [projects], comments and the user's own
provider tables stay byte-for-byte. auth.json, config.toml, the model
catalog, the managed-account marker and a device-local login stash commit
as one operation, so a crash at any point rolls forward on startup.

- Third-party routes are always written as [model_providers.custom] with
  the key in experimental_bearer_token. Rows that use another id, a
  top-level openai_base_url or a legacy reserved table are normalized on
  the way out. Switching to an official card leaves a dormant custom table
  (local proxy address plus placeholder credential) so older third-party
  sessions can still resume.
- requires_openai_auth follows the route's auth and whether a login is on
  disk; keyring and ephemeral credential stores are handled separately.
- A switch is refused before anything is written when the active profile
  overrides the route, the selected table is missing or built in, a
  keyless row would fall back to the official login, or the row has no
  auth.
- Provider tables an older version wrote are removed only when they can
  be proven to be CC Switch's. Tables a profile references are kept, and
  reserved ids that cannot be proven are renamed.
- Provider-exclusive fields, including web_search and a row's own
  model_catalog_json, are removed on the way out only when live still
  holds the previous provider's value.
- No more backfill. A native ChatGPT login that CC Switch deletes or
  overwrites is kept in ~/.cc-switch/codex-login-stash.json (0600, not
  synced) and restored when switching back to an official card; logging
  out on an official card discards it. Login snapshots already in
  official rows seed the stash once. A leftover API key in auth.json is
  deleted only when it matches a third-party row's key.
- Managed accounts fetch their token before taking the lock. If the CLI
  refreshes the login during a switch, the switch stops before writing
  anything. Once files are published, a later failure no longer rolls the
  row back; startup finishes the switch.
- Local routing: third-party routes get a fixed local-proxy table and the
  official route uses cc-switch-official plus the client's own login. The
  contract covers every field written, so a route switch with the same
  contract touches no files.
- The Codex editor shows what config.toml will look like after switching.
  On save, key fields go to the row and other edits go to live as global
  settings with a three-way check. Row fields that never reach live are
  listed read-only and can be copied.
- The Codex common-config snippet is frozen like Claude's; its UI and the
  TOML snippet command are removed. New Codex rows keep
  commonConfigEnabled: true for older versions that sync them.
- The write engine can delete a file as part of an operation.
- Integration-test mutexes tolerate poisoning so one failure no longer
  cascades into unrelated tests.
2026-09-27 22:38:42 +08:00

108 lines
4.2 KiB
Rust

use std::fs;
use std::path::PathBuf;
use cc_switch_lib::{AppError, MultiAppConfig};
mod support;
use support::{ensure_test_home, reset_test_fs, test_mutex};
fn cfg_path() -> PathBuf {
let home = std::env::var("HOME").expect("HOME should be set by ensure_test_home");
PathBuf::from(home).join(".cc-switch").join("config.json")
}
#[test]
fn load_v1_config_returns_error_and_does_not_write() {
let _guard = test_mutex().lock().unwrap_or_else(|e| e.into_inner());
reset_test_fs();
let home = ensure_test_home();
let path = cfg_path();
fs::create_dir_all(path.parent().unwrap()).expect("create cfg dir");
// 最小 v1 形状:providers + current,且不含 version/apps/mcp
let v1_json = r#"{"providers":{},"current":""}"#;
fs::write(&path, v1_json).expect("seed v1 json");
let before = fs::read_to_string(&path).expect("read before");
let err = MultiAppConfig::load().expect_err("v1 should not be auto-migrated");
match err {
AppError::Localized { key, .. } => assert_eq!(key, "config.unsupported_v1"),
other => panic!("expected Localized v1 error, got {other:?}"),
}
// 文件不应有任何变化,且不应生成 .bak
let after = fs::read_to_string(&path).expect("read after");
assert_eq!(before, after, "config.json should not be modified");
let bak = home.join(".cc-switch").join("config.json.bak");
assert!(!bak.exists(), ".bak should not be created on load error");
}
#[test]
fn load_v1_with_extra_version_still_treated_as_v1() {
let _guard = test_mutex().lock().unwrap_or_else(|e| e.into_inner());
reset_test_fs();
let home = ensure_test_home();
let path = cfg_path();
std::fs::create_dir_all(path.parent().unwrap()).expect("create cfg dir");
// 畸形:包含 providers + current + version,但没有 apps,应按 v1 处理
let v1_like = r#"{"providers":{},"current":"","version":2}"#;
std::fs::write(&path, v1_like).expect("seed v1-like json");
let before = std::fs::read_to_string(&path).expect("read before");
let err = MultiAppConfig::load().expect_err("v1-like should not be parsed as v2");
match err {
AppError::Localized { key, .. } => assert_eq!(key, "config.unsupported_v1"),
other => panic!("expected Localized v1 error, got {other:?}"),
}
let after = std::fs::read_to_string(&path).expect("read after");
assert_eq!(before, after, "config.json should not be modified");
let bak = home.join(".cc-switch").join("config.json.bak");
assert!(!bak.exists(), ".bak should not be created on v1-like error");
}
#[test]
fn load_invalid_json_returns_parse_error_and_does_not_write() {
let _guard = test_mutex().lock().unwrap_or_else(|e| e.into_inner());
reset_test_fs();
let home = ensure_test_home();
let path = cfg_path();
fs::create_dir_all(path.parent().unwrap()).expect("create cfg dir");
fs::write(&path, "{not json").expect("seed invalid json");
let before = fs::read_to_string(&path).expect("read before");
let err = MultiAppConfig::load().expect_err("invalid json should error");
match err {
AppError::Json { .. } => {}
other => panic!("expected Json error, got {other:?}"),
}
let after = fs::read_to_string(&path).expect("read after");
assert_eq!(before, after, "config.json should remain unchanged");
let bak = home.join(".cc-switch").join("config.json.bak");
assert!(!bak.exists(), ".bak should not be created on parse error");
}
#[test]
fn load_valid_v2_config_succeeds() {
let _guard = test_mutex().lock().unwrap_or_else(|e| e.into_inner());
reset_test_fs();
let _home = ensure_test_home();
let path = cfg_path();
fs::create_dir_all(path.parent().unwrap()).expect("create cfg dir");
// 使用默认结构序列化为 v2
let default_cfg = MultiAppConfig::default();
let json = serde_json::to_string_pretty(&default_cfg).expect("serialize default cfg");
fs::write(&path, json).expect("write v2 json");
let loaded = MultiAppConfig::load().expect("v2 should load successfully");
assert_eq!(loaded.version, 2);
assert!(loaded
.get_manager(&cc_switch_lib::AppType::Claude)
.is_some());
assert!(loaded.get_manager(&cc_switch_lib::AppType::Codex).is_some());
}