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(all(feature = "json", feature = "async"))]
670 pub async fn execute_json(&self, claude: &Claude) -> Result<crate::types::QueryResult> {
671 let args = self.build_args_with_forced_json();
672
673 let output = if self.prompt_via_stdin {
674 exec::run_claude_with_stdin_prompt(claude, args, self.prompt.clone()).await?
677 } else {
678 exec::run_claude_with_retry(claude, args, self.retry_policy.as_ref()).await?
679 };
680
681 serde_json::from_str(&output.stdout).map_err(|e| crate::error::Error::Json {
682 message: format!("failed to parse query result: {e}"),
683 source: e,
684 })
685 }
686
687 #[cfg(feature = "sync")]
694 pub fn execute_sync(&self, claude: &Claude) -> Result<CommandOutput> {
695 if self.prompt_via_stdin {
696 exec::run_claude_with_stdin_prompt_sync(claude, self.build_args(), self.prompt.clone())
699 } else {
700 exec::run_claude_with_retry_sync(claude, self.args(), self.retry_policy.as_ref())
701 }
702 }
703
704 #[cfg(all(feature = "sync", feature = "json"))]
706 pub fn execute_json_sync(&self, claude: &Claude) -> Result<crate::types::QueryResult> {
707 let args = self.build_args_with_forced_json();
708
709 let output = if self.prompt_via_stdin {
710 exec::run_claude_with_stdin_prompt_sync(claude, args, self.prompt.clone())?
713 } else {
714 exec::run_claude_with_retry_sync(claude, args, self.retry_policy.as_ref())?
715 };
716
717 serde_json::from_str(&output.stdout).map_err(|e| crate::error::Error::Json {
718 message: format!("failed to parse query result: {e}"),
719 source: e,
720 })
721 }
722
723 #[must_use]
749 pub fn prompt_via_stdin(mut self, value: bool) -> Self {
750 self.prompt_via_stdin = value;
751 self
752 }
753
754 #[allow(dead_code)] fn build_args_with_forced_json(&self) -> Vec<String> {
763 if self.output_format.is_some() {
764 return self.build_args();
765 }
766 let mut effective = self.clone();
767 effective.output_format = Some(OutputFormat::Json);
768 effective.build_args()
769 }
770
771 fn build_args(&self) -> Vec<String> {
772 let mut args = vec!["--print".to_string()];
773
774 if let Some(ref format) = self.output_format {
775 args.push("--output-format".to_string());
776 args.push(format.as_arg().to_string());
777 }
778
779 if self.verbose || matches!(self.output_format, Some(OutputFormat::StreamJson)) {
783 args.push("--verbose".to_string());
784 }
785
786 self.shared.append_to(&mut args);
787
788 if self.include_partial_messages {
789 args.push("--include-partial-messages".to_string());
790 }
791
792 if let Some(ref format) = self.input_format {
793 args.push("--input-format".to_string());
794 args.push(format.as_arg().to_string());
795 }
796
797 if self.brief {
798 args.push("--brief".to_string());
799 }
800
801 if self.prompt_suggestions {
802 args.push("--prompt-suggestions".to_string());
803 }
804
805 if self.replay_user_messages {
806 args.push("--replay-user-messages".to_string());
807 }
808
809 if let Some(ref pr) = self.from_pr {
810 args.push("--from-pr".to_string());
811 args.push(pr.clone());
812 }
813
814 if !self.prompt_via_stdin {
818 args.push("--".to_string());
819 args.push(self.prompt.clone());
820 }
821
822 args
823 }
824}
825
826impl ClaudeCommand for QueryCommand {
827 type Output = CommandOutput;
828
829 fn args(&self) -> Vec<String> {
830 self.build_args()
831 }
832
833 #[cfg(feature = "async")]
834 async fn execute(&self, claude: &Claude) -> Result<CommandOutput> {
835 if self.prompt_via_stdin {
836 let args = self.build_args(); exec::run_claude_with_stdin_prompt(claude, args, self.prompt.clone()).await
840 } else {
841 exec::run_claude_with_retry(claude, self.args(), self.retry_policy.as_ref()).await
842 }
843 }
844}
845
846#[cfg(test)]
847mod tests {
848 use super::*;
849
850 #[test]
851 fn test_basic_query_args() {
852 let cmd = QueryCommand::new("hello world");
853 let args = cmd.args();
854 assert_eq!(args, vec!["--print", "--", "hello world"]);
855 }
856
857 #[test]
858 fn prompt_via_stdin_omits_prompt_from_args() {
859 let cmd = QueryCommand::new("secret payload").prompt_via_stdin(true);
860 let args = cmd.args();
861 assert!(
862 !args.contains(&"secret payload".to_string()),
863 "prompt must not appear in args when prompt_via_stdin is set"
864 );
865 assert!(
866 !args.contains(&"--".to_string()),
867 "-- separator must be absent when prompt_via_stdin is set"
868 );
869 }
870
871 #[test]
872 fn prompt_via_stdin_false_keeps_prompt_in_args() {
873 let cmd = QueryCommand::new("visible prompt").prompt_via_stdin(false);
874 let args = cmd.args();
875 assert!(
876 args.contains(&"visible prompt".to_string()),
877 "prompt must still appear in args when prompt_via_stdin is false"
878 );
879 assert!(
880 args.contains(&"--".to_string()),
881 "-- separator must be present when prompt_via_stdin is false"
882 );
883 }
884
885 #[test]
886 #[cfg(feature = "async")] #[ignore = "requires a real claude binary"]
888 fn prompt_via_stdin_integration() {
889 use crate::{Claude, ClaudeCommand};
892 let rt = tokio::runtime::Runtime::new().unwrap();
893 rt.block_on(async {
894 let claude = Claude::builder().build().unwrap();
895 let out = QueryCommand::new("reply with: STDIN_OK")
896 .prompt_via_stdin(true)
897 .execute(&claude)
898 .await
899 .unwrap();
900 assert!(
901 !out.stdout.is_empty(),
902 "expected non-empty output from stdin-mode query"
903 );
904 });
905 }
906
907 #[test]
908 fn build_args_with_forced_json_inserts_flag_before_separator() {
909 let cmd = QueryCommand::new("hello");
915 let args = cmd.build_args_with_forced_json();
916
917 assert_eq!(
919 &args[args.len() - 2..],
920 &["--".to_string(), "hello".to_string()],
921 );
922
923 let sep = args.iter().position(|a| a == "--").expect("`--` present");
925 let fmt = args
926 .iter()
927 .position(|a| a == "--output-format")
928 .expect("--output-format present");
929 assert!(
930 fmt < sep,
931 "--output-format must come before `--` separator; got {args:?}"
932 );
933 assert_eq!(args[fmt + 1], "json");
934 }
935
936 #[test]
937 fn build_args_with_forced_json_respects_explicit_format() {
938 let cmd = QueryCommand::new("hello").output_format(OutputFormat::Text);
941 let args = cmd.build_args_with_forced_json();
942 let fmt = args
943 .iter()
944 .position(|a| a == "--output-format")
945 .expect("--output-format present");
946 assert_eq!(args[fmt + 1], "text");
947 assert_eq!(args.iter().filter(|a| *a == "--output-format").count(), 1);
949 }
950
951 #[test]
952 #[allow(deprecated)] fn test_full_query_args() {
954 let cmd = QueryCommand::new("explain this")
955 .model("sonnet")
956 .system_prompt("be concise")
957 .output_format(OutputFormat::Json)
958 .max_budget_usd(0.50)
959 .permission_mode(PermissionMode::BypassPermissions)
960 .allowed_tools(["Bash", "Read"])
961 .mcp_config("/tmp/mcp.json")
962 .effort(Effort::High)
963 .max_turns(3)
964 .no_session_persistence();
965
966 let args = cmd.args();
967 assert!(args.contains(&"--print".to_string()));
968 assert!(args.contains(&"--model".to_string()));
969 assert!(args.contains(&"sonnet".to_string()));
970 assert!(args.contains(&"--system-prompt".to_string()));
971 assert!(args.contains(&"--output-format".to_string()));
972 assert!(args.contains(&"json".to_string()));
973 assert!(!args.contains(&"--verbose".to_string()));
975 assert!(args.contains(&"--max-budget-usd".to_string()));
976 assert!(args.contains(&"--permission-mode".to_string()));
977 assert!(args.contains(&"bypassPermissions".to_string()));
978 assert!(args.contains(&"--allowed-tools".to_string()));
979 assert!(args.contains(&"Bash,Read".to_string()));
980 assert!(args.contains(&"--effort".to_string()));
981 assert!(args.contains(&"high".to_string()));
982 assert!(args.contains(&"--max-turns".to_string()));
983 assert!(args.contains(&"--no-session-persistence".to_string()));
984 assert_eq!(args.last().unwrap(), "explain this");
986 assert_eq!(args[args.len() - 2], "--");
987 }
988
989 #[test]
990 fn typed_patterns_render_in_allowed_tools() {
991 use crate::ToolPattern;
992
993 let cmd = QueryCommand::new("hi")
994 .allowed_tool(ToolPattern::tool("Read"))
995 .allowed_tool(ToolPattern::tool_with_args("Bash", "git log:*"))
996 .allowed_tool(ToolPattern::all("Write"))
997 .allowed_tool(ToolPattern::mcp("srv", "*"));
998
999 let args = cmd.args();
1000 let joined = args
1001 .iter()
1002 .position(|a| a == "--allowed-tools")
1003 .map(|i| &args[i + 1])
1004 .unwrap();
1005 assert_eq!(joined, "Read,Bash(git log:*),Write(*),mcp__srv__*");
1006 }
1007
1008 #[test]
1009 fn disallowed_tool_singular_appends() {
1010 use crate::ToolPattern;
1011
1012 let cmd = QueryCommand::new("hi")
1013 .disallowed_tool("Write")
1014 .disallowed_tool(ToolPattern::tool_with_args("Bash", "rm*"));
1015
1016 let args = cmd.args();
1017 let joined = args
1018 .iter()
1019 .position(|a| a == "--disallowed-tools")
1020 .map(|i| &args[i + 1])
1021 .unwrap();
1022 assert_eq!(joined, "Write,Bash(rm*)");
1023 }
1024
1025 #[test]
1026 fn mixed_string_and_typed_patterns_both_accepted() {
1027 use crate::ToolPattern;
1028
1029 let strs: Vec<ToolPattern> = vec!["Bash".into(), ToolPattern::all("Read")];
1033 let cmd = QueryCommand::new("hi").allowed_tools(strs);
1034 assert!(cmd.args().contains(&"--allowed-tools".to_string()));
1035 }
1036
1037 #[test]
1038 fn new_bool_flags_emit_correct_cli_args() {
1039 let args = QueryCommand::new("hi")
1040 .bare()
1041 .disable_slash_commands()
1042 .include_hook_events()
1043 .exclude_dynamic_system_prompt_sections()
1044 .args();
1045 assert!(args.contains(&"--bare".to_string()));
1046 assert!(args.contains(&"--disable-slash-commands".to_string()));
1047 assert!(args.contains(&"--include-hook-events".to_string()));
1048 assert!(args.contains(&"--exclude-dynamic-system-prompt-sections".to_string()));
1049 }
1050
1051 #[test]
1052 fn name_flag_renders_with_value() {
1053 let args = QueryCommand::new("hi").name("my session").args();
1054 let pos = args.iter().position(|a| a == "--name").unwrap();
1055 assert_eq!(args[pos + 1], "my session");
1056 }
1057
1058 #[test]
1059 fn from_pr_flag_renders_with_value() {
1060 let args = QueryCommand::new("hi").from_pr("42").args();
1061 let pos = args.iter().position(|a| a == "--from-pr").unwrap();
1062 assert_eq!(args[pos + 1], "42");
1063 }
1064
1065 #[test]
1066 fn new_bool_flags_default_to_off() {
1067 let args = QueryCommand::new("hi").args();
1068 assert!(!args.contains(&"--bare".to_string()));
1069 assert!(!args.contains(&"--disable-slash-commands".to_string()));
1070 assert!(!args.contains(&"--include-hook-events".to_string()));
1071 assert!(!args.contains(&"--exclude-dynamic-system-prompt-sections".to_string()));
1072 assert!(!args.contains(&"--name".to_string()));
1073 }
1074
1075 #[test]
1076 fn test_separator_before_prompt_prevents_greedy_flag_parsing() {
1077 let cmd = QueryCommand::new("fix the bug")
1080 .allowed_tools(["Read", "Edit", "Bash(cargo *)"])
1081 .output_format(OutputFormat::StreamJson);
1082 let args = cmd.args();
1083 let sep_pos = args.iter().position(|a| a == "--").unwrap();
1085 let prompt_pos = args.iter().position(|a| a == "fix the bug").unwrap();
1086 assert_eq!(prompt_pos, sep_pos + 1, "prompt must follow -- separator");
1087 let tools_pos = args
1089 .iter()
1090 .position(|a| a.contains("Bash(cargo *)"))
1091 .unwrap();
1092 assert!(
1093 tools_pos < sep_pos,
1094 "allowed-tools must come before -- separator"
1095 );
1096 }
1097
1098 #[test]
1099 fn test_stream_json_includes_verbose() {
1100 let cmd = QueryCommand::new("test").output_format(OutputFormat::StreamJson);
1101 let args = cmd.args();
1102 assert!(args.contains(&"--output-format".to_string()));
1103 assert!(args.contains(&"stream-json".to_string()));
1104 assert!(args.contains(&"--verbose".to_string()));
1105 }
1106
1107 #[test]
1108 fn verbose_flag_emitted_when_set() {
1109 let args = QueryCommand::new("test").verbose(true).args();
1110 assert!(args.contains(&"--verbose".to_string()));
1111 }
1112
1113 #[test]
1114 fn verbose_absent_by_default_and_when_false() {
1115 assert!(
1116 !QueryCommand::new("test")
1117 .args()
1118 .contains(&"--verbose".to_string())
1119 );
1120 assert!(
1121 !QueryCommand::new("test")
1122 .verbose(false)
1123 .args()
1124 .contains(&"--verbose".to_string())
1125 );
1126 }
1127
1128 #[test]
1129 fn verbose_not_duplicated_with_stream_json() {
1130 let cmd = QueryCommand::new("test")
1133 .verbose(true)
1134 .output_format(OutputFormat::StreamJson);
1135 let count = cmd.args().iter().filter(|a| *a == "--verbose").count();
1136 assert_eq!(count, 1, "--verbose must appear exactly once");
1137 }
1138
1139 #[test]
1140 fn prompt_suggestions_flag_emitted_when_set() {
1141 let args = QueryCommand::new("test").prompt_suggestions(true).args();
1142 assert!(args.contains(&"--prompt-suggestions".to_string()));
1143 let sep = args.iter().position(|a| a == "--").unwrap();
1146 let flag = args
1147 .iter()
1148 .position(|a| a == "--prompt-suggestions")
1149 .unwrap();
1150 assert!(flag < sep, "--prompt-suggestions must precede `--`");
1151 }
1152
1153 #[test]
1154 fn prompt_suggestions_absent_by_default_and_when_false() {
1155 assert!(
1156 !QueryCommand::new("test")
1157 .args()
1158 .contains(&"--prompt-suggestions".to_string())
1159 );
1160 assert!(
1161 !QueryCommand::new("test")
1162 .prompt_suggestions(false)
1163 .args()
1164 .contains(&"--prompt-suggestions".to_string())
1165 );
1166 }
1167
1168 #[test]
1169 fn replay_user_messages_flag_emitted_when_set() {
1170 let args = QueryCommand::new("test").replay_user_messages(true).args();
1171 assert!(args.contains(&"--replay-user-messages".to_string()));
1172 }
1173
1174 #[test]
1175 fn replay_user_messages_absent_by_default_and_when_false() {
1176 assert!(
1177 !QueryCommand::new("test")
1178 .args()
1179 .contains(&"--replay-user-messages".to_string())
1180 );
1181 assert!(
1182 !QueryCommand::new("test")
1183 .replay_user_messages(false)
1184 .args()
1185 .contains(&"--replay-user-messages".to_string())
1186 );
1187 }
1188
1189 #[test]
1190 fn test_to_command_string_simple() {
1191 let claude = Claude::builder()
1192 .binary("/usr/local/bin/claude")
1193 .build()
1194 .unwrap();
1195
1196 let cmd = QueryCommand::new("hello");
1197 let command_str = cmd.to_command_string(&claude);
1198
1199 assert!(command_str.starts_with("/usr/local/bin/claude"));
1200 assert!(command_str.contains("--print"));
1201 assert!(command_str.contains("hello"));
1202 }
1203
1204 #[test]
1205 fn test_to_command_string_with_spaces() {
1206 let claude = Claude::builder()
1207 .binary("/usr/local/bin/claude")
1208 .build()
1209 .unwrap();
1210
1211 let cmd = QueryCommand::new("hello world").model("sonnet");
1212 let command_str = cmd.to_command_string(&claude);
1213
1214 assert!(command_str.starts_with("/usr/local/bin/claude"));
1215 assert!(command_str.contains("--print"));
1216 assert!(command_str.contains("'hello world'"));
1218 assert!(command_str.contains("--model"));
1219 assert!(command_str.contains("sonnet"));
1220 }
1221
1222 #[test]
1223 fn test_to_command_string_with_special_chars() {
1224 let claude = Claude::builder()
1225 .binary("/usr/local/bin/claude")
1226 .build()
1227 .unwrap();
1228
1229 let cmd = QueryCommand::new("test $VAR and `cmd`");
1230 let command_str = cmd.to_command_string(&claude);
1231
1232 assert!(command_str.contains("'test $VAR and `cmd`'"));
1234 }
1235
1236 #[test]
1237 fn test_to_command_string_with_single_quotes() {
1238 let claude = Claude::builder()
1239 .binary("/usr/local/bin/claude")
1240 .build()
1241 .unwrap();
1242
1243 let cmd = QueryCommand::new("it's");
1244 let command_str = cmd.to_command_string(&claude);
1245
1246 assert!(command_str.contains("'it'\\''s'"));
1248 }
1249
1250 #[test]
1251 fn to_command_string_includes_global_args() {
1252 let claude = Claude::builder()
1255 .binary("/usr/local/bin/claude")
1256 .arg("--debug")
1257 .build()
1258 .unwrap();
1259
1260 let command_str = QueryCommand::new("hello").to_command_string(&claude);
1261
1262 assert!(
1263 command_str.starts_with("/usr/local/bin/claude --debug --print"),
1264 "global args must precede command args; got {command_str}"
1265 );
1266 }
1267
1268 #[test]
1269 fn test_worktree_flag() {
1270 let cmd = QueryCommand::new("test").worktree();
1271 let args = cmd.args();
1272 assert!(args.contains(&"--worktree".to_string()));
1273 }
1274
1275 #[test]
1276 fn test_worktree_named() {
1277 let cmd = QueryCommand::new("test").worktree_named("feature-x");
1278 let args = cmd.args();
1279 assert!(
1280 args.windows(2).any(|w| w == ["--worktree", "feature-x"]),
1281 "missing --worktree feature-x in {args:?}"
1282 );
1283 }
1284
1285 #[test]
1286 fn test_brief_flag() {
1287 let cmd = QueryCommand::new("test").brief();
1288 let args = cmd.args();
1289 assert!(args.contains(&"--brief".to_string()));
1290 }
1291
1292 #[test]
1293 fn test_debug_filter() {
1294 let cmd = QueryCommand::new("test").debug_filter("api,hooks");
1295 let args = cmd.args();
1296 assert!(args.contains(&"--debug".to_string()));
1297 assert!(args.contains(&"api,hooks".to_string()));
1298 }
1299
1300 #[test]
1301 fn test_debug_file() {
1302 let cmd = QueryCommand::new("test").debug_file("/tmp/debug.log");
1303 let args = cmd.args();
1304 assert!(args.contains(&"--debug-file".to_string()));
1305 assert!(args.contains(&"/tmp/debug.log".to_string()));
1306 }
1307
1308 #[test]
1309 fn test_betas() {
1310 let cmd = QueryCommand::new("test").betas("feature-x");
1311 let args = cmd.args();
1312 assert!(args.contains(&"--betas".to_string()));
1313 assert!(args.contains(&"feature-x".to_string()));
1314 }
1315
1316 #[test]
1317 fn test_plugin_dir_single() {
1318 let cmd = QueryCommand::new("test").plugin_dir("/plugins/foo");
1319 let args = cmd.args();
1320 assert!(args.contains(&"--plugin-dir".to_string()));
1321 assert!(args.contains(&"/plugins/foo".to_string()));
1322 }
1323
1324 #[test]
1325 fn test_plugin_dir_multiple() {
1326 let cmd = QueryCommand::new("test")
1327 .plugin_dir("/plugins/foo")
1328 .plugin_dir("/plugins/bar");
1329 let args = cmd.args();
1330 let plugin_dir_count = args.iter().filter(|a| *a == "--plugin-dir").count();
1331 assert_eq!(plugin_dir_count, 2);
1332 assert!(args.contains(&"/plugins/foo".to_string()));
1333 assert!(args.contains(&"/plugins/bar".to_string()));
1334 }
1335
1336 #[test]
1337 fn test_plugin_url_single() {
1338 let cmd = QueryCommand::new("test").plugin_url("https://example.com/p.zip");
1339 let args = cmd.args();
1340 assert!(args.contains(&"--plugin-url".to_string()));
1341 assert!(args.contains(&"https://example.com/p.zip".to_string()));
1342 }
1343
1344 #[test]
1345 fn test_plugin_url_multiple() {
1346 let cmd = QueryCommand::new("test")
1347 .plugin_url("https://example.com/a.zip")
1348 .plugin_url("https://example.com/b.zip");
1349 let args = cmd.args();
1350 let plugin_url_count = args.iter().filter(|a| *a == "--plugin-url").count();
1351 assert_eq!(plugin_url_count, 2);
1352 assert!(args.contains(&"https://example.com/a.zip".to_string()));
1353 assert!(args.contains(&"https://example.com/b.zip".to_string()));
1354 }
1355
1356 #[test]
1357 fn test_safe_mode_flag() {
1358 let cmd = QueryCommand::new("test").safe_mode();
1359 let args = cmd.args();
1360 assert!(args.contains(&"--safe-mode".to_string()));
1361 }
1362
1363 #[test]
1364 fn test_safe_mode_absent_by_default() {
1365 let cmd = QueryCommand::new("test");
1366 let args = cmd.args();
1367 assert!(!args.contains(&"--safe-mode".to_string()));
1368 }
1369
1370 #[test]
1371 fn hermetic_emits_full_seal_flags() {
1372 let args = QueryCommand::new("test").hermetic().args();
1373 assert!(
1374 args.windows(2)
1375 .any(|w| w[0] == "--setting-sources" && w[1].is_empty()),
1376 "got {args:?}"
1377 );
1378 assert!(args.contains(&"--strict-mcp-config".to_string()));
1379 assert!(args.contains(&"--exclude-dynamic-system-prompt-sections".to_string()));
1380 assert!(!args.contains(&"--bare".to_string()));
1381 }
1382
1383 #[test]
1384 fn hermetic_scoped_project_keeps_user() {
1385 let args = QueryCommand::new("test")
1386 .hermetic_scoped(HermeticScope::Project)
1387 .args();
1388 assert!(args.windows(2).any(|w| w == ["--setting-sources", "user"]));
1389 assert!(args.contains(&"--strict-mcp-config".to_string()));
1390 }
1391
1392 #[test]
1393 fn setting_sources_overrides_hermetic_scope() {
1394 let args = QueryCommand::new("test")
1397 .hermetic()
1398 .setting_sources("user,project")
1399 .args();
1400 assert!(
1401 args.windows(2)
1402 .any(|w| w == ["--setting-sources", "user,project"]),
1403 "got {args:?}"
1404 );
1405 assert_eq!(
1406 args.iter().filter(|a| *a == "--setting-sources").count(),
1407 1,
1408 "--setting-sources must not be duplicated"
1409 );
1410 }
1411
1412 #[test]
1413 fn test_setting_sources() {
1414 let cmd = QueryCommand::new("test").setting_sources("user,project,local");
1415 let args = cmd.args();
1416 assert!(args.contains(&"--setting-sources".to_string()));
1417 assert!(args.contains(&"user,project,local".to_string()));
1418 }
1419
1420 #[test]
1421 fn test_tmux_flag() {
1422 let cmd = QueryCommand::new("test").tmux();
1423 let args = cmd.args();
1424 assert!(args.contains(&"--tmux".to_string()));
1425 }
1426}