From a60abd7d7ae609a3ca1e3344eb4514de6f1ae3aa Mon Sep 17 00:00:00 2001 From: nb5p Date: Sat, 2 May 2026 09:05:19 +0800 Subject: [PATCH 1/2] feat(opencode): support opencode.jsonc config files Make get_opencode_config_path() prefer opencode.jsonc over opencode.json when both exist, falling back to opencode.json otherwise. Restores the file-detection half of farion1231/cc-switch#1279 (sosyz, 6c72a23) that never landed because the PR head branch was force-moved. Co-Authored-By: sosyz <30596875+sosyz@users.noreply.github.com> Co-Authored-By: Claude Opus 4.7 --- src-tauri/src/opencode_config.rs | 122 ++++++++++++++++++++++++++++++- 1 file changed, 121 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/opencode_config.rs b/src-tauri/src/opencode_config.rs index 4e659e8741..d2adba85c4 100644 --- a/src-tauri/src/opencode_config.rs +++ b/src-tauri/src/opencode_config.rs @@ -43,7 +43,15 @@ pub fn get_opencode_dir() -> PathBuf { } pub fn get_opencode_config_path() -> PathBuf { - get_opencode_dir().join("opencode.json") + let dir = get_opencode_dir(); + + // Prefer opencode.jsonc if it exists, fallback to opencode.json + let jsonc_path = dir.join("opencode.jsonc"); + if jsonc_path.exists() { + return jsonc_path; + } + + dir.join("opencode.json") } #[allow(dead_code)] @@ -231,3 +239,115 @@ pub fn remove_plugins_by_prefixes(prefixes: &[&str]) -> Result<(), AppError> { write_opencode_config(&config) } + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + fn setup_test_env() -> (TempDir, PathBuf) { + let temp_dir = TempDir::new().unwrap(); + let opencode_dir = temp_dir.path().join(".config").join("opencode"); + fs::create_dir_all(&opencode_dir).unwrap(); + (temp_dir, opencode_dir) + } + + #[test] + fn test_read_opencode_config_prefers_jsonc() { + let (_temp, opencode_dir) = setup_test_env(); + + // Create both .json and .jsonc files + let json_path = opencode_dir.join("opencode.json"); + let jsonc_path = opencode_dir.join("opencode.jsonc"); + + fs::write(&json_path, r#"{"provider": {"test-json": {}}}"#).unwrap(); + fs::write(&jsonc_path, r#"{"provider": {"test-jsonc": {}}}"#).unwrap(); + + // Set override dir to use temp directory + std::env::set_var("HOME", opencode_dir.parent().unwrap().parent().unwrap()); + + // Should prefer .jsonc file + let path = get_opencode_config_path(); + assert!(path.ends_with("opencode.jsonc")); + + // Clean up env var + std::env::remove_var("HOME"); + } + + #[test] + fn test_read_opencode_config_fallback_to_json() { + let (_temp, opencode_dir) = setup_test_env(); + + // Create only .json file + let json_path = opencode_dir.join("opencode.json"); + fs::write(&json_path, r#"{"provider": {"test": {}}}"#).unwrap(); + + std::env::set_var("HOME", opencode_dir.parent().unwrap().parent().unwrap()); + + // Should fallback to .json file + let path = get_opencode_config_path(); + assert!(path.ends_with("opencode.json")); + + std::env::remove_var("HOME"); + } + + #[test] + fn test_read_opencode_config_with_comments() { + let (_temp, opencode_dir) = setup_test_env(); + + // Create .jsonc file with comments + let jsonc_path = opencode_dir.join("opencode.jsonc"); + let config_with_comments = r#"{ + // This is a comment + "$schema": "https://opencode.ai/config.json", + /* Multi-line + comment */ + "provider": { + "test-provider": { + "apiKey": "test-key" // Inline comment + } + } + }"#; + fs::write(&jsonc_path, config_with_comments).unwrap(); + + std::env::set_var("HOME", opencode_dir.parent().unwrap().parent().unwrap()); + + // Should successfully parse config with comments + let config = read_opencode_config().unwrap(); + assert!(config.get("provider").is_some()); + assert!(config["provider"] + .get("test-provider") + .and_then(|p| p.get("apiKey")) + .and_then(|k| k.as_str()) + == Some("test-key")); + + std::env::remove_var("HOME"); + } + + #[test] + fn test_read_opencode_config_trailing_commas() { + let (_temp, opencode_dir) = setup_test_env(); + + // Create .jsonc file with trailing commas + let jsonc_path = opencode_dir.join("opencode.jsonc"); + let config_with_trailing = r#"{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "test-provider": { + "apiKey": "test-key", + "baseUrl": "https://api.example.com", + }, + }, + }"#; + fs::write(&jsonc_path, config_with_trailing).unwrap(); + + std::env::set_var("HOME", opencode_dir.parent().unwrap().parent().unwrap()); + + // Should successfully parse config with trailing commas + let config = read_opencode_config().unwrap(); + assert!(config.get("provider").is_some()); + + std::env::remove_var("HOME"); + } +} From 03d963e19aa99eb81255316fd564c9b4652f72ca Mon Sep 17 00:00:00 2001 From: nb5p Date: Sun, 3 May 2026 11:34:08 +0800 Subject: [PATCH 2/2] test(opencode): isolate jsonc tests via CC_SWITCH_TEST_HOME Address bot review on PR #2522 (claude-bot, codex-bot): - Use CC_SWITCH_TEST_HOME instead of HOME so tests are reliable on Windows (per config.rs:13-28; HOME is ignored by dirs::home_dir on Windows). Matches the convention in hermes_config.rs / openclaw_config.rs. - RAII TempHome saves the prior value via var_os (preserves non-UTF-8 paths) and restores on Drop instead of unconditionally remove_var. - Add #[serial] to every test that mutates env vars. - Document on write_opencode_config that writing back to .jsonc strips comments (serde_json::to_string_pretty round-trip). Co-Authored-By: Claude Opus 4.7 --- src-tauri/src/opencode_config.rs | 80 ++++++++++++++++++-------------- 1 file changed, 46 insertions(+), 34 deletions(-) diff --git a/src-tauri/src/opencode_config.rs b/src-tauri/src/opencode_config.rs index d2adba85c4..5cebe7dd29 100644 --- a/src-tauri/src/opencode_config.rs +++ b/src-tauri/src/opencode_config.rs @@ -77,6 +77,8 @@ pub fn read_opencode_config() -> Result { }) } +/// Note: writes via `serde_json::to_string_pretty`, which strips comments +/// and trailing commas from `.jsonc` files. pub fn write_opencode_config(config: &Value) -> Result<(), AppError> { let path = get_opencode_config_path(); write_json_file(&path, config)?; @@ -243,60 +245,80 @@ pub fn remove_plugins_by_prefixes(prefixes: &[&str]) -> Result<(), AppError> { #[cfg(test)] mod tests { use super::*; + use serial_test::serial; use std::fs; use tempfile::TempDir; - fn setup_test_env() -> (TempDir, PathBuf) { - let temp_dir = TempDir::new().unwrap(); - let opencode_dir = temp_dir.path().join(".config").join("opencode"); + /// RAII helper that points `get_home_dir()` at a temp directory by + /// setting `CC_SWITCH_TEST_HOME` (the only env var the codebase honors — + /// see `config.rs` `get_home_dir`; `HOME` is intentionally *not* used + /// because it's unreliable on Windows). Saves and restores the prior + /// value via `OsString` so non-UTF-8 paths survive the round-trip. + struct TempHome { + dir: TempDir, + original_test_home: Option, + } + + impl TempHome { + fn new() -> Self { + let dir = TempDir::new().expect("failed to create temp home"); + let original_test_home = std::env::var_os("CC_SWITCH_TEST_HOME"); + std::env::set_var("CC_SWITCH_TEST_HOME", dir.path()); + Self { + dir, + original_test_home, + } + } + } + + impl Drop for TempHome { + fn drop(&mut self) { + match &self.original_test_home { + Some(value) => std::env::set_var("CC_SWITCH_TEST_HOME", value), + None => std::env::remove_var("CC_SWITCH_TEST_HOME"), + } + } + } + + fn setup_test_env() -> (TempHome, PathBuf) { + let home = TempHome::new(); + let opencode_dir = home.dir.path().join(".config").join("opencode"); fs::create_dir_all(&opencode_dir).unwrap(); - (temp_dir, opencode_dir) + (home, opencode_dir) } #[test] + #[serial] fn test_read_opencode_config_prefers_jsonc() { - let (_temp, opencode_dir) = setup_test_env(); + let (_home, opencode_dir) = setup_test_env(); - // Create both .json and .jsonc files let json_path = opencode_dir.join("opencode.json"); let jsonc_path = opencode_dir.join("opencode.jsonc"); fs::write(&json_path, r#"{"provider": {"test-json": {}}}"#).unwrap(); fs::write(&jsonc_path, r#"{"provider": {"test-jsonc": {}}}"#).unwrap(); - // Set override dir to use temp directory - std::env::set_var("HOME", opencode_dir.parent().unwrap().parent().unwrap()); - - // Should prefer .jsonc file let path = get_opencode_config_path(); assert!(path.ends_with("opencode.jsonc")); - - // Clean up env var - std::env::remove_var("HOME"); } #[test] + #[serial] fn test_read_opencode_config_fallback_to_json() { - let (_temp, opencode_dir) = setup_test_env(); + let (_home, opencode_dir) = setup_test_env(); - // Create only .json file let json_path = opencode_dir.join("opencode.json"); fs::write(&json_path, r#"{"provider": {"test": {}}}"#).unwrap(); - std::env::set_var("HOME", opencode_dir.parent().unwrap().parent().unwrap()); - - // Should fallback to .json file let path = get_opencode_config_path(); assert!(path.ends_with("opencode.json")); - - std::env::remove_var("HOME"); } #[test] + #[serial] fn test_read_opencode_config_with_comments() { - let (_temp, opencode_dir) = setup_test_env(); + let (_home, opencode_dir) = setup_test_env(); - // Create .jsonc file with comments let jsonc_path = opencode_dir.join("opencode.jsonc"); let config_with_comments = r#"{ // This is a comment @@ -311,9 +333,6 @@ mod tests { }"#; fs::write(&jsonc_path, config_with_comments).unwrap(); - std::env::set_var("HOME", opencode_dir.parent().unwrap().parent().unwrap()); - - // Should successfully parse config with comments let config = read_opencode_config().unwrap(); assert!(config.get("provider").is_some()); assert!(config["provider"] @@ -321,15 +340,13 @@ mod tests { .and_then(|p| p.get("apiKey")) .and_then(|k| k.as_str()) == Some("test-key")); - - std::env::remove_var("HOME"); } #[test] + #[serial] fn test_read_opencode_config_trailing_commas() { - let (_temp, opencode_dir) = setup_test_env(); + let (_home, opencode_dir) = setup_test_env(); - // Create .jsonc file with trailing commas let jsonc_path = opencode_dir.join("opencode.jsonc"); let config_with_trailing = r#"{ "$schema": "https://opencode.ai/config.json", @@ -342,12 +359,7 @@ mod tests { }"#; fs::write(&jsonc_path, config_with_trailing).unwrap(); - std::env::set_var("HOME", opencode_dir.parent().unwrap().parent().unwrap()); - - // Should successfully parse config with trailing commas let config = read_opencode_config().unwrap(); assert!(config.get("provider").is_some()); - - std::env::remove_var("HOME"); } }