1use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, Serialize, Deserialize)]
12#[serde(default)]
13#[derive(Default)]
14pub struct Config {
15 pub api: ApiConfig,
16 pub permissions: PermissionsConfig,
17 pub ui: UiConfig,
18 #[serde(default)]
20 pub features: FeaturesConfig,
21 #[serde(default)]
23 pub mcp_servers: std::collections::HashMap<String, McpServerEntry>,
24 #[serde(default)]
26 pub hooks: Vec<HookDefinition>,
27 #[serde(default)]
29 pub security: SecurityConfig,
30 #[serde(default)]
32 pub sandbox: SandboxConfig,
33}
34
35#[derive(Debug, Clone, Default, Serialize, Deserialize)]
37#[serde(default)]
38pub struct SecurityConfig {
39 #[serde(default)]
41 pub additional_directories: Vec<String>,
42 #[serde(default)]
44 pub mcp_server_allowlist: Vec<String>,
45 #[serde(default)]
47 pub mcp_server_denylist: Vec<String>,
48 #[serde(default)]
50 pub disable_bypass_permissions: bool,
51 #[serde(default)]
53 pub env_allowlist: Vec<String>,
54 #[serde(default)]
56 pub disable_skill_shell_execution: bool,
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
68#[serde(default)]
69pub struct SandboxConfig {
70 pub enabled: bool,
72 pub strategy: String,
77 pub allowed_write_paths: Vec<String>,
81 pub forbidden_paths: Vec<String>,
84 pub allow_network: bool,
86}
87
88impl Default for SandboxConfig {
89 fn default() -> Self {
90 Self {
91 enabled: false,
92 strategy: "auto".to_string(),
93 allowed_write_paths: vec!["/tmp".to_string(), "~/.cache/agent-code".to_string()],
94 forbidden_paths: vec![
95 "~/.ssh".to_string(),
96 "~/.aws".to_string(),
97 "~/.gnupg".to_string(),
98 ],
99 allow_network: true,
100 }
101 }
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize)]
106pub struct McpServerEntry {
107 pub command: Option<String>,
109 #[serde(default)]
111 pub args: Vec<String>,
112 pub url: Option<String>,
114 #[serde(default)]
116 pub env: std::collections::HashMap<String, String>,
117}
118
119#[derive(Debug, Clone, Serialize, Deserialize)]
125#[serde(default)]
126pub struct ApiConfig {
127 pub base_url: String,
129 pub model: String,
131 #[serde(skip_serializing)]
134 pub api_key: Option<String>,
135 pub max_output_tokens: Option<u32>,
137 pub thinking: Option<String>,
139 pub effort: Option<String>,
141 pub max_cost_usd: Option<f64>,
143 pub timeout_secs: u64,
145 pub max_retries: u32,
147}
148
149impl Default for ApiConfig {
150 fn default() -> Self {
151 let api_key = std::env::var("AGENT_CODE_API_KEY")
153 .or_else(|_| std::env::var("ANTHROPIC_API_KEY"))
154 .or_else(|_| std::env::var("OPENAI_API_KEY"))
155 .or_else(|_| std::env::var("AZURE_OPENAI_API_KEY"))
156 .or_else(|_| std::env::var("XAI_API_KEY"))
157 .or_else(|_| std::env::var("GOOGLE_API_KEY"))
158 .or_else(|_| std::env::var("DEEPSEEK_API_KEY"))
159 .or_else(|_| std::env::var("GROQ_API_KEY"))
160 .or_else(|_| std::env::var("MISTRAL_API_KEY"))
161 .or_else(|_| std::env::var("ZHIPU_API_KEY"))
162 .or_else(|_| std::env::var("TOGETHER_API_KEY"))
163 .or_else(|_| std::env::var("OPENROUTER_API_KEY"))
164 .or_else(|_| std::env::var("COHERE_API_KEY"))
165 .or_else(|_| std::env::var("PERPLEXITY_API_KEY"))
166 .ok();
167
168 let use_bedrock = std::env::var("AGENT_CODE_USE_BEDROCK").is_ok()
171 || std::env::var("AWS_REGION").is_ok() && api_key.is_some();
172 let use_vertex = std::env::var("AGENT_CODE_USE_VERTEX").is_ok();
173 let use_azure = std::env::var("AZURE_OPENAI_ENDPOINT").is_ok()
174 || std::env::var("AZURE_OPENAI_API_KEY").is_ok();
175
176 let has_generic = std::env::var("AGENT_CODE_API_KEY").is_ok();
177 let base_url = if use_bedrock {
178 let region = std::env::var("AWS_REGION").unwrap_or_else(|_| "us-east-1".to_string());
180 format!("https://bedrock-runtime.{region}.amazonaws.com")
181 } else if use_vertex {
182 let project = std::env::var("GOOGLE_CLOUD_PROJECT").unwrap_or_default();
184 let location = std::env::var("GOOGLE_CLOUD_LOCATION")
185 .unwrap_or_else(|_| "us-central1".to_string());
186 format!(
187 "https://{location}-aiplatform.googleapis.com/v1/projects/{project}/locations/{location}/publishers/anthropic/models"
188 )
189 } else if use_azure {
190 std::env::var("AZURE_OPENAI_ENDPOINT").unwrap_or_else(|_| {
192 "https://YOUR_RESOURCE.openai.azure.com/openai/deployments/YOUR_DEPLOYMENT"
193 .to_string()
194 })
195 } else if has_generic {
196 "https://api.openai.com/v1".to_string()
198 } else if std::env::var("GOOGLE_API_KEY").is_ok() {
199 "https://generativelanguage.googleapis.com/v1beta/openai".to_string()
200 } else if std::env::var("DEEPSEEK_API_KEY").is_ok() {
201 "https://api.deepseek.com/v1".to_string()
202 } else if std::env::var("XAI_API_KEY").is_ok() {
203 "https://api.x.ai/v1".to_string()
204 } else if std::env::var("GROQ_API_KEY").is_ok() {
205 "https://api.groq.com/openai/v1".to_string()
206 } else if std::env::var("MISTRAL_API_KEY").is_ok() {
207 "https://api.mistral.ai/v1".to_string()
208 } else if std::env::var("TOGETHER_API_KEY").is_ok() {
209 "https://api.together.xyz/v1".to_string()
210 } else if std::env::var("OPENROUTER_API_KEY").is_ok() {
211 "https://openrouter.ai/api/v1".to_string()
212 } else if std::env::var("COHERE_API_KEY").is_ok() {
213 "https://api.cohere.com/v2".to_string()
214 } else if std::env::var("PERPLEXITY_API_KEY").is_ok() {
215 "https://api.perplexity.ai".to_string()
216 } else {
217 "https://api.openai.com/v1".to_string()
219 };
220
221 Self {
222 base_url,
223 model: "gpt-5.4".to_string(),
224 api_key,
225 max_output_tokens: Some(16384),
226 thinking: None,
227 effort: None,
228 max_cost_usd: None,
229 timeout_secs: 120,
230 max_retries: 3,
231 }
232 }
233}
234
235#[derive(Debug, Clone, Serialize, Deserialize)]
237#[serde(default)]
238pub struct PermissionsConfig {
239 pub default_mode: PermissionMode,
241 pub rules: Vec<PermissionRule>,
243}
244
245impl Default for PermissionsConfig {
246 fn default() -> Self {
247 Self {
248 default_mode: PermissionMode::Ask,
249 rules: Vec::new(),
250 }
251 }
252}
253
254#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
258#[serde(rename_all = "snake_case")]
259pub enum PermissionMode {
260 Allow,
262 Deny,
264 Ask,
266 AcceptEdits,
268 Plan,
270}
271
272#[derive(Debug, Clone, Serialize, Deserialize)]
274pub struct PermissionRule {
275 pub tool: String,
277 pub pattern: Option<String>,
279 pub action: PermissionMode,
281}
282
283#[derive(Debug, Clone, Serialize, Deserialize)]
285#[serde(default)]
286pub struct UiConfig {
287 pub markdown: bool,
289 pub syntax_highlight: bool,
291 pub theme: String,
293 pub edit_mode: String,
295}
296
297impl Default for UiConfig {
298 fn default() -> Self {
299 Self {
300 markdown: true,
301 syntax_highlight: true,
302 theme: "dark".to_string(),
303 edit_mode: "emacs".to_string(),
304 }
305 }
306}
307
308#[derive(Debug, Clone, Serialize, Deserialize)]
311#[serde(default)]
312pub struct FeaturesConfig {
313 pub token_budget: bool,
315 pub commit_attribution: bool,
317 pub compaction_reminders: bool,
319 pub unattended_retry: bool,
321 pub history_snip: bool,
323 pub auto_theme: bool,
325 pub mcp_rich_output: bool,
327 pub fork_conversation: bool,
329 pub verification_agent: bool,
331 pub extract_memories: bool,
333 pub context_collapse: bool,
335 pub reactive_compact: bool,
337 pub prompt_caching: bool,
340}
341
342impl Default for FeaturesConfig {
343 fn default() -> Self {
344 Self {
345 token_budget: true,
346 commit_attribution: true,
347 compaction_reminders: true,
348 unattended_retry: true,
349 history_snip: true,
350 auto_theme: true,
351 mcp_rich_output: true,
352 fork_conversation: true,
353 verification_agent: true,
354 extract_memories: true,
355 context_collapse: true,
356 reactive_compact: true,
357 prompt_caching: true,
358 }
359 }
360}
361
362#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
366#[serde(rename_all = "snake_case")]
367pub enum HookEvent {
368 SessionStart,
369 SessionStop,
370 PreToolUse,
371 PostToolUse,
372 UserPromptSubmit,
373}
374
375#[derive(Debug, Clone, Serialize, Deserialize)]
377#[serde(tag = "type")]
378pub enum HookAction {
379 #[serde(rename = "shell")]
381 Shell { command: String },
382 #[serde(rename = "http")]
384 Http { url: String, method: Option<String> },
385}
386
387#[derive(Debug, Clone, Serialize, Deserialize)]
389pub struct HookDefinition {
390 pub event: HookEvent,
391 pub action: HookAction,
392 pub tool_name: Option<String>,
394}
395
396#[cfg(test)]
397mod tests {
398 use super::*;
399
400 #[test]
403 fn api_config_default_model() {
404 let cfg = ApiConfig::default();
405 assert_eq!(cfg.model, "gpt-5.4");
406 }
407
408 #[test]
409 fn api_config_default_timeout() {
410 let cfg = ApiConfig::default();
411 assert_eq!(cfg.timeout_secs, 120);
412 }
413
414 #[test]
415 fn api_config_default_max_retries() {
416 let cfg = ApiConfig::default();
417 assert_eq!(cfg.max_retries, 3);
418 }
419
420 #[test]
421 fn api_config_default_max_output_tokens() {
422 let cfg = ApiConfig::default();
423 assert_eq!(cfg.max_output_tokens, Some(16384));
424 }
425
426 #[test]
427 fn api_config_default_base_url_contains_scheme() {
428 let cfg = ApiConfig::default();
429 assert!(
430 cfg.base_url.starts_with("https://"),
431 "base_url should start with https://, got: {}",
432 cfg.base_url
433 );
434 }
435
436 #[test]
437 fn api_config_default_thinking_is_none() {
438 let cfg = ApiConfig::default();
439 assert!(cfg.thinking.is_none());
440 }
441
442 #[test]
443 fn api_config_default_effort_is_none() {
444 let cfg = ApiConfig::default();
445 assert!(cfg.effort.is_none());
446 }
447
448 #[test]
449 fn api_config_default_max_cost_is_none() {
450 let cfg = ApiConfig::default();
451 assert!(cfg.max_cost_usd.is_none());
452 }
453
454 #[test]
457 fn permissions_config_default_mode_is_ask() {
458 let cfg = PermissionsConfig::default();
459 assert_eq!(cfg.default_mode, PermissionMode::Ask);
460 }
461
462 #[test]
463 fn permissions_config_default_rules_empty() {
464 let cfg = PermissionsConfig::default();
465 assert!(cfg.rules.is_empty());
466 }
467
468 #[test]
471 fn ui_config_default_markdown_true() {
472 let cfg = UiConfig::default();
473 assert!(cfg.markdown);
474 }
475
476 #[test]
477 fn ui_config_default_syntax_highlight_true() {
478 let cfg = UiConfig::default();
479 assert!(cfg.syntax_highlight);
480 }
481
482 #[test]
483 fn ui_config_default_theme_dark() {
484 let cfg = UiConfig::default();
485 assert_eq!(cfg.theme, "dark");
486 }
487
488 #[test]
489 fn ui_config_default_edit_mode_emacs() {
490 let cfg = UiConfig::default();
491 assert_eq!(cfg.edit_mode, "emacs");
492 }
493
494 #[test]
497 fn features_config_default_all_true() {
498 let cfg = FeaturesConfig::default();
499 assert!(cfg.token_budget);
500 assert!(cfg.commit_attribution);
501 assert!(cfg.compaction_reminders);
502 assert!(cfg.unattended_retry);
503 assert!(cfg.history_snip);
504 assert!(cfg.auto_theme);
505 assert!(cfg.mcp_rich_output);
506 assert!(cfg.fork_conversation);
507 assert!(cfg.verification_agent);
508 assert!(cfg.extract_memories);
509 assert!(cfg.context_collapse);
510 assert!(cfg.reactive_compact);
511 }
512
513 #[test]
516 fn security_config_default_empty_vecs() {
517 let cfg = SecurityConfig::default();
518 assert!(cfg.additional_directories.is_empty());
519 assert!(cfg.mcp_server_allowlist.is_empty());
520 assert!(cfg.mcp_server_denylist.is_empty());
521 assert!(cfg.env_allowlist.is_empty());
522 }
523
524 #[test]
525 fn security_config_default_booleans_false() {
526 let cfg = SecurityConfig::default();
527 assert!(!cfg.disable_bypass_permissions);
528 assert!(!cfg.disable_skill_shell_execution);
529 }
530
531 #[test]
534 fn config_default_composes_sub_defaults() {
535 let cfg = Config::default();
536 assert_eq!(cfg.api.model, "gpt-5.4");
537 assert_eq!(cfg.permissions.default_mode, PermissionMode::Ask);
538 assert!(cfg.ui.markdown);
539 assert!(cfg.features.token_budget);
540 assert!(cfg.mcp_servers.is_empty());
541 assert!(cfg.hooks.is_empty());
542 assert!(cfg.security.additional_directories.is_empty());
543 }
544
545 #[test]
548 fn permission_mode_serde_roundtrip_allow() {
549 let json = serde_json::to_string(&PermissionMode::Allow).unwrap();
550 assert_eq!(json, "\"allow\"");
551 let back: PermissionMode = serde_json::from_str(&json).unwrap();
552 assert_eq!(back, PermissionMode::Allow);
553 }
554
555 #[test]
556 fn permission_mode_serde_roundtrip_deny() {
557 let json = serde_json::to_string(&PermissionMode::Deny).unwrap();
558 assert_eq!(json, "\"deny\"");
559 let back: PermissionMode = serde_json::from_str(&json).unwrap();
560 assert_eq!(back, PermissionMode::Deny);
561 }
562
563 #[test]
564 fn permission_mode_serde_roundtrip_ask() {
565 let json = serde_json::to_string(&PermissionMode::Ask).unwrap();
566 assert_eq!(json, "\"ask\"");
567 let back: PermissionMode = serde_json::from_str(&json).unwrap();
568 assert_eq!(back, PermissionMode::Ask);
569 }
570
571 #[test]
572 fn permission_mode_serde_roundtrip_accept_edits() {
573 let json = serde_json::to_string(&PermissionMode::AcceptEdits).unwrap();
574 assert_eq!(json, "\"accept_edits\"");
575 let back: PermissionMode = serde_json::from_str(&json).unwrap();
576 assert_eq!(back, PermissionMode::AcceptEdits);
577 }
578
579 #[test]
580 fn permission_mode_serde_roundtrip_plan() {
581 let json = serde_json::to_string(&PermissionMode::Plan).unwrap();
582 assert_eq!(json, "\"plan\"");
583 let back: PermissionMode = serde_json::from_str(&json).unwrap();
584 assert_eq!(back, PermissionMode::Plan);
585 }
586
587 #[test]
590 fn hook_event_serde_roundtrip_session_start() {
591 let json = serde_json::to_string(&HookEvent::SessionStart).unwrap();
592 assert_eq!(json, "\"session_start\"");
593 let back: HookEvent = serde_json::from_str(&json).unwrap();
594 assert_eq!(back, HookEvent::SessionStart);
595 }
596
597 #[test]
598 fn hook_event_serde_roundtrip_session_stop() {
599 let json = serde_json::to_string(&HookEvent::SessionStop).unwrap();
600 assert_eq!(json, "\"session_stop\"");
601 let back: HookEvent = serde_json::from_str(&json).unwrap();
602 assert_eq!(back, HookEvent::SessionStop);
603 }
604
605 #[test]
606 fn hook_event_serde_roundtrip_pre_tool_use() {
607 let json = serde_json::to_string(&HookEvent::PreToolUse).unwrap();
608 assert_eq!(json, "\"pre_tool_use\"");
609 let back: HookEvent = serde_json::from_str(&json).unwrap();
610 assert_eq!(back, HookEvent::PreToolUse);
611 }
612
613 #[test]
614 fn hook_event_serde_roundtrip_post_tool_use() {
615 let json = serde_json::to_string(&HookEvent::PostToolUse).unwrap();
616 assert_eq!(json, "\"post_tool_use\"");
617 let back: HookEvent = serde_json::from_str(&json).unwrap();
618 assert_eq!(back, HookEvent::PostToolUse);
619 }
620
621 #[test]
622 fn hook_event_serde_roundtrip_user_prompt_submit() {
623 let json = serde_json::to_string(&HookEvent::UserPromptSubmit).unwrap();
624 assert_eq!(json, "\"user_prompt_submit\"");
625 let back: HookEvent = serde_json::from_str(&json).unwrap();
626 assert_eq!(back, HookEvent::UserPromptSubmit);
627 }
628
629 #[test]
632 fn hook_action_serde_roundtrip_shell() {
633 let action = HookAction::Shell {
634 command: "echo hello".into(),
635 };
636 let json = serde_json::to_string(&action).unwrap();
637 assert!(json.contains("\"type\":\"shell\""));
638 assert!(json.contains("\"command\":\"echo hello\""));
639 let back: HookAction = serde_json::from_str(&json).unwrap();
640 match back {
641 HookAction::Shell { command } => assert_eq!(command, "echo hello"),
642 _ => panic!("expected Shell variant"),
643 }
644 }
645
646 #[test]
647 fn hook_action_serde_roundtrip_http() {
648 let action = HookAction::Http {
649 url: "https://example.com/hook".into(),
650 method: Some("POST".into()),
651 };
652 let json = serde_json::to_string(&action).unwrap();
653 assert!(json.contains("\"type\":\"http\""));
654 let back: HookAction = serde_json::from_str(&json).unwrap();
655 match back {
656 HookAction::Http { url, method } => {
657 assert_eq!(url, "https://example.com/hook");
658 assert_eq!(method.unwrap(), "POST");
659 }
660 _ => panic!("expected Http variant"),
661 }
662 }
663
664 #[test]
665 fn hook_action_http_method_none() {
666 let action = HookAction::Http {
667 url: "https://example.com".into(),
668 method: None,
669 };
670 let json = serde_json::to_string(&action).unwrap();
671 let back: HookAction = serde_json::from_str(&json).unwrap();
672 match back {
673 HookAction::Http { method, .. } => assert!(method.is_none()),
674 _ => panic!("expected Http variant"),
675 }
676 }
677
678 #[test]
681 fn hook_definition_serde_roundtrip() {
682 let def = HookDefinition {
683 event: HookEvent::PreToolUse,
684 action: HookAction::Shell {
685 command: "lint.sh".into(),
686 },
687 tool_name: Some("Bash".into()),
688 };
689 let json = serde_json::to_string(&def).unwrap();
690 let back: HookDefinition = serde_json::from_str(&json).unwrap();
691 assert_eq!(back.event, HookEvent::PreToolUse);
692 assert_eq!(back.tool_name, Some("Bash".into()));
693 }
694
695 #[test]
696 fn hook_definition_tool_name_none() {
697 let def = HookDefinition {
698 event: HookEvent::SessionStart,
699 action: HookAction::Shell {
700 command: "setup.sh".into(),
701 },
702 tool_name: None,
703 };
704 let json = serde_json::to_string(&def).unwrap();
705 let back: HookDefinition = serde_json::from_str(&json).unwrap();
706 assert!(back.tool_name.is_none());
707 }
708
709 #[test]
712 fn config_toml_deserialization_full() {
713 let toml_str = r#"
714[api]
715model = "test-model"
716timeout_secs = 60
717max_retries = 5
718base_url = "https://api.test.com/v1"
719
720[permissions]
721default_mode = "allow"
722
723[ui]
724markdown = false
725syntax_highlight = false
726theme = "light"
727edit_mode = "vi"
728
729[features]
730token_budget = false
731commit_attribution = false
732
733[security]
734disable_bypass_permissions = true
735additional_directories = ["/tmp"]
736"#;
737 let cfg: Config = toml::from_str(toml_str).unwrap();
738 assert_eq!(cfg.api.model, "test-model");
739 assert_eq!(cfg.api.timeout_secs, 60);
740 assert_eq!(cfg.api.max_retries, 5);
741 assert_eq!(cfg.api.base_url, "https://api.test.com/v1");
742 assert_eq!(cfg.permissions.default_mode, PermissionMode::Allow);
743 assert!(!cfg.ui.markdown);
744 assert!(!cfg.ui.syntax_highlight);
745 assert_eq!(cfg.ui.theme, "light");
746 assert_eq!(cfg.ui.edit_mode, "vi");
747 assert!(!cfg.features.token_budget);
748 assert!(!cfg.features.commit_attribution);
749 assert!(cfg.security.disable_bypass_permissions);
750 assert_eq!(cfg.security.additional_directories, vec!["/tmp"]);
751 }
752
753 #[test]
754 fn config_toml_empty_string_uses_defaults() {
755 let cfg: Config = toml::from_str("").unwrap();
756 assert_eq!(cfg.api.timeout_secs, 120);
757 assert_eq!(cfg.permissions.default_mode, PermissionMode::Ask);
758 assert!(cfg.ui.markdown);
759 }
760
761 #[test]
762 fn config_toml_partial_override() {
763 let toml_str = r#"
764[ui]
765theme = "solarized"
766"#;
767 let cfg: Config = toml::from_str(toml_str).unwrap();
768 assert_eq!(cfg.ui.theme, "solarized");
770 assert!(cfg.ui.markdown);
772 assert!(cfg.ui.syntax_highlight);
773 assert_eq!(cfg.ui.edit_mode, "emacs");
774 }
775
776 #[test]
779 fn mcp_server_entry_with_command() {
780 let json = r#"{"command": "npx mcp-server", "args": ["--port", "3000"]}"#;
781 let entry: McpServerEntry = serde_json::from_str(json).unwrap();
782 assert_eq!(entry.command, Some("npx mcp-server".into()));
783 assert_eq!(entry.args, vec!["--port", "3000"]);
784 assert!(entry.url.is_none());
785 }
786
787 #[test]
788 fn mcp_server_entry_with_url() {
789 let json = r#"{"url": "https://mcp.example.com/sse"}"#;
790 let entry: McpServerEntry = serde_json::from_str(json).unwrap();
791 assert!(entry.command.is_none());
792 assert_eq!(entry.url, Some("https://mcp.example.com/sse".into()));
793 assert!(entry.args.is_empty());
794 }
795
796 #[test]
797 fn mcp_server_entry_with_env() {
798 let json = r#"{"command": "server", "env": {"TOKEN": "abc"}}"#;
799 let entry: McpServerEntry = serde_json::from_str(json).unwrap();
800 assert_eq!(entry.env.get("TOKEN").unwrap(), "abc");
801 }
802
803 #[test]
806 fn permission_rule_serde_roundtrip_with_pattern() {
807 let rule = PermissionRule {
808 tool: "Bash".into(),
809 pattern: Some("rm -rf *".into()),
810 action: PermissionMode::Deny,
811 };
812 let json = serde_json::to_string(&rule).unwrap();
813 let back: PermissionRule = serde_json::from_str(&json).unwrap();
814 assert_eq!(back.tool, "Bash");
815 assert_eq!(back.pattern, Some("rm -rf *".into()));
816 assert_eq!(back.action, PermissionMode::Deny);
817 }
818
819 #[test]
820 fn permission_rule_serde_roundtrip_without_pattern() {
821 let rule = PermissionRule {
822 tool: "Read".into(),
823 pattern: None,
824 action: PermissionMode::Allow,
825 };
826 let json = serde_json::to_string(&rule).unwrap();
827 let back: PermissionRule = serde_json::from_str(&json).unwrap();
828 assert_eq!(back.tool, "Read");
829 assert!(back.pattern.is_none());
830 assert_eq!(back.action, PermissionMode::Allow);
831 }
832
833 #[test]
836 fn config_toml_with_hooks() {
837 let toml_str = r#"
838[[hooks]]
839event = "session_start"
840tool_name = "Bash"
841
842[hooks.action]
843type = "shell"
844command = "echo starting"
845"#;
846 let cfg: Config = toml::from_str(toml_str).unwrap();
847 assert_eq!(cfg.hooks.len(), 1);
848 assert_eq!(cfg.hooks[0].event, HookEvent::SessionStart);
849 assert_eq!(cfg.hooks[0].tool_name, Some("Bash".into()));
850 }
851
852 #[test]
855 fn config_toml_with_mcp_servers() {
856 let toml_str = r#"
857[mcp_servers.my_server]
858command = "npx my-mcp"
859args = ["--flag"]
860"#;
861 let cfg: Config = toml::from_str(toml_str).unwrap();
862 assert!(cfg.mcp_servers.contains_key("my_server"));
863 let server = &cfg.mcp_servers["my_server"];
864 assert_eq!(server.command, Some("npx my-mcp".into()));
865 assert_eq!(server.args, vec!["--flag"]);
866 }
867}