1use crate::Claude;
12use crate::command::ClaudeCommand;
13use crate::command::spawn_args::SharedSpawnArgs;
14use crate::error::Result;
15use crate::exec::{self, CommandOutput};
16use crate::tool_pattern::ToolPattern;
17use crate::types::{Effort, HermeticScope, InputFormat, OutputFormat, PermissionMode};
18
19#[derive(Debug, Clone)]
42pub struct QueryCommand {
43 prompt: String,
44 shared: SharedSpawnArgs,
47 output_format: Option<OutputFormat>,
48 include_partial_messages: bool,
49 input_format: Option<InputFormat>,
50 retry_policy: Option<crate::retry::RetryPolicy>,
51 brief: bool,
52 from_pr: Option<String>,
53 prompt_via_stdin: bool,
54 verbose: bool,
55 prompt_suggestions: bool,
56 replay_user_messages: bool,
57}
58
59impl QueryCommand {
60 #[must_use]
62 pub fn new(prompt: impl Into<String>) -> Self {
63 Self {
64 prompt: prompt.into(),
65 shared: SharedSpawnArgs::default(),
66 output_format: None,
67 include_partial_messages: false,
68 input_format: None,
69 retry_policy: None,
70 brief: false,
71 from_pr: None,
72 prompt_via_stdin: false,
73 verbose: false,
74 prompt_suggestions: false,
75 replay_user_messages: false,
76 }
77 }
78
79 #[must_use]
81 pub fn model(mut self, model: impl Into<String>) -> Self {
82 self.shared.model = Some(model.into());
83 self
84 }
85
86 #[must_use]
88 pub fn system_prompt(mut self, prompt: impl Into<String>) -> Self {
89 self.shared.system_prompt = Some(prompt.into());
90 self
91 }
92
93 #[must_use]
95 pub fn append_system_prompt(mut self, prompt: impl Into<String>) -> Self {
96 self.shared.append_system_prompt = Some(prompt.into());
97 self
98 }
99
100 #[must_use]
102 pub fn output_format(mut self, format: OutputFormat) -> Self {
103 self.output_format = Some(format);
104 self
105 }
106
107 #[must_use]
109 pub fn max_budget_usd(mut self, budget: f64) -> Self {
110 self.shared.max_budget_usd = Some(budget);
111 self
112 }
113
114 #[must_use]
116 pub fn permission_mode(mut self, mode: PermissionMode) -> Self {
117 self.shared.permission_mode = Some(mode);
118 self
119 }
120
121 #[must_use]
137 pub fn allowed_tools<I, T>(mut self, tools: I) -> Self
138 where
139 I: IntoIterator<Item = T>,
140 T: Into<ToolPattern>,
141 {
142 self.shared
143 .allowed_tools
144 .extend(tools.into_iter().map(Into::into));
145 self
146 }
147
148 #[must_use]
150 pub fn allowed_tool(mut self, tool: impl Into<ToolPattern>) -> Self {
151 self.shared.allowed_tools.push(tool.into());
152 self
153 }
154
155 #[must_use]
157 pub fn disallowed_tools<I, T>(mut self, tools: I) -> Self
158 where
159 I: IntoIterator<Item = T>,
160 T: Into<ToolPattern>,
161 {
162 self.shared
163 .disallowed_tools
164 .extend(tools.into_iter().map(Into::into));
165 self
166 }
167
168 #[must_use]
170 pub fn disallowed_tool(mut self, tool: impl Into<ToolPattern>) -> Self {
171 self.shared.disallowed_tools.push(tool.into());
172 self
173 }
174
175 #[must_use]
177 pub fn mcp_config(mut self, path: impl Into<String>) -> Self {
178 self.shared.mcp_config.push(path.into());
179 self
180 }
181
182 #[must_use]
184 pub fn add_dir(mut self, dir: impl Into<String>) -> Self {
185 self.shared.add_dir.push(dir.into());
186 self
187 }
188
189 #[must_use]
191 pub fn effort(mut self, effort: Effort) -> Self {
192 self.shared.effort = Some(effort);
193 self
194 }
195
196 #[must_use]
198 pub fn max_turns(mut self, turns: u32) -> Self {
199 self.shared.max_turns = Some(turns);
200 self
201 }
202
203 #[must_use]
205 pub fn json_schema(mut self, schema: impl Into<String>) -> Self {
206 self.shared.json_schema = Some(schema.into());
207 self
208 }
209
210 #[must_use]
212 pub fn continue_session(mut self) -> Self {
213 self.shared.continue_session = true;
214 self
215 }
216
217 #[must_use]
219 pub fn resume(mut self, session_id: impl Into<String>) -> Self {
220 self.shared.resume = Some(session_id.into());
221 self
222 }
223
224 #[must_use]
226 pub fn session_id(mut self, id: impl Into<String>) -> Self {
227 self.shared.session_id = Some(id.into());
228 self
229 }
230
231 #[cfg(all(feature = "json", feature = "async"))]
239 pub(crate) fn replace_session(mut self, id: impl Into<String>) -> Self {
240 self.shared.continue_session = false;
241 self.shared.resume = Some(id.into());
242 self.shared.session_id = None;
243 self.shared.fork_session = false;
244 self
245 }
246
247 #[must_use]
249 pub fn fallback_model(mut self, model: impl Into<String>) -> Self {
250 self.shared.fallback_model = Some(model.into());
251 self
252 }
253
254 #[must_use]
256 pub fn no_session_persistence(mut self) -> Self {
257 self.shared.no_session_persistence = true;
258 self
259 }
260
261 #[must_use]
263 pub fn dangerously_skip_permissions(mut self) -> Self {
264 self.shared.dangerously_skip_permissions = true;
265 self
266 }
267
268 #[must_use]
282 pub fn agent(mut self, agent: impl Into<String>) -> Self {
283 self.shared.agent = Some(agent.into());
284 self
285 }
286
287 #[must_use]
299 pub fn agents_json(mut self, json: impl Into<String>) -> Self {
300 self.shared.agents_json = Some(json.into());
301 self
302 }
303
304 #[must_use]
310 pub fn tools(mut self, tools: impl IntoIterator<Item = impl Into<String>>) -> Self {
311 self.shared.tools.extend(tools.into_iter().map(Into::into));
312 self
313 }
314
315 #[must_use]
319 pub fn file(mut self, spec: impl Into<String>) -> Self {
320 self.shared.file.push(spec.into());
321 self
322 }
323
324 #[must_use]
328 pub fn include_partial_messages(mut self) -> Self {
329 self.include_partial_messages = true;
330 self
331 }
332
333 #[must_use]
335 pub fn input_format(mut self, format: InputFormat) -> Self {
336 self.input_format = Some(format);
337 self
338 }
339
340 #[must_use]
342 pub fn strict_mcp_config(mut self) -> Self {
343 self.shared.strict_mcp_config = true;
344 self
345 }
346
347 #[must_use]
349 pub fn settings(mut self, settings: impl Into<String>) -> Self {
350 self.shared.settings = Some(settings.into());
351 self
352 }
353
354 #[must_use]
356 pub fn fork_session(mut self) -> Self {
357 self.shared.fork_session = true;
358 self
359 }
360
361 #[must_use]
363 pub fn worktree(mut self) -> Self {
364 self.shared.worktree = true;
365 self
366 }
367
368 #[must_use]
391 pub fn worktree_named(mut self, name: impl Into<String>) -> Self {
392 self.shared.worktree = true;
393 self.shared.worktree_name = Some(name.into());
394 self
395 }
396
397 #[must_use]
399 pub fn brief(mut self) -> Self {
400 self.brief = true;
401 self
402 }
403
404 #[must_use]
406 pub fn debug_filter(mut self, filter: impl Into<String>) -> Self {
407 self.shared.debug_filter = Some(filter.into());
408 self
409 }
410
411 #[must_use]
413 pub fn debug_file(mut self, path: impl Into<String>) -> Self {
414 self.shared.debug_file = Some(path.into());
415 self
416 }
417
418 #[must_use]
420 pub fn betas(mut self, betas: impl Into<String>) -> Self {
421 self.shared.betas = Some(betas.into());
422 self
423 }
424
425 #[must_use]
427 pub fn plugin_dir(mut self, dir: impl Into<String>) -> Self {
428 self.shared.plugin_dirs.push(dir.into());
429 self
430 }
431
432 #[must_use]
436 pub fn plugin_url(mut self, url: impl Into<String>) -> Self {
437 self.shared.plugin_urls.push(url.into());
438 self
439 }
440
441 #[must_use]
443 pub fn setting_sources(mut self, sources: impl Into<String>) -> Self {
444 self.shared.setting_sources = Some(sources.into());
445 self
446 }
447
448 #[must_use]
463 pub fn hermetic(mut self) -> Self {
464 self.shared.apply_hermetic(HermeticScope::Full);
465 self
466 }
467
468 #[must_use]
475 pub fn hermetic_scoped(mut self, scope: HermeticScope) -> Self {
476 self.shared.apply_hermetic(scope);
477 self
478 }
479
480 #[must_use]
482 pub fn tmux(mut self) -> Self {
483 self.shared.tmux = true;
484 self
485 }
486
487 #[must_use]
503 pub fn bare(mut self) -> Self {
504 self.shared.bare = true;
505 self
506 }
507
508 #[must_use]
510 pub fn disable_slash_commands(mut self) -> Self {
511 self.shared.disable_slash_commands = true;
512 self
513 }
514
515 #[must_use]
524 pub fn safe_mode(mut self) -> Self {
525 self.shared.safe_mode = true;
526 self
527 }
528
529 #[must_use]
533 pub fn include_hook_events(mut self) -> Self {
534 self.shared.include_hook_events = true;
535 self
536 }
537
538 #[must_use]
544 pub fn exclude_dynamic_system_prompt_sections(mut self) -> Self {
545 self.shared.exclude_dynamic_system_prompt_sections = true;
546 self
547 }
548
549 #[must_use]
552 pub fn name(mut self, name: impl Into<String>) -> Self {
553 self.shared.name = Some(name.into());
554 self
555 }
556
557 #[must_use]
564 pub fn from_pr(mut self, pr: impl Into<String>) -> Self {
565 self.from_pr = Some(pr.into());
566 self
567 }
568
569 #[must_use]
577 pub fn verbose(mut self, value: bool) -> Self {
578 self.verbose = value;
579 self
580 }
581
582 #[must_use]
588 pub fn prompt_suggestions(mut self, value: bool) -> Self {
589 self.prompt_suggestions = value;
590 self
591 }
592
593 #[must_use]
601 pub fn replay_user_messages(mut self, value: bool) -> Self {
602 self.replay_user_messages = value;
603 self
604 }
605
606 #[must_use]
629 pub fn retry(mut self, policy: crate::retry::RetryPolicy) -> Self {
630 self.retry_policy = Some(policy);
631 self
632 }
633
634 pub fn to_command_string(&self, claude: &Claude) -> String {
657 let args = self.build_args();
658 let quoted_args = args.iter().map(|arg| shell_quote(arg)).collect::<Vec<_>>();
659 format!("{} {}", claude.binary().display(), quoted_args.join(" "))
660 }
661
662 #[cfg(all(feature = "json", feature = "async"))]
667 pub async fn execute_json(&self, claude: &Claude) -> Result<crate::types::QueryResult> {
668 let args = self.build_args_with_forced_json();
669
670 let output = if self.prompt_via_stdin {
671 exec::run_claude_with_stdin_prompt(claude, args, self.prompt.clone()).await?
674 } else {
675 exec::run_claude_with_retry(claude, args, self.retry_policy.as_ref()).await?
676 };
677
678 serde_json::from_str(&output.stdout).map_err(|e| crate::error::Error::Json {
679 message: format!("failed to parse query result: {e}"),
680 source: e,
681 })
682 }
683
684 #[cfg(feature = "sync")]
691 pub fn execute_sync(&self, claude: &Claude) -> Result<CommandOutput> {
692 if self.prompt_via_stdin {
693 exec::run_claude_with_stdin_prompt_sync(claude, self.build_args(), self.prompt.clone())
696 } else {
697 exec::run_claude_with_retry_sync(claude, self.args(), self.retry_policy.as_ref())
698 }
699 }
700
701 #[cfg(all(feature = "sync", feature = "json"))]
703 pub fn execute_json_sync(&self, claude: &Claude) -> Result<crate::types::QueryResult> {
704 let args = self.build_args_with_forced_json();
705
706 let output = if self.prompt_via_stdin {
707 exec::run_claude_with_stdin_prompt_sync(claude, args, self.prompt.clone())?
710 } else {
711 exec::run_claude_with_retry_sync(claude, args, self.retry_policy.as_ref())?
712 };
713
714 serde_json::from_str(&output.stdout).map_err(|e| crate::error::Error::Json {
715 message: format!("failed to parse query result: {e}"),
716 source: e,
717 })
718 }
719
720 #[must_use]
746 pub fn prompt_via_stdin(mut self, value: bool) -> Self {
747 self.prompt_via_stdin = value;
748 self
749 }
750
751 fn build_args_with_forced_json(&self) -> Vec<String> {
759 if self.output_format.is_some() {
760 return self.build_args();
761 }
762 let mut effective = self.clone();
763 effective.output_format = Some(OutputFormat::Json);
764 effective.build_args()
765 }
766
767 fn build_args(&self) -> Vec<String> {
768 let mut args = vec!["--print".to_string()];
769
770 if let Some(ref format) = self.output_format {
771 args.push("--output-format".to_string());
772 args.push(format.as_arg().to_string());
773 }
774
775 if self.verbose || matches!(self.output_format, Some(OutputFormat::StreamJson)) {
779 args.push("--verbose".to_string());
780 }
781
782 self.shared.append_to(&mut args);
783
784 if self.include_partial_messages {
785 args.push("--include-partial-messages".to_string());
786 }
787
788 if let Some(ref format) = self.input_format {
789 args.push("--input-format".to_string());
790 args.push(format.as_arg().to_string());
791 }
792
793 if self.brief {
794 args.push("--brief".to_string());
795 }
796
797 if self.prompt_suggestions {
798 args.push("--prompt-suggestions".to_string());
799 }
800
801 if self.replay_user_messages {
802 args.push("--replay-user-messages".to_string());
803 }
804
805 if let Some(ref pr) = self.from_pr {
806 args.push("--from-pr".to_string());
807 args.push(pr.clone());
808 }
809
810 if !self.prompt_via_stdin {
814 args.push("--".to_string());
815 args.push(self.prompt.clone());
816 }
817
818 args
819 }
820}
821
822impl ClaudeCommand for QueryCommand {
823 type Output = CommandOutput;
824
825 fn args(&self) -> Vec<String> {
826 self.build_args()
827 }
828
829 #[cfg(feature = "async")]
830 async fn execute(&self, claude: &Claude) -> Result<CommandOutput> {
831 if self.prompt_via_stdin {
832 let args = self.build_args(); exec::run_claude_with_stdin_prompt(claude, args, self.prompt.clone()).await
836 } else {
837 exec::run_claude_with_retry(claude, self.args(), self.retry_policy.as_ref()).await
838 }
839 }
840}
841
842fn shell_quote(arg: &str) -> String {
844 if arg.contains(|c: char| c.is_whitespace() || "\"'$\\`|;<>&()[]{}".contains(c)) {
846 format!("'{}'", arg.replace("'", "'\\''"))
848 } else {
849 arg.to_string()
850 }
851}
852
853#[cfg(test)]
854mod tests {
855 use super::*;
856
857 #[test]
858 fn test_basic_query_args() {
859 let cmd = QueryCommand::new("hello world");
860 let args = cmd.args();
861 assert_eq!(args, vec!["--print", "--", "hello world"]);
862 }
863
864 #[test]
865 fn prompt_via_stdin_omits_prompt_from_args() {
866 let cmd = QueryCommand::new("secret payload").prompt_via_stdin(true);
867 let args = cmd.args();
868 assert!(
869 !args.contains(&"secret payload".to_string()),
870 "prompt must not appear in args when prompt_via_stdin is set"
871 );
872 assert!(
873 !args.contains(&"--".to_string()),
874 "-- separator must be absent when prompt_via_stdin is set"
875 );
876 }
877
878 #[test]
879 fn prompt_via_stdin_false_keeps_prompt_in_args() {
880 let cmd = QueryCommand::new("visible prompt").prompt_via_stdin(false);
881 let args = cmd.args();
882 assert!(
883 args.contains(&"visible prompt".to_string()),
884 "prompt must still appear in args when prompt_via_stdin is false"
885 );
886 assert!(
887 args.contains(&"--".to_string()),
888 "-- separator must be present when prompt_via_stdin is false"
889 );
890 }
891
892 #[test]
893 #[cfg(feature = "async")] #[ignore = "requires a real claude binary"]
895 fn prompt_via_stdin_integration() {
896 use crate::{Claude, ClaudeCommand};
899 let rt = tokio::runtime::Runtime::new().unwrap();
900 rt.block_on(async {
901 let claude = Claude::builder().build().unwrap();
902 let out = QueryCommand::new("reply with: STDIN_OK")
903 .prompt_via_stdin(true)
904 .execute(&claude)
905 .await
906 .unwrap();
907 assert!(
908 !out.stdout.is_empty(),
909 "expected non-empty output from stdin-mode query"
910 );
911 });
912 }
913
914 #[test]
915 fn build_args_with_forced_json_inserts_flag_before_separator() {
916 let cmd = QueryCommand::new("hello");
922 let args = cmd.build_args_with_forced_json();
923
924 assert_eq!(
926 &args[args.len() - 2..],
927 &["--".to_string(), "hello".to_string()],
928 );
929
930 let sep = args.iter().position(|a| a == "--").expect("`--` present");
932 let fmt = args
933 .iter()
934 .position(|a| a == "--output-format")
935 .expect("--output-format present");
936 assert!(
937 fmt < sep,
938 "--output-format must come before `--` separator; got {args:?}"
939 );
940 assert_eq!(args[fmt + 1], "json");
941 }
942
943 #[test]
944 fn build_args_with_forced_json_respects_explicit_format() {
945 let cmd = QueryCommand::new("hello").output_format(OutputFormat::Text);
948 let args = cmd.build_args_with_forced_json();
949 let fmt = args
950 .iter()
951 .position(|a| a == "--output-format")
952 .expect("--output-format present");
953 assert_eq!(args[fmt + 1], "text");
954 assert_eq!(args.iter().filter(|a| *a == "--output-format").count(), 1);
956 }
957
958 #[test]
959 #[allow(deprecated)] fn test_full_query_args() {
961 let cmd = QueryCommand::new("explain this")
962 .model("sonnet")
963 .system_prompt("be concise")
964 .output_format(OutputFormat::Json)
965 .max_budget_usd(0.50)
966 .permission_mode(PermissionMode::BypassPermissions)
967 .allowed_tools(["Bash", "Read"])
968 .mcp_config("/tmp/mcp.json")
969 .effort(Effort::High)
970 .max_turns(3)
971 .no_session_persistence();
972
973 let args = cmd.args();
974 assert!(args.contains(&"--print".to_string()));
975 assert!(args.contains(&"--model".to_string()));
976 assert!(args.contains(&"sonnet".to_string()));
977 assert!(args.contains(&"--system-prompt".to_string()));
978 assert!(args.contains(&"--output-format".to_string()));
979 assert!(args.contains(&"json".to_string()));
980 assert!(!args.contains(&"--verbose".to_string()));
982 assert!(args.contains(&"--max-budget-usd".to_string()));
983 assert!(args.contains(&"--permission-mode".to_string()));
984 assert!(args.contains(&"bypassPermissions".to_string()));
985 assert!(args.contains(&"--allowed-tools".to_string()));
986 assert!(args.contains(&"Bash,Read".to_string()));
987 assert!(args.contains(&"--effort".to_string()));
988 assert!(args.contains(&"high".to_string()));
989 assert!(args.contains(&"--max-turns".to_string()));
990 assert!(args.contains(&"--no-session-persistence".to_string()));
991 assert_eq!(args.last().unwrap(), "explain this");
993 assert_eq!(args[args.len() - 2], "--");
994 }
995
996 #[test]
997 fn typed_patterns_render_in_allowed_tools() {
998 use crate::ToolPattern;
999
1000 let cmd = QueryCommand::new("hi")
1001 .allowed_tool(ToolPattern::tool("Read"))
1002 .allowed_tool(ToolPattern::tool_with_args("Bash", "git log:*"))
1003 .allowed_tool(ToolPattern::all("Write"))
1004 .allowed_tool(ToolPattern::mcp("srv", "*"));
1005
1006 let args = cmd.args();
1007 let joined = args
1008 .iter()
1009 .position(|a| a == "--allowed-tools")
1010 .map(|i| &args[i + 1])
1011 .unwrap();
1012 assert_eq!(joined, "Read,Bash(git log:*),Write(*),mcp__srv__*");
1013 }
1014
1015 #[test]
1016 fn disallowed_tool_singular_appends() {
1017 use crate::ToolPattern;
1018
1019 let cmd = QueryCommand::new("hi")
1020 .disallowed_tool("Write")
1021 .disallowed_tool(ToolPattern::tool_with_args("Bash", "rm*"));
1022
1023 let args = cmd.args();
1024 let joined = args
1025 .iter()
1026 .position(|a| a == "--disallowed-tools")
1027 .map(|i| &args[i + 1])
1028 .unwrap();
1029 assert_eq!(joined, "Write,Bash(rm*)");
1030 }
1031
1032 #[test]
1033 fn mixed_string_and_typed_patterns_both_accepted() {
1034 use crate::ToolPattern;
1035
1036 let strs: Vec<ToolPattern> = vec!["Bash".into(), ToolPattern::all("Read")];
1040 let cmd = QueryCommand::new("hi").allowed_tools(strs);
1041 assert!(cmd.args().contains(&"--allowed-tools".to_string()));
1042 }
1043
1044 #[test]
1045 fn new_bool_flags_emit_correct_cli_args() {
1046 let args = QueryCommand::new("hi")
1047 .bare()
1048 .disable_slash_commands()
1049 .include_hook_events()
1050 .exclude_dynamic_system_prompt_sections()
1051 .args();
1052 assert!(args.contains(&"--bare".to_string()));
1053 assert!(args.contains(&"--disable-slash-commands".to_string()));
1054 assert!(args.contains(&"--include-hook-events".to_string()));
1055 assert!(args.contains(&"--exclude-dynamic-system-prompt-sections".to_string()));
1056 }
1057
1058 #[test]
1059 fn name_flag_renders_with_value() {
1060 let args = QueryCommand::new("hi").name("my session").args();
1061 let pos = args.iter().position(|a| a == "--name").unwrap();
1062 assert_eq!(args[pos + 1], "my session");
1063 }
1064
1065 #[test]
1066 fn from_pr_flag_renders_with_value() {
1067 let args = QueryCommand::new("hi").from_pr("42").args();
1068 let pos = args.iter().position(|a| a == "--from-pr").unwrap();
1069 assert_eq!(args[pos + 1], "42");
1070 }
1071
1072 #[test]
1073 fn new_bool_flags_default_to_off() {
1074 let args = QueryCommand::new("hi").args();
1075 assert!(!args.contains(&"--bare".to_string()));
1076 assert!(!args.contains(&"--disable-slash-commands".to_string()));
1077 assert!(!args.contains(&"--include-hook-events".to_string()));
1078 assert!(!args.contains(&"--exclude-dynamic-system-prompt-sections".to_string()));
1079 assert!(!args.contains(&"--name".to_string()));
1080 }
1081
1082 #[test]
1083 fn test_separator_before_prompt_prevents_greedy_flag_parsing() {
1084 let cmd = QueryCommand::new("fix the bug")
1087 .allowed_tools(["Read", "Edit", "Bash(cargo *)"])
1088 .output_format(OutputFormat::StreamJson);
1089 let args = cmd.args();
1090 let sep_pos = args.iter().position(|a| a == "--").unwrap();
1092 let prompt_pos = args.iter().position(|a| a == "fix the bug").unwrap();
1093 assert_eq!(prompt_pos, sep_pos + 1, "prompt must follow -- separator");
1094 let tools_pos = args
1096 .iter()
1097 .position(|a| a.contains("Bash(cargo *)"))
1098 .unwrap();
1099 assert!(
1100 tools_pos < sep_pos,
1101 "allowed-tools must come before -- separator"
1102 );
1103 }
1104
1105 #[test]
1106 fn test_stream_json_includes_verbose() {
1107 let cmd = QueryCommand::new("test").output_format(OutputFormat::StreamJson);
1108 let args = cmd.args();
1109 assert!(args.contains(&"--output-format".to_string()));
1110 assert!(args.contains(&"stream-json".to_string()));
1111 assert!(args.contains(&"--verbose".to_string()));
1112 }
1113
1114 #[test]
1115 fn verbose_flag_emitted_when_set() {
1116 let args = QueryCommand::new("test").verbose(true).args();
1117 assert!(args.contains(&"--verbose".to_string()));
1118 }
1119
1120 #[test]
1121 fn verbose_absent_by_default_and_when_false() {
1122 assert!(
1123 !QueryCommand::new("test")
1124 .args()
1125 .contains(&"--verbose".to_string())
1126 );
1127 assert!(
1128 !QueryCommand::new("test")
1129 .verbose(false)
1130 .args()
1131 .contains(&"--verbose".to_string())
1132 );
1133 }
1134
1135 #[test]
1136 fn verbose_not_duplicated_with_stream_json() {
1137 let cmd = QueryCommand::new("test")
1140 .verbose(true)
1141 .output_format(OutputFormat::StreamJson);
1142 let count = cmd.args().iter().filter(|a| *a == "--verbose").count();
1143 assert_eq!(count, 1, "--verbose must appear exactly once");
1144 }
1145
1146 #[test]
1147 fn prompt_suggestions_flag_emitted_when_set() {
1148 let args = QueryCommand::new("test").prompt_suggestions(true).args();
1149 assert!(args.contains(&"--prompt-suggestions".to_string()));
1150 let sep = args.iter().position(|a| a == "--").unwrap();
1153 let flag = args
1154 .iter()
1155 .position(|a| a == "--prompt-suggestions")
1156 .unwrap();
1157 assert!(flag < sep, "--prompt-suggestions must precede `--`");
1158 }
1159
1160 #[test]
1161 fn prompt_suggestions_absent_by_default_and_when_false() {
1162 assert!(
1163 !QueryCommand::new("test")
1164 .args()
1165 .contains(&"--prompt-suggestions".to_string())
1166 );
1167 assert!(
1168 !QueryCommand::new("test")
1169 .prompt_suggestions(false)
1170 .args()
1171 .contains(&"--prompt-suggestions".to_string())
1172 );
1173 }
1174
1175 #[test]
1176 fn replay_user_messages_flag_emitted_when_set() {
1177 let args = QueryCommand::new("test").replay_user_messages(true).args();
1178 assert!(args.contains(&"--replay-user-messages".to_string()));
1179 }
1180
1181 #[test]
1182 fn replay_user_messages_absent_by_default_and_when_false() {
1183 assert!(
1184 !QueryCommand::new("test")
1185 .args()
1186 .contains(&"--replay-user-messages".to_string())
1187 );
1188 assert!(
1189 !QueryCommand::new("test")
1190 .replay_user_messages(false)
1191 .args()
1192 .contains(&"--replay-user-messages".to_string())
1193 );
1194 }
1195
1196 #[test]
1197 fn test_to_command_string_simple() {
1198 let claude = Claude::builder()
1199 .binary("/usr/local/bin/claude")
1200 .build()
1201 .unwrap();
1202
1203 let cmd = QueryCommand::new("hello");
1204 let command_str = cmd.to_command_string(&claude);
1205
1206 assert!(command_str.starts_with("/usr/local/bin/claude"));
1207 assert!(command_str.contains("--print"));
1208 assert!(command_str.contains("hello"));
1209 }
1210
1211 #[test]
1212 fn test_to_command_string_with_spaces() {
1213 let claude = Claude::builder()
1214 .binary("/usr/local/bin/claude")
1215 .build()
1216 .unwrap();
1217
1218 let cmd = QueryCommand::new("hello world").model("sonnet");
1219 let command_str = cmd.to_command_string(&claude);
1220
1221 assert!(command_str.starts_with("/usr/local/bin/claude"));
1222 assert!(command_str.contains("--print"));
1223 assert!(command_str.contains("'hello world'"));
1225 assert!(command_str.contains("--model"));
1226 assert!(command_str.contains("sonnet"));
1227 }
1228
1229 #[test]
1230 fn test_to_command_string_with_special_chars() {
1231 let claude = Claude::builder()
1232 .binary("/usr/local/bin/claude")
1233 .build()
1234 .unwrap();
1235
1236 let cmd = QueryCommand::new("test $VAR and `cmd`");
1237 let command_str = cmd.to_command_string(&claude);
1238
1239 assert!(command_str.contains("'test $VAR and `cmd`'"));
1241 }
1242
1243 #[test]
1244 fn test_to_command_string_with_single_quotes() {
1245 let claude = Claude::builder()
1246 .binary("/usr/local/bin/claude")
1247 .build()
1248 .unwrap();
1249
1250 let cmd = QueryCommand::new("it's");
1251 let command_str = cmd.to_command_string(&claude);
1252
1253 assert!(command_str.contains("'it'\\''s'"));
1255 }
1256
1257 #[test]
1258 fn test_worktree_flag() {
1259 let cmd = QueryCommand::new("test").worktree();
1260 let args = cmd.args();
1261 assert!(args.contains(&"--worktree".to_string()));
1262 }
1263
1264 #[test]
1265 fn test_worktree_named() {
1266 let cmd = QueryCommand::new("test").worktree_named("feature-x");
1267 let args = cmd.args();
1268 assert!(
1269 args.windows(2).any(|w| w == ["--worktree", "feature-x"]),
1270 "missing --worktree feature-x in {args:?}"
1271 );
1272 }
1273
1274 #[test]
1275 fn test_brief_flag() {
1276 let cmd = QueryCommand::new("test").brief();
1277 let args = cmd.args();
1278 assert!(args.contains(&"--brief".to_string()));
1279 }
1280
1281 #[test]
1282 fn test_debug_filter() {
1283 let cmd = QueryCommand::new("test").debug_filter("api,hooks");
1284 let args = cmd.args();
1285 assert!(args.contains(&"--debug".to_string()));
1286 assert!(args.contains(&"api,hooks".to_string()));
1287 }
1288
1289 #[test]
1290 fn test_debug_file() {
1291 let cmd = QueryCommand::new("test").debug_file("/tmp/debug.log");
1292 let args = cmd.args();
1293 assert!(args.contains(&"--debug-file".to_string()));
1294 assert!(args.contains(&"/tmp/debug.log".to_string()));
1295 }
1296
1297 #[test]
1298 fn test_betas() {
1299 let cmd = QueryCommand::new("test").betas("feature-x");
1300 let args = cmd.args();
1301 assert!(args.contains(&"--betas".to_string()));
1302 assert!(args.contains(&"feature-x".to_string()));
1303 }
1304
1305 #[test]
1306 fn test_plugin_dir_single() {
1307 let cmd = QueryCommand::new("test").plugin_dir("/plugins/foo");
1308 let args = cmd.args();
1309 assert!(args.contains(&"--plugin-dir".to_string()));
1310 assert!(args.contains(&"/plugins/foo".to_string()));
1311 }
1312
1313 #[test]
1314 fn test_plugin_dir_multiple() {
1315 let cmd = QueryCommand::new("test")
1316 .plugin_dir("/plugins/foo")
1317 .plugin_dir("/plugins/bar");
1318 let args = cmd.args();
1319 let plugin_dir_count = args.iter().filter(|a| *a == "--plugin-dir").count();
1320 assert_eq!(plugin_dir_count, 2);
1321 assert!(args.contains(&"/plugins/foo".to_string()));
1322 assert!(args.contains(&"/plugins/bar".to_string()));
1323 }
1324
1325 #[test]
1326 fn test_plugin_url_single() {
1327 let cmd = QueryCommand::new("test").plugin_url("https://example.com/p.zip");
1328 let args = cmd.args();
1329 assert!(args.contains(&"--plugin-url".to_string()));
1330 assert!(args.contains(&"https://example.com/p.zip".to_string()));
1331 }
1332
1333 #[test]
1334 fn test_plugin_url_multiple() {
1335 let cmd = QueryCommand::new("test")
1336 .plugin_url("https://example.com/a.zip")
1337 .plugin_url("https://example.com/b.zip");
1338 let args = cmd.args();
1339 let plugin_url_count = args.iter().filter(|a| *a == "--plugin-url").count();
1340 assert_eq!(plugin_url_count, 2);
1341 assert!(args.contains(&"https://example.com/a.zip".to_string()));
1342 assert!(args.contains(&"https://example.com/b.zip".to_string()));
1343 }
1344
1345 #[test]
1346 fn test_safe_mode_flag() {
1347 let cmd = QueryCommand::new("test").safe_mode();
1348 let args = cmd.args();
1349 assert!(args.contains(&"--safe-mode".to_string()));
1350 }
1351
1352 #[test]
1353 fn test_safe_mode_absent_by_default() {
1354 let cmd = QueryCommand::new("test");
1355 let args = cmd.args();
1356 assert!(!args.contains(&"--safe-mode".to_string()));
1357 }
1358
1359 #[test]
1360 fn hermetic_emits_full_seal_flags() {
1361 let args = QueryCommand::new("test").hermetic().args();
1362 assert!(
1363 args.windows(2)
1364 .any(|w| w[0] == "--setting-sources" && w[1].is_empty()),
1365 "got {args:?}"
1366 );
1367 assert!(args.contains(&"--strict-mcp-config".to_string()));
1368 assert!(args.contains(&"--exclude-dynamic-system-prompt-sections".to_string()));
1369 assert!(!args.contains(&"--bare".to_string()));
1370 }
1371
1372 #[test]
1373 fn hermetic_scoped_project_keeps_user() {
1374 let args = QueryCommand::new("test")
1375 .hermetic_scoped(HermeticScope::Project)
1376 .args();
1377 assert!(args.windows(2).any(|w| w == ["--setting-sources", "user"]));
1378 assert!(args.contains(&"--strict-mcp-config".to_string()));
1379 }
1380
1381 #[test]
1382 fn setting_sources_overrides_hermetic_scope() {
1383 let args = QueryCommand::new("test")
1386 .hermetic()
1387 .setting_sources("user,project")
1388 .args();
1389 assert!(
1390 args.windows(2)
1391 .any(|w| w == ["--setting-sources", "user,project"]),
1392 "got {args:?}"
1393 );
1394 assert_eq!(
1395 args.iter().filter(|a| *a == "--setting-sources").count(),
1396 1,
1397 "--setting-sources must not be duplicated"
1398 );
1399 }
1400
1401 #[test]
1402 fn test_setting_sources() {
1403 let cmd = QueryCommand::new("test").setting_sources("user,project,local");
1404 let args = cmd.args();
1405 assert!(args.contains(&"--setting-sources".to_string()));
1406 assert!(args.contains(&"user,project,local".to_string()));
1407 }
1408
1409 #[test]
1410 fn test_tmux_flag() {
1411 let cmd = QueryCommand::new("test").tmux();
1412 let args = cmd.args();
1413 assert!(args.contains(&"--tmux".to_string()));
1414 }
1415
1416 #[test]
1419 fn shell_quote_plain_word_is_unchanged() {
1420 assert_eq!(shell_quote("simple"), "simple");
1421 assert_eq!(shell_quote(""), "");
1422 assert_eq!(shell_quote("file.rs"), "file.rs");
1423 }
1424
1425 #[test]
1426 fn shell_quote_whitespace_gets_single_quoted() {
1427 assert_eq!(shell_quote("hello world"), "'hello world'");
1428 assert_eq!(shell_quote("a\tb"), "'a\tb'");
1429 }
1430
1431 #[test]
1432 fn shell_quote_metacharacters_get_quoted() {
1433 assert_eq!(shell_quote("a|b"), "'a|b'");
1434 assert_eq!(shell_quote("$VAR"), "'$VAR'");
1435 assert_eq!(shell_quote("a;b"), "'a;b'");
1436 assert_eq!(shell_quote("(x)"), "'(x)'");
1437 }
1438
1439 #[test]
1440 fn shell_quote_embedded_single_quote_is_escaped() {
1441 assert_eq!(shell_quote("it's"), "'it'\\''s'");
1442 }
1443
1444 #[test]
1445 fn shell_quote_double_quote_gets_single_quoted() {
1446 assert_eq!(shell_quote(r#"say "hi""#), r#"'say "hi"'"#);
1447 }
1448}