1use std::collections::{BTreeMap, HashMap};
18use std::path::PathBuf;
19use std::sync::Arc;
20use std::time::Duration;
21
22use serde::{Deserialize, Serialize};
23use typed_builder::TypedBuilder;
24
25use tokio_util::sync::CancellationToken;
26
27use crate::callback::MessageCallback;
28use crate::hooks::HookMatcher;
29use crate::mcp::McpServers;
30use crate::permissions::CanUseToolCallback;
31
32#[derive(TypedBuilder)]
38pub struct ClientConfig {
39 #[builder(setter(into))]
42 pub prompt: String,
43
44 #[builder(default, setter(strip_option))]
48 pub cli_path: Option<PathBuf>,
49
50 #[builder(default, setter(strip_option))]
52 pub cwd: Option<PathBuf>,
53
54 #[builder(default, setter(strip_option, into))]
56 pub model: Option<String>,
57
58 #[builder(default, setter(strip_option, into))]
60 pub fallback_model: Option<String>,
61
62 #[builder(default, setter(strip_option))]
64 pub system_prompt: Option<SystemPrompt>,
65
66 #[builder(default, setter(strip_option))]
69 pub max_turns: Option<u32>,
70
71 #[builder(default, setter(strip_option))]
73 pub max_budget_usd: Option<f64>,
74
75 #[builder(default, setter(strip_option))]
77 pub max_thinking_tokens: Option<u32>,
78
79 #[builder(default)]
82 pub allowed_tools: Vec<String>,
83
84 #[builder(default)]
86 pub disallowed_tools: Vec<String>,
87
88 #[builder(default)]
91 pub permission_mode: PermissionMode,
92
93 #[builder(default, setter(strip_option))]
95 pub can_use_tool: Option<CanUseToolCallback>,
96
97 #[builder(default, setter(strip_option, into))]
100 pub resume: Option<String>,
101
102 #[builder(default)]
105 pub hooks: Vec<HookMatcher>,
106
107 #[builder(default)]
110 pub mcp_servers: McpServers,
111
112 #[builder(default, setter(strip_option))]
115 pub message_callback: Option<MessageCallback>,
116
117 #[builder(default)]
120 pub env: HashMap<String, String>,
121
122 #[builder(default)]
124 pub verbose: bool,
125
126 #[builder(default_code = r#""json".into()"#, setter(into))]
129 pub output_format: String,
130
131 #[builder(default)]
156 pub extra_args: BTreeMap<String, Option<String>>,
157
158 #[builder(default_code = "Some(Duration::from_secs(30))")]
163 pub connect_timeout: Option<Duration>,
164
165 #[builder(default_code = "Some(Duration::from_secs(10))")]
170 pub close_timeout: Option<Duration>,
171
172 #[builder(default)]
177 pub read_timeout: Option<Duration>,
178
179 #[builder(default_code = "Duration::from_secs(30)")]
184 pub default_hook_timeout: Duration,
185
186 #[builder(default_code = "Some(Duration::from_secs(5))")]
192 pub version_check_timeout: Option<Duration>,
193
194 #[builder(default_code = "Duration::from_secs(30)")]
200 pub control_request_timeout: Duration,
201
202 #[builder(default, setter(strip_option))]
208 pub cancellation_token: Option<CancellationToken>,
209
210 #[builder(default, setter(strip_option))]
213 pub stderr_callback: Option<Arc<dyn Fn(String) + Send + Sync>>,
214}
215
216impl std::fmt::Debug for ClientConfig {
217 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
218 f.debug_struct("ClientConfig")
219 .field("prompt", &self.prompt)
220 .field("cli_path", &self.cli_path)
221 .field("cwd", &self.cwd)
222 .field("model", &self.model)
223 .field("permission_mode", &self.permission_mode)
224 .field("max_turns", &self.max_turns)
225 .field("max_budget_usd", &self.max_budget_usd)
226 .field("verbose", &self.verbose)
227 .field("output_format", &self.output_format)
228 .field("connect_timeout", &self.connect_timeout)
229 .field("close_timeout", &self.close_timeout)
230 .field("read_timeout", &self.read_timeout)
231 .field("default_hook_timeout", &self.default_hook_timeout)
232 .field("version_check_timeout", &self.version_check_timeout)
233 .field("control_request_timeout", &self.control_request_timeout)
234 .finish_non_exhaustive()
235 }
236}
237
238#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
242#[serde(rename_all = "snake_case")]
243pub enum PermissionMode {
244 #[default]
246 Default,
247 AcceptEdits,
249 Plan,
251 BypassPermissions,
253}
254
255impl PermissionMode {
256 #[must_use]
258 pub fn as_cli_flag(&self) -> &'static str {
259 match self {
260 Self::Default => "default",
261 Self::AcceptEdits => "acceptEdits",
262 Self::Plan => "plan",
263 Self::BypassPermissions => "bypassPermissions",
264 }
265 }
266}
267
268#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
272#[serde(tag = "type", rename_all = "snake_case")]
273pub enum SystemPrompt {
274 Text {
276 text: String,
278 },
279 Preset {
281 kind: String,
283 preset: String,
285 #[serde(default, skip_serializing_if = "Option::is_none")]
287 append: Option<String>,
288 },
289}
290
291impl SystemPrompt {
292 #[must_use]
294 pub fn text(s: impl Into<String>) -> Self {
295 Self::Text { text: s.into() }
296 }
297
298 #[must_use]
300 pub fn preset(kind: impl Into<String>, preset: impl Into<String>) -> Self {
301 Self::Preset {
302 kind: kind.into(),
303 preset: preset.into(),
304 append: None,
305 }
306 }
307}
308
309impl ClientConfig {
312 pub fn validate(&self) -> crate::errors::Result<()> {
319 if let Some(ref cwd) = self.cwd {
320 if !cwd.exists() {
321 return Err(crate::errors::Error::Config(format!(
322 "working directory does not exist: {}",
323 cwd.display()
324 )));
325 }
326 if !cwd.is_dir() {
327 return Err(crate::errors::Error::Config(format!(
328 "working directory is not a directory: {}",
329 cwd.display()
330 )));
331 }
332 }
333 Ok(())
334 }
335
336 #[must_use]
340 pub fn to_cli_args(&self) -> Vec<String> {
341 let mut args = vec![
342 "--output-format".into(),
343 self.output_format.clone(),
344 "--print".into(),
345 self.prompt.clone(),
346 ];
347
348 if let Some(model) = &self.model {
349 args.push("--model".into());
350 args.push(model.clone());
351 }
352
353 if let Some(fallback) = &self.fallback_model {
354 args.push("--fallback-model".into());
355 args.push(fallback.clone());
356 }
357
358 if let Some(turns) = self.max_turns {
359 args.push("--max-turns".into());
360 args.push(turns.to_string());
361 }
362
363 if let Some(budget) = self.max_budget_usd {
364 args.push("--max-budget-usd".into());
365 args.push(budget.to_string());
366 }
367
368 if let Some(thinking) = self.max_thinking_tokens {
369 args.push("--max-thinking-tokens".into());
370 args.push(thinking.to_string());
371 }
372
373 if self.permission_mode != PermissionMode::Default {
374 args.push("--permission-mode".into());
375 args.push(self.permission_mode.as_cli_flag().into());
376 }
377
378 if let Some(resume) = &self.resume {
379 args.push("--resume".into());
380 args.push(resume.clone());
381 }
382
383 if self.verbose {
384 args.push("--verbose".into());
385 }
386
387 for tool in &self.allowed_tools {
388 args.push("--allowedTools".into());
389 args.push(tool.clone());
390 }
391
392 for tool in &self.disallowed_tools {
393 args.push("--disallowedTools".into());
394 args.push(tool.clone());
395 }
396
397 if !self.mcp_servers.is_empty() {
398 let json = serde_json::to_string(&self.mcp_servers)
399 .expect("McpServers serialization is infallible");
400 args.push("--mcp-servers".into());
401 args.push(json);
402 }
403
404 if let Some(prompt) = &self.system_prompt {
405 match prompt {
406 SystemPrompt::Text { text } => {
407 args.push("--system-prompt".into());
408 args.push(text.clone());
409 }
410 SystemPrompt::Preset { preset, append, .. } => {
411 args.push("--system-prompt-preset".into());
412 args.push(preset.clone());
413 if let Some(append_text) = append {
414 args.push("--append-system-prompt".into());
415 args.push(append_text.clone());
416 }
417 }
418 }
419 }
420
421 for (key, value) in &self.extra_args {
423 args.push(format!("--{key}"));
424 if let Some(v) = value {
425 args.push(v.clone());
426 }
427 }
428
429 args
430 }
431
432 #[must_use]
442 pub fn to_env(&self) -> HashMap<String, String> {
443 let mut env = HashMap::new();
444
445 env.insert(
447 "CLAUDE_CODE_SDK_ORIGINATOR".into(),
448 "claude_cli_sdk_rs".into(),
449 );
450 env.insert("CI".into(), "true".into());
451 env.insert("TERM".into(), "dumb".into());
452
453 env.extend(self.env.clone());
455
456 if self.can_use_tool.is_some() || !self.hooks.is_empty() {
458 env.insert("CLAUDE_CODE_SDK_CONTROL_PORT".into(), "stdin".into());
459 }
460
461 env
462 }
463}
464
465#[cfg(test)]
468mod tests {
469 use super::*;
470
471 #[test]
472 fn builder_minimal() {
473 let config = ClientConfig::builder().prompt("hello").build();
474 assert_eq!(config.prompt, "hello");
475 assert_eq!(config.output_format, "json");
476 assert_eq!(config.permission_mode, PermissionMode::Default);
477 }
478
479 #[test]
480 fn builder_full() {
481 let config = ClientConfig::builder()
482 .prompt("test prompt")
483 .model("claude-opus-4-5")
484 .max_turns(5_u32)
485 .max_budget_usd(1.0_f64)
486 .permission_mode(PermissionMode::AcceptEdits)
487 .verbose(true)
488 .build();
489
490 assert_eq!(config.model.as_deref(), Some("claude-opus-4-5"));
491 assert_eq!(config.max_turns, Some(5));
492 assert_eq!(config.max_budget_usd, Some(1.0));
493 assert_eq!(config.permission_mode, PermissionMode::AcceptEdits);
494 assert!(config.verbose);
495 }
496
497 #[test]
498 fn to_cli_args_minimal() {
499 let config = ClientConfig::builder().prompt("hello").build();
500 let args = config.to_cli_args();
501 assert!(args.contains(&"--output-format".into()));
502 assert!(args.contains(&"json".into()));
503 assert!(args.contains(&"--print".into()));
504 assert!(args.contains(&"hello".into()));
505 }
506
507 #[test]
508 fn to_cli_args_with_model_and_turns() {
509 let config = ClientConfig::builder()
510 .prompt("test")
511 .model("claude-sonnet-4-5")
512 .max_turns(10_u32)
513 .build();
514 let args = config.to_cli_args();
515 assert!(args.contains(&"--model".into()));
516 assert!(args.contains(&"claude-sonnet-4-5".into()));
517 assert!(args.contains(&"--max-turns".into()));
518 assert!(args.contains(&"10".into()));
519 }
520
521 #[test]
522 fn to_cli_args_with_permission_mode() {
523 let config = ClientConfig::builder()
524 .prompt("test")
525 .permission_mode(PermissionMode::BypassPermissions)
526 .build();
527 let args = config.to_cli_args();
528 assert!(args.contains(&"--permission-mode".into()));
529 assert!(args.contains(&"bypassPermissions".into()));
530 }
531
532 #[test]
533 fn to_cli_args_default_permission_mode_not_included() {
534 let config = ClientConfig::builder().prompt("test").build();
535 let args = config.to_cli_args();
536 assert!(!args.contains(&"--permission-mode".into()));
537 }
538
539 #[test]
540 fn to_cli_args_with_system_prompt_text() {
541 let config = ClientConfig::builder()
542 .prompt("test")
543 .system_prompt(SystemPrompt::text("You are a helpful assistant"))
544 .build();
545 let args = config.to_cli_args();
546 assert!(args.contains(&"--system-prompt".into()));
547 assert!(args.contains(&"You are a helpful assistant".into()));
548 }
549
550 #[test]
551 fn to_cli_args_with_mcp_servers() {
552 use crate::mcp::McpServerConfig;
553
554 let mut servers = McpServers::new();
555 servers.insert(
556 "fs".into(),
557 McpServerConfig::new("npx").with_args(["-y", "mcp-fs"]),
558 );
559
560 let config = ClientConfig::builder()
561 .prompt("test")
562 .mcp_servers(servers)
563 .build();
564 let args = config.to_cli_args();
565 assert!(args.contains(&"--mcp-servers".into()));
566 }
567
568 #[test]
569 fn to_env_without_callbacks() {
570 let config = ClientConfig::builder().prompt("test").build();
571 let env = config.to_env();
572 assert!(!env.contains_key("CLAUDE_CODE_SDK_CONTROL_PORT"));
573 }
574
575 #[test]
576 fn to_env_includes_originator_and_headless_defaults() {
577 let config = ClientConfig::builder().prompt("test").build();
578 let env = config.to_env();
579 assert_eq!(
580 env.get("CLAUDE_CODE_SDK_ORIGINATOR"),
581 Some(&"claude_cli_sdk_rs".into())
582 );
583 assert_eq!(env.get("CI"), Some(&"true".into()));
584 assert_eq!(env.get("TERM"), Some(&"dumb".into()));
585 }
586
587 #[test]
588 fn to_env_user_env_overrides_defaults() {
589 let config = ClientConfig::builder()
590 .prompt("test")
591 .env(HashMap::from([
592 ("CI".into(), "false".into()),
593 ("TERM".into(), "xterm-256color".into()),
594 ]))
595 .build();
596 let env = config.to_env();
597 assert_eq!(env.get("CI"), Some(&"false".into()));
599 assert_eq!(env.get("TERM"), Some(&"xterm-256color".into()));
600 assert_eq!(
602 env.get("CLAUDE_CODE_SDK_ORIGINATOR"),
603 Some(&"claude_cli_sdk_rs".into())
604 );
605 }
606
607 #[test]
608 fn to_env_with_hooks_enables_control_port() {
609 use crate::hooks::{HookCallback, HookEvent, HookMatcher, HookOutput};
610 let cb: HookCallback = Arc::new(|_, _, _| Box::pin(async { HookOutput::allow() }));
611 let config = ClientConfig::builder()
612 .prompt("test")
613 .hooks(vec![HookMatcher::new(HookEvent::PreToolUse, cb)])
614 .build();
615 let env = config.to_env();
616 assert_eq!(
617 env.get("CLAUDE_CODE_SDK_CONTROL_PORT"),
618 Some(&"stdin".into())
619 );
620 }
621
622 #[test]
623 fn permission_mode_serde_round_trip() {
624 let modes = [
625 PermissionMode::Default,
626 PermissionMode::AcceptEdits,
627 PermissionMode::Plan,
628 PermissionMode::BypassPermissions,
629 ];
630 for mode in modes {
631 let json = serde_json::to_string(&mode).unwrap();
632 let decoded: PermissionMode = serde_json::from_str(&json).unwrap();
633 assert_eq!(mode, decoded);
634 }
635 }
636
637 #[test]
638 fn system_prompt_text_round_trip() {
639 let sp = SystemPrompt::text("You are helpful");
640 let json = serde_json::to_string(&sp).unwrap();
641 let decoded: SystemPrompt = serde_json::from_str(&json).unwrap();
642 assert_eq!(sp, decoded);
643 }
644
645 #[test]
646 fn system_prompt_preset_round_trip() {
647 let sp = SystemPrompt::Preset {
648 kind: "custom".into(),
649 preset: "coding".into(),
650 append: Some("Also be concise.".into()),
651 };
652 let json = serde_json::to_string(&sp).unwrap();
653 let decoded: SystemPrompt = serde_json::from_str(&json).unwrap();
654 assert_eq!(sp, decoded);
655 }
656
657 #[test]
658 fn debug_does_not_panic() {
659 let config = ClientConfig::builder().prompt("test").build();
660 let _ = format!("{config:?}");
661 }
662
663 #[test]
664 fn to_cli_args_with_allowed_tools() {
665 let config = ClientConfig::builder()
666 .prompt("test")
667 .allowed_tools(vec!["bash".into(), "read_file".into()])
668 .build();
669 let args = config.to_cli_args();
670 let allowed_count = args.iter().filter(|a| *a == "--allowedTools").count();
671 assert_eq!(allowed_count, 2);
672 }
673
674 #[test]
675 fn to_cli_args_with_extra_args_boolean_flag() {
676 let config = ClientConfig::builder()
677 .prompt("test")
678 .extra_args(BTreeMap::from([("replay-user-messages".into(), None)]))
679 .build();
680 let args = config.to_cli_args();
681 assert!(args.contains(&"--replay-user-messages".into()));
682 }
683
684 #[test]
685 fn to_cli_args_with_extra_args_valued_flag() {
686 let config = ClientConfig::builder()
687 .prompt("test")
688 .extra_args(BTreeMap::from([(
689 "context-window".into(),
690 Some("200000".into()),
691 )]))
692 .build();
693 let args = config.to_cli_args();
694 let idx = args.iter().position(|a| a == "--context-window").unwrap();
695 assert_eq!(args[idx + 1], "200000");
696 }
697
698 #[test]
699 fn builder_timeout_defaults() {
700 let config = ClientConfig::builder().prompt("test").build();
701 assert_eq!(config.connect_timeout, Some(Duration::from_secs(30)));
702 assert_eq!(config.close_timeout, Some(Duration::from_secs(10)));
703 assert_eq!(config.read_timeout, None);
704 assert_eq!(config.default_hook_timeout, Duration::from_secs(30));
705 assert_eq!(config.version_check_timeout, Some(Duration::from_secs(5)));
706 }
707
708 #[test]
709 fn builder_custom_timeouts() {
710 let config = ClientConfig::builder()
711 .prompt("test")
712 .connect_timeout(Some(Duration::from_secs(60)))
713 .close_timeout(Some(Duration::from_secs(20)))
714 .read_timeout(Some(Duration::from_secs(120)))
715 .default_hook_timeout(Duration::from_secs(10))
716 .version_check_timeout(Some(Duration::from_secs(15)))
717 .build();
718 assert_eq!(config.connect_timeout, Some(Duration::from_secs(60)));
719 assert_eq!(config.close_timeout, Some(Duration::from_secs(20)));
720 assert_eq!(config.read_timeout, Some(Duration::from_secs(120)));
721 assert_eq!(config.default_hook_timeout, Duration::from_secs(10));
722 assert_eq!(config.version_check_timeout, Some(Duration::from_secs(15)));
723 }
724
725 #[test]
726 fn builder_disable_connect_timeout() {
727 let config = ClientConfig::builder()
728 .prompt("test")
729 .connect_timeout(None::<Duration>)
730 .build();
731 assert_eq!(config.connect_timeout, None);
732 }
733
734 #[test]
735 fn builder_cancellation_token() {
736 let token = CancellationToken::new();
737 let config = ClientConfig::builder()
738 .prompt("test")
739 .cancellation_token(token.clone())
740 .build();
741 assert!(config.cancellation_token.is_some());
742 }
743
744 #[test]
745 fn builder_cancellation_token_default_is_none() {
746 let config = ClientConfig::builder().prompt("test").build();
747 assert!(config.cancellation_token.is_none());
748 }
749
750 #[test]
751 fn to_cli_args_with_resume() {
752 let config = ClientConfig::builder()
753 .prompt("test")
754 .resume("session-123")
755 .build();
756 let args = config.to_cli_args();
757 assert!(args.contains(&"--resume".into()));
758 assert!(args.contains(&"session-123".into()));
759 }
760}