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 extra_args: Vec<String>,
59}
60
61impl QueryCommand {
62 #[must_use]
64 pub fn new(prompt: impl Into<String>) -> Self {
65 Self {
66 prompt: prompt.into(),
67 shared: SharedSpawnArgs::default(),
68 output_format: None,
69 include_partial_messages: false,
70 input_format: None,
71 retry_policy: None,
72 brief: false,
73 from_pr: None,
74 prompt_via_stdin: false,
75 verbose: false,
76 prompt_suggestions: false,
77 replay_user_messages: false,
78 extra_args: Vec::new(),
79 }
80 }
81
82 #[must_use]
84 pub fn model(mut self, model: impl Into<String>) -> Self {
85 self.shared.model = Some(model.into());
86 self
87 }
88
89 #[must_use]
91 pub fn system_prompt(mut self, prompt: impl Into<String>) -> Self {
92 self.shared.system_prompt = Some(prompt.into());
93 self
94 }
95
96 #[must_use]
98 pub fn append_system_prompt(mut self, prompt: impl Into<String>) -> Self {
99 self.shared.append_system_prompt = Some(prompt.into());
100 self
101 }
102
103 #[must_use]
105 pub fn output_format(mut self, format: OutputFormat) -> Self {
106 self.output_format = Some(format);
107 self
108 }
109
110 #[must_use]
112 pub fn max_budget_usd(mut self, budget: f64) -> Self {
113 self.shared.max_budget_usd = Some(budget);
114 self
115 }
116
117 #[must_use]
119 pub fn permission_mode(mut self, mode: PermissionMode) -> Self {
120 self.shared.permission_mode = Some(mode);
121 self
122 }
123
124 #[must_use]
140 pub fn allowed_tools<I, T>(mut self, tools: I) -> Self
141 where
142 I: IntoIterator<Item = T>,
143 T: Into<ToolPattern>,
144 {
145 self.shared
146 .allowed_tools
147 .extend(tools.into_iter().map(Into::into));
148 self
149 }
150
151 #[must_use]
153 pub fn allowed_tool(mut self, tool: impl Into<ToolPattern>) -> Self {
154 self.shared.allowed_tools.push(tool.into());
155 self
156 }
157
158 #[must_use]
160 pub fn disallowed_tools<I, T>(mut self, tools: I) -> Self
161 where
162 I: IntoIterator<Item = T>,
163 T: Into<ToolPattern>,
164 {
165 self.shared
166 .disallowed_tools
167 .extend(tools.into_iter().map(Into::into));
168 self
169 }
170
171 #[must_use]
173 pub fn disallowed_tool(mut self, tool: impl Into<ToolPattern>) -> Self {
174 self.shared.disallowed_tools.push(tool.into());
175 self
176 }
177
178 #[must_use]
180 pub fn mcp_config(mut self, path: impl Into<String>) -> Self {
181 self.shared.mcp_config.push(path.into());
182 self
183 }
184
185 #[must_use]
187 pub fn add_dir(mut self, dir: impl Into<String>) -> Self {
188 self.shared.add_dir.push(dir.into());
189 self
190 }
191
192 #[must_use]
194 pub fn effort(mut self, effort: Effort) -> Self {
195 self.shared.effort = Some(effort);
196 self
197 }
198
199 #[must_use]
201 pub fn max_turns(mut self, turns: u32) -> Self {
202 self.shared.max_turns = Some(turns);
203 self
204 }
205
206 #[must_use]
208 pub fn json_schema(mut self, schema: impl Into<String>) -> Self {
209 self.shared.json_schema = Some(schema.into());
210 self
211 }
212
213 #[must_use]
215 pub fn continue_session(mut self) -> Self {
216 self.shared.continue_session = true;
217 self
218 }
219
220 #[must_use]
222 pub fn resume(mut self, session_id: impl Into<String>) -> Self {
223 self.shared.resume = Some(session_id.into());
224 self
225 }
226
227 #[must_use]
229 pub fn session_id(mut self, id: impl Into<String>) -> Self {
230 self.shared.session_id = Some(id.into());
231 self
232 }
233
234 #[cfg(all(feature = "json", feature = "async"))]
242 pub(crate) fn replace_session(mut self, id: impl Into<String>) -> Self {
243 self.shared.continue_session = false;
244 self.shared.resume = Some(id.into());
245 self.shared.session_id = None;
246 self.shared.fork_session = false;
247 self
248 }
249
250 #[must_use]
252 pub fn fallback_model(mut self, model: impl Into<String>) -> Self {
253 self.shared.fallback_model = Some(model.into());
254 self
255 }
256
257 #[must_use]
259 pub fn no_session_persistence(mut self) -> Self {
260 self.shared.no_session_persistence = true;
261 self
262 }
263
264 #[must_use]
268 pub fn forward_subagent_text(mut self) -> Self {
269 self.shared.forward_subagent_text = true;
270 self
271 }
272
273 #[must_use]
277 pub fn allow_dangerously_skip_permissions(mut self) -> Self {
278 self.shared.allow_dangerously_skip_permissions = true;
279 self
280 }
281
282 #[must_use]
284 pub fn dangerously_skip_permissions(mut self) -> Self {
285 self.shared.dangerously_skip_permissions = true;
286 self
287 }
288
289 #[must_use]
303 pub fn agent(mut self, agent: impl Into<String>) -> Self {
304 self.shared.agent = Some(agent.into());
305 self
306 }
307
308 #[must_use]
320 pub fn agents_json(mut self, json: impl Into<String>) -> Self {
321 self.shared.agents_json = Some(json.into());
322 self
323 }
324
325 #[must_use]
331 pub fn tools(mut self, tools: impl IntoIterator<Item = impl Into<String>>) -> Self {
332 self.shared.tools.extend(tools.into_iter().map(Into::into));
333 self
334 }
335
336 #[must_use]
340 pub fn file(mut self, spec: impl Into<String>) -> Self {
341 self.shared.file.push(spec.into());
342 self
343 }
344
345 #[must_use]
349 pub fn include_partial_messages(mut self) -> Self {
350 self.include_partial_messages = true;
351 self
352 }
353
354 #[must_use]
356 pub fn input_format(mut self, format: InputFormat) -> Self {
357 self.input_format = Some(format);
358 self
359 }
360
361 #[must_use]
363 pub fn strict_mcp_config(mut self) -> Self {
364 self.shared.strict_mcp_config = true;
365 self
366 }
367
368 #[must_use]
370 pub fn settings(mut self, settings: impl Into<String>) -> Self {
371 self.shared.settings = Some(settings.into());
372 self
373 }
374
375 #[must_use]
377 pub fn fork_session(mut self) -> Self {
378 self.shared.fork_session = true;
379 self
380 }
381
382 #[must_use]
384 pub fn worktree(mut self) -> Self {
385 self.shared.worktree = true;
386 self
387 }
388
389 #[must_use]
412 pub fn worktree_named(mut self, name: impl Into<String>) -> Self {
413 self.shared.worktree = true;
414 self.shared.worktree_name = Some(name.into());
415 self
416 }
417
418 #[must_use]
420 pub fn brief(mut self) -> Self {
421 self.brief = true;
422 self
423 }
424
425 #[must_use]
427 pub fn debug_filter(mut self, filter: impl Into<String>) -> Self {
428 self.shared.debug_filter = Some(filter.into());
429 self
430 }
431
432 #[must_use]
434 pub fn debug_file(mut self, path: impl Into<String>) -> Self {
435 self.shared.debug_file = Some(path.into());
436 self
437 }
438
439 #[must_use]
441 pub fn betas(mut self, betas: impl Into<String>) -> Self {
442 self.shared.betas = Some(betas.into());
443 self
444 }
445
446 #[must_use]
448 pub fn plugin_dir(mut self, dir: impl Into<String>) -> Self {
449 self.shared.plugin_dirs.push(dir.into());
450 self
451 }
452
453 #[must_use]
457 pub fn plugin_url(mut self, url: impl Into<String>) -> Self {
458 self.shared.plugin_urls.push(url.into());
459 self
460 }
461
462 #[must_use]
464 pub fn setting_sources(mut self, sources: impl Into<String>) -> Self {
465 self.shared.setting_sources = Some(sources.into());
466 self
467 }
468
469 #[must_use]
484 pub fn hermetic(mut self) -> Self {
485 self.shared.apply_hermetic(HermeticScope::Full);
486 self
487 }
488
489 #[must_use]
496 pub fn hermetic_scoped(mut self, scope: HermeticScope) -> Self {
497 self.shared.apply_hermetic(scope);
498 self
499 }
500
501 #[must_use]
503 pub fn tmux(mut self) -> Self {
504 self.shared.tmux = true;
505 self
506 }
507
508 #[must_use]
524 pub fn bare(mut self) -> Self {
525 self.shared.bare = true;
526 self
527 }
528
529 #[must_use]
531 pub fn disable_slash_commands(mut self) -> Self {
532 self.shared.disable_slash_commands = true;
533 self
534 }
535
536 #[must_use]
545 pub fn safe_mode(mut self) -> Self {
546 self.shared.safe_mode = true;
547 self
548 }
549
550 #[must_use]
554 pub fn include_hook_events(mut self) -> Self {
555 self.shared.include_hook_events = true;
556 self
557 }
558
559 #[must_use]
565 pub fn exclude_dynamic_system_prompt_sections(mut self) -> Self {
566 self.shared.exclude_dynamic_system_prompt_sections = true;
567 self
568 }
569
570 #[must_use]
573 pub fn name(mut self, name: impl Into<String>) -> Self {
574 self.shared.name = Some(name.into());
575 self
576 }
577
578 #[must_use]
585 pub fn from_pr(mut self, pr: impl Into<String>) -> Self {
586 self.from_pr = Some(pr.into());
587 self
588 }
589
590 #[must_use]
598 pub fn verbose(mut self, value: bool) -> Self {
599 self.verbose = value;
600 self
601 }
602
603 #[must_use]
609 pub fn prompt_suggestions(mut self, value: bool) -> Self {
610 self.prompt_suggestions = value;
611 self
612 }
613
614 #[must_use]
622 pub fn replay_user_messages(mut self, value: bool) -> Self {
623 self.replay_user_messages = value;
624 self
625 }
626
627 #[must_use]
650 pub fn retry(mut self, policy: crate::retry::RetryPolicy) -> Self {
651 self.retry_policy = Some(policy);
652 self
653 }
654
655 #[must_use]
667 pub fn extra_arg(mut self, arg: impl Into<String>) -> Self {
668 self.extra_args.push(arg.into());
669 self
670 }
671
672 #[must_use]
689 pub fn extra_args(mut self, args: impl IntoIterator<Item = impl Into<String>>) -> Self {
690 self.extra_args.extend(args.into_iter().map(Into::into));
691 self
692 }
693
694 pub fn to_command_string(&self, claude: &Claude) -> String {
719 let args = exec::full_command_args(claude, self.build_args());
720 let quoted_args = args.iter().map(|arg| shell_quote(arg)).collect::<Vec<_>>();
721 format!("{} {}", claude.binary().display(), quoted_args.join(" "))
722 }
723
724 #[cfg(feature = "async")]
729 pub async fn execute_cancellable<C>(&self, claude: &Claude, cancel: C) -> Result<CommandOutput>
730 where
731 C: std::future::Future<Output = ()> + Send,
732 {
733 if self.prompt_via_stdin {
734 exec::run_claude_with_stdin_prompt_cancellable(
735 claude,
736 self.build_args(),
737 self.prompt.clone(),
738 cancel,
739 )
740 .await
741 } else {
742 exec::run_claude_cancellable(claude, self.args(), cancel).await
743 }
744 }
745
746 #[cfg(all(feature = "json", feature = "async"))]
751 pub async fn execute_json(&self, claude: &Claude) -> Result<crate::types::QueryResult> {
752 let args = self.build_args_with_forced_json();
753
754 let output = if self.prompt_via_stdin {
755 exec::run_claude_with_stdin_prompt(claude, args, self.prompt.clone()).await?
758 } else {
759 exec::run_claude_with_retry(claude, args, self.retry_policy.as_ref()).await?
760 };
761
762 serde_json::from_str(&output.stdout).map_err(|e| crate::error::Error::Json {
763 message: format!("failed to parse query result: {e}"),
764 source: e,
765 })
766 }
767
768 #[cfg(all(feature = "json", feature = "async"))]
773 pub async fn execute_json_cancellable<C>(
774 &self,
775 claude: &Claude,
776 cancel: C,
777 ) -> Result<crate::types::QueryResult>
778 where
779 C: std::future::Future<Output = ()> + Send,
780 {
781 let args = self.build_args_with_forced_json();
782 let output = if self.prompt_via_stdin {
783 exec::run_claude_with_stdin_prompt_cancellable(
784 claude,
785 args,
786 self.prompt.clone(),
787 cancel,
788 )
789 .await?
790 } else {
791 exec::run_claude_cancellable(claude, args, cancel).await?
792 };
793
794 serde_json::from_str(&output.stdout).map_err(|e| crate::error::Error::Json {
795 message: format!("failed to parse query result: {e}"),
796 source: e,
797 })
798 }
799
800 #[cfg(feature = "sync")]
807 pub fn execute_sync(&self, claude: &Claude) -> Result<CommandOutput> {
808 if self.prompt_via_stdin {
809 exec::run_claude_with_stdin_prompt_sync(claude, self.build_args(), self.prompt.clone())
812 } else {
813 exec::run_claude_with_retry_sync(claude, self.args(), self.retry_policy.as_ref())
814 }
815 }
816
817 #[cfg(all(feature = "sync", feature = "json"))]
819 pub fn execute_json_sync(&self, claude: &Claude) -> Result<crate::types::QueryResult> {
820 let args = self.build_args_with_forced_json();
821
822 let output = if self.prompt_via_stdin {
823 exec::run_claude_with_stdin_prompt_sync(claude, args, self.prompt.clone())?
826 } else {
827 exec::run_claude_with_retry_sync(claude, args, self.retry_policy.as_ref())?
828 };
829
830 serde_json::from_str(&output.stdout).map_err(|e| crate::error::Error::Json {
831 message: format!("failed to parse query result: {e}"),
832 source: e,
833 })
834 }
835
836 #[must_use]
862 pub fn prompt_via_stdin(mut self, value: bool) -> Self {
863 self.prompt_via_stdin = value;
864 self
865 }
866
867 #[cfg(all(feature = "json", feature = "async"))]
868 pub(crate) fn stdin_prompt(&self) -> Option<&str> {
869 self.prompt_via_stdin.then_some(self.prompt.as_str())
870 }
871
872 #[allow(dead_code)] fn build_args_with_forced_json(&self) -> Vec<String> {
881 if self.output_format.is_some() {
882 return self.build_args();
883 }
884 let mut effective = self.clone();
885 effective.output_format = Some(OutputFormat::Json);
886 effective.build_args()
887 }
888
889 fn build_args(&self) -> Vec<String> {
890 let mut args = vec!["--print".to_string()];
891
892 if let Some(ref format) = self.output_format {
893 args.push("--output-format".to_string());
894 args.push(format.as_arg().to_string());
895 }
896
897 if self.verbose || matches!(self.output_format, Some(OutputFormat::StreamJson)) {
901 args.push("--verbose".to_string());
902 }
903
904 self.shared.append_to(&mut args);
905
906 if self.include_partial_messages {
907 args.push("--include-partial-messages".to_string());
908 }
909
910 if let Some(ref format) = self.input_format {
911 args.push("--input-format".to_string());
912 args.push(format.as_arg().to_string());
913 }
914
915 if self.brief {
916 args.push("--brief".to_string());
917 }
918
919 if self.prompt_suggestions {
920 args.push("--prompt-suggestions".to_string());
921 }
922
923 if self.replay_user_messages {
924 args.push("--replay-user-messages".to_string());
925 }
926
927 if let Some(ref pr) = self.from_pr {
928 args.push("--from-pr".to_string());
929 args.push(pr.clone());
930 }
931
932 args.extend(self.extra_args.iter().cloned());
933
934 if !self.prompt_via_stdin {
938 args.push("--".to_string());
939 args.push(self.prompt.clone());
940 }
941
942 args
943 }
944}
945
946impl ClaudeCommand for QueryCommand {
947 type Output = CommandOutput;
948
949 fn args(&self) -> Vec<String> {
950 self.build_args()
951 }
952
953 #[cfg(feature = "async")]
954 async fn execute(&self, claude: &Claude) -> Result<CommandOutput> {
955 if self.prompt_via_stdin {
956 let args = self.build_args(); exec::run_claude_with_stdin_prompt(claude, args, self.prompt.clone()).await
960 } else {
961 exec::run_claude_with_retry(claude, self.args(), self.retry_policy.as_ref()).await
962 }
963 }
964}
965
966#[cfg(test)]
967mod tests {
968 use super::*;
969
970 #[test]
971 fn test_basic_query_args() {
972 let cmd = QueryCommand::new("hello world");
973 let args = cmd.args();
974 assert_eq!(args, vec!["--print", "--", "hello world"]);
975 }
976
977 #[test]
978 fn extra_args_preserve_order_and_values_before_prompt() {
979 let args = QueryCommand::new("--prompt with spaces")
980 .extra_arg("--future-option")
981 .extra_args(["two words\nnext line", "", "--model", "future-model"])
982 .model("sonnet")
983 .extra_arg("--another-option=value")
984 .args();
985
986 assert_eq!(
987 args,
988 [
989 "--print",
990 "--model",
991 "sonnet",
992 "--future-option",
993 "two words\nnext line",
994 "",
995 "--model",
996 "future-model",
997 "--another-option=value",
998 "--",
999 "--prompt with spaces",
1000 ]
1001 );
1002 }
1003
1004 #[test]
1005 fn extra_args_follow_forced_json_options_in_both_prompt_transports() {
1006 for stdin in [false, true] {
1007 let cmd = QueryCommand::new("private prompt")
1008 .extra_args(["--future-option", "value"])
1009 .prompt_via_stdin(stdin);
1010 let mut expected = vec![
1011 "--print",
1012 "--output-format",
1013 "json",
1014 "--future-option",
1015 "value",
1016 ];
1017 if !stdin {
1018 expected.extend(["--", "private prompt"]);
1019 }
1020 assert_eq!(cmd.build_args_with_forced_json(), expected);
1021 }
1022 }
1023
1024 #[test]
1025 fn prompt_via_stdin_omits_prompt_from_args() {
1026 let cmd = QueryCommand::new("secret payload").prompt_via_stdin(true);
1027 let args = cmd.args();
1028 assert!(
1029 !args.contains(&"secret payload".to_string()),
1030 "prompt must not appear in args when prompt_via_stdin is set"
1031 );
1032 assert!(
1033 !args.contains(&"--".to_string()),
1034 "-- separator must be absent when prompt_via_stdin is set"
1035 );
1036 }
1037
1038 #[test]
1039 fn prompt_via_stdin_false_keeps_prompt_in_args() {
1040 let cmd = QueryCommand::new("visible prompt").prompt_via_stdin(false);
1041 let args = cmd.args();
1042 assert!(
1043 args.contains(&"visible prompt".to_string()),
1044 "prompt must still appear in args when prompt_via_stdin is false"
1045 );
1046 assert!(
1047 args.contains(&"--".to_string()),
1048 "-- separator must be present when prompt_via_stdin is false"
1049 );
1050 }
1051
1052 #[test]
1053 #[cfg(feature = "async")] #[ignore = "requires a real claude binary"]
1055 fn prompt_via_stdin_integration() {
1056 use crate::{Claude, ClaudeCommand};
1059 let rt = tokio::runtime::Runtime::new().unwrap();
1060 rt.block_on(async {
1061 let claude = Claude::builder().build().unwrap();
1062 let out = QueryCommand::new("reply with: STDIN_OK")
1063 .prompt_via_stdin(true)
1064 .execute(&claude)
1065 .await
1066 .unwrap();
1067 assert!(
1068 !out.stdout.is_empty(),
1069 "expected non-empty output from stdin-mode query"
1070 );
1071 });
1072 }
1073
1074 #[test]
1075 fn build_args_with_forced_json_inserts_flag_before_separator() {
1076 let cmd = QueryCommand::new("hello");
1082 let args = cmd.build_args_with_forced_json();
1083
1084 assert_eq!(
1086 &args[args.len() - 2..],
1087 &["--".to_string(), "hello".to_string()],
1088 );
1089
1090 let sep = args.iter().position(|a| a == "--").expect("`--` present");
1092 let fmt = args
1093 .iter()
1094 .position(|a| a == "--output-format")
1095 .expect("--output-format present");
1096 assert!(
1097 fmt < sep,
1098 "--output-format must come before `--` separator; got {args:?}"
1099 );
1100 assert_eq!(args[fmt + 1], "json");
1101 }
1102
1103 #[test]
1104 fn build_args_with_forced_json_respects_explicit_format() {
1105 let cmd = QueryCommand::new("hello").output_format(OutputFormat::Text);
1108 let args = cmd.build_args_with_forced_json();
1109 let fmt = args
1110 .iter()
1111 .position(|a| a == "--output-format")
1112 .expect("--output-format present");
1113 assert_eq!(args[fmt + 1], "text");
1114 assert_eq!(args.iter().filter(|a| *a == "--output-format").count(), 1);
1116 }
1117
1118 #[test]
1119 #[allow(deprecated)] fn test_full_query_args() {
1121 let cmd = QueryCommand::new("explain this")
1122 .model("sonnet")
1123 .system_prompt("be concise")
1124 .output_format(OutputFormat::Json)
1125 .max_budget_usd(0.50)
1126 .permission_mode(PermissionMode::BypassPermissions)
1127 .allowed_tools(["Bash", "Read"])
1128 .mcp_config("/tmp/mcp.json")
1129 .effort(Effort::High)
1130 .max_turns(3)
1131 .no_session_persistence();
1132
1133 let args = cmd.args();
1134 assert!(args.contains(&"--print".to_string()));
1135 assert!(args.contains(&"--model".to_string()));
1136 assert!(args.contains(&"sonnet".to_string()));
1137 assert!(args.contains(&"--system-prompt".to_string()));
1138 assert!(args.contains(&"--output-format".to_string()));
1139 assert!(args.contains(&"json".to_string()));
1140 assert!(!args.contains(&"--verbose".to_string()));
1142 assert!(args.contains(&"--max-budget-usd".to_string()));
1143 assert!(args.contains(&"--permission-mode".to_string()));
1144 assert!(args.contains(&"bypassPermissions".to_string()));
1145 assert!(args.contains(&"--allowed-tools".to_string()));
1146 assert!(args.contains(&"Bash,Read".to_string()));
1147 assert!(args.contains(&"--effort".to_string()));
1148 assert!(args.contains(&"high".to_string()));
1149 assert!(args.contains(&"--max-turns".to_string()));
1150 assert!(args.contains(&"--no-session-persistence".to_string()));
1151 assert_eq!(args.last().unwrap(), "explain this");
1153 assert_eq!(args[args.len() - 2], "--");
1154 }
1155
1156 #[test]
1157 fn typed_patterns_render_in_allowed_tools() {
1158 use crate::ToolPattern;
1159
1160 let cmd = QueryCommand::new("hi")
1161 .allowed_tool(ToolPattern::tool("Read"))
1162 .allowed_tool(ToolPattern::tool_with_args("Bash", "git log:*"))
1163 .allowed_tool(ToolPattern::all("Write"))
1164 .allowed_tool(ToolPattern::mcp("srv", "*"));
1165
1166 let args = cmd.args();
1167 let joined = args
1168 .iter()
1169 .position(|a| a == "--allowed-tools")
1170 .map(|i| &args[i + 1])
1171 .unwrap();
1172 assert_eq!(joined, "Read,Bash(git log:*),Write(*),mcp__srv__*");
1173 }
1174
1175 #[test]
1176 fn disallowed_tool_singular_appends() {
1177 use crate::ToolPattern;
1178
1179 let cmd = QueryCommand::new("hi")
1180 .disallowed_tool("Write")
1181 .disallowed_tool(ToolPattern::tool_with_args("Bash", "rm*"));
1182
1183 let args = cmd.args();
1184 let joined = args
1185 .iter()
1186 .position(|a| a == "--disallowed-tools")
1187 .map(|i| &args[i + 1])
1188 .unwrap();
1189 assert_eq!(joined, "Write,Bash(rm*)");
1190 }
1191
1192 #[test]
1193 fn mixed_string_and_typed_patterns_both_accepted() {
1194 use crate::ToolPattern;
1195
1196 let strs: Vec<ToolPattern> = vec!["Bash".into(), ToolPattern::all("Read")];
1200 let cmd = QueryCommand::new("hi").allowed_tools(strs);
1201 assert!(cmd.args().contains(&"--allowed-tools".to_string()));
1202 }
1203
1204 #[test]
1205 fn new_bool_flags_emit_correct_cli_args() {
1206 let args = QueryCommand::new("hi")
1207 .bare()
1208 .disable_slash_commands()
1209 .include_hook_events()
1210 .exclude_dynamic_system_prompt_sections()
1211 .args();
1212 assert!(args.contains(&"--bare".to_string()));
1213 assert!(args.contains(&"--disable-slash-commands".to_string()));
1214 assert!(args.contains(&"--include-hook-events".to_string()));
1215 assert!(args.contains(&"--exclude-dynamic-system-prompt-sections".to_string()));
1216 }
1217
1218 #[test]
1219 fn name_flag_renders_with_value() {
1220 let args = QueryCommand::new("hi").name("my session").args();
1221 let pos = args.iter().position(|a| a == "--name").unwrap();
1222 assert_eq!(args[pos + 1], "my session");
1223 }
1224
1225 #[test]
1226 fn from_pr_flag_renders_with_value() {
1227 let args = QueryCommand::new("hi").from_pr("42").args();
1228 let pos = args.iter().position(|a| a == "--from-pr").unwrap();
1229 assert_eq!(args[pos + 1], "42");
1230 }
1231
1232 #[test]
1233 fn new_bool_flags_default_to_off() {
1234 let args = QueryCommand::new("hi").args();
1235 assert!(!args.contains(&"--bare".to_string()));
1236 assert!(!args.contains(&"--disable-slash-commands".to_string()));
1237 assert!(!args.contains(&"--include-hook-events".to_string()));
1238 assert!(!args.contains(&"--exclude-dynamic-system-prompt-sections".to_string()));
1239 assert!(!args.contains(&"--name".to_string()));
1240 }
1241
1242 #[test]
1243 fn test_separator_before_prompt_prevents_greedy_flag_parsing() {
1244 let cmd = QueryCommand::new("fix the bug")
1247 .allowed_tools(["Read", "Edit", "Bash(cargo *)"])
1248 .output_format(OutputFormat::StreamJson);
1249 let args = cmd.args();
1250 let sep_pos = args.iter().position(|a| a == "--").unwrap();
1252 let prompt_pos = args.iter().position(|a| a == "fix the bug").unwrap();
1253 assert_eq!(prompt_pos, sep_pos + 1, "prompt must follow -- separator");
1254 let tools_pos = args
1256 .iter()
1257 .position(|a| a.contains("Bash(cargo *)"))
1258 .unwrap();
1259 assert!(
1260 tools_pos < sep_pos,
1261 "allowed-tools must come before -- separator"
1262 );
1263 }
1264
1265 #[test]
1266 fn test_stream_json_includes_verbose() {
1267 let cmd = QueryCommand::new("test").output_format(OutputFormat::StreamJson);
1268 let args = cmd.args();
1269 assert!(args.contains(&"--output-format".to_string()));
1270 assert!(args.contains(&"stream-json".to_string()));
1271 assert!(args.contains(&"--verbose".to_string()));
1272 }
1273
1274 #[test]
1275 fn verbose_flag_emitted_when_set() {
1276 let args = QueryCommand::new("test").verbose(true).args();
1277 assert!(args.contains(&"--verbose".to_string()));
1278 }
1279
1280 #[test]
1281 fn verbose_absent_by_default_and_when_false() {
1282 assert!(
1283 !QueryCommand::new("test")
1284 .args()
1285 .contains(&"--verbose".to_string())
1286 );
1287 assert!(
1288 !QueryCommand::new("test")
1289 .verbose(false)
1290 .args()
1291 .contains(&"--verbose".to_string())
1292 );
1293 }
1294
1295 #[test]
1296 fn verbose_not_duplicated_with_stream_json() {
1297 let cmd = QueryCommand::new("test")
1300 .verbose(true)
1301 .output_format(OutputFormat::StreamJson);
1302 let count = cmd.args().iter().filter(|a| *a == "--verbose").count();
1303 assert_eq!(count, 1, "--verbose must appear exactly once");
1304 }
1305
1306 #[test]
1307 fn prompt_suggestions_flag_emitted_when_set() {
1308 let args = QueryCommand::new("test").prompt_suggestions(true).args();
1309 assert!(args.contains(&"--prompt-suggestions".to_string()));
1310 let sep = args.iter().position(|a| a == "--").unwrap();
1313 let flag = args
1314 .iter()
1315 .position(|a| a == "--prompt-suggestions")
1316 .unwrap();
1317 assert!(flag < sep, "--prompt-suggestions must precede `--`");
1318 }
1319
1320 #[test]
1321 fn prompt_suggestions_absent_by_default_and_when_false() {
1322 assert!(
1323 !QueryCommand::new("test")
1324 .args()
1325 .contains(&"--prompt-suggestions".to_string())
1326 );
1327 assert!(
1328 !QueryCommand::new("test")
1329 .prompt_suggestions(false)
1330 .args()
1331 .contains(&"--prompt-suggestions".to_string())
1332 );
1333 }
1334
1335 #[test]
1336 fn replay_user_messages_flag_emitted_when_set() {
1337 let args = QueryCommand::new("test").replay_user_messages(true).args();
1338 assert!(args.contains(&"--replay-user-messages".to_string()));
1339 }
1340
1341 #[test]
1342 fn replay_user_messages_absent_by_default_and_when_false() {
1343 assert!(
1344 !QueryCommand::new("test")
1345 .args()
1346 .contains(&"--replay-user-messages".to_string())
1347 );
1348 assert!(
1349 !QueryCommand::new("test")
1350 .replay_user_messages(false)
1351 .args()
1352 .contains(&"--replay-user-messages".to_string())
1353 );
1354 }
1355
1356 #[test]
1357 fn test_to_command_string_simple() {
1358 let claude = Claude::builder()
1359 .binary("/usr/local/bin/claude")
1360 .build()
1361 .unwrap();
1362
1363 let cmd = QueryCommand::new("hello");
1364 let command_str = cmd.to_command_string(&claude);
1365
1366 assert!(command_str.starts_with("/usr/local/bin/claude"));
1367 assert!(command_str.contains("--print"));
1368 assert!(command_str.contains("hello"));
1369 }
1370
1371 #[test]
1372 fn test_to_command_string_with_spaces() {
1373 let claude = Claude::builder()
1374 .binary("/usr/local/bin/claude")
1375 .build()
1376 .unwrap();
1377
1378 let cmd = QueryCommand::new("hello world").model("sonnet");
1379 let command_str = cmd.to_command_string(&claude);
1380
1381 assert!(command_str.starts_with("/usr/local/bin/claude"));
1382 assert!(command_str.contains("--print"));
1383 assert!(command_str.contains("'hello world'"));
1385 assert!(command_str.contains("--model"));
1386 assert!(command_str.contains("sonnet"));
1387 }
1388
1389 #[test]
1390 fn test_to_command_string_with_special_chars() {
1391 let claude = Claude::builder()
1392 .binary("/usr/local/bin/claude")
1393 .build()
1394 .unwrap();
1395
1396 let cmd = QueryCommand::new("test $VAR and `cmd`");
1397 let command_str = cmd.to_command_string(&claude);
1398
1399 assert!(command_str.contains("'test $VAR and `cmd`'"));
1401 }
1402
1403 #[test]
1404 fn test_to_command_string_with_single_quotes() {
1405 let claude = Claude::builder()
1406 .binary("/usr/local/bin/claude")
1407 .build()
1408 .unwrap();
1409
1410 let cmd = QueryCommand::new("it's");
1411 let command_str = cmd.to_command_string(&claude);
1412
1413 assert!(command_str.contains("'it'\\''s'"));
1415 }
1416
1417 #[test]
1418 fn to_command_string_includes_global_args() {
1419 let claude = Claude::builder()
1422 .binary("/usr/local/bin/claude")
1423 .arg("--debug")
1424 .build()
1425 .unwrap();
1426
1427 let command_str = QueryCommand::new("hello").to_command_string(&claude);
1428
1429 assert!(
1430 command_str.starts_with("/usr/local/bin/claude --debug --print"),
1431 "global args must precede command args; got {command_str}"
1432 );
1433 }
1434
1435 #[test]
1436 fn test_worktree_flag() {
1437 let cmd = QueryCommand::new("test").worktree();
1438 let args = cmd.args();
1439 assert!(args.contains(&"--worktree".to_string()));
1440 }
1441
1442 #[test]
1443 fn test_worktree_named() {
1444 let cmd = QueryCommand::new("test").worktree_named("feature-x");
1445 let args = cmd.args();
1446 assert!(
1447 args.windows(2).any(|w| w == ["--worktree", "feature-x"]),
1448 "missing --worktree feature-x in {args:?}"
1449 );
1450 }
1451
1452 #[test]
1453 fn test_brief_flag() {
1454 let cmd = QueryCommand::new("test").brief();
1455 let args = cmd.args();
1456 assert!(args.contains(&"--brief".to_string()));
1457 }
1458
1459 #[test]
1460 fn test_debug_filter() {
1461 let cmd = QueryCommand::new("test").debug_filter("api,hooks");
1462 let args = cmd.args();
1463 assert!(args.contains(&"--debug".to_string()));
1464 assert!(args.contains(&"api,hooks".to_string()));
1465 }
1466
1467 #[test]
1468 fn test_debug_file() {
1469 let cmd = QueryCommand::new("test").debug_file("/tmp/debug.log");
1470 let args = cmd.args();
1471 assert!(args.contains(&"--debug-file".to_string()));
1472 assert!(args.contains(&"/tmp/debug.log".to_string()));
1473 }
1474
1475 #[test]
1476 fn test_betas() {
1477 let cmd = QueryCommand::new("test").betas("feature-x");
1478 let args = cmd.args();
1479 assert!(args.contains(&"--betas".to_string()));
1480 assert!(args.contains(&"feature-x".to_string()));
1481 }
1482
1483 #[test]
1484 fn test_plugin_dir_single() {
1485 let cmd = QueryCommand::new("test").plugin_dir("/plugins/foo");
1486 let args = cmd.args();
1487 assert!(args.contains(&"--plugin-dir".to_string()));
1488 assert!(args.contains(&"/plugins/foo".to_string()));
1489 }
1490
1491 #[test]
1492 fn test_plugin_dir_multiple() {
1493 let cmd = QueryCommand::new("test")
1494 .plugin_dir("/plugins/foo")
1495 .plugin_dir("/plugins/bar");
1496 let args = cmd.args();
1497 let plugin_dir_count = args.iter().filter(|a| *a == "--plugin-dir").count();
1498 assert_eq!(plugin_dir_count, 2);
1499 assert!(args.contains(&"/plugins/foo".to_string()));
1500 assert!(args.contains(&"/plugins/bar".to_string()));
1501 }
1502
1503 #[test]
1504 fn test_plugin_url_single() {
1505 let cmd = QueryCommand::new("test").plugin_url("https://example.com/p.zip");
1506 let args = cmd.args();
1507 assert!(args.contains(&"--plugin-url".to_string()));
1508 assert!(args.contains(&"https://example.com/p.zip".to_string()));
1509 }
1510
1511 #[test]
1512 fn test_plugin_url_multiple() {
1513 let cmd = QueryCommand::new("test")
1514 .plugin_url("https://example.com/a.zip")
1515 .plugin_url("https://example.com/b.zip");
1516 let args = cmd.args();
1517 let plugin_url_count = args.iter().filter(|a| *a == "--plugin-url").count();
1518 assert_eq!(plugin_url_count, 2);
1519 assert!(args.contains(&"https://example.com/a.zip".to_string()));
1520 assert!(args.contains(&"https://example.com/b.zip".to_string()));
1521 }
1522
1523 #[test]
1524 fn test_safe_mode_flag() {
1525 let cmd = QueryCommand::new("test").safe_mode();
1526 let args = cmd.args();
1527 assert!(args.contains(&"--safe-mode".to_string()));
1528 }
1529
1530 #[test]
1531 fn test_safe_mode_absent_by_default() {
1532 let cmd = QueryCommand::new("test");
1533 let args = cmd.args();
1534 assert!(!args.contains(&"--safe-mode".to_string()));
1535 }
1536
1537 #[test]
1538 fn hermetic_emits_full_seal_flags() {
1539 let args = QueryCommand::new("test").hermetic().args();
1540 assert!(
1541 args.windows(2)
1542 .any(|w| w[0] == "--setting-sources" && w[1].is_empty()),
1543 "got {args:?}"
1544 );
1545 assert!(args.contains(&"--strict-mcp-config".to_string()));
1546 assert!(args.contains(&"--exclude-dynamic-system-prompt-sections".to_string()));
1547 assert!(!args.contains(&"--bare".to_string()));
1548 }
1549
1550 #[test]
1551 fn hermetic_scoped_project_keeps_user() {
1552 let args = QueryCommand::new("test")
1553 .hermetic_scoped(HermeticScope::Project)
1554 .args();
1555 assert!(args.windows(2).any(|w| w == ["--setting-sources", "user"]));
1556 assert!(args.contains(&"--strict-mcp-config".to_string()));
1557 }
1558
1559 #[test]
1560 fn setting_sources_overrides_hermetic_scope() {
1561 let args = QueryCommand::new("test")
1564 .hermetic()
1565 .setting_sources("user,project")
1566 .args();
1567 assert!(
1568 args.windows(2)
1569 .any(|w| w == ["--setting-sources", "user,project"]),
1570 "got {args:?}"
1571 );
1572 assert_eq!(
1573 args.iter().filter(|a| *a == "--setting-sources").count(),
1574 1,
1575 "--setting-sources must not be duplicated"
1576 );
1577 }
1578
1579 #[test]
1580 fn test_setting_sources() {
1581 let cmd = QueryCommand::new("test").setting_sources("user,project,local");
1582 let args = cmd.args();
1583 assert!(args.contains(&"--setting-sources".to_string()));
1584 assert!(args.contains(&"user,project,local".to_string()));
1585 }
1586
1587 #[test]
1588 fn test_tmux_flag() {
1589 let cmd = QueryCommand::new("test").tmux();
1590 let args = cmd.args();
1591 assert!(args.contains(&"--tmux".to_string()));
1592 }
1593 #[test]
1594 fn compatible_headless_flags_are_explicit_and_do_not_activate_bypass() {
1595 let defaults = QueryCommand::new("p").args();
1596 assert!(
1597 !defaults.iter().any(|arg| arg == "--forward-subagent-text"
1598 || arg == "--allow-dangerously-skip-permissions")
1599 );
1600 let args = QueryCommand::new("p")
1601 .forward_subagent_text()
1602 .allow_dangerously_skip_permissions()
1603 .args();
1604 assert!(args.iter().any(|arg| arg == "--forward-subagent-text"));
1605 assert!(
1606 args.iter()
1607 .any(|arg| arg == "--allow-dangerously-skip-permissions")
1608 );
1609 assert!(
1610 !args
1611 .iter()
1612 .any(|arg| arg == "--dangerously-skip-permissions" || arg == "bypassPermissions")
1613 );
1614 }
1615}