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 brief: bool,
64 debug_filter: Option<String>,
65 debug_file: Option<String>,
66 betas: Option<String>,
67 plugin_dirs: Vec<String>,
68 setting_sources: Option<String>,
69 tmux: bool,
70 bare: bool,
71 disable_slash_commands: bool,
72 include_hook_events: bool,
73 exclude_dynamic_system_prompt_sections: bool,
74 name: Option<String>,
75 from_pr: Option<String>,
76}
77
78impl QueryCommand {
79 #[must_use]
81 pub fn new(prompt: impl Into<String>) -> Self {
82 Self {
83 prompt: prompt.into(),
84 model: None,
85 system_prompt: None,
86 append_system_prompt: None,
87 output_format: None,
88 max_budget_usd: None,
89 permission_mode: None,
90 allowed_tools: Vec::new(),
91 disallowed_tools: Vec::new(),
92 mcp_config: Vec::new(),
93 add_dir: Vec::new(),
94 effort: None,
95 max_turns: None,
96 json_schema: None,
97 continue_session: false,
98 resume: None,
99 session_id: None,
100 fallback_model: None,
101 no_session_persistence: false,
102 dangerously_skip_permissions: false,
103 agent: None,
104 agents_json: None,
105 tools: Vec::new(),
106 file: Vec::new(),
107 include_partial_messages: false,
108 input_format: None,
109 strict_mcp_config: false,
110 settings: None,
111 fork_session: false,
112 retry_policy: None,
113 worktree: false,
114 brief: false,
115 debug_filter: None,
116 debug_file: None,
117 betas: None,
118 plugin_dirs: Vec::new(),
119 setting_sources: None,
120 tmux: false,
121 bare: false,
122 disable_slash_commands: false,
123 include_hook_events: false,
124 exclude_dynamic_system_prompt_sections: false,
125 name: None,
126 from_pr: None,
127 }
128 }
129
130 #[must_use]
132 pub fn model(mut self, model: impl Into<String>) -> Self {
133 self.model = Some(model.into());
134 self
135 }
136
137 #[must_use]
139 pub fn system_prompt(mut self, prompt: impl Into<String>) -> Self {
140 self.system_prompt = Some(prompt.into());
141 self
142 }
143
144 #[must_use]
146 pub fn append_system_prompt(mut self, prompt: impl Into<String>) -> Self {
147 self.append_system_prompt = Some(prompt.into());
148 self
149 }
150
151 #[must_use]
153 pub fn output_format(mut self, format: OutputFormat) -> Self {
154 self.output_format = Some(format);
155 self
156 }
157
158 #[must_use]
160 pub fn max_budget_usd(mut self, budget: f64) -> Self {
161 self.max_budget_usd = Some(budget);
162 self
163 }
164
165 #[must_use]
167 pub fn permission_mode(mut self, mode: PermissionMode) -> Self {
168 self.permission_mode = Some(mode);
169 self
170 }
171
172 #[must_use]
188 pub fn allowed_tools<I, T>(mut self, tools: I) -> Self
189 where
190 I: IntoIterator<Item = T>,
191 T: Into<ToolPattern>,
192 {
193 self.allowed_tools.extend(tools.into_iter().map(Into::into));
194 self
195 }
196
197 #[must_use]
199 pub fn allowed_tool(mut self, tool: impl Into<ToolPattern>) -> Self {
200 self.allowed_tools.push(tool.into());
201 self
202 }
203
204 #[must_use]
206 pub fn disallowed_tools<I, T>(mut self, tools: I) -> Self
207 where
208 I: IntoIterator<Item = T>,
209 T: Into<ToolPattern>,
210 {
211 self.disallowed_tools
212 .extend(tools.into_iter().map(Into::into));
213 self
214 }
215
216 #[must_use]
218 pub fn disallowed_tool(mut self, tool: impl Into<ToolPattern>) -> Self {
219 self.disallowed_tools.push(tool.into());
220 self
221 }
222
223 #[must_use]
225 pub fn mcp_config(mut self, path: impl Into<String>) -> Self {
226 self.mcp_config.push(path.into());
227 self
228 }
229
230 #[must_use]
232 pub fn add_dir(mut self, dir: impl Into<String>) -> Self {
233 self.add_dir.push(dir.into());
234 self
235 }
236
237 #[must_use]
239 pub fn effort(mut self, effort: Effort) -> Self {
240 self.effort = Some(effort);
241 self
242 }
243
244 #[must_use]
246 pub fn max_turns(mut self, turns: u32) -> Self {
247 self.max_turns = Some(turns);
248 self
249 }
250
251 #[must_use]
253 pub fn json_schema(mut self, schema: impl Into<String>) -> Self {
254 self.json_schema = Some(schema.into());
255 self
256 }
257
258 #[must_use]
260 pub fn continue_session(mut self) -> Self {
261 self.continue_session = true;
262 self
263 }
264
265 #[must_use]
267 pub fn resume(mut self, session_id: impl Into<String>) -> Self {
268 self.resume = Some(session_id.into());
269 self
270 }
271
272 #[must_use]
274 pub fn session_id(mut self, id: impl Into<String>) -> Self {
275 self.session_id = Some(id.into());
276 self
277 }
278
279 #[cfg(all(feature = "json", feature = "async"))]
287 pub(crate) fn replace_session(mut self, id: impl Into<String>) -> Self {
288 self.continue_session = false;
289 self.resume = Some(id.into());
290 self.session_id = None;
291 self.fork_session = false;
292 self
293 }
294
295 #[must_use]
297 pub fn fallback_model(mut self, model: impl Into<String>) -> Self {
298 self.fallback_model = Some(model.into());
299 self
300 }
301
302 #[must_use]
304 pub fn no_session_persistence(mut self) -> Self {
305 self.no_session_persistence = true;
306 self
307 }
308
309 #[must_use]
311 pub fn dangerously_skip_permissions(mut self) -> Self {
312 self.dangerously_skip_permissions = true;
313 self
314 }
315
316 #[must_use]
318 pub fn agent(mut self, agent: impl Into<String>) -> Self {
319 self.agent = Some(agent.into());
320 self
321 }
322
323 #[must_use]
327 pub fn agents_json(mut self, json: impl Into<String>) -> Self {
328 self.agents_json = Some(json.into());
329 self
330 }
331
332 #[must_use]
338 pub fn tools(mut self, tools: impl IntoIterator<Item = impl Into<String>>) -> Self {
339 self.tools.extend(tools.into_iter().map(Into::into));
340 self
341 }
342
343 #[must_use]
347 pub fn file(mut self, spec: impl Into<String>) -> Self {
348 self.file.push(spec.into());
349 self
350 }
351
352 #[must_use]
356 pub fn include_partial_messages(mut self) -> Self {
357 self.include_partial_messages = true;
358 self
359 }
360
361 #[must_use]
363 pub fn input_format(mut self, format: InputFormat) -> Self {
364 self.input_format = Some(format);
365 self
366 }
367
368 #[must_use]
370 pub fn strict_mcp_config(mut self) -> Self {
371 self.strict_mcp_config = true;
372 self
373 }
374
375 #[must_use]
377 pub fn settings(mut self, settings: impl Into<String>) -> Self {
378 self.settings = Some(settings.into());
379 self
380 }
381
382 #[must_use]
384 pub fn fork_session(mut self) -> Self {
385 self.fork_session = true;
386 self
387 }
388
389 #[must_use]
391 pub fn worktree(mut self) -> Self {
392 self.worktree = true;
393 self
394 }
395
396 #[must_use]
398 pub fn brief(mut self) -> Self {
399 self.brief = true;
400 self
401 }
402
403 #[must_use]
405 pub fn debug_filter(mut self, filter: impl Into<String>) -> Self {
406 self.debug_filter = Some(filter.into());
407 self
408 }
409
410 #[must_use]
412 pub fn debug_file(mut self, path: impl Into<String>) -> Self {
413 self.debug_file = Some(path.into());
414 self
415 }
416
417 #[must_use]
419 pub fn betas(mut self, betas: impl Into<String>) -> Self {
420 self.betas = Some(betas.into());
421 self
422 }
423
424 #[must_use]
426 pub fn plugin_dir(mut self, dir: impl Into<String>) -> Self {
427 self.plugin_dirs.push(dir.into());
428 self
429 }
430
431 #[must_use]
433 pub fn setting_sources(mut self, sources: impl Into<String>) -> Self {
434 self.setting_sources = Some(sources.into());
435 self
436 }
437
438 #[must_use]
440 pub fn tmux(mut self) -> Self {
441 self.tmux = true;
442 self
443 }
444
445 #[must_use]
461 pub fn bare(mut self) -> Self {
462 self.bare = true;
463 self
464 }
465
466 #[must_use]
468 pub fn disable_slash_commands(mut self) -> Self {
469 self.disable_slash_commands = true;
470 self
471 }
472
473 #[must_use]
477 pub fn include_hook_events(mut self) -> Self {
478 self.include_hook_events = true;
479 self
480 }
481
482 #[must_use]
488 pub fn exclude_dynamic_system_prompt_sections(mut self) -> Self {
489 self.exclude_dynamic_system_prompt_sections = true;
490 self
491 }
492
493 #[must_use]
496 pub fn name(mut self, name: impl Into<String>) -> Self {
497 self.name = Some(name.into());
498 self
499 }
500
501 #[must_use]
508 pub fn from_pr(mut self, pr: impl Into<String>) -> Self {
509 self.from_pr = Some(pr.into());
510 self
511 }
512
513 #[must_use]
536 pub fn retry(mut self, policy: crate::retry::RetryPolicy) -> Self {
537 self.retry_policy = Some(policy);
538 self
539 }
540
541 pub fn to_command_string(&self, claude: &Claude) -> String {
564 let args = self.build_args();
565 let quoted_args = args.iter().map(|arg| shell_quote(arg)).collect::<Vec<_>>();
566 format!("{} {}", claude.binary().display(), quoted_args.join(" "))
567 }
568
569 #[cfg(all(feature = "json", feature = "async"))]
574 pub async fn execute_json(&self, claude: &Claude) -> Result<crate::types::QueryResult> {
575 let mut args = self.build_args();
577
578 if self.output_format.is_none() {
580 args.push("--output-format".to_string());
581 args.push("json".to_string());
582 }
583
584 let output = exec::run_claude_with_retry(claude, args, self.retry_policy.as_ref()).await?;
585
586 serde_json::from_str(&output.stdout).map_err(|e| crate::error::Error::Json {
587 message: format!("failed to parse query result: {e}"),
588 source: e,
589 })
590 }
591
592 #[cfg(feature = "sync")]
599 pub fn execute_sync(&self, claude: &Claude) -> Result<CommandOutput> {
600 exec::run_claude_with_retry_sync(claude, self.args(), self.retry_policy.as_ref())
601 }
602
603 #[cfg(all(feature = "sync", feature = "json"))]
605 pub fn execute_json_sync(&self, claude: &Claude) -> Result<crate::types::QueryResult> {
606 let mut args = self.build_args();
607
608 if self.output_format.is_none() {
609 args.push("--output-format".to_string());
610 args.push("json".to_string());
611 }
612
613 let output = exec::run_claude_with_retry_sync(claude, args, self.retry_policy.as_ref())?;
614
615 serde_json::from_str(&output.stdout).map_err(|e| crate::error::Error::Json {
616 message: format!("failed to parse query result: {e}"),
617 source: e,
618 })
619 }
620
621 fn build_args(&self) -> Vec<String> {
622 let mut args = vec!["--print".to_string()];
623
624 if let Some(ref model) = self.model {
625 args.push("--model".to_string());
626 args.push(model.clone());
627 }
628
629 if let Some(ref prompt) = self.system_prompt {
630 args.push("--system-prompt".to_string());
631 args.push(prompt.clone());
632 }
633
634 if let Some(ref prompt) = self.append_system_prompt {
635 args.push("--append-system-prompt".to_string());
636 args.push(prompt.clone());
637 }
638
639 if let Some(ref format) = self.output_format {
640 args.push("--output-format".to_string());
641 args.push(format.as_arg().to_string());
642 if matches!(format, OutputFormat::StreamJson) {
644 args.push("--verbose".to_string());
645 }
646 }
647
648 if let Some(budget) = self.max_budget_usd {
649 args.push("--max-budget-usd".to_string());
650 args.push(budget.to_string());
651 }
652
653 if let Some(ref mode) = self.permission_mode {
654 args.push("--permission-mode".to_string());
655 args.push(mode.as_arg().to_string());
656 }
657
658 if !self.allowed_tools.is_empty() {
659 args.push("--allowed-tools".to_string());
660 args.push(join_patterns(&self.allowed_tools));
661 }
662
663 if !self.disallowed_tools.is_empty() {
664 args.push("--disallowed-tools".to_string());
665 args.push(join_patterns(&self.disallowed_tools));
666 }
667
668 for config in &self.mcp_config {
669 args.push("--mcp-config".to_string());
670 args.push(config.clone());
671 }
672
673 for dir in &self.add_dir {
674 args.push("--add-dir".to_string());
675 args.push(dir.clone());
676 }
677
678 if let Some(ref effort) = self.effort {
679 args.push("--effort".to_string());
680 args.push(effort.as_arg().to_string());
681 }
682
683 if let Some(turns) = self.max_turns {
684 args.push("--max-turns".to_string());
685 args.push(turns.to_string());
686 }
687
688 if let Some(ref schema) = self.json_schema {
689 args.push("--json-schema".to_string());
690 args.push(schema.clone());
691 }
692
693 if self.continue_session {
694 args.push("--continue".to_string());
695 }
696
697 if let Some(ref session_id) = self.resume {
698 args.push("--resume".to_string());
699 args.push(session_id.clone());
700 }
701
702 if let Some(ref id) = self.session_id {
703 args.push("--session-id".to_string());
704 args.push(id.clone());
705 }
706
707 if let Some(ref model) = self.fallback_model {
708 args.push("--fallback-model".to_string());
709 args.push(model.clone());
710 }
711
712 if self.no_session_persistence {
713 args.push("--no-session-persistence".to_string());
714 }
715
716 if self.dangerously_skip_permissions {
717 args.push("--dangerously-skip-permissions".to_string());
718 }
719
720 if let Some(ref agent) = self.agent {
721 args.push("--agent".to_string());
722 args.push(agent.clone());
723 }
724
725 if let Some(ref agents) = self.agents_json {
726 args.push("--agents".to_string());
727 args.push(agents.clone());
728 }
729
730 if !self.tools.is_empty() {
731 args.push("--tools".to_string());
732 args.push(self.tools.join(","));
733 }
734
735 for spec in &self.file {
736 args.push("--file".to_string());
737 args.push(spec.clone());
738 }
739
740 if self.include_partial_messages {
741 args.push("--include-partial-messages".to_string());
742 }
743
744 if let Some(ref format) = self.input_format {
745 args.push("--input-format".to_string());
746 args.push(format.as_arg().to_string());
747 }
748
749 if self.strict_mcp_config {
750 args.push("--strict-mcp-config".to_string());
751 }
752
753 if let Some(ref settings) = self.settings {
754 args.push("--settings".to_string());
755 args.push(settings.clone());
756 }
757
758 if self.fork_session {
759 args.push("--fork-session".to_string());
760 }
761
762 if self.worktree {
763 args.push("--worktree".to_string());
764 }
765
766 if self.brief {
767 args.push("--brief".to_string());
768 }
769
770 if let Some(ref filter) = self.debug_filter {
771 args.push("--debug".to_string());
772 args.push(filter.clone());
773 }
774
775 if let Some(ref path) = self.debug_file {
776 args.push("--debug-file".to_string());
777 args.push(path.clone());
778 }
779
780 if let Some(ref betas) = self.betas {
781 args.push("--betas".to_string());
782 args.push(betas.clone());
783 }
784
785 for dir in &self.plugin_dirs {
786 args.push("--plugin-dir".to_string());
787 args.push(dir.clone());
788 }
789
790 if let Some(ref sources) = self.setting_sources {
791 args.push("--setting-sources".to_string());
792 args.push(sources.clone());
793 }
794
795 if self.tmux {
796 args.push("--tmux".to_string());
797 }
798
799 if self.bare {
800 args.push("--bare".to_string());
801 }
802
803 if self.disable_slash_commands {
804 args.push("--disable-slash-commands".to_string());
805 }
806
807 if self.include_hook_events {
808 args.push("--include-hook-events".to_string());
809 }
810
811 if self.exclude_dynamic_system_prompt_sections {
812 args.push("--exclude-dynamic-system-prompt-sections".to_string());
813 }
814
815 if let Some(ref name) = self.name {
816 args.push("--name".to_string());
817 args.push(name.clone());
818 }
819
820 if let Some(ref pr) = self.from_pr {
821 args.push("--from-pr".to_string());
822 args.push(pr.clone());
823 }
824
825 args.push("--".to_string());
827 args.push(self.prompt.clone());
828
829 args
830 }
831}
832
833impl ClaudeCommand for QueryCommand {
834 type Output = CommandOutput;
835
836 fn args(&self) -> Vec<String> {
837 self.build_args()
838 }
839
840 #[cfg(feature = "async")]
841 async fn execute(&self, claude: &Claude) -> Result<CommandOutput> {
842 exec::run_claude_with_retry(claude, self.args(), self.retry_policy.as_ref()).await
843 }
844}
845
846fn shell_quote(arg: &str) -> String {
848 if arg.contains(|c: char| c.is_whitespace() || "\"'$\\`|;<>&()[]{}".contains(c)) {
850 format!("'{}'", arg.replace("'", "'\\''"))
852 } else {
853 arg.to_string()
854 }
855}
856
857fn join_patterns(patterns: &[ToolPattern]) -> String {
858 let mut out = String::new();
859 for (i, p) in patterns.iter().enumerate() {
860 if i > 0 {
861 out.push(',');
862 }
863 out.push_str(p.as_str());
864 }
865 out
866}
867
868#[cfg(test)]
869mod tests {
870 use super::*;
871
872 #[test]
873 fn test_basic_query_args() {
874 let cmd = QueryCommand::new("hello world");
875 let args = cmd.args();
876 assert_eq!(args, vec!["--print", "--", "hello world"]);
877 }
878
879 #[test]
880 #[allow(deprecated)] fn test_full_query_args() {
882 let cmd = QueryCommand::new("explain this")
883 .model("sonnet")
884 .system_prompt("be concise")
885 .output_format(OutputFormat::Json)
886 .max_budget_usd(0.50)
887 .permission_mode(PermissionMode::BypassPermissions)
888 .allowed_tools(["Bash", "Read"])
889 .mcp_config("/tmp/mcp.json")
890 .effort(Effort::High)
891 .max_turns(3)
892 .no_session_persistence();
893
894 let args = cmd.args();
895 assert!(args.contains(&"--print".to_string()));
896 assert!(args.contains(&"--model".to_string()));
897 assert!(args.contains(&"sonnet".to_string()));
898 assert!(args.contains(&"--system-prompt".to_string()));
899 assert!(args.contains(&"--output-format".to_string()));
900 assert!(args.contains(&"json".to_string()));
901 assert!(!args.contains(&"--verbose".to_string()));
903 assert!(args.contains(&"--max-budget-usd".to_string()));
904 assert!(args.contains(&"--permission-mode".to_string()));
905 assert!(args.contains(&"bypassPermissions".to_string()));
906 assert!(args.contains(&"--allowed-tools".to_string()));
907 assert!(args.contains(&"Bash,Read".to_string()));
908 assert!(args.contains(&"--effort".to_string()));
909 assert!(args.contains(&"high".to_string()));
910 assert!(args.contains(&"--max-turns".to_string()));
911 assert!(args.contains(&"--no-session-persistence".to_string()));
912 assert_eq!(args.last().unwrap(), "explain this");
914 assert_eq!(args[args.len() - 2], "--");
915 }
916
917 #[test]
918 fn typed_patterns_render_in_allowed_tools() {
919 use crate::ToolPattern;
920
921 let cmd = QueryCommand::new("hi")
922 .allowed_tool(ToolPattern::tool("Read"))
923 .allowed_tool(ToolPattern::tool_with_args("Bash", "git log:*"))
924 .allowed_tool(ToolPattern::all("Write"))
925 .allowed_tool(ToolPattern::mcp("srv", "*"));
926
927 let args = cmd.args();
928 let joined = args
929 .iter()
930 .position(|a| a == "--allowed-tools")
931 .map(|i| &args[i + 1])
932 .unwrap();
933 assert_eq!(joined, "Read,Bash(git log:*),Write(*),mcp__srv__*");
934 }
935
936 #[test]
937 fn disallowed_tool_singular_appends() {
938 use crate::ToolPattern;
939
940 let cmd = QueryCommand::new("hi")
941 .disallowed_tool("Write")
942 .disallowed_tool(ToolPattern::tool_with_args("Bash", "rm*"));
943
944 let args = cmd.args();
945 let joined = args
946 .iter()
947 .position(|a| a == "--disallowed-tools")
948 .map(|i| &args[i + 1])
949 .unwrap();
950 assert_eq!(joined, "Write,Bash(rm*)");
951 }
952
953 #[test]
954 fn mixed_string_and_typed_patterns_both_accepted() {
955 use crate::ToolPattern;
956
957 let strs: Vec<ToolPattern> = vec!["Bash".into(), ToolPattern::all("Read")];
961 let cmd = QueryCommand::new("hi").allowed_tools(strs);
962 assert!(cmd.args().contains(&"--allowed-tools".to_string()));
963 }
964
965 #[test]
966 fn new_bool_flags_emit_correct_cli_args() {
967 let args = QueryCommand::new("hi")
968 .bare()
969 .disable_slash_commands()
970 .include_hook_events()
971 .exclude_dynamic_system_prompt_sections()
972 .args();
973 assert!(args.contains(&"--bare".to_string()));
974 assert!(args.contains(&"--disable-slash-commands".to_string()));
975 assert!(args.contains(&"--include-hook-events".to_string()));
976 assert!(args.contains(&"--exclude-dynamic-system-prompt-sections".to_string()));
977 }
978
979 #[test]
980 fn name_flag_renders_with_value() {
981 let args = QueryCommand::new("hi").name("my session").args();
982 let pos = args.iter().position(|a| a == "--name").unwrap();
983 assert_eq!(args[pos + 1], "my session");
984 }
985
986 #[test]
987 fn from_pr_flag_renders_with_value() {
988 let args = QueryCommand::new("hi").from_pr("42").args();
989 let pos = args.iter().position(|a| a == "--from-pr").unwrap();
990 assert_eq!(args[pos + 1], "42");
991 }
992
993 #[test]
994 fn new_bool_flags_default_to_off() {
995 let args = QueryCommand::new("hi").args();
996 assert!(!args.contains(&"--bare".to_string()));
997 assert!(!args.contains(&"--disable-slash-commands".to_string()));
998 assert!(!args.contains(&"--include-hook-events".to_string()));
999 assert!(!args.contains(&"--exclude-dynamic-system-prompt-sections".to_string()));
1000 assert!(!args.contains(&"--name".to_string()));
1001 }
1002
1003 #[test]
1004 fn test_separator_before_prompt_prevents_greedy_flag_parsing() {
1005 let cmd = QueryCommand::new("fix the bug")
1008 .allowed_tools(["Read", "Edit", "Bash(cargo *)"])
1009 .output_format(OutputFormat::StreamJson);
1010 let args = cmd.args();
1011 let sep_pos = args.iter().position(|a| a == "--").unwrap();
1013 let prompt_pos = args.iter().position(|a| a == "fix the bug").unwrap();
1014 assert_eq!(prompt_pos, sep_pos + 1, "prompt must follow -- separator");
1015 let tools_pos = args
1017 .iter()
1018 .position(|a| a.contains("Bash(cargo *)"))
1019 .unwrap();
1020 assert!(
1021 tools_pos < sep_pos,
1022 "allowed-tools must come before -- separator"
1023 );
1024 }
1025
1026 #[test]
1027 fn test_stream_json_includes_verbose() {
1028 let cmd = QueryCommand::new("test").output_format(OutputFormat::StreamJson);
1029 let args = cmd.args();
1030 assert!(args.contains(&"--output-format".to_string()));
1031 assert!(args.contains(&"stream-json".to_string()));
1032 assert!(args.contains(&"--verbose".to_string()));
1033 }
1034
1035 #[test]
1036 fn test_to_command_string_simple() {
1037 let claude = Claude::builder()
1038 .binary("/usr/local/bin/claude")
1039 .build()
1040 .unwrap();
1041
1042 let cmd = QueryCommand::new("hello");
1043 let command_str = cmd.to_command_string(&claude);
1044
1045 assert!(command_str.starts_with("/usr/local/bin/claude"));
1046 assert!(command_str.contains("--print"));
1047 assert!(command_str.contains("hello"));
1048 }
1049
1050 #[test]
1051 fn test_to_command_string_with_spaces() {
1052 let claude = Claude::builder()
1053 .binary("/usr/local/bin/claude")
1054 .build()
1055 .unwrap();
1056
1057 let cmd = QueryCommand::new("hello world").model("sonnet");
1058 let command_str = cmd.to_command_string(&claude);
1059
1060 assert!(command_str.starts_with("/usr/local/bin/claude"));
1061 assert!(command_str.contains("--print"));
1062 assert!(command_str.contains("'hello world'"));
1064 assert!(command_str.contains("--model"));
1065 assert!(command_str.contains("sonnet"));
1066 }
1067
1068 #[test]
1069 fn test_to_command_string_with_special_chars() {
1070 let claude = Claude::builder()
1071 .binary("/usr/local/bin/claude")
1072 .build()
1073 .unwrap();
1074
1075 let cmd = QueryCommand::new("test $VAR and `cmd`");
1076 let command_str = cmd.to_command_string(&claude);
1077
1078 assert!(command_str.contains("'test $VAR and `cmd`'"));
1080 }
1081
1082 #[test]
1083 fn test_to_command_string_with_single_quotes() {
1084 let claude = Claude::builder()
1085 .binary("/usr/local/bin/claude")
1086 .build()
1087 .unwrap();
1088
1089 let cmd = QueryCommand::new("it's");
1090 let command_str = cmd.to_command_string(&claude);
1091
1092 assert!(command_str.contains("'it'\\''s'"));
1094 }
1095
1096 #[test]
1097 fn test_worktree_flag() {
1098 let cmd = QueryCommand::new("test").worktree();
1099 let args = cmd.args();
1100 assert!(args.contains(&"--worktree".to_string()));
1101 }
1102
1103 #[test]
1104 fn test_brief_flag() {
1105 let cmd = QueryCommand::new("test").brief();
1106 let args = cmd.args();
1107 assert!(args.contains(&"--brief".to_string()));
1108 }
1109
1110 #[test]
1111 fn test_debug_filter() {
1112 let cmd = QueryCommand::new("test").debug_filter("api,hooks");
1113 let args = cmd.args();
1114 assert!(args.contains(&"--debug".to_string()));
1115 assert!(args.contains(&"api,hooks".to_string()));
1116 }
1117
1118 #[test]
1119 fn test_debug_file() {
1120 let cmd = QueryCommand::new("test").debug_file("/tmp/debug.log");
1121 let args = cmd.args();
1122 assert!(args.contains(&"--debug-file".to_string()));
1123 assert!(args.contains(&"/tmp/debug.log".to_string()));
1124 }
1125
1126 #[test]
1127 fn test_betas() {
1128 let cmd = QueryCommand::new("test").betas("feature-x");
1129 let args = cmd.args();
1130 assert!(args.contains(&"--betas".to_string()));
1131 assert!(args.contains(&"feature-x".to_string()));
1132 }
1133
1134 #[test]
1135 fn test_plugin_dir_single() {
1136 let cmd = QueryCommand::new("test").plugin_dir("/plugins/foo");
1137 let args = cmd.args();
1138 assert!(args.contains(&"--plugin-dir".to_string()));
1139 assert!(args.contains(&"/plugins/foo".to_string()));
1140 }
1141
1142 #[test]
1143 fn test_plugin_dir_multiple() {
1144 let cmd = QueryCommand::new("test")
1145 .plugin_dir("/plugins/foo")
1146 .plugin_dir("/plugins/bar");
1147 let args = cmd.args();
1148 let plugin_dir_count = args.iter().filter(|a| *a == "--plugin-dir").count();
1149 assert_eq!(plugin_dir_count, 2);
1150 assert!(args.contains(&"/plugins/foo".to_string()));
1151 assert!(args.contains(&"/plugins/bar".to_string()));
1152 }
1153
1154 #[test]
1155 fn test_setting_sources() {
1156 let cmd = QueryCommand::new("test").setting_sources("user,project,local");
1157 let args = cmd.args();
1158 assert!(args.contains(&"--setting-sources".to_string()));
1159 assert!(args.contains(&"user,project,local".to_string()));
1160 }
1161
1162 #[test]
1163 fn test_tmux_flag() {
1164 let cmd = QueryCommand::new("test").tmux();
1165 let args = cmd.args();
1166 assert!(args.contains(&"--tmux".to_string()));
1167 }
1168
1169 #[test]
1172 fn shell_quote_plain_word_is_unchanged() {
1173 assert_eq!(shell_quote("simple"), "simple");
1174 assert_eq!(shell_quote(""), "");
1175 assert_eq!(shell_quote("file.rs"), "file.rs");
1176 }
1177
1178 #[test]
1179 fn shell_quote_whitespace_gets_single_quoted() {
1180 assert_eq!(shell_quote("hello world"), "'hello world'");
1181 assert_eq!(shell_quote("a\tb"), "'a\tb'");
1182 }
1183
1184 #[test]
1185 fn shell_quote_metacharacters_get_quoted() {
1186 assert_eq!(shell_quote("a|b"), "'a|b'");
1187 assert_eq!(shell_quote("$VAR"), "'$VAR'");
1188 assert_eq!(shell_quote("a;b"), "'a;b'");
1189 assert_eq!(shell_quote("(x)"), "'(x)'");
1190 }
1191
1192 #[test]
1193 fn shell_quote_embedded_single_quote_is_escaped() {
1194 assert_eq!(shell_quote("it's"), "'it'\\''s'");
1195 }
1196
1197 #[test]
1198 fn shell_quote_double_quote_gets_single_quoted() {
1199 assert_eq!(shell_quote(r#"say "hi""#), r#"'say "hi"'"#);
1200 }
1201}