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}
78
79impl QueryCommand {
80 #[must_use]
82 pub fn new(prompt: impl Into<String>) -> Self {
83 Self {
84 prompt: prompt.into(),
85 model: None,
86 system_prompt: None,
87 append_system_prompt: None,
88 output_format: None,
89 max_budget_usd: None,
90 permission_mode: None,
91 allowed_tools: Vec::new(),
92 disallowed_tools: Vec::new(),
93 mcp_config: Vec::new(),
94 add_dir: Vec::new(),
95 effort: None,
96 max_turns: None,
97 json_schema: None,
98 continue_session: false,
99 resume: None,
100 session_id: None,
101 fallback_model: None,
102 no_session_persistence: false,
103 dangerously_skip_permissions: false,
104 agent: None,
105 agents_json: None,
106 tools: Vec::new(),
107 file: Vec::new(),
108 include_partial_messages: false,
109 input_format: None,
110 strict_mcp_config: false,
111 settings: None,
112 fork_session: false,
113 retry_policy: None,
114 worktree: false,
115 worktree_name: None,
116 brief: false,
117 debug_filter: None,
118 debug_file: None,
119 betas: None,
120 plugin_dirs: Vec::new(),
121 setting_sources: None,
122 tmux: false,
123 bare: false,
124 disable_slash_commands: false,
125 include_hook_events: false,
126 exclude_dynamic_system_prompt_sections: false,
127 name: None,
128 from_pr: None,
129 }
130 }
131
132 #[must_use]
134 pub fn model(mut self, model: impl Into<String>) -> Self {
135 self.model = Some(model.into());
136 self
137 }
138
139 #[must_use]
141 pub fn system_prompt(mut self, prompt: impl Into<String>) -> Self {
142 self.system_prompt = Some(prompt.into());
143 self
144 }
145
146 #[must_use]
148 pub fn append_system_prompt(mut self, prompt: impl Into<String>) -> Self {
149 self.append_system_prompt = Some(prompt.into());
150 self
151 }
152
153 #[must_use]
155 pub fn output_format(mut self, format: OutputFormat) -> Self {
156 self.output_format = Some(format);
157 self
158 }
159
160 #[must_use]
162 pub fn max_budget_usd(mut self, budget: f64) -> Self {
163 self.max_budget_usd = Some(budget);
164 self
165 }
166
167 #[must_use]
169 pub fn permission_mode(mut self, mode: PermissionMode) -> Self {
170 self.permission_mode = Some(mode);
171 self
172 }
173
174 #[must_use]
190 pub fn allowed_tools<I, T>(mut self, tools: I) -> Self
191 where
192 I: IntoIterator<Item = T>,
193 T: Into<ToolPattern>,
194 {
195 self.allowed_tools.extend(tools.into_iter().map(Into::into));
196 self
197 }
198
199 #[must_use]
201 pub fn allowed_tool(mut self, tool: impl Into<ToolPattern>) -> Self {
202 self.allowed_tools.push(tool.into());
203 self
204 }
205
206 #[must_use]
208 pub fn disallowed_tools<I, T>(mut self, tools: I) -> Self
209 where
210 I: IntoIterator<Item = T>,
211 T: Into<ToolPattern>,
212 {
213 self.disallowed_tools
214 .extend(tools.into_iter().map(Into::into));
215 self
216 }
217
218 #[must_use]
220 pub fn disallowed_tool(mut self, tool: impl Into<ToolPattern>) -> Self {
221 self.disallowed_tools.push(tool.into());
222 self
223 }
224
225 #[must_use]
227 pub fn mcp_config(mut self, path: impl Into<String>) -> Self {
228 self.mcp_config.push(path.into());
229 self
230 }
231
232 #[must_use]
234 pub fn add_dir(mut self, dir: impl Into<String>) -> Self {
235 self.add_dir.push(dir.into());
236 self
237 }
238
239 #[must_use]
241 pub fn effort(mut self, effort: Effort) -> Self {
242 self.effort = Some(effort);
243 self
244 }
245
246 #[must_use]
248 pub fn max_turns(mut self, turns: u32) -> Self {
249 self.max_turns = Some(turns);
250 self
251 }
252
253 #[must_use]
255 pub fn json_schema(mut self, schema: impl Into<String>) -> Self {
256 self.json_schema = Some(schema.into());
257 self
258 }
259
260 #[must_use]
262 pub fn continue_session(mut self) -> Self {
263 self.continue_session = true;
264 self
265 }
266
267 #[must_use]
269 pub fn resume(mut self, session_id: impl Into<String>) -> Self {
270 self.resume = Some(session_id.into());
271 self
272 }
273
274 #[must_use]
276 pub fn session_id(mut self, id: impl Into<String>) -> Self {
277 self.session_id = Some(id.into());
278 self
279 }
280
281 #[cfg(all(feature = "json", feature = "async"))]
289 pub(crate) fn replace_session(mut self, id: impl Into<String>) -> Self {
290 self.continue_session = false;
291 self.resume = Some(id.into());
292 self.session_id = None;
293 self.fork_session = false;
294 self
295 }
296
297 #[must_use]
299 pub fn fallback_model(mut self, model: impl Into<String>) -> Self {
300 self.fallback_model = Some(model.into());
301 self
302 }
303
304 #[must_use]
306 pub fn no_session_persistence(mut self) -> Self {
307 self.no_session_persistence = true;
308 self
309 }
310
311 #[must_use]
313 pub fn dangerously_skip_permissions(mut self) -> Self {
314 self.dangerously_skip_permissions = true;
315 self
316 }
317
318 #[must_use]
332 pub fn agent(mut self, agent: impl Into<String>) -> Self {
333 self.agent = Some(agent.into());
334 self
335 }
336
337 #[must_use]
349 pub fn agents_json(mut self, json: impl Into<String>) -> Self {
350 self.agents_json = Some(json.into());
351 self
352 }
353
354 #[must_use]
360 pub fn tools(mut self, tools: impl IntoIterator<Item = impl Into<String>>) -> Self {
361 self.tools.extend(tools.into_iter().map(Into::into));
362 self
363 }
364
365 #[must_use]
369 pub fn file(mut self, spec: impl Into<String>) -> Self {
370 self.file.push(spec.into());
371 self
372 }
373
374 #[must_use]
378 pub fn include_partial_messages(mut self) -> Self {
379 self.include_partial_messages = true;
380 self
381 }
382
383 #[must_use]
385 pub fn input_format(mut self, format: InputFormat) -> Self {
386 self.input_format = Some(format);
387 self
388 }
389
390 #[must_use]
392 pub fn strict_mcp_config(mut self) -> Self {
393 self.strict_mcp_config = true;
394 self
395 }
396
397 #[must_use]
399 pub fn settings(mut self, settings: impl Into<String>) -> Self {
400 self.settings = Some(settings.into());
401 self
402 }
403
404 #[must_use]
406 pub fn fork_session(mut self) -> Self {
407 self.fork_session = true;
408 self
409 }
410
411 #[must_use]
413 pub fn worktree(mut self) -> Self {
414 self.worktree = true;
415 self
416 }
417
418 #[must_use]
441 pub fn worktree_named(mut self, name: impl Into<String>) -> Self {
442 self.worktree = true;
443 self.worktree_name = Some(name.into());
444 self
445 }
446
447 #[must_use]
449 pub fn brief(mut self) -> Self {
450 self.brief = true;
451 self
452 }
453
454 #[must_use]
456 pub fn debug_filter(mut self, filter: impl Into<String>) -> Self {
457 self.debug_filter = Some(filter.into());
458 self
459 }
460
461 #[must_use]
463 pub fn debug_file(mut self, path: impl Into<String>) -> Self {
464 self.debug_file = Some(path.into());
465 self
466 }
467
468 #[must_use]
470 pub fn betas(mut self, betas: impl Into<String>) -> Self {
471 self.betas = Some(betas.into());
472 self
473 }
474
475 #[must_use]
477 pub fn plugin_dir(mut self, dir: impl Into<String>) -> Self {
478 self.plugin_dirs.push(dir.into());
479 self
480 }
481
482 #[must_use]
484 pub fn setting_sources(mut self, sources: impl Into<String>) -> Self {
485 self.setting_sources = Some(sources.into());
486 self
487 }
488
489 #[must_use]
491 pub fn tmux(mut self) -> Self {
492 self.tmux = true;
493 self
494 }
495
496 #[must_use]
512 pub fn bare(mut self) -> Self {
513 self.bare = true;
514 self
515 }
516
517 #[must_use]
519 pub fn disable_slash_commands(mut self) -> Self {
520 self.disable_slash_commands = true;
521 self
522 }
523
524 #[must_use]
528 pub fn include_hook_events(mut self) -> Self {
529 self.include_hook_events = true;
530 self
531 }
532
533 #[must_use]
539 pub fn exclude_dynamic_system_prompt_sections(mut self) -> Self {
540 self.exclude_dynamic_system_prompt_sections = true;
541 self
542 }
543
544 #[must_use]
547 pub fn name(mut self, name: impl Into<String>) -> Self {
548 self.name = Some(name.into());
549 self
550 }
551
552 #[must_use]
559 pub fn from_pr(mut self, pr: impl Into<String>) -> Self {
560 self.from_pr = Some(pr.into());
561 self
562 }
563
564 #[must_use]
587 pub fn retry(mut self, policy: crate::retry::RetryPolicy) -> Self {
588 self.retry_policy = Some(policy);
589 self
590 }
591
592 pub fn to_command_string(&self, claude: &Claude) -> String {
615 let args = self.build_args();
616 let quoted_args = args.iter().map(|arg| shell_quote(arg)).collect::<Vec<_>>();
617 format!("{} {}", claude.binary().display(), quoted_args.join(" "))
618 }
619
620 #[cfg(all(feature = "json", feature = "async"))]
625 pub async fn execute_json(&self, claude: &Claude) -> Result<crate::types::QueryResult> {
626 let args = self.build_args_with_forced_json();
627
628 let output = exec::run_claude_with_retry(claude, args, self.retry_policy.as_ref()).await?;
629
630 serde_json::from_str(&output.stdout).map_err(|e| crate::error::Error::Json {
631 message: format!("failed to parse query result: {e}"),
632 source: e,
633 })
634 }
635
636 #[cfg(feature = "sync")]
643 pub fn execute_sync(&self, claude: &Claude) -> Result<CommandOutput> {
644 exec::run_claude_with_retry_sync(claude, self.args(), self.retry_policy.as_ref())
645 }
646
647 #[cfg(all(feature = "sync", feature = "json"))]
649 pub fn execute_json_sync(&self, claude: &Claude) -> Result<crate::types::QueryResult> {
650 let args = self.build_args_with_forced_json();
651
652 let output = exec::run_claude_with_retry_sync(claude, args, self.retry_policy.as_ref())?;
653
654 serde_json::from_str(&output.stdout).map_err(|e| crate::error::Error::Json {
655 message: format!("failed to parse query result: {e}"),
656 source: e,
657 })
658 }
659
660 fn build_args_with_forced_json(&self) -> Vec<String> {
668 if self.output_format.is_some() {
669 return self.build_args();
670 }
671 let mut effective = self.clone();
672 effective.output_format = Some(OutputFormat::Json);
673 effective.build_args()
674 }
675
676 fn build_args(&self) -> Vec<String> {
677 let mut args = vec!["--print".to_string()];
678
679 if let Some(ref model) = self.model {
680 args.push("--model".to_string());
681 args.push(model.clone());
682 }
683
684 if let Some(ref prompt) = self.system_prompt {
685 args.push("--system-prompt".to_string());
686 args.push(prompt.clone());
687 }
688
689 if let Some(ref prompt) = self.append_system_prompt {
690 args.push("--append-system-prompt".to_string());
691 args.push(prompt.clone());
692 }
693
694 if let Some(ref format) = self.output_format {
695 args.push("--output-format".to_string());
696 args.push(format.as_arg().to_string());
697 if matches!(format, OutputFormat::StreamJson) {
699 args.push("--verbose".to_string());
700 }
701 }
702
703 if let Some(budget) = self.max_budget_usd {
704 args.push("--max-budget-usd".to_string());
705 args.push(budget.to_string());
706 }
707
708 if let Some(ref mode) = self.permission_mode {
709 args.push("--permission-mode".to_string());
710 args.push(mode.as_arg().to_string());
711 }
712
713 if !self.allowed_tools.is_empty() {
714 args.push("--allowed-tools".to_string());
715 args.push(join_patterns(&self.allowed_tools));
716 }
717
718 if !self.disallowed_tools.is_empty() {
719 args.push("--disallowed-tools".to_string());
720 args.push(join_patterns(&self.disallowed_tools));
721 }
722
723 for config in &self.mcp_config {
724 args.push("--mcp-config".to_string());
725 args.push(config.clone());
726 }
727
728 for dir in &self.add_dir {
729 args.push("--add-dir".to_string());
730 args.push(dir.clone());
731 }
732
733 if let Some(ref effort) = self.effort {
734 args.push("--effort".to_string());
735 args.push(effort.as_arg().to_string());
736 }
737
738 if let Some(turns) = self.max_turns {
739 args.push("--max-turns".to_string());
740 args.push(turns.to_string());
741 }
742
743 if let Some(ref schema) = self.json_schema {
744 args.push("--json-schema".to_string());
745 args.push(schema.clone());
746 }
747
748 if self.continue_session {
749 args.push("--continue".to_string());
750 }
751
752 if let Some(ref session_id) = self.resume {
753 args.push("--resume".to_string());
754 args.push(session_id.clone());
755 }
756
757 if let Some(ref id) = self.session_id {
758 args.push("--session-id".to_string());
759 args.push(id.clone());
760 }
761
762 if let Some(ref model) = self.fallback_model {
763 args.push("--fallback-model".to_string());
764 args.push(model.clone());
765 }
766
767 if self.no_session_persistence {
768 args.push("--no-session-persistence".to_string());
769 }
770
771 if self.dangerously_skip_permissions {
772 args.push("--dangerously-skip-permissions".to_string());
773 }
774
775 if let Some(ref agent) = self.agent {
776 args.push("--agent".to_string());
777 args.push(agent.clone());
778 }
779
780 if let Some(ref agents) = self.agents_json {
781 args.push("--agents".to_string());
782 args.push(agents.clone());
783 }
784
785 if !self.tools.is_empty() {
786 args.push("--tools".to_string());
787 args.push(self.tools.join(","));
788 }
789
790 for spec in &self.file {
791 args.push("--file".to_string());
792 args.push(spec.clone());
793 }
794
795 if self.include_partial_messages {
796 args.push("--include-partial-messages".to_string());
797 }
798
799 if let Some(ref format) = self.input_format {
800 args.push("--input-format".to_string());
801 args.push(format.as_arg().to_string());
802 }
803
804 if self.strict_mcp_config {
805 args.push("--strict-mcp-config".to_string());
806 }
807
808 if let Some(ref settings) = self.settings {
809 args.push("--settings".to_string());
810 args.push(settings.clone());
811 }
812
813 if self.fork_session {
814 args.push("--fork-session".to_string());
815 }
816
817 if self.worktree {
818 args.push("--worktree".to_string());
819 if let Some(ref name) = self.worktree_name {
820 args.push(name.clone());
821 }
822 }
823
824 if self.brief {
825 args.push("--brief".to_string());
826 }
827
828 if let Some(ref filter) = self.debug_filter {
829 args.push("--debug".to_string());
830 args.push(filter.clone());
831 }
832
833 if let Some(ref path) = self.debug_file {
834 args.push("--debug-file".to_string());
835 args.push(path.clone());
836 }
837
838 if let Some(ref betas) = self.betas {
839 args.push("--betas".to_string());
840 args.push(betas.clone());
841 }
842
843 for dir in &self.plugin_dirs {
844 args.push("--plugin-dir".to_string());
845 args.push(dir.clone());
846 }
847
848 if let Some(ref sources) = self.setting_sources {
849 args.push("--setting-sources".to_string());
850 args.push(sources.clone());
851 }
852
853 if self.tmux {
854 args.push("--tmux".to_string());
855 }
856
857 if self.bare {
858 args.push("--bare".to_string());
859 }
860
861 if self.disable_slash_commands {
862 args.push("--disable-slash-commands".to_string());
863 }
864
865 if self.include_hook_events {
866 args.push("--include-hook-events".to_string());
867 }
868
869 if self.exclude_dynamic_system_prompt_sections {
870 args.push("--exclude-dynamic-system-prompt-sections".to_string());
871 }
872
873 if let Some(ref name) = self.name {
874 args.push("--name".to_string());
875 args.push(name.clone());
876 }
877
878 if let Some(ref pr) = self.from_pr {
879 args.push("--from-pr".to_string());
880 args.push(pr.clone());
881 }
882
883 args.push("--".to_string());
885 args.push(self.prompt.clone());
886
887 args
888 }
889}
890
891impl ClaudeCommand for QueryCommand {
892 type Output = CommandOutput;
893
894 fn args(&self) -> Vec<String> {
895 self.build_args()
896 }
897
898 #[cfg(feature = "async")]
899 async fn execute(&self, claude: &Claude) -> Result<CommandOutput> {
900 exec::run_claude_with_retry(claude, self.args(), self.retry_policy.as_ref()).await
901 }
902}
903
904fn shell_quote(arg: &str) -> String {
906 if arg.contains(|c: char| c.is_whitespace() || "\"'$\\`|;<>&()[]{}".contains(c)) {
908 format!("'{}'", arg.replace("'", "'\\''"))
910 } else {
911 arg.to_string()
912 }
913}
914
915fn join_patterns(patterns: &[ToolPattern]) -> String {
916 let mut out = String::new();
917 for (i, p) in patterns.iter().enumerate() {
918 if i > 0 {
919 out.push(',');
920 }
921 out.push_str(p.as_str());
922 }
923 out
924}
925
926#[cfg(test)]
927mod tests {
928 use super::*;
929
930 #[test]
931 fn test_basic_query_args() {
932 let cmd = QueryCommand::new("hello world");
933 let args = cmd.args();
934 assert_eq!(args, vec!["--print", "--", "hello world"]);
935 }
936
937 #[test]
938 fn build_args_with_forced_json_inserts_flag_before_separator() {
939 let cmd = QueryCommand::new("hello");
945 let args = cmd.build_args_with_forced_json();
946
947 assert_eq!(
949 &args[args.len() - 2..],
950 &["--".to_string(), "hello".to_string()],
951 );
952
953 let sep = args.iter().position(|a| a == "--").expect("`--` present");
955 let fmt = args
956 .iter()
957 .position(|a| a == "--output-format")
958 .expect("--output-format present");
959 assert!(
960 fmt < sep,
961 "--output-format must come before `--` separator; got {args:?}"
962 );
963 assert_eq!(args[fmt + 1], "json");
964 }
965
966 #[test]
967 fn build_args_with_forced_json_respects_explicit_format() {
968 let cmd = QueryCommand::new("hello").output_format(OutputFormat::Text);
971 let args = cmd.build_args_with_forced_json();
972 let fmt = args
973 .iter()
974 .position(|a| a == "--output-format")
975 .expect("--output-format present");
976 assert_eq!(args[fmt + 1], "text");
977 assert_eq!(args.iter().filter(|a| *a == "--output-format").count(), 1);
979 }
980
981 #[test]
982 #[allow(deprecated)] fn test_full_query_args() {
984 let cmd = QueryCommand::new("explain this")
985 .model("sonnet")
986 .system_prompt("be concise")
987 .output_format(OutputFormat::Json)
988 .max_budget_usd(0.50)
989 .permission_mode(PermissionMode::BypassPermissions)
990 .allowed_tools(["Bash", "Read"])
991 .mcp_config("/tmp/mcp.json")
992 .effort(Effort::High)
993 .max_turns(3)
994 .no_session_persistence();
995
996 let args = cmd.args();
997 assert!(args.contains(&"--print".to_string()));
998 assert!(args.contains(&"--model".to_string()));
999 assert!(args.contains(&"sonnet".to_string()));
1000 assert!(args.contains(&"--system-prompt".to_string()));
1001 assert!(args.contains(&"--output-format".to_string()));
1002 assert!(args.contains(&"json".to_string()));
1003 assert!(!args.contains(&"--verbose".to_string()));
1005 assert!(args.contains(&"--max-budget-usd".to_string()));
1006 assert!(args.contains(&"--permission-mode".to_string()));
1007 assert!(args.contains(&"bypassPermissions".to_string()));
1008 assert!(args.contains(&"--allowed-tools".to_string()));
1009 assert!(args.contains(&"Bash,Read".to_string()));
1010 assert!(args.contains(&"--effort".to_string()));
1011 assert!(args.contains(&"high".to_string()));
1012 assert!(args.contains(&"--max-turns".to_string()));
1013 assert!(args.contains(&"--no-session-persistence".to_string()));
1014 assert_eq!(args.last().unwrap(), "explain this");
1016 assert_eq!(args[args.len() - 2], "--");
1017 }
1018
1019 #[test]
1020 fn typed_patterns_render_in_allowed_tools() {
1021 use crate::ToolPattern;
1022
1023 let cmd = QueryCommand::new("hi")
1024 .allowed_tool(ToolPattern::tool("Read"))
1025 .allowed_tool(ToolPattern::tool_with_args("Bash", "git log:*"))
1026 .allowed_tool(ToolPattern::all("Write"))
1027 .allowed_tool(ToolPattern::mcp("srv", "*"));
1028
1029 let args = cmd.args();
1030 let joined = args
1031 .iter()
1032 .position(|a| a == "--allowed-tools")
1033 .map(|i| &args[i + 1])
1034 .unwrap();
1035 assert_eq!(joined, "Read,Bash(git log:*),Write(*),mcp__srv__*");
1036 }
1037
1038 #[test]
1039 fn disallowed_tool_singular_appends() {
1040 use crate::ToolPattern;
1041
1042 let cmd = QueryCommand::new("hi")
1043 .disallowed_tool("Write")
1044 .disallowed_tool(ToolPattern::tool_with_args("Bash", "rm*"));
1045
1046 let args = cmd.args();
1047 let joined = args
1048 .iter()
1049 .position(|a| a == "--disallowed-tools")
1050 .map(|i| &args[i + 1])
1051 .unwrap();
1052 assert_eq!(joined, "Write,Bash(rm*)");
1053 }
1054
1055 #[test]
1056 fn mixed_string_and_typed_patterns_both_accepted() {
1057 use crate::ToolPattern;
1058
1059 let strs: Vec<ToolPattern> = vec!["Bash".into(), ToolPattern::all("Read")];
1063 let cmd = QueryCommand::new("hi").allowed_tools(strs);
1064 assert!(cmd.args().contains(&"--allowed-tools".to_string()));
1065 }
1066
1067 #[test]
1068 fn new_bool_flags_emit_correct_cli_args() {
1069 let args = QueryCommand::new("hi")
1070 .bare()
1071 .disable_slash_commands()
1072 .include_hook_events()
1073 .exclude_dynamic_system_prompt_sections()
1074 .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 }
1080
1081 #[test]
1082 fn name_flag_renders_with_value() {
1083 let args = QueryCommand::new("hi").name("my session").args();
1084 let pos = args.iter().position(|a| a == "--name").unwrap();
1085 assert_eq!(args[pos + 1], "my session");
1086 }
1087
1088 #[test]
1089 fn from_pr_flag_renders_with_value() {
1090 let args = QueryCommand::new("hi").from_pr("42").args();
1091 let pos = args.iter().position(|a| a == "--from-pr").unwrap();
1092 assert_eq!(args[pos + 1], "42");
1093 }
1094
1095 #[test]
1096 fn new_bool_flags_default_to_off() {
1097 let args = QueryCommand::new("hi").args();
1098 assert!(!args.contains(&"--bare".to_string()));
1099 assert!(!args.contains(&"--disable-slash-commands".to_string()));
1100 assert!(!args.contains(&"--include-hook-events".to_string()));
1101 assert!(!args.contains(&"--exclude-dynamic-system-prompt-sections".to_string()));
1102 assert!(!args.contains(&"--name".to_string()));
1103 }
1104
1105 #[test]
1106 fn test_separator_before_prompt_prevents_greedy_flag_parsing() {
1107 let cmd = QueryCommand::new("fix the bug")
1110 .allowed_tools(["Read", "Edit", "Bash(cargo *)"])
1111 .output_format(OutputFormat::StreamJson);
1112 let args = cmd.args();
1113 let sep_pos = args.iter().position(|a| a == "--").unwrap();
1115 let prompt_pos = args.iter().position(|a| a == "fix the bug").unwrap();
1116 assert_eq!(prompt_pos, sep_pos + 1, "prompt must follow -- separator");
1117 let tools_pos = args
1119 .iter()
1120 .position(|a| a.contains("Bash(cargo *)"))
1121 .unwrap();
1122 assert!(
1123 tools_pos < sep_pos,
1124 "allowed-tools must come before -- separator"
1125 );
1126 }
1127
1128 #[test]
1129 fn test_stream_json_includes_verbose() {
1130 let cmd = QueryCommand::new("test").output_format(OutputFormat::StreamJson);
1131 let args = cmd.args();
1132 assert!(args.contains(&"--output-format".to_string()));
1133 assert!(args.contains(&"stream-json".to_string()));
1134 assert!(args.contains(&"--verbose".to_string()));
1135 }
1136
1137 #[test]
1138 fn test_to_command_string_simple() {
1139 let claude = Claude::builder()
1140 .binary("/usr/local/bin/claude")
1141 .build()
1142 .unwrap();
1143
1144 let cmd = QueryCommand::new("hello");
1145 let command_str = cmd.to_command_string(&claude);
1146
1147 assert!(command_str.starts_with("/usr/local/bin/claude"));
1148 assert!(command_str.contains("--print"));
1149 assert!(command_str.contains("hello"));
1150 }
1151
1152 #[test]
1153 fn test_to_command_string_with_spaces() {
1154 let claude = Claude::builder()
1155 .binary("/usr/local/bin/claude")
1156 .build()
1157 .unwrap();
1158
1159 let cmd = QueryCommand::new("hello world").model("sonnet");
1160 let command_str = cmd.to_command_string(&claude);
1161
1162 assert!(command_str.starts_with("/usr/local/bin/claude"));
1163 assert!(command_str.contains("--print"));
1164 assert!(command_str.contains("'hello world'"));
1166 assert!(command_str.contains("--model"));
1167 assert!(command_str.contains("sonnet"));
1168 }
1169
1170 #[test]
1171 fn test_to_command_string_with_special_chars() {
1172 let claude = Claude::builder()
1173 .binary("/usr/local/bin/claude")
1174 .build()
1175 .unwrap();
1176
1177 let cmd = QueryCommand::new("test $VAR and `cmd`");
1178 let command_str = cmd.to_command_string(&claude);
1179
1180 assert!(command_str.contains("'test $VAR and `cmd`'"));
1182 }
1183
1184 #[test]
1185 fn test_to_command_string_with_single_quotes() {
1186 let claude = Claude::builder()
1187 .binary("/usr/local/bin/claude")
1188 .build()
1189 .unwrap();
1190
1191 let cmd = QueryCommand::new("it's");
1192 let command_str = cmd.to_command_string(&claude);
1193
1194 assert!(command_str.contains("'it'\\''s'"));
1196 }
1197
1198 #[test]
1199 fn test_worktree_flag() {
1200 let cmd = QueryCommand::new("test").worktree();
1201 let args = cmd.args();
1202 assert!(args.contains(&"--worktree".to_string()));
1203 }
1204
1205 #[test]
1206 fn test_worktree_named() {
1207 let cmd = QueryCommand::new("test").worktree_named("feature-x");
1208 let args = cmd.args();
1209 assert!(
1210 args.windows(2).any(|w| w == ["--worktree", "feature-x"]),
1211 "missing --worktree feature-x in {args:?}"
1212 );
1213 }
1214
1215 #[test]
1216 fn test_brief_flag() {
1217 let cmd = QueryCommand::new("test").brief();
1218 let args = cmd.args();
1219 assert!(args.contains(&"--brief".to_string()));
1220 }
1221
1222 #[test]
1223 fn test_debug_filter() {
1224 let cmd = QueryCommand::new("test").debug_filter("api,hooks");
1225 let args = cmd.args();
1226 assert!(args.contains(&"--debug".to_string()));
1227 assert!(args.contains(&"api,hooks".to_string()));
1228 }
1229
1230 #[test]
1231 fn test_debug_file() {
1232 let cmd = QueryCommand::new("test").debug_file("/tmp/debug.log");
1233 let args = cmd.args();
1234 assert!(args.contains(&"--debug-file".to_string()));
1235 assert!(args.contains(&"/tmp/debug.log".to_string()));
1236 }
1237
1238 #[test]
1239 fn test_betas() {
1240 let cmd = QueryCommand::new("test").betas("feature-x");
1241 let args = cmd.args();
1242 assert!(args.contains(&"--betas".to_string()));
1243 assert!(args.contains(&"feature-x".to_string()));
1244 }
1245
1246 #[test]
1247 fn test_plugin_dir_single() {
1248 let cmd = QueryCommand::new("test").plugin_dir("/plugins/foo");
1249 let args = cmd.args();
1250 assert!(args.contains(&"--plugin-dir".to_string()));
1251 assert!(args.contains(&"/plugins/foo".to_string()));
1252 }
1253
1254 #[test]
1255 fn test_plugin_dir_multiple() {
1256 let cmd = QueryCommand::new("test")
1257 .plugin_dir("/plugins/foo")
1258 .plugin_dir("/plugins/bar");
1259 let args = cmd.args();
1260 let plugin_dir_count = args.iter().filter(|a| *a == "--plugin-dir").count();
1261 assert_eq!(plugin_dir_count, 2);
1262 assert!(args.contains(&"/plugins/foo".to_string()));
1263 assert!(args.contains(&"/plugins/bar".to_string()));
1264 }
1265
1266 #[test]
1267 fn test_setting_sources() {
1268 let cmd = QueryCommand::new("test").setting_sources("user,project,local");
1269 let args = cmd.args();
1270 assert!(args.contains(&"--setting-sources".to_string()));
1271 assert!(args.contains(&"user,project,local".to_string()));
1272 }
1273
1274 #[test]
1275 fn test_tmux_flag() {
1276 let cmd = QueryCommand::new("test").tmux();
1277 let args = cmd.args();
1278 assert!(args.contains(&"--tmux".to_string()));
1279 }
1280
1281 #[test]
1284 fn shell_quote_plain_word_is_unchanged() {
1285 assert_eq!(shell_quote("simple"), "simple");
1286 assert_eq!(shell_quote(""), "");
1287 assert_eq!(shell_quote("file.rs"), "file.rs");
1288 }
1289
1290 #[test]
1291 fn shell_quote_whitespace_gets_single_quoted() {
1292 assert_eq!(shell_quote("hello world"), "'hello world'");
1293 assert_eq!(shell_quote("a\tb"), "'a\tb'");
1294 }
1295
1296 #[test]
1297 fn shell_quote_metacharacters_get_quoted() {
1298 assert_eq!(shell_quote("a|b"), "'a|b'");
1299 assert_eq!(shell_quote("$VAR"), "'$VAR'");
1300 assert_eq!(shell_quote("a;b"), "'a;b'");
1301 assert_eq!(shell_quote("(x)"), "'(x)'");
1302 }
1303
1304 #[test]
1305 fn shell_quote_embedded_single_quote_is_escaped() {
1306 assert_eq!(shell_quote("it's"), "'it'\\''s'");
1307 }
1308
1309 #[test]
1310 fn shell_quote_double_quote_gets_single_quoted() {
1311 assert_eq!(shell_quote(r#"say "hi""#), r#"'say "hi"'"#);
1312 }
1313}