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")]
320 pub command: Option<CommandField>,
321 #[serde(skip_serializing_if = "Option::is_none")]
322 pub args: Option<Vec<String>>,
323 #[serde(skip_serializing_if = "Option::is_none")]
324 pub url: Option<String>,
325 #[serde(skip_serializing_if = "Option::is_none")]
326 pub env: Option<HashMap<String, String>>,
327 #[serde(skip_serializing_if = "Option::is_none")]
328 pub enabled: Option<bool>,
329}
330
331#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
337#[serde(untagged)]
338pub enum CommandField {
339 String(String),
340 Array(Vec<String>),
341}
342
343#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
348#[serde(untagged)]
349pub enum PermissionConfig {
350 Simple(PermissionAction),
352 Detailed(HashMap<String, PermissionRule>),
354}
355
356#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
358#[serde(rename_all = "lowercase")]
359pub enum PermissionAction {
360 Ask,
361 Allow,
362 Deny,
363}
364
365#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
367#[serde(untagged)]
368pub enum PermissionRule {
369 Simple(PermissionAction),
371 Detailed(HashMap<String, PermissionAction>),
373}
374
375#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
377#[serde(rename_all = "camelCase")]
378pub struct FormatterConfig {
379 #[serde(skip_serializing_if = "Option::is_none")]
380 pub disabled: Option<bool>,
381 #[serde(skip_serializing_if = "Option::is_none")]
382 pub command: Option<Vec<String>>,
383 #[serde(skip_serializing_if = "Option::is_none")]
384 pub environment: Option<HashMap<String, String>>,
385 #[serde(skip_serializing_if = "Option::is_none")]
386 pub extensions: Option<Vec<String>>,
387}
388
389#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
391#[serde(rename_all = "camelCase")]
392pub struct CompactionConfig {
393 #[serde(skip_serializing_if = "Option::is_none")]
394 pub auto: Option<bool>,
395 #[serde(skip_serializing_if = "Option::is_none")]
396 pub prune: Option<bool>,
397 #[serde(skip_serializing_if = "Option::is_none")]
398 pub reserved: Option<u64>,
399}
400
401#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
403#[serde(rename_all = "lowercase")]
404pub enum ShareMode {
405 Manual,
406 Auto,
407 Disabled,
408}
409
410#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
412#[serde(untagged)]
413pub enum AutoupdateConfig {
414 Bool(bool),
415 Notify(String),
416}
417
418#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
420#[serde(untagged)]
421pub enum PluginEntry {
422 Name(String),
423 WithOptions(Vec<serde_json::Value>),
424}
425
426#[cfg(test)]
427mod tests {
428 use super::*;
429
430 #[test]
431 fn test_provider_config_deserialize() {
432 let json = r#"{
433 "npm": "@ai-sdk/openai-compatible",
434 "name": "My Custom Provider",
435 "options": {
436 "baseURL": "http://127.0.0.1:1234/v1"
437 },
438 "models": {
439 "gpt-4o": {
440 "name": "GPT-4o"
441 }
442 }
443 }"#;
444
445 let config: ProviderConfig = serde_json::from_str(json).unwrap();
446 assert_eq!(config.npm.as_deref(), Some("@ai-sdk/openai-compatible"));
447 assert_eq!(config.name.as_deref(), Some("My Custom Provider"));
448 assert!(config.models.is_some());
449 assert!(config.options.is_some());
450 }
451
452 #[test]
453 fn test_opencode_config_deserialize_minimal() {
454 let json = r#"{
455 "$schema": "https://opencode.ai/config.json",
456 "model": "anthropic/claude-sonnet-4-5"
457 }"#;
458
459 let config: OpenCodeConfig = serde_json::from_str(json).unwrap();
460 assert_eq!(
461 config.schema.as_deref(),
462 Some("https://opencode.ai/config.json")
463 );
464 assert_eq!(config.model.as_deref(), Some("anthropic/claude-sonnet-4-5"));
465 }
466
467 #[test]
468 fn test_opencode_config_serialize_roundtrip() {
469 let json = r#"{
470 "$schema": "https://opencode.ai/config.json",
471 "provider": {
472 "anthropic": {
473 "options": {
474 "apiKey": "{env:ANTHROPIC_API_KEY}"
475 }
476 }
477 },
478 "model": "anthropic/claude-sonnet-4-5",
479 "smallModel": "anthropic/claude-haiku-4-5",
480 "autoupdate": true
481 }"#;
482
483 let config: OpenCodeConfig = serde_json::from_str(json).unwrap();
484 let serialized = serde_json::to_string_pretty(&config).unwrap();
485 let deserialized: OpenCodeConfig = serde_json::from_str(&serialized).unwrap();
486 assert_eq!(config, deserialized);
487 }
488
489 #[test]
490 fn test_log_level_deserialize() {
491 let json = r#""WARN""#;
492 let level: LogLevel = serde_json::from_str(json).unwrap();
493 assert_eq!(level, LogLevel::Warn);
494 }
495
496 #[test]
497 fn test_share_mode_deserialize() {
498 assert_eq!(
499 serde_json::from_str::<ShareMode>(r#""manual""#).unwrap(),
500 ShareMode::Manual
501 );
502 assert_eq!(
503 serde_json::from_str::<ShareMode>(r#""disabled""#).unwrap(),
504 ShareMode::Disabled
505 );
506 }
507
508 #[test]
509 fn test_plugin_entry_variants() {
510 let name: PluginEntry = serde_json::from_str(r#""my-plugin""#).unwrap();
511 assert!(matches!(name, PluginEntry::Name(_)));
512
513 let with_opts: PluginEntry =
514 serde_json::from_str(r#"["my-plugin", {"key": "value"}]"#).unwrap();
515 assert!(matches!(with_opts, PluginEntry::WithOptions(_)));
516 }
517
518 #[test]
519 fn test_autoupdate_config_variants() {
520 let bool_val: AutoupdateConfig = serde_json::from_str(r#"true"#).unwrap();
521 assert!(matches!(bool_val, AutoupdateConfig::Bool(true)));
522
523 let notify_val: AutoupdateConfig = serde_json::from_str(r#""notify""#).unwrap();
524 assert!(matches!(notify_val, AutoupdateConfig::Notify(_)));
525 }
526
527 #[test]
528 fn test_unknown_fields_preserved_in_extra() {
529 let json = r#"{
532 "$schema": "https://opencode.ai/config.json",
533 "model": "anthropic/claude-sonnet-4-5",
534 "provider": {
535 "openai": {
536 "npm": "openai"
537 }
538 },
539 "theme": "dark",
540 "customFeature": { "enabled": true, "level": 42 }
541 }"#;
542
543 let config: OpenCodeConfig = serde_json::from_str(json).unwrap();
544 assert_eq!(config.model.as_deref(), Some("anthropic/claude-sonnet-4-5"));
546 assert!(config.provider.is_some());
547 assert_eq!(
549 config.extra.get("theme").and_then(|v| v.as_str()),
550 Some("dark")
551 );
552 assert_eq!(
553 config
554 .extra
555 .get("customFeature")
556 .and_then(|v| v.get("enabled"))
557 .and_then(|v| v.as_bool()),
558 Some(true)
559 );
560
561 let serialized = serde_json::to_string_pretty(&config).unwrap();
563 let deserialized: OpenCodeConfig = serde_json::from_str(&serialized).unwrap();
564 assert_eq!(
565 deserialized.extra.get("theme").and_then(|v| v.as_str()),
566 Some("dark")
567 );
568 assert_eq!(
569 deserialized
570 .extra
571 .get("customFeature")
572 .and_then(|v| v.get("level"))
573 .and_then(|v| v.as_i64()),
574 Some(42)
575 );
576 }
577}