Skip to main content

claude_pool/
config.rs

1//! Configuration resolution for slots and tasks.
2//!
3//! Configuration cascades in three layers:
4//! 1. [`PoolConfig`] — pool-wide defaults
5//! 2. [`SlotConfig`] — per-slot overrides
6//! 3. [`SlotConfig`] on a task record — per-task overrides
7
8use std::collections::HashMap;
9
10use claude_wrapper::types::{Effort, PermissionMode};
11
12use crate::types::{PoolConfig, SlotConfig};
13
14/// Resolved configuration for a single task execution.
15///
16/// Produced by merging global -> slot -> task config layers.
17#[derive(Debug, Clone)]
18pub struct ResolvedConfig {
19    pub model: Option<String>,
20    pub permission_mode: PermissionMode,
21    pub max_turns: Option<u32>,
22    pub system_prompt: Option<String>,
23    pub allowed_tools: Vec<String>,
24    pub effort: Option<Effort>,
25    /// Merged MCP servers from global + slot + task layers.
26    /// Later layers override earlier layers by server name.
27    pub mcp_servers: HashMap<String, serde_json::Value>,
28    /// Whether to pass `--strict-mcp-config` to the CLI.
29    pub strict_mcp_config: bool,
30}
31
32impl ResolvedConfig {
33    /// Resolve configuration by layering global, slot, and task configs.
34    ///
35    /// Later layers override earlier layers for scalar fields.
36    /// Map/list fields (allowed_tools, mcp_servers) are merged additively,
37    /// with later layers overriding same-named entries.
38    pub fn resolve(global: &PoolConfig, slot: &SlotConfig, task: Option<&SlotConfig>) -> Self {
39        let model = task
40            .and_then(|t| t.model.clone())
41            .or_else(|| slot.model.clone())
42            .or_else(|| global.model.clone());
43
44        let permission_mode = task
45            .and_then(|t| t.permission_mode)
46            .or(slot.permission_mode)
47            .or(global.permission_mode)
48            .unwrap_or(PermissionMode::Plan);
49
50        let max_turns = task
51            .and_then(|t| t.max_turns)
52            .or(slot.max_turns)
53            .or(global.max_turns);
54
55        let system_prompt = task
56            .and_then(|t| t.system_prompt.clone())
57            .or_else(|| slot.system_prompt.clone())
58            .or_else(|| global.system_prompt.clone());
59
60        let effort = task
61            .and_then(|t| t.effort)
62            .or(slot.effort)
63            .or(global.effort);
64
65        // Merge allowed tools: global + slot + task
66        let mut allowed_tools = global.allowed_tools.clone();
67        if let Some(ref wt) = slot.allowed_tools {
68            allowed_tools.extend(wt.iter().cloned());
69        }
70        if let Some(task_cfg) = task
71            && let Some(ref tt) = task_cfg.allowed_tools
72        {
73            allowed_tools.extend(tt.iter().cloned());
74        }
75
76        // Merge MCP servers: global base, slot overrides/adds, task overrides/adds.
77        // Same-named servers in later layers replace earlier ones.
78        let mut mcp_servers = global.mcp_servers.clone();
79        if let Some(ref slot_servers) = slot.mcp_servers {
80            mcp_servers.extend(slot_servers.iter().map(|(k, v)| (k.clone(), v.clone())));
81        }
82        if let Some(task_cfg) = task
83            && let Some(ref task_servers) = task_cfg.mcp_servers
84        {
85            mcp_servers.extend(task_servers.iter().map(|(k, v)| (k.clone(), v.clone())));
86        }
87
88        let strict_mcp_config = global.strict_mcp_config;
89
90        Self {
91            model,
92            permission_mode,
93            max_turns,
94            system_prompt,
95            allowed_tools,
96            effort,
97            mcp_servers,
98            strict_mcp_config,
99        }
100    }
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106
107    #[test]
108    fn global_defaults() {
109        let global = PoolConfig::default();
110        let slot = SlotConfig::default();
111        let resolved = ResolvedConfig::resolve(&global, &slot, None);
112
113        // PoolConfig defaults to Plan mode.
114        assert_eq!(resolved.permission_mode, PermissionMode::Plan);
115        assert!(resolved.model.is_none());
116        assert!(resolved.allowed_tools.is_empty());
117        assert!(resolved.mcp_servers.is_empty());
118        assert!(resolved.strict_mcp_config);
119    }
120
121    #[test]
122    fn slot_overrides_global() {
123        let global = PoolConfig {
124            model: Some("haiku".into()),
125            effort: Some(Effort::Low),
126            ..Default::default()
127        };
128        let slot = SlotConfig {
129            model: Some("opus".into()),
130            ..Default::default()
131        };
132        let resolved = ResolvedConfig::resolve(&global, &slot, None);
133
134        assert_eq!(resolved.model.as_deref(), Some("opus"));
135        assert_eq!(resolved.effort, Some(Effort::Low)); // inherited from global
136    }
137
138    #[test]
139    fn task_overrides_slot() {
140        let global = PoolConfig {
141            model: Some("haiku".into()),
142            ..Default::default()
143        };
144        let slot = SlotConfig {
145            model: Some("sonnet".into()),
146            effort: Some(Effort::Medium),
147            ..Default::default()
148        };
149        let task = SlotConfig {
150            effort: Some(Effort::Max),
151            ..Default::default()
152        };
153        let resolved = ResolvedConfig::resolve(&global, &slot, Some(&task));
154
155        assert_eq!(resolved.model.as_deref(), Some("sonnet")); // slot wins over global
156        assert_eq!(resolved.effort, Some(Effort::Max)); // task wins over slot
157    }
158
159    #[test]
160    fn allowed_tools_merge() {
161        let global = PoolConfig {
162            allowed_tools: vec!["Bash".into(), "Read".into()],
163            ..Default::default()
164        };
165        let slot = SlotConfig {
166            allowed_tools: Some(vec!["Write".into()]),
167            ..Default::default()
168        };
169        let task = SlotConfig {
170            allowed_tools: Some(vec!["Edit".into()]),
171            ..Default::default()
172        };
173        let resolved = ResolvedConfig::resolve(&global, &slot, Some(&task));
174
175        assert_eq!(
176            resolved.allowed_tools,
177            vec!["Bash", "Read", "Write", "Edit"]
178        );
179    }
180
181    #[test]
182    fn mcp_servers_merge() {
183        let global = PoolConfig {
184            mcp_servers: [(
185                "hub".to_string(),
186                serde_json::json!({"type": "http", "url": "http://global/"}),
187            )]
188            .into_iter()
189            .collect(),
190            ..Default::default()
191        };
192        let slot = SlotConfig {
193            mcp_servers: Some(
194                [(
195                    "tool".to_string(),
196                    serde_json::json!({"type": "stdio", "command": "npx"}),
197                )]
198                .into_iter()
199                .collect(),
200            ),
201            ..Default::default()
202        };
203        let resolved = ResolvedConfig::resolve(&global, &slot, None);
204
205        assert_eq!(resolved.mcp_servers.len(), 2);
206        assert!(resolved.mcp_servers.contains_key("hub"));
207        assert!(resolved.mcp_servers.contains_key("tool"));
208    }
209
210    #[test]
211    fn task_mcp_servers_override_slot() {
212        let global = PoolConfig::default();
213        let slot = SlotConfig {
214            mcp_servers: Some(
215                [(
216                    "hub".to_string(),
217                    serde_json::json!({"type": "http", "url": "http://slot/"}),
218                )]
219                .into_iter()
220                .collect(),
221            ),
222            ..Default::default()
223        };
224        let task = SlotConfig {
225            mcp_servers: Some(
226                [(
227                    "hub".to_string(),
228                    serde_json::json!({"type": "http", "url": "http://task/"}),
229                )]
230                .into_iter()
231                .collect(),
232            ),
233            ..Default::default()
234        };
235        let resolved = ResolvedConfig::resolve(&global, &slot, Some(&task));
236
237        assert_eq!(resolved.mcp_servers["hub"]["url"], "http://task/");
238    }
239
240    #[test]
241    fn strict_mcp_config_defaults_true() {
242        let global = PoolConfig::default();
243        let slot = SlotConfig::default();
244        let resolved = ResolvedConfig::resolve(&global, &slot, None);
245        assert!(resolved.strict_mcp_config);
246    }
247
248    #[test]
249    fn strict_mcp_config_can_be_disabled() {
250        let global = PoolConfig {
251            strict_mcp_config: false,
252            ..Default::default()
253        };
254        let slot = SlotConfig::default();
255        let resolved = ResolvedConfig::resolve(&global, &slot, None);
256        assert!(!resolved.strict_mcp_config);
257    }
258}