use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct OpenCodeConfig {
#[serde(rename = "$schema", skip_serializing_if = "Option::is_none")]
pub schema: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub log_level: Option<LogLevel>,
#[serde(skip_serializing_if = "Option::is_none")]
pub server: Option<ServerConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub command: Option<HashMap<String, CommandConfig>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub skills: Option<SkillsConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub watcher: Option<WatcherConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub snapshot: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub plugin: Option<Vec<PluginEntry>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub share: Option<ShareMode>,
#[serde(skip_serializing_if = "Option::is_none")]
pub autoupdate: Option<AutoupdateConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub disabled_providers: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub enabled_providers: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub small_model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub default_agent: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub username: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub agent: Option<HashMap<String, AgentConfig>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub provider: Option<HashMap<String, ProviderConfig>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mcp: Option<HashMap<String, McpConfig>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub permission: Option<PermissionConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub formatter: Option<HashMap<String, FormatterConfig>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub instructions: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub compaction: Option<CompactionConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub experimental: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tools: Option<HashMap<String, bool>>,
#[serde(flatten)]
pub extra: HashMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum LogLevel {
Debug,
Info,
Warn,
Error,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ServerConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub port: Option<u16>,
#[serde(skip_serializing_if = "Option::is_none")]
pub hostname: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mdns: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mdns_domain: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cors: Option<Vec<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct CommandConfig {
pub template: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub agent: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub subtask: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct SkillsConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub paths: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub urls: Option<Vec<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct WatcherConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub ignore: Option<Vec<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ProviderConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub npm: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub options: Option<HashMap<String, serde_json::Value>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub models: Option<HashMap<String, ModelConfig>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub disabled: Option<bool>,
#[serde(flatten)]
pub extra: HashMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ModelConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub options: Option<HashMap<String, serde_json::Value>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub variants: Option<HashMap<String, VariantConfig>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub limit: Option<ModelLimit>,
#[serde(skip_serializing_if = "Option::is_none")]
pub disabled: Option<bool>,
#[serde(flatten)]
pub extra: HashMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ModelLimit {
#[serde(skip_serializing_if = "Option::is_none")]
pub context: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub output: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
pub struct VariantConfig {
#[serde(flatten)]
pub options: HashMap<String, serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub disabled: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct AgentConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub variant: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub temperature: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub top_p: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub prompt: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub disable: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mode: Option<AgentMode>,
#[serde(skip_serializing_if = "Option::is_none")]
pub hidden: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub steps: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub color: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub options: Option<HashMap<String, serde_json::Value>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub permission: Option<PermissionConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tools: Option<HashMap<String, bool>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum AgentMode {
Subagent,
Primary,
All,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct McpConfig {
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
pub mcp_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub command: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub args: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub env: Option<HashMap<String, String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(untagged)]
pub enum PermissionConfig {
Simple(PermissionAction),
Detailed(HashMap<String, PermissionRule>),
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum PermissionAction {
Ask,
Allow,
Deny,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(untagged)]
pub enum PermissionRule {
Simple(PermissionAction),
Detailed(HashMap<String, PermissionAction>),
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct FormatterConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub disabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub command: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub environment: Option<HashMap<String, String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub extensions: Option<Vec<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct CompactionConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub auto: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub prune: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reserved: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum ShareMode {
Manual,
Auto,
Disabled,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(untagged)]
pub enum AutoupdateConfig {
Bool(bool),
Notify(String),
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(untagged)]
pub enum PluginEntry {
Name(String),
WithOptions(Vec<serde_json::Value>),
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_provider_config_deserialize() {
let json = r#"{
"npm": "@ai-sdk/openai-compatible",
"name": "My Custom Provider",
"options": {
"baseURL": "http://127.0.0.1:1234/v1"
},
"models": {
"gpt-4o": {
"name": "GPT-4o"
}
}
}"#;
let config: ProviderConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.npm.as_deref(), Some("@ai-sdk/openai-compatible"));
assert_eq!(config.name.as_deref(), Some("My Custom Provider"));
assert!(config.models.is_some());
assert!(config.options.is_some());
}
#[test]
fn test_opencode_config_deserialize_minimal() {
let json = r#"{
"$schema": "https://opencode.ai/config.json",
"model": "anthropic/claude-sonnet-4-5"
}"#;
let config: OpenCodeConfig = serde_json::from_str(json).unwrap();
assert_eq!(
config.schema.as_deref(),
Some("https://opencode.ai/config.json")
);
assert_eq!(config.model.as_deref(), Some("anthropic/claude-sonnet-4-5"));
}
#[test]
fn test_opencode_config_serialize_roundtrip() {
let json = r#"{
"$schema": "https://opencode.ai/config.json",
"provider": {
"anthropic": {
"options": {
"apiKey": "{env:ANTHROPIC_API_KEY}"
}
}
},
"model": "anthropic/claude-sonnet-4-5",
"smallModel": "anthropic/claude-haiku-4-5",
"autoupdate": true
}"#;
let config: OpenCodeConfig = serde_json::from_str(json).unwrap();
let serialized = serde_json::to_string_pretty(&config).unwrap();
let deserialized: OpenCodeConfig = serde_json::from_str(&serialized).unwrap();
assert_eq!(config, deserialized);
}
#[test]
fn test_log_level_deserialize() {
let json = r#""WARN""#;
let level: LogLevel = serde_json::from_str(json).unwrap();
assert_eq!(level, LogLevel::Warn);
}
#[test]
fn test_share_mode_deserialize() {
assert_eq!(
serde_json::from_str::<ShareMode>(r#""manual""#).unwrap(),
ShareMode::Manual
);
assert_eq!(
serde_json::from_str::<ShareMode>(r#""disabled""#).unwrap(),
ShareMode::Disabled
);
}
#[test]
fn test_plugin_entry_variants() {
let name: PluginEntry = serde_json::from_str(r#""my-plugin""#).unwrap();
assert!(matches!(name, PluginEntry::Name(_)));
let with_opts: PluginEntry =
serde_json::from_str(r#"["my-plugin", {"key": "value"}]"#).unwrap();
assert!(matches!(with_opts, PluginEntry::WithOptions(_)));
}
#[test]
fn test_autoupdate_config_variants() {
let bool_val: AutoupdateConfig = serde_json::from_str(r#"true"#).unwrap();
assert!(matches!(bool_val, AutoupdateConfig::Bool(true)));
let notify_val: AutoupdateConfig = serde_json::from_str(r#""notify""#).unwrap();
assert!(matches!(notify_val, AutoupdateConfig::Notify(_)));
}
#[test]
fn test_unknown_fields_preserved_in_extra() {
let json = r#"{
"$schema": "https://opencode.ai/config.json",
"model": "anthropic/claude-sonnet-4-5",
"provider": {
"openai": {
"npm": "openai"
}
},
"theme": "dark",
"customFeature": { "enabled": true, "level": 42 }
}"#;
let config: OpenCodeConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.model.as_deref(), Some("anthropic/claude-sonnet-4-5"));
assert!(config.provider.is_some());
assert_eq!(
config.extra.get("theme").and_then(|v| v.as_str()),
Some("dark")
);
assert_eq!(
config
.extra
.get("customFeature")
.and_then(|v| v.get("enabled"))
.and_then(|v| v.as_bool()),
Some(true)
);
let serialized = serde_json::to_string_pretty(&config).unwrap();
let deserialized: OpenCodeConfig = serde_json::from_str(&serialized).unwrap();
assert_eq!(
deserialized.extra.get("theme").and_then(|v| v.as_str()),
Some("dark")
);
assert_eq!(
deserialized
.extra
.get("customFeature")
.and_then(|v| v.get("level"))
.and_then(|v| v.as_i64()),
Some(42)
);
}
}