mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-10-26 01:34:51 +08:00
fix(opencode): support unified OMO config (#6011)
* fix(opencode): support unified OMO config * fix(opencode): couple rollback snapshots to writes * fix(ci): simplify rollback version type * fix OMO unified config review issues * validate OMO round-trip semantics
This commit is contained in:
1 parent
290b65c03b
commit
0345fad604
3 files changed
+1588
-189
No files matched your search
+140
-20
@@ -290,8 +290,11 @@ fn sort_json_keys(value: &Value) -> Value {
|
||||
}
|
||||
}
|
||||
|
||||
/// 写入 JSON 配置文件(键按字母排序,确保确定性输出)
|
||||
pub fn write_json_file<T: Serialize>(path: &Path, data: &T) -> Result<(), AppError> {
|
||||
/// 写入 JSON 配置文件并返回实际写入的字节。
|
||||
pub fn write_json_file_with_contents<T: Serialize>(
|
||||
path: &Path,
|
||||
data: &T,
|
||||
) -> Result<Vec<u8>, AppError> {
|
||||
// 确保目录存在
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
|
||||
@@ -302,7 +305,14 @@ pub fn write_json_file<T: Serialize>(path: &Path, data: &T) -> Result<(), AppErr
|
||||
let json = serde_json::to_string_pretty(&sorted_value)
|
||||
.map_err(|e| AppError::JsonSerialize { source: e })?;
|
||||
|
||||
atomic_write(path, json.as_bytes())
|
||||
let contents = json.into_bytes();
|
||||
atomic_write(path, &contents)?;
|
||||
Ok(contents)
|
||||
}
|
||||
|
||||
/// 写入 JSON 配置文件(键按字母排序,确保确定性输出)
|
||||
pub fn write_json_file<T: Serialize>(path: &Path, data: &T) -> Result<(), AppError> {
|
||||
write_json_file_with_contents(path, data).map(|_| ())
|
||||
}
|
||||
|
||||
/// 原子写入文本文件(用于 TOML/纯文本)
|
||||
@@ -322,7 +332,6 @@ pub fn atomic_write(path: &Path, data: &[u8]) -> Result<(), AppError> {
|
||||
let parent = path
|
||||
.parent()
|
||||
.ok_or_else(|| AppError::Config("无效的路径".to_string()))?;
|
||||
let mut tmp = parent.to_path_buf();
|
||||
let file_name = path
|
||||
.file_name()
|
||||
.ok_or_else(|| AppError::Config("无效的文件名".to_string()))?
|
||||
@@ -332,13 +341,38 @@ pub fn atomic_write(path: &Path, data: &[u8]) -> Result<(), AppError> {
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos();
|
||||
tmp.push(format!("{file_name}.tmp.{ts}"));
|
||||
static TEMP_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
let (tmp, mut file) = (|| -> Result<(PathBuf, fs::File), AppError> {
|
||||
let mut last_collision = None;
|
||||
for _ in 0..16 {
|
||||
let counter = TEMP_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let candidate = parent.join(format!(
|
||||
"{file_name}.tmp.{}.{ts}.{counter}",
|
||||
std::process::id()
|
||||
));
|
||||
match fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&candidate)
|
||||
{
|
||||
Ok(file) => return Ok((candidate, file)),
|
||||
Err(source) if source.kind() == std::io::ErrorKind::AlreadyExists => {
|
||||
last_collision = Some((candidate, source));
|
||||
}
|
||||
Err(source) => return Err(AppError::io(&candidate, source)),
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let mut f = fs::File::create(&tmp).map_err(|e| AppError::io(&tmp, e))?;
|
||||
f.write_all(data).map_err(|e| AppError::io(&tmp, e))?;
|
||||
f.flush().map_err(|e| AppError::io(&tmp, e))?;
|
||||
let (candidate, source) = last_collision.expect("temporary filename loop must run");
|
||||
Err(AppError::io(&candidate, source))
|
||||
})()?;
|
||||
|
||||
if let Err(source) = file.write_all(data).and_then(|_| file.flush()) {
|
||||
drop(file);
|
||||
let _ = fs::remove_file(&tmp);
|
||||
return Err(AppError::io(&tmp, source));
|
||||
}
|
||||
drop(file);
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
@@ -351,22 +385,85 @@ pub fn atomic_write(path: &Path, data: &[u8]) -> Result<(), AppError> {
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
// Windows 上 rename 目标存在会失败,先移除再重命名(尽量接近原子性)
|
||||
if path.exists() {
|
||||
let _ = fs::remove_file(path);
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
use windows_sys::Win32::Storage::FileSystem::ReplaceFileW;
|
||||
|
||||
let replaced: Vec<u16> = path
|
||||
.as_os_str()
|
||||
.encode_wide()
|
||||
.chain(std::iter::once(0))
|
||||
.collect();
|
||||
let replacement: Vec<u16> = tmp
|
||||
.as_os_str()
|
||||
.encode_wide()
|
||||
.chain(std::iter::once(0))
|
||||
.collect();
|
||||
let mut completed = false;
|
||||
let mut last_error = None;
|
||||
|
||||
for _ in 0..3 {
|
||||
// SAFETY: both path buffers are NUL-terminated UTF-16 and remain alive for the
|
||||
// duration of the call. Backup, exclusion, and reserved pointers are intentionally null.
|
||||
let replaced_ok = unsafe {
|
||||
ReplaceFileW(
|
||||
replaced.as_ptr(),
|
||||
replacement.as_ptr(),
|
||||
std::ptr::null(),
|
||||
0,
|
||||
std::ptr::null(),
|
||||
std::ptr::null(),
|
||||
)
|
||||
};
|
||||
if replaced_ok != 0 {
|
||||
completed = true;
|
||||
break;
|
||||
}
|
||||
|
||||
let replace_error = std::io::Error::last_os_error();
|
||||
if replace_error.kind() != std::io::ErrorKind::NotFound {
|
||||
last_error = Some(replace_error);
|
||||
break;
|
||||
}
|
||||
|
||||
match fs::rename(&tmp, path) {
|
||||
Ok(()) => {
|
||||
completed = true;
|
||||
break;
|
||||
}
|
||||
Err(source)
|
||||
if matches!(
|
||||
source.kind(),
|
||||
std::io::ErrorKind::AlreadyExists | std::io::ErrorKind::PermissionDenied
|
||||
) =>
|
||||
{
|
||||
last_error = Some(source);
|
||||
}
|
||||
Err(source) => {
|
||||
last_error = Some(source);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !completed {
|
||||
let source = last_error.unwrap_or_else(std::io::Error::last_os_error);
|
||||
let _ = fs::remove_file(&tmp);
|
||||
return Err(AppError::IoContext {
|
||||
context: format!("原子替换失败: {} -> {}", tmp.display(), path.display()),
|
||||
source,
|
||||
});
|
||||
}
|
||||
fs::rename(&tmp, path).map_err(|e| AppError::IoContext {
|
||||
context: format!("原子替换失败: {} -> {}", tmp.display(), path.display()),
|
||||
source: e,
|
||||
})?;
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
fs::rename(&tmp, path).map_err(|e| AppError::IoContext {
|
||||
context: format!("原子替换失败: {} -> {}", tmp.display(), path.display()),
|
||||
source: e,
|
||||
})?;
|
||||
if let Err(source) = fs::rename(&tmp, path) {
|
||||
let _ = fs::remove_file(&tmp);
|
||||
return Err(AppError::IoContext {
|
||||
context: format!("原子替换失败: {} -> {}", tmp.display(), path.display()),
|
||||
source,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -375,6 +472,29 @@ pub fn atomic_write(path: &Path, data: &[u8]) -> Result<(), AppError> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn atomic_write_preserves_destination_when_windows_replace_fails() {
|
||||
use std::os::windows::fs::OpenOptionsExt;
|
||||
use windows_sys::Win32::Storage::FileSystem::FILE_SHARE_READ;
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("config.json");
|
||||
std::fs::write(&path, b"old contents").unwrap();
|
||||
let held_file = std::fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.share_mode(FILE_SHARE_READ)
|
||||
.open(&path)
|
||||
.unwrap();
|
||||
|
||||
let result = atomic_write(&path, b"new contents");
|
||||
|
||||
assert!(result.is_err());
|
||||
drop(held_file);
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"old contents");
|
||||
assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derive_mcp_path_from_override_uses_config_dir_for_custom_path() {
|
||||
let override_dir = PathBuf::from("/tmp/profile/.claude");
|
||||
|
||||
@@ -1,13 +1,26 @@
|
||||
use crate::config::write_json_file;
|
||||
use crate::config::write_json_file_with_contents;
|
||||
use crate::error::AppError;
|
||||
use crate::provider::OpenCodeProviderConfig;
|
||||
use crate::settings::get_opencode_override_dir;
|
||||
use indexmap::IndexMap;
|
||||
use serde_json::{json, Map, Value};
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
const STANDARD_OMO_PLUGIN_PREFIXES: [&str; 2] = ["oh-my-openagent", "oh-my-opencode"];
|
||||
const SLIM_OMO_PLUGIN_PREFIXES: [&str; 1] = ["oh-my-opencode-slim"];
|
||||
fn opencode_config_lock() -> &'static Mutex<()> {
|
||||
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
LOCK.get_or_init(|| Mutex::new(()))
|
||||
}
|
||||
|
||||
fn read_config_contents(path: &Path) -> Result<Option<Vec<u8>>, AppError> {
|
||||
match std::fs::read(path) {
|
||||
Ok(contents) => Ok(Some(contents)),
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
Err(err) => Err(AppError::io(path, err)),
|
||||
}
|
||||
}
|
||||
|
||||
fn matches_plugin_prefix(plugin_name: &str, prefix: &str) -> bool {
|
||||
plugin_name == prefix
|
||||
@@ -85,16 +98,16 @@ pub fn get_opencode_env_path() -> PathBuf {
|
||||
get_opencode_dir().join(".env")
|
||||
}
|
||||
|
||||
pub fn read_opencode_config() -> Result<Value, AppError> {
|
||||
let path = get_opencode_config_path();
|
||||
|
||||
if !path.exists() {
|
||||
return Ok(json!({
|
||||
"$schema": "https://opencode.ai/config.json"
|
||||
}));
|
||||
}
|
||||
|
||||
let content = std::fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?;
|
||||
fn read_opencode_config_from_path(path: &Path) -> Result<Value, AppError> {
|
||||
let content = match std::fs::read_to_string(path) {
|
||||
Ok(content) => content,
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
|
||||
return Ok(json!({
|
||||
"$schema": "https://opencode.ai/config.json"
|
||||
}));
|
||||
}
|
||||
Err(err) => return Err(AppError::io(path, err)),
|
||||
};
|
||||
let value: Value = json5::from_str(&content).map_err(|e| {
|
||||
AppError::Config(format!(
|
||||
"Failed to parse OpenCode config: {}: {e}",
|
||||
@@ -118,12 +131,18 @@ pub fn read_opencode_config() -> Result<Value, AppError> {
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
pub fn write_opencode_config(config: &Value) -> Result<(), AppError> {
|
||||
let path = get_opencode_config_path();
|
||||
write_json_file(&path, config)?;
|
||||
pub fn read_opencode_config() -> Result<Value, AppError> {
|
||||
read_opencode_config_from_path(&get_opencode_config_path())
|
||||
}
|
||||
|
||||
fn write_opencode_config_to_path_with_contents(
|
||||
path: &Path,
|
||||
config: &Value,
|
||||
) -> Result<Vec<u8>, AppError> {
|
||||
let contents = write_json_file_with_contents(path, config)?;
|
||||
|
||||
log::debug!("OpenCode config written to {path:?}");
|
||||
Ok(())
|
||||
Ok(contents)
|
||||
}
|
||||
|
||||
pub fn get_providers() -> Result<Map<String, Value>, AppError> {
|
||||
@@ -136,7 +155,9 @@ pub fn get_providers() -> Result<Map<String, Value>, AppError> {
|
||||
}
|
||||
|
||||
pub fn set_provider(id: &str, config: Value) -> Result<(), AppError> {
|
||||
let mut full_config = read_opencode_config()?;
|
||||
let _guard = opencode_config_lock().lock()?;
|
||||
let path = get_opencode_config_path();
|
||||
let mut full_config = read_opencode_config_from_path(&path)?;
|
||||
|
||||
// 判空要连「存在但不是对象」一起算:否则下面 as_object_mut 拿不到,
|
||||
// 写入会静默失效——界面显示添加成功而文件里没有。provider 段是 cc-switch
|
||||
@@ -155,11 +176,13 @@ pub fn set_provider(id: &str, config: Value) -> Result<(), AppError> {
|
||||
providers.insert(id.to_string(), config);
|
||||
}
|
||||
|
||||
write_opencode_config(&full_config)
|
||||
write_opencode_config_to_path_with_contents(&path, &full_config).map(|_| ())
|
||||
}
|
||||
|
||||
pub fn remove_provider(id: &str) -> Result<(), AppError> {
|
||||
let mut config = read_opencode_config()?;
|
||||
let _guard = opencode_config_lock().lock()?;
|
||||
let path = get_opencode_config_path();
|
||||
let mut config = read_opencode_config_from_path(&path)?;
|
||||
|
||||
if let Some(providers) = config.get_mut("provider").and_then(|v| v.as_object_mut()) {
|
||||
providers.remove(id);
|
||||
@@ -167,7 +190,7 @@ pub fn remove_provider(id: &str) -> Result<(), AppError> {
|
||||
log::warn!("opencode.json 的 provider 不是对象,无法删除供应商 '{id}'");
|
||||
}
|
||||
|
||||
write_opencode_config(&config)
|
||||
write_opencode_config_to_path_with_contents(&path, &config).map(|_| ())
|
||||
}
|
||||
|
||||
pub fn get_typed_providers() -> Result<IndexMap<String, OpenCodeProviderConfig>, AppError> {
|
||||
@@ -203,7 +226,9 @@ pub fn get_mcp_servers() -> Result<Map<String, Value>, AppError> {
|
||||
}
|
||||
|
||||
pub fn set_mcp_server(id: &str, config: Value) -> Result<(), AppError> {
|
||||
let mut full_config = read_opencode_config()?;
|
||||
let _guard = opencode_config_lock().lock()?;
|
||||
let path = get_opencode_config_path();
|
||||
let mut full_config = read_opencode_config_from_path(&path)?;
|
||||
|
||||
if !full_config.get("mcp").is_some_and(Value::is_object) {
|
||||
if full_config.get("mcp").is_some() {
|
||||
@@ -216,11 +241,13 @@ pub fn set_mcp_server(id: &str, config: Value) -> Result<(), AppError> {
|
||||
mcp.insert(id.to_string(), config);
|
||||
}
|
||||
|
||||
write_opencode_config(&full_config)
|
||||
write_opencode_config_to_path_with_contents(&path, &full_config).map(|_| ())
|
||||
}
|
||||
|
||||
pub fn remove_mcp_server(id: &str) -> Result<(), AppError> {
|
||||
let mut config = read_opencode_config()?;
|
||||
let _guard = opencode_config_lock().lock()?;
|
||||
let path = get_opencode_config_path();
|
||||
let mut config = read_opencode_config_from_path(&path)?;
|
||||
|
||||
if let Some(mcp) = config.get_mut("mcp").and_then(|v| v.as_object_mut()) {
|
||||
mcp.remove(id);
|
||||
@@ -228,70 +255,98 @@ pub fn remove_mcp_server(id: &str) -> Result<(), AppError> {
|
||||
log::warn!("opencode.json 的 mcp 不是对象,无法删除服务器 '{id}'");
|
||||
}
|
||||
|
||||
write_opencode_config(&config)
|
||||
write_opencode_config_to_path_with_contents(&path, &config).map(|_| ())
|
||||
}
|
||||
|
||||
pub fn add_plugin(plugin_name: &str) -> Result<(), AppError> {
|
||||
let mut config = read_opencode_config()?;
|
||||
pub fn add_plugin(path: &Path, plugin_name: &str) -> Result<(), AppError> {
|
||||
let _guard = opencode_config_lock().lock()?;
|
||||
let mut config = read_opencode_config_from_path(path)?;
|
||||
let normalized_plugin_name = canonicalize_plugin_name(plugin_name);
|
||||
let target_is_omo =
|
||||
matches_any_plugin_prefix(&normalized_plugin_name, &STANDARD_OMO_PLUGIN_PREFIXES)
|
||||
|| matches_any_plugin_prefix(&normalized_plugin_name, &SLIM_OMO_PLUGIN_PREFIXES);
|
||||
let mut changed = false;
|
||||
|
||||
let plugins = config.get_mut("plugin").and_then(|v| v.as_array_mut());
|
||||
|
||||
match plugins {
|
||||
Some(arr) => {
|
||||
// Mutual exclusion: standard OMO and OMO Slim cannot coexist as plugins
|
||||
if matches_any_plugin_prefix(&normalized_plugin_name, &STANDARD_OMO_PLUGIN_PREFIXES) {
|
||||
arr.retain(|v| {
|
||||
v.as_str()
|
||||
.map(|s| {
|
||||
!matches_any_plugin_prefix(s, &STANDARD_OMO_PLUGIN_PREFIXES)
|
||||
&& !matches_any_plugin_prefix(s, &SLIM_OMO_PLUGIN_PREFIXES)
|
||||
})
|
||||
.unwrap_or(true)
|
||||
});
|
||||
} else if matches_any_plugin_prefix(&normalized_plugin_name, &SLIM_OMO_PLUGIN_PREFIXES)
|
||||
{
|
||||
arr.retain(|v| {
|
||||
v.as_str()
|
||||
.map(|s| {
|
||||
!matches_any_plugin_prefix(s, &STANDARD_OMO_PLUGIN_PREFIXES)
|
||||
&& !matches_any_plugin_prefix(s, &SLIM_OMO_PLUGIN_PREFIXES)
|
||||
})
|
||||
.unwrap_or(true)
|
||||
});
|
||||
}
|
||||
let mut found_target = false;
|
||||
arr.retain(|value| {
|
||||
let Some(existing_name) = value.as_str() else {
|
||||
return true;
|
||||
};
|
||||
if existing_name == normalized_plugin_name {
|
||||
if found_target {
|
||||
changed = true;
|
||||
return false;
|
||||
}
|
||||
found_target = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
let already_exists = arr
|
||||
.iter()
|
||||
.any(|v| v.as_str() == Some(normalized_plugin_name.as_str()));
|
||||
if !already_exists {
|
||||
// Standard OMO and OMO Slim are mutually exclusive.
|
||||
if target_is_omo
|
||||
&& (matches_any_plugin_prefix(existing_name, &STANDARD_OMO_PLUGIN_PREFIXES)
|
||||
|| matches_any_plugin_prefix(existing_name, &SLIM_OMO_PLUGIN_PREFIXES))
|
||||
{
|
||||
changed = true;
|
||||
return false;
|
||||
}
|
||||
true
|
||||
});
|
||||
|
||||
if !found_target {
|
||||
arr.push(Value::String(normalized_plugin_name));
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
None => {
|
||||
config["plugin"] = json!([normalized_plugin_name]);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
write_opencode_config(&config)
|
||||
if !changed {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
write_opencode_config_to_path_with_contents(path, &config).map(|_| ())
|
||||
}
|
||||
|
||||
pub fn remove_plugins_by_prefixes(prefixes: &[&str]) -> Result<(), AppError> {
|
||||
let mut config = read_opencode_config()?;
|
||||
pub fn remove_plugins_by_prefixes(path: &Path, prefixes: &[&str]) -> Result<bool, AppError> {
|
||||
let _guard = opencode_config_lock().lock()?;
|
||||
let previous_contents = read_config_contents(path)?;
|
||||
let mut config = read_opencode_config_from_path(path)?;
|
||||
|
||||
let mut changed = false;
|
||||
if let Some(arr) = config.get_mut("plugin").and_then(|v| v.as_array_mut()) {
|
||||
let previous_len = arr.len();
|
||||
arr.retain(|v| {
|
||||
v.as_str()
|
||||
.map(|s| !matches_any_plugin_prefix(s, prefixes))
|
||||
.unwrap_or(true)
|
||||
});
|
||||
changed = arr.len() != previous_len;
|
||||
|
||||
if arr.is_empty() {
|
||||
if changed && arr.is_empty() {
|
||||
config.as_object_mut().map(|obj| obj.remove("plugin"));
|
||||
}
|
||||
}
|
||||
|
||||
write_opencode_config(&config)
|
||||
if !changed {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let current_contents = read_config_contents(path)?;
|
||||
if current_contents != previous_contents {
|
||||
return Err(AppError::Config(
|
||||
"OpenCode config changed on disk. Please reload and try again.".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
write_opencode_config_to_path_with_contents(path, &config)?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -366,4 +421,48 @@ mod tests {
|
||||
"unrelated user config must be preserved"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_missing_plugin_does_not_create_config_file() {
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let path = temp.path().join("opencode.json");
|
||||
|
||||
let result = remove_plugins_by_prefixes(&path, &["oh-my-openagent"]).unwrap();
|
||||
|
||||
assert!(!result);
|
||||
assert!(!path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_missing_plugin_preserves_existing_source() {
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let path = temp.path().join("opencode.json");
|
||||
let original = r#"{
|
||||
// Keep formatting when the target plugin is absent.
|
||||
"plugin": ["unrelated-plugin"],
|
||||
"theme": "dark",
|
||||
}"#;
|
||||
std::fs::write(&path, original).unwrap();
|
||||
|
||||
let result = remove_plugins_by_prefixes(&path, &["oh-my-openagent"]).unwrap();
|
||||
|
||||
assert!(!result);
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_existing_plugin_preserves_existing_source() {
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let path = temp.path().join("opencode.json");
|
||||
let original = r#"{
|
||||
// Keep comments and formatting when the plugin is already configured.
|
||||
plugin: ['oh-my-openagent@latest'],
|
||||
theme: 'dark',
|
||||
}"#;
|
||||
std::fs::write(&path, original).unwrap();
|
||||
|
||||
add_plugin(&path, "oh-my-openagent@latest").unwrap();
|
||||
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), original);
|
||||
}
|
||||
}
|
||||
+1293
-113
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user