1use bon::Builder;
2use serde::{Deserialize, Serialize};
3use std::collections::{HashMap, HashSet};
4use std::path::PathBuf;
5
6#[derive(Debug, Clone, Default, Serialize, Deserialize)]
12pub struct McpServer {
13 pub name: String,
14 pub command: String,
15 pub args: Vec<String>,
16 pub env: HashMap<String, String>,
17}
18
19#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
29#[serde(rename_all = "snake_case")]
30pub enum McpConfigMode {
31 #[default]
32 Merge,
33 ExplicitOnly,
34}
35
36#[derive(Debug, Clone, Builder, Serialize, Deserialize)]
37pub struct SessionConfig<C> {
38 #[builder(into)]
39 pub model: Option<String>,
40
41 pub cwd: Option<PathBuf>,
42
43 #[builder(default)]
44 pub additional_dirs: Vec<PathBuf>,
45
46 #[builder(default)]
47 pub env: HashMap<String, String>,
48
49 #[builder(default)]
52 pub env_remove: HashSet<String>,
53
54 pub max_turns: Option<u32>,
55
56 #[builder(into)]
57 pub system_prompt: Option<String>,
58
59 pub backend: C,
60}
61
62impl<C> SessionConfig<C> {
63 pub fn with_backend<D>(self, backend: D) -> SessionConfig<D> {
65 self.map_backend(|_| backend)
66 }
67
68 pub fn map_backend<D>(self, f: impl FnOnce(C) -> D) -> SessionConfig<D> {
70 let SessionConfig {
71 backend,
72 model,
73 cwd,
74 additional_dirs,
75 env,
76 env_remove,
77 max_turns,
78 system_prompt,
79 } = self;
80 SessionConfig {
81 backend: f(backend),
82 model,
83 cwd,
84 additional_dirs,
85 env,
86 env_remove,
87 max_turns,
88 system_prompt,
89 }
90 }
91
92 pub fn try_map_backend<D, E>(
94 self,
95 f: impl FnOnce(C) -> std::result::Result<D, E>,
96 ) -> std::result::Result<SessionConfig<D>, E> {
97 let SessionConfig {
98 backend,
99 model,
100 cwd,
101 additional_dirs,
102 env,
103 env_remove,
104 max_turns,
105 system_prompt,
106 } = self;
107 Ok(SessionConfig {
108 backend: f(backend)?,
109 model,
110 cwd,
111 additional_dirs,
112 env,
113 env_remove,
114 max_turns,
115 system_prompt,
116 })
117 }
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct ToolDefinition {
123 pub name: String,
125 pub description: String,
127 pub parameters: serde_json::Value,
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize)]
133#[serde(rename_all = "snake_case")]
134pub enum ToolChoice {
135 Auto,
137 Required,
139 None,
141 Function(String),
143}
144
145#[derive(Debug, Clone, Default, Builder, Serialize, Deserialize)]
146pub struct TurnOptions {
147 pub output_schema: Option<serde_json::Value>,
148 #[serde(default)]
151 pub timeout: Option<std::time::Duration>,
152 #[builder(default)]
154 #[serde(default)]
155 pub tools: Vec<ToolDefinition>,
156 pub tool_choice: Option<ToolChoice>,
158}
159
160#[derive(Debug, Clone, Serialize, Deserialize)]
161#[non_exhaustive]
162pub enum Input {
163 Text(String),
164 Structured(Vec<InputPart>),
165}
166
167#[derive(Debug, Clone, Serialize, Deserialize)]
168#[non_exhaustive]
169pub enum InputPart {
170 Text(String),
171 Image(PathBuf),
172}
173
174impl From<String> for Input {
175 fn from(s: String) -> Self {
176 Input::Text(s)
177 }
178}
179
180impl From<&str> for Input {
181 fn from(s: &str) -> Self {
182 Input::Text(s.to_owned())
183 }
184}
185
186#[cfg(test)]
187mod tests {
188 use super::*;
189
190 #[test]
191 fn session_config_builder() {
192 let config = SessionConfig::builder()
193 .model("gpt-4")
194 .cwd(PathBuf::from("/tmp"))
195 .backend("test-backend")
196 .build();
197
198 assert_eq!(config.model.as_deref(), Some("gpt-4"));
199 assert_eq!(config.cwd, Some(PathBuf::from("/tmp")));
200 assert_eq!(config.backend, "test-backend");
201 assert!(config.additional_dirs.is_empty());
202 assert!(config.env.is_empty());
203 }
204
205 #[test]
206 fn with_backend_preserves_shared_fields() {
207 let config = SessionConfig::builder()
208 .model("gpt-4")
209 .cwd(PathBuf::from("/tmp"))
210 .max_turns(5)
211 .backend("original")
212 .build();
213
214 let swapped = config.with_backend(42u32);
215
216 assert_eq!(swapped.model.as_deref(), Some("gpt-4"));
217 assert_eq!(swapped.cwd, Some(PathBuf::from("/tmp")));
218 assert_eq!(swapped.max_turns, Some(5));
219 assert_eq!(swapped.backend, 42);
220 }
221
222 #[test]
223 fn map_backend_transforms() {
224 let config = SessionConfig::builder()
225 .model("gpt-4")
226 .backend("hello")
227 .build();
228
229 let mapped = config.map_backend(|s: &str| s.len());
230
231 assert_eq!(mapped.model.as_deref(), Some("gpt-4"));
232 assert_eq!(mapped.backend, 5);
233 }
234
235 #[test]
236 fn mcp_config_mode_defaults_to_merge() {
237 assert_eq!(McpConfigMode::default(), McpConfigMode::Merge);
238 }
239
240 #[test]
241 fn try_map_backend_ok() {
242 let mut env = HashMap::new();
243 env.insert("KEY".into(), "VAL".into());
244
245 let config = SessionConfig::builder()
246 .model("gpt-4")
247 .cwd(PathBuf::from("/tmp"))
248 .additional_dirs(vec![PathBuf::from("/extra")])
249 .env(env)
250 .max_turns(10)
251 .system_prompt("be helpful")
252 .backend(42u32)
253 .build();
254
255 let result: std::result::Result<SessionConfig<String>, &str> =
256 config.try_map_backend(|n| Ok(n.to_string()));
257
258 let mapped = result.unwrap();
259 assert_eq!(mapped.backend, "42");
260 assert_eq!(mapped.model.as_deref(), Some("gpt-4"));
261 assert_eq!(mapped.cwd, Some(PathBuf::from("/tmp")));
262 assert_eq!(mapped.additional_dirs, vec![PathBuf::from("/extra")]);
263 assert_eq!(mapped.env.get("KEY").unwrap(), "VAL");
264 assert_eq!(mapped.max_turns, Some(10));
265 assert_eq!(mapped.system_prompt.as_deref(), Some("be helpful"));
266 }
267
268 #[test]
269 fn try_map_backend_err() {
270 let config = SessionConfig::builder().backend(42u32).build();
271
272 let result: std::result::Result<SessionConfig<String>, &str> =
273 config.try_map_backend(|_| Err("mismatch"));
274
275 assert_eq!(result.unwrap_err(), "mismatch");
276 }
277
278 #[test]
279 fn env_remove_preserved_through_map_backend() {
280 let config = SessionConfig::builder()
281 .env_remove(HashSet::from(["SECRET".into()]))
282 .backend("original")
283 .build();
284
285 let mapped = config.map_backend(|s: &str| s.len());
286
287 assert!(mapped.env_remove.contains("SECRET"));
288 assert_eq!(mapped.backend, 8);
289 }
290
291 #[test]
292 fn env_remove_preserved_through_try_map_backend() {
293 let config = SessionConfig::builder()
294 .env_remove(HashSet::from(["KEY".into()]))
295 .backend(42u32)
296 .build();
297
298 let result: std::result::Result<SessionConfig<String>, &str> =
299 config.try_map_backend(|n| Ok(n.to_string()));
300
301 let mapped = result.unwrap();
302 assert!(mapped.env_remove.contains("KEY"));
303 }
304
305 #[test]
306 fn input_from_str() {
307 let input: Input = "hello".into();
308 match input {
309 Input::Text(s) => assert_eq!(s, "hello"),
310 _ => panic!("expected Text variant"),
311 }
312 }
313
314 #[test]
315 fn input_from_string() {
316 let input: Input = String::from("hello").into();
317 match input {
318 Input::Text(s) => assert_eq!(s, "hello"),
319 _ => panic!("expected Text variant"),
320 }
321 }
322}