use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use crate::{
client::{Opencode, RequestOptions},
error::OpencodeError,
};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct ModeConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub disable: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub prompt: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub temperature: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tools: Option<HashMap<String, bool>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct AgentConfig {
pub description: String,
#[serde(flatten)]
pub mode: ModeConfig,
}
pub type Agent = HashMap<String, AgentConfig>;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct HookCommand {
pub command: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub environment: Option<HashMap<String, String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Hook {
#[serde(skip_serializing_if = "Option::is_none")]
pub file_edited: Option<HashMap<String, Vec<HookCommand>>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub session_completed: Option<Vec<HookCommand>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Experimental {
#[serde(skip_serializing_if = "Option::is_none")]
pub hook: Option<Hook>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct KeybindsConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub app_exit: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub app_help: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub editor_open: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub file_close: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub file_diff_toggle: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub file_list: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub file_search: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub input_clear: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub input_newline: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub input_paste: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub input_submit: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub leader: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub messages_copy: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub messages_first: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub messages_half_page_down: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub messages_half_page_up: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub messages_last: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub messages_layout_toggle: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub messages_next: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub messages_page_down: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub messages_page_up: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub messages_previous: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub messages_redo: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub messages_revert: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub messages_undo: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub model_list: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub project_init: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub session_compact: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub session_export: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub session_interrupt: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub session_list: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub session_new: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub session_share: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub session_unshare: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub switch_mode: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub switch_mode_reverse: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub theme_list: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_details: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct McpLocalConfig {
pub command: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub environment: Option<HashMap<String, String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct McpRemoteConfig {
pub url: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub headers: Option<HashMap<String, String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "type")]
pub enum McpConfig {
#[serde(rename = "local")]
Local(McpLocalConfig),
#[serde(rename = "remote")]
Remote(McpRemoteConfig),
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ModelCost {
pub input: f64,
pub output: f64,
#[serde(skip_serializing_if = "Option::is_none")]
pub cache_read: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cache_write: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ModelLimit {
pub context: u64,
pub output: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct ProviderModelConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub attachment: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cost: Option<ModelCost>,
#[serde(skip_serializing_if = "Option::is_none")]
pub limit: Option<ModelLimit>,
#[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 reasoning: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub release_date: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub temperature: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_call: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct ProviderOptions {
#[serde(rename = "apiKey", skip_serializing_if = "Option::is_none")]
pub api_key: Option<String>,
#[serde(rename = "baseURL", skip_serializing_if = "Option::is_none")]
pub base_url: Option<String>,
#[serde(flatten)]
pub extra: HashMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ProviderConfig {
pub models: HashMap<String, ProviderModelConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub api: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub env: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub npm: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub options: Option<ProviderOptions>,
}
#[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(rename_all = "lowercase")]
pub enum Layout {
Auto,
Stretch,
}
pub type ModeMap = HashMap<String, ModeConfig>;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct Config {
#[serde(rename = "$schema", skip_serializing_if = "Option::is_none")]
pub schema: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub agent: Option<Agent>,
#[serde(skip_serializing_if = "Option::is_none")]
pub autoshare: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub autoupdate: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub disabled_providers: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub experimental: Option<Experimental>,
#[serde(skip_serializing_if = "Option::is_none")]
pub instructions: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub keybinds: Option<KeybindsConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub layout: Option<Layout>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mcp: Option<HashMap<String, McpConfig>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mode: Option<ModeMap>,
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub provider: Option<HashMap<String, ProviderConfig>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub share: Option<ShareMode>,
#[serde(skip_serializing_if = "Option::is_none")]
pub small_model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub theme: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub username: Option<String>,
}
#[derive(Debug, Clone)]
pub struct ConfigResource<'a> {
client: &'a Opencode,
}
impl<'a> ConfigResource<'a> {
pub(crate) const fn new(client: &'a Opencode) -> Self {
Self { client }
}
pub async fn get(&self, options: Option<&RequestOptions>) -> Result<Config, OpencodeError> {
self.client.get("/config", options).await
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
#[test]
fn mode_config_round_trip() {
let mc = ModeConfig {
disable: Some(false),
model: Some("gpt-4o".into()),
prompt: Some("You are helpful.".into()),
temperature: Some(0.7),
tools: Some(HashMap::from([("bash".into(), true), ("file_write".into(), false)])),
};
let json_str = serde_json::to_string(&mc).unwrap();
let back: ModeConfig = serde_json::from_str(&json_str).unwrap();
assert_eq!(mc, back);
}
#[test]
fn mode_config_empty() {
let mc = ModeConfig::default();
let json_str = serde_json::to_string(&mc).unwrap();
assert_eq!(json_str, "{}");
let back: ModeConfig = serde_json::from_str(&json_str).unwrap();
assert_eq!(mc, back);
}
#[test]
fn mcp_local_round_trip() {
let cfg = McpConfig::Local(McpLocalConfig {
command: vec!["npx".into(), "mcp-server".into()],
enabled: Some(true),
environment: Some(HashMap::from([("NODE_ENV".into(), "production".into())])),
});
let v = serde_json::to_value(&cfg).unwrap();
assert_eq!(v["type"], "local");
assert_eq!(v["command"], json!(["npx", "mcp-server"]));
let back: McpConfig = serde_json::from_value(v).unwrap();
assert_eq!(cfg, back);
}
#[test]
fn mcp_remote_round_trip() {
let cfg = McpConfig::Remote(McpRemoteConfig {
url: "https://mcp.example.com".into(),
enabled: None,
headers: Some(HashMap::from([("Authorization".into(), "Bearer tok".into())])),
});
let v = serde_json::to_value(&cfg).unwrap();
assert_eq!(v["type"], "remote");
assert_eq!(v["url"], "https://mcp.example.com");
let back: McpConfig = serde_json::from_value(v).unwrap();
assert_eq!(cfg, back);
}
#[test]
fn keybinds_config_round_trip() {
let kb = KeybindsConfig {
app_exit: Some("ctrl+q".into()),
app_help: Some("ctrl+h".into()),
editor_open: Some("ctrl+e".into()),
file_close: Some("ctrl+w".into()),
file_diff_toggle: Some("ctrl+d".into()),
file_list: Some("ctrl+l".into()),
file_search: Some("ctrl+f".into()),
input_clear: Some("ctrl+u".into()),
input_newline: Some("shift+enter".into()),
input_paste: Some("ctrl+v".into()),
input_submit: Some("enter".into()),
leader: Some("ctrl+space".into()),
messages_copy: Some("ctrl+c".into()),
messages_first: Some("home".into()),
messages_half_page_down: Some("ctrl+d".into()),
messages_half_page_up: Some("ctrl+u".into()),
messages_last: Some("end".into()),
messages_layout_toggle: Some("ctrl+t".into()),
messages_next: Some("ctrl+n".into()),
messages_page_down: Some("pagedown".into()),
messages_page_up: Some("pageup".into()),
messages_previous: Some("ctrl+p".into()),
messages_redo: Some("ctrl+y".into()),
messages_revert: Some("ctrl+r".into()),
messages_undo: Some("ctrl+z".into()),
model_list: Some("ctrl+m".into()),
project_init: Some("ctrl+i".into()),
session_compact: Some("ctrl+k".into()),
session_export: Some("ctrl+shift+e".into()),
session_interrupt: Some("escape".into()),
session_list: Some("ctrl+s".into()),
session_new: Some("ctrl+shift+n".into()),
session_share: Some("ctrl+shift+s".into()),
session_unshare: Some("ctrl+shift+u".into()),
switch_mode: Some("tab".into()),
switch_mode_reverse: Some("shift+tab".into()),
theme_list: Some("ctrl+shift+t".into()),
tool_details: Some("ctrl+shift+d".into()),
};
let json_str = serde_json::to_string(&kb).unwrap();
let back: KeybindsConfig = serde_json::from_str(&json_str).unwrap();
assert_eq!(kb, back);
}
#[test]
fn config_with_schema_field() {
let cfg = Config {
schema: Some("https://opencode.ai/config.schema.json".into()),
..Default::default()
};
let v = serde_json::to_value(&cfg).unwrap();
assert_eq!(v["$schema"], "https://opencode.ai/config.schema.json");
assert!(v.get("schema").is_none(), "$schema must not appear as 'schema'");
let back: Config = serde_json::from_value(v).unwrap();
assert_eq!(cfg, back);
}
#[test]
fn config_full_round_trip() {
let cfg = Config {
schema: Some("https://opencode.ai/schema.json".into()),
agent: Some(HashMap::from([(
"general".into(),
AgentConfig {
description: "Default agent".into(),
mode: ModeConfig {
model: Some("claude-3-opus".into()),
temperature: Some(0.5),
..Default::default()
},
},
)])),
autoshare: Some(false),
autoupdate: Some(serde_json::Value::Bool(true)),
disabled_providers: Some(vec!["azure".into()]),
experimental: Some(Experimental {
hook: Some(Hook {
file_edited: Some(HashMap::from([(
"*.rs".into(),
vec![HookCommand {
command: vec!["cargo".into(), "fmt".into()],
environment: None,
}],
)])),
session_completed: Some(vec![HookCommand {
command: vec!["notify-send".into(), "done".into()],
environment: Some(HashMap::from([("DISPLAY".into(), ":0".into())])),
}]),
}),
}),
instructions: Some(vec!["Be concise.".into()]),
keybinds: None,
layout: Some(Layout::Auto),
mcp: Some(HashMap::from([(
"local-server".into(),
McpConfig::Local(McpLocalConfig {
command: vec!["node".into(), "server.js".into()],
enabled: Some(true),
environment: None,
}),
)])),
mode: Some(HashMap::from([(
"build".into(),
ModeConfig { model: Some("gpt-4o".into()), ..Default::default() },
)])),
model: Some("claude-3-opus".into()),
provider: Some(HashMap::from([(
"openai".into(),
ProviderConfig {
models: HashMap::from([(
"gpt-4o".into(),
ProviderModelConfig {
id: Some("gpt-4o".into()),
attachment: Some(true),
cost: Some(ModelCost {
input: 5.0,
output: 15.0,
cache_read: None,
cache_write: None,
}),
limit: Some(ModelLimit { context: 128_000, output: 4_096 }),
name: Some("GPT-4o".into()),
options: None,
reasoning: Some(false),
release_date: Some("2024-05-13".into()),
temperature: Some(true),
tool_call: Some(true),
},
)]),
id: Some("openai".into()),
api: Some("https://api.openai.com/v1".into()),
env: Some(vec!["OPENAI_API_KEY".into()]),
name: Some("OpenAI".into()),
npm: None,
options: Some(ProviderOptions {
api_key: None,
base_url: Some("https://api.openai.com/v1".into()),
extra: HashMap::new(),
}),
},
)])),
share: Some(ShareMode::Manual),
small_model: Some("gpt-4o-mini".into()),
theme: Some("dark".into()),
username: Some("developer".into()),
};
let json_str = serde_json::to_string(&cfg).unwrap();
let back: Config = serde_json::from_str(&json_str).unwrap();
assert_eq!(cfg, back);
}
#[test]
fn share_mode_serde() {
for (variant, expected) in [
(ShareMode::Manual, "manual"),
(ShareMode::Auto, "auto"),
(ShareMode::Disabled, "disabled"),
] {
let json_str = serde_json::to_string(&variant).unwrap();
assert_eq!(json_str, format!("\"{expected}\""));
let back: ShareMode = serde_json::from_str(&json_str).unwrap();
assert_eq!(variant, back);
}
}
#[test]
fn layout_serde() {
for (variant, expected) in [(Layout::Auto, "auto"), (Layout::Stretch, "stretch")] {
let json_str = serde_json::to_string(&variant).unwrap();
assert_eq!(json_str, format!("\"{expected}\""));
let back: Layout = serde_json::from_str(&json_str).unwrap();
assert_eq!(variant, back);
}
}
#[test]
fn agent_config_flatten() {
let ac = AgentConfig {
description: "Build agent".into(),
mode: ModeConfig {
model: Some("gpt-4o".into()),
tools: Some(HashMap::from([("bash".into(), true)])),
..Default::default()
},
};
let v = serde_json::to_value(&ac).unwrap();
assert_eq!(v["description"], "Build agent");
assert_eq!(v["model"], "gpt-4o");
assert_eq!(v["tools"]["bash"], true);
let back: AgentConfig = serde_json::from_value(v).unwrap();
assert_eq!(ac, back);
}
#[test]
fn provider_options_with_extras() {
let opts = ProviderOptions {
api_key: Some("sk-test".into()),
base_url: None,
extra: HashMap::from([("organization".into(), json!("org-123"))]),
};
let v = serde_json::to_value(&opts).unwrap();
assert_eq!(v["apiKey"], "sk-test");
assert_eq!(v["organization"], "org-123");
let back: ProviderOptions = serde_json::from_value(v).unwrap();
assert_eq!(opts, back);
}
#[test]
fn config_empty_round_trip() {
let cfg = Config::default();
let json_str = serde_json::to_string(&cfg).unwrap();
assert_eq!(json_str, "{}");
let back: Config = serde_json::from_str(&json_str).unwrap();
assert_eq!(cfg, back);
}
#[test]
fn config_minimal_partial_fields() {
let cfg = Config {
theme: Some("dark".into()),
autoupdate: Some(serde_json::Value::Bool(false)),
..Default::default()
};
let json_str = serde_json::to_string(&cfg).unwrap();
assert!(json_str.contains("theme"));
assert!(json_str.contains("autoupdate"));
assert!(!json_str.contains("$schema"));
assert!(!json_str.contains("agent"));
assert!(!json_str.contains("mcp"));
let back: Config = serde_json::from_str(&json_str).unwrap();
assert_eq!(cfg, back);
}
#[test]
fn mcp_local_minimal() {
let cfg = McpConfig::Local(McpLocalConfig {
command: vec!["my-server".into()],
enabled: None,
environment: None,
});
let v = serde_json::to_value(&cfg).unwrap();
assert_eq!(v["type"], "local");
assert!(v.get("enabled").is_none());
assert!(v.get("environment").is_none());
let back: McpConfig = serde_json::from_value(v).unwrap();
assert_eq!(cfg, back);
}
#[test]
fn mcp_remote_minimal() {
let cfg = McpConfig::Remote(McpRemoteConfig {
url: "https://remote.example.com".into(),
enabled: None,
headers: None,
});
let v = serde_json::to_value(&cfg).unwrap();
assert_eq!(v["type"], "remote");
assert!(v.get("enabled").is_none());
assert!(v.get("headers").is_none());
let back: McpConfig = serde_json::from_value(v).unwrap();
assert_eq!(cfg, back);
}
#[test]
fn mode_config_single_field() {
let mc = ModeConfig { temperature: Some(0.3), ..Default::default() };
let v = serde_json::to_value(&mc).unwrap();
assert_eq!(v["temperature"], 0.3);
assert!(v.get("disable").is_none());
assert!(v.get("model").is_none());
assert!(v.get("prompt").is_none());
assert!(v.get("tools").is_none());
let back: ModeConfig = serde_json::from_value(v).unwrap();
assert_eq!(mc, back);
}
#[test]
fn config_with_empty_collections() {
let cfg = Config {
disabled_providers: Some(vec![]),
instructions: Some(vec![]),
mcp: Some(HashMap::new()),
mode: Some(HashMap::new()),
provider: Some(HashMap::new()),
..Default::default()
};
let json_str = serde_json::to_string(&cfg).unwrap();
let back: Config = serde_json::from_str(&json_str).unwrap();
assert_eq!(cfg, back);
}
#[test]
fn hook_command_minimal() {
let hc = HookCommand { command: vec!["echo".into(), "done".into()], environment: None };
let v = serde_json::to_value(&hc).unwrap();
assert!(v.get("environment").is_none());
let back: HookCommand = serde_json::from_value(v).unwrap();
assert_eq!(hc, back);
}
#[test]
fn experimental_no_hooks() {
let exp = Experimental { hook: None };
let v = serde_json::to_value(&exp).unwrap();
assert!(v.get("hook").is_none());
let back: Experimental = serde_json::from_value(v).unwrap();
assert_eq!(exp, back);
}
#[test]
fn provider_config_minimal() {
let pc = ProviderConfig {
models: HashMap::new(),
id: None,
api: None,
env: None,
name: None,
npm: None,
options: None,
};
let v = serde_json::to_value(&pc).unwrap();
assert!(v.get("id").is_none());
assert!(v.get("api").is_none());
assert!(v.get("name").is_none());
let back: ProviderConfig = serde_json::from_value(v).unwrap();
assert_eq!(pc, back);
}
#[test]
fn provider_model_config_all_none() {
let pmc = ProviderModelConfig::default();
let json_str = serde_json::to_string(&pmc).unwrap();
assert_eq!(json_str, "{}");
let back: ProviderModelConfig = serde_json::from_str(&json_str).unwrap();
assert_eq!(pmc, back);
}
}