use crate::config::{atomic_write, write_json_file}; 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, pub categories: Option, pub other_fields: Option, pub file_path: String, pub last_modified: Option, } type OmoProfileData = (Option, Option, Option); // ── 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 { v.config_candidates .iter() .map(|name| base_dir.join(name)) .collect() } fn find_existing_config_path(v: &OmoVariant, base_dir: &Path) -> Option { 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 { Self::find_existing_config_path(v, &get_opencode_dir()).ok_or(AppError::OmoConfigNotFound) } fn read_jsonc_object(path: &Path) -> Result, 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, known: &[&str], ) -> Map { 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, key: &str, value: &Option) { if let Some(v) = value { result.insert(key.to_string(), v.clone()); } } fn insert_object_entries(result: &mut Map, 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>, 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 { 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.save_provider("opencode", &provider)?; 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 { 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::::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, file_path: String, last_modified: Option, ) -> 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" ); } }