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