1use std::collections::HashMap;
4
5use serde::{Deserialize, Serialize};
6
7use crate::{
8 client::{Opencode, RequestOptions},
9 error::OpencodeError,
10};
11
12#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
18pub struct ModeConfig {
19 #[serde(skip_serializing_if = "Option::is_none")]
21 pub disable: Option<bool>,
22
23 #[serde(skip_serializing_if = "Option::is_none")]
25 pub model: Option<String>,
26
27 #[serde(skip_serializing_if = "Option::is_none")]
29 pub prompt: Option<String>,
30
31 #[serde(skip_serializing_if = "Option::is_none")]
33 pub temperature: Option<f64>,
34
35 #[serde(skip_serializing_if = "Option::is_none")]
37 pub tools: Option<HashMap<String, bool>>,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
48pub struct AgentConfig {
49 pub description: String,
51
52 #[serde(flatten)]
54 pub mode: ModeConfig,
55}
56
57pub type Agent = HashMap<String, AgentConfig>;
61
62#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
68pub struct HookCommand {
69 pub command: Vec<String>,
71
72 #[serde(skip_serializing_if = "Option::is_none")]
74 pub environment: Option<HashMap<String, String>>,
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
79pub struct Hook {
80 #[serde(skip_serializing_if = "Option::is_none")]
82 pub file_edited: Option<HashMap<String, Vec<HookCommand>>>,
83
84 #[serde(skip_serializing_if = "Option::is_none")]
86 pub session_completed: Option<Vec<HookCommand>>,
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
91pub struct Experimental {
92 #[serde(skip_serializing_if = "Option::is_none")]
94 pub hook: Option<Hook>,
95}
96
97#[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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
188pub struct McpLocalConfig {
189 pub command: Vec<String>,
191
192 #[serde(skip_serializing_if = "Option::is_none")]
194 pub enabled: Option<bool>,
195
196 #[serde(skip_serializing_if = "Option::is_none")]
198 pub environment: Option<HashMap<String, String>>,
199}
200
201#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
203pub struct McpRemoteConfig {
204 pub url: String,
206
207 #[serde(skip_serializing_if = "Option::is_none")]
209 pub enabled: Option<bool>,
210
211 #[serde(skip_serializing_if = "Option::is_none")]
213 pub headers: Option<HashMap<String, String>>,
214}
215
216#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
218#[serde(tag = "type")]
219pub enum McpConfig {
220 #[serde(rename = "local")]
222 Local(McpLocalConfig),
223
224 #[serde(rename = "remote")]
226 Remote(McpRemoteConfig),
227}
228
229#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
235pub struct ModelCost {
236 pub input: f64,
238
239 pub output: f64,
241
242 #[serde(skip_serializing_if = "Option::is_none")]
244 pub cache_read: Option<f64>,
245
246 #[serde(skip_serializing_if = "Option::is_none")]
248 pub cache_write: Option<f64>,
249}
250
251#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
253pub struct ModelLimit {
254 pub context: u64,
256
257 pub output: u64,
259}
260
261#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
263pub struct ProviderModelConfig {
264 #[serde(skip_serializing_if = "Option::is_none")]
266 pub id: Option<String>,
267
268 #[serde(skip_serializing_if = "Option::is_none")]
270 pub attachment: Option<bool>,
271
272 #[serde(skip_serializing_if = "Option::is_none")]
274 pub cost: Option<ModelCost>,
275
276 #[serde(skip_serializing_if = "Option::is_none")]
278 pub limit: Option<ModelLimit>,
279
280 #[serde(skip_serializing_if = "Option::is_none")]
282 pub name: Option<String>,
283
284 #[serde(skip_serializing_if = "Option::is_none")]
286 pub options: Option<HashMap<String, serde_json::Value>>,
287
288 #[serde(skip_serializing_if = "Option::is_none")]
290 pub reasoning: Option<bool>,
291
292 #[serde(skip_serializing_if = "Option::is_none")]
294 pub release_date: Option<String>,
295
296 #[serde(skip_serializing_if = "Option::is_none")]
298 pub temperature: Option<bool>,
299
300 #[serde(skip_serializing_if = "Option::is_none")]
302 pub tool_call: Option<bool>,
303}
304
305#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
307pub struct ProviderOptions {
308 #[serde(rename = "apiKey", skip_serializing_if = "Option::is_none")]
310 pub api_key: Option<String>,
311
312 #[serde(rename = "baseURL", skip_serializing_if = "Option::is_none")]
314 pub base_url: Option<String>,
315
316 #[serde(flatten)]
318 pub extra: HashMap<String, serde_json::Value>,
319}
320
321#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
323pub struct ProviderConfig {
324 pub models: HashMap<String, ProviderModelConfig>,
326
327 #[serde(skip_serializing_if = "Option::is_none")]
329 pub id: Option<String>,
330
331 #[serde(skip_serializing_if = "Option::is_none")]
333 pub api: Option<String>,
334
335 #[serde(skip_serializing_if = "Option::is_none")]
337 pub env: Option<Vec<String>>,
338
339 #[serde(skip_serializing_if = "Option::is_none")]
341 pub name: Option<String>,
342
343 #[serde(skip_serializing_if = "Option::is_none")]
345 pub npm: Option<String>,
346
347 #[serde(skip_serializing_if = "Option::is_none")]
349 pub options: Option<ProviderOptions>,
350}
351
352#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
358#[serde(rename_all = "lowercase")]
359pub enum ShareMode {
360 Manual,
362 Auto,
364 Disabled,
366}
367
368#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
370#[serde(rename_all = "lowercase")]
371pub enum Layout {
372 Auto,
374 Stretch,
376}
377
378pub type ModeMap = HashMap<String, ModeConfig>;
384
385#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
393pub struct Config {
394 #[serde(rename = "$schema", skip_serializing_if = "Option::is_none")]
396 pub schema: Option<String>,
397
398 #[serde(skip_serializing_if = "Option::is_none")]
400 pub agent: Option<Agent>,
401
402 #[serde(skip_serializing_if = "Option::is_none")]
404 pub autoshare: Option<bool>,
405
406 #[serde(skip_serializing_if = "Option::is_none")]
409 pub autoupdate: Option<serde_json::Value>,
410
411 #[serde(skip_serializing_if = "Option::is_none")]
413 pub disabled_providers: Option<Vec<String>>,
414
415 #[serde(skip_serializing_if = "Option::is_none")]
417 pub experimental: Option<Experimental>,
418
419 #[serde(skip_serializing_if = "Option::is_none")]
421 pub instructions: Option<Vec<String>>,
422
423 #[serde(skip_serializing_if = "Option::is_none")]
425 pub keybinds: Option<KeybindsConfig>,
426
427 #[serde(skip_serializing_if = "Option::is_none")]
429 pub layout: Option<Layout>,
430
431 #[serde(skip_serializing_if = "Option::is_none")]
433 pub mcp: Option<HashMap<String, McpConfig>>,
434
435 #[serde(skip_serializing_if = "Option::is_none")]
437 pub mode: Option<ModeMap>,
438
439 #[serde(skip_serializing_if = "Option::is_none")]
441 pub model: Option<String>,
442
443 #[serde(skip_serializing_if = "Option::is_none")]
445 pub provider: Option<HashMap<String, ProviderConfig>>,
446
447 #[serde(skip_serializing_if = "Option::is_none")]
449 pub share: Option<ShareMode>,
450
451 #[serde(skip_serializing_if = "Option::is_none")]
453 pub small_model: Option<String>,
454
455 #[serde(skip_serializing_if = "Option::is_none")]
457 pub theme: Option<String>,
458
459 #[serde(skip_serializing_if = "Option::is_none")]
461 pub username: Option<String>,
462}
463
464#[derive(Debug, Clone)]
470pub struct ConfigResource<'a> {
471 client: &'a Opencode,
472}
473
474impl<'a> ConfigResource<'a> {
475 pub(crate) const fn new(client: &'a Opencode) -> Self {
477 Self { client }
478 }
479
480 pub async fn get(&self, options: Option<&RequestOptions>) -> Result<Config, OpencodeError> {
482 self.client.get("/config", options).await
483 }
484}
485
486#[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 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 #[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 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}