Skip to main content

acorn/io/sync/
opencode.rs

1//! OpenCode synchronization configuration types
2//!
3//! When syncing ACORN models to OpenCode, the sync command creates or updates
4//! a custom provider entry that points to the local llama-swap instance.
5//! Each model is registered as a keyed entry in the provider's model map.
6use super::{Options, RenderedOutput, SyncTarget};
7use crate::io::{home_directory, read_file, ApiResult, CstValue, PathConversion};
8use crate::prelude::{current_dir, Path, PathBuf};
9use crate::schema::agent::opencode;
10use crate::util::constants::app::DEFAULT_OPENCODE_CONFIG_PATH;
11use alloc::collections::{BTreeMap, BTreeSet};
12use alloc::string::String;
13use alloc::string::ToString;
14use color_eyre::eyre::eyre;
15use core::{fmt, iter::once};
16use serde::{Deserialize, Serialize};
17use serde_json::Value;
18use serde_with::skip_serializing_none;
19use validator::Validate;
20
21#[derive(Clone, Debug, Serialize)]
22#[serde(untagged)]
23enum Entry {
24    Provider {
25        npm: String,
26        name: String,
27        options: BTreeMap<String, String>,
28    },
29    Model {
30        name: String,
31    },
32}
33/// Configuration for synchronizing models into OpenCode
34#[skip_serializing_none]
35#[derive(Clone, Debug, Deserialize, Serialize, Validate)]
36#[serde(rename_all = "camelCase")]
37pub struct Config {
38    /// Path to the OpenCode configuration file on disk
39    #[serde(skip_serializing)]
40    #[validate(length(min = 1))]
41    pub path: Option<String>,
42    /// Base URL for the OpenAI-compatible endpoint (e.g., `http://localhost:8080/v1`)
43    #[serde(default = "default_base_url")]
44    #[validate(length(min = 1))]
45    pub base_url: String,
46    /// Provider identifier in the OpenCode configuration
47    #[serde(default = "default_provider_id")]
48    #[validate(length(min = 1))]
49    pub provider_id: String,
50    /// Human-readable provider display name
51    #[serde(default = "default_provider_name")]
52    #[validate(length(min = 1))]
53    pub provider_name: String,
54    /// Default model to use when none is specified
55    #[validate(length(min = 1))]
56    pub default_model: Option<String>,
57}
58impl Default for Config {
59    fn default() -> Self {
60        Self {
61            path: None,
62            base_url: default_base_url(),
63            provider_id: default_provider_id(),
64            provider_name: default_provider_name(),
65            default_model: None,
66        }
67    }
68}
69impl fmt::Display for Config {
70    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
71        self.provider_id.fmt(formatter)
72    }
73}
74impl SyncTarget for Config {
75    const COMMAND: &'static str = "opencode";
76    fn merge(self, overrides: Self) -> Self {
77        Self {
78            path: overrides.path.or(self.path),
79            base_url: overrides.base_url,
80            provider_id: overrides.provider_id,
81            provider_name: overrides.provider_name,
82            default_model: overrides.default_model.or(self.default_model),
83        }
84    }
85    fn merge_cli_overrides(self, overrides: Self) -> Self {
86        Self {
87            path: overrides.path.or(self.path),
88            ..self
89        }
90    }
91    fn resolve_path(explicit: Option<&str>) -> ApiResult<PathBuf> {
92        match explicit {
93            | Some(path) => Ok(PathBuf::from(Path::new(path).cross_platform_display())),
94            | None => current_dir()
95                .map_err(|why| eyre!("Failed to get ACORN calling directory — {why}"))
96                .and_then(|directory| home_directory(DEFAULT_OPENCODE_CONFIG_PATH).map(|user_config| Self::discover_path(&directory, user_config))),
97        }
98    }
99    fn render(&self, options: Options<'_>) -> ApiResult<RenderedOutput> {
100        Self::resolve_path(self.path.as_deref()).and_then(|path| {
101            let models = options
102                .models
103                .iter()
104                .filter_map(|model| model.name.as_ref().or(model.id.as_ref()))
105                .map(|name| (name.clone(), name.clone()))
106                .collect::<Vec<_>>();
107            path.is_file()
108                .then(|| read_file(path.clone()))
109                .transpose()
110                .map(|content| content.unwrap_or_default())
111                .and_then(|before| {
112                    match path.is_file() {
113                        | true => opencode::Config::from_path(&path),
114                        | false => Ok(opencode::Config::default()),
115                    }
116                    .and_then(|existing| self.upsert(&existing, &models, options.prune))
117                    .and_then(|opencode| Config::render(self, &opencode))
118                    .map(|content| RenderedOutput {
119                        target: "OpenCode",
120                        path,
121                        before,
122                        content,
123                    })
124                })
125        })
126    }
127}
128impl Config {
129    /// Build the JSON structure for a model entry.
130    pub fn model_entry(&self, display_name: &str) -> Value {
131        serde_json::to_value(Entry::Model {
132            name: display_name.to_string(),
133        })
134        .unwrap_or(Value::Null)
135    }
136    /// Build the JSON structure for an OpenCode provider entry.
137    pub fn provider_entry(&self) -> Value {
138        serde_json::to_value(Entry::Provider {
139            npm: "@ai-sdk/openai-compatible".to_string(),
140            name: self.provider_name.clone(),
141            options: once(("baseURL".to_string(), self.base_url.clone())).collect(),
142        })
143        .unwrap_or(Value::Null)
144    }
145    fn discover_path(calling_directory: &Path, user_config: PathBuf) -> PathBuf {
146        [
147            calling_directory.join("opencode.jsonc"),
148            calling_directory.join("opencode.json"),
149            user_config.clone(),
150            user_config.with_extension("json"),
151        ]
152        .into_iter()
153        .find(|path| path.is_file())
154        .unwrap_or(user_config)
155    }
156    /// Render an updated OpenCode configuration while preserving JSONC comments outside the managed provider
157    pub fn render(&self, config: &opencode::Config) -> ApiResult<String> {
158        match &config.cst {
159            | Some(cst) => config
160                .provider
161                .as_ref()
162                .and_then(|providers| providers.get(&self.provider_id))
163                .ok_or_else(|| eyre!("Managed OpenCode provider '{self}' is missing"))
164                .map(|provider| {
165                    let root = cst.object_value_or_set();
166                    let providers = root.object_value_or_set("provider");
167                    match providers.get(&self.provider_id) {
168                        | Some(property) => property.set_value(CstValue(provider).into()),
169                        | None => {
170                            providers.append(&self.provider_id, CstValue(provider).into());
171                        }
172                    }
173                    if let Some(model) = config.model.as_ref() {
174                        match root.get("model") {
175                            | Some(property) => property.set_value(model.clone().into()),
176                            | None => {
177                                root.append("model", model.clone().into());
178                            }
179                        }
180                    }
181                    cst.to_string()
182                })
183                .and_then(|content| {
184                    opencode::Config::parse_jsonc(&content)
185                        .map(|_| content)
186                        .map_err(|why| eyre!("Generated OpenCode JSONC is invalid — {why}"))
187                }),
188            | None => serde_json::to_string_pretty(config)
189                .map_err(|why| eyre!("Failed to serialize OpenCode config — {why}"))
190                .and_then(|content| {
191                    serde_json::from_str::<opencode::Config>(&content)
192                        .map(|_| content)
193                        .map_err(|why| eyre!("Generated OpenCode JSON is invalid — {why}"))
194                }),
195        }
196    }
197    /// Upsert the managed provider into an existing OpenCode `Config`
198    ///
199    /// Returns the modified config with the llama-swap provider and its models
200    /// upserted, preserving all other existing configuration.
201    pub fn upsert(&self, existing: &opencode::Config, model_ids: &[(String, String)], prune: bool) -> ApiResult<opencode::Config> {
202        let current_ids = model_ids.iter().map(|(identifier, _)| identifier.as_str()).collect::<BTreeSet<_>>();
203        let existing_provider = existing
204            .provider
205            .as_ref()
206            .and_then(|providers| providers.get(&self.provider_id))
207            .and_then(Value::as_object)
208            .cloned()
209            .unwrap_or_default();
210        let existing_models = existing_provider.get("models").and_then(Value::as_object).cloned().unwrap_or_default();
211        let models = existing_models
212            .into_iter()
213            .filter(|(identifier, _)| !prune || current_ids.contains(identifier.as_str()))
214            .chain(model_ids.iter().map(|(identifier, name)| (identifier.clone(), self.model_entry(name))))
215            .collect::<serde_json::Map<_, _>>();
216        let options = existing_provider
217            .get("options")
218            .and_then(Value::as_object)
219            .into_iter()
220            .flat_map(|options| options.iter())
221            .map(|(key, value)| (key.clone(), value.clone()))
222            .chain(once(("baseURL".to_string(), Value::String(self.base_url.clone()))))
223            .collect::<serde_json::Map<_, _>>();
224        let provider = existing_provider
225            .into_iter()
226            .chain([
227                ("npm".to_string(), Value::String("@ai-sdk/openai-compatible".to_string())),
228                ("name".to_string(), Value::String(self.provider_name.clone())),
229                ("options".to_string(), Value::Object(options)),
230                ("models".to_string(), Value::Object(models)),
231            ])
232            .collect::<serde_json::Map<_, _>>();
233        let providers = existing
234            .provider
235            .as_ref()
236            .into_iter()
237            .flat_map(|providers| providers.iter())
238            .map(|(identifier, value)| (identifier.clone(), value.clone()))
239            .chain(once((self.provider_id.clone(), Value::Object(provider))))
240            .collect::<BTreeMap<_, _>>();
241        let root_model = existing
242            .model
243            .as_ref()
244            .and_then(|model| model.split_once('/'))
245            .filter(|(provider, _)| *provider == self.provider_id);
246        match (prune, root_model, self.default_model.as_ref()) {
247            | (true, Some((_, model)), None) if !current_ids.contains(model) => Err(eyre!(
248                "Root model '{self}/{model}' points to a removed entry without a configured defaultModel"
249            )),
250            | _ => Ok(opencode::Config {
251                model: self
252                    .default_model
253                    .as_ref()
254                    .map(|model| format!("{}/{model}", self.provider_id))
255                    .or_else(|| existing.model.clone()),
256                provider: Some(providers),
257                ..existing.clone()
258            }),
259        }
260    }
261}
262fn default_base_url() -> String {
263    "http://localhost:8080/v1".to_string()
264}
265fn default_provider_id() -> String {
266    "llama-swap".to_string()
267}
268fn default_provider_name() -> String {
269    "Local (llama-swap)".to_string()
270}
271#[cfg(test)]
272mod tests {
273    #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
274    use super::*;
275    use crate::prelude::{create_dir_all, remove_dir_all, write};
276    use crate::test::utils::temp_dir;
277
278    #[test]
279    fn test_default_values() {
280        let config = Config::default();
281        assert_eq!(config.base_url, "http://localhost:8080/v1");
282        assert_eq!(config.provider_id, "llama-swap");
283        assert_eq!(config.provider_name, "Local (llama-swap)");
284        assert_eq!(config.to_string(), "llama-swap");
285    }
286    #[test]
287    fn test_resolve_path_uses_discovered_or_default_config() {
288        let calling_directory = current_dir().unwrap();
289        let user_config = home_directory(DEFAULT_OPENCODE_CONFIG_PATH).unwrap();
290        let expected = [
291            calling_directory.join("opencode.jsonc"),
292            calling_directory.join("opencode.json"),
293            user_config.clone(),
294            user_config.with_extension("json"),
295        ]
296        .into_iter()
297        .find(|path| path.is_file())
298        .unwrap_or(user_config);
299        assert_eq!(Config::resolve_path(None).unwrap(), expected);
300    }
301    #[test]
302    fn test_resolve_path_prefers_explicit_path() {
303        let explicit = PathBuf::from(Path::new("custom/opencode.json").cross_platform_display());
304        assert_eq!(Config::resolve_path(Some("custom/opencode.json")).unwrap(), explicit);
305    }
306    #[test]
307    fn test_discover_path_prefers_calling_directory_jsonc_then_json() {
308        let dir = temp_dir("opencode-calling-directory");
309        let user_config = dir.join("user").join("opencode.jsonc");
310        create_dir_all(user_config.parent().unwrap()).unwrap();
311        write(&user_config, "{}").unwrap();
312        let project_json = dir.join("opencode.json");
313        write(&project_json, "{}").unwrap();
314        assert_eq!(Config::discover_path(&dir, user_config.clone()), project_json);
315        let project_jsonc = dir.join("opencode.jsonc");
316        write(&project_jsonc, "not valid jsonc").unwrap();
317        assert_eq!(Config::discover_path(&dir, user_config), project_jsonc);
318        let _ = remove_dir_all(dir);
319    }
320    #[test]
321    fn test_model_json_structure() {
322        let json = Config::default().model_entry("Qwen GGUF");
323        assert_eq!(json.get("name").unwrap().as_str().unwrap(), "Qwen GGUF");
324    }
325    #[test]
326    fn test_provider_json_structure() {
327        let config = Config::default();
328        let json = config.provider_entry();
329        assert_eq!(json.get("npm").unwrap().as_str().unwrap(), "@ai-sdk/openai-compatible");
330        assert_eq!(json.get("name").unwrap().as_str().unwrap(), "Local (llama-swap)");
331        let options = json.get("options").unwrap();
332        assert_eq!(options.get("baseURL").unwrap().as_str().unwrap(), "http://localhost:8080/v1");
333    }
334    #[test]
335    fn test_render_preserves_jsonc_comments_outside_managed_provider() {
336        let existing = opencode::Config::parse_jsonc(
337            r#"{
338  // root comment
339  "username": "acorn",
340  "provider": {
341    // unrelated provider comment
342    "other": {"options": {"baseURL": "https://example.test"}},
343    "llama-swap": {"models": {"stale": {"name": "Stale"}}}
344  }
345}"#,
346        )
347        .unwrap();
348        let config = Config::default();
349        let updated = config.upsert(&existing, &[("qwen".to_string(), "Qwen".to_string())], false).unwrap();
350        let rendered = config.render(&updated).unwrap();
351        assert!(rendered.contains("// root comment"));
352        assert!(rendered.contains("// unrelated provider comment"));
353        assert!(rendered.contains("\"stale\""));
354        assert!(rendered.contains("\"qwen\""));
355        assert_eq!(rendered, config.render(&updated).unwrap());
356    }
357    #[test]
358    fn test_upsert_is_additive_unless_pruned() {
359        let existing = opencode::Config {
360            username: Some("acorn".to_string()),
361            provider: Some(
362                [
363                    ("other".to_string(), serde_json::json!({"options": {"baseURL": "https://example.test"}})),
364                    (
365                        "llama-swap".to_string(),
366                        serde_json::json!({
367                            "custom": true,
368                            "options": {"apiKey": "secret"},
369                            "models": {"stale": {"name": "Stale"}}
370                        }),
371                    ),
372                ]
373                .into_iter()
374                .collect(),
375            ),
376            ..Default::default()
377        };
378        let config = Config::default();
379        let models = [("qwen".to_string(), "Qwen".to_string())];
380        let additive = config.upsert(&existing, &models, false).unwrap();
381        let provider = additive.provider.as_ref().unwrap().get("llama-swap").unwrap();
382        assert!(provider.pointer("/models/stale").is_some());
383        assert!(provider.pointer("/models/qwen").is_some());
384        assert_eq!(provider.get("custom"), Some(&Value::Bool(true)));
385        assert_eq!(provider.pointer("/options/apiKey").and_then(Value::as_str), Some("secret"));
386        assert!(additive.provider.as_ref().unwrap().contains_key("other"));
387        assert_eq!(additive.username.as_deref(), Some("acorn"));
388        let pruned = config.upsert(&existing, &models, true).unwrap();
389        let provider = pruned.provider.as_ref().unwrap().get("llama-swap").unwrap();
390        assert!(provider.pointer("/models/stale").is_none());
391        assert!(provider.pointer("/models/qwen").is_some());
392    }
393    #[test]
394    fn test_upsert_protects_pruned_root_model() {
395        let existing = opencode::Config {
396            model: Some("llama-swap/stale".to_string()),
397            provider: Some(
398                [("llama-swap".to_string(), serde_json::json!({"models": {"stale": {"name": "Stale"}}}))]
399                    .into_iter()
400                    .collect(),
401            ),
402            ..Default::default()
403        };
404        let models = [("qwen".to_string(), "Qwen".to_string())];
405        assert!(Config::default().upsert(&existing, &models, true).is_err());
406        let replacement = Config {
407            default_model: Some("qwen".to_string()),
408            ..Default::default()
409        }
410        .upsert(&existing, &models, true)
411        .unwrap();
412        assert_eq!(replacement.model.as_deref(), Some("llama-swap/qwen"));
413    }
414}