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}
31
32/// Security and enterprise configuration.
33#[derive(Debug, Clone, Default, Serialize, Deserialize)]
34#[serde(default)]
35pub struct SecurityConfig {
36    /// Additional directories the agent can access (beyond cwd).
37    #[serde(default)]
38    pub additional_directories: Vec<String>,
39    /// MCP server allowlist. If non-empty, only listed servers can connect.
40    #[serde(default)]
41    pub mcp_server_allowlist: Vec<String>,
42    /// MCP server denylist. Listed servers are blocked from connecting.
43    #[serde(default)]
44    pub mcp_server_denylist: Vec<String>,
45    /// Disable the --dangerously-skip-permissions flag.
46    #[serde(default)]
47    pub disable_bypass_permissions: bool,
48    /// Restrict which environment variables the agent can read.
49    #[serde(default)]
50    pub env_allowlist: Vec<String>,
51    /// Disable inline shell execution within skill templates.
52    #[serde(default)]
53    pub disable_skill_shell_execution: bool,
54}
55
56/// Entry for a configured MCP server.
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct McpServerEntry {
59    /// Command to run (for stdio transport).
60    pub command: Option<String>,
61    /// Arguments for the command.
62    #[serde(default)]
63    pub args: Vec<String>,
64    /// URL (for SSE transport).
65    pub url: Option<String>,
66    /// Environment variables for the server process.
67    #[serde(default)]
68    pub env: std::collections::HashMap<String, String>,
69}
70
71/// API connection settings.
72///
73/// Configures the LLM provider: base URL, model, API key, timeouts,
74/// cost limits, and thinking mode. The API key is resolved from
75/// multiple sources (env vars, config file, CLI flag).
76#[derive(Debug, Clone, Serialize, Deserialize)]
77#[serde(default)]
78pub struct ApiConfig {
79    /// Base URL for the LLM API.
80    pub base_url: String,
81    /// Model identifier.
82    pub model: String,
83    /// API key. Resolved from (in order): config, AGENT_CODE_API_KEY,
84    /// ANTHROPIC_API_KEY, OPENAI_API_KEY env vars.
85    #[serde(skip_serializing)]
86    pub api_key: Option<String>,
87    /// Maximum output tokens per response.
88    pub max_output_tokens: Option<u32>,
89    /// Thinking mode: "enabled", "disabled", or "adaptive".
90    pub thinking: Option<String>,
91    /// Effort level: "low", "medium", "high".
92    pub effort: Option<String>,
93    /// Maximum spend per session in USD.
94    pub max_cost_usd: Option<f64>,
95    /// Request timeout in seconds.
96    pub timeout_secs: u64,
97    /// Maximum retry attempts for transient errors.
98    pub max_retries: u32,
99}
100
101impl Default for ApiConfig {
102    fn default() -> Self {
103        // Resolve API key from multiple environment variables.
104        let api_key = std::env::var("AGENT_CODE_API_KEY")
105            .or_else(|_| std::env::var("ANTHROPIC_API_KEY"))
106            .or_else(|_| std::env::var("OPENAI_API_KEY"))
107            .or_else(|_| std::env::var("AZURE_OPENAI_API_KEY"))
108            .or_else(|_| std::env::var("XAI_API_KEY"))
109            .or_else(|_| std::env::var("GOOGLE_API_KEY"))
110            .or_else(|_| std::env::var("DEEPSEEK_API_KEY"))
111            .or_else(|_| std::env::var("GROQ_API_KEY"))
112            .or_else(|_| std::env::var("MISTRAL_API_KEY"))
113            .or_else(|_| std::env::var("ZHIPU_API_KEY"))
114            .or_else(|_| std::env::var("TOGETHER_API_KEY"))
115            .or_else(|_| std::env::var("OPENROUTER_API_KEY"))
116            .or_else(|_| std::env::var("COHERE_API_KEY"))
117            .or_else(|_| std::env::var("PERPLEXITY_API_KEY"))
118            .ok();
119
120        // Auto-detect base URL from which key is set.
121        // Check for cloud provider env vars first.
122        let use_bedrock = std::env::var("AGENT_CODE_USE_BEDROCK").is_ok()
123            || std::env::var("AWS_REGION").is_ok() && api_key.is_some();
124        let use_vertex = std::env::var("AGENT_CODE_USE_VERTEX").is_ok();
125        let use_azure = std::env::var("AZURE_OPENAI_ENDPOINT").is_ok()
126            || std::env::var("AZURE_OPENAI_API_KEY").is_ok();
127
128        let has_generic = std::env::var("AGENT_CODE_API_KEY").is_ok();
129        let base_url = if use_bedrock {
130            // AWS Bedrock — URL constructed from region.
131            let region = std::env::var("AWS_REGION").unwrap_or_else(|_| "us-east-1".to_string());
132            format!("https://bedrock-runtime.{region}.amazonaws.com")
133        } else if use_vertex {
134            // Google Vertex AI.
135            let project = std::env::var("GOOGLE_CLOUD_PROJECT").unwrap_or_default();
136            let location = std::env::var("GOOGLE_CLOUD_LOCATION")
137                .unwrap_or_else(|_| "us-central1".to_string());
138            format!(
139                "https://{location}-aiplatform.googleapis.com/v1/projects/{project}/locations/{location}/publishers/anthropic/models"
140            )
141        } else if use_azure {
142            // Azure OpenAI — URL from AZURE_OPENAI_ENDPOINT or placeholder.
143            std::env::var("AZURE_OPENAI_ENDPOINT").unwrap_or_else(|_| {
144                "https://YOUR_RESOURCE.openai.azure.com/openai/deployments/YOUR_DEPLOYMENT"
145                    .to_string()
146            })
147        } else if has_generic {
148            // Generic key — default to OpenAI (default model is gpt-5.4).
149            "https://api.openai.com/v1".to_string()
150        } else if std::env::var("GOOGLE_API_KEY").is_ok() {
151            "https://generativelanguage.googleapis.com/v1beta/openai".to_string()
152        } else if std::env::var("DEEPSEEK_API_KEY").is_ok() {
153            "https://api.deepseek.com/v1".to_string()
154        } else if std::env::var("XAI_API_KEY").is_ok() {
155            "https://api.x.ai/v1".to_string()
156        } else if std::env::var("GROQ_API_KEY").is_ok() {
157            "https://api.groq.com/openai/v1".to_string()
158        } else if std::env::var("MISTRAL_API_KEY").is_ok() {
159            "https://api.mistral.ai/v1".to_string()
160        } else if std::env::var("TOGETHER_API_KEY").is_ok() {
161            "https://api.together.xyz/v1".to_string()
162        } else if std::env::var("OPENROUTER_API_KEY").is_ok() {
163            "https://openrouter.ai/api/v1".to_string()
164        } else if std::env::var("COHERE_API_KEY").is_ok() {
165            "https://api.cohere.com/v2".to_string()
166        } else if std::env::var("PERPLEXITY_API_KEY").is_ok() {
167            "https://api.perplexity.ai".to_string()
168        } else {
169            // Default to OpenAI (default model is gpt-5.4).
170            "https://api.openai.com/v1".to_string()
171        };
172
173        Self {
174            base_url,
175            model: "gpt-5.4".to_string(),
176            api_key,
177            max_output_tokens: Some(16384),
178            thinking: None,
179            effort: None,
180            max_cost_usd: None,
181            timeout_secs: 120,
182            max_retries: 3,
183        }
184    }
185}
186
187/// Permission system configuration.
188#[derive(Debug, Clone, Serialize, Deserialize)]
189#[serde(default)]
190pub struct PermissionsConfig {
191    /// Default permission mode for tools without explicit rules.
192    pub default_mode: PermissionMode,
193    /// Per-tool permission rules.
194    pub rules: Vec<PermissionRule>,
195}
196
197impl Default for PermissionsConfig {
198    fn default() -> Self {
199        Self {
200            default_mode: PermissionMode::Ask,
201            rules: Vec::new(),
202        }
203    }
204}
205
206/// Permission mode controlling how tool calls are authorized.
207///
208/// Set globally via `[permissions] default_mode` or per-tool via rules.
209#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
210#[serde(rename_all = "snake_case")]
211pub enum PermissionMode {
212    /// Always allow without asking.
213    Allow,
214    /// Always deny.
215    Deny,
216    /// Ask the user interactively.
217    Ask,
218    /// Accept file edits automatically, ask for other mutations.
219    AcceptEdits,
220    /// Plan mode: read-only tools only.
221    Plan,
222}
223
224/// A single permission rule matching a tool and optional pattern.
225#[derive(Debug, Clone, Serialize, Deserialize)]
226pub struct PermissionRule {
227    /// Tool name to match.
228    pub tool: String,
229    /// Optional glob/regex pattern for the tool's input.
230    pub pattern: Option<String>,
231    /// Action to take when this rule matches.
232    pub action: PermissionMode,
233}
234
235/// UI configuration.
236#[derive(Debug, Clone, Serialize, Deserialize)]
237#[serde(default)]
238pub struct UiConfig {
239    /// Enable markdown rendering in output.
240    pub markdown: bool,
241    /// Enable syntax highlighting in code blocks.
242    pub syntax_highlight: bool,
243    /// Theme name.
244    pub theme: String,
245    /// Editing mode: "emacs" or "vi".
246    pub edit_mode: String,
247}
248
249impl Default for UiConfig {
250    fn default() -> Self {
251        Self {
252            markdown: true,
253            syntax_highlight: true,
254            theme: "dark".to_string(),
255            edit_mode: "emacs".to_string(),
256        }
257    }
258}
259
260/// Feature flags. All enabled by default — no artificial gates.
261/// Users can disable individual features in config.toml under [features].
262#[derive(Debug, Clone, Serialize, Deserialize)]
263#[serde(default)]
264pub struct FeaturesConfig {
265    /// Track per-turn token usage and warn when approaching budget.
266    pub token_budget: bool,
267    /// Add co-author attribution line to git commits.
268    pub commit_attribution: bool,
269    /// Show a system reminder after context compaction.
270    pub compaction_reminders: bool,
271    /// Auto retry on capacity/overload errors in non-interactive mode.
272    pub unattended_retry: bool,
273    /// Enable /snip command to remove message ranges from history.
274    pub history_snip: bool,
275    /// Auto-detect system dark/light mode for theme.
276    pub auto_theme: bool,
277    /// Rich formatting for MCP tool output.
278    pub mcp_rich_output: bool,
279    /// Enable /fork command to branch conversation.
280    pub fork_conversation: bool,
281    /// Verification agent that checks completed tasks.
282    pub verification_agent: bool,
283    /// Background memory extraction after each turn.
284    pub extract_memories: bool,
285    /// Context collapse (snip old messages) when approaching limits.
286    pub context_collapse: bool,
287    /// Reactive auto-compaction when token budget is tight.
288    pub reactive_compact: bool,
289    /// Enable prompt caching (system prompt + tools + conversation prefix).
290    /// Reduces cost by up to 90% for long sessions with supporting providers.
291    pub prompt_caching: bool,
292}
293
294impl Default for FeaturesConfig {
295    fn default() -> Self {
296        Self {
297            token_budget: true,
298            commit_attribution: true,
299            compaction_reminders: true,
300            unattended_retry: true,
301            history_snip: true,
302            auto_theme: true,
303            mcp_rich_output: true,
304            fork_conversation: true,
305            verification_agent: true,
306            extract_memories: true,
307            context_collapse: true,
308            reactive_compact: true,
309            prompt_caching: true,
310        }
311    }
312}
313
314// ---- Hook types (defined here so config has no runtime dependencies) ----
315
316/// Hook event types that can trigger user-defined actions.
317#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
318#[serde(rename_all = "snake_case")]
319pub enum HookEvent {
320    SessionStart,
321    SessionStop,
322    PreToolUse,
323    PostToolUse,
324    UserPromptSubmit,
325}
326
327/// A configured hook action.
328#[derive(Debug, Clone, Serialize, Deserialize)]
329#[serde(tag = "type")]
330pub enum HookAction {
331    /// Run a shell command.
332    #[serde(rename = "shell")]
333    Shell { command: String },
334    /// Make an HTTP request.
335    #[serde(rename = "http")]
336    Http { url: String, method: Option<String> },
337}
338
339/// A hook definition binding an event to an action.
340#[derive(Debug, Clone, Serialize, Deserialize)]
341pub struct HookDefinition {
342    pub event: HookEvent,
343    pub action: HookAction,
344    /// Optional tool name filter (for PreToolUse/PostToolUse).
345    pub tool_name: Option<String>,
346}
347
348#[cfg(test)]
349mod tests {
350    use super::*;
351
352    // ---- ApiConfig::default() ----
353
354    #[test]
355    fn api_config_default_model() {
356        let cfg = ApiConfig::default();
357        assert_eq!(cfg.model, "gpt-5.4");
358    }
359
360    #[test]
361    fn api_config_default_timeout() {
362        let cfg = ApiConfig::default();
363        assert_eq!(cfg.timeout_secs, 120);
364    }
365
366    #[test]
367    fn api_config_default_max_retries() {
368        let cfg = ApiConfig::default();
369        assert_eq!(cfg.max_retries, 3);
370    }
371
372    #[test]
373    fn api_config_default_max_output_tokens() {
374        let cfg = ApiConfig::default();
375        assert_eq!(cfg.max_output_tokens, Some(16384));
376    }
377
378    #[test]
379    fn api_config_default_base_url_contains_scheme() {
380        let cfg = ApiConfig::default();
381        assert!(
382            cfg.base_url.starts_with("https://"),
383            "base_url should start with https://, got: {}",
384            cfg.base_url
385        );
386    }
387
388    #[test]
389    fn api_config_default_thinking_is_none() {
390        let cfg = ApiConfig::default();
391        assert!(cfg.thinking.is_none());
392    }
393
394    #[test]
395    fn api_config_default_effort_is_none() {
396        let cfg = ApiConfig::default();
397        assert!(cfg.effort.is_none());
398    }
399
400    #[test]
401    fn api_config_default_max_cost_is_none() {
402        let cfg = ApiConfig::default();
403        assert!(cfg.max_cost_usd.is_none());
404    }
405
406    // ---- PermissionsConfig::default() ----
407
408    #[test]
409    fn permissions_config_default_mode_is_ask() {
410        let cfg = PermissionsConfig::default();
411        assert_eq!(cfg.default_mode, PermissionMode::Ask);
412    }
413
414    #[test]
415    fn permissions_config_default_rules_empty() {
416        let cfg = PermissionsConfig::default();
417        assert!(cfg.rules.is_empty());
418    }
419
420    // ---- UiConfig::default() ----
421
422    #[test]
423    fn ui_config_default_markdown_true() {
424        let cfg = UiConfig::default();
425        assert!(cfg.markdown);
426    }
427
428    #[test]
429    fn ui_config_default_syntax_highlight_true() {
430        let cfg = UiConfig::default();
431        assert!(cfg.syntax_highlight);
432    }
433
434    #[test]
435    fn ui_config_default_theme_dark() {
436        let cfg = UiConfig::default();
437        assert_eq!(cfg.theme, "dark");
438    }
439
440    #[test]
441    fn ui_config_default_edit_mode_emacs() {
442        let cfg = UiConfig::default();
443        assert_eq!(cfg.edit_mode, "emacs");
444    }
445
446    // ---- FeaturesConfig::default() ----
447
448    #[test]
449    fn features_config_default_all_true() {
450        let cfg = FeaturesConfig::default();
451        assert!(cfg.token_budget);
452        assert!(cfg.commit_attribution);
453        assert!(cfg.compaction_reminders);
454        assert!(cfg.unattended_retry);
455        assert!(cfg.history_snip);
456        assert!(cfg.auto_theme);
457        assert!(cfg.mcp_rich_output);
458        assert!(cfg.fork_conversation);
459        assert!(cfg.verification_agent);
460        assert!(cfg.extract_memories);
461        assert!(cfg.context_collapse);
462        assert!(cfg.reactive_compact);
463    }
464
465    // ---- SecurityConfig::default() ----
466
467    #[test]
468    fn security_config_default_empty_vecs() {
469        let cfg = SecurityConfig::default();
470        assert!(cfg.additional_directories.is_empty());
471        assert!(cfg.mcp_server_allowlist.is_empty());
472        assert!(cfg.mcp_server_denylist.is_empty());
473        assert!(cfg.env_allowlist.is_empty());
474    }
475
476    #[test]
477    fn security_config_default_booleans_false() {
478        let cfg = SecurityConfig::default();
479        assert!(!cfg.disable_bypass_permissions);
480        assert!(!cfg.disable_skill_shell_execution);
481    }
482
483    // ---- Config::default() composes sub-defaults ----
484
485    #[test]
486    fn config_default_composes_sub_defaults() {
487        let cfg = Config::default();
488        assert_eq!(cfg.api.model, "gpt-5.4");
489        assert_eq!(cfg.permissions.default_mode, PermissionMode::Ask);
490        assert!(cfg.ui.markdown);
491        assert!(cfg.features.token_budget);
492        assert!(cfg.mcp_servers.is_empty());
493        assert!(cfg.hooks.is_empty());
494        assert!(cfg.security.additional_directories.is_empty());
495    }
496
497    // ---- PermissionMode serde round-trip ----
498
499    #[test]
500    fn permission_mode_serde_roundtrip_allow() {
501        let json = serde_json::to_string(&PermissionMode::Allow).unwrap();
502        assert_eq!(json, "\"allow\"");
503        let back: PermissionMode = serde_json::from_str(&json).unwrap();
504        assert_eq!(back, PermissionMode::Allow);
505    }
506
507    #[test]
508    fn permission_mode_serde_roundtrip_deny() {
509        let json = serde_json::to_string(&PermissionMode::Deny).unwrap();
510        assert_eq!(json, "\"deny\"");
511        let back: PermissionMode = serde_json::from_str(&json).unwrap();
512        assert_eq!(back, PermissionMode::Deny);
513    }
514
515    #[test]
516    fn permission_mode_serde_roundtrip_ask() {
517        let json = serde_json::to_string(&PermissionMode::Ask).unwrap();
518        assert_eq!(json, "\"ask\"");
519        let back: PermissionMode = serde_json::from_str(&json).unwrap();
520        assert_eq!(back, PermissionMode::Ask);
521    }
522
523    #[test]
524    fn permission_mode_serde_roundtrip_accept_edits() {
525        let json = serde_json::to_string(&PermissionMode::AcceptEdits).unwrap();
526        assert_eq!(json, "\"accept_edits\"");
527        let back: PermissionMode = serde_json::from_str(&json).unwrap();
528        assert_eq!(back, PermissionMode::AcceptEdits);
529    }
530
531    #[test]
532    fn permission_mode_serde_roundtrip_plan() {
533        let json = serde_json::to_string(&PermissionMode::Plan).unwrap();
534        assert_eq!(json, "\"plan\"");
535        let back: PermissionMode = serde_json::from_str(&json).unwrap();
536        assert_eq!(back, PermissionMode::Plan);
537    }
538
539    // ---- HookEvent serde round-trip ----
540
541    #[test]
542    fn hook_event_serde_roundtrip_session_start() {
543        let json = serde_json::to_string(&HookEvent::SessionStart).unwrap();
544        assert_eq!(json, "\"session_start\"");
545        let back: HookEvent = serde_json::from_str(&json).unwrap();
546        assert_eq!(back, HookEvent::SessionStart);
547    }
548
549    #[test]
550    fn hook_event_serde_roundtrip_session_stop() {
551        let json = serde_json::to_string(&HookEvent::SessionStop).unwrap();
552        assert_eq!(json, "\"session_stop\"");
553        let back: HookEvent = serde_json::from_str(&json).unwrap();
554        assert_eq!(back, HookEvent::SessionStop);
555    }
556
557    #[test]
558    fn hook_event_serde_roundtrip_pre_tool_use() {
559        let json = serde_json::to_string(&HookEvent::PreToolUse).unwrap();
560        assert_eq!(json, "\"pre_tool_use\"");
561        let back: HookEvent = serde_json::from_str(&json).unwrap();
562        assert_eq!(back, HookEvent::PreToolUse);
563    }
564
565    #[test]
566    fn hook_event_serde_roundtrip_post_tool_use() {
567        let json = serde_json::to_string(&HookEvent::PostToolUse).unwrap();
568        assert_eq!(json, "\"post_tool_use\"");
569        let back: HookEvent = serde_json::from_str(&json).unwrap();
570        assert_eq!(back, HookEvent::PostToolUse);
571    }
572
573    #[test]
574    fn hook_event_serde_roundtrip_user_prompt_submit() {
575        let json = serde_json::to_string(&HookEvent::UserPromptSubmit).unwrap();
576        assert_eq!(json, "\"user_prompt_submit\"");
577        let back: HookEvent = serde_json::from_str(&json).unwrap();
578        assert_eq!(back, HookEvent::UserPromptSubmit);
579    }
580
581    // ---- HookAction serde round-trip ----
582
583    #[test]
584    fn hook_action_serde_roundtrip_shell() {
585        let action = HookAction::Shell {
586            command: "echo hello".into(),
587        };
588        let json = serde_json::to_string(&action).unwrap();
589        assert!(json.contains("\"type\":\"shell\""));
590        assert!(json.contains("\"command\":\"echo hello\""));
591        let back: HookAction = serde_json::from_str(&json).unwrap();
592        match back {
593            HookAction::Shell { command } => assert_eq!(command, "echo hello"),
594            _ => panic!("expected Shell variant"),
595        }
596    }
597
598    #[test]
599    fn hook_action_serde_roundtrip_http() {
600        let action = HookAction::Http {
601            url: "https://example.com/hook".into(),
602            method: Some("POST".into()),
603        };
604        let json = serde_json::to_string(&action).unwrap();
605        assert!(json.contains("\"type\":\"http\""));
606        let back: HookAction = serde_json::from_str(&json).unwrap();
607        match back {
608            HookAction::Http { url, method } => {
609                assert_eq!(url, "https://example.com/hook");
610                assert_eq!(method.unwrap(), "POST");
611            }
612            _ => panic!("expected Http variant"),
613        }
614    }
615
616    #[test]
617    fn hook_action_http_method_none() {
618        let action = HookAction::Http {
619            url: "https://example.com".into(),
620            method: None,
621        };
622        let json = serde_json::to_string(&action).unwrap();
623        let back: HookAction = serde_json::from_str(&json).unwrap();
624        match back {
625            HookAction::Http { method, .. } => assert!(method.is_none()),
626            _ => panic!("expected Http variant"),
627        }
628    }
629
630    // ---- HookDefinition serde round-trip ----
631
632    #[test]
633    fn hook_definition_serde_roundtrip() {
634        let def = HookDefinition {
635            event: HookEvent::PreToolUse,
636            action: HookAction::Shell {
637                command: "lint.sh".into(),
638            },
639            tool_name: Some("Bash".into()),
640        };
641        let json = serde_json::to_string(&def).unwrap();
642        let back: HookDefinition = serde_json::from_str(&json).unwrap();
643        assert_eq!(back.event, HookEvent::PreToolUse);
644        assert_eq!(back.tool_name, Some("Bash".into()));
645    }
646
647    #[test]
648    fn hook_definition_tool_name_none() {
649        let def = HookDefinition {
650            event: HookEvent::SessionStart,
651            action: HookAction::Shell {
652                command: "setup.sh".into(),
653            },
654            tool_name: None,
655        };
656        let json = serde_json::to_string(&def).unwrap();
657        let back: HookDefinition = serde_json::from_str(&json).unwrap();
658        assert!(back.tool_name.is_none());
659    }
660
661    // ---- Config TOML deserialization ----
662
663    #[test]
664    fn config_toml_deserialization_full() {
665        let toml_str = r#"
666[api]
667model = "test-model"
668timeout_secs = 60
669max_retries = 5
670base_url = "https://api.test.com/v1"
671
672[permissions]
673default_mode = "allow"
674
675[ui]
676markdown = false
677syntax_highlight = false
678theme = "light"
679edit_mode = "vi"
680
681[features]
682token_budget = false
683commit_attribution = false
684
685[security]
686disable_bypass_permissions = true
687additional_directories = ["/tmp"]
688"#;
689        let cfg: Config = toml::from_str(toml_str).unwrap();
690        assert_eq!(cfg.api.model, "test-model");
691        assert_eq!(cfg.api.timeout_secs, 60);
692        assert_eq!(cfg.api.max_retries, 5);
693        assert_eq!(cfg.api.base_url, "https://api.test.com/v1");
694        assert_eq!(cfg.permissions.default_mode, PermissionMode::Allow);
695        assert!(!cfg.ui.markdown);
696        assert!(!cfg.ui.syntax_highlight);
697        assert_eq!(cfg.ui.theme, "light");
698        assert_eq!(cfg.ui.edit_mode, "vi");
699        assert!(!cfg.features.token_budget);
700        assert!(!cfg.features.commit_attribution);
701        assert!(cfg.security.disable_bypass_permissions);
702        assert_eq!(cfg.security.additional_directories, vec!["/tmp"]);
703    }
704
705    #[test]
706    fn config_toml_empty_string_uses_defaults() {
707        let cfg: Config = toml::from_str("").unwrap();
708        assert_eq!(cfg.api.timeout_secs, 120);
709        assert_eq!(cfg.permissions.default_mode, PermissionMode::Ask);
710        assert!(cfg.ui.markdown);
711    }
712
713    #[test]
714    fn config_toml_partial_override() {
715        let toml_str = r#"
716[ui]
717theme = "solarized"
718"#;
719        let cfg: Config = toml::from_str(toml_str).unwrap();
720        // Overridden field
721        assert_eq!(cfg.ui.theme, "solarized");
722        // Other fields keep defaults
723        assert!(cfg.ui.markdown);
724        assert!(cfg.ui.syntax_highlight);
725        assert_eq!(cfg.ui.edit_mode, "emacs");
726    }
727
728    // ---- McpServerEntry ----
729
730    #[test]
731    fn mcp_server_entry_with_command() {
732        let json = r#"{"command": "npx mcp-server", "args": ["--port", "3000"]}"#;
733        let entry: McpServerEntry = serde_json::from_str(json).unwrap();
734        assert_eq!(entry.command, Some("npx mcp-server".into()));
735        assert_eq!(entry.args, vec!["--port", "3000"]);
736        assert!(entry.url.is_none());
737    }
738
739    #[test]
740    fn mcp_server_entry_with_url() {
741        let json = r#"{"url": "https://mcp.example.com/sse"}"#;
742        let entry: McpServerEntry = serde_json::from_str(json).unwrap();
743        assert!(entry.command.is_none());
744        assert_eq!(entry.url, Some("https://mcp.example.com/sse".into()));
745        assert!(entry.args.is_empty());
746    }
747
748    #[test]
749    fn mcp_server_entry_with_env() {
750        let json = r#"{"command": "server", "env": {"TOKEN": "abc"}}"#;
751        let entry: McpServerEntry = serde_json::from_str(json).unwrap();
752        assert_eq!(entry.env.get("TOKEN").unwrap(), "abc");
753    }
754
755    // ---- PermissionRule serialization ----
756
757    #[test]
758    fn permission_rule_serde_roundtrip_with_pattern() {
759        let rule = PermissionRule {
760            tool: "Bash".into(),
761            pattern: Some("rm -rf *".into()),
762            action: PermissionMode::Deny,
763        };
764        let json = serde_json::to_string(&rule).unwrap();
765        let back: PermissionRule = serde_json::from_str(&json).unwrap();
766        assert_eq!(back.tool, "Bash");
767        assert_eq!(back.pattern, Some("rm -rf *".into()));
768        assert_eq!(back.action, PermissionMode::Deny);
769    }
770
771    #[test]
772    fn permission_rule_serde_roundtrip_without_pattern() {
773        let rule = PermissionRule {
774            tool: "Read".into(),
775            pattern: None,
776            action: PermissionMode::Allow,
777        };
778        let json = serde_json::to_string(&rule).unwrap();
779        let back: PermissionRule = serde_json::from_str(&json).unwrap();
780        assert_eq!(back.tool, "Read");
781        assert!(back.pattern.is_none());
782        assert_eq!(back.action, PermissionMode::Allow);
783    }
784
785    // ---- Config with hooks in TOML ----
786
787    #[test]
788    fn config_toml_with_hooks() {
789        let toml_str = r#"
790[[hooks]]
791event = "session_start"
792tool_name = "Bash"
793
794[hooks.action]
795type = "shell"
796command = "echo starting"
797"#;
798        let cfg: Config = toml::from_str(toml_str).unwrap();
799        assert_eq!(cfg.hooks.len(), 1);
800        assert_eq!(cfg.hooks[0].event, HookEvent::SessionStart);
801        assert_eq!(cfg.hooks[0].tool_name, Some("Bash".into()));
802    }
803
804    // ---- Config with mcp_servers in TOML ----
805
806    #[test]
807    fn config_toml_with_mcp_servers() {
808        let toml_str = r#"
809[mcp_servers.my_server]
810command = "npx my-mcp"
811args = ["--flag"]
812"#;
813        let cfg: Config = toml::from_str(toml_str).unwrap();
814        assert!(cfg.mcp_servers.contains_key("my_server"));
815        let server = &cfg.mcp_servers["my_server"];
816        assert_eq!(server.command, Some("npx my-mcp".into()));
817        assert_eq!(server.args, vec!["--flag"]);
818    }
819}