mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-03 19:12:04 +08:00
4f78451405
Replace the generic Provider save/upsert surface with strict typed create/update/rename/endpoint operations. Keep aggregate hydration read-only, preserve nullable endpoint timestamps end to end, and make service create own the initial endpoint set atomically. Restore SQL and binary backups only through UntrustedScratch, migrate and copy fixed data columns into a fresh CanonicalStage, validate the canonical result, then publish through the Backup API. The imported schema is never eligible to become the live schema. Old save_provider callsite classification ========================================== Inventory authority: abandoned 5a385fc8 tree. The old definition at src-tauri/src/database/dao/providers.rs:180 is deleted and is not a callsite. Production callsites: - src-tauri/src/commands/provider.rs:253 [create] Claude Desktop import creates one absent aggregate; it now strict-inserts the row and initial endpoints in one transaction. - src-tauri/src/database/dao/providers.rs:638 [create] official seed first proves absence, then strict-creates; a racing insert is a conflict. - src-tauri/src/database/dao/providers.rs:704 [create] on-demand seed first proves absence, then strict-creates; it cannot overwrite an existing row. - src-tauri/src/services/omo.rs:291 [create] OMO import constructs a new aggregate and strict-creates it; OMO is not eligible for rename. - src-tauri/src/services/provider/endpoints.rs:85 [update] endpoint last-used is not a Provider-row save; it now calls the exact touch endpoint operation. - src-tauri/src/services/provider/live.rs:1567 [create/update] default live import is reconciliation: read first, then strict create or strict update. - src-tauri/src/services/provider/live.rs:1743 [update] an existing OpenCode live provider follows the strict row-update branch. - src-tauri/src/services/provider/live.rs:1770 [create] a new OpenCode live provider follows the strict aggregate-create branch. - src-tauri/src/services/provider/live.rs:1825 [update] an existing OpenClaw live provider follows the strict row-update branch. - src-tauri/src/services/provider/live.rs:1858 [create] a new OpenClaw live provider follows the strict aggregate-create branch. - src-tauri/src/services/provider/live.rs:1900 [update] an existing Hermes live provider follows the strict row-update branch. - src-tauri/src/services/provider/live.rs:1926 [create] a new Hermes live provider follows the strict aggregate-create branch. - src-tauri/src/services/provider/mod.rs:2568 [create] ProviderService::add owns strict aggregate creation and all initial endpoints. - src-tauri/src/services/provider/mod.rs:2680 [rename] an additive DB-only key change now uses the dedicated transactional rename after eligibility checks. - src-tauri/src/services/provider/mod.rs:2711 [update] OMO edit updates exactly the existing main row after its live-file coordination. - src-tauri/src/services/provider/mod.rs:2740 [update] additive-provider edit updates exactly the existing main row after resolving live ownership. - src-tauri/src/services/provider/mod.rs:2750 [update] switch-mode edit updates exactly the existing main row and never inserts. - src-tauri/src/services/provider/mod.rs:2948 [update] remove-from-live changes only the existing provider's live-managed marker. - src-tauri/src/services/provider/mod.rs:3120 [update] switch backfill updates only the existing current provider row. - src-tauri/src/services/provider/mod.rs:3174 [update] successful additive switch changes only the existing live-managed marker. - src-tauri/src/services/provider/mod.rs:3315 [update] common-config migration updates only each already-read existing row. - src-tauri/src/services/provider/mod.rs:3895 [update] Gemini credential scrub updates only each already-read existing row. - src-tauri/src/services/provider/mod.rs:4082 [update] sort ordering is routed to the dedicated sort-index state operation, not row replacement. - src-tauri/src/services/provider/mod.rs:4636 [create/update] universal-to- Claude reconciliation reads the target and selects strict create or update. - src-tauri/src/services/provider/mod.rs:4651 [create/update] universal-to- Codex reconciliation reads the target and selects strict create or update. - src-tauri/src/services/provider/mod.rs:4665 [create/update] universal-to- Gemini reconciliation reads the target and selects strict create or update. Required indirect ownership paths: - src-tauri/src/deeplink/provider.rs [create] the old indirect flow called ProviderService::add and then appended endpoints one by one. It now supplies every non-primary endpoint to one strict aggregate create, so hydration is complete atomically and a duplicate is zero-side-effect. - [restore] no old generic-save callsite is reclassified as restore. Exact aggregate replacement exists only as the sealed restore_provider_aggregate_on_tx compensation primitive. Test-only callsites: Every item below is classified [test]. Each is fixture setup, not a production write authority, and is migrated to a real ProviderService entry where the behavior is under test or to the cfg(test)-only typed fixture reconciler where the test merely needs pre-existing rows. - src-tauri/src/codex_history_migration.rs:1442 [test] migration fixture setup. - src-tauri/src/codex_history_migration.rs:1452 [test] migration fixture setup. - src-tauri/src/codex_history_migration.rs:2174 [test] migration fixture setup. - src-tauri/src/codex_history_migration.rs:2176 [test] migration fixture setup. - src-tauri/src/codex_history_migration.rs:2199 [test] migration fixture setup. - src-tauri/src/codex_history_migration.rs:2219 [test] migration fixture setup. - src-tauri/src/codex_history_migration.rs:2247 [test] migration fixture setup. - src-tauri/src/codex_history_migration.rs:2267 [test] migration fixture setup. - src-tauri/src/codex_history_migration.rs:2288 [test] migration fixture setup. - src-tauri/src/codex_history_migration.rs:2320 [test] migration fixture setup. - src-tauri/src/codex_history_migration.rs:2393 [test] migration fixture setup. - src-tauri/src/codex_history_migration.rs:2449 [test] migration fixture setup. - src-tauri/src/codex_history_migration.rs:2498 [test] migration fixture setup. - src-tauri/src/codex_history_migration.rs:2555 [test] migration fixture setup. - src-tauri/src/codex_history_migration.rs:2604 [test] migration fixture setup. - src-tauri/src/codex_history_migration.rs:2625 [test] migration fixture setup. - src-tauri/src/database/dao/providers.rs:754 [test] DAO fixture setup. - src-tauri/src/proxy/provider_router.rs:351 [test] router fixture setup. - src-tauri/src/proxy/provider_router.rs:352 [test] router fixture setup. - src-tauri/src/proxy/provider_router.rs:377 [test] router fixture setup. - src-tauri/src/proxy/provider_router.rs:378 [test] router fixture setup. - src-tauri/src/proxy/provider_router.rs:410 [test] router fixture setup. - src-tauri/src/proxy/provider_router.rs:411 [test] router fixture setup. - src-tauri/src/proxy/provider_router.rs:447 [test] router fixture setup. - src-tauri/src/proxy/provider_router.rs:448 [test] router fixture setup. - src-tauri/src/proxy/provider_router.rs:488 [test] router fixture setup. - src-tauri/src/services/provider/mod.rs:485 [test] service fixture setup. - src-tauri/src/services/provider/mod.rs:586 [test] service fixture setup. - src-tauri/src/services/provider/mod.rs:813 [test] service fixture setup. - src-tauri/src/services/provider/mod.rs:825 [test] service fixture setup. - src-tauri/src/services/provider/mod.rs:1472 [test] service fixture setup. - src-tauri/src/services/provider/mod.rs:1607 [test] service fixture setup. - src-tauri/src/services/provider/mod.rs:1737 [test] service fixture setup. - src-tauri/src/services/provider/mod.rs:1945 [test] service fixture setup. - src-tauri/src/services/provider/mod.rs:1952 [test] service fixture setup. - src-tauri/src/services/provider/mod.rs:1978 [test] service fixture setup. - src-tauri/src/services/provider/mod.rs:2006 [test] service fixture setup. - src-tauri/src/services/provider/mod.rs:2056 [test] service fixture setup. - src-tauri/src/services/provider/mod.rs:2130 [test] service fixture setup. - src-tauri/src/services/provider/mod.rs:2167 [test] service fixture setup. - src-tauri/src/services/provider/mod.rs:2207 [test] service fixture setup. - src-tauri/src/services/provider/mod.rs:2235 [test] service fixture setup. - src-tauri/src/services/provider/mod.rs:2270 [test] service fixture setup. - src-tauri/src/services/provider/mod.rs:2320 [test] service fixture setup. - src-tauri/src/services/provider/mod.rs:2362 [test] service fixture setup. - src-tauri/src/services/proxy.rs:3762 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:3948 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:4034 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:4095 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:4114 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:4263 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:4341 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:4421 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:4533 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:4651 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:4787 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:5264 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:5320 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:5385 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:5387 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:5460 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:5462 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:5611 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:5613 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:5615 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:5698 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:5700 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:5998 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:6000 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:6173 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:6175 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:6417 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:6419 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:6553 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:6555 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:6635 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:6637 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:6919 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:7173 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:7175 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:7240 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:7242 [test] proxy fixture setup. - src-tauri/tests/profile_roundtrip.rs:112 [test] profile fixture create. - src-tauri/tests/profile_roundtrip.rs:116 [test] profile fixture create. - src-tauri/tests/profile_roundtrip.rs:126 [test] profile fixture create. - src-tauri/tests/profile_roundtrip.rs:133 [test] profile fixture create. - src-tauri/tests/profile_roundtrip.rs:292 [test] profile fixture create. - src-tauri/tests/profile_roundtrip.rs:501 [test] profile fixture create. - src-tauri/tests/profile_roundtrip.rs:505 [test] profile fixture create. - src-tauri/tests/profile_roundtrip.rs:670 [test] profile fixture create. - src-tauri/tests/profile_roundtrip.rs:677 [test] profile fixture create. - src-tauri/tests/profile_roundtrip.rs:762 [test] Linux Desktop fixture create. - src-tauri/tests/profile_roundtrip.rs:769 [test] Linux Desktop fixture create. - src-tauri/tests/provider_commands.rs:69 [test] command fixture create. - src-tauri/tests/provider_service.rs:2927 [test] service fixture create.
565 lines
19 KiB
Rust
565 lines
19 KiB
Rust
use crate::config::{atomic_write, write_json_file};
|
|
use crate::database::NewProviderAggregate;
|
|
use crate::error::AppError;
|
|
use crate::opencode_config::get_opencode_dir;
|
|
use crate::provider::Provider;
|
|
use crate::store::AppState;
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::{Map, Value};
|
|
use std::path::{Path, PathBuf};
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct OmoLocalFileData {
|
|
pub agents: Option<Value>,
|
|
pub categories: Option<Value>,
|
|
pub other_fields: Option<Value>,
|
|
pub file_path: String,
|
|
pub last_modified: Option<String>,
|
|
}
|
|
|
|
type OmoProfileData = (Option<Value>, Option<Value>, Option<Value>);
|
|
|
|
// ── Variant descriptor ─────────────────────────────────────────
|
|
|
|
pub struct OmoVariant {
|
|
pub preferred_filename: &'static str,
|
|
pub config_candidates: &'static [&'static str],
|
|
pub category: &'static str,
|
|
pub provider_prefix: &'static str,
|
|
pub plugin_name: &'static str,
|
|
pub plugin_prefixes: &'static [&'static str],
|
|
pub has_categories: bool,
|
|
pub label: &'static str,
|
|
pub import_label: &'static str,
|
|
}
|
|
|
|
pub const STANDARD: OmoVariant = OmoVariant {
|
|
preferred_filename: "oh-my-openagent.jsonc",
|
|
config_candidates: &[
|
|
"oh-my-openagent.jsonc",
|
|
"oh-my-openagent.json",
|
|
"oh-my-opencode.jsonc",
|
|
"oh-my-opencode.json",
|
|
],
|
|
category: "omo",
|
|
provider_prefix: "omo-",
|
|
plugin_name: "oh-my-openagent@latest",
|
|
plugin_prefixes: &["oh-my-openagent", "oh-my-opencode"],
|
|
has_categories: true,
|
|
label: "OMO",
|
|
import_label: "Imported",
|
|
};
|
|
|
|
pub const SLIM: OmoVariant = OmoVariant {
|
|
preferred_filename: "oh-my-opencode-slim.jsonc",
|
|
config_candidates: &["oh-my-opencode-slim.jsonc", "oh-my-opencode-slim.json"],
|
|
category: "omo-slim",
|
|
provider_prefix: "omo-slim-",
|
|
plugin_name: "oh-my-opencode-slim@latest",
|
|
plugin_prefixes: &["oh-my-opencode-slim"],
|
|
has_categories: false,
|
|
label: "OMO Slim",
|
|
import_label: "Imported Slim",
|
|
};
|
|
|
|
// ── Service ────────────────────────────────────────────────────
|
|
|
|
pub struct OmoService;
|
|
|
|
impl OmoService {
|
|
// ── Path helpers ────────────────────────────────────────
|
|
|
|
fn config_candidates(v: &OmoVariant, base_dir: &Path) -> Vec<PathBuf> {
|
|
v.config_candidates
|
|
.iter()
|
|
.map(|name| base_dir.join(name))
|
|
.collect()
|
|
}
|
|
|
|
fn find_existing_config_path(v: &OmoVariant, base_dir: &Path) -> Option<PathBuf> {
|
|
Self::config_candidates(v, base_dir)
|
|
.into_iter()
|
|
.find(|path| path.exists())
|
|
}
|
|
|
|
fn config_path(v: &OmoVariant) -> PathBuf {
|
|
let base_dir = get_opencode_dir();
|
|
Self::find_existing_config_path(v, &base_dir)
|
|
.unwrap_or_else(|| base_dir.join(v.preferred_filename))
|
|
}
|
|
|
|
fn resolve_local_config_path(v: &OmoVariant) -> Result<PathBuf, AppError> {
|
|
Self::find_existing_config_path(v, &get_opencode_dir()).ok_or(AppError::OmoConfigNotFound)
|
|
}
|
|
|
|
fn read_jsonc_object(path: &Path) -> Result<Map<String, Value>, AppError> {
|
|
let content = std::fs::read_to_string(path).map_err(|e| AppError::io(path, e))?;
|
|
let cleaned = Self::strip_jsonc_comments(&content);
|
|
let parsed: Value = serde_json::from_str(&cleaned)
|
|
.map_err(|e| AppError::Config(format!("Failed to parse oh-my-opencode config: {e}")))?;
|
|
parsed
|
|
.as_object()
|
|
.cloned()
|
|
.ok_or_else(|| AppError::Config("Expected JSON object".to_string()))
|
|
}
|
|
|
|
// ── Field extraction ───────────────────────────────────
|
|
|
|
fn extract_other_fields_with_keys(
|
|
obj: &Map<String, Value>,
|
|
known: &[&str],
|
|
) -> Map<String, Value> {
|
|
let mut other = Map::new();
|
|
for (k, v) in obj {
|
|
if !known.contains(&k.as_str()) {
|
|
other.insert(k.clone(), v.clone());
|
|
}
|
|
}
|
|
other
|
|
}
|
|
|
|
// ── Merge helpers ──────────────────────────────────────
|
|
|
|
fn insert_opt_value(result: &mut Map<String, Value>, key: &str, value: &Option<Value>) {
|
|
if let Some(v) = value {
|
|
result.insert(key.to_string(), v.clone());
|
|
}
|
|
}
|
|
|
|
fn insert_object_entries(result: &mut Map<String, Value>, value: Option<&Value>) {
|
|
if let Some(Value::Object(map)) = value {
|
|
for (k, v) in map {
|
|
result.insert(k.clone(), v.clone());
|
|
}
|
|
}
|
|
}
|
|
|
|
fn profile_data_from_provider(provider: &Provider, v: &OmoVariant) -> OmoProfileData {
|
|
let agents = provider.settings_config.get("agents").cloned();
|
|
let categories = if v.has_categories {
|
|
provider.settings_config.get("categories").cloned()
|
|
} else {
|
|
None
|
|
};
|
|
let other_fields = provider.settings_config.get("otherFields").cloned();
|
|
(agents, categories, other_fields)
|
|
}
|
|
|
|
fn snapshot_config_file(path: &Path) -> Result<Option<Vec<u8>>, AppError> {
|
|
if !path.exists() {
|
|
return Ok(None);
|
|
}
|
|
|
|
std::fs::read(path)
|
|
.map(Some)
|
|
.map_err(|e| AppError::io(path, e))
|
|
}
|
|
|
|
fn restore_config_file(path: &Path, snapshot: Option<&[u8]>) -> Result<(), AppError> {
|
|
match snapshot {
|
|
Some(bytes) => atomic_write(path, bytes),
|
|
None => {
|
|
if path.exists() {
|
|
std::fs::remove_file(path).map_err(|e| AppError::io(path, e))?;
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
}
|
|
|
|
fn write_profile_config(
|
|
v: &OmoVariant,
|
|
profile_data: Option<&OmoProfileData>,
|
|
) -> Result<(), AppError> {
|
|
let merged = Self::build_config(v, profile_data);
|
|
let config_path = Self::config_path(v);
|
|
|
|
if let Some(parent) = config_path.parent() {
|
|
std::fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
|
|
}
|
|
|
|
let previous_contents = Self::snapshot_config_file(&config_path)?;
|
|
write_json_file(&config_path, &merged)?;
|
|
if let Err(err) = crate::opencode_config::add_plugin(v.plugin_name) {
|
|
if let Err(rollback_err) =
|
|
Self::restore_config_file(&config_path, previous_contents.as_deref())
|
|
{
|
|
log::warn!(
|
|
"Failed to roll back {} config after plugin sync error: {}",
|
|
v.label,
|
|
rollback_err
|
|
);
|
|
}
|
|
return Err(err);
|
|
}
|
|
log::info!("{} config written to {config_path:?}", v.label);
|
|
Ok(())
|
|
}
|
|
|
|
// ── Public API (variant-parameterized) ─────────────────
|
|
|
|
pub fn delete_config_file(v: &OmoVariant) -> Result<(), AppError> {
|
|
let base_dir = get_opencode_dir();
|
|
let mut deleted_paths = Vec::new();
|
|
for config_path in Self::config_candidates(v, &base_dir) {
|
|
if config_path.exists() {
|
|
std::fs::remove_file(&config_path).map_err(|e| AppError::io(&config_path, e))?;
|
|
deleted_paths.push(config_path);
|
|
}
|
|
}
|
|
if !deleted_paths.is_empty() {
|
|
log::info!("{} config files deleted: {deleted_paths:?}", v.label);
|
|
}
|
|
crate::opencode_config::remove_plugins_by_prefixes(v.plugin_prefixes)?;
|
|
Ok(())
|
|
}
|
|
|
|
pub fn write_config_to_file(state: &AppState, v: &OmoVariant) -> Result<(), AppError> {
|
|
let current_omo = state.db.get_current_omo_provider("opencode", v.category)?;
|
|
let profile_data = current_omo
|
|
.as_ref()
|
|
.map(|provider| Self::profile_data_from_provider(provider, v));
|
|
Self::write_profile_config(v, profile_data.as_ref())
|
|
}
|
|
|
|
pub fn write_provider_config_to_file(
|
|
provider: &Provider,
|
|
v: &OmoVariant,
|
|
) -> Result<(), AppError> {
|
|
let profile_data = Self::profile_data_from_provider(provider, v);
|
|
Self::write_profile_config(v, Some(&profile_data))
|
|
}
|
|
|
|
fn build_config(v: &OmoVariant, profile_data: Option<&OmoProfileData>) -> Value {
|
|
let mut result = Map::new();
|
|
if let Some((agents, categories, other_fields)) = profile_data {
|
|
Self::insert_object_entries(&mut result, other_fields.as_ref());
|
|
Self::insert_opt_value(&mut result, "agents", agents);
|
|
if v.has_categories {
|
|
Self::insert_opt_value(&mut result, "categories", categories);
|
|
}
|
|
}
|
|
Value::Object(result)
|
|
}
|
|
|
|
pub fn import_from_local(
|
|
state: &AppState,
|
|
v: &OmoVariant,
|
|
) -> Result<crate::provider::Provider, AppError> {
|
|
let actual_path = Self::resolve_local_config_path(v)?;
|
|
let obj = Self::read_jsonc_object(&actual_path)?;
|
|
|
|
let mut settings = Map::new();
|
|
if let Some(agents) = obj.get("agents") {
|
|
settings.insert("agents".to_string(), agents.clone());
|
|
}
|
|
if v.has_categories {
|
|
if let Some(categories) = obj.get("categories") {
|
|
settings.insert("categories".to_string(), categories.clone());
|
|
}
|
|
}
|
|
|
|
let other = Self::extract_other_fields_with_keys(&obj, &["agents", "categories"]);
|
|
if !other.is_empty() {
|
|
settings.insert("otherFields".to_string(), Value::Object(other));
|
|
}
|
|
|
|
let provider_id = format!("{}{}", v.provider_prefix, uuid::Uuid::new_v4());
|
|
let name = format!(
|
|
"{} {}",
|
|
v.import_label,
|
|
chrono::Local::now().format("%Y-%m-%d %H:%M")
|
|
);
|
|
let settings_config =
|
|
serde_json::to_value(&settings).unwrap_or_else(|_| serde_json::json!({}));
|
|
|
|
let provider = crate::provider::Provider {
|
|
id: provider_id,
|
|
name,
|
|
settings_config,
|
|
website_url: None,
|
|
category: Some(v.category.to_string()),
|
|
created_at: Some(chrono::Utc::now().timestamp_millis()),
|
|
sort_index: None,
|
|
notes: None,
|
|
meta: None,
|
|
icon: None,
|
|
icon_color: None,
|
|
in_failover_queue: false,
|
|
};
|
|
|
|
state.db.create_provider(NewProviderAggregate::from_input(
|
|
"opencode",
|
|
crate::services::provider::provider_to_mutation_input(provider.clone()),
|
|
)?)?;
|
|
state
|
|
.db
|
|
.set_omo_provider_current("opencode", &provider.id, v.category)?;
|
|
Self::write_config_to_file(state, v)?;
|
|
Ok(provider)
|
|
}
|
|
|
|
pub fn read_local_file(v: &OmoVariant) -> Result<OmoLocalFileData, AppError> {
|
|
let actual_path = Self::resolve_local_config_path(v)?;
|
|
let metadata = std::fs::metadata(&actual_path).ok();
|
|
let last_modified = metadata
|
|
.and_then(|m| m.modified().ok())
|
|
.map(|t| chrono::DateTime::<chrono::Utc>::from(t).to_rfc3339());
|
|
|
|
let obj = Self::read_jsonc_object(&actual_path)?;
|
|
|
|
Ok(Self::build_local_file_data(
|
|
v,
|
|
&obj,
|
|
actual_path.to_string_lossy().to_string(),
|
|
last_modified,
|
|
))
|
|
}
|
|
|
|
fn build_local_file_data(
|
|
v: &OmoVariant,
|
|
obj: &Map<String, Value>,
|
|
file_path: String,
|
|
last_modified: Option<String>,
|
|
) -> OmoLocalFileData {
|
|
let agents = obj.get("agents").cloned();
|
|
let categories = if v.has_categories {
|
|
obj.get("categories").cloned()
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let other = Self::extract_other_fields_with_keys(obj, &["agents", "categories"]);
|
|
let other_fields = if other.is_empty() {
|
|
None
|
|
} else {
|
|
Some(Value::Object(other))
|
|
};
|
|
|
|
OmoLocalFileData {
|
|
agents,
|
|
categories,
|
|
other_fields,
|
|
file_path,
|
|
last_modified,
|
|
}
|
|
}
|
|
|
|
fn strip_jsonc_comments(input: &str) -> String {
|
|
let mut result = String::with_capacity(input.len());
|
|
let mut chars = input.chars().peekable();
|
|
let mut in_string = false;
|
|
let mut escape = false;
|
|
|
|
while let Some(&c) = chars.peek() {
|
|
if in_string {
|
|
result.push(c);
|
|
chars.next();
|
|
if escape {
|
|
escape = false;
|
|
} else if c == '\\' {
|
|
escape = true;
|
|
} else if c == '"' {
|
|
in_string = false;
|
|
}
|
|
} else if c == '"' {
|
|
in_string = true;
|
|
result.push(c);
|
|
chars.next();
|
|
} else if c == '/' {
|
|
chars.next();
|
|
match chars.peek() {
|
|
Some('/') => {
|
|
chars.next();
|
|
while let Some(&nc) = chars.peek() {
|
|
if nc == '\n' {
|
|
break;
|
|
}
|
|
chars.next();
|
|
}
|
|
}
|
|
Some('*') => {
|
|
chars.next();
|
|
while let Some(nc) = chars.next() {
|
|
if nc == '*' {
|
|
if let Some(&'/') = chars.peek() {
|
|
chars.next();
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
_ => {
|
|
result.push('/');
|
|
}
|
|
}
|
|
} else {
|
|
result.push(c);
|
|
chars.next();
|
|
}
|
|
}
|
|
result
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_strip_jsonc_comments() {
|
|
let input = r#"{
|
|
// This is a comment
|
|
"key": "value", // inline comment
|
|
/* multi
|
|
line */
|
|
"key2": "val//ue"
|
|
}"#;
|
|
let result = OmoService::strip_jsonc_comments(input);
|
|
let parsed: Value = serde_json::from_str(&result).unwrap();
|
|
assert_eq!(parsed["key"], "value");
|
|
assert_eq!(parsed["key2"], "val//ue");
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_config_empty() {
|
|
let merged = OmoService::build_config(&STANDARD, None);
|
|
assert!(merged.is_object());
|
|
assert!(merged.as_object().unwrap().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_config_with_profile() {
|
|
let agents = Some(serde_json::json!({
|
|
"sisyphus": { "model": "claude-opus-4-5" }
|
|
}));
|
|
let categories = None;
|
|
let other_fields = Some(serde_json::json!({
|
|
"$schema": "https://example.com/schema.json",
|
|
"disabled_agents": ["explore"]
|
|
}));
|
|
let profile_data = (agents, categories, other_fields);
|
|
let merged = OmoService::build_config(&STANDARD, Some(&profile_data));
|
|
let obj = merged.as_object().unwrap();
|
|
|
|
assert_eq!(obj["$schema"], "https://example.com/schema.json");
|
|
assert_eq!(obj["disabled_agents"], serde_json::json!(["explore"]));
|
|
assert!(obj.contains_key("agents"));
|
|
assert_eq!(obj["agents"]["sisyphus"]["model"], "claude-opus-4-5");
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_local_file_data_keeps_all_non_agent_category_fields_in_other() {
|
|
let obj = serde_json::json!({
|
|
"$schema": "https://example.com/schema.json",
|
|
"disabled_agents": ["oracle"],
|
|
"agents": {
|
|
"sisyphus": { "model": "claude-opus-4-6" }
|
|
},
|
|
"categories": {
|
|
"code": { "model": "gpt-5.3" }
|
|
},
|
|
"custom_top_level": {
|
|
"enabled": true
|
|
}
|
|
});
|
|
let obj_map = obj.as_object().unwrap().clone();
|
|
|
|
let data = OmoService::build_local_file_data(
|
|
&STANDARD,
|
|
&obj_map,
|
|
"/tmp/oh-my-opencode.jsonc".to_string(),
|
|
None,
|
|
);
|
|
|
|
// All non-agents/categories fields should be in other_fields
|
|
let other = data.other_fields.unwrap();
|
|
let other_obj = other.as_object().unwrap();
|
|
assert_eq!(
|
|
other_obj.get("$schema").unwrap(),
|
|
"https://example.com/schema.json"
|
|
);
|
|
assert_eq!(
|
|
other_obj.get("disabled_agents").unwrap(),
|
|
&serde_json::json!(["oracle"])
|
|
);
|
|
assert_eq!(
|
|
other_obj.get("custom_top_level").unwrap(),
|
|
&serde_json::json!({"enabled": true})
|
|
);
|
|
// agents and categories should NOT be in other_fields
|
|
assert!(!other_obj.contains_key("agents"));
|
|
assert!(!other_obj.contains_key("categories"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_config_ignores_non_object_other_fields() {
|
|
let agents = None;
|
|
let categories = None;
|
|
let other_fields = Some(serde_json::json!("profile_non_object"));
|
|
let profile_data = (agents, categories, other_fields);
|
|
|
|
let merged = OmoService::build_config(&STANDARD, Some(&profile_data));
|
|
let obj = merged.as_object().unwrap();
|
|
|
|
assert!(!obj.contains_key("profile_non_object"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_config_slim_excludes_categories() {
|
|
let agents = Some(serde_json::json!({"orchestrator": {"model": "k2"}}));
|
|
let categories = Some(serde_json::json!({"code": {"model": "gpt"}}));
|
|
let other_fields = Some(serde_json::json!({
|
|
"$schema": "https://slim.schema",
|
|
"disabled_agents": ["oracle"]
|
|
}));
|
|
let profile_data = (agents, categories, other_fields);
|
|
|
|
let merged = OmoService::build_config(&SLIM, Some(&profile_data));
|
|
let obj = merged.as_object().unwrap();
|
|
|
|
// Slim should NOT include categories
|
|
assert!(!obj.contains_key("categories"));
|
|
|
|
// Slim SHOULD include these
|
|
assert_eq!(obj["$schema"], "https://slim.schema");
|
|
assert!(obj.contains_key("agents"));
|
|
assert!(obj.contains_key("disabled_agents"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_find_existing_config_prefers_new_name_over_old() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let old_path = dir.path().join("oh-my-opencode.jsonc");
|
|
let new_path = dir.path().join("oh-my-openagent.jsonc");
|
|
|
|
// Create both old and new files
|
|
std::fs::write(&old_path, r#"{"agents":{}}"#).unwrap();
|
|
std::fs::write(&new_path, r#"{"agents":{}}"#).unwrap();
|
|
|
|
let found = OmoService::find_existing_config_path(&STANDARD, dir.path());
|
|
assert_eq!(
|
|
found.unwrap(),
|
|
new_path,
|
|
"When both old and new config files exist, the new name (oh-my-openagent) must be preferred"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_find_existing_config_falls_back_to_old_name() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let old_path = dir.path().join("oh-my-opencode.jsonc");
|
|
|
|
// Only old file exists
|
|
std::fs::write(&old_path, r#"{"agents":{}}"#).unwrap();
|
|
|
|
let found = OmoService::find_existing_config_path(&STANDARD, dir.path());
|
|
assert_eq!(
|
|
found.unwrap(),
|
|
old_path,
|
|
"When only the old config file exists, it should still be found"
|
|
);
|
|
}
|
|
}
|