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. [`TaskOverrides`] 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, TaskOverrides};
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 fallback_model: Option<String>,
21    pub permission_mode: PermissionMode,
22    pub max_turns: Option<u32>,
23    pub system_prompt: Option<String>,
24    pub allowed_tools: Vec<String>,
25    pub disallowed_tools: Vec<String>,
26    pub tools: Vec<String>,
27    pub effort: Option<Effort>,
28    /// Merged MCP servers from global + slot + task layers.
29    /// Later layers override earlier layers by server name.
30    pub mcp_servers: HashMap<String, serde_json::Value>,
31    /// Whether to pass `--strict-mcp-config` to the CLI.
32    pub strict_mcp_config: bool,
33    /// JSON schema for structured output validation (task overrides).
34    pub json_schema: Option<serde_json::Value>,
35    /// Maximum budget cap for the task in USD.
36    pub max_budget_usd: Option<f64>,
37}
38
39impl ResolvedConfig {
40    /// Resolve configuration by layering global, slot, and task configs.
41    ///
42    /// Later layers override earlier layers for scalar fields.
43    /// Map/list fields (allowed_tools, mcp_servers) are merged additively,
44    /// with later layers overriding same-named entries.
45    pub fn resolve(global: &PoolConfig, slot: &SlotConfig, task: Option<&TaskOverrides>) -> Self {
46        let model = task
47            .and_then(|t| t.model.clone())
48            .or_else(|| slot.model.clone())
49            .or_else(|| global.model.clone());
50
51        let fallback_model = task
52            .and_then(|t| t.fallback_model.clone())
53            .or_else(|| slot.fallback_model.clone())
54            .or_else(|| global.fallback_model.clone());
55
56        let permission_mode = task
57            .and_then(|t| t.permission_mode)
58            .or(slot.permission_mode)
59            .or(global.permission_mode)
60            .unwrap_or(PermissionMode::Plan);
61
62        let max_turns = task
63            .and_then(|t| t.max_turns)
64            .or(slot.max_turns)
65            .or(global.max_turns);
66
67        let system_prompt = task
68            .and_then(|t| t.system_prompt.clone())
69            .or_else(|| slot.system_prompt.clone())
70            .or_else(|| global.system_prompt.clone());
71
72        let effort = task
73            .and_then(|t| t.effort)
74            .or(slot.effort)
75            .or(global.effort);
76
77        // Merge allowed tools: global + slot + task
78        let mut allowed_tools = global.allowed_tools.clone();
79        if let Some(ref wt) = slot.allowed_tools {
80            allowed_tools.extend(wt.iter().cloned());
81        }
82        if let Some(task_cfg) = task
83            && let Some(ref tt) = task_cfg.allowed_tools
84        {
85            allowed_tools.extend(tt.iter().cloned());
86        }
87
88        // Task-level disallowed tools
89        let disallowed_tools = task
90            .and_then(|t| t.disallowed_tools.as_ref())
91            .cloned()
92            .unwrap_or_default();
93
94        // Task-level tools selection
95        let tools = task
96            .and_then(|t| t.tools.as_ref())
97            .cloned()
98            .unwrap_or_default();
99
100        // Merge MCP servers: global base, slot overrides/adds, task overrides/adds.
101        // Same-named servers in later layers replace earlier ones.
102        let mut mcp_servers = global.mcp_servers.clone();
103        if let Some(ref slot_servers) = slot.mcp_servers {
104            mcp_servers.extend(slot_servers.iter().map(|(k, v)| (k.clone(), v.clone())));
105        }
106        if let Some(task_cfg) = task
107            && let Some(ref task_servers) = task_cfg.mcp_servers
108        {
109            mcp_servers.extend(task_servers.iter().map(|(k, v)| (k.clone(), v.clone())));
110        }
111
112        let strict_mcp_config = global.strict_mcp_config;
113
114        let json_schema = task.and_then(|t| t.json_schema.clone());
115        let max_budget_usd = task
116            .and_then(|t| t.max_budget_usd)
117            .or_else(|| global.budget_microdollars.map(|m| m as f64 / 1_000_000.0));
118
119        Self {
120            model,
121            fallback_model,
122            permission_mode,
123            max_turns,
124            system_prompt,
125            allowed_tools,
126            disallowed_tools,
127            tools,
128            effort,
129            mcp_servers,
130            strict_mcp_config,
131            json_schema,
132            max_budget_usd,
133        }
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    #[test]
142    fn global_defaults() {
143        let global = PoolConfig::default();
144        let slot = SlotConfig::default();
145        let resolved = ResolvedConfig::resolve(&global, &slot, None);
146
147        // PoolConfig defaults to Plan mode.
148        assert_eq!(resolved.permission_mode, PermissionMode::Plan);
149        assert!(resolved.model.is_none());
150        assert!(resolved.allowed_tools.is_empty());
151        assert!(resolved.mcp_servers.is_empty());
152        assert!(resolved.strict_mcp_config);
153    }
154
155    #[test]
156    fn slot_overrides_global() {
157        let global = PoolConfig {
158            model: Some("haiku".into()),
159            effort: Some(Effort::Low),
160            ..Default::default()
161        };
162        let slot = SlotConfig {
163            model: Some("opus".into()),
164            ..Default::default()
165        };
166        let resolved = ResolvedConfig::resolve(&global, &slot, None);
167
168        assert_eq!(resolved.model.as_deref(), Some("opus"));
169        assert_eq!(resolved.effort, Some(Effort::Low)); // inherited from global
170    }
171
172    #[test]
173    fn task_overrides_slot() {
174        let global = PoolConfig {
175            model: Some("haiku".into()),
176            ..Default::default()
177        };
178        let slot = SlotConfig {
179            model: Some("sonnet".into()),
180            effort: Some(Effort::Medium),
181            ..Default::default()
182        };
183        let task = TaskOverrides {
184            effort: Some(Effort::Max),
185            ..Default::default()
186        };
187        let resolved = ResolvedConfig::resolve(&global, &slot, Some(&task));
188
189        assert_eq!(resolved.model.as_deref(), Some("sonnet")); // slot wins over global
190        assert_eq!(resolved.effort, Some(Effort::Max)); // task wins over slot
191    }
192
193    #[test]
194    fn allowed_tools_merge() {
195        let global = PoolConfig {
196            allowed_tools: vec!["Bash".into(), "Read".into()],
197            ..Default::default()
198        };
199        let slot = SlotConfig {
200            allowed_tools: Some(vec!["Write".into()]),
201            ..Default::default()
202        };
203        let task = TaskOverrides {
204            allowed_tools: Some(vec!["Edit".into()]),
205            ..Default::default()
206        };
207        let resolved = ResolvedConfig::resolve(&global, &slot, Some(&task));
208
209        assert_eq!(
210            resolved.allowed_tools,
211            vec!["Bash", "Read", "Write", "Edit"]
212        );
213    }
214
215    #[test]
216    fn mcp_servers_merge() {
217        let global = PoolConfig {
218            mcp_servers: [(
219                "hub".to_string(),
220                serde_json::json!({"type": "http", "url": "http://global/"}),
221            )]
222            .into_iter()
223            .collect(),
224            ..Default::default()
225        };
226        let slot = SlotConfig {
227            mcp_servers: Some(
228                [(
229                    "tool".to_string(),
230                    serde_json::json!({"type": "stdio", "command": "npx"}),
231                )]
232                .into_iter()
233                .collect(),
234            ),
235            ..Default::default()
236        };
237        let resolved = ResolvedConfig::resolve(&global, &slot, None);
238
239        assert_eq!(resolved.mcp_servers.len(), 2);
240        assert!(resolved.mcp_servers.contains_key("hub"));
241        assert!(resolved.mcp_servers.contains_key("tool"));
242    }
243
244    #[test]
245    fn task_mcp_servers_override_slot() {
246        let global = PoolConfig::default();
247        let slot = SlotConfig {
248            mcp_servers: Some(
249                [(
250                    "hub".to_string(),
251                    serde_json::json!({"type": "http", "url": "http://slot/"}),
252                )]
253                .into_iter()
254                .collect(),
255            ),
256            ..Default::default()
257        };
258        let task = TaskOverrides {
259            mcp_servers: Some(
260                [(
261                    "hub".to_string(),
262                    serde_json::json!({"type": "http", "url": "http://task/"}),
263                )]
264                .into_iter()
265                .collect(),
266            ),
267            ..Default::default()
268        };
269        let resolved = ResolvedConfig::resolve(&global, &slot, Some(&task));
270
271        assert_eq!(resolved.mcp_servers["hub"]["url"], "http://task/");
272    }
273
274    #[test]
275    fn strict_mcp_config_defaults_true() {
276        let global = PoolConfig::default();
277        let slot = SlotConfig::default();
278        let resolved = ResolvedConfig::resolve(&global, &slot, None);
279        assert!(resolved.strict_mcp_config);
280    }
281
282    #[test]
283    fn strict_mcp_config_can_be_disabled() {
284        let global = PoolConfig {
285            strict_mcp_config: false,
286            ..Default::default()
287        };
288        let slot = SlotConfig::default();
289        let resolved = ResolvedConfig::resolve(&global, &slot, None);
290        assert!(!resolved.strict_mcp_config);
291    }
292}