1use crate::Claude;
12use crate::command::ClaudeCommand;
13use crate::command::spawn_args::{SharedSpawnArgs, shell_quote};
14#[cfg(any(feature = "async", feature = "sync"))]
15use crate::error::Result;
16use crate::exec::{self, CommandOutput};
17use crate::tool_pattern::ToolPattern;
18use crate::types::{Effort, HermeticScope, InputFormat, OutputFormat, PermissionMode};
19
20#[derive(Debug, Clone)]
43pub struct QueryCommand {
44 prompt: String,
45 shared: SharedSpawnArgs,
48 output_format: Option<OutputFormat>,
49 include_partial_messages: bool,
50 input_format: Option<InputFormat>,
51 retry_policy: Option<crate::retry::RetryPolicy>,
52 brief: bool,
53 from_pr: Option<String>,
54 prompt_via_stdin: bool,
55 verbose: bool,
56 prompt_suggestions: bool,
57 replay_user_messages: bool,
58}
59
60impl QueryCommand {
61 #[must_use]
63 pub fn new(prompt: impl Into<String>) -> Self {
64 Self {
65 prompt: prompt.into(),
66 shared: SharedSpawnArgs::default(),
67 output_format: None,
68 include_partial_messages: false,
69 input_format: None,
70 retry_policy: None,
71 brief: false,
72 from_pr: None,
73 prompt_via_stdin: false,
74 verbose: false,
75 prompt_suggestions: false,
76 replay_user_messages: false,
77 }
78 }
79
80 #[must_use]
82 pub fn model(mut self, model: impl Into<String>) -> Self {
83 self.shared.model = Some(model.into());
84 self
85 }
86
87 #[must_use]
89 pub fn system_prompt(mut self, prompt: impl Into<String>) -> Self {
90 self.shared.system_prompt = Some(prompt.into());
91 self
92 }
93
94 #[must_use]
96 pub fn append_system_prompt(mut self, prompt: impl Into<String>) -> Self {
97 self.shared.append_system_prompt = Some(prompt.into());
98 self
99 }
100
101 #[must_use]
103 pub fn output_format(mut self, format: OutputFormat) -> Self {
104 self.output_format = Some(format);
105 self
106 }
107
108 #[must_use]
110 pub fn max_budget_usd(mut self, budget: f64) -> Self {
111 self.shared.max_budget_usd = Some(budget);
112 self
113 }
114
115 #[must_use]
117 pub fn permission_mode(mut self, mode: PermissionMode) -> Self {
118 self.shared.permission_mode = Some(mode);
119 self
120 }
121
122 #[must_use]
138 pub fn allowed_tools<I, T>(mut self, tools: I) -> Self
139 where
140 I: IntoIterator<Item = T>,
141 T: Into<ToolPattern>,
142 {
143 self.shared
144 .allowed_tools
145 .extend(tools.into_iter().map(Into::into));
146 self
147 }
148
149 #[must_use]
151 pub fn allowed_tool(mut self, tool: impl Into<ToolPattern>) -> Self {
152 self.shared.allowed_tools.push(tool.into());
153 self
154 }
155
156 #[must_use]
158 pub fn disallowed_tools<I, T>(mut self, tools: I) -> Self
159 where
160 I: IntoIterator<Item = T>,
161 T: Into<ToolPattern>,
162 {
163 self.shared
164 .disallowed_tools
165 .extend(tools.into_iter().map(Into::into));
166 self
167 }
168
169 #[must_use]
171 pub fn disallowed_tool(mut self, tool: impl Into<ToolPattern>) -> Self {
172 self.shared.disallowed_tools.push(tool.into());
173 self
174 }
175
176 #[must_use]
178 pub fn mcp_config(mut self, path: impl Into<String>) -> Self {
179 self.shared.mcp_config.push(path.into());
180 self
181 }
182
183 #[must_use]
185 pub fn add_dir(mut self, dir: impl Into<String>) -> Self {
186 self.shared.add_dir.push(dir.into());
187 self
188 }
189
190 #[must_use]
192 pub fn effort(mut self, effort: Effort) -> Self {
193 self.shared.effort = Some(effort);
194 self
195 }
196
197 #[must_use]
199 pub fn max_turns(mut self, turns: u32) -> Self {
200 self.shared.max_turns = Some(turns);
201 self
202 }
203
204 #[must_use]
206 pub fn json_schema(mut self, schema: impl Into<String>) -> Self {
207 self.shared.json_schema = Some(schema.into());
208 self
209 }
210
211 #[must_use]
213 pub fn continue_session(mut self) -> Self {
214 self.shared.continue_session = true;
215 self
216 }
217
218 #[must_use]
220 pub fn resume(mut self, session_id: impl Into<String>) -> Self {
221 self.shared.resume = Some(session_id.into());
222 self
223 }
224
225 #[must_use]
227 pub fn session_id(mut self, id: impl Into<String>) -> Self {
228 self.shared.session_id = Some(id.into());
229 self
230 }
231
232 #[cfg(all(feature = "json", feature = "async"))]
240 pub(crate) fn replace_session(mut self, id: impl Into<String>) -> Self {
241 self.shared.continue_session = false;
242 self.shared.resume = Some(id.into());
243 self.shared.session_id = None;
244 self.shared.fork_session = false;
245 self
246 }
247
248 #[must_use]
250 pub fn fallback_model(mut self, model: impl Into<String>) -> Self {
251 self.shared.fallback_model = Some(model.into());
252 self
253 }
254
255 #[must_use]
257 pub fn no_session_persistence(mut self) -> Self {
258 self.shared.no_session_persistence = true;
259 self
260 }
261
262 #[must_use]
264 pub fn dangerously_skip_permissions(mut self) -> Self {
265 self.shared.dangerously_skip_permissions = true;
266 self
267 }
268
269 #[must_use]
283 pub fn agent(mut self, agent: impl Into<String>) -> Self {
284 self.shared.agent = Some(agent.into());
285 self
286 }
287
288 #[must_use]
300 pub fn agents_json(mut self, json: impl Into<String>) -> Self {
301 self.shared.agents_json = Some(json.into());
302 self
303 }
304
305 #[must_use]
311 pub fn tools(mut self, tools: impl IntoIterator<Item = impl Into<String>>) -> Self {
312 self.shared.tools.extend(tools.into_iter().map(Into::into));
313 self
314 }
315
316 #[must_use]
320 pub fn file(mut self, spec: impl Into<String>) -> Self {
321 self.shared.file.push(spec.into());
322 self
323 }
324
325 #[must_use]
329 pub fn include_partial_messages(mut self) -> Self {
330 self.include_partial_messages = true;
331 self
332 }
333
334 #[must_use]
336 pub fn input_format(mut self, format: InputFormat) -> Self {
337 self.input_format = Some(format);
338 self
339 }
340
341 #[must_use]
343 pub fn strict_mcp_config(mut self) -> Self {
344 self.shared.strict_mcp_config = true;
345 self
346 }
347
348 #[must_use]
350 pub fn settings(mut self, settings: impl Into<String>) -> Self {
351 self.shared.settings = Some(settings.into());
352 self
353 }
354
355 #[must_use]
357 pub fn fork_session(mut self) -> Self {
358 self.shared.fork_session = true;
359 self
360 }
361
362 #[must_use]
364 pub fn worktree(mut self) -> Self {
365 self.shared.worktree = true;
366 self
367 }
368
369 #[must_use]
392 pub fn worktree_named(mut self, name: impl Into<String>) -> Self {
393 self.shared.worktree = true;
394 self.shared.worktree_name = Some(name.into());
395 self
396 }
397
398 #[must_use]
400 pub fn brief(mut self) -> Self {
401 self.brief = true;
402 self
403 }
404
405 #[must_use]
407 pub fn debug_filter(mut self, filter: impl Into<String>) -> Self {
408 self.shared.debug_filter = Some(filter.into());
409 self
410 }
411
412 #[must_use]
414 pub fn debug_file(mut self, path: impl Into<String>) -> Self {
415 self.shared.debug_file = Some(path.into());
416 self
417 }
418
419 #[must_use]
421 pub fn betas(mut self, betas: impl Into<String>) -> Self {
422 self.shared.betas = Some(betas.into());
423 self
424 }
425
426 #[must_use]
428 pub fn plugin_dir(mut self, dir: impl Into<String>) -> Self {
429 self.shared.plugin_dirs.push(dir.into());
430 self
431 }
432
433 #[must_use]
437 pub fn plugin_url(mut self, url: impl Into<String>) -> Self {
438 self.shared.plugin_urls.push(url.into());
439 self
440 }
441
442 #[must_use]
444 pub fn setting_sources(mut self, sources: impl Into<String>) -> Self {
445 self.shared.setting_sources = Some(sources.into());
446 self
447 }
448
449 #[must_use]
464 pub fn hermetic(mut self) -> Self {
465 self.shared.apply_hermetic(HermeticScope::Full);
466 self
467 }
468
469 #[must_use]
476 pub fn hermetic_scoped(mut self, scope: HermeticScope) -> Self {
477 self.shared.apply_hermetic(scope);
478 self
479 }
480
481 #[must_use]
483 pub fn tmux(mut self) -> Self {
484 self.shared.tmux = true;
485 self
486 }
487
488 #[must_use]
504 pub fn bare(mut self) -> Self {
505 self.shared.bare = true;
506 self
507 }
508
509 #[must_use]
511 pub fn disable_slash_commands(mut self) -> Self {
512 self.shared.disable_slash_commands = true;
513 self
514 }
515
516 #[must_use]
525 pub fn safe_mode(mut self) -> Self {
526 self.shared.safe_mode = true;
527 self
528 }
529
530 #[must_use]
534 pub fn include_hook_events(mut self) -> Self {
535 self.shared.include_hook_events = true;
536 self
537 }
538
539 #[must_use]
545 pub fn exclude_dynamic_system_prompt_sections(mut self) -> Self {
546 self.shared.exclude_dynamic_system_prompt_sections = true;
547 self
548 }
549
550 #[must_use]
553 pub fn name(mut self, name: impl Into<String>) -> Self {
554 self.shared.name = Some(name.into());
555 self
556 }
557
558 #[must_use]
565 pub fn from_pr(mut self, pr: impl Into<String>) -> Self {
566 self.from_pr = Some(pr.into());
567 self
568 }
569
570 #[must_use]
578 pub fn verbose(mut self, value: bool) -> Self {
579 self.verbose = value;
580 self
581 }
582
583 #[must_use]
589 pub fn prompt_suggestions(mut self, value: bool) -> Self {
590 self.prompt_suggestions = value;
591 self
592 }
593
594 #[must_use]
602 pub fn replay_user_messages(mut self, value: bool) -> Self {
603 self.replay_user_messages = value;
604 self
605 }
606
607 #[must_use]
630 pub fn retry(mut self, policy: crate::retry::RetryPolicy) -> Self {
631 self.retry_policy = Some(policy);
632 self
633 }
634
635 pub fn to_command_string(&self, claude: &Claude) -> String {
660 let args = exec::full_command_args(claude, self.build_args());
661 let quoted_args = args.iter().map(|arg| shell_quote(arg)).collect::<Vec<_>>();
662 format!("{} {}", claude.binary().display(), quoted_args.join(" "))
663 }
664
665 #[cfg(feature = "async")]
670 pub async fn execute_cancellable<C>(&self, claude: &Claude, cancel: C) -> Result<CommandOutput>
671 where
672 C: std::future::Future<Output = ()> + Send,
673 {
674 if self.prompt_via_stdin {
675 exec::run_claude_with_stdin_prompt_cancellable(
676 claude,
677 self.build_args(),
678 self.prompt.clone(),
679 cancel,
680 )
681 .await
682 } else {
683 exec::run_claude_cancellable(claude, self.args(), cancel).await
684 }
685 }
686
687 #[cfg(all(feature = "json", feature = "async"))]
692 pub async fn execute_json(&self, claude: &Claude) -> Result<crate::types::QueryResult> {
693 let args = self.build_args_with_forced_json();
694
695 let output = if self.prompt_via_stdin {
696 exec::run_claude_with_stdin_prompt(claude, args, self.prompt.clone()).await?
699 } else {
700 exec::run_claude_with_retry(claude, args, self.retry_policy.as_ref()).await?
701 };
702
703 serde_json::from_str(&output.stdout).map_err(|e| crate::error::Error::Json {
704 message: format!("failed to parse query result: {e}"),
705 source: e,
706 })
707 }
708
709 #[cfg(all(feature = "json", feature = "async"))]
714 pub async fn execute_json_cancellable<C>(
715 &self,
716 claude: &Claude,
717 cancel: C,
718 ) -> Result<crate::types::QueryResult>
719 where
720 C: std::future::Future<Output = ()> + Send,
721 {
722 let args = self.build_args_with_forced_json();
723 let output = if self.prompt_via_stdin {
724 exec::run_claude_with_stdin_prompt_cancellable(
725 claude,
726 args,
727 self.prompt.clone(),
728 cancel,
729 )
730 .await?
731 } else {
732 exec::run_claude_cancellable(claude, args, cancel).await?
733 };
734
735 serde_json::from_str(&output.stdout).map_err(|e| crate::error::Error::Json {
736 message: format!("failed to parse query result: {e}"),
737 source: e,
738 })
739 }
740
741 #[cfg(feature = "sync")]
748 pub fn execute_sync(&self, claude: &Claude) -> Result<CommandOutput> {
749 if self.prompt_via_stdin {
750 exec::run_claude_with_stdin_prompt_sync(claude, self.build_args(), self.prompt.clone())
753 } else {
754 exec::run_claude_with_retry_sync(claude, self.args(), self.retry_policy.as_ref())
755 }
756 }
757
758 #[cfg(all(feature = "sync", feature = "json"))]
760 pub fn execute_json_sync(&self, claude: &Claude) -> Result<crate::types::QueryResult> {
761 let args = self.build_args_with_forced_json();
762
763 let output = if self.prompt_via_stdin {
764 exec::run_claude_with_stdin_prompt_sync(claude, args, self.prompt.clone())?
767 } else {
768 exec::run_claude_with_retry_sync(claude, args, self.retry_policy.as_ref())?
769 };
770
771 serde_json::from_str(&output.stdout).map_err(|e| crate::error::Error::Json {
772 message: format!("failed to parse query result: {e}"),
773 source: e,
774 })
775 }
776
777 #[must_use]
803 pub fn prompt_via_stdin(mut self, value: bool) -> Self {
804 self.prompt_via_stdin = value;
805 self
806 }
807
808 #[cfg(all(feature = "json", feature = "async"))]
809 pub(crate) fn stdin_prompt(&self) -> Option<&str> {
810 self.prompt_via_stdin.then_some(self.prompt.as_str())
811 }
812
813 #[allow(dead_code)] fn build_args_with_forced_json(&self) -> Vec<String> {
822 if self.output_format.is_some() {
823 return self.build_args();
824 }
825 let mut effective = self.clone();
826 effective.output_format = Some(OutputFormat::Json);
827 effective.build_args()
828 }
829
830 fn build_args(&self) -> Vec<String> {
831 let mut args = vec!["--print".to_string()];
832
833 if let Some(ref format) = self.output_format {
834 args.push("--output-format".to_string());
835 args.push(format.as_arg().to_string());
836 }
837
838 if self.verbose || matches!(self.output_format, Some(OutputFormat::StreamJson)) {
842 args.push("--verbose".to_string());
843 }
844
845 self.shared.append_to(&mut args);
846
847 if self.include_partial_messages {
848 args.push("--include-partial-messages".to_string());
849 }
850
851 if let Some(ref format) = self.input_format {
852 args.push("--input-format".to_string());
853 args.push(format.as_arg().to_string());
854 }
855
856 if self.brief {
857 args.push("--brief".to_string());
858 }
859
860 if self.prompt_suggestions {
861 args.push("--prompt-suggestions".to_string());
862 }
863
864 if self.replay_user_messages {
865 args.push("--replay-user-messages".to_string());
866 }
867
868 if let Some(ref pr) = self.from_pr {
869 args.push("--from-pr".to_string());
870 args.push(pr.clone());
871 }
872
873 if !self.prompt_via_stdin {
877 args.push("--".to_string());
878 args.push(self.prompt.clone());
879 }
880
881 args
882 }
883}
884
885impl ClaudeCommand for QueryCommand {
886 type Output = CommandOutput;
887
888 fn args(&self) -> Vec<String> {
889 self.build_args()
890 }
891
892 #[cfg(feature = "async")]
893 async fn execute(&self, claude: &Claude) -> Result<CommandOutput> {
894 if self.prompt_via_stdin {
895 let args = self.build_args(); exec::run_claude_with_stdin_prompt(claude, args, self.prompt.clone()).await
899 } else {
900 exec::run_claude_with_retry(claude, self.args(), self.retry_policy.as_ref()).await
901 }
902 }
903}
904
905#[cfg(test)]
906mod tests {
907 use super::*;
908
909 #[test]
910 fn test_basic_query_args() {
911 let cmd = QueryCommand::new("hello world");
912 let args = cmd.args();
913 assert_eq!(args, vec!["--print", "--", "hello world"]);
914 }
915
916 #[test]
917 fn prompt_via_stdin_omits_prompt_from_args() {
918 let cmd = QueryCommand::new("secret payload").prompt_via_stdin(true);
919 let args = cmd.args();
920 assert!(
921 !args.contains(&"secret payload".to_string()),
922 "prompt must not appear in args when prompt_via_stdin is set"
923 );
924 assert!(
925 !args.contains(&"--".to_string()),
926 "-- separator must be absent when prompt_via_stdin is set"
927 );
928 }
929
930 #[test]
931 fn prompt_via_stdin_false_keeps_prompt_in_args() {
932 let cmd = QueryCommand::new("visible prompt").prompt_via_stdin(false);
933 let args = cmd.args();
934 assert!(
935 args.contains(&"visible prompt".to_string()),
936 "prompt must still appear in args when prompt_via_stdin is false"
937 );
938 assert!(
939 args.contains(&"--".to_string()),
940 "-- separator must be present when prompt_via_stdin is false"
941 );
942 }
943
944 #[test]
945 #[cfg(feature = "async")] #[ignore = "requires a real claude binary"]
947 fn prompt_via_stdin_integration() {
948 use crate::{Claude, ClaudeCommand};
951 let rt = tokio::runtime::Runtime::new().unwrap();
952 rt.block_on(async {
953 let claude = Claude::builder().build().unwrap();
954 let out = QueryCommand::new("reply with: STDIN_OK")
955 .prompt_via_stdin(true)
956 .execute(&claude)
957 .await
958 .unwrap();
959 assert!(
960 !out.stdout.is_empty(),
961 "expected non-empty output from stdin-mode query"
962 );
963 });
964 }
965
966 #[test]
967 fn build_args_with_forced_json_inserts_flag_before_separator() {
968 let cmd = QueryCommand::new("hello");
974 let args = cmd.build_args_with_forced_json();
975
976 assert_eq!(
978 &args[args.len() - 2..],
979 &["--".to_string(), "hello".to_string()],
980 );
981
982 let sep = args.iter().position(|a| a == "--").expect("`--` present");
984 let fmt = args
985 .iter()
986 .position(|a| a == "--output-format")
987 .expect("--output-format present");
988 assert!(
989 fmt < sep,
990 "--output-format must come before `--` separator; got {args:?}"
991 );
992 assert_eq!(args[fmt + 1], "json");
993 }
994
995 #[test]
996 fn build_args_with_forced_json_respects_explicit_format() {
997 let cmd = QueryCommand::new("hello").output_format(OutputFormat::Text);
1000 let args = cmd.build_args_with_forced_json();
1001 let fmt = args
1002 .iter()
1003 .position(|a| a == "--output-format")
1004 .expect("--output-format present");
1005 assert_eq!(args[fmt + 1], "text");
1006 assert_eq!(args.iter().filter(|a| *a == "--output-format").count(), 1);
1008 }
1009
1010 #[test]
1011 #[allow(deprecated)] fn test_full_query_args() {
1013 let cmd = QueryCommand::new("explain this")
1014 .model("sonnet")
1015 .system_prompt("be concise")
1016 .output_format(OutputFormat::Json)
1017 .max_budget_usd(0.50)
1018 .permission_mode(PermissionMode::BypassPermissions)
1019 .allowed_tools(["Bash", "Read"])
1020 .mcp_config("/tmp/mcp.json")
1021 .effort(Effort::High)
1022 .max_turns(3)
1023 .no_session_persistence();
1024
1025 let args = cmd.args();
1026 assert!(args.contains(&"--print".to_string()));
1027 assert!(args.contains(&"--model".to_string()));
1028 assert!(args.contains(&"sonnet".to_string()));
1029 assert!(args.contains(&"--system-prompt".to_string()));
1030 assert!(args.contains(&"--output-format".to_string()));
1031 assert!(args.contains(&"json".to_string()));
1032 assert!(!args.contains(&"--verbose".to_string()));
1034 assert!(args.contains(&"--max-budget-usd".to_string()));
1035 assert!(args.contains(&"--permission-mode".to_string()));
1036 assert!(args.contains(&"bypassPermissions".to_string()));
1037 assert!(args.contains(&"--allowed-tools".to_string()));
1038 assert!(args.contains(&"Bash,Read".to_string()));
1039 assert!(args.contains(&"--effort".to_string()));
1040 assert!(args.contains(&"high".to_string()));
1041 assert!(args.contains(&"--max-turns".to_string()));
1042 assert!(args.contains(&"--no-session-persistence".to_string()));
1043 assert_eq!(args.last().unwrap(), "explain this");
1045 assert_eq!(args[args.len() - 2], "--");
1046 }
1047
1048 #[test]
1049 fn typed_patterns_render_in_allowed_tools() {
1050 use crate::ToolPattern;
1051
1052 let cmd = QueryCommand::new("hi")
1053 .allowed_tool(ToolPattern::tool("Read"))
1054 .allowed_tool(ToolPattern::tool_with_args("Bash", "git log:*"))
1055 .allowed_tool(ToolPattern::all("Write"))
1056 .allowed_tool(ToolPattern::mcp("srv", "*"));
1057
1058 let args = cmd.args();
1059 let joined = args
1060 .iter()
1061 .position(|a| a == "--allowed-tools")
1062 .map(|i| &args[i + 1])
1063 .unwrap();
1064 assert_eq!(joined, "Read,Bash(git log:*),Write(*),mcp__srv__*");
1065 }
1066
1067 #[test]
1068 fn disallowed_tool_singular_appends() {
1069 use crate::ToolPattern;
1070
1071 let cmd = QueryCommand::new("hi")
1072 .disallowed_tool("Write")
1073 .disallowed_tool(ToolPattern::tool_with_args("Bash", "rm*"));
1074
1075 let args = cmd.args();
1076 let joined = args
1077 .iter()
1078 .position(|a| a == "--disallowed-tools")
1079 .map(|i| &args[i + 1])
1080 .unwrap();
1081 assert_eq!(joined, "Write,Bash(rm*)");
1082 }
1083
1084 #[test]
1085 fn mixed_string_and_typed_patterns_both_accepted() {
1086 use crate::ToolPattern;
1087
1088 let strs: Vec<ToolPattern> = vec!["Bash".into(), ToolPattern::all("Read")];
1092 let cmd = QueryCommand::new("hi").allowed_tools(strs);
1093 assert!(cmd.args().contains(&"--allowed-tools".to_string()));
1094 }
1095
1096 #[test]
1097 fn new_bool_flags_emit_correct_cli_args() {
1098 let args = QueryCommand::new("hi")
1099 .bare()
1100 .disable_slash_commands()
1101 .include_hook_events()
1102 .exclude_dynamic_system_prompt_sections()
1103 .args();
1104 assert!(args.contains(&"--bare".to_string()));
1105 assert!(args.contains(&"--disable-slash-commands".to_string()));
1106 assert!(args.contains(&"--include-hook-events".to_string()));
1107 assert!(args.contains(&"--exclude-dynamic-system-prompt-sections".to_string()));
1108 }
1109
1110 #[test]
1111 fn name_flag_renders_with_value() {
1112 let args = QueryCommand::new("hi").name("my session").args();
1113 let pos = args.iter().position(|a| a == "--name").unwrap();
1114 assert_eq!(args[pos + 1], "my session");
1115 }
1116
1117 #[test]
1118 fn from_pr_flag_renders_with_value() {
1119 let args = QueryCommand::new("hi").from_pr("42").args();
1120 let pos = args.iter().position(|a| a == "--from-pr").unwrap();
1121 assert_eq!(args[pos + 1], "42");
1122 }
1123
1124 #[test]
1125 fn new_bool_flags_default_to_off() {
1126 let args = QueryCommand::new("hi").args();
1127 assert!(!args.contains(&"--bare".to_string()));
1128 assert!(!args.contains(&"--disable-slash-commands".to_string()));
1129 assert!(!args.contains(&"--include-hook-events".to_string()));
1130 assert!(!args.contains(&"--exclude-dynamic-system-prompt-sections".to_string()));
1131 assert!(!args.contains(&"--name".to_string()));
1132 }
1133
1134 #[test]
1135 fn test_separator_before_prompt_prevents_greedy_flag_parsing() {
1136 let cmd = QueryCommand::new("fix the bug")
1139 .allowed_tools(["Read", "Edit", "Bash(cargo *)"])
1140 .output_format(OutputFormat::StreamJson);
1141 let args = cmd.args();
1142 let sep_pos = args.iter().position(|a| a == "--").unwrap();
1144 let prompt_pos = args.iter().position(|a| a == "fix the bug").unwrap();
1145 assert_eq!(prompt_pos, sep_pos + 1, "prompt must follow -- separator");
1146 let tools_pos = args
1148 .iter()
1149 .position(|a| a.contains("Bash(cargo *)"))
1150 .unwrap();
1151 assert!(
1152 tools_pos < sep_pos,
1153 "allowed-tools must come before -- separator"
1154 );
1155 }
1156
1157 #[test]
1158 fn test_stream_json_includes_verbose() {
1159 let cmd = QueryCommand::new("test").output_format(OutputFormat::StreamJson);
1160 let args = cmd.args();
1161 assert!(args.contains(&"--output-format".to_string()));
1162 assert!(args.contains(&"stream-json".to_string()));
1163 assert!(args.contains(&"--verbose".to_string()));
1164 }
1165
1166 #[test]
1167 fn verbose_flag_emitted_when_set() {
1168 let args = QueryCommand::new("test").verbose(true).args();
1169 assert!(args.contains(&"--verbose".to_string()));
1170 }
1171
1172 #[test]
1173 fn verbose_absent_by_default_and_when_false() {
1174 assert!(
1175 !QueryCommand::new("test")
1176 .args()
1177 .contains(&"--verbose".to_string())
1178 );
1179 assert!(
1180 !QueryCommand::new("test")
1181 .verbose(false)
1182 .args()
1183 .contains(&"--verbose".to_string())
1184 );
1185 }
1186
1187 #[test]
1188 fn verbose_not_duplicated_with_stream_json() {
1189 let cmd = QueryCommand::new("test")
1192 .verbose(true)
1193 .output_format(OutputFormat::StreamJson);
1194 let count = cmd.args().iter().filter(|a| *a == "--verbose").count();
1195 assert_eq!(count, 1, "--verbose must appear exactly once");
1196 }
1197
1198 #[test]
1199 fn prompt_suggestions_flag_emitted_when_set() {
1200 let args = QueryCommand::new("test").prompt_suggestions(true).args();
1201 assert!(args.contains(&"--prompt-suggestions".to_string()));
1202 let sep = args.iter().position(|a| a == "--").unwrap();
1205 let flag = args
1206 .iter()
1207 .position(|a| a == "--prompt-suggestions")
1208 .unwrap();
1209 assert!(flag < sep, "--prompt-suggestions must precede `--`");
1210 }
1211
1212 #[test]
1213 fn prompt_suggestions_absent_by_default_and_when_false() {
1214 assert!(
1215 !QueryCommand::new("test")
1216 .args()
1217 .contains(&"--prompt-suggestions".to_string())
1218 );
1219 assert!(
1220 !QueryCommand::new("test")
1221 .prompt_suggestions(false)
1222 .args()
1223 .contains(&"--prompt-suggestions".to_string())
1224 );
1225 }
1226
1227 #[test]
1228 fn replay_user_messages_flag_emitted_when_set() {
1229 let args = QueryCommand::new("test").replay_user_messages(true).args();
1230 assert!(args.contains(&"--replay-user-messages".to_string()));
1231 }
1232
1233 #[test]
1234 fn replay_user_messages_absent_by_default_and_when_false() {
1235 assert!(
1236 !QueryCommand::new("test")
1237 .args()
1238 .contains(&"--replay-user-messages".to_string())
1239 );
1240 assert!(
1241 !QueryCommand::new("test")
1242 .replay_user_messages(false)
1243 .args()
1244 .contains(&"--replay-user-messages".to_string())
1245 );
1246 }
1247
1248 #[test]
1249 fn test_to_command_string_simple() {
1250 let claude = Claude::builder()
1251 .binary("/usr/local/bin/claude")
1252 .build()
1253 .unwrap();
1254
1255 let cmd = QueryCommand::new("hello");
1256 let command_str = cmd.to_command_string(&claude);
1257
1258 assert!(command_str.starts_with("/usr/local/bin/claude"));
1259 assert!(command_str.contains("--print"));
1260 assert!(command_str.contains("hello"));
1261 }
1262
1263 #[test]
1264 fn test_to_command_string_with_spaces() {
1265 let claude = Claude::builder()
1266 .binary("/usr/local/bin/claude")
1267 .build()
1268 .unwrap();
1269
1270 let cmd = QueryCommand::new("hello world").model("sonnet");
1271 let command_str = cmd.to_command_string(&claude);
1272
1273 assert!(command_str.starts_with("/usr/local/bin/claude"));
1274 assert!(command_str.contains("--print"));
1275 assert!(command_str.contains("'hello world'"));
1277 assert!(command_str.contains("--model"));
1278 assert!(command_str.contains("sonnet"));
1279 }
1280
1281 #[test]
1282 fn test_to_command_string_with_special_chars() {
1283 let claude = Claude::builder()
1284 .binary("/usr/local/bin/claude")
1285 .build()
1286 .unwrap();
1287
1288 let cmd = QueryCommand::new("test $VAR and `cmd`");
1289 let command_str = cmd.to_command_string(&claude);
1290
1291 assert!(command_str.contains("'test $VAR and `cmd`'"));
1293 }
1294
1295 #[test]
1296 fn test_to_command_string_with_single_quotes() {
1297 let claude = Claude::builder()
1298 .binary("/usr/local/bin/claude")
1299 .build()
1300 .unwrap();
1301
1302 let cmd = QueryCommand::new("it's");
1303 let command_str = cmd.to_command_string(&claude);
1304
1305 assert!(command_str.contains("'it'\\''s'"));
1307 }
1308
1309 #[test]
1310 fn to_command_string_includes_global_args() {
1311 let claude = Claude::builder()
1314 .binary("/usr/local/bin/claude")
1315 .arg("--debug")
1316 .build()
1317 .unwrap();
1318
1319 let command_str = QueryCommand::new("hello").to_command_string(&claude);
1320
1321 assert!(
1322 command_str.starts_with("/usr/local/bin/claude --debug --print"),
1323 "global args must precede command args; got {command_str}"
1324 );
1325 }
1326
1327 #[test]
1328 fn test_worktree_flag() {
1329 let cmd = QueryCommand::new("test").worktree();
1330 let args = cmd.args();
1331 assert!(args.contains(&"--worktree".to_string()));
1332 }
1333
1334 #[test]
1335 fn test_worktree_named() {
1336 let cmd = QueryCommand::new("test").worktree_named("feature-x");
1337 let args = cmd.args();
1338 assert!(
1339 args.windows(2).any(|w| w == ["--worktree", "feature-x"]),
1340 "missing --worktree feature-x in {args:?}"
1341 );
1342 }
1343
1344 #[test]
1345 fn test_brief_flag() {
1346 let cmd = QueryCommand::new("test").brief();
1347 let args = cmd.args();
1348 assert!(args.contains(&"--brief".to_string()));
1349 }
1350
1351 #[test]
1352 fn test_debug_filter() {
1353 let cmd = QueryCommand::new("test").debug_filter("api,hooks");
1354 let args = cmd.args();
1355 assert!(args.contains(&"--debug".to_string()));
1356 assert!(args.contains(&"api,hooks".to_string()));
1357 }
1358
1359 #[test]
1360 fn test_debug_file() {
1361 let cmd = QueryCommand::new("test").debug_file("/tmp/debug.log");
1362 let args = cmd.args();
1363 assert!(args.contains(&"--debug-file".to_string()));
1364 assert!(args.contains(&"/tmp/debug.log".to_string()));
1365 }
1366
1367 #[test]
1368 fn test_betas() {
1369 let cmd = QueryCommand::new("test").betas("feature-x");
1370 let args = cmd.args();
1371 assert!(args.contains(&"--betas".to_string()));
1372 assert!(args.contains(&"feature-x".to_string()));
1373 }
1374
1375 #[test]
1376 fn test_plugin_dir_single() {
1377 let cmd = QueryCommand::new("test").plugin_dir("/plugins/foo");
1378 let args = cmd.args();
1379 assert!(args.contains(&"--plugin-dir".to_string()));
1380 assert!(args.contains(&"/plugins/foo".to_string()));
1381 }
1382
1383 #[test]
1384 fn test_plugin_dir_multiple() {
1385 let cmd = QueryCommand::new("test")
1386 .plugin_dir("/plugins/foo")
1387 .plugin_dir("/plugins/bar");
1388 let args = cmd.args();
1389 let plugin_dir_count = args.iter().filter(|a| *a == "--plugin-dir").count();
1390 assert_eq!(plugin_dir_count, 2);
1391 assert!(args.contains(&"/plugins/foo".to_string()));
1392 assert!(args.contains(&"/plugins/bar".to_string()));
1393 }
1394
1395 #[test]
1396 fn test_plugin_url_single() {
1397 let cmd = QueryCommand::new("test").plugin_url("https://example.com/p.zip");
1398 let args = cmd.args();
1399 assert!(args.contains(&"--plugin-url".to_string()));
1400 assert!(args.contains(&"https://example.com/p.zip".to_string()));
1401 }
1402
1403 #[test]
1404 fn test_plugin_url_multiple() {
1405 let cmd = QueryCommand::new("test")
1406 .plugin_url("https://example.com/a.zip")
1407 .plugin_url("https://example.com/b.zip");
1408 let args = cmd.args();
1409 let plugin_url_count = args.iter().filter(|a| *a == "--plugin-url").count();
1410 assert_eq!(plugin_url_count, 2);
1411 assert!(args.contains(&"https://example.com/a.zip".to_string()));
1412 assert!(args.contains(&"https://example.com/b.zip".to_string()));
1413 }
1414
1415 #[test]
1416 fn test_safe_mode_flag() {
1417 let cmd = QueryCommand::new("test").safe_mode();
1418 let args = cmd.args();
1419 assert!(args.contains(&"--safe-mode".to_string()));
1420 }
1421
1422 #[test]
1423 fn test_safe_mode_absent_by_default() {
1424 let cmd = QueryCommand::new("test");
1425 let args = cmd.args();
1426 assert!(!args.contains(&"--safe-mode".to_string()));
1427 }
1428
1429 #[test]
1430 fn hermetic_emits_full_seal_flags() {
1431 let args = QueryCommand::new("test").hermetic().args();
1432 assert!(
1433 args.windows(2)
1434 .any(|w| w[0] == "--setting-sources" && w[1].is_empty()),
1435 "got {args:?}"
1436 );
1437 assert!(args.contains(&"--strict-mcp-config".to_string()));
1438 assert!(args.contains(&"--exclude-dynamic-system-prompt-sections".to_string()));
1439 assert!(!args.contains(&"--bare".to_string()));
1440 }
1441
1442 #[test]
1443 fn hermetic_scoped_project_keeps_user() {
1444 let args = QueryCommand::new("test")
1445 .hermetic_scoped(HermeticScope::Project)
1446 .args();
1447 assert!(args.windows(2).any(|w| w == ["--setting-sources", "user"]));
1448 assert!(args.contains(&"--strict-mcp-config".to_string()));
1449 }
1450
1451 #[test]
1452 fn setting_sources_overrides_hermetic_scope() {
1453 let args = QueryCommand::new("test")
1456 .hermetic()
1457 .setting_sources("user,project")
1458 .args();
1459 assert!(
1460 args.windows(2)
1461 .any(|w| w == ["--setting-sources", "user,project"]),
1462 "got {args:?}"
1463 );
1464 assert_eq!(
1465 args.iter().filter(|a| *a == "--setting-sources").count(),
1466 1,
1467 "--setting-sources must not be duplicated"
1468 );
1469 }
1470
1471 #[test]
1472 fn test_setting_sources() {
1473 let cmd = QueryCommand::new("test").setting_sources("user,project,local");
1474 let args = cmd.args();
1475 assert!(args.contains(&"--setting-sources".to_string()));
1476 assert!(args.contains(&"user,project,local".to_string()));
1477 }
1478
1479 #[test]
1480 fn test_tmux_flag() {
1481 let cmd = QueryCommand::new("test").tmux();
1482 let args = cmd.args();
1483 assert!(args.contains(&"--tmux".to_string()));
1484 }
1485}