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 #[allow(dead_code)] fn build_args_with_forced_json(&self) -> Vec<String> {
817 if self.output_format.is_some() {
818 return self.build_args();
819 }
820 let mut effective = self.clone();
821 effective.output_format = Some(OutputFormat::Json);
822 effective.build_args()
823 }
824
825 fn build_args(&self) -> Vec<String> {
826 let mut args = vec!["--print".to_string()];
827
828 if let Some(ref format) = self.output_format {
829 args.push("--output-format".to_string());
830 args.push(format.as_arg().to_string());
831 }
832
833 if self.verbose || matches!(self.output_format, Some(OutputFormat::StreamJson)) {
837 args.push("--verbose".to_string());
838 }
839
840 self.shared.append_to(&mut args);
841
842 if self.include_partial_messages {
843 args.push("--include-partial-messages".to_string());
844 }
845
846 if let Some(ref format) = self.input_format {
847 args.push("--input-format".to_string());
848 args.push(format.as_arg().to_string());
849 }
850
851 if self.brief {
852 args.push("--brief".to_string());
853 }
854
855 if self.prompt_suggestions {
856 args.push("--prompt-suggestions".to_string());
857 }
858
859 if self.replay_user_messages {
860 args.push("--replay-user-messages".to_string());
861 }
862
863 if let Some(ref pr) = self.from_pr {
864 args.push("--from-pr".to_string());
865 args.push(pr.clone());
866 }
867
868 if !self.prompt_via_stdin {
872 args.push("--".to_string());
873 args.push(self.prompt.clone());
874 }
875
876 args
877 }
878}
879
880impl ClaudeCommand for QueryCommand {
881 type Output = CommandOutput;
882
883 fn args(&self) -> Vec<String> {
884 self.build_args()
885 }
886
887 #[cfg(feature = "async")]
888 async fn execute(&self, claude: &Claude) -> Result<CommandOutput> {
889 if self.prompt_via_stdin {
890 let args = self.build_args(); exec::run_claude_with_stdin_prompt(claude, args, self.prompt.clone()).await
894 } else {
895 exec::run_claude_with_retry(claude, self.args(), self.retry_policy.as_ref()).await
896 }
897 }
898}
899
900#[cfg(test)]
901mod tests {
902 use super::*;
903
904 #[test]
905 fn test_basic_query_args() {
906 let cmd = QueryCommand::new("hello world");
907 let args = cmd.args();
908 assert_eq!(args, vec!["--print", "--", "hello world"]);
909 }
910
911 #[test]
912 fn prompt_via_stdin_omits_prompt_from_args() {
913 let cmd = QueryCommand::new("secret payload").prompt_via_stdin(true);
914 let args = cmd.args();
915 assert!(
916 !args.contains(&"secret payload".to_string()),
917 "prompt must not appear in args when prompt_via_stdin is set"
918 );
919 assert!(
920 !args.contains(&"--".to_string()),
921 "-- separator must be absent when prompt_via_stdin is set"
922 );
923 }
924
925 #[test]
926 fn prompt_via_stdin_false_keeps_prompt_in_args() {
927 let cmd = QueryCommand::new("visible prompt").prompt_via_stdin(false);
928 let args = cmd.args();
929 assert!(
930 args.contains(&"visible prompt".to_string()),
931 "prompt must still appear in args when prompt_via_stdin is false"
932 );
933 assert!(
934 args.contains(&"--".to_string()),
935 "-- separator must be present when prompt_via_stdin is false"
936 );
937 }
938
939 #[test]
940 #[cfg(feature = "async")] #[ignore = "requires a real claude binary"]
942 fn prompt_via_stdin_integration() {
943 use crate::{Claude, ClaudeCommand};
946 let rt = tokio::runtime::Runtime::new().unwrap();
947 rt.block_on(async {
948 let claude = Claude::builder().build().unwrap();
949 let out = QueryCommand::new("reply with: STDIN_OK")
950 .prompt_via_stdin(true)
951 .execute(&claude)
952 .await
953 .unwrap();
954 assert!(
955 !out.stdout.is_empty(),
956 "expected non-empty output from stdin-mode query"
957 );
958 });
959 }
960
961 #[test]
962 fn build_args_with_forced_json_inserts_flag_before_separator() {
963 let cmd = QueryCommand::new("hello");
969 let args = cmd.build_args_with_forced_json();
970
971 assert_eq!(
973 &args[args.len() - 2..],
974 &["--".to_string(), "hello".to_string()],
975 );
976
977 let sep = args.iter().position(|a| a == "--").expect("`--` present");
979 let fmt = args
980 .iter()
981 .position(|a| a == "--output-format")
982 .expect("--output-format present");
983 assert!(
984 fmt < sep,
985 "--output-format must come before `--` separator; got {args:?}"
986 );
987 assert_eq!(args[fmt + 1], "json");
988 }
989
990 #[test]
991 fn build_args_with_forced_json_respects_explicit_format() {
992 let cmd = QueryCommand::new("hello").output_format(OutputFormat::Text);
995 let args = cmd.build_args_with_forced_json();
996 let fmt = args
997 .iter()
998 .position(|a| a == "--output-format")
999 .expect("--output-format present");
1000 assert_eq!(args[fmt + 1], "text");
1001 assert_eq!(args.iter().filter(|a| *a == "--output-format").count(), 1);
1003 }
1004
1005 #[test]
1006 #[allow(deprecated)] fn test_full_query_args() {
1008 let cmd = QueryCommand::new("explain this")
1009 .model("sonnet")
1010 .system_prompt("be concise")
1011 .output_format(OutputFormat::Json)
1012 .max_budget_usd(0.50)
1013 .permission_mode(PermissionMode::BypassPermissions)
1014 .allowed_tools(["Bash", "Read"])
1015 .mcp_config("/tmp/mcp.json")
1016 .effort(Effort::High)
1017 .max_turns(3)
1018 .no_session_persistence();
1019
1020 let args = cmd.args();
1021 assert!(args.contains(&"--print".to_string()));
1022 assert!(args.contains(&"--model".to_string()));
1023 assert!(args.contains(&"sonnet".to_string()));
1024 assert!(args.contains(&"--system-prompt".to_string()));
1025 assert!(args.contains(&"--output-format".to_string()));
1026 assert!(args.contains(&"json".to_string()));
1027 assert!(!args.contains(&"--verbose".to_string()));
1029 assert!(args.contains(&"--max-budget-usd".to_string()));
1030 assert!(args.contains(&"--permission-mode".to_string()));
1031 assert!(args.contains(&"bypassPermissions".to_string()));
1032 assert!(args.contains(&"--allowed-tools".to_string()));
1033 assert!(args.contains(&"Bash,Read".to_string()));
1034 assert!(args.contains(&"--effort".to_string()));
1035 assert!(args.contains(&"high".to_string()));
1036 assert!(args.contains(&"--max-turns".to_string()));
1037 assert!(args.contains(&"--no-session-persistence".to_string()));
1038 assert_eq!(args.last().unwrap(), "explain this");
1040 assert_eq!(args[args.len() - 2], "--");
1041 }
1042
1043 #[test]
1044 fn typed_patterns_render_in_allowed_tools() {
1045 use crate::ToolPattern;
1046
1047 let cmd = QueryCommand::new("hi")
1048 .allowed_tool(ToolPattern::tool("Read"))
1049 .allowed_tool(ToolPattern::tool_with_args("Bash", "git log:*"))
1050 .allowed_tool(ToolPattern::all("Write"))
1051 .allowed_tool(ToolPattern::mcp("srv", "*"));
1052
1053 let args = cmd.args();
1054 let joined = args
1055 .iter()
1056 .position(|a| a == "--allowed-tools")
1057 .map(|i| &args[i + 1])
1058 .unwrap();
1059 assert_eq!(joined, "Read,Bash(git log:*),Write(*),mcp__srv__*");
1060 }
1061
1062 #[test]
1063 fn disallowed_tool_singular_appends() {
1064 use crate::ToolPattern;
1065
1066 let cmd = QueryCommand::new("hi")
1067 .disallowed_tool("Write")
1068 .disallowed_tool(ToolPattern::tool_with_args("Bash", "rm*"));
1069
1070 let args = cmd.args();
1071 let joined = args
1072 .iter()
1073 .position(|a| a == "--disallowed-tools")
1074 .map(|i| &args[i + 1])
1075 .unwrap();
1076 assert_eq!(joined, "Write,Bash(rm*)");
1077 }
1078
1079 #[test]
1080 fn mixed_string_and_typed_patterns_both_accepted() {
1081 use crate::ToolPattern;
1082
1083 let strs: Vec<ToolPattern> = vec!["Bash".into(), ToolPattern::all("Read")];
1087 let cmd = QueryCommand::new("hi").allowed_tools(strs);
1088 assert!(cmd.args().contains(&"--allowed-tools".to_string()));
1089 }
1090
1091 #[test]
1092 fn new_bool_flags_emit_correct_cli_args() {
1093 let args = QueryCommand::new("hi")
1094 .bare()
1095 .disable_slash_commands()
1096 .include_hook_events()
1097 .exclude_dynamic_system_prompt_sections()
1098 .args();
1099 assert!(args.contains(&"--bare".to_string()));
1100 assert!(args.contains(&"--disable-slash-commands".to_string()));
1101 assert!(args.contains(&"--include-hook-events".to_string()));
1102 assert!(args.contains(&"--exclude-dynamic-system-prompt-sections".to_string()));
1103 }
1104
1105 #[test]
1106 fn name_flag_renders_with_value() {
1107 let args = QueryCommand::new("hi").name("my session").args();
1108 let pos = args.iter().position(|a| a == "--name").unwrap();
1109 assert_eq!(args[pos + 1], "my session");
1110 }
1111
1112 #[test]
1113 fn from_pr_flag_renders_with_value() {
1114 let args = QueryCommand::new("hi").from_pr("42").args();
1115 let pos = args.iter().position(|a| a == "--from-pr").unwrap();
1116 assert_eq!(args[pos + 1], "42");
1117 }
1118
1119 #[test]
1120 fn new_bool_flags_default_to_off() {
1121 let args = QueryCommand::new("hi").args();
1122 assert!(!args.contains(&"--bare".to_string()));
1123 assert!(!args.contains(&"--disable-slash-commands".to_string()));
1124 assert!(!args.contains(&"--include-hook-events".to_string()));
1125 assert!(!args.contains(&"--exclude-dynamic-system-prompt-sections".to_string()));
1126 assert!(!args.contains(&"--name".to_string()));
1127 }
1128
1129 #[test]
1130 fn test_separator_before_prompt_prevents_greedy_flag_parsing() {
1131 let cmd = QueryCommand::new("fix the bug")
1134 .allowed_tools(["Read", "Edit", "Bash(cargo *)"])
1135 .output_format(OutputFormat::StreamJson);
1136 let args = cmd.args();
1137 let sep_pos = args.iter().position(|a| a == "--").unwrap();
1139 let prompt_pos = args.iter().position(|a| a == "fix the bug").unwrap();
1140 assert_eq!(prompt_pos, sep_pos + 1, "prompt must follow -- separator");
1141 let tools_pos = args
1143 .iter()
1144 .position(|a| a.contains("Bash(cargo *)"))
1145 .unwrap();
1146 assert!(
1147 tools_pos < sep_pos,
1148 "allowed-tools must come before -- separator"
1149 );
1150 }
1151
1152 #[test]
1153 fn test_stream_json_includes_verbose() {
1154 let cmd = QueryCommand::new("test").output_format(OutputFormat::StreamJson);
1155 let args = cmd.args();
1156 assert!(args.contains(&"--output-format".to_string()));
1157 assert!(args.contains(&"stream-json".to_string()));
1158 assert!(args.contains(&"--verbose".to_string()));
1159 }
1160
1161 #[test]
1162 fn verbose_flag_emitted_when_set() {
1163 let args = QueryCommand::new("test").verbose(true).args();
1164 assert!(args.contains(&"--verbose".to_string()));
1165 }
1166
1167 #[test]
1168 fn verbose_absent_by_default_and_when_false() {
1169 assert!(
1170 !QueryCommand::new("test")
1171 .args()
1172 .contains(&"--verbose".to_string())
1173 );
1174 assert!(
1175 !QueryCommand::new("test")
1176 .verbose(false)
1177 .args()
1178 .contains(&"--verbose".to_string())
1179 );
1180 }
1181
1182 #[test]
1183 fn verbose_not_duplicated_with_stream_json() {
1184 let cmd = QueryCommand::new("test")
1187 .verbose(true)
1188 .output_format(OutputFormat::StreamJson);
1189 let count = cmd.args().iter().filter(|a| *a == "--verbose").count();
1190 assert_eq!(count, 1, "--verbose must appear exactly once");
1191 }
1192
1193 #[test]
1194 fn prompt_suggestions_flag_emitted_when_set() {
1195 let args = QueryCommand::new("test").prompt_suggestions(true).args();
1196 assert!(args.contains(&"--prompt-suggestions".to_string()));
1197 let sep = args.iter().position(|a| a == "--").unwrap();
1200 let flag = args
1201 .iter()
1202 .position(|a| a == "--prompt-suggestions")
1203 .unwrap();
1204 assert!(flag < sep, "--prompt-suggestions must precede `--`");
1205 }
1206
1207 #[test]
1208 fn prompt_suggestions_absent_by_default_and_when_false() {
1209 assert!(
1210 !QueryCommand::new("test")
1211 .args()
1212 .contains(&"--prompt-suggestions".to_string())
1213 );
1214 assert!(
1215 !QueryCommand::new("test")
1216 .prompt_suggestions(false)
1217 .args()
1218 .contains(&"--prompt-suggestions".to_string())
1219 );
1220 }
1221
1222 #[test]
1223 fn replay_user_messages_flag_emitted_when_set() {
1224 let args = QueryCommand::new("test").replay_user_messages(true).args();
1225 assert!(args.contains(&"--replay-user-messages".to_string()));
1226 }
1227
1228 #[test]
1229 fn replay_user_messages_absent_by_default_and_when_false() {
1230 assert!(
1231 !QueryCommand::new("test")
1232 .args()
1233 .contains(&"--replay-user-messages".to_string())
1234 );
1235 assert!(
1236 !QueryCommand::new("test")
1237 .replay_user_messages(false)
1238 .args()
1239 .contains(&"--replay-user-messages".to_string())
1240 );
1241 }
1242
1243 #[test]
1244 fn test_to_command_string_simple() {
1245 let claude = Claude::builder()
1246 .binary("/usr/local/bin/claude")
1247 .build()
1248 .unwrap();
1249
1250 let cmd = QueryCommand::new("hello");
1251 let command_str = cmd.to_command_string(&claude);
1252
1253 assert!(command_str.starts_with("/usr/local/bin/claude"));
1254 assert!(command_str.contains("--print"));
1255 assert!(command_str.contains("hello"));
1256 }
1257
1258 #[test]
1259 fn test_to_command_string_with_spaces() {
1260 let claude = Claude::builder()
1261 .binary("/usr/local/bin/claude")
1262 .build()
1263 .unwrap();
1264
1265 let cmd = QueryCommand::new("hello world").model("sonnet");
1266 let command_str = cmd.to_command_string(&claude);
1267
1268 assert!(command_str.starts_with("/usr/local/bin/claude"));
1269 assert!(command_str.contains("--print"));
1270 assert!(command_str.contains("'hello world'"));
1272 assert!(command_str.contains("--model"));
1273 assert!(command_str.contains("sonnet"));
1274 }
1275
1276 #[test]
1277 fn test_to_command_string_with_special_chars() {
1278 let claude = Claude::builder()
1279 .binary("/usr/local/bin/claude")
1280 .build()
1281 .unwrap();
1282
1283 let cmd = QueryCommand::new("test $VAR and `cmd`");
1284 let command_str = cmd.to_command_string(&claude);
1285
1286 assert!(command_str.contains("'test $VAR and `cmd`'"));
1288 }
1289
1290 #[test]
1291 fn test_to_command_string_with_single_quotes() {
1292 let claude = Claude::builder()
1293 .binary("/usr/local/bin/claude")
1294 .build()
1295 .unwrap();
1296
1297 let cmd = QueryCommand::new("it's");
1298 let command_str = cmd.to_command_string(&claude);
1299
1300 assert!(command_str.contains("'it'\\''s'"));
1302 }
1303
1304 #[test]
1305 fn to_command_string_includes_global_args() {
1306 let claude = Claude::builder()
1309 .binary("/usr/local/bin/claude")
1310 .arg("--debug")
1311 .build()
1312 .unwrap();
1313
1314 let command_str = QueryCommand::new("hello").to_command_string(&claude);
1315
1316 assert!(
1317 command_str.starts_with("/usr/local/bin/claude --debug --print"),
1318 "global args must precede command args; got {command_str}"
1319 );
1320 }
1321
1322 #[test]
1323 fn test_worktree_flag() {
1324 let cmd = QueryCommand::new("test").worktree();
1325 let args = cmd.args();
1326 assert!(args.contains(&"--worktree".to_string()));
1327 }
1328
1329 #[test]
1330 fn test_worktree_named() {
1331 let cmd = QueryCommand::new("test").worktree_named("feature-x");
1332 let args = cmd.args();
1333 assert!(
1334 args.windows(2).any(|w| w == ["--worktree", "feature-x"]),
1335 "missing --worktree feature-x in {args:?}"
1336 );
1337 }
1338
1339 #[test]
1340 fn test_brief_flag() {
1341 let cmd = QueryCommand::new("test").brief();
1342 let args = cmd.args();
1343 assert!(args.contains(&"--brief".to_string()));
1344 }
1345
1346 #[test]
1347 fn test_debug_filter() {
1348 let cmd = QueryCommand::new("test").debug_filter("api,hooks");
1349 let args = cmd.args();
1350 assert!(args.contains(&"--debug".to_string()));
1351 assert!(args.contains(&"api,hooks".to_string()));
1352 }
1353
1354 #[test]
1355 fn test_debug_file() {
1356 let cmd = QueryCommand::new("test").debug_file("/tmp/debug.log");
1357 let args = cmd.args();
1358 assert!(args.contains(&"--debug-file".to_string()));
1359 assert!(args.contains(&"/tmp/debug.log".to_string()));
1360 }
1361
1362 #[test]
1363 fn test_betas() {
1364 let cmd = QueryCommand::new("test").betas("feature-x");
1365 let args = cmd.args();
1366 assert!(args.contains(&"--betas".to_string()));
1367 assert!(args.contains(&"feature-x".to_string()));
1368 }
1369
1370 #[test]
1371 fn test_plugin_dir_single() {
1372 let cmd = QueryCommand::new("test").plugin_dir("/plugins/foo");
1373 let args = cmd.args();
1374 assert!(args.contains(&"--plugin-dir".to_string()));
1375 assert!(args.contains(&"/plugins/foo".to_string()));
1376 }
1377
1378 #[test]
1379 fn test_plugin_dir_multiple() {
1380 let cmd = QueryCommand::new("test")
1381 .plugin_dir("/plugins/foo")
1382 .plugin_dir("/plugins/bar");
1383 let args = cmd.args();
1384 let plugin_dir_count = args.iter().filter(|a| *a == "--plugin-dir").count();
1385 assert_eq!(plugin_dir_count, 2);
1386 assert!(args.contains(&"/plugins/foo".to_string()));
1387 assert!(args.contains(&"/plugins/bar".to_string()));
1388 }
1389
1390 #[test]
1391 fn test_plugin_url_single() {
1392 let cmd = QueryCommand::new("test").plugin_url("https://example.com/p.zip");
1393 let args = cmd.args();
1394 assert!(args.contains(&"--plugin-url".to_string()));
1395 assert!(args.contains(&"https://example.com/p.zip".to_string()));
1396 }
1397
1398 #[test]
1399 fn test_plugin_url_multiple() {
1400 let cmd = QueryCommand::new("test")
1401 .plugin_url("https://example.com/a.zip")
1402 .plugin_url("https://example.com/b.zip");
1403 let args = cmd.args();
1404 let plugin_url_count = args.iter().filter(|a| *a == "--plugin-url").count();
1405 assert_eq!(plugin_url_count, 2);
1406 assert!(args.contains(&"https://example.com/a.zip".to_string()));
1407 assert!(args.contains(&"https://example.com/b.zip".to_string()));
1408 }
1409
1410 #[test]
1411 fn test_safe_mode_flag() {
1412 let cmd = QueryCommand::new("test").safe_mode();
1413 let args = cmd.args();
1414 assert!(args.contains(&"--safe-mode".to_string()));
1415 }
1416
1417 #[test]
1418 fn test_safe_mode_absent_by_default() {
1419 let cmd = QueryCommand::new("test");
1420 let args = cmd.args();
1421 assert!(!args.contains(&"--safe-mode".to_string()));
1422 }
1423
1424 #[test]
1425 fn hermetic_emits_full_seal_flags() {
1426 let args = QueryCommand::new("test").hermetic().args();
1427 assert!(
1428 args.windows(2)
1429 .any(|w| w[0] == "--setting-sources" && w[1].is_empty()),
1430 "got {args:?}"
1431 );
1432 assert!(args.contains(&"--strict-mcp-config".to_string()));
1433 assert!(args.contains(&"--exclude-dynamic-system-prompt-sections".to_string()));
1434 assert!(!args.contains(&"--bare".to_string()));
1435 }
1436
1437 #[test]
1438 fn hermetic_scoped_project_keeps_user() {
1439 let args = QueryCommand::new("test")
1440 .hermetic_scoped(HermeticScope::Project)
1441 .args();
1442 assert!(args.windows(2).any(|w| w == ["--setting-sources", "user"]));
1443 assert!(args.contains(&"--strict-mcp-config".to_string()));
1444 }
1445
1446 #[test]
1447 fn setting_sources_overrides_hermetic_scope() {
1448 let args = QueryCommand::new("test")
1451 .hermetic()
1452 .setting_sources("user,project")
1453 .args();
1454 assert!(
1455 args.windows(2)
1456 .any(|w| w == ["--setting-sources", "user,project"]),
1457 "got {args:?}"
1458 );
1459 assert_eq!(
1460 args.iter().filter(|a| *a == "--setting-sources").count(),
1461 1,
1462 "--setting-sources must not be duplicated"
1463 );
1464 }
1465
1466 #[test]
1467 fn test_setting_sources() {
1468 let cmd = QueryCommand::new("test").setting_sources("user,project,local");
1469 let args = cmd.args();
1470 assert!(args.contains(&"--setting-sources".to_string()));
1471 assert!(args.contains(&"user,project,local".to_string()));
1472 }
1473
1474 #[test]
1475 fn test_tmux_flag() {
1476 let cmd = QueryCommand::new("test").tmux();
1477 let args = cmd.args();
1478 assert!(args.contains(&"--tmux".to_string()));
1479 }
1480}