1use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9
10#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
15#[serde(rename_all = "camelCase")]
16pub struct OpenCodeConfig {
17 #[serde(rename = "$schema", skip_serializing_if = "Option::is_none")]
19 pub schema: Option<String>,
20
21 #[serde(skip_serializing_if = "Option::is_none")]
23 pub log_level: Option<LogLevel>,
24
25 #[serde(skip_serializing_if = "Option::is_none")]
27 pub server: Option<ServerConfig>,
28
29 #[serde(skip_serializing_if = "Option::is_none")]
31 pub command: Option<HashMap<String, CommandConfig>>,
32
33 #[serde(skip_serializing_if = "Option::is_none")]
35 pub skills: Option<SkillsConfig>,
36
37 #[serde(skip_serializing_if = "Option::is_none")]
39 pub watcher: Option<WatcherConfig>,
40
41 #[serde(skip_serializing_if = "Option::is_none")]
43 pub snapshot: Option<bool>,
44
45 #[serde(skip_serializing_if = "Option::is_none")]
47 pub plugin: Option<Vec<PluginEntry>>,
48
49 #[serde(skip_serializing_if = "Option::is_none")]
51 pub share: Option<ShareMode>,
52
53 #[serde(skip_serializing_if = "Option::is_none")]
55 pub autoupdate: Option<AutoupdateConfig>,
56
57 #[serde(skip_serializing_if = "Option::is_none")]
59 pub disabled_providers: Option<Vec<String>>,
60
61 #[serde(skip_serializing_if = "Option::is_none")]
63 pub enabled_providers: Option<Vec<String>>,
64
65 #[serde(skip_serializing_if = "Option::is_none")]
67 pub model: Option<String>,
68
69 #[serde(skip_serializing_if = "Option::is_none")]
71 pub small_model: Option<String>,
72
73 #[serde(skip_serializing_if = "Option::is_none")]
75 pub default_agent: Option<String>,
76
77 #[serde(skip_serializing_if = "Option::is_none")]
79 pub username: Option<String>,
80
81 #[serde(skip_serializing_if = "Option::is_none")]
83 pub agent: Option<HashMap<String, AgentConfig>>,
84
85 #[serde(skip_serializing_if = "Option::is_none")]
87 pub provider: Option<HashMap<String, ProviderConfig>>,
88
89 #[serde(skip_serializing_if = "Option::is_none")]
91 pub mcp: Option<HashMap<String, McpConfig>>,
92
93 #[serde(skip_serializing_if = "Option::is_none")]
95 pub permission: Option<PermissionConfig>,
96
97 #[serde(skip_serializing_if = "Option::is_none")]
99 pub formatter: Option<HashMap<String, FormatterConfig>>,
100
101 #[serde(skip_serializing_if = "Option::is_none")]
103 pub instructions: Option<Vec<String>>,
104
105 #[serde(skip_serializing_if = "Option::is_none")]
107 pub compaction: Option<CompactionConfig>,
108
109 #[serde(skip_serializing_if = "Option::is_none")]
111 pub experimental: Option<serde_json::Value>,
112
113 #[serde(skip_serializing_if = "Option::is_none")]
115 pub tools: Option<HashMap<String, bool>>,
116
117 #[serde(flatten)]
121 pub extra: HashMap<String, serde_json::Value>,
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
126#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
127pub enum LogLevel {
128 Debug,
129 Info,
130 Warn,
131 Error,
132}
133
134#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
136#[serde(rename_all = "camelCase")]
137pub struct ServerConfig {
138 #[serde(skip_serializing_if = "Option::is_none")]
139 pub port: Option<u16>,
140 #[serde(skip_serializing_if = "Option::is_none")]
141 pub hostname: Option<String>,
142 #[serde(skip_serializing_if = "Option::is_none")]
143 pub mdns: Option<bool>,
144 #[serde(skip_serializing_if = "Option::is_none")]
145 pub mdns_domain: Option<String>,
146 #[serde(skip_serializing_if = "Option::is_none")]
147 pub cors: Option<Vec<String>>,
148}
149
150#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
152#[serde(rename_all = "camelCase")]
153pub struct CommandConfig {
154 pub template: String,
155 #[serde(skip_serializing_if = "Option::is_none")]
156 pub description: Option<String>,
157 #[serde(skip_serializing_if = "Option::is_none")]
158 pub agent: Option<String>,
159 #[serde(skip_serializing_if = "Option::is_none")]
160 pub model: Option<String>,
161 #[serde(skip_serializing_if = "Option::is_none")]
162 pub subtask: Option<bool>,
163}
164
165#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
167#[serde(rename_all = "camelCase")]
168pub struct SkillsConfig {
169 #[serde(skip_serializing_if = "Option::is_none")]
170 pub paths: Option<Vec<String>>,
171 #[serde(skip_serializing_if = "Option::is_none")]
172 pub urls: Option<Vec<String>>,
173}
174
175#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
177pub struct WatcherConfig {
178 #[serde(skip_serializing_if = "Option::is_none")]
179 pub ignore: Option<Vec<String>>,
180}
181
182#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
187#[serde(rename_all = "camelCase")]
188pub struct ProviderConfig {
189 #[serde(skip_serializing_if = "Option::is_none")]
191 pub npm: Option<String>,
192
193 #[serde(skip_serializing_if = "Option::is_none")]
195 pub name: Option<String>,
196
197 #[serde(skip_serializing_if = "Option::is_none")]
199 pub options: Option<HashMap<String, serde_json::Value>>,
200
201 #[serde(skip_serializing_if = "Option::is_none")]
203 pub models: Option<HashMap<String, ModelConfig>>,
204
205 #[serde(skip_serializing_if = "Option::is_none")]
207 pub disabled: Option<bool>,
208
209 #[serde(flatten)]
211 pub extra: HashMap<String, serde_json::Value>,
212}
213
214#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
216#[serde(rename_all = "camelCase")]
217pub struct ModelConfig {
218 #[serde(skip_serializing_if = "Option::is_none")]
220 pub name: Option<String>,
221
222 #[serde(skip_serializing_if = "Option::is_none")]
224 pub id: Option<String>,
225
226 #[serde(skip_serializing_if = "Option::is_none")]
228 pub options: Option<HashMap<String, serde_json::Value>>,
229
230 #[serde(skip_serializing_if = "Option::is_none")]
232 pub variants: Option<HashMap<String, VariantConfig>>,
233
234 #[serde(skip_serializing_if = "Option::is_none")]
236 pub limit: Option<ModelLimit>,
237
238 #[serde(skip_serializing_if = "Option::is_none")]
240 pub disabled: Option<bool>,
241
242 #[serde(flatten)]
244 pub extra: HashMap<String, serde_json::Value>,
245}
246
247#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
249pub struct ModelLimit {
250 #[serde(skip_serializing_if = "Option::is_none")]
251 pub context: Option<u64>,
252 #[serde(skip_serializing_if = "Option::is_none")]
253 pub output: Option<u64>,
254}
255
256#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
258pub struct VariantConfig {
259 #[serde(flatten)]
260 pub options: HashMap<String, serde_json::Value>,
261
262 #[serde(skip_serializing_if = "Option::is_none")]
263 pub disabled: Option<bool>,
264}
265
266#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
268#[serde(rename_all = "camelCase")]
269pub struct AgentConfig {
270 #[serde(skip_serializing_if = "Option::is_none")]
271 pub model: Option<String>,
272 #[serde(skip_serializing_if = "Option::is_none")]
273 pub variant: Option<String>,
274 #[serde(skip_serializing_if = "Option::is_none")]
275 pub temperature: Option<f64>,
276 #[serde(skip_serializing_if = "Option::is_none")]
277 pub top_p: Option<f64>,
278 #[serde(skip_serializing_if = "Option::is_none")]
279 pub prompt: Option<String>,
280 #[serde(skip_serializing_if = "Option::is_none")]
281 pub description: Option<String>,
282 #[serde(skip_serializing_if = "Option::is_none")]
283 pub disable: Option<bool>,
284 #[serde(skip_serializing_if = "Option::is_none")]
285 pub mode: Option<AgentMode>,
286 #[serde(skip_serializing_if = "Option::is_none")]
287 pub hidden: Option<bool>,
288 #[serde(skip_serializing_if = "Option::is_none")]
289 pub steps: Option<u64>,
290 #[serde(skip_serializing_if = "Option::is_none")]
291 pub color: Option<String>,
292 #[serde(skip_serializing_if = "Option::is_none")]
293 pub options: Option<HashMap<String, serde_json::Value>>,
294 #[serde(skip_serializing_if = "Option::is_none")]
295 pub permission: Option<PermissionConfig>,
296
297 #[serde(skip_serializing_if = "Option::is_none")]
299 pub tools: Option<HashMap<String, bool>>,
300}
301
302#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
304#[serde(rename_all = "lowercase")]
305pub enum AgentMode {
306 Subagent,
307 Primary,
308 All,
309}
310
311#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
313#[serde(rename_all = "camelCase")]
314pub struct McpConfig {
315 #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
316 pub mcp_type: Option<String>,
317 #[serde(skip_serializing_if = "Option::is_none")]
318 pub command: Option<String>,
319 #[serde(skip_serializing_if = "Option::is_none")]
320 pub args: Option<Vec<String>>,
321 #[serde(skip_serializing_if = "Option::is_none")]
322 pub url: Option<String>,
323 #[serde(skip_serializing_if = "Option::is_none")]
324 pub env: Option<HashMap<String, String>>,
325 #[serde(skip_serializing_if = "Option::is_none")]
326 pub enabled: Option<bool>,
327}
328
329#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
334#[serde(untagged)]
335pub enum PermissionConfig {
336 Simple(PermissionAction),
338 Detailed(HashMap<String, PermissionRule>),
340}
341
342#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
344#[serde(rename_all = "lowercase")]
345pub enum PermissionAction {
346 Ask,
347 Allow,
348 Deny,
349}
350
351#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
353#[serde(untagged)]
354pub enum PermissionRule {
355 Simple(PermissionAction),
357 Detailed(HashMap<String, PermissionAction>),
359}
360
361#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
363#[serde(rename_all = "camelCase")]
364pub struct FormatterConfig {
365 #[serde(skip_serializing_if = "Option::is_none")]
366 pub disabled: Option<bool>,
367 #[serde(skip_serializing_if = "Option::is_none")]
368 pub command: Option<Vec<String>>,
369 #[serde(skip_serializing_if = "Option::is_none")]
370 pub environment: Option<HashMap<String, String>>,
371 #[serde(skip_serializing_if = "Option::is_none")]
372 pub extensions: Option<Vec<String>>,
373}
374
375#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
377#[serde(rename_all = "camelCase")]
378pub struct CompactionConfig {
379 #[serde(skip_serializing_if = "Option::is_none")]
380 pub auto: Option<bool>,
381 #[serde(skip_serializing_if = "Option::is_none")]
382 pub prune: Option<bool>,
383 #[serde(skip_serializing_if = "Option::is_none")]
384 pub reserved: Option<u64>,
385}
386
387#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
389#[serde(rename_all = "lowercase")]
390pub enum ShareMode {
391 Manual,
392 Auto,
393 Disabled,
394}
395
396#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
398#[serde(untagged)]
399pub enum AutoupdateConfig {
400 Bool(bool),
401 Notify(String),
402}
403
404#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
406#[serde(untagged)]
407pub enum PluginEntry {
408 Name(String),
409 WithOptions(Vec<serde_json::Value>),
410}
411
412#[cfg(test)]
413mod tests {
414 use super::*;
415
416 #[test]
417 fn test_provider_config_deserialize() {
418 let json = r#"{
419 "npm": "@ai-sdk/openai-compatible",
420 "name": "My Custom Provider",
421 "options": {
422 "baseURL": "http://127.0.0.1:1234/v1"
423 },
424 "models": {
425 "gpt-4o": {
426 "name": "GPT-4o"
427 }
428 }
429 }"#;
430
431 let config: ProviderConfig = serde_json::from_str(json).unwrap();
432 assert_eq!(config.npm.as_deref(), Some("@ai-sdk/openai-compatible"));
433 assert_eq!(config.name.as_deref(), Some("My Custom Provider"));
434 assert!(config.models.is_some());
435 assert!(config.options.is_some());
436 }
437
438 #[test]
439 fn test_opencode_config_deserialize_minimal() {
440 let json = r#"{
441 "$schema": "https://opencode.ai/config.json",
442 "model": "anthropic/claude-sonnet-4-5"
443 }"#;
444
445 let config: OpenCodeConfig = serde_json::from_str(json).unwrap();
446 assert_eq!(
447 config.schema.as_deref(),
448 Some("https://opencode.ai/config.json")
449 );
450 assert_eq!(config.model.as_deref(), Some("anthropic/claude-sonnet-4-5"));
451 }
452
453 #[test]
454 fn test_opencode_config_serialize_roundtrip() {
455 let json = r#"{
456 "$schema": "https://opencode.ai/config.json",
457 "provider": {
458 "anthropic": {
459 "options": {
460 "apiKey": "{env:ANTHROPIC_API_KEY}"
461 }
462 }
463 },
464 "model": "anthropic/claude-sonnet-4-5",
465 "smallModel": "anthropic/claude-haiku-4-5",
466 "autoupdate": true
467 }"#;
468
469 let config: OpenCodeConfig = serde_json::from_str(json).unwrap();
470 let serialized = serde_json::to_string_pretty(&config).unwrap();
471 let deserialized: OpenCodeConfig = serde_json::from_str(&serialized).unwrap();
472 assert_eq!(config, deserialized);
473 }
474
475 #[test]
476 fn test_log_level_deserialize() {
477 let json = r#""WARN""#;
478 let level: LogLevel = serde_json::from_str(json).unwrap();
479 assert_eq!(level, LogLevel::Warn);
480 }
481
482 #[test]
483 fn test_share_mode_deserialize() {
484 assert_eq!(
485 serde_json::from_str::<ShareMode>(r#""manual""#).unwrap(),
486 ShareMode::Manual
487 );
488 assert_eq!(
489 serde_json::from_str::<ShareMode>(r#""disabled""#).unwrap(),
490 ShareMode::Disabled
491 );
492 }
493
494 #[test]
495 fn test_plugin_entry_variants() {
496 let name: PluginEntry = serde_json::from_str(r#""my-plugin""#).unwrap();
497 assert!(matches!(name, PluginEntry::Name(_)));
498
499 let with_opts: PluginEntry =
500 serde_json::from_str(r#"["my-plugin", {"key": "value"}]"#).unwrap();
501 assert!(matches!(with_opts, PluginEntry::WithOptions(_)));
502 }
503
504 #[test]
505 fn test_autoupdate_config_variants() {
506 let bool_val: AutoupdateConfig = serde_json::from_str(r#"true"#).unwrap();
507 assert!(matches!(bool_val, AutoupdateConfig::Bool(true)));
508
509 let notify_val: AutoupdateConfig = serde_json::from_str(r#""notify""#).unwrap();
510 assert!(matches!(notify_val, AutoupdateConfig::Notify(_)));
511 }
512
513 #[test]
514 fn test_unknown_fields_preserved_in_extra() {
515 let json = r#"{
518 "$schema": "https://opencode.ai/config.json",
519 "model": "anthropic/claude-sonnet-4-5",
520 "provider": {
521 "openai": {
522 "npm": "openai"
523 }
524 },
525 "theme": "dark",
526 "customFeature": { "enabled": true, "level": 42 }
527 }"#;
528
529 let config: OpenCodeConfig = serde_json::from_str(json).unwrap();
530 assert_eq!(config.model.as_deref(), Some("anthropic/claude-sonnet-4-5"));
532 assert!(config.provider.is_some());
533 assert_eq!(
535 config.extra.get("theme").and_then(|v| v.as_str()),
536 Some("dark")
537 );
538 assert_eq!(
539 config
540 .extra
541 .get("customFeature")
542 .and_then(|v| v.get("enabled"))
543 .and_then(|v| v.as_bool()),
544 Some(true)
545 );
546
547 let serialized = serde_json::to_string_pretty(&config).unwrap();
549 let deserialized: OpenCodeConfig = serde_json::from_str(&serialized).unwrap();
550 assert_eq!(
551 deserialized.extra.get("theme").and_then(|v| v.as_str()),
552 Some("dark")
553 );
554 assert_eq!(
555 deserialized
556 .extra
557 .get("customFeature")
558 .and_then(|v| v.get("level"))
559 .and_then(|v| v.as_i64()),
560 Some(42)
561 );
562 }
563}