From 214a12bfd5908748f93463057694cfd1f56864e1 Mon Sep 17 00:00:00 2001 From: "Yu.Wz" Date: Tue, 5 May 2026 13:30:33 +0800 Subject: [PATCH 1/3] feat: add edit-json CLI command and fix custom JSON key preservation Add `cc-switch edit-json provider --app-type ` command for editing provider settings_config via external editor with `--force` flag for full replacement (default: incremental merge). Fix custom JSON keys disappearing after TUI switch/edit operations: - DB layer: save_provider and update_provider_settings_config now merge incoming settings_config with existing DB row to preserve custom keys - Live snapshots: Codex/Gemini build_effective_live_snapshot and refresh_provider_snapshot functions now clone provider.settings_config and update in-place instead of rebuilding from scratch Co-Authored-By: Claude Opus 4.7 --- docs/edit-json-cli-design.md | 116 ++++++ docs/prd-edit-json-cli.md | 157 ++++++++ src-tauri/src/cli/commands/edit_json.rs | 341 ++++++++++++++++++ src-tauri/src/cli/commands/mod.rs | 1 + src-tauri/src/cli/mod.rs | 4 + src-tauri/src/database/dao/providers.rs | 50 ++- src-tauri/src/main.rs | 1 + .../src/services/provider/common_config.rs | 2 +- src-tauri/src/services/provider/mod.rs | 90 +++-- src-tauri/src/services/provider/tests.rs | 186 ++++++++++ src-tauri/src/services/proxy.rs | 1 + src-tauri/src/store.rs | 1 + 12 files changed, 917 insertions(+), 33 deletions(-) create mode 100644 docs/edit-json-cli-design.md create mode 100644 docs/prd-edit-json-cli.md create mode 100644 src-tauri/src/cli/commands/edit_json.rs diff --git a/docs/edit-json-cli-design.md b/docs/edit-json-cli-design.md new file mode 100644 index 00000000..31ed7161 --- /dev/null +++ b/docs/edit-json-cli-design.md @@ -0,0 +1,116 @@ +# edit-json CLI 命令设计文档 + +## 需求背景 + +cc-switch 的 TUI 入口本质工作是:接受用户数据 → 生成 JSON → 写入 DB → 提供给后端使用。 + +现有流程是通过 TUI 表单收集字段,由表单逻辑拼接 JSON 后入库。现需提供一个新入口,绕过表单,允许用户直接编辑原始 JSON(在外部编辑器中),校验合法后直接保存到 DB。 + +## 范围 + +首个版本仅支持 `provider` 实体(`settings_config` 列),后续可扩展至 `mcp`、`prompts` 等其他实体。 + +## 命令格式 + +``` +cc-switch edit-json provider --app-type +``` + +- `` — provider ID +- `--app-type` — 必填,严格匹配:`claude` | `codex` | `gemini` | `opencode` | `openclaw` + +## 工作流(类似 git commit) + +``` + 打开外部编辑器 + │ + ┌──────────▼──────────┐ + │ 临时文件预填 JSON │ + │ (settings_config, │ + │ pretty-printed) │ + └──────────┬──────────┘ + │ 用户编辑、保存、关闭 + │ + ┌──────────▼──────────┐ + │ 读取临时文件内容 │ + │ 校验 JSON │ + │ ↓ 失败 → 报错退出 │ + │ ↓ 未改 → 提示取消 │ + │ ↓ 成功 → 写入 DB │ + └─────────────────────┘ +``` + +## 编辑目标 + +只编辑 `providers.settings_config` 列(TEXT,JSON blob)。不编辑 `meta` 及其他字段。 + +## 编辑器 + +使用 `$EDITOR` 环境变量,未设置则 fallback 到 `$VISUAL`,再 fallback 到系统默认(macOS: `vi`)。 + +复用已有的 `crate::cli::editor::open_external_editor()` 函数(`edit` crate 封装)。 + +## JSON 格式 + +临时文件中使用 `serde_json::to_string_pretty()` 格式化,保持可读性。 + +## 校验规则 + +分三层: + +1. **JSON 语法** — `serde_json::from_str::()` 成功 +2. **类型约束** — 必须为 JSON Object(不能是 array/string/null/数字) +3. **业务规则(app-type 特定)** — 复用现有校验逻辑: + - Codex 非官方 provider:`settings_config.config` 中的 TOML 必须能解析出非空的 `base_url` + - 其他 app-type 暂无额外业务校验 + +> 注意:不限制 JSON 内部的 key 集合。用户写入的自定义 KV 会被原样保存,不会丢失。 + +## 临时文件生命周期 + +| 场景 | 行为 | +|------|------| +| 保存成功 | 清理临时文件 | +| 校验失败 | 保留临时文件,错误信息中显示文件路径 | +| 未修改退出 | 清理临时文件,输出「未修改,已取消」 | + +## 输出 + +| 场景 | 输出 | +|------|------| +| 保存成功 | `✓ 已更新 provider '' () 的 settingsConfig` | +| 未修改 | `未修改,已取消` | +| JSON 语法错误 | `JSON 解析失败: \n临时文件保留在: ` | +| 非 Object | `settingsConfig 必须为 JSON Object\n临时文件保留在: ` | +| 业务校验失败 | `<具体校验错误信息>\n临时文件保留在: ` | + +## 命令层级 + +作为顶级子命令挂载到 `Commands` 枚举下,而非 `ProviderCommand` 下(方便后续扩展 `mcp`、`prompts` 等实体)。 + +``` +cc-switch +├── provider ... +├── mcp ... +├── edit-json ← 新增 +└── ... +``` + +## 实现涉及的文件 + +| 文件 | 改动 | +|------|------| +| `src-tauri/src/cli/commands/mod.rs` | 新增 `edit_json` 模块 | +| `src-tauri/src/cli/commands/edit_json.rs` | 新建,核心逻辑 | +| `src-tauri/src/cli/mod.rs` | `Commands` 枚举新增 `EditJson` 变体 | +| `src-tauri/src/main.rs` | `run()` 函数新增 dispatch 分支 | + +## 自定义 KV 安全性 + +现有校验逻辑 `validate_provider_submit` 只检查: +- provider name 非空 +- Codex(非官方)的 `settings_config.config` TOML 中 `base_url` 可解析且非空 + +`update_provider_settings_config` 方法直接序列化 `serde_json::Value` 写入 DB,不做 key 过滤。 + +TUI 表单回读时,无法匹配到表单字段的 JSON key 存储在 `form.extra` 中,表单保存时重新合并写入,不会丢失。 diff --git a/docs/prd-edit-json-cli.md b/docs/prd-edit-json-cli.md new file mode 100644 index 00000000..ebcdc5eb --- /dev/null +++ b/docs/prd-edit-json-cli.md @@ -0,0 +1,157 @@ +# PRD: edit-json CLI 命令 + +## Problem Statement + +cc-switch 用户目前只能通过 TUI 表单编辑 provider 的 `settings_config`。当用户需要批量修改 JSON 字段、写入自定义 KV(extensions/experimental 配置),或对 JSON 结构做精细调整时,表单操作效率低下且容易受限。用户需要一个绕过表单、直接在外部编辑器中编辑原始 JSON 的 CLI 入口。 + +## Solution + +新增顶级子命令 `cc-switch edit-json provider --app-type `,工作流类似 `git commit`:打开外部编辑器预填当前 JSON → 用户编辑保存 → 校验 → 直接写入 DB。首版仅支持 `provider` 实体,后续可扩展。 + +## User Stories + +1. As a developer, I want to run `cc-switch edit-json provider --app-type claude` so that I can directly edit the raw `settings_config` JSON in my preferred editor. +2. As a developer, I want the editor to be pre-filled with the current `settings_config` (pretty-printed), so that I don't need to manually look up or copy the existing JSON. +3. As a developer, I want JSON syntax validation to catch malformed edits before they hit the database, so that I don't corrupt my provider config. +4. As a developer, I want a clear error message with the edited content echoed back when validation fails, so that I can recover my work instead of losing it. +5. As a developer, I want the command to detect when I haven't made any changes and cancel gracefully, so that I don't accidentally trigger unnecessary DB writes. +6. As a developer, I want the command to enforce that `settings_config` is always a JSON Object (not an array, string, number, or null), so that the app config format remains consistent. +7. As a Codex user, I want non-official Codex providers to be validated for a non-empty `base_url` in their TOML config snippet, so that I don't save a broken Codex config. +8. As a developer, I want my custom JSON keys (not covered by any TUI form field) to be preserved as-is when I edit via this command, so that extensions and experimental configs are not lost. +9. As a developer, I want the command to respect my `$EDITOR` / `$VISUAL` environment variables, with a fallback to `vi`, so that my preferred editor is used. +10. As a developer, I want the command output to clearly indicate success, cancellation, or the specific validation error that occurred. +11. As a developer, I want the command to fail early with a clear error if the specified provider ID does not exist, so that I don't open an editor with empty content by mistake. +12. As a future developer, I want the `edit-json` command structure to easily support other entities (mcp, prompts) by adding new subcommands under the same top-level command, without restructuring the CLI. + +## Implementation Decisions + +### 模块架构 + +四个关注点,分层协作: + +**CLI 编排层** (`src-tauri/src/cli/commands/edit_json.rs`) — 新建文件,负责解析 clap 参数、调用 editor、编排工作流、格式化输出。不直接操作 DB,不实现校验逻辑。 + +**通用校验** (同文件内私有逻辑) — JSON 语法校验(`serde_json::from_str::`)和类型约束校验(必须是 JSON Object)。这两项是通用 one-liner,不涉及业务知识,放在编排层合理。 + +**业务校验** (委托给 `ProviderService`) — Codex base_url 检查复用 `ProviderService` 中已有的两个函数: +- `is_codex_official_provider(provider: &Provider) -> bool` — 判断是否官方 Codex provider(`src-tauri/src/services/provider/mod.rs:76`) +- `codex_config_has_base_url(config_text: &str) -> bool` — 解析 TOML 配置中的 base_url 并验证非空(`src-tauri/src/services/provider/mod.rs:88`),已覆盖 `base_url` 顶层键和 `model_providers..base_url` 两种 TOML 结构 + +这两个函数当前为 `fn`(私有),需改为 `pub(crate)`。 + +**为什么不在 edit_json.rs 中重新实现业务校验:** `is_codex_official_provider` 已在代码库中定义了 5 份独立副本(`services/provider/mod.rs`、`cli/commands/provider.rs`、`cli/tui/runtime_actions/editor.rs`、`cli/tui/data.rs`、`cli/tui/form/provider_state.rs`),7 个调用点散布在各层。新增第 6 份副本会让未来 Codex 配置格式变化时需要修改 6 处。暴露 ProviderService 中的 canonical 实现,建立单一事实来源。 + +**编辑-json mcp 扩展时遵循相同模式:** 找到 McpService 中已有的 validate 函数,暴露为 `pub(crate)` 后调用。 + +**持久化层** (复用已有 `Database::update_provider_settings_config`) — 位于 `src-tauri/src/database/dao/providers.rs:413-433`,直接执行 `UPDATE providers SET settings_config = ?1 WHERE id = ?2 AND app_type = ?3`,绕过 in-memory config 模型和全量快照持久化路径,只做单列部分更新。 + +### 命令参数设计 + +``` +cc-switch edit-json provider --app-type +``` + +- `` — 位置参数,provider ID +- `--app-type` — 必填选项,严格匹配 `claude | codex | gemini | opencode | openclaw`,复用 `AppType` 的 `FromStr` 实现(`src-tauri/src/app_config.rs:315-337`) + +命令作为 `Commands` 枚举的顶级变体 `EditJson(commands::edit_json::EditJsonCommand)`,内部 `EditJsonCommand` 枚举目前只有一个 `Provider` 变体,便于后续扩展 `Mcp`、`Prompts` 等。 + +### 外部编辑器调用 + +复用 `crate::cli::editor::open_external_editor(initial_content: &str) -> Result`(`src-tauri/src/cli/editor.rs:4-7`),底层为 `edit` crate(`edit = "0.1"`),自动读取 `$EDITOR` → `$VISUAL` → `vi` 回退链。 + +### 临时文件生命周期 + +`edit` crate(v0.1)在返回 `Ok(String)` 后自动删除临时文件。校验失败时内容已在内存中,无需保留临时文件。设计文档中"保留临时文件"的 recoverability 需求通过错误输出中回显编辑后的 JSON 内容来实现。 + +### 数据库读取路径 + +不走 `ProviderService` / in-memory `MultiAppConfig` 路径。直接通过 `Database::get_provider_by_id(app_type: &str, id: &str)` 查询 DB(`src-tauri/src/database/dao/providers.rs:127-176`),只读取 `settings_config` 列。该查询使用复合主键 `(id, app_type)`,返回 `Result, AppError>`。未找到时返回 `AppError::InvalidInput`。 + +### 工作流编排 + +``` +1. 解析 CLI 参数 (provider ID, app_type) +2. 获取 AppState::try_new(),打开 DB +3. db.get_provider_by_id(app_type.as_str(), &id) + - 未找到 → Err(AppError::InvalidInput("provider '' not found for app ''")) +4. serde_json::to_string_pretty(&provider.settings_config) → 初始内容 +5. open_external_editor(&initial) → 编辑后内容 +6. 比较编辑后内容.trim() == 初始内容.trim() + - 相同 → println!("未修改,已取消"); return Ok(()) +7. serde_json::from_str::(&edited) → JSON 语法校验 + - 失败 → Err(AppError::Message("JSON 解析失败: ")) +8. 校验编辑后内容为 JSON Object + - 非 Object → Err(AppError::Message("settingsConfig 必须为 JSON Object")) +9. 业务校验 (仅 Codex 非官方 provider): + - 调用 ProviderService::is_codex_official_provider(&provider) 判断 + - 非官方时提取 settings_config["config"] 字符串 → ProviderService::codex_config_has_base_url(config_str) + - 返回 false → Err(AppError::Message("Codex provider 必须配置非空的 base_url")) +10. db.update_provider_settings_config(app_type.as_str(), &id, &new_value) +11. println!("✓ 已更新 provider '' () 的 settingsConfig") +``` + +### 变更文件清单 + +| 文件 | 改动类型 | 说明 | +|------|---------|------| +| `src-tauri/src/cli/commands/edit_json.rs` | **新建** | CLI 编排:参数定义、workflow 调用、输出格式化;通用校验(JSON 语法、Object 类型约束)| +| `src-tauri/src/cli/commands/mod.rs` | 修改 | 新增 `pub mod edit_json;` | +| `src-tauri/src/cli/mod.rs` | 修改 | `Commands` 枚举新增 `EditJson(commands::edit_json::EditJsonCommand)` 变体 | +| `src-tauri/src/main.rs` | 修改 | `run()` 函数新增 `Commands::EditJson(cmd)` dispatch 分支 | +| `src-tauri/src/services/provider/mod.rs` | 修改 | `is_codex_official_provider` 和 `codex_config_has_base_url` 可见性从 `fn` 改为 `pub(crate)` | + +### 命令是否需要启动状态 + +`edit-json` 需要读取和写入 DB,因此必须初始化 `AppState`。在 `command_requires_startup_state()` 中属于默认的 `true` 分支,**无需修改**该函数。 + +### 错误输出约定 + +- 使用 `crate::cli::ui` 模块的 `success()`(绿色)、`info()`(青色)、`error()`(红色)进行格式化输出 +- 校验失败时,使用 `error()` 输出错误详情,并回显编辑后的 JSON 内容(满足 recoverability 需求) +- 成功时使用 `success()` 输出确认信息 +- 未修改时使用 `info()` 输出取消提示 + +## Testing Decisions + +### 测试原则 + +只测试外部可观测行为:给定输入,验证输出/副作用。不测试临时文件路径、编辑器调用过程(`edit` crate 自身已测)。不测试 `ProviderService` 内部校验逻辑(应在 service 层单独测试)。 + +### 测试模块 + +**集成测试** — `src-tauri/src/cli/commands/edit_json.rs` 内的 `#[cfg(test)] mod tests`,使用 `Database::memory()` 创建内存 SQLite: + +1. **成功更新** — 写入一个 provider,调用 workflow 核心函数(跳过编辑器交互,直接传入编辑后的 JSON 字符串),验证 DB 中 settings_config 已更新为预期值 +2. **provider 不存在** — 查询不存在的 ID,验证返回 `AppError::InvalidInput`,且未触发编辑器调用 +3. **JSON 语法错误** — 传入非 JSON 字符串(如 `{broken`),验证返回 `AppError` 且包含描述性错误信息 +4. **非 Object 校验** — 分别传入 JSON array (`[]`)、string (`"hello"`)、number (`42`)、null (`null`),验证均返回 `AppError` +5. **未修改检测** — 传入与 DB 中原始 JSON 完全相同的字符串,验证返回 `Ok(())` 且 DB 中数据未被 UPDATE +6. **边界:编辑器清空后保存 `{}`** — 传入 `{}`,验证通过校验并成功写入 DB(空 Object 合法) + +> 注意:Codex base_url 校验的正确性测试属于 `ProviderService::codex_config_has_base_url` 的单元测试范围,不在 edit-json 命令的集成测试中覆盖。edit-json 只需验证校验被正确**编排调用**(正式 provider 跳过、非正式 provider 触发),不对 TOML 解析内部路径做重复测试。 + +### 测试参考 + +- `Database::memory()` 构造器:`src-tauri/src/database/mod.rs` +- 已有测试模式:`src-tauri/src/database/tests.rs`(内存 DB + 直接构造测试数据) +- DAO 层测试:`src-tauri/src/database/dao/settings.rs:243`(`#[test]` 标注) + +## Out of Scope + +- 编辑 `mcp`、`prompts`、`skills` 等其他实体的 `edit-json` 子命令 +- 编辑 `meta` 字段(仅限 `settings_config` 列) +- 在编辑器中做 JSON Schema 自动补全或实时校验 +- 支持通过 stdin / pipe 传入 JSON(仅支持外部编辑器交互) +- 批量编辑多个 provider +- 将校验失败的临时文件持久化到磁盘(`edit` crate 自动清理;recoverability 通过错误输出回显内容实现) +- 回滚 / 撤销功能(依赖 DB 备份机制,非本命令职责) +- 同步更新 in-memory `MultiAppConfig`(edit-json 直接写 DB 单列;in-memory config 在下次 `AppState::try_new()` 时从 DB 重新加载,或 TUI 下次刷新时自然同步) + +## Further Notes + +- `edit` crate v0.1 在 macOS 上默认使用 `$EDITOR` → `$VISUAL` → `vi` 回退链,与设计文档中的 fallback 策略一致 +- 不限制 JSON 内部的 key 集合。TUI 表单已有 `form.extra` 机制保留无法匹配表单字段的自定义 KV(`src-tauri/src/cli/tui/form/provider_state.rs`),本次改动不与 TUI 流程冲突 +- `update_provider_settings_config` 是已有方法,现有调用方为 `migrate_legacy_codex_configs`(`src-tauri/src/store.rs:435`),本次新增调用不会改变其语义 +- 命令输出使用 ASCII 字符(`✓`),不依赖 emoji,与现有 CLI 输出风格一致 +- `is_codex_official_provider` 在 `ProviderService` 中的实现比 TUI/CLI 层的 4 份副本更简洁:只检查 `meta.codex_official` 和 `category == "official"` 两个条件,不含 `website_url` 和 `name` 的硬编码判断。暴露后所有调用方应逐步迁移到此实现 diff --git a/src-tauri/src/cli/commands/edit_json.rs b/src-tauri/src/cli/commands/edit_json.rs new file mode 100644 index 00000000..2c0a49bc --- /dev/null +++ b/src-tauri/src/cli/commands/edit_json.rs @@ -0,0 +1,341 @@ +use clap::Subcommand; +use serde_json::Value; + +use crate::app_config::AppType; +use crate::database::Database; +use crate::error::AppError; +use crate::provider::Provider; +use crate::services::provider::ProviderService; + +#[derive(Subcommand)] +pub enum EditJsonCommand { + /// Edit a provider's settings_config JSON in an external editor + Provider { + /// Provider ID + id: String, + + /// Application type + #[arg(long, value_enum)] + app_type: AppType, + + /// Replace settings_config entirely (skip merge with existing keys) + #[arg(long, default_value_t = false)] + force: bool, + }, +} + +pub fn execute(cmd: EditJsonCommand) -> Result<(), AppError> { + match cmd { + EditJsonCommand::Provider { + id, + app_type, + force, + } => edit_provider(&app_type, &id, force), + } +} + +fn edit_provider(app_type: &AppType, id: &str, force: bool) -> Result<(), AppError> { + let state = crate::store::AppState::try_new()?; + + let provider = state + .db + .get_provider_by_id(id, app_type.as_str())? + .ok_or_else(|| { + AppError::InvalidInput(format!( + "provider '{}' not found for app '{}'", + id, + app_type.as_str() + )) + })?; + + let initial = serde_json::to_string_pretty(&provider.settings_config) + .map_err(|e| AppError::Message(format!("failed to serialize settings_config: {e}")))?; + + let edited = crate::cli::editor::open_external_editor(&initial)?; + + if edited.trim() == initial.trim() { + println!("未修改,已取消"); + return Ok(()); + } + + let new_value = validate_edited_json(&edited, &provider, app_type)?; + + apply_settings_config_update(&state.db, app_type, id, &new_value, force)?; + + use crate::cli::ui::success; + println!( + "{}", + success(&format!( + "✓ 已更新 provider '{}' ({}) 的 settingsConfig", + id, + app_type.as_str() + )) + ); + Ok(()) +} + +/// Validate edited JSON: syntax → must be Object → business rules. +fn validate_edited_json( + edited: &str, + provider: &Provider, + app_type: &AppType, +) -> Result { + let value: Value = serde_json::from_str(edited).map_err(|e| { + AppError::Message(format!("JSON 解析失败: {e}")) + })?; + + if !value.is_object() { + return Err(AppError::Message( + "settingsConfig 必须为 JSON Object".to_string(), + )); + } + + if matches!(app_type, AppType::Codex) && !ProviderService::is_codex_official_provider(provider) { + let config_text = value + .get("config") + .and_then(Value::as_str) + .unwrap_or(""); + if !ProviderService::codex_config_has_base_url(config_text) { + return Err(AppError::Message( + "Codex provider 必须配置非空的 base_url".to_string(), + )); + } + } + + Ok(value) +} + +/// Write the validated JSON value directly to the database. +fn apply_settings_config_update( + db: &Database, + app_type: &AppType, + id: &str, + value: &Value, + force: bool, +) -> Result<(), AppError> { + db.update_provider_settings_config(app_type.as_str(), id, value, force) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::database::Database; + use crate::provider::{Provider, ProviderMeta}; + use serde_json::json; + + fn make_provider(id: &str, settings_config: Value) -> Provider { + Provider { + id: id.to_string(), + name: "Test Provider".to_string(), + settings_config, + website_url: None, + category: None, + created_at: None, + sort_index: None, + notes: None, + meta: Some(ProviderMeta::default()), + icon: None, + icon_color: None, + in_failover_queue: false, + } + } + + fn seed_provider(db: &Database, id: &str, app_type: &str, cfg: Value) { + let p = make_provider(id, cfg); + db.save_provider(app_type, &p).expect("seed provider"); + } + + #[test] + fn save_provider_update_merges_custom_keys() { + let db = Database::memory().expect("memory db"); + // Seed with a custom key + seed_provider( + &db, + "test-id", + "claude", + json!({"env": {"ANTHROPIC_BASE_URL": "https://old.example.com"}, "customKey": "my-value"}), + ); + + // Simulate update that only touches canonical keys + let mut updated = make_provider("test-id", json!({"env": {"ANTHROPIC_BASE_URL": "https://new.example.com"}})); + updated.meta = None; // so save_provider preserves old meta + db.save_provider("claude", &updated).expect("save"); + + let after = db + .get_provider_by_id("test-id", "claude") + .expect("query") + .expect("exists"); + // canonical key updated + assert_eq!(after.settings_config["env"]["ANTHROPIC_BASE_URL"], "https://new.example.com"); + // custom key preserved by merge + assert_eq!(after.settings_config["customKey"], "my-value"); + } + + #[test] + fn update_provider_settings_config_merges_by_default() { + let db = Database::memory().expect("memory db"); + seed_provider( + &db, + "test-id", + "claude", + json!({"env": {"BASE_URL": "old"}, "custom": "keep-me"}), + ); + + db.update_provider_settings_config( + "claude", + "test-id", + &json!({"env": {"BASE_URL": "new"}}), + false, // merge mode + ) + .expect("update"); + + let after = db + .get_provider_by_id("test-id", "claude") + .expect("query") + .expect("exists"); + assert_eq!(after.settings_config["env"]["BASE_URL"], "new"); + assert_eq!(after.settings_config["custom"], "keep-me"); + } + + #[test] + fn update_provider_settings_config_force_replaces_entirely() { + let db = Database::memory().expect("memory db"); + seed_provider( + &db, + "test-id", + "claude", + json!({"env": {"BASE_URL": "old"}, "custom": "should-be-gone"}), + ); + + db.update_provider_settings_config( + "claude", + "test-id", + &json!({"env": {"BASE_URL": "new"}}), + true, // force replace + ) + .expect("update"); + + let after = db + .get_provider_by_id("test-id", "claude") + .expect("query") + .expect("exists"); + assert_eq!(after.settings_config["env"]["BASE_URL"], "new"); + assert!(after.settings_config.get("custom").is_none(), "custom key should be removed by force replace"); + } + + #[test] + fn json_syntax_error() { + let db = Database::memory().expect("memory db"); + seed_provider(&db, "test-id", "claude", json!({"key": "value"})); + + let provider = db + .get_provider_by_id("test-id", "claude") + .expect("query") + .expect("exists"); + + let result = validate_edited_json("{broken", &provider, &AppType::Claude); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("JSON 解析失败")); + } + + #[test] + fn non_object_rejected() { + let db = Database::memory().expect("memory db"); + seed_provider(&db, "test-id", "claude", json!({"key": "value"})); + + let provider = db + .get_provider_by_id("test-id", "claude") + .expect("query") + .expect("exists"); + + for invalid in &["[]", "\"string\"", "42", "null"] { + let result = validate_edited_json(invalid, &provider, &AppType::Claude); + assert!( + result.is_err(), + "expected error for input: {}", + invalid + ); + let err = result.unwrap_err().to_string(); + assert!( + err.contains("JSON Object"), + "unexpected error for '{}': {err}", + invalid + ); + } + } + + #[test] + fn codex_official_skips_base_url_check() { + let db = Database::memory().expect("memory db"); + let mut provider = make_provider("test-id", json!({})); + provider.meta.as_mut().unwrap().codex_official = Some(true); + db.save_provider("codex", &provider).expect("seed"); + + let provider = db + .get_provider_by_id("test-id", "codex") + .expect("query") + .expect("exists"); + + let result = validate_edited_json("{}", &provider, &AppType::Codex); + assert!(result.is_ok(), "official codex should skip base_url check"); + } + + #[test] + fn codex_non_official_missing_base_url_fails() { + let db = Database::memory().expect("memory db"); + let provider = make_provider("test-id", json!({ + "config": "[model_provider]\nprovider = \"custom\"\n" + })); + db.save_provider("codex", &provider).expect("seed"); + + let provider = db + .get_provider_by_id("test-id", "codex") + .expect("query") + .expect("exists"); + + let edited = json!({"config": "[model_provider]\nprovider = \"custom\"\n"}).to_string(); + let result = validate_edited_json(&edited, &provider, &AppType::Codex); + assert!(result.is_err()); + + let err = result.unwrap_err().to_string(); + assert!(err.contains("base_url")); + } + + #[test] + fn no_change_detection() { + let db = Database::memory().expect("memory db"); + let original = json!({"key": "value"}); + seed_provider(&db, "test-id", "claude", original.clone()); + + let provider = db + .get_provider_by_id("test-id", "claude") + .expect("query") + .expect("exists"); + + let initial = serde_json::to_string_pretty(&provider.settings_config).unwrap(); + assert_eq!(initial.trim(), initial.trim()); + + let after = db + .get_provider_by_id("test-id", "claude") + .expect("query") + .expect("exists"); + assert_eq!(after.settings_config, original); + } + + #[test] + fn empty_object_is_valid() { + let db = Database::memory().expect("memory db"); + seed_provider(&db, "test-id", "claude", json!({"old": true})); + + let provider = db + .get_provider_by_id("test-id", "claude") + .expect("query") + .expect("exists"); + + let new_value = + validate_edited_json("{}", &provider, &AppType::Claude).expect("{} is valid"); + assert!(new_value.is_object()); + assert!(new_value.as_object().unwrap().is_empty()); + } +} diff --git a/src-tauri/src/cli/commands/mod.rs b/src-tauri/src/cli/commands/mod.rs index a7fb1549..29530051 100644 --- a/src-tauri/src/cli/commands/mod.rs +++ b/src-tauri/src/cli/commands/mod.rs @@ -1,4 +1,5 @@ pub mod completions; +pub mod edit_json; pub mod config; mod config_common; pub mod config_webdav; diff --git a/src-tauri/src/cli/mod.rs b/src-tauri/src/cli/mod.rs index 12cf8b1e..b344d0b5 100644 --- a/src-tauri/src/cli/mod.rs +++ b/src-tauri/src/cli/mod.rs @@ -78,6 +78,10 @@ pub enum Commands { /// Generate, install, inspect, or uninstall shell completions Completions(commands::completions::CompletionsCommand), + + /// Edit a provider's settings_config JSON in an external editor + #[command(subcommand)] + EditJson(commands::edit_json::EditJsonCommand), } /// Generate shell completions diff --git a/src-tauri/src/database/dao/providers.rs b/src-tauri/src/database/dao/providers.rs index c819a661..a59472de 100644 --- a/src-tauri/src/database/dao/providers.rs +++ b/src-tauri/src/database/dao/providers.rs @@ -6,8 +6,10 @@ use crate::database::dao::providers_seed::{is_official_seed_id, OFFICIAL_SEEDS}; use crate::database::{lock_conn, Database}; use crate::error::AppError; use crate::provider::{Provider, ProviderMeta}; +use crate::services::provider::json_deep_merge; use indexmap::IndexMap; use rusqlite::params; +use serde_json::Value; use std::collections::{HashMap, HashSet}; impl Database { @@ -292,6 +294,24 @@ impl Database { let (is_current, in_failover_queue) = existing.unwrap_or((false, provider.in_failover_queue)); + // Merge settings_config: preserve custom keys from existing DB row + let final_settings_config = if is_update { + let existing_cfg_str: String = tx + .query_row( + "SELECT settings_config FROM providers WHERE id = ?1 AND app_type = ?2", + params![provider.id, app_type], + |row| row.get(0), + ) + .map_err(|e| AppError::Database(e.to_string()))?; + let existing_cfg: Value = + serde_json::from_str(&existing_cfg_str).unwrap_or(Value::Null); + let mut merged = existing_cfg; + json_deep_merge(&mut merged, &provider.settings_config); + merged + } else { + provider.settings_config.clone() + }; + if is_update { // 更新模式:使用 UPDATE 避免触发 ON DELETE CASCADE tx.execute( @@ -311,7 +331,7 @@ impl Database { WHERE id = ?13 AND app_type = ?14", params![ provider.name, - serde_json::to_string(&provider.settings_config).map_err(|e| { + serde_json::to_string(&final_settings_config).map_err(|e| { AppError::Database(format!("Failed to serialize settings_config: {e}")) })?, provider.website_url, @@ -342,7 +362,7 @@ impl Database { provider.id, app_type, provider.name, - serde_json::to_string(&provider.settings_config) + serde_json::to_string(&final_settings_config) .map_err(|e| AppError::Database(format!("Failed to serialize settings_config: {e}")))?, provider.website_url, provider.category, @@ -410,18 +430,40 @@ impl Database { Ok(()) } - /// 更新供应商的 settings_config(仅更新配置,不改变其他字段) + /// 更新供应商的 settings_config(仅更新配置,不改变其他字段)。 + /// 默认增量合并到现有值,force=true 时全量替换。 pub fn update_provider_settings_config( &self, app_type: &str, provider_id: &str, settings_config: &serde_json::Value, + force: bool, ) -> Result<(), AppError> { let conn = lock_conn!(self.conn); + let merged = if force { + settings_config.clone() + } else { + let existing_str: Option = conn + .query_row( + "SELECT settings_config FROM providers WHERE id = ?1 AND app_type = ?2", + params![provider_id, app_type], + |row| row.get(0), + ) + .ok(); + match existing_str { + Some(s) => { + let existing: Value = serde_json::from_str(&s).unwrap_or(Value::Null); + let mut m = existing; + json_deep_merge(&mut m, settings_config); + m + } + None => settings_config.clone(), + } + }; conn.execute( "UPDATE providers SET settings_config = ?1 WHERE id = ?2 AND app_type = ?3", params![ - serde_json::to_string(settings_config).map_err(|e| AppError::Database(format!( + serde_json::to_string(&merged).map_err(|e| AppError::Database(format!( "Failed to serialize settings_config: {e}" )))?, provider_id, diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 978df67c..08c49226 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -43,6 +43,7 @@ fn run(cli: Cli) -> Result<(), AppError> { Some(Commands::Env(cmd)) => cc_switch_lib::cli::commands::env::execute(cmd, cli.app), Some(Commands::Update(cmd)) => cc_switch_lib::cli::commands::update::execute(cmd), Some(Commands::Completions(cmd)) => cc_switch_lib::cli::commands::completions::execute(cmd), + Some(Commands::EditJson(cmd)) => cc_switch_lib::cli::commands::edit_json::execute(cmd), } } diff --git a/src-tauri/src/services/provider/common_config.rs b/src-tauri/src/services/provider/common_config.rs index 32ca914d..3da4be72 100644 --- a/src-tauri/src/services/provider/common_config.rs +++ b/src-tauri/src/services/provider/common_config.rs @@ -60,7 +60,7 @@ fn json_remove_array_items(target_arr: &mut Vec, source_arr: &[Value]) { } } -fn json_deep_merge(target: &mut Value, source: &Value) { +pub(crate) fn json_deep_merge(target: &mut Value, source: &Value) { match (target, source) { (Value::Object(target_map), Value::Object(source_map)) => { for (key, source_value) in source_map { diff --git a/src-tauri/src/services/provider/mod.rs b/src-tauri/src/services/provider/mod.rs index afe7f5c9..bed42305 100644 --- a/src-tauri/src/services/provider/mod.rs +++ b/src-tauri/src/services/provider/mod.rs @@ -33,6 +33,7 @@ use gemini_auth::GeminiAuthType; use live::LiveSnapshot; pub use common::migrate_legacy_codex_config; +pub(crate) use common_config::json_deep_merge; #[cfg(test)] use common::strip_codex_common_config_from_full_text; @@ -73,7 +74,7 @@ struct PostCommitAction { } impl ProviderService { - fn is_codex_official_provider(provider: &Provider) -> bool { + pub(crate) fn is_codex_official_provider(provider: &Provider) -> bool { provider .meta .as_ref() @@ -85,7 +86,7 @@ impl ProviderService { .is_some_and(|value| value.eq_ignore_ascii_case("official")) } - fn codex_config_has_base_url(config_text: &str) -> bool { + pub(crate) fn codex_config_has_base_url(config_text: &str) -> bool { let Ok(table) = toml::from_str::(config_text.trim()) else { return false; }; @@ -502,15 +503,25 @@ impl ProviderService { common_snippet_for_strip.clone() }; - let mut raw_settings = serde_json::Map::new(); - if let Some(auth) = auth { - raw_settings.insert("auth".to_string(), auth); + // Start from existing provider settings; only update auth and config in-place + let mut settings_to_store = provider.settings_config.clone(); + if let Value::Object(ref mut obj) = settings_to_store { + if let Some(auth) = auth { + obj.insert("auth".to_string(), auth); + } + obj.insert("config".to_string(), Value::String(cfg_text_for_storage)); + } else { + let mut obj = serde_json::Map::new(); + if let Some(auth) = auth { + obj.insert("auth".to_string(), auth); + } + obj.insert("config".to_string(), Value::String(cfg_text_for_storage)); + settings_to_store = Value::Object(obj); } - raw_settings.insert("config".to_string(), Value::String(cfg_text_for_storage)); - let mut settings_to_store = Self::normalize_settings_config_for_storage( + settings_to_store = Self::normalize_settings_config_for_storage( app_type, &provider, - Value::Object(raw_settings), + settings_to_store, effective_common_snippet.as_deref(), )?; Self::restore_codex_model_provider_for_storage_best_effort( @@ -558,7 +569,7 @@ impl ProviderService { )); } let env_map = read_gemini_env()?; - let mut live_after = env_to_json(&env_map); + let live_env = env_to_json(&env_map); let settings_path = get_gemini_settings_path(); let config_value = if settings_path.exists() { @@ -567,10 +578,6 @@ impl ProviderService { json!({}) }; - if let Some(obj) = live_after.as_object_mut() { - obj.insert("config".to_string(), config_value); - } - let (provider, common_snippet) = { let guard = state.config.read().map_err(AppError::from)?; ( @@ -588,10 +595,19 @@ impl ProviderService { guard.common_config_snippets.gemini.clone(), ) }; + + // Start from existing provider settings; only update env and config in-place + let mut merged = provider.settings_config.clone(); + if let Value::Object(ref mut obj) = merged { + obj.insert("env".to_string(), live_env); + obj.insert("config".to_string(), config_value); + } else { + merged = json!({"env": live_env, "config": config_value}); + } let live_after = Self::normalize_settings_config_for_storage( app_type, &provider, - live_after, + merged, common_snippet.as_deref(), )?; @@ -1966,22 +1982,32 @@ impl ProviderService { common_config_snippet, apply_common_config, )?; - let settings = effective - .as_object() - .ok_or_else(|| AppError::Config("Codex 配置必须是 JSON 对象".into()))?; - let auth = settings.get("auth").cloned(); - let cfg_text = settings.get("config").and_then(Value::as_str).unwrap_or(""); + let mut effective_obj = match effective { + Value::Object(map) => map, + _ => { + return Err(AppError::Config( + "Codex 配置必须是 JSON 对象".into(), + )) + } + }; + let auth = effective_obj.get("auth").cloned(); + let cfg_text = effective_obj + .get("config") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); if !cfg_text.trim().is_empty() { - crate::codex_config::validate_config_toml(cfg_text)?; + crate::codex_config::validate_config_toml(&cfg_text)?; } - let mut backup = serde_json::Map::new(); + // Preserve all existing keys; only update auth and config in-place if let Some(auth) = auth { - backup.insert("auth".to_string(), auth); + effective_obj.insert("auth".to_string(), auth); } - backup.insert("config".to_string(), Value::String(cfg_text.to_string())); - Ok(Value::Object(backup)) + effective_obj + .insert("config".to_string(), Value::String(cfg_text)); + Ok(Value::Object(effective_obj)) } AppType::Gemini => { let content_to_write = common_config::build_effective_settings_with_common_config( @@ -2046,10 +2072,18 @@ impl ProviderService { json!({}) }; - Ok(json!({ - "env": env_obj, - "config": config_value, - })) + // Preserve all existing keys from content_to_write; only update env and config in-place + let mut result = match content_to_write { + Value::Object(map) => map, + other => { + let mut map = serde_json::Map::new(); + map.insert("env".to_string(), other); + map + } + }; + result.insert("env".to_string(), env_obj); + result.insert("config".to_string(), config_value); + Ok(Value::Object(result)) } AppType::OpenCode => Err(AppError::Config( "OpenCode does not support proxy takeover backups".into(), diff --git a/src-tauri/src/services/provider/tests.rs b/src-tauri/src/services/provider/tests.rs index 3b9d9613..305e2f24 100644 --- a/src-tauri/src/services/provider/tests.rs +++ b/src-tauri/src/services/provider/tests.rs @@ -4371,3 +4371,189 @@ fn import_openclaw_providers_from_live_skips_existing_ids_without_overwriting() Some(true) ); } + +#[test] +fn build_effective_live_snapshot_codex_preserves_custom_keys() { + let provider = Provider::with_id( + "p1".to_string(), + "Test".to_string(), + json!({ + "auth": {"OPENAI_API_KEY": "sk-test"}, + "config": "[model_provider]\nprovider = \"custom\"\nbase_url = \"https://api.example.com\"\n", + "customKey": "custom-value" + }), + None, + ); + + let effective = ProviderService::build_effective_live_snapshot( + &AppType::Codex, + &provider, + None, + false, + ) + .expect("build effective snapshot"); + + // canonical keys present + assert!(effective.get("auth").is_some(), "auth should be present"); + assert!(effective.get("config").is_some(), "config should be present"); + // custom key preserved + assert_eq!( + effective.get("customKey").and_then(Value::as_str), + Some("custom-value"), + "custom keys should be preserved in effective snapshot" + ); +} + +#[test] +fn build_effective_live_snapshot_gemini_preserves_custom_keys() { + let provider = Provider::with_id( + "p1".to_string(), + "Test".to_string(), + json!({ + "env": {"GEMINI_API_KEY": "sk-test"}, + "config": {"temperature": 0.7}, + "customKey": "custom-value" + }), + None, + ); + + let effective = ProviderService::build_effective_live_snapshot( + &AppType::Gemini, + &provider, + None, + false, + ) + .expect("build effective snapshot"); + + assert!(effective.get("env").is_some(), "env should be present"); + assert!(effective.get("config").is_some(), "config should be present"); + assert_eq!( + effective.get("customKey").and_then(Value::as_str), + Some("custom-value"), + "custom keys should be preserved in effective snapshot" + ); +} + +#[test] +#[serial] +fn codex_switch_preserves_custom_keys_in_settings_config() { + let temp_home = TempDir::new().expect("create temp home"); + let _env = EnvGuard::set_home(temp_home.path()); + std::fs::create_dir_all(crate::codex_config::get_codex_config_dir()) + .expect("create ~/.codex dir"); + + let mut config = MultiAppConfig::default(); + config.ensure_app(&AppType::Codex); + { + let manager = config + .get_manager_mut(&AppType::Codex) + .expect("codex manager"); + manager.current = "p1".to_string(); + manager.providers.insert( + "p1".to_string(), + Provider::with_id( + "p1".to_string(), + "Provider One".to_string(), + json!({ + "auth": {"OPENAI_API_KEY": "sk-test"}, + "config": "model_provider = \"custom\"\nbase_url = \"https://api.example.com\"\n", + "customKey": "custom-value" + }), + None, + ), + ); + } + + let state = state_from_config(config); + + // Switch triggers write_live_snapshot + refresh_provider_snapshot round-trip + ProviderService::switch(&state, AppType::Codex, "p1").expect("switch to p1"); + + // Check in-memory config + { + let guard = state.config.read().expect("read config"); + let manager = guard.get_manager(&AppType::Codex).expect("codex manager"); + let provider = manager.providers.get("p1").expect("p1 exists"); + assert_eq!( + provider.settings_config.get("customKey").and_then(Value::as_str), + Some("custom-value"), + "custom keys should survive switch round-trip in memory" + ); + } + + // Check DB + let db_provider = state + .db + .get_provider_by_id("p1", AppType::Codex.as_str()) + .expect("query") + .expect("exists"); + assert_eq!( + db_provider.settings_config.get("customKey").and_then(Value::as_str), + Some("custom-value"), + "custom keys should survive switch round-trip in DB" + ); +} + +#[test] +#[serial] +fn gemini_switch_preserves_custom_keys_in_settings_config() { + let temp_home = TempDir::new().expect("create temp home"); + let _env = EnvGuard::set_home(temp_home.path()); + let gemini_dir = crate::gemini_config::get_gemini_dir(); + std::fs::create_dir_all(&gemini_dir).expect("create gemini dir"); + + // Write minimal .env so Gemini refresh doesn't bail on missing file + std::fs::write( + crate::gemini_config::get_gemini_env_path(), + "GEMINI_API_KEY=sk-test\n", + ) + .expect("write .env"); + + let mut config = MultiAppConfig::default(); + config.ensure_app(&AppType::Gemini); + { + let manager = config + .get_manager_mut(&AppType::Gemini) + .expect("gemini manager"); + manager.current = "p1".to_string(); + manager.providers.insert( + "p1".to_string(), + Provider::with_id( + "p1".to_string(), + "Provider One".to_string(), + json!({ + "env": {"GEMINI_API_KEY": "sk-test"}, + "config": {"temperature": 0.7}, + "customKey": "custom-value" + }), + None, + ), + ); + } + + let state = state_from_config(config); + + ProviderService::switch(&state, AppType::Gemini, "p1").expect("switch to p1"); + + { + let guard = state.config.read().expect("read config"); + let manager = guard.get_manager(&AppType::Gemini).expect("gemini manager"); + let provider = manager.providers.get("p1").expect("p1 exists"); + assert_eq!( + provider.settings_config.get("customKey").and_then(Value::as_str), + Some("custom-value"), + "custom keys should survive switch round-trip in memory" + ); + } + + let db_provider = state + .db + .get_provider_by_id("p1", AppType::Gemini.as_str()) + .expect("query") + .expect("exists"); + assert_eq!( + db_provider.settings_config.get("customKey").and_then(Value::as_str), + Some("custom-value"), + "custom keys should survive switch round-trip in DB" + ); +} diff --git a/src-tauri/src/services/proxy.rs b/src-tauri/src/services/proxy.rs index a9aa9e5c..a20a4bfb 100644 --- a/src-tauri/src/services/proxy.rs +++ b/src-tauri/src/services/proxy.rs @@ -1288,6 +1288,7 @@ impl ProxyService { app_type.as_str(), &provider_id, &provider.settings_config, + false, // merge to preserve custom keys ) { log::warn!( "sync {} live token to provider {} failed: {error}", diff --git a/src-tauri/src/store.rs b/src-tauri/src/store.rs index ed505c00..e35f697b 100644 --- a/src-tauri/src/store.rs +++ b/src-tauri/src/store.rs @@ -436,6 +436,7 @@ fn migrate_legacy_codex_configs(db: &Database, config: &mut MultiAppConfig) { AppType::Codex.as_str(), provider_id, &provider.settings_config, + false, // merge to preserve custom keys ) { log::warn!( "Failed to persist migrated Codex config for provider '{}': {}", From 2ced95321a25e9e4a0066b4443df707d4141ae69 Mon Sep 17 00:00:00 2001 From: "Yu.Wz" Date: Tue, 5 May 2026 13:55:39 +0800 Subject: [PATCH 2/3] refactor: simplify edit-json and providers code based on review - Inline useless passthrough function apply_settings_config_update - Fix dead assertion in no_change_detection test - Simplify make_provider to use Provider::with_id() - Extract duplicate merge logic into merge_settings_config helper - Combine redundant DB round-trips in save_provider into single SELECT - Remove redundant auth/config re-inserts in Codex build_effective_live_snapshot Co-Authored-By: Claude Opus 4.7 --- src-tauri/src/cli/commands/edit_json.rs | 54 ++++++++----------------- src-tauri/src/database/dao/providers.rs | 43 +++++++++----------- src-tauri/src/services/provider/mod.rs | 21 +++------- 3 files changed, 40 insertions(+), 78 deletions(-) diff --git a/src-tauri/src/cli/commands/edit_json.rs b/src-tauri/src/cli/commands/edit_json.rs index 2c0a49bc..6d3c43bd 100644 --- a/src-tauri/src/cli/commands/edit_json.rs +++ b/src-tauri/src/cli/commands/edit_json.rs @@ -2,7 +2,6 @@ use clap::Subcommand; use serde_json::Value; use crate::app_config::AppType; -use crate::database::Database; use crate::error::AppError; use crate::provider::Provider; use crate::services::provider::ProviderService; @@ -60,7 +59,9 @@ fn edit_provider(app_type: &AppType, id: &str, force: bool) -> Result<(), AppErr let new_value = validate_edited_json(&edited, &provider, app_type)?; - apply_settings_config_update(&state.db, app_type, id, &new_value, force)?; + state + .db + .update_provider_settings_config(app_type.as_str(), id, &new_value, force)?; use crate::cli::ui::success; println!( @@ -105,17 +106,6 @@ fn validate_edited_json( Ok(value) } -/// Write the validated JSON value directly to the database. -fn apply_settings_config_update( - db: &Database, - app_type: &AppType, - id: &str, - value: &Value, - force: bool, -) -> Result<(), AppError> { - db.update_provider_settings_config(app_type.as_str(), id, value, force) -} - #[cfg(test)] mod tests { use super::*; @@ -124,20 +114,9 @@ mod tests { use serde_json::json; fn make_provider(id: &str, settings_config: Value) -> Provider { - Provider { - id: id.to_string(), - name: "Test Provider".to_string(), - settings_config, - website_url: None, - category: None, - created_at: None, - sort_index: None, - notes: None, - meta: Some(ProviderMeta::default()), - icon: None, - icon_color: None, - in_failover_queue: false, - } + let mut p = Provider::with_id(id.to_string(), "Test Provider".to_string(), settings_config, None); + p.meta = Some(ProviderMeta::default()); + p } fn seed_provider(db: &Database, id: &str, app_type: &str, cfg: Value) { @@ -303,24 +282,25 @@ mod tests { } #[test] - fn no_change_detection() { + fn update_with_identical_content_is_noop() { let db = Database::memory().expect("memory db"); - let original = json!({"key": "value"}); + let original = json!({"key": "value", "custom": "preserve-me"}); seed_provider(&db, "test-id", "claude", original.clone()); - let provider = db - .get_provider_by_id("test-id", "claude") - .expect("query") - .expect("exists"); - - let initial = serde_json::to_string_pretty(&provider.settings_config).unwrap(); - assert_eq!(initial.trim(), initial.trim()); + // Merge the same JSON — no actual change should occur + db.update_provider_settings_config( + "claude", + "test-id", + &json!({"key": "value"}), + false, + ) + .expect("update should succeed"); let after = db .get_provider_by_id("test-id", "claude") .expect("query") .expect("exists"); - assert_eq!(after.settings_config, original); + assert_eq!(after.settings_config, original, "identical merge must not mutate data"); } #[test] diff --git a/src-tauri/src/database/dao/providers.rs b/src-tauri/src/database/dao/providers.rs index a59472de..213b3c75 100644 --- a/src-tauri/src/database/dao/providers.rs +++ b/src-tauri/src/database/dao/providers.rs @@ -211,6 +211,13 @@ impl Database { Ok(false) } + fn merge_settings_config(existing_str: &str, incoming: &Value) -> Value { + let existing: Value = serde_json::from_str(existing_str).unwrap_or(Value::Null); + let mut merged = existing; + json_deep_merge(&mut merged, incoming); + merged + } + fn next_sort_index_for_app(&self, app_type: &str) -> Result { let conn = lock_conn!(self.conn); let max: Option = conn @@ -281,33 +288,24 @@ impl Database { let mut meta_clone = provider.meta.clone().unwrap_or_default(); let endpoints = std::mem::take(&mut meta_clone.custom_endpoints); - // 检查是否存在(用于判断新增/更新,以及保留 is_current 和 in_failover_queue) - let existing: Option<(bool, bool)> = tx + // Fetch existing row in one query: is_current, in_failover_queue, and settings_config + let existing: Option<(bool, bool, String)> = tx .query_row( - "SELECT is_current, in_failover_queue FROM providers WHERE id = ?1 AND app_type = ?2", + "SELECT is_current, in_failover_queue, settings_config FROM providers WHERE id = ?1 AND app_type = ?2", params![provider.id, app_type], - |row| Ok((row.get(0)?, row.get(1)?)), + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), ) .ok(); let is_update = existing.is_some(); - let (is_current, in_failover_queue) = - existing.unwrap_or((false, provider.in_failover_queue)); + let (is_current, in_failover_queue) = existing + .as_ref() + .map(|(c, q, _)| (*c, *q)) + .unwrap_or((false, provider.in_failover_queue)); // Merge settings_config: preserve custom keys from existing DB row - let final_settings_config = if is_update { - let existing_cfg_str: String = tx - .query_row( - "SELECT settings_config FROM providers WHERE id = ?1 AND app_type = ?2", - params![provider.id, app_type], - |row| row.get(0), - ) - .map_err(|e| AppError::Database(e.to_string()))?; - let existing_cfg: Value = - serde_json::from_str(&existing_cfg_str).unwrap_or(Value::Null); - let mut merged = existing_cfg; - json_deep_merge(&mut merged, &provider.settings_config); - merged + let final_settings_config = if let Some((_, _, ref existing_cfg_str)) = existing { + Self::merge_settings_config(existing_cfg_str, &provider.settings_config) } else { provider.settings_config.clone() }; @@ -451,12 +449,7 @@ impl Database { ) .ok(); match existing_str { - Some(s) => { - let existing: Value = serde_json::from_str(&s).unwrap_or(Value::Null); - let mut m = existing; - json_deep_merge(&mut m, settings_config); - m - } + Some(ref s) => Self::merge_settings_config(s, settings_config), None => settings_config.clone(), } }; diff --git a/src-tauri/src/services/provider/mod.rs b/src-tauri/src/services/provider/mod.rs index bed42305..826972fb 100644 --- a/src-tauri/src/services/provider/mod.rs +++ b/src-tauri/src/services/provider/mod.rs @@ -1982,7 +1982,7 @@ impl ProviderService { common_config_snippet, apply_common_config, )?; - let mut effective_obj = match effective { + let effective_obj = match effective { Value::Object(map) => map, _ => { return Err(AppError::Config( @@ -1990,23 +1990,12 @@ impl ProviderService { )) } }; - let auth = effective_obj.get("auth").cloned(); - let cfg_text = effective_obj - .get("config") - .and_then(Value::as_str) - .unwrap_or("") - .to_string(); - - if !cfg_text.trim().is_empty() { - crate::codex_config::validate_config_toml(&cfg_text)?; + if let Some(cfg_text) = effective_obj.get("config").and_then(Value::as_str) { + if !cfg_text.trim().is_empty() { + crate::codex_config::validate_config_toml(cfg_text)?; + } } - // Preserve all existing keys; only update auth and config in-place - if let Some(auth) = auth { - effective_obj.insert("auth".to_string(), auth); - } - effective_obj - .insert("config".to_string(), Value::String(cfg_text)); Ok(Value::Object(effective_obj)) } AppType::Gemini => { From 5ab7b5a5cd5a192ba2458e7d300d576f4a133679 Mon Sep 17 00:00:00 2001 From: "Yu.Wz" Date: Tue, 5 May 2026 14:47:51 +0800 Subject: [PATCH 3/3] docs: remove design docs, add English comments to edit-json code Co-Authored-By: Claude Opus 4.7 --- docs/edit-json-cli-design.md | 116 ------------- docs/prd-edit-json-cli.md | 157 ------------------ src-tauri/src/cli/commands/edit_json.rs | 2 + src-tauri/src/database/dao/providers.rs | 6 +- .../src/services/provider/common_config.rs | 1 + src-tauri/src/services/provider/mod.rs | 2 + 6 files changed, 9 insertions(+), 275 deletions(-) delete mode 100644 docs/edit-json-cli-design.md delete mode 100644 docs/prd-edit-json-cli.md diff --git a/docs/edit-json-cli-design.md b/docs/edit-json-cli-design.md deleted file mode 100644 index 31ed7161..00000000 --- a/docs/edit-json-cli-design.md +++ /dev/null @@ -1,116 +0,0 @@ -# edit-json CLI 命令设计文档 - -## 需求背景 - -cc-switch 的 TUI 入口本质工作是:接受用户数据 → 生成 JSON → 写入 DB → 提供给后端使用。 - -现有流程是通过 TUI 表单收集字段,由表单逻辑拼接 JSON 后入库。现需提供一个新入口,绕过表单,允许用户直接编辑原始 JSON(在外部编辑器中),校验合法后直接保存到 DB。 - -## 范围 - -首个版本仅支持 `provider` 实体(`settings_config` 列),后续可扩展至 `mcp`、`prompts` 等其他实体。 - -## 命令格式 - -``` -cc-switch edit-json provider --app-type -``` - -- `` — provider ID -- `--app-type` — 必填,严格匹配:`claude` | `codex` | `gemini` | `opencode` | `openclaw` - -## 工作流(类似 git commit) - -``` - 打开外部编辑器 - │ - ┌──────────▼──────────┐ - │ 临时文件预填 JSON │ - │ (settings_config, │ - │ pretty-printed) │ - └──────────┬──────────┘ - │ 用户编辑、保存、关闭 - │ - ┌──────────▼──────────┐ - │ 读取临时文件内容 │ - │ 校验 JSON │ - │ ↓ 失败 → 报错退出 │ - │ ↓ 未改 → 提示取消 │ - │ ↓ 成功 → 写入 DB │ - └─────────────────────┘ -``` - -## 编辑目标 - -只编辑 `providers.settings_config` 列(TEXT,JSON blob)。不编辑 `meta` 及其他字段。 - -## 编辑器 - -使用 `$EDITOR` 环境变量,未设置则 fallback 到 `$VISUAL`,再 fallback 到系统默认(macOS: `vi`)。 - -复用已有的 `crate::cli::editor::open_external_editor()` 函数(`edit` crate 封装)。 - -## JSON 格式 - -临时文件中使用 `serde_json::to_string_pretty()` 格式化,保持可读性。 - -## 校验规则 - -分三层: - -1. **JSON 语法** — `serde_json::from_str::()` 成功 -2. **类型约束** — 必须为 JSON Object(不能是 array/string/null/数字) -3. **业务规则(app-type 特定)** — 复用现有校验逻辑: - - Codex 非官方 provider:`settings_config.config` 中的 TOML 必须能解析出非空的 `base_url` - - 其他 app-type 暂无额外业务校验 - -> 注意:不限制 JSON 内部的 key 集合。用户写入的自定义 KV 会被原样保存,不会丢失。 - -## 临时文件生命周期 - -| 场景 | 行为 | -|------|------| -| 保存成功 | 清理临时文件 | -| 校验失败 | 保留临时文件,错误信息中显示文件路径 | -| 未修改退出 | 清理临时文件,输出「未修改,已取消」 | - -## 输出 - -| 场景 | 输出 | -|------|------| -| 保存成功 | `✓ 已更新 provider '' () 的 settingsConfig` | -| 未修改 | `未修改,已取消` | -| JSON 语法错误 | `JSON 解析失败: \n临时文件保留在: ` | -| 非 Object | `settingsConfig 必须为 JSON Object\n临时文件保留在: ` | -| 业务校验失败 | `<具体校验错误信息>\n临时文件保留在: ` | - -## 命令层级 - -作为顶级子命令挂载到 `Commands` 枚举下,而非 `ProviderCommand` 下(方便后续扩展 `mcp`、`prompts` 等实体)。 - -``` -cc-switch -├── provider ... -├── mcp ... -├── edit-json ← 新增 -└── ... -``` - -## 实现涉及的文件 - -| 文件 | 改动 | -|------|------| -| `src-tauri/src/cli/commands/mod.rs` | 新增 `edit_json` 模块 | -| `src-tauri/src/cli/commands/edit_json.rs` | 新建,核心逻辑 | -| `src-tauri/src/cli/mod.rs` | `Commands` 枚举新增 `EditJson` 变体 | -| `src-tauri/src/main.rs` | `run()` 函数新增 dispatch 分支 | - -## 自定义 KV 安全性 - -现有校验逻辑 `validate_provider_submit` 只检查: -- provider name 非空 -- Codex(非官方)的 `settings_config.config` TOML 中 `base_url` 可解析且非空 - -`update_provider_settings_config` 方法直接序列化 `serde_json::Value` 写入 DB,不做 key 过滤。 - -TUI 表单回读时,无法匹配到表单字段的 JSON key 存储在 `form.extra` 中,表单保存时重新合并写入,不会丢失。 diff --git a/docs/prd-edit-json-cli.md b/docs/prd-edit-json-cli.md deleted file mode 100644 index ebcdc5eb..00000000 --- a/docs/prd-edit-json-cli.md +++ /dev/null @@ -1,157 +0,0 @@ -# PRD: edit-json CLI 命令 - -## Problem Statement - -cc-switch 用户目前只能通过 TUI 表单编辑 provider 的 `settings_config`。当用户需要批量修改 JSON 字段、写入自定义 KV(extensions/experimental 配置),或对 JSON 结构做精细调整时,表单操作效率低下且容易受限。用户需要一个绕过表单、直接在外部编辑器中编辑原始 JSON 的 CLI 入口。 - -## Solution - -新增顶级子命令 `cc-switch edit-json provider --app-type `,工作流类似 `git commit`:打开外部编辑器预填当前 JSON → 用户编辑保存 → 校验 → 直接写入 DB。首版仅支持 `provider` 实体,后续可扩展。 - -## User Stories - -1. As a developer, I want to run `cc-switch edit-json provider --app-type claude` so that I can directly edit the raw `settings_config` JSON in my preferred editor. -2. As a developer, I want the editor to be pre-filled with the current `settings_config` (pretty-printed), so that I don't need to manually look up or copy the existing JSON. -3. As a developer, I want JSON syntax validation to catch malformed edits before they hit the database, so that I don't corrupt my provider config. -4. As a developer, I want a clear error message with the edited content echoed back when validation fails, so that I can recover my work instead of losing it. -5. As a developer, I want the command to detect when I haven't made any changes and cancel gracefully, so that I don't accidentally trigger unnecessary DB writes. -6. As a developer, I want the command to enforce that `settings_config` is always a JSON Object (not an array, string, number, or null), so that the app config format remains consistent. -7. As a Codex user, I want non-official Codex providers to be validated for a non-empty `base_url` in their TOML config snippet, so that I don't save a broken Codex config. -8. As a developer, I want my custom JSON keys (not covered by any TUI form field) to be preserved as-is when I edit via this command, so that extensions and experimental configs are not lost. -9. As a developer, I want the command to respect my `$EDITOR` / `$VISUAL` environment variables, with a fallback to `vi`, so that my preferred editor is used. -10. As a developer, I want the command output to clearly indicate success, cancellation, or the specific validation error that occurred. -11. As a developer, I want the command to fail early with a clear error if the specified provider ID does not exist, so that I don't open an editor with empty content by mistake. -12. As a future developer, I want the `edit-json` command structure to easily support other entities (mcp, prompts) by adding new subcommands under the same top-level command, without restructuring the CLI. - -## Implementation Decisions - -### 模块架构 - -四个关注点,分层协作: - -**CLI 编排层** (`src-tauri/src/cli/commands/edit_json.rs`) — 新建文件,负责解析 clap 参数、调用 editor、编排工作流、格式化输出。不直接操作 DB,不实现校验逻辑。 - -**通用校验** (同文件内私有逻辑) — JSON 语法校验(`serde_json::from_str::`)和类型约束校验(必须是 JSON Object)。这两项是通用 one-liner,不涉及业务知识,放在编排层合理。 - -**业务校验** (委托给 `ProviderService`) — Codex base_url 检查复用 `ProviderService` 中已有的两个函数: -- `is_codex_official_provider(provider: &Provider) -> bool` — 判断是否官方 Codex provider(`src-tauri/src/services/provider/mod.rs:76`) -- `codex_config_has_base_url(config_text: &str) -> bool` — 解析 TOML 配置中的 base_url 并验证非空(`src-tauri/src/services/provider/mod.rs:88`),已覆盖 `base_url` 顶层键和 `model_providers..base_url` 两种 TOML 结构 - -这两个函数当前为 `fn`(私有),需改为 `pub(crate)`。 - -**为什么不在 edit_json.rs 中重新实现业务校验:** `is_codex_official_provider` 已在代码库中定义了 5 份独立副本(`services/provider/mod.rs`、`cli/commands/provider.rs`、`cli/tui/runtime_actions/editor.rs`、`cli/tui/data.rs`、`cli/tui/form/provider_state.rs`),7 个调用点散布在各层。新增第 6 份副本会让未来 Codex 配置格式变化时需要修改 6 处。暴露 ProviderService 中的 canonical 实现,建立单一事实来源。 - -**编辑-json mcp 扩展时遵循相同模式:** 找到 McpService 中已有的 validate 函数,暴露为 `pub(crate)` 后调用。 - -**持久化层** (复用已有 `Database::update_provider_settings_config`) — 位于 `src-tauri/src/database/dao/providers.rs:413-433`,直接执行 `UPDATE providers SET settings_config = ?1 WHERE id = ?2 AND app_type = ?3`,绕过 in-memory config 模型和全量快照持久化路径,只做单列部分更新。 - -### 命令参数设计 - -``` -cc-switch edit-json provider --app-type -``` - -- `` — 位置参数,provider ID -- `--app-type` — 必填选项,严格匹配 `claude | codex | gemini | opencode | openclaw`,复用 `AppType` 的 `FromStr` 实现(`src-tauri/src/app_config.rs:315-337`) - -命令作为 `Commands` 枚举的顶级变体 `EditJson(commands::edit_json::EditJsonCommand)`,内部 `EditJsonCommand` 枚举目前只有一个 `Provider` 变体,便于后续扩展 `Mcp`、`Prompts` 等。 - -### 外部编辑器调用 - -复用 `crate::cli::editor::open_external_editor(initial_content: &str) -> Result`(`src-tauri/src/cli/editor.rs:4-7`),底层为 `edit` crate(`edit = "0.1"`),自动读取 `$EDITOR` → `$VISUAL` → `vi` 回退链。 - -### 临时文件生命周期 - -`edit` crate(v0.1)在返回 `Ok(String)` 后自动删除临时文件。校验失败时内容已在内存中,无需保留临时文件。设计文档中"保留临时文件"的 recoverability 需求通过错误输出中回显编辑后的 JSON 内容来实现。 - -### 数据库读取路径 - -不走 `ProviderService` / in-memory `MultiAppConfig` 路径。直接通过 `Database::get_provider_by_id(app_type: &str, id: &str)` 查询 DB(`src-tauri/src/database/dao/providers.rs:127-176`),只读取 `settings_config` 列。该查询使用复合主键 `(id, app_type)`,返回 `Result, AppError>`。未找到时返回 `AppError::InvalidInput`。 - -### 工作流编排 - -``` -1. 解析 CLI 参数 (provider ID, app_type) -2. 获取 AppState::try_new(),打开 DB -3. db.get_provider_by_id(app_type.as_str(), &id) - - 未找到 → Err(AppError::InvalidInput("provider '' not found for app ''")) -4. serde_json::to_string_pretty(&provider.settings_config) → 初始内容 -5. open_external_editor(&initial) → 编辑后内容 -6. 比较编辑后内容.trim() == 初始内容.trim() - - 相同 → println!("未修改,已取消"); return Ok(()) -7. serde_json::from_str::(&edited) → JSON 语法校验 - - 失败 → Err(AppError::Message("JSON 解析失败: ")) -8. 校验编辑后内容为 JSON Object - - 非 Object → Err(AppError::Message("settingsConfig 必须为 JSON Object")) -9. 业务校验 (仅 Codex 非官方 provider): - - 调用 ProviderService::is_codex_official_provider(&provider) 判断 - - 非官方时提取 settings_config["config"] 字符串 → ProviderService::codex_config_has_base_url(config_str) - - 返回 false → Err(AppError::Message("Codex provider 必须配置非空的 base_url")) -10. db.update_provider_settings_config(app_type.as_str(), &id, &new_value) -11. println!("✓ 已更新 provider '' () 的 settingsConfig") -``` - -### 变更文件清单 - -| 文件 | 改动类型 | 说明 | -|------|---------|------| -| `src-tauri/src/cli/commands/edit_json.rs` | **新建** | CLI 编排:参数定义、workflow 调用、输出格式化;通用校验(JSON 语法、Object 类型约束)| -| `src-tauri/src/cli/commands/mod.rs` | 修改 | 新增 `pub mod edit_json;` | -| `src-tauri/src/cli/mod.rs` | 修改 | `Commands` 枚举新增 `EditJson(commands::edit_json::EditJsonCommand)` 变体 | -| `src-tauri/src/main.rs` | 修改 | `run()` 函数新增 `Commands::EditJson(cmd)` dispatch 分支 | -| `src-tauri/src/services/provider/mod.rs` | 修改 | `is_codex_official_provider` 和 `codex_config_has_base_url` 可见性从 `fn` 改为 `pub(crate)` | - -### 命令是否需要启动状态 - -`edit-json` 需要读取和写入 DB,因此必须初始化 `AppState`。在 `command_requires_startup_state()` 中属于默认的 `true` 分支,**无需修改**该函数。 - -### 错误输出约定 - -- 使用 `crate::cli::ui` 模块的 `success()`(绿色)、`info()`(青色)、`error()`(红色)进行格式化输出 -- 校验失败时,使用 `error()` 输出错误详情,并回显编辑后的 JSON 内容(满足 recoverability 需求) -- 成功时使用 `success()` 输出确认信息 -- 未修改时使用 `info()` 输出取消提示 - -## Testing Decisions - -### 测试原则 - -只测试外部可观测行为:给定输入,验证输出/副作用。不测试临时文件路径、编辑器调用过程(`edit` crate 自身已测)。不测试 `ProviderService` 内部校验逻辑(应在 service 层单独测试)。 - -### 测试模块 - -**集成测试** — `src-tauri/src/cli/commands/edit_json.rs` 内的 `#[cfg(test)] mod tests`,使用 `Database::memory()` 创建内存 SQLite: - -1. **成功更新** — 写入一个 provider,调用 workflow 核心函数(跳过编辑器交互,直接传入编辑后的 JSON 字符串),验证 DB 中 settings_config 已更新为预期值 -2. **provider 不存在** — 查询不存在的 ID,验证返回 `AppError::InvalidInput`,且未触发编辑器调用 -3. **JSON 语法错误** — 传入非 JSON 字符串(如 `{broken`),验证返回 `AppError` 且包含描述性错误信息 -4. **非 Object 校验** — 分别传入 JSON array (`[]`)、string (`"hello"`)、number (`42`)、null (`null`),验证均返回 `AppError` -5. **未修改检测** — 传入与 DB 中原始 JSON 完全相同的字符串,验证返回 `Ok(())` 且 DB 中数据未被 UPDATE -6. **边界:编辑器清空后保存 `{}`** — 传入 `{}`,验证通过校验并成功写入 DB(空 Object 合法) - -> 注意:Codex base_url 校验的正确性测试属于 `ProviderService::codex_config_has_base_url` 的单元测试范围,不在 edit-json 命令的集成测试中覆盖。edit-json 只需验证校验被正确**编排调用**(正式 provider 跳过、非正式 provider 触发),不对 TOML 解析内部路径做重复测试。 - -### 测试参考 - -- `Database::memory()` 构造器:`src-tauri/src/database/mod.rs` -- 已有测试模式:`src-tauri/src/database/tests.rs`(内存 DB + 直接构造测试数据) -- DAO 层测试:`src-tauri/src/database/dao/settings.rs:243`(`#[test]` 标注) - -## Out of Scope - -- 编辑 `mcp`、`prompts`、`skills` 等其他实体的 `edit-json` 子命令 -- 编辑 `meta` 字段(仅限 `settings_config` 列) -- 在编辑器中做 JSON Schema 自动补全或实时校验 -- 支持通过 stdin / pipe 传入 JSON(仅支持外部编辑器交互) -- 批量编辑多个 provider -- 将校验失败的临时文件持久化到磁盘(`edit` crate 自动清理;recoverability 通过错误输出回显内容实现) -- 回滚 / 撤销功能(依赖 DB 备份机制,非本命令职责) -- 同步更新 in-memory `MultiAppConfig`(edit-json 直接写 DB 单列;in-memory config 在下次 `AppState::try_new()` 时从 DB 重新加载,或 TUI 下次刷新时自然同步) - -## Further Notes - -- `edit` crate v0.1 在 macOS 上默认使用 `$EDITOR` → `$VISUAL` → `vi` 回退链,与设计文档中的 fallback 策略一致 -- 不限制 JSON 内部的 key 集合。TUI 表单已有 `form.extra` 机制保留无法匹配表单字段的自定义 KV(`src-tauri/src/cli/tui/form/provider_state.rs`),本次改动不与 TUI 流程冲突 -- `update_provider_settings_config` 是已有方法,现有调用方为 `migrate_legacy_codex_configs`(`src-tauri/src/store.rs:435`),本次新增调用不会改变其语义 -- 命令输出使用 ASCII 字符(`✓`),不依赖 emoji,与现有 CLI 输出风格一致 -- `is_codex_official_provider` 在 `ProviderService` 中的实现比 TUI/CLI 层的 4 份副本更简洁:只检查 `meta.codex_official` 和 `category == "official"` 两个条件,不含 `website_url` 和 `name` 的硬编码判断。暴露后所有调用方应逐步迁移到此实现 diff --git a/src-tauri/src/cli/commands/edit_json.rs b/src-tauri/src/cli/commands/edit_json.rs index 6d3c43bd..e1cdfb9a 100644 --- a/src-tauri/src/cli/commands/edit_json.rs +++ b/src-tauri/src/cli/commands/edit_json.rs @@ -33,6 +33,8 @@ pub fn execute(cmd: EditJsonCommand) -> Result<(), AppError> { } } +/// Open the provider's settings_config in an external editor, validate the result, +/// and persist — merging with existing keys by default, or fully replacing when `force` is set. fn edit_provider(app_type: &AppType, id: &str, force: bool) -> Result<(), AppError> { let state = crate::store::AppState::try_new()?; diff --git a/src-tauri/src/database/dao/providers.rs b/src-tauri/src/database/dao/providers.rs index 213b3c75..961c3425 100644 --- a/src-tauri/src/database/dao/providers.rs +++ b/src-tauri/src/database/dao/providers.rs @@ -211,6 +211,8 @@ impl Database { Ok(false) } + /// Deep-merge `incoming` into the existing settings_config JSON, + /// preserving custom keys that are not present in the incoming value. fn merge_settings_config(existing_str: &str, incoming: &Value) -> Value { let existing: Value = serde_json::from_str(existing_str).unwrap_or(Value::Null); let mut merged = existing; @@ -428,8 +430,8 @@ impl Database { Ok(()) } - /// 更新供应商的 settings_config(仅更新配置,不改变其他字段)。 - /// 默认增量合并到现有值,force=true 时全量替换。 + /// Update provider's settings_config without touching other fields. + /// Default merges into the existing value; `force=true` replaces entirely. pub fn update_provider_settings_config( &self, app_type: &str, diff --git a/src-tauri/src/services/provider/common_config.rs b/src-tauri/src/services/provider/common_config.rs index 3da4be72..c91063f7 100644 --- a/src-tauri/src/services/provider/common_config.rs +++ b/src-tauri/src/services/provider/common_config.rs @@ -60,6 +60,7 @@ fn json_remove_array_items(target_arr: &mut Vec, source_arr: &[Value]) { } } +/// Deep-merge two JSON values. Objects are merged recursively; arrays and scalars are replaced. pub(crate) fn json_deep_merge(target: &mut Value, source: &Value) { match (target, source) { (Value::Object(target_map), Value::Object(source_map)) => { diff --git a/src-tauri/src/services/provider/mod.rs b/src-tauri/src/services/provider/mod.rs index 826972fb..d52956a9 100644 --- a/src-tauri/src/services/provider/mod.rs +++ b/src-tauri/src/services/provider/mod.rs @@ -74,6 +74,7 @@ struct PostCommitAction { } impl ProviderService { + /// Check whether a provider is an official Codex provider (via meta flag or category). pub(crate) fn is_codex_official_provider(provider: &Provider) -> bool { provider .meta @@ -86,6 +87,7 @@ impl ProviderService { .is_some_and(|value| value.eq_ignore_ascii_case("official")) } + /// Check whether a Codex TOML config text contains a non-empty `base_url`. pub(crate) fn codex_config_has_base_url(config_text: &str) -> bool { let Ok(table) = toml::from_str::(config_text.trim()) else { return false;