Skip to main content

opencode_sdk_rs/resources/
config.rs

1//! Config resource types and methods mirroring the JS SDK's `resources/config.ts`.
2
3use std::collections::HashMap;
4
5use serde::{Deserialize, Serialize};
6
7use crate::{
8    client::{Opencode, RequestOptions},
9    error::OpencodeError,
10};
11
12// ---------------------------------------------------------------------------
13// ModeConfig
14// ---------------------------------------------------------------------------
15
16/// Configuration for an operational mode (shared base for agents/modes).
17#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
18pub struct ModeConfig {
19    /// Whether this mode is disabled.
20    #[serde(skip_serializing_if = "Option::is_none")]
21    pub disable: Option<bool>,
22
23    /// Optional model override for this mode.
24    #[serde(skip_serializing_if = "Option::is_none")]
25    pub model: Option<String>,
26
27    /// Optional system prompt override.
28    #[serde(skip_serializing_if = "Option::is_none")]
29    pub prompt: Option<String>,
30
31    /// Optional temperature override.
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub temperature: Option<f64>,
34
35    /// Map of tool names to their enabled state.
36    #[serde(skip_serializing_if = "Option::is_none")]
37    pub tools: Option<HashMap<String, bool>>,
38}
39
40// ---------------------------------------------------------------------------
41// Agent types
42// ---------------------------------------------------------------------------
43
44/// Configuration for a single agent entry.
45///
46/// Combines a human-readable `description` with all fields from [`ModeConfig`].
47#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
48pub struct AgentConfig {
49    /// Human-readable description of this agent.
50    pub description: String,
51
52    /// Flattened mode configuration fields.
53    #[serde(flatten)]
54    pub mode: ModeConfig,
55}
56
57/// Map of agent names to their configuration.
58///
59/// The key `"general"` is the conventional default agent entry in the JS SDK.
60pub type Agent = HashMap<String, AgentConfig>;
61
62// ---------------------------------------------------------------------------
63// Experimental / Hooks
64// ---------------------------------------------------------------------------
65
66/// A hook command entry (used by both `file_edited` and `session_completed`).
67#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
68pub struct HookCommand {
69    /// The command and its arguments.
70    pub command: Vec<String>,
71
72    /// Optional environment variables for the command.
73    #[serde(skip_serializing_if = "Option::is_none")]
74    pub environment: Option<HashMap<String, String>>,
75}
76
77/// Hook configuration within [`Experimental`].
78#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
79pub struct Hook {
80    /// Hooks triggered when a file is edited, keyed by glob / pattern.
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub file_edited: Option<HashMap<String, Vec<HookCommand>>>,
83
84    /// Hooks triggered when a session completes.
85    #[serde(skip_serializing_if = "Option::is_none")]
86    pub session_completed: Option<Vec<HookCommand>>,
87}
88
89/// Experimental features configuration.
90#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
91pub struct Experimental {
92    /// Hook definitions.
93    #[serde(skip_serializing_if = "Option::is_none")]
94    pub hook: Option<Hook>,
95}
96
97// ---------------------------------------------------------------------------
98// Keybinds
99// ---------------------------------------------------------------------------
100
101/// Keybinding configuration mirroring the JS SDK's `KeybindsConfig`.
102#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
103pub struct KeybindsConfig {
104    #[serde(skip_serializing_if = "Option::is_none")]
105    pub app_exit: Option<String>,
106    #[serde(skip_serializing_if = "Option::is_none")]
107    pub app_help: Option<String>,
108    #[serde(skip_serializing_if = "Option::is_none")]
109    pub editor_open: Option<String>,
110    #[serde(skip_serializing_if = "Option::is_none")]
111    pub file_close: Option<String>,
112    #[serde(skip_serializing_if = "Option::is_none")]
113    pub file_diff_toggle: Option<String>,
114    #[serde(skip_serializing_if = "Option::is_none")]
115    pub file_list: Option<String>,
116    #[serde(skip_serializing_if = "Option::is_none")]
117    pub file_search: Option<String>,
118    #[serde(skip_serializing_if = "Option::is_none")]
119    pub input_clear: Option<String>,
120    #[serde(skip_serializing_if = "Option::is_none")]
121    pub input_newline: Option<String>,
122    #[serde(skip_serializing_if = "Option::is_none")]
123    pub input_paste: Option<String>,
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub input_submit: Option<String>,
126    #[serde(skip_serializing_if = "Option::is_none")]
127    pub leader: Option<String>,
128    #[serde(skip_serializing_if = "Option::is_none")]
129    pub messages_copy: Option<String>,
130    #[serde(skip_serializing_if = "Option::is_none")]
131    pub messages_first: Option<String>,
132    #[serde(skip_serializing_if = "Option::is_none")]
133    pub messages_half_page_down: Option<String>,
134    #[serde(skip_serializing_if = "Option::is_none")]
135    pub messages_half_page_up: Option<String>,
136    #[serde(skip_serializing_if = "Option::is_none")]
137    pub messages_last: Option<String>,
138    #[serde(skip_serializing_if = "Option::is_none")]
139    pub messages_layout_toggle: Option<String>,
140    #[serde(skip_serializing_if = "Option::is_none")]
141    pub messages_next: Option<String>,
142    #[serde(skip_serializing_if = "Option::is_none")]
143    pub messages_page_down: Option<String>,
144    #[serde(skip_serializing_if = "Option::is_none")]
145    pub messages_page_up: Option<String>,
146    #[serde(skip_serializing_if = "Option::is_none")]
147    pub messages_previous: Option<String>,
148    #[serde(skip_serializing_if = "Option::is_none")]
149    pub messages_redo: Option<String>,
150    #[serde(skip_serializing_if = "Option::is_none")]
151    pub messages_revert: Option<String>,
152    #[serde(skip_serializing_if = "Option::is_none")]
153    pub messages_undo: Option<String>,
154    #[serde(skip_serializing_if = "Option::is_none")]
155    pub model_list: Option<String>,
156    #[serde(skip_serializing_if = "Option::is_none")]
157    pub project_init: Option<String>,
158    #[serde(skip_serializing_if = "Option::is_none")]
159    pub session_compact: Option<String>,
160    #[serde(skip_serializing_if = "Option::is_none")]
161    pub session_export: Option<String>,
162    #[serde(skip_serializing_if = "Option::is_none")]
163    pub session_interrupt: Option<String>,
164    #[serde(skip_serializing_if = "Option::is_none")]
165    pub session_list: Option<String>,
166    #[serde(skip_serializing_if = "Option::is_none")]
167    pub session_new: Option<String>,
168    #[serde(skip_serializing_if = "Option::is_none")]
169    pub session_share: Option<String>,
170    #[serde(skip_serializing_if = "Option::is_none")]
171    pub session_unshare: Option<String>,
172    #[serde(skip_serializing_if = "Option::is_none")]
173    pub switch_mode: Option<String>,
174    #[serde(skip_serializing_if = "Option::is_none")]
175    pub switch_mode_reverse: Option<String>,
176    #[serde(skip_serializing_if = "Option::is_none")]
177    pub theme_list: Option<String>,
178    #[serde(skip_serializing_if = "Option::is_none")]
179    pub tool_details: Option<String>,
180}
181
182// ---------------------------------------------------------------------------
183// MCP config
184// ---------------------------------------------------------------------------
185
186/// Local MCP server configuration.
187#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
188pub struct McpLocalConfig {
189    /// The command and its arguments.
190    pub command: Vec<String>,
191
192    /// Whether this MCP server is enabled.
193    #[serde(skip_serializing_if = "Option::is_none")]
194    pub enabled: Option<bool>,
195
196    /// Optional environment variables for the process.
197    #[serde(skip_serializing_if = "Option::is_none")]
198    pub environment: Option<HashMap<String, String>>,
199}
200
201/// Remote MCP server configuration.
202#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
203pub struct McpRemoteConfig {
204    /// The URL of the remote MCP server.
205    pub url: String,
206
207    /// Whether this MCP server is enabled.
208    #[serde(skip_serializing_if = "Option::is_none")]
209    pub enabled: Option<bool>,
210
211    /// Optional HTTP headers to send with requests.
212    #[serde(skip_serializing_if = "Option::is_none")]
213    pub headers: Option<HashMap<String, String>>,
214}
215
216/// Discriminated union of MCP server configurations, tagged by `"type"`.
217#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
218#[serde(tag = "type")]
219pub enum McpConfig {
220    /// A locally-spawned MCP server.
221    #[serde(rename = "local")]
222    Local(McpLocalConfig),
223
224    /// A remote MCP server accessed over HTTP.
225    #[serde(rename = "remote")]
226    Remote(McpRemoteConfig),
227}
228
229// ---------------------------------------------------------------------------
230// Provider config
231// ---------------------------------------------------------------------------
232
233/// Cost information for a model (input/output tokens).
234#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
235pub struct ModelCost {
236    /// Cost per input token.
237    pub input: f64,
238
239    /// Cost per output token.
240    pub output: f64,
241
242    /// Cost per cached-read token.
243    #[serde(skip_serializing_if = "Option::is_none")]
244    pub cache_read: Option<f64>,
245
246    /// Cost per cached-write token.
247    #[serde(skip_serializing_if = "Option::is_none")]
248    pub cache_write: Option<f64>,
249}
250
251/// Context and output limits for a model.
252#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
253pub struct ModelLimit {
254    /// Maximum context window size in tokens.
255    pub context: u64,
256
257    /// Maximum output size in tokens.
258    pub output: u64,
259}
260
261/// Configuration for a single model within a provider.
262#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
263pub struct ProviderModelConfig {
264    /// Model identifier override.
265    #[serde(skip_serializing_if = "Option::is_none")]
266    pub id: Option<String>,
267
268    /// Whether the model supports file attachments.
269    #[serde(skip_serializing_if = "Option::is_none")]
270    pub attachment: Option<bool>,
271
272    /// Token cost information.
273    #[serde(skip_serializing_if = "Option::is_none")]
274    pub cost: Option<ModelCost>,
275
276    /// Context and output limits.
277    #[serde(skip_serializing_if = "Option::is_none")]
278    pub limit: Option<ModelLimit>,
279
280    /// Display name for the model.
281    #[serde(skip_serializing_if = "Option::is_none")]
282    pub name: Option<String>,
283
284    /// Arbitrary model-specific options.
285    #[serde(skip_serializing_if = "Option::is_none")]
286    pub options: Option<HashMap<String, serde_json::Value>>,
287
288    /// Whether the model supports reasoning / chain-of-thought.
289    #[serde(skip_serializing_if = "Option::is_none")]
290    pub reasoning: Option<bool>,
291
292    /// Release date string (e.g. `"2024-05-13"`).
293    #[serde(skip_serializing_if = "Option::is_none")]
294    pub release_date: Option<String>,
295
296    /// Whether the model supports temperature adjustment.
297    #[serde(skip_serializing_if = "Option::is_none")]
298    pub temperature: Option<bool>,
299
300    /// Whether the model supports tool calling.
301    #[serde(skip_serializing_if = "Option::is_none")]
302    pub tool_call: Option<bool>,
303}
304
305/// Provider-level options (API key, base URL, and arbitrary extras).
306#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
307pub struct ProviderOptions {
308    /// API key for authenticating with the provider.
309    #[serde(rename = "apiKey", skip_serializing_if = "Option::is_none")]
310    pub api_key: Option<String>,
311
312    /// Override for the provider's base URL.
313    #[serde(rename = "baseURL", skip_serializing_if = "Option::is_none")]
314    pub base_url: Option<String>,
315
316    /// Arbitrary additional options.
317    #[serde(flatten)]
318    pub extra: HashMap<String, serde_json::Value>,
319}
320
321/// Configuration for a single provider.
322#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
323pub struct ProviderConfig {
324    /// Map of model identifiers to their configuration.
325    pub models: HashMap<String, ProviderModelConfig>,
326
327    /// Provider identifier override.
328    #[serde(skip_serializing_if = "Option::is_none")]
329    pub id: Option<String>,
330
331    /// Provider API endpoint.
332    #[serde(skip_serializing_if = "Option::is_none")]
333    pub api: Option<String>,
334
335    /// Environment variable names used for authentication.
336    #[serde(skip_serializing_if = "Option::is_none")]
337    pub env: Option<Vec<String>>,
338
339    /// Display name for the provider.
340    #[serde(skip_serializing_if = "Option::is_none")]
341    pub name: Option<String>,
342
343    /// NPM package name (JS SDK compatibility).
344    #[serde(skip_serializing_if = "Option::is_none")]
345    pub npm: Option<String>,
346
347    /// Provider-level options.
348    #[serde(skip_serializing_if = "Option::is_none")]
349    pub options: Option<ProviderOptions>,
350}
351
352// ---------------------------------------------------------------------------
353// Enums
354// ---------------------------------------------------------------------------
355
356/// How session sharing is configured.
357#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
358#[serde(rename_all = "lowercase")]
359pub enum ShareMode {
360    /// Share only on explicit user action.
361    Manual,
362    /// Share automatically.
363    Auto,
364    /// Sharing is disabled.
365    Disabled,
366}
367
368/// UI layout configuration.
369#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
370#[serde(rename_all = "lowercase")]
371pub enum Layout {
372    /// Automatically choose layout.
373    Auto,
374    /// Stretch to fill available space.
375    Stretch,
376}
377
378// ---------------------------------------------------------------------------
379// Mode map
380// ---------------------------------------------------------------------------
381
382/// Map of mode names (e.g. `"build"`, `"plan"`) to their configuration.
383pub type ModeMap = HashMap<String, ModeConfig>;
384
385// ---------------------------------------------------------------------------
386// Top-level Config
387// ---------------------------------------------------------------------------
388
389/// Top-level configuration returned by `GET /config`.
390///
391/// All fields are optional to tolerate partial / minimal server responses.
392#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
393pub struct Config {
394    /// JSON Schema reference.
395    #[serde(rename = "$schema", skip_serializing_if = "Option::is_none")]
396    pub schema: Option<String>,
397
398    /// Agent configuration map.
399    #[serde(skip_serializing_if = "Option::is_none")]
400    pub agent: Option<Agent>,
401
402    /// Whether to auto-share sessions (deprecated).
403    #[serde(skip_serializing_if = "Option::is_none")]
404    pub autoshare: Option<bool>,
405
406    /// Whether to auto-update the application.
407    /// Can be a boolean or the string "notify" to show update notifications.
408    #[serde(skip_serializing_if = "Option::is_none")]
409    pub autoupdate: Option<serde_json::Value>,
410
411    /// List of disabled provider identifiers.
412    #[serde(skip_serializing_if = "Option::is_none")]
413    pub disabled_providers: Option<Vec<String>>,
414
415    /// Experimental features.
416    #[serde(skip_serializing_if = "Option::is_none")]
417    pub experimental: Option<Experimental>,
418
419    /// Custom instructions / system prompts.
420    #[serde(skip_serializing_if = "Option::is_none")]
421    pub instructions: Option<Vec<String>>,
422
423    /// Keybinding configuration.
424    #[serde(skip_serializing_if = "Option::is_none")]
425    pub keybinds: Option<KeybindsConfig>,
426
427    /// UI layout (deprecated).
428    #[serde(skip_serializing_if = "Option::is_none")]
429    pub layout: Option<Layout>,
430
431    /// MCP server configurations, keyed by server name.
432    #[serde(skip_serializing_if = "Option::is_none")]
433    pub mcp: Option<HashMap<String, McpConfig>>,
434
435    /// Mode configurations.
436    #[serde(skip_serializing_if = "Option::is_none")]
437    pub mode: Option<ModeMap>,
438
439    /// Default model identifier.
440    #[serde(skip_serializing_if = "Option::is_none")]
441    pub model: Option<String>,
442
443    /// Provider configurations.
444    #[serde(skip_serializing_if = "Option::is_none")]
445    pub provider: Option<HashMap<String, ProviderConfig>>,
446
447    /// Session sharing mode.
448    #[serde(skip_serializing_if = "Option::is_none")]
449    pub share: Option<ShareMode>,
450
451    /// Default small-model identifier.
452    #[serde(skip_serializing_if = "Option::is_none")]
453    pub small_model: Option<String>,
454
455    /// UI theme name.
456    #[serde(skip_serializing_if = "Option::is_none")]
457    pub theme: Option<String>,
458
459    /// Display username.
460    #[serde(skip_serializing_if = "Option::is_none")]
461    pub username: Option<String>,
462}
463
464// ---------------------------------------------------------------------------
465// Resource
466// ---------------------------------------------------------------------------
467
468/// Handle for the `/config` resource.
469#[derive(Debug, Clone)]
470pub struct ConfigResource<'a> {
471    client: &'a Opencode,
472}
473
474impl<'a> ConfigResource<'a> {
475    /// Create a new `ConfigResource` bound to the given client.
476    pub(crate) const fn new(client: &'a Opencode) -> Self {
477        Self { client }
478    }
479
480    /// Retrieve the current configuration (`GET /config`).
481    pub async fn get(&self, options: Option<&RequestOptions>) -> Result<Config, OpencodeError> {
482        self.client.get("/config", options).await
483    }
484}
485
486// ---------------------------------------------------------------------------
487// Tests
488// ---------------------------------------------------------------------------
489
490#[cfg(test)]
491mod tests {
492    use serde_json::json;
493
494    use super::*;
495
496    #[test]
497    fn mode_config_round_trip() {
498        let mc = ModeConfig {
499            disable: Some(false),
500            model: Some("gpt-4o".into()),
501            prompt: Some("You are helpful.".into()),
502            temperature: Some(0.7),
503            tools: Some(HashMap::from([("bash".into(), true), ("file_write".into(), false)])),
504        };
505        let json_str = serde_json::to_string(&mc).unwrap();
506        let back: ModeConfig = serde_json::from_str(&json_str).unwrap();
507        assert_eq!(mc, back);
508    }
509
510    #[test]
511    fn mode_config_empty() {
512        let mc = ModeConfig::default();
513        let json_str = serde_json::to_string(&mc).unwrap();
514        assert_eq!(json_str, "{}");
515        let back: ModeConfig = serde_json::from_str(&json_str).unwrap();
516        assert_eq!(mc, back);
517    }
518
519    #[test]
520    fn mcp_local_round_trip() {
521        let cfg = McpConfig::Local(McpLocalConfig {
522            command: vec!["npx".into(), "mcp-server".into()],
523            enabled: Some(true),
524            environment: Some(HashMap::from([("NODE_ENV".into(), "production".into())])),
525        });
526        let v = serde_json::to_value(&cfg).unwrap();
527        assert_eq!(v["type"], "local");
528        assert_eq!(v["command"], json!(["npx", "mcp-server"]));
529        let back: McpConfig = serde_json::from_value(v).unwrap();
530        assert_eq!(cfg, back);
531    }
532
533    #[test]
534    fn mcp_remote_round_trip() {
535        let cfg = McpConfig::Remote(McpRemoteConfig {
536            url: "https://mcp.example.com".into(),
537            enabled: None,
538            headers: Some(HashMap::from([("Authorization".into(), "Bearer tok".into())])),
539        });
540        let v = serde_json::to_value(&cfg).unwrap();
541        assert_eq!(v["type"], "remote");
542        assert_eq!(v["url"], "https://mcp.example.com");
543        let back: McpConfig = serde_json::from_value(v).unwrap();
544        assert_eq!(cfg, back);
545    }
546
547    #[test]
548    fn keybinds_config_round_trip() {
549        let kb = KeybindsConfig {
550            app_exit: Some("ctrl+q".into()),
551            app_help: Some("ctrl+h".into()),
552            editor_open: Some("ctrl+e".into()),
553            file_close: Some("ctrl+w".into()),
554            file_diff_toggle: Some("ctrl+d".into()),
555            file_list: Some("ctrl+l".into()),
556            file_search: Some("ctrl+f".into()),
557            input_clear: Some("ctrl+u".into()),
558            input_newline: Some("shift+enter".into()),
559            input_paste: Some("ctrl+v".into()),
560            input_submit: Some("enter".into()),
561            leader: Some("ctrl+space".into()),
562            messages_copy: Some("ctrl+c".into()),
563            messages_first: Some("home".into()),
564            messages_half_page_down: Some("ctrl+d".into()),
565            messages_half_page_up: Some("ctrl+u".into()),
566            messages_last: Some("end".into()),
567            messages_layout_toggle: Some("ctrl+t".into()),
568            messages_next: Some("ctrl+n".into()),
569            messages_page_down: Some("pagedown".into()),
570            messages_page_up: Some("pageup".into()),
571            messages_previous: Some("ctrl+p".into()),
572            messages_redo: Some("ctrl+y".into()),
573            messages_revert: Some("ctrl+r".into()),
574            messages_undo: Some("ctrl+z".into()),
575            model_list: Some("ctrl+m".into()),
576            project_init: Some("ctrl+i".into()),
577            session_compact: Some("ctrl+k".into()),
578            session_export: Some("ctrl+shift+e".into()),
579            session_interrupt: Some("escape".into()),
580            session_list: Some("ctrl+s".into()),
581            session_new: Some("ctrl+shift+n".into()),
582            session_share: Some("ctrl+shift+s".into()),
583            session_unshare: Some("ctrl+shift+u".into()),
584            switch_mode: Some("tab".into()),
585            switch_mode_reverse: Some("shift+tab".into()),
586            theme_list: Some("ctrl+shift+t".into()),
587            tool_details: Some("ctrl+shift+d".into()),
588        };
589        let json_str = serde_json::to_string(&kb).unwrap();
590        let back: KeybindsConfig = serde_json::from_str(&json_str).unwrap();
591        assert_eq!(kb, back);
592    }
593
594    #[test]
595    fn config_with_schema_field() {
596        let cfg = Config {
597            schema: Some("https://opencode.ai/config.schema.json".into()),
598            ..Default::default()
599        };
600        let v = serde_json::to_value(&cfg).unwrap();
601        assert_eq!(v["$schema"], "https://opencode.ai/config.schema.json");
602        assert!(v.get("schema").is_none(), "$schema must not appear as 'schema'");
603        let back: Config = serde_json::from_value(v).unwrap();
604        assert_eq!(cfg, back);
605    }
606
607    #[test]
608    fn config_full_round_trip() {
609        let cfg = Config {
610            schema: Some("https://opencode.ai/schema.json".into()),
611            agent: Some(HashMap::from([(
612                "general".into(),
613                AgentConfig {
614                    description: "Default agent".into(),
615                    mode: ModeConfig {
616                        model: Some("claude-3-opus".into()),
617                        temperature: Some(0.5),
618                        ..Default::default()
619                    },
620                },
621            )])),
622            autoshare: Some(false),
623            autoupdate: Some(serde_json::Value::Bool(true)),
624            disabled_providers: Some(vec!["azure".into()]),
625            experimental: Some(Experimental {
626                hook: Some(Hook {
627                    file_edited: Some(HashMap::from([(
628                        "*.rs".into(),
629                        vec![HookCommand {
630                            command: vec!["cargo".into(), "fmt".into()],
631                            environment: None,
632                        }],
633                    )])),
634                    session_completed: Some(vec![HookCommand {
635                        command: vec!["notify-send".into(), "done".into()],
636                        environment: Some(HashMap::from([("DISPLAY".into(), ":0".into())])),
637                    }]),
638                }),
639            }),
640            instructions: Some(vec!["Be concise.".into()]),
641            keybinds: None,
642            layout: Some(Layout::Auto),
643            mcp: Some(HashMap::from([(
644                "local-server".into(),
645                McpConfig::Local(McpLocalConfig {
646                    command: vec!["node".into(), "server.js".into()],
647                    enabled: Some(true),
648                    environment: None,
649                }),
650            )])),
651            mode: Some(HashMap::from([(
652                "build".into(),
653                ModeConfig { model: Some("gpt-4o".into()), ..Default::default() },
654            )])),
655            model: Some("claude-3-opus".into()),
656            provider: Some(HashMap::from([(
657                "openai".into(),
658                ProviderConfig {
659                    models: HashMap::from([(
660                        "gpt-4o".into(),
661                        ProviderModelConfig {
662                            id: Some("gpt-4o".into()),
663                            attachment: Some(true),
664                            cost: Some(ModelCost {
665                                input: 5.0,
666                                output: 15.0,
667                                cache_read: None,
668                                cache_write: None,
669                            }),
670                            limit: Some(ModelLimit { context: 128_000, output: 4_096 }),
671                            name: Some("GPT-4o".into()),
672                            options: None,
673                            reasoning: Some(false),
674                            release_date: Some("2024-05-13".into()),
675                            temperature: Some(true),
676                            tool_call: Some(true),
677                        },
678                    )]),
679                    id: Some("openai".into()),
680                    api: Some("https://api.openai.com/v1".into()),
681                    env: Some(vec!["OPENAI_API_KEY".into()]),
682                    name: Some("OpenAI".into()),
683                    npm: None,
684                    options: Some(ProviderOptions {
685                        api_key: None,
686                        base_url: Some("https://api.openai.com/v1".into()),
687                        extra: HashMap::new(),
688                    }),
689                },
690            )])),
691            share: Some(ShareMode::Manual),
692            small_model: Some("gpt-4o-mini".into()),
693            theme: Some("dark".into()),
694            username: Some("developer".into()),
695        };
696        let json_str = serde_json::to_string(&cfg).unwrap();
697        let back: Config = serde_json::from_str(&json_str).unwrap();
698        assert_eq!(cfg, back);
699    }
700
701    #[test]
702    fn share_mode_serde() {
703        for (variant, expected) in [
704            (ShareMode::Manual, "manual"),
705            (ShareMode::Auto, "auto"),
706            (ShareMode::Disabled, "disabled"),
707        ] {
708            let json_str = serde_json::to_string(&variant).unwrap();
709            assert_eq!(json_str, format!("\"{expected}\""));
710            let back: ShareMode = serde_json::from_str(&json_str).unwrap();
711            assert_eq!(variant, back);
712        }
713    }
714
715    #[test]
716    fn layout_serde() {
717        for (variant, expected) in [(Layout::Auto, "auto"), (Layout::Stretch, "stretch")] {
718            let json_str = serde_json::to_string(&variant).unwrap();
719            assert_eq!(json_str, format!("\"{expected}\""));
720            let back: Layout = serde_json::from_str(&json_str).unwrap();
721            assert_eq!(variant, back);
722        }
723    }
724
725    #[test]
726    fn agent_config_flatten() {
727        let ac = AgentConfig {
728            description: "Build agent".into(),
729            mode: ModeConfig {
730                model: Some("gpt-4o".into()),
731                tools: Some(HashMap::from([("bash".into(), true)])),
732                ..Default::default()
733            },
734        };
735        let v = serde_json::to_value(&ac).unwrap();
736        // Flattened fields appear at the top level
737        assert_eq!(v["description"], "Build agent");
738        assert_eq!(v["model"], "gpt-4o");
739        assert_eq!(v["tools"]["bash"], true);
740        let back: AgentConfig = serde_json::from_value(v).unwrap();
741        assert_eq!(ac, back);
742    }
743
744    #[test]
745    fn provider_options_with_extras() {
746        let opts = ProviderOptions {
747            api_key: Some("sk-test".into()),
748            base_url: None,
749            extra: HashMap::from([("organization".into(), json!("org-123"))]),
750        };
751        let v = serde_json::to_value(&opts).unwrap();
752        assert_eq!(v["apiKey"], "sk-test");
753        assert_eq!(v["organization"], "org-123");
754        let back: ProviderOptions = serde_json::from_value(v).unwrap();
755        assert_eq!(opts, back);
756    }
757
758    #[test]
759    fn config_empty_round_trip() {
760        let cfg = Config::default();
761        let json_str = serde_json::to_string(&cfg).unwrap();
762        assert_eq!(json_str, "{}");
763        let back: Config = serde_json::from_str(&json_str).unwrap();
764        assert_eq!(cfg, back);
765    }
766
767    // -- Edge cases --
768
769    #[test]
770    fn config_minimal_partial_fields() {
771        let cfg = Config {
772            theme: Some("dark".into()),
773            autoupdate: Some(serde_json::Value::Bool(false)),
774            ..Default::default()
775        };
776        let json_str = serde_json::to_string(&cfg).unwrap();
777        // Only the two set fields should appear
778        assert!(json_str.contains("theme"));
779        assert!(json_str.contains("autoupdate"));
780        assert!(!json_str.contains("$schema"));
781        assert!(!json_str.contains("agent"));
782        assert!(!json_str.contains("mcp"));
783        let back: Config = serde_json::from_str(&json_str).unwrap();
784        assert_eq!(cfg, back);
785    }
786
787    #[test]
788    fn mcp_local_minimal() {
789        let cfg = McpConfig::Local(McpLocalConfig {
790            command: vec!["my-server".into()],
791            enabled: None,
792            environment: None,
793        });
794        let v = serde_json::to_value(&cfg).unwrap();
795        assert_eq!(v["type"], "local");
796        assert!(v.get("enabled").is_none());
797        assert!(v.get("environment").is_none());
798        let back: McpConfig = serde_json::from_value(v).unwrap();
799        assert_eq!(cfg, back);
800    }
801
802    #[test]
803    fn mcp_remote_minimal() {
804        let cfg = McpConfig::Remote(McpRemoteConfig {
805            url: "https://remote.example.com".into(),
806            enabled: None,
807            headers: None,
808        });
809        let v = serde_json::to_value(&cfg).unwrap();
810        assert_eq!(v["type"], "remote");
811        assert!(v.get("enabled").is_none());
812        assert!(v.get("headers").is_none());
813        let back: McpConfig = serde_json::from_value(v).unwrap();
814        assert_eq!(cfg, back);
815    }
816
817    #[test]
818    fn mode_config_single_field() {
819        let mc = ModeConfig { temperature: Some(0.3), ..Default::default() };
820        let v = serde_json::to_value(&mc).unwrap();
821        assert_eq!(v["temperature"], 0.3);
822        assert!(v.get("disable").is_none());
823        assert!(v.get("model").is_none());
824        assert!(v.get("prompt").is_none());
825        assert!(v.get("tools").is_none());
826        let back: ModeConfig = serde_json::from_value(v).unwrap();
827        assert_eq!(mc, back);
828    }
829
830    #[test]
831    fn config_with_empty_collections() {
832        let cfg = Config {
833            disabled_providers: Some(vec![]),
834            instructions: Some(vec![]),
835            mcp: Some(HashMap::new()),
836            mode: Some(HashMap::new()),
837            provider: Some(HashMap::new()),
838            ..Default::default()
839        };
840        let json_str = serde_json::to_string(&cfg).unwrap();
841        let back: Config = serde_json::from_str(&json_str).unwrap();
842        assert_eq!(cfg, back);
843    }
844
845    #[test]
846    fn hook_command_minimal() {
847        let hc = HookCommand { command: vec!["echo".into(), "done".into()], environment: None };
848        let v = serde_json::to_value(&hc).unwrap();
849        assert!(v.get("environment").is_none());
850        let back: HookCommand = serde_json::from_value(v).unwrap();
851        assert_eq!(hc, back);
852    }
853
854    #[test]
855    fn experimental_no_hooks() {
856        let exp = Experimental { hook: None };
857        let v = serde_json::to_value(&exp).unwrap();
858        assert!(v.get("hook").is_none());
859        let back: Experimental = serde_json::from_value(v).unwrap();
860        assert_eq!(exp, back);
861    }
862
863    #[test]
864    fn provider_config_minimal() {
865        let pc = ProviderConfig {
866            models: HashMap::new(),
867            id: None,
868            api: None,
869            env: None,
870            name: None,
871            npm: None,
872            options: None,
873        };
874        let v = serde_json::to_value(&pc).unwrap();
875        assert!(v.get("id").is_none());
876        assert!(v.get("api").is_none());
877        assert!(v.get("name").is_none());
878        let back: ProviderConfig = serde_json::from_value(v).unwrap();
879        assert_eq!(pc, back);
880    }
881
882    #[test]
883    fn provider_model_config_all_none() {
884        let pmc = ProviderModelConfig::default();
885        let json_str = serde_json::to_string(&pmc).unwrap();
886        assert_eq!(json_str, "{}");
887        let back: ProviderModelConfig = serde_json::from_str(&json_str).unwrap();
888        assert_eq!(pmc, back);
889    }
890}