mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-10-25 17:22:27 +08:00
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.
108 lines
4.2 KiB
Rust
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());
|
|
}
|