Skip to main content

agent_code_lib/config/
schema.rs

1//! Configuration schema definitions.
2
3use serde::{Deserialize, Serialize};
4
5/// Top-level configuration for the agent.
6///
7/// Loaded from three layers (highest priority first):
8/// 1. CLI flags and environment variables
9/// 2. Project config (`.agent/settings.toml`)
10/// 3. User config (`~/.config/agent-code/config.toml`)
11#[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    /// Feature flags — all enabled by default.
19    #[serde(default)]
20    pub features: FeaturesConfig,
21    /// MCP server configurations.
22    #[serde(default)]
23    pub mcp_servers: std::collections::HashMap<String, McpServerEntry>,
24    /// Lifecycle hooks.
25    #[serde(default)]
26    pub hooks: Vec<HookDefinition>,
27    /// Security and enterprise settings.
28    #[serde(default)]
29    pub security: SecurityConfig,
30    /// Process-level sandbox settings.
31    #[serde(default)]
32    pub sandbox: SandboxConfig,
33}
34
35/// Security and enterprise configuration.
36#[derive(Debug, Clone, Default, Serialize, Deserialize)]
37#[serde(default)]
38pub struct SecurityConfig {
39    /// Additional directories the agent can access (beyond cwd).
40    #[serde(default)]
41    pub additional_directories: Vec<String>,
42    /// MCP server allowlist. If non-empty, only listed servers can connect.
43    #[serde(default)]
44    pub mcp_server_allowlist: Vec<String>,
45    /// MCP server denylist. Listed servers are blocked from connecting.
46    #[serde(default)]
47    pub mcp_server_denylist: Vec<String>,
48    /// Disable the --dangerously-skip-permissions flag.
49    #[serde(default)]
50    pub disable_bypass_permissions: bool,
51    /// Restrict which environment variables the agent can read.
52    #[serde(default)]
53    pub env_allowlist: Vec<String>,
54    /// Disable inline shell execution within skill templates.
55    #[serde(default)]
56    pub disable_skill_shell_execution: bool,
57}
58
59/// Process-level sandbox configuration.
60///
61/// When `enabled` is true, subprocess-spawning tools (currently the Bash
62/// tool) wrap their child process with an OS-level isolation mechanism:
63/// `sandbox-exec` on macOS, future strategies on Linux/Windows. Defaults
64/// ship the sandbox **disabled** while Linux and Windows strategies land,
65/// so that opt-in users on macOS can exercise the integration without
66/// asymmetric platform security.
67#[derive(Debug, Clone, Serialize, Deserialize)]
68#[serde(default)]
69pub struct SandboxConfig {
70    /// Whether process-level sandboxing is enabled for subprocess tools.
71    pub enabled: bool,
72    /// Strategy selector: `"auto"`, `"seatbelt"`, or `"none"`.
73    ///
74    /// `"auto"` picks the best available strategy for the host OS and
75    /// falls back to `"none"` with a warning when no strategy is available.
76    pub strategy: String,
77    /// Absolute or `~`-prefixed paths that the sandbox may write to in
78    /// addition to the project directory. Relative paths are resolved
79    /// against the project directory.
80    pub allowed_write_paths: Vec<String>,
81    /// Paths the sandbox must never read. Overrides the default
82    /// broad-read allow rule so credentials stay masked.
83    pub forbidden_paths: Vec<String>,
84    /// Whether subprocesses in the sandbox can open network sockets.
85    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/// Entry for a configured MCP server.
105#[derive(Debug, Clone, Serialize, Deserialize)]
106pub struct McpServerEntry {
107    /// Command to run (for stdio transport).
108    pub command: Option<String>,
109    /// Arguments for the command.
110    #[serde(default)]
111    pub args: Vec<String>,
112    /// URL (for SSE transport).
113    pub url: Option<String>,
114    /// Environment variables for the server process.
115    #[serde(default)]
116    pub env: std::collections::HashMap<String, String>,
117}
118
119/// API connection settings.
120///
121/// Configures the LLM provider: base URL, model, API key, timeouts,
122/// cost limits, and thinking mode. The API key is resolved from
123/// multiple sources (env vars, config file, CLI flag).
124#[derive(Debug, Clone, Serialize, Deserialize)]
125#[serde(default)]
126pub struct ApiConfig {
127    /// Base URL for the LLM API.
128    pub base_url: String,
129    /// Model identifier.
130    pub model: String,
131    /// API key. Resolved from (in order): config, AGENT_CODE_API_KEY,
132    /// ANTHROPIC_API_KEY, OPENAI_API_KEY env vars.
133    #[serde(skip_serializing)]
134    pub api_key: Option<String>,
135    /// Maximum output tokens per response.
136    pub max_output_tokens: Option<u32>,
137    /// Thinking mode: "enabled", "disabled", or "adaptive".
138    pub thinking: Option<String>,
139    /// Effort level: "low", "medium", "high".
140    pub effort: Option<String>,
141    /// Maximum spend per session in USD.
142    pub max_cost_usd: Option<f64>,
143    /// Request timeout in seconds.
144    pub timeout_secs: u64,
145    /// Maximum retry attempts for transient errors.
146    pub max_retries: u32,
147}
148
149impl Default for ApiConfig {
150    fn default() -> Self {
151        // Resolve API key from multiple environment variables.
152        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        // Auto-detect base URL from which key is set.
169        // Check for cloud provider env vars first.
170        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            // AWS Bedrock — URL constructed from region.
179            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            // Google Vertex AI.
183            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            // Azure OpenAI — URL from AZURE_OPENAI_ENDPOINT or placeholder.
191            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            // Generic key — default to OpenAI (default model is gpt-5.4).
197            "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            // Default to OpenAI (default model is gpt-5.4).
218            "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/// Permission system configuration.
236#[derive(Debug, Clone, Serialize, Deserialize)]
237#[serde(default)]
238pub struct PermissionsConfig {
239    /// Default permission mode for tools without explicit rules.
240    pub default_mode: PermissionMode,
241    /// Per-tool permission rules.
242    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/// Permission mode controlling how tool calls are authorized.
255///
256/// Set globally via `[permissions] default_mode` or per-tool via rules.
257#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
258#[serde(rename_all = "snake_case")]
259pub enum PermissionMode {
260    /// Always allow without asking.
261    Allow,
262    /// Always deny.
263    Deny,
264    /// Ask the user interactively.
265    Ask,
266    /// Accept file edits automatically, ask for other mutations.
267    AcceptEdits,
268    /// Plan mode: read-only tools only.
269    Plan,
270}
271
272/// A single permission rule matching a tool and optional pattern.
273#[derive(Debug, Clone, Serialize, Deserialize)]
274pub struct PermissionRule {
275    /// Tool name to match.
276    pub tool: String,
277    /// Optional glob/regex pattern for the tool's input.
278    pub pattern: Option<String>,
279    /// Action to take when this rule matches.
280    pub action: PermissionMode,
281}
282
283/// UI configuration.
284#[derive(Debug, Clone, Serialize, Deserialize)]
285#[serde(default)]
286pub struct UiConfig {
287    /// Enable markdown rendering in output.
288    pub markdown: bool,
289    /// Enable syntax highlighting in code blocks.
290    pub syntax_highlight: bool,
291    /// Theme name.
292    pub theme: String,
293    /// Editing mode: "emacs" or "vi".
294    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/// Feature flags. All enabled by default — no artificial gates.
309/// Users can disable individual features in config.toml under [features].
310#[derive(Debug, Clone, Serialize, Deserialize)]
311#[serde(default)]
312pub struct FeaturesConfig {
313    /// Track per-turn token usage and warn when approaching budget.
314    pub token_budget: bool,
315    /// Add co-author attribution line to git commits.
316    pub commit_attribution: bool,
317    /// Show a system reminder after context compaction.
318    pub compaction_reminders: bool,
319    /// Auto retry on capacity/overload errors in non-interactive mode.
320    pub unattended_retry: bool,
321    /// Enable /snip command to remove message ranges from history.
322    pub history_snip: bool,
323    /// Auto-detect system dark/light mode for theme.
324    pub auto_theme: bool,
325    /// Rich formatting for MCP tool output.
326    pub mcp_rich_output: bool,
327    /// Enable /fork command to branch conversation.
328    pub fork_conversation: bool,
329    /// Verification agent that checks completed tasks.
330    pub verification_agent: bool,
331    /// Background memory extraction after each turn.
332    pub extract_memories: bool,
333    /// Context collapse (snip old messages) when approaching limits.
334    pub context_collapse: bool,
335    /// Reactive auto-compaction when token budget is tight.
336    pub reactive_compact: bool,
337    /// Enable prompt caching (system prompt + tools + conversation prefix).
338    /// Reduces cost by up to 90% for long sessions with supporting providers.
339    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// ---- Hook types (defined here so config has no runtime dependencies) ----
363
364/// Hook event types that can trigger user-defined actions.
365#[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/// A configured hook action.
376#[derive(Debug, Clone, Serialize, Deserialize)]
377#[serde(tag = "type")]
378pub enum HookAction {
379    /// Run a shell command.
380    #[serde(rename = "shell")]
381    Shell { command: String },
382    /// Make an HTTP request.
383    #[serde(rename = "http")]
384    Http { url: String, method: Option<String> },
385}
386
387/// A hook definition binding an event to an action.
388#[derive(Debug, Clone, Serialize, Deserialize)]
389pub struct HookDefinition {
390    pub event: HookEvent,
391    pub action: HookAction,
392    /// Optional tool name filter (for PreToolUse/PostToolUse).
393    pub tool_name: Option<String>,
394}
395
396#[cfg(test)]
397mod tests {
398    use super::*;
399
400    // ---- ApiConfig::default() ----
401
402    #[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    // ---- PermissionsConfig::default() ----
455
456    #[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    // ---- UiConfig::default() ----
469
470    #[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    // ---- FeaturesConfig::default() ----
495
496    #[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    // ---- SecurityConfig::default() ----
514
515    #[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    // ---- Config::default() composes sub-defaults ----
532
533    #[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    // ---- PermissionMode serde round-trip ----
546
547    #[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    // ---- HookEvent serde round-trip ----
588
589    #[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    // ---- HookAction serde round-trip ----
630
631    #[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    // ---- HookDefinition serde round-trip ----
679
680    #[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    // ---- Config TOML deserialization ----
710
711    #[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        // Overridden field
769        assert_eq!(cfg.ui.theme, "solarized");
770        // Other fields keep defaults
771        assert!(cfg.ui.markdown);
772        assert!(cfg.ui.syntax_highlight);
773        assert_eq!(cfg.ui.edit_mode, "emacs");
774    }
775
776    // ---- McpServerEntry ----
777
778    #[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    // ---- PermissionRule serialization ----
804
805    #[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    // ---- Config with hooks in TOML ----
834
835    #[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    // ---- Config with mcp_servers in TOML ----
853
854    #[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}