1use crate::Claude;
2use crate::command::ClaudeCommand;
3use crate::error::Result;
4use crate::exec::{self, CommandOutput};
5use crate::tool_pattern::ToolPattern;
6use crate::types::{Effort, InputFormat, OutputFormat, PermissionMode};
7
8#[derive(Debug, Clone)]
31pub struct QueryCommand {
32 prompt: String,
33 model: Option<String>,
34 system_prompt: Option<String>,
35 append_system_prompt: Option<String>,
36 output_format: Option<OutputFormat>,
37 max_budget_usd: Option<f64>,
38 permission_mode: Option<PermissionMode>,
39 allowed_tools: Vec<ToolPattern>,
40 disallowed_tools: Vec<ToolPattern>,
41 mcp_config: Vec<String>,
42 add_dir: Vec<String>,
43 effort: Option<Effort>,
44 max_turns: Option<u32>,
45 json_schema: Option<String>,
46 continue_session: bool,
47 resume: Option<String>,
48 session_id: Option<String>,
49 fallback_model: Option<String>,
50 no_session_persistence: bool,
51 dangerously_skip_permissions: bool,
52 agent: Option<String>,
53 agents_json: Option<String>,
54 tools: Vec<String>,
55 file: Vec<String>,
56 include_partial_messages: bool,
57 input_format: Option<InputFormat>,
58 strict_mcp_config: bool,
59 settings: Option<String>,
60 fork_session: bool,
61 retry_policy: Option<crate::retry::RetryPolicy>,
62 worktree: bool,
63 worktree_name: Option<String>,
64 brief: bool,
65 debug_filter: Option<String>,
66 debug_file: Option<String>,
67 betas: Option<String>,
68 plugin_dirs: Vec<String>,
69 setting_sources: Option<String>,
70 tmux: bool,
71 bare: bool,
72 disable_slash_commands: bool,
73 include_hook_events: bool,
74 exclude_dynamic_system_prompt_sections: bool,
75 name: Option<String>,
76 from_pr: Option<String>,
77 prompt_via_stdin: bool,
78}
79
80impl QueryCommand {
81 #[must_use]
83 pub fn new(prompt: impl Into<String>) -> Self {
84 Self {
85 prompt: prompt.into(),
86 model: None,
87 system_prompt: None,
88 append_system_prompt: None,
89 output_format: None,
90 max_budget_usd: None,
91 permission_mode: None,
92 allowed_tools: Vec::new(),
93 disallowed_tools: Vec::new(),
94 mcp_config: Vec::new(),
95 add_dir: Vec::new(),
96 effort: None,
97 max_turns: None,
98 json_schema: None,
99 continue_session: false,
100 resume: None,
101 session_id: None,
102 fallback_model: None,
103 no_session_persistence: false,
104 dangerously_skip_permissions: false,
105 agent: None,
106 agents_json: None,
107 tools: Vec::new(),
108 file: Vec::new(),
109 include_partial_messages: false,
110 input_format: None,
111 strict_mcp_config: false,
112 settings: None,
113 fork_session: false,
114 retry_policy: None,
115 worktree: false,
116 worktree_name: None,
117 brief: false,
118 debug_filter: None,
119 debug_file: None,
120 betas: None,
121 plugin_dirs: Vec::new(),
122 setting_sources: None,
123 tmux: false,
124 bare: false,
125 disable_slash_commands: false,
126 include_hook_events: false,
127 exclude_dynamic_system_prompt_sections: false,
128 name: None,
129 from_pr: None,
130 prompt_via_stdin: false,
131 }
132 }
133
134 #[must_use]
136 pub fn model(mut self, model: impl Into<String>) -> Self {
137 self.model = Some(model.into());
138 self
139 }
140
141 #[must_use]
143 pub fn system_prompt(mut self, prompt: impl Into<String>) -> Self {
144 self.system_prompt = Some(prompt.into());
145 self
146 }
147
148 #[must_use]
150 pub fn append_system_prompt(mut self, prompt: impl Into<String>) -> Self {
151 self.append_system_prompt = Some(prompt.into());
152 self
153 }
154
155 #[must_use]
157 pub fn output_format(mut self, format: OutputFormat) -> Self {
158 self.output_format = Some(format);
159 self
160 }
161
162 #[must_use]
164 pub fn max_budget_usd(mut self, budget: f64) -> Self {
165 self.max_budget_usd = Some(budget);
166 self
167 }
168
169 #[must_use]
171 pub fn permission_mode(mut self, mode: PermissionMode) -> Self {
172 self.permission_mode = Some(mode);
173 self
174 }
175
176 #[must_use]
192 pub fn allowed_tools<I, T>(mut self, tools: I) -> Self
193 where
194 I: IntoIterator<Item = T>,
195 T: Into<ToolPattern>,
196 {
197 self.allowed_tools.extend(tools.into_iter().map(Into::into));
198 self
199 }
200
201 #[must_use]
203 pub fn allowed_tool(mut self, tool: impl Into<ToolPattern>) -> Self {
204 self.allowed_tools.push(tool.into());
205 self
206 }
207
208 #[must_use]
210 pub fn disallowed_tools<I, T>(mut self, tools: I) -> Self
211 where
212 I: IntoIterator<Item = T>,
213 T: Into<ToolPattern>,
214 {
215 self.disallowed_tools
216 .extend(tools.into_iter().map(Into::into));
217 self
218 }
219
220 #[must_use]
222 pub fn disallowed_tool(mut self, tool: impl Into<ToolPattern>) -> Self {
223 self.disallowed_tools.push(tool.into());
224 self
225 }
226
227 #[must_use]
229 pub fn mcp_config(mut self, path: impl Into<String>) -> Self {
230 self.mcp_config.push(path.into());
231 self
232 }
233
234 #[must_use]
236 pub fn add_dir(mut self, dir: impl Into<String>) -> Self {
237 self.add_dir.push(dir.into());
238 self
239 }
240
241 #[must_use]
243 pub fn effort(mut self, effort: Effort) -> Self {
244 self.effort = Some(effort);
245 self
246 }
247
248 #[must_use]
250 pub fn max_turns(mut self, turns: u32) -> Self {
251 self.max_turns = Some(turns);
252 self
253 }
254
255 #[must_use]
257 pub fn json_schema(mut self, schema: impl Into<String>) -> Self {
258 self.json_schema = Some(schema.into());
259 self
260 }
261
262 #[must_use]
264 pub fn continue_session(mut self) -> Self {
265 self.continue_session = true;
266 self
267 }
268
269 #[must_use]
271 pub fn resume(mut self, session_id: impl Into<String>) -> Self {
272 self.resume = Some(session_id.into());
273 self
274 }
275
276 #[must_use]
278 pub fn session_id(mut self, id: impl Into<String>) -> Self {
279 self.session_id = Some(id.into());
280 self
281 }
282
283 #[cfg(all(feature = "json", feature = "async"))]
291 pub(crate) fn replace_session(mut self, id: impl Into<String>) -> Self {
292 self.continue_session = false;
293 self.resume = Some(id.into());
294 self.session_id = None;
295 self.fork_session = false;
296 self
297 }
298
299 #[must_use]
301 pub fn fallback_model(mut self, model: impl Into<String>) -> Self {
302 self.fallback_model = Some(model.into());
303 self
304 }
305
306 #[must_use]
308 pub fn no_session_persistence(mut self) -> Self {
309 self.no_session_persistence = true;
310 self
311 }
312
313 #[must_use]
315 pub fn dangerously_skip_permissions(mut self) -> Self {
316 self.dangerously_skip_permissions = true;
317 self
318 }
319
320 #[must_use]
334 pub fn agent(mut self, agent: impl Into<String>) -> Self {
335 self.agent = Some(agent.into());
336 self
337 }
338
339 #[must_use]
351 pub fn agents_json(mut self, json: impl Into<String>) -> Self {
352 self.agents_json = Some(json.into());
353 self
354 }
355
356 #[must_use]
362 pub fn tools(mut self, tools: impl IntoIterator<Item = impl Into<String>>) -> Self {
363 self.tools.extend(tools.into_iter().map(Into::into));
364 self
365 }
366
367 #[must_use]
371 pub fn file(mut self, spec: impl Into<String>) -> Self {
372 self.file.push(spec.into());
373 self
374 }
375
376 #[must_use]
380 pub fn include_partial_messages(mut self) -> Self {
381 self.include_partial_messages = true;
382 self
383 }
384
385 #[must_use]
387 pub fn input_format(mut self, format: InputFormat) -> Self {
388 self.input_format = Some(format);
389 self
390 }
391
392 #[must_use]
394 pub fn strict_mcp_config(mut self) -> Self {
395 self.strict_mcp_config = true;
396 self
397 }
398
399 #[must_use]
401 pub fn settings(mut self, settings: impl Into<String>) -> Self {
402 self.settings = Some(settings.into());
403 self
404 }
405
406 #[must_use]
408 pub fn fork_session(mut self) -> Self {
409 self.fork_session = true;
410 self
411 }
412
413 #[must_use]
415 pub fn worktree(mut self) -> Self {
416 self.worktree = true;
417 self
418 }
419
420 #[must_use]
443 pub fn worktree_named(mut self, name: impl Into<String>) -> Self {
444 self.worktree = true;
445 self.worktree_name = Some(name.into());
446 self
447 }
448
449 #[must_use]
451 pub fn brief(mut self) -> Self {
452 self.brief = true;
453 self
454 }
455
456 #[must_use]
458 pub fn debug_filter(mut self, filter: impl Into<String>) -> Self {
459 self.debug_filter = Some(filter.into());
460 self
461 }
462
463 #[must_use]
465 pub fn debug_file(mut self, path: impl Into<String>) -> Self {
466 self.debug_file = Some(path.into());
467 self
468 }
469
470 #[must_use]
472 pub fn betas(mut self, betas: impl Into<String>) -> Self {
473 self.betas = Some(betas.into());
474 self
475 }
476
477 #[must_use]
479 pub fn plugin_dir(mut self, dir: impl Into<String>) -> Self {
480 self.plugin_dirs.push(dir.into());
481 self
482 }
483
484 #[must_use]
486 pub fn setting_sources(mut self, sources: impl Into<String>) -> Self {
487 self.setting_sources = Some(sources.into());
488 self
489 }
490
491 #[must_use]
493 pub fn tmux(mut self) -> Self {
494 self.tmux = true;
495 self
496 }
497
498 #[must_use]
514 pub fn bare(mut self) -> Self {
515 self.bare = true;
516 self
517 }
518
519 #[must_use]
521 pub fn disable_slash_commands(mut self) -> Self {
522 self.disable_slash_commands = true;
523 self
524 }
525
526 #[must_use]
530 pub fn include_hook_events(mut self) -> Self {
531 self.include_hook_events = true;
532 self
533 }
534
535 #[must_use]
541 pub fn exclude_dynamic_system_prompt_sections(mut self) -> Self {
542 self.exclude_dynamic_system_prompt_sections = true;
543 self
544 }
545
546 #[must_use]
549 pub fn name(mut self, name: impl Into<String>) -> Self {
550 self.name = Some(name.into());
551 self
552 }
553
554 #[must_use]
561 pub fn from_pr(mut self, pr: impl Into<String>) -> Self {
562 self.from_pr = Some(pr.into());
563 self
564 }
565
566 #[must_use]
589 pub fn retry(mut self, policy: crate::retry::RetryPolicy) -> Self {
590 self.retry_policy = Some(policy);
591 self
592 }
593
594 pub fn to_command_string(&self, claude: &Claude) -> String {
617 let args = self.build_args();
618 let quoted_args = args.iter().map(|arg| shell_quote(arg)).collect::<Vec<_>>();
619 format!("{} {}", claude.binary().display(), quoted_args.join(" "))
620 }
621
622 #[cfg(all(feature = "json", feature = "async"))]
627 pub async fn execute_json(&self, claude: &Claude) -> Result<crate::types::QueryResult> {
628 let args = self.build_args_with_forced_json();
629
630 let output = if self.prompt_via_stdin {
631 exec::run_claude_with_stdin_prompt(claude, args, self.prompt.clone()).await?
634 } else {
635 exec::run_claude_with_retry(claude, args, self.retry_policy.as_ref()).await?
636 };
637
638 serde_json::from_str(&output.stdout).map_err(|e| crate::error::Error::Json {
639 message: format!("failed to parse query result: {e}"),
640 source: e,
641 })
642 }
643
644 #[cfg(feature = "sync")]
651 pub fn execute_sync(&self, claude: &Claude) -> Result<CommandOutput> {
652 if self.prompt_via_stdin {
653 exec::run_claude_with_stdin_prompt_sync(claude, self.build_args(), self.prompt.clone())
656 } else {
657 exec::run_claude_with_retry_sync(claude, self.args(), self.retry_policy.as_ref())
658 }
659 }
660
661 #[cfg(all(feature = "sync", feature = "json"))]
663 pub fn execute_json_sync(&self, claude: &Claude) -> Result<crate::types::QueryResult> {
664 let args = self.build_args_with_forced_json();
665
666 let output = if self.prompt_via_stdin {
667 exec::run_claude_with_stdin_prompt_sync(claude, args, self.prompt.clone())?
670 } else {
671 exec::run_claude_with_retry_sync(claude, args, self.retry_policy.as_ref())?
672 };
673
674 serde_json::from_str(&output.stdout).map_err(|e| crate::error::Error::Json {
675 message: format!("failed to parse query result: {e}"),
676 source: e,
677 })
678 }
679
680 #[must_use]
706 pub fn prompt_via_stdin(mut self, value: bool) -> Self {
707 self.prompt_via_stdin = value;
708 self
709 }
710
711 fn build_args_with_forced_json(&self) -> Vec<String> {
719 if self.output_format.is_some() {
720 return self.build_args();
721 }
722 let mut effective = self.clone();
723 effective.output_format = Some(OutputFormat::Json);
724 effective.build_args()
725 }
726
727 fn build_args(&self) -> Vec<String> {
728 let mut args = vec!["--print".to_string()];
729
730 if let Some(ref model) = self.model {
731 args.push("--model".to_string());
732 args.push(model.clone());
733 }
734
735 if let Some(ref prompt) = self.system_prompt {
736 args.push("--system-prompt".to_string());
737 args.push(prompt.clone());
738 }
739
740 if let Some(ref prompt) = self.append_system_prompt {
741 args.push("--append-system-prompt".to_string());
742 args.push(prompt.clone());
743 }
744
745 if let Some(ref format) = self.output_format {
746 args.push("--output-format".to_string());
747 args.push(format.as_arg().to_string());
748 if matches!(format, OutputFormat::StreamJson) {
750 args.push("--verbose".to_string());
751 }
752 }
753
754 if let Some(budget) = self.max_budget_usd {
755 args.push("--max-budget-usd".to_string());
756 args.push(budget.to_string());
757 }
758
759 if let Some(ref mode) = self.permission_mode {
760 args.push("--permission-mode".to_string());
761 args.push(mode.as_arg().to_string());
762 }
763
764 if !self.allowed_tools.is_empty() {
765 args.push("--allowed-tools".to_string());
766 args.push(join_patterns(&self.allowed_tools));
767 }
768
769 if !self.disallowed_tools.is_empty() {
770 args.push("--disallowed-tools".to_string());
771 args.push(join_patterns(&self.disallowed_tools));
772 }
773
774 for config in &self.mcp_config {
775 args.push("--mcp-config".to_string());
776 args.push(config.clone());
777 }
778
779 for dir in &self.add_dir {
780 args.push("--add-dir".to_string());
781 args.push(dir.clone());
782 }
783
784 if let Some(ref effort) = self.effort {
785 args.push("--effort".to_string());
786 args.push(effort.as_arg().to_string());
787 }
788
789 if let Some(turns) = self.max_turns {
790 args.push("--max-turns".to_string());
791 args.push(turns.to_string());
792 }
793
794 if let Some(ref schema) = self.json_schema {
795 args.push("--json-schema".to_string());
796 args.push(schema.clone());
797 }
798
799 if self.continue_session {
800 args.push("--continue".to_string());
801 }
802
803 if let Some(ref session_id) = self.resume {
804 args.push("--resume".to_string());
805 args.push(session_id.clone());
806 }
807
808 if let Some(ref id) = self.session_id {
809 args.push("--session-id".to_string());
810 args.push(id.clone());
811 }
812
813 if let Some(ref model) = self.fallback_model {
814 args.push("--fallback-model".to_string());
815 args.push(model.clone());
816 }
817
818 if self.no_session_persistence {
819 args.push("--no-session-persistence".to_string());
820 }
821
822 if self.dangerously_skip_permissions {
823 args.push("--dangerously-skip-permissions".to_string());
824 }
825
826 if let Some(ref agent) = self.agent {
827 args.push("--agent".to_string());
828 args.push(agent.clone());
829 }
830
831 if let Some(ref agents) = self.agents_json {
832 args.push("--agents".to_string());
833 args.push(agents.clone());
834 }
835
836 if !self.tools.is_empty() {
837 args.push("--tools".to_string());
838 args.push(self.tools.join(","));
839 }
840
841 for spec in &self.file {
842 args.push("--file".to_string());
843 args.push(spec.clone());
844 }
845
846 if self.include_partial_messages {
847 args.push("--include-partial-messages".to_string());
848 }
849
850 if let Some(ref format) = self.input_format {
851 args.push("--input-format".to_string());
852 args.push(format.as_arg().to_string());
853 }
854
855 if self.strict_mcp_config {
856 args.push("--strict-mcp-config".to_string());
857 }
858
859 if let Some(ref settings) = self.settings {
860 args.push("--settings".to_string());
861 args.push(settings.clone());
862 }
863
864 if self.fork_session {
865 args.push("--fork-session".to_string());
866 }
867
868 if self.worktree {
869 args.push("--worktree".to_string());
870 if let Some(ref name) = self.worktree_name {
871 args.push(name.clone());
872 }
873 }
874
875 if self.brief {
876 args.push("--brief".to_string());
877 }
878
879 if let Some(ref filter) = self.debug_filter {
880 args.push("--debug".to_string());
881 args.push(filter.clone());
882 }
883
884 if let Some(ref path) = self.debug_file {
885 args.push("--debug-file".to_string());
886 args.push(path.clone());
887 }
888
889 if let Some(ref betas) = self.betas {
890 args.push("--betas".to_string());
891 args.push(betas.clone());
892 }
893
894 for dir in &self.plugin_dirs {
895 args.push("--plugin-dir".to_string());
896 args.push(dir.clone());
897 }
898
899 if let Some(ref sources) = self.setting_sources {
900 args.push("--setting-sources".to_string());
901 args.push(sources.clone());
902 }
903
904 if self.tmux {
905 args.push("--tmux".to_string());
906 }
907
908 if self.bare {
909 args.push("--bare".to_string());
910 }
911
912 if self.disable_slash_commands {
913 args.push("--disable-slash-commands".to_string());
914 }
915
916 if self.include_hook_events {
917 args.push("--include-hook-events".to_string());
918 }
919
920 if self.exclude_dynamic_system_prompt_sections {
921 args.push("--exclude-dynamic-system-prompt-sections".to_string());
922 }
923
924 if let Some(ref name) = self.name {
925 args.push("--name".to_string());
926 args.push(name.clone());
927 }
928
929 if let Some(ref pr) = self.from_pr {
930 args.push("--from-pr".to_string());
931 args.push(pr.clone());
932 }
933
934 if !self.prompt_via_stdin {
938 args.push("--".to_string());
939 args.push(self.prompt.clone());
940 }
941
942 args
943 }
944}
945
946impl ClaudeCommand for QueryCommand {
947 type Output = CommandOutput;
948
949 fn args(&self) -> Vec<String> {
950 self.build_args()
951 }
952
953 #[cfg(feature = "async")]
954 async fn execute(&self, claude: &Claude) -> Result<CommandOutput> {
955 if self.prompt_via_stdin {
956 let args = self.build_args(); exec::run_claude_with_stdin_prompt(claude, args, self.prompt.clone()).await
960 } else {
961 exec::run_claude_with_retry(claude, self.args(), self.retry_policy.as_ref()).await
962 }
963 }
964}
965
966fn shell_quote(arg: &str) -> String {
968 if arg.contains(|c: char| c.is_whitespace() || "\"'$\\`|;<>&()[]{}".contains(c)) {
970 format!("'{}'", arg.replace("'", "'\\''"))
972 } else {
973 arg.to_string()
974 }
975}
976
977fn join_patterns(patterns: &[ToolPattern]) -> String {
978 let mut out = String::new();
979 for (i, p) in patterns.iter().enumerate() {
980 if i > 0 {
981 out.push(',');
982 }
983 out.push_str(p.as_str());
984 }
985 out
986}
987
988#[cfg(test)]
989mod tests {
990 use super::*;
991
992 #[test]
993 fn test_basic_query_args() {
994 let cmd = QueryCommand::new("hello world");
995 let args = cmd.args();
996 assert_eq!(args, vec!["--print", "--", "hello world"]);
997 }
998
999 #[test]
1000 fn prompt_via_stdin_omits_prompt_from_args() {
1001 let cmd = QueryCommand::new("secret payload").prompt_via_stdin(true);
1002 let args = cmd.args();
1003 assert!(
1004 !args.contains(&"secret payload".to_string()),
1005 "prompt must not appear in args when prompt_via_stdin is set"
1006 );
1007 assert!(
1008 !args.contains(&"--".to_string()),
1009 "-- separator must be absent when prompt_via_stdin is set"
1010 );
1011 }
1012
1013 #[test]
1014 fn prompt_via_stdin_false_keeps_prompt_in_args() {
1015 let cmd = QueryCommand::new("visible prompt").prompt_via_stdin(false);
1016 let args = cmd.args();
1017 assert!(
1018 args.contains(&"visible prompt".to_string()),
1019 "prompt must still appear in args when prompt_via_stdin is false"
1020 );
1021 assert!(
1022 args.contains(&"--".to_string()),
1023 "-- separator must be present when prompt_via_stdin is false"
1024 );
1025 }
1026
1027 #[test]
1028 #[ignore = "requires a real claude binary"]
1029 fn prompt_via_stdin_integration() {
1030 use crate::{Claude, ClaudeCommand};
1033 let rt = tokio::runtime::Runtime::new().unwrap();
1034 rt.block_on(async {
1035 let claude = Claude::builder().build().unwrap();
1036 let out = QueryCommand::new("reply with: STDIN_OK")
1037 .prompt_via_stdin(true)
1038 .execute(&claude)
1039 .await
1040 .unwrap();
1041 assert!(
1042 !out.stdout.is_empty(),
1043 "expected non-empty output from stdin-mode query"
1044 );
1045 });
1046 }
1047
1048 #[test]
1049 fn build_args_with_forced_json_inserts_flag_before_separator() {
1050 let cmd = QueryCommand::new("hello");
1056 let args = cmd.build_args_with_forced_json();
1057
1058 assert_eq!(
1060 &args[args.len() - 2..],
1061 &["--".to_string(), "hello".to_string()],
1062 );
1063
1064 let sep = args.iter().position(|a| a == "--").expect("`--` present");
1066 let fmt = args
1067 .iter()
1068 .position(|a| a == "--output-format")
1069 .expect("--output-format present");
1070 assert!(
1071 fmt < sep,
1072 "--output-format must come before `--` separator; got {args:?}"
1073 );
1074 assert_eq!(args[fmt + 1], "json");
1075 }
1076
1077 #[test]
1078 fn build_args_with_forced_json_respects_explicit_format() {
1079 let cmd = QueryCommand::new("hello").output_format(OutputFormat::Text);
1082 let args = cmd.build_args_with_forced_json();
1083 let fmt = args
1084 .iter()
1085 .position(|a| a == "--output-format")
1086 .expect("--output-format present");
1087 assert_eq!(args[fmt + 1], "text");
1088 assert_eq!(args.iter().filter(|a| *a == "--output-format").count(), 1);
1090 }
1091
1092 #[test]
1093 #[allow(deprecated)] fn test_full_query_args() {
1095 let cmd = QueryCommand::new("explain this")
1096 .model("sonnet")
1097 .system_prompt("be concise")
1098 .output_format(OutputFormat::Json)
1099 .max_budget_usd(0.50)
1100 .permission_mode(PermissionMode::BypassPermissions)
1101 .allowed_tools(["Bash", "Read"])
1102 .mcp_config("/tmp/mcp.json")
1103 .effort(Effort::High)
1104 .max_turns(3)
1105 .no_session_persistence();
1106
1107 let args = cmd.args();
1108 assert!(args.contains(&"--print".to_string()));
1109 assert!(args.contains(&"--model".to_string()));
1110 assert!(args.contains(&"sonnet".to_string()));
1111 assert!(args.contains(&"--system-prompt".to_string()));
1112 assert!(args.contains(&"--output-format".to_string()));
1113 assert!(args.contains(&"json".to_string()));
1114 assert!(!args.contains(&"--verbose".to_string()));
1116 assert!(args.contains(&"--max-budget-usd".to_string()));
1117 assert!(args.contains(&"--permission-mode".to_string()));
1118 assert!(args.contains(&"bypassPermissions".to_string()));
1119 assert!(args.contains(&"--allowed-tools".to_string()));
1120 assert!(args.contains(&"Bash,Read".to_string()));
1121 assert!(args.contains(&"--effort".to_string()));
1122 assert!(args.contains(&"high".to_string()));
1123 assert!(args.contains(&"--max-turns".to_string()));
1124 assert!(args.contains(&"--no-session-persistence".to_string()));
1125 assert_eq!(args.last().unwrap(), "explain this");
1127 assert_eq!(args[args.len() - 2], "--");
1128 }
1129
1130 #[test]
1131 fn typed_patterns_render_in_allowed_tools() {
1132 use crate::ToolPattern;
1133
1134 let cmd = QueryCommand::new("hi")
1135 .allowed_tool(ToolPattern::tool("Read"))
1136 .allowed_tool(ToolPattern::tool_with_args("Bash", "git log:*"))
1137 .allowed_tool(ToolPattern::all("Write"))
1138 .allowed_tool(ToolPattern::mcp("srv", "*"));
1139
1140 let args = cmd.args();
1141 let joined = args
1142 .iter()
1143 .position(|a| a == "--allowed-tools")
1144 .map(|i| &args[i + 1])
1145 .unwrap();
1146 assert_eq!(joined, "Read,Bash(git log:*),Write(*),mcp__srv__*");
1147 }
1148
1149 #[test]
1150 fn disallowed_tool_singular_appends() {
1151 use crate::ToolPattern;
1152
1153 let cmd = QueryCommand::new("hi")
1154 .disallowed_tool("Write")
1155 .disallowed_tool(ToolPattern::tool_with_args("Bash", "rm*"));
1156
1157 let args = cmd.args();
1158 let joined = args
1159 .iter()
1160 .position(|a| a == "--disallowed-tools")
1161 .map(|i| &args[i + 1])
1162 .unwrap();
1163 assert_eq!(joined, "Write,Bash(rm*)");
1164 }
1165
1166 #[test]
1167 fn mixed_string_and_typed_patterns_both_accepted() {
1168 use crate::ToolPattern;
1169
1170 let strs: Vec<ToolPattern> = vec!["Bash".into(), ToolPattern::all("Read")];
1174 let cmd = QueryCommand::new("hi").allowed_tools(strs);
1175 assert!(cmd.args().contains(&"--allowed-tools".to_string()));
1176 }
1177
1178 #[test]
1179 fn new_bool_flags_emit_correct_cli_args() {
1180 let args = QueryCommand::new("hi")
1181 .bare()
1182 .disable_slash_commands()
1183 .include_hook_events()
1184 .exclude_dynamic_system_prompt_sections()
1185 .args();
1186 assert!(args.contains(&"--bare".to_string()));
1187 assert!(args.contains(&"--disable-slash-commands".to_string()));
1188 assert!(args.contains(&"--include-hook-events".to_string()));
1189 assert!(args.contains(&"--exclude-dynamic-system-prompt-sections".to_string()));
1190 }
1191
1192 #[test]
1193 fn name_flag_renders_with_value() {
1194 let args = QueryCommand::new("hi").name("my session").args();
1195 let pos = args.iter().position(|a| a == "--name").unwrap();
1196 assert_eq!(args[pos + 1], "my session");
1197 }
1198
1199 #[test]
1200 fn from_pr_flag_renders_with_value() {
1201 let args = QueryCommand::new("hi").from_pr("42").args();
1202 let pos = args.iter().position(|a| a == "--from-pr").unwrap();
1203 assert_eq!(args[pos + 1], "42");
1204 }
1205
1206 #[test]
1207 fn new_bool_flags_default_to_off() {
1208 let args = QueryCommand::new("hi").args();
1209 assert!(!args.contains(&"--bare".to_string()));
1210 assert!(!args.contains(&"--disable-slash-commands".to_string()));
1211 assert!(!args.contains(&"--include-hook-events".to_string()));
1212 assert!(!args.contains(&"--exclude-dynamic-system-prompt-sections".to_string()));
1213 assert!(!args.contains(&"--name".to_string()));
1214 }
1215
1216 #[test]
1217 fn test_separator_before_prompt_prevents_greedy_flag_parsing() {
1218 let cmd = QueryCommand::new("fix the bug")
1221 .allowed_tools(["Read", "Edit", "Bash(cargo *)"])
1222 .output_format(OutputFormat::StreamJson);
1223 let args = cmd.args();
1224 let sep_pos = args.iter().position(|a| a == "--").unwrap();
1226 let prompt_pos = args.iter().position(|a| a == "fix the bug").unwrap();
1227 assert_eq!(prompt_pos, sep_pos + 1, "prompt must follow -- separator");
1228 let tools_pos = args
1230 .iter()
1231 .position(|a| a.contains("Bash(cargo *)"))
1232 .unwrap();
1233 assert!(
1234 tools_pos < sep_pos,
1235 "allowed-tools must come before -- separator"
1236 );
1237 }
1238
1239 #[test]
1240 fn test_stream_json_includes_verbose() {
1241 let cmd = QueryCommand::new("test").output_format(OutputFormat::StreamJson);
1242 let args = cmd.args();
1243 assert!(args.contains(&"--output-format".to_string()));
1244 assert!(args.contains(&"stream-json".to_string()));
1245 assert!(args.contains(&"--verbose".to_string()));
1246 }
1247
1248 #[test]
1249 fn test_to_command_string_simple() {
1250 let claude = Claude::builder()
1251 .binary("/usr/local/bin/claude")
1252 .build()
1253 .unwrap();
1254
1255 let cmd = QueryCommand::new("hello");
1256 let command_str = cmd.to_command_string(&claude);
1257
1258 assert!(command_str.starts_with("/usr/local/bin/claude"));
1259 assert!(command_str.contains("--print"));
1260 assert!(command_str.contains("hello"));
1261 }
1262
1263 #[test]
1264 fn test_to_command_string_with_spaces() {
1265 let claude = Claude::builder()
1266 .binary("/usr/local/bin/claude")
1267 .build()
1268 .unwrap();
1269
1270 let cmd = QueryCommand::new("hello world").model("sonnet");
1271 let command_str = cmd.to_command_string(&claude);
1272
1273 assert!(command_str.starts_with("/usr/local/bin/claude"));
1274 assert!(command_str.contains("--print"));
1275 assert!(command_str.contains("'hello world'"));
1277 assert!(command_str.contains("--model"));
1278 assert!(command_str.contains("sonnet"));
1279 }
1280
1281 #[test]
1282 fn test_to_command_string_with_special_chars() {
1283 let claude = Claude::builder()
1284 .binary("/usr/local/bin/claude")
1285 .build()
1286 .unwrap();
1287
1288 let cmd = QueryCommand::new("test $VAR and `cmd`");
1289 let command_str = cmd.to_command_string(&claude);
1290
1291 assert!(command_str.contains("'test $VAR and `cmd`'"));
1293 }
1294
1295 #[test]
1296 fn test_to_command_string_with_single_quotes() {
1297 let claude = Claude::builder()
1298 .binary("/usr/local/bin/claude")
1299 .build()
1300 .unwrap();
1301
1302 let cmd = QueryCommand::new("it's");
1303 let command_str = cmd.to_command_string(&claude);
1304
1305 assert!(command_str.contains("'it'\\''s'"));
1307 }
1308
1309 #[test]
1310 fn test_worktree_flag() {
1311 let cmd = QueryCommand::new("test").worktree();
1312 let args = cmd.args();
1313 assert!(args.contains(&"--worktree".to_string()));
1314 }
1315
1316 #[test]
1317 fn test_worktree_named() {
1318 let cmd = QueryCommand::new("test").worktree_named("feature-x");
1319 let args = cmd.args();
1320 assert!(
1321 args.windows(2).any(|w| w == ["--worktree", "feature-x"]),
1322 "missing --worktree feature-x in {args:?}"
1323 );
1324 }
1325
1326 #[test]
1327 fn test_brief_flag() {
1328 let cmd = QueryCommand::new("test").brief();
1329 let args = cmd.args();
1330 assert!(args.contains(&"--brief".to_string()));
1331 }
1332
1333 #[test]
1334 fn test_debug_filter() {
1335 let cmd = QueryCommand::new("test").debug_filter("api,hooks");
1336 let args = cmd.args();
1337 assert!(args.contains(&"--debug".to_string()));
1338 assert!(args.contains(&"api,hooks".to_string()));
1339 }
1340
1341 #[test]
1342 fn test_debug_file() {
1343 let cmd = QueryCommand::new("test").debug_file("/tmp/debug.log");
1344 let args = cmd.args();
1345 assert!(args.contains(&"--debug-file".to_string()));
1346 assert!(args.contains(&"/tmp/debug.log".to_string()));
1347 }
1348
1349 #[test]
1350 fn test_betas() {
1351 let cmd = QueryCommand::new("test").betas("feature-x");
1352 let args = cmd.args();
1353 assert!(args.contains(&"--betas".to_string()));
1354 assert!(args.contains(&"feature-x".to_string()));
1355 }
1356
1357 #[test]
1358 fn test_plugin_dir_single() {
1359 let cmd = QueryCommand::new("test").plugin_dir("/plugins/foo");
1360 let args = cmd.args();
1361 assert!(args.contains(&"--plugin-dir".to_string()));
1362 assert!(args.contains(&"/plugins/foo".to_string()));
1363 }
1364
1365 #[test]
1366 fn test_plugin_dir_multiple() {
1367 let cmd = QueryCommand::new("test")
1368 .plugin_dir("/plugins/foo")
1369 .plugin_dir("/plugins/bar");
1370 let args = cmd.args();
1371 let plugin_dir_count = args.iter().filter(|a| *a == "--plugin-dir").count();
1372 assert_eq!(plugin_dir_count, 2);
1373 assert!(args.contains(&"/plugins/foo".to_string()));
1374 assert!(args.contains(&"/plugins/bar".to_string()));
1375 }
1376
1377 #[test]
1378 fn test_setting_sources() {
1379 let cmd = QueryCommand::new("test").setting_sources("user,project,local");
1380 let args = cmd.args();
1381 assert!(args.contains(&"--setting-sources".to_string()));
1382 assert!(args.contains(&"user,project,local".to_string()));
1383 }
1384
1385 #[test]
1386 fn test_tmux_flag() {
1387 let cmd = QueryCommand::new("test").tmux();
1388 let args = cmd.args();
1389 assert!(args.contains(&"--tmux".to_string()));
1390 }
1391
1392 #[test]
1395 fn shell_quote_plain_word_is_unchanged() {
1396 assert_eq!(shell_quote("simple"), "simple");
1397 assert_eq!(shell_quote(""), "");
1398 assert_eq!(shell_quote("file.rs"), "file.rs");
1399 }
1400
1401 #[test]
1402 fn shell_quote_whitespace_gets_single_quoted() {
1403 assert_eq!(shell_quote("hello world"), "'hello world'");
1404 assert_eq!(shell_quote("a\tb"), "'a\tb'");
1405 }
1406
1407 #[test]
1408 fn shell_quote_metacharacters_get_quoted() {
1409 assert_eq!(shell_quote("a|b"), "'a|b'");
1410 assert_eq!(shell_quote("$VAR"), "'$VAR'");
1411 assert_eq!(shell_quote("a;b"), "'a;b'");
1412 assert_eq!(shell_quote("(x)"), "'(x)'");
1413 }
1414
1415 #[test]
1416 fn shell_quote_embedded_single_quote_is_escaped() {
1417 assert_eq!(shell_quote("it's"), "'it'\\''s'");
1418 }
1419
1420 #[test]
1421 fn shell_quote_double_quote_gets_single_quoted() {
1422 assert_eq!(shell_quote(r#"say "hi""#), r#"'say "hi"'"#);
1423 }
1424}