From 26cf618bd4af1106ed6fed1e555b9ccaba336b7d Mon Sep 17 00:00:00 2001 From: xintaofei Date: Sun, 12 Apr 2026 10:10:13 +0800 Subject: [PATCH] feat(acp): add resolve_bun_binary and atomic_rewrite_opencode_json helpers Co-Authored-By: Claude Sonnet 4.6 --- src-tauri/src/acp/opencode_plugins.rs | 112 ++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) diff --git a/src-tauri/src/acp/opencode_plugins.rs b/src-tauri/src/acp/opencode_plugins.rs index 3fc75cb..3e9729e 100644 --- a/src-tauri/src/acp/opencode_plugins.rs +++ b/src-tauri/src/acp/opencode_plugins.rs @@ -1,4 +1,5 @@ use std::collections::HashSet; +use std::fs; use std::path::{Path, PathBuf}; use serde::Serialize; @@ -175,6 +176,117 @@ pub fn check_opencode_plugins( }) } +/// Locate a usable bun binary. +/// Priority: opencode-bundled bun → system bun → error. +pub fn resolve_bun_binary() -> Result { + let cache_dir = opencode_cache_dir(); + + // Try opencode-bundled bun + if let Some(ref dir) = cache_dir { + let candidates = if cfg!(windows) { + vec![dir.join("bin").join("bun.exe")] + } else { + vec![dir.join("bin").join("bun")] + }; + for candidate in candidates { + if candidate.exists() { + return Ok(candidate); + } + } + } + + // Fallback to system bun + if let Ok(system_bun) = which::which("bun") { + return Ok(system_bun); + } + + Err( + "bun binary not found. Neither opencode-bundled bun (~/.cache/opencode/bin/bun) \ + nor system bun is available." + .to_string(), + ) +} + +/// Detect whether a JSON string contains comments (// or /*). +fn json_has_comments(raw: &str) -> bool { + raw.contains("//") || raw.contains("/*") +} + +/// Write a timestamped backup of a file, keeping only the most recent `keep` copies. +fn write_backup_and_prune(path: &Path, content: &str, keep: usize) -> Result<(), String> { + let now = chrono::Local::now().format("%Y-%m-%dT%H-%M-%S"); + let backup_path = path.with_file_name(format!( + "{}.bak.{now}", + path.file_name().unwrap_or_default().to_string_lossy() + )); + fs::write(&backup_path, content) + .map_err(|e| format!("Failed to write backup {}: {e}", backup_path.display()))?; + + // Prune old backups + let parent = path.parent().ok_or("No parent directory")?; + let stem = path + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + let prefix = format!("{stem}.bak."); + + let mut backups: Vec<_> = fs::read_dir(parent) + .map_err(|e| e.to_string())? + .filter_map(|entry| entry.ok()) + .filter(|entry| { + entry + .file_name() + .to_string_lossy() + .starts_with(&prefix) + }) + .collect(); + + // Sort by name descending (timestamp in name → newest first) + backups.sort_by(|a, b| b.file_name().cmp(&a.file_name())); + + for old in backups.iter().skip(keep) { + let _ = fs::remove_file(old.path()); + } + + Ok(()) +} + +/// Atomically rewrite opencode.json: read → backup → mutate → write temp → rename. +pub(crate) fn atomic_rewrite_opencode_json( + path: &Path, + mutator: impl FnOnce(&mut serde_json::Value) -> Result<(), String>, +) -> Result<(), String> { + let raw = fs::read_to_string(path) + .map_err(|e| format!("Failed to read {}: {e}", path.display()))?; + + if json_has_comments(&raw) { + return Err( + "opencode.json contains comments (// or /*). Refusing to rewrite to avoid data loss. \ + Please edit the file manually." + .to_string(), + ); + } + + write_backup_and_prune(path, &raw, 3)?; + + let mut doc: serde_json::Value = serde_json::from_str(&raw) + .map_err(|e| format!("Failed to parse {}: {e}", path.display()))?; + + mutator(&mut doc)?; + + let new_raw = serde_json::to_string_pretty(&doc) + .map_err(|e| format!("Failed to serialize JSON: {e}"))?; + + let tmp_path = path.with_extension("json.tmp"); + fs::write(&tmp_path, &new_raw) + .map_err(|e| format!("Failed to write temp file: {e}"))?; + fs::rename(&tmp_path, path) + .map_err(|e| format!("Failed to rename temp file: {e}"))?; + + Ok(()) +} + /// Parse a plugin spec string from opencode.json `plugin[]` into (package_name, full_spec). /// /// Examples: