1use 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#[skip_serializing_none]
35#[derive(Clone, Debug, Deserialize, Serialize, Validate)]
36#[serde(rename_all = "camelCase")]
37pub struct Config {
38 #[serde(skip_serializing)]
40 #[validate(length(min = 1))]
41 pub path: Option<String>,
42 #[serde(default = "default_base_url")]
44 #[validate(length(min = 1))]
45 pub base_url: String,
46 #[serde(default = "default_provider_id")]
48 #[validate(length(min = 1))]
49 pub provider_id: String,
50 #[serde(default = "default_provider_name")]
52 #[validate(length(min = 1))]
53 pub provider_name: String,
54 #[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 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 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 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 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}