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]
330 pub fn agent(mut self, agent: impl Into<String>) -> Self {
331 self.agent = Some(agent.into());
332 self
333 }
334
335 #[must_use]
347 pub fn agents_json(mut self, json: impl Into<String>) -> Self {
348 self.agents_json = Some(json.into());
349 self
350 }
351
352 #[must_use]
358 pub fn tools(mut self, tools: impl IntoIterator<Item = impl Into<String>>) -> Self {
359 self.tools.extend(tools.into_iter().map(Into::into));
360 self
361 }
362
363 #[must_use]
367 pub fn file(mut self, spec: impl Into<String>) -> Self {
368 self.file.push(spec.into());
369 self
370 }
371
372 #[must_use]
376 pub fn include_partial_messages(mut self) -> Self {
377 self.include_partial_messages = true;
378 self
379 }
380
381 #[must_use]
383 pub fn input_format(mut self, format: InputFormat) -> Self {
384 self.input_format = Some(format);
385 self
386 }
387
388 #[must_use]
390 pub fn strict_mcp_config(mut self) -> Self {
391 self.strict_mcp_config = true;
392 self
393 }
394
395 #[must_use]
397 pub fn settings(mut self, settings: impl Into<String>) -> Self {
398 self.settings = Some(settings.into());
399 self
400 }
401
402 #[must_use]
404 pub fn fork_session(mut self) -> Self {
405 self.fork_session = true;
406 self
407 }
408
409 #[must_use]
411 pub fn worktree(mut self) -> Self {
412 self.worktree = true;
413 self
414 }
415
416 #[must_use]
418 pub fn brief(mut self) -> Self {
419 self.brief = true;
420 self
421 }
422
423 #[must_use]
425 pub fn debug_filter(mut self, filter: impl Into<String>) -> Self {
426 self.debug_filter = Some(filter.into());
427 self
428 }
429
430 #[must_use]
432 pub fn debug_file(mut self, path: impl Into<String>) -> Self {
433 self.debug_file = Some(path.into());
434 self
435 }
436
437 #[must_use]
439 pub fn betas(mut self, betas: impl Into<String>) -> Self {
440 self.betas = Some(betas.into());
441 self
442 }
443
444 #[must_use]
446 pub fn plugin_dir(mut self, dir: impl Into<String>) -> Self {
447 self.plugin_dirs.push(dir.into());
448 self
449 }
450
451 #[must_use]
453 pub fn setting_sources(mut self, sources: impl Into<String>) -> Self {
454 self.setting_sources = Some(sources.into());
455 self
456 }
457
458 #[must_use]
460 pub fn tmux(mut self) -> Self {
461 self.tmux = true;
462 self
463 }
464
465 #[must_use]
481 pub fn bare(mut self) -> Self {
482 self.bare = true;
483 self
484 }
485
486 #[must_use]
488 pub fn disable_slash_commands(mut self) -> Self {
489 self.disable_slash_commands = true;
490 self
491 }
492
493 #[must_use]
497 pub fn include_hook_events(mut self) -> Self {
498 self.include_hook_events = true;
499 self
500 }
501
502 #[must_use]
508 pub fn exclude_dynamic_system_prompt_sections(mut self) -> Self {
509 self.exclude_dynamic_system_prompt_sections = true;
510 self
511 }
512
513 #[must_use]
516 pub fn name(mut self, name: impl Into<String>) -> Self {
517 self.name = Some(name.into());
518 self
519 }
520
521 #[must_use]
528 pub fn from_pr(mut self, pr: impl Into<String>) -> Self {
529 self.from_pr = Some(pr.into());
530 self
531 }
532
533 #[must_use]
556 pub fn retry(mut self, policy: crate::retry::RetryPolicy) -> Self {
557 self.retry_policy = Some(policy);
558 self
559 }
560
561 pub fn to_command_string(&self, claude: &Claude) -> String {
584 let args = self.build_args();
585 let quoted_args = args.iter().map(|arg| shell_quote(arg)).collect::<Vec<_>>();
586 format!("{} {}", claude.binary().display(), quoted_args.join(" "))
587 }
588
589 #[cfg(all(feature = "json", feature = "async"))]
594 pub async fn execute_json(&self, claude: &Claude) -> Result<crate::types::QueryResult> {
595 let args = self.build_args_with_forced_json();
596
597 let output = exec::run_claude_with_retry(claude, args, self.retry_policy.as_ref()).await?;
598
599 serde_json::from_str(&output.stdout).map_err(|e| crate::error::Error::Json {
600 message: format!("failed to parse query result: {e}"),
601 source: e,
602 })
603 }
604
605 #[cfg(feature = "sync")]
612 pub fn execute_sync(&self, claude: &Claude) -> Result<CommandOutput> {
613 exec::run_claude_with_retry_sync(claude, self.args(), self.retry_policy.as_ref())
614 }
615
616 #[cfg(all(feature = "sync", feature = "json"))]
618 pub fn execute_json_sync(&self, claude: &Claude) -> Result<crate::types::QueryResult> {
619 let args = self.build_args_with_forced_json();
620
621 let output = exec::run_claude_with_retry_sync(claude, args, self.retry_policy.as_ref())?;
622
623 serde_json::from_str(&output.stdout).map_err(|e| crate::error::Error::Json {
624 message: format!("failed to parse query result: {e}"),
625 source: e,
626 })
627 }
628
629 fn build_args_with_forced_json(&self) -> Vec<String> {
637 if self.output_format.is_some() {
638 return self.build_args();
639 }
640 let mut effective = self.clone();
641 effective.output_format = Some(OutputFormat::Json);
642 effective.build_args()
643 }
644
645 fn build_args(&self) -> Vec<String> {
646 let mut args = vec!["--print".to_string()];
647
648 if let Some(ref model) = self.model {
649 args.push("--model".to_string());
650 args.push(model.clone());
651 }
652
653 if let Some(ref prompt) = self.system_prompt {
654 args.push("--system-prompt".to_string());
655 args.push(prompt.clone());
656 }
657
658 if let Some(ref prompt) = self.append_system_prompt {
659 args.push("--append-system-prompt".to_string());
660 args.push(prompt.clone());
661 }
662
663 if let Some(ref format) = self.output_format {
664 args.push("--output-format".to_string());
665 args.push(format.as_arg().to_string());
666 if matches!(format, OutputFormat::StreamJson) {
668 args.push("--verbose".to_string());
669 }
670 }
671
672 if let Some(budget) = self.max_budget_usd {
673 args.push("--max-budget-usd".to_string());
674 args.push(budget.to_string());
675 }
676
677 if let Some(ref mode) = self.permission_mode {
678 args.push("--permission-mode".to_string());
679 args.push(mode.as_arg().to_string());
680 }
681
682 if !self.allowed_tools.is_empty() {
683 args.push("--allowed-tools".to_string());
684 args.push(join_patterns(&self.allowed_tools));
685 }
686
687 if !self.disallowed_tools.is_empty() {
688 args.push("--disallowed-tools".to_string());
689 args.push(join_patterns(&self.disallowed_tools));
690 }
691
692 for config in &self.mcp_config {
693 args.push("--mcp-config".to_string());
694 args.push(config.clone());
695 }
696
697 for dir in &self.add_dir {
698 args.push("--add-dir".to_string());
699 args.push(dir.clone());
700 }
701
702 if let Some(ref effort) = self.effort {
703 args.push("--effort".to_string());
704 args.push(effort.as_arg().to_string());
705 }
706
707 if let Some(turns) = self.max_turns {
708 args.push("--max-turns".to_string());
709 args.push(turns.to_string());
710 }
711
712 if let Some(ref schema) = self.json_schema {
713 args.push("--json-schema".to_string());
714 args.push(schema.clone());
715 }
716
717 if self.continue_session {
718 args.push("--continue".to_string());
719 }
720
721 if let Some(ref session_id) = self.resume {
722 args.push("--resume".to_string());
723 args.push(session_id.clone());
724 }
725
726 if let Some(ref id) = self.session_id {
727 args.push("--session-id".to_string());
728 args.push(id.clone());
729 }
730
731 if let Some(ref model) = self.fallback_model {
732 args.push("--fallback-model".to_string());
733 args.push(model.clone());
734 }
735
736 if self.no_session_persistence {
737 args.push("--no-session-persistence".to_string());
738 }
739
740 if self.dangerously_skip_permissions {
741 args.push("--dangerously-skip-permissions".to_string());
742 }
743
744 if let Some(ref agent) = self.agent {
745 args.push("--agent".to_string());
746 args.push(agent.clone());
747 }
748
749 if let Some(ref agents) = self.agents_json {
750 args.push("--agents".to_string());
751 args.push(agents.clone());
752 }
753
754 if !self.tools.is_empty() {
755 args.push("--tools".to_string());
756 args.push(self.tools.join(","));
757 }
758
759 for spec in &self.file {
760 args.push("--file".to_string());
761 args.push(spec.clone());
762 }
763
764 if self.include_partial_messages {
765 args.push("--include-partial-messages".to_string());
766 }
767
768 if let Some(ref format) = self.input_format {
769 args.push("--input-format".to_string());
770 args.push(format.as_arg().to_string());
771 }
772
773 if self.strict_mcp_config {
774 args.push("--strict-mcp-config".to_string());
775 }
776
777 if let Some(ref settings) = self.settings {
778 args.push("--settings".to_string());
779 args.push(settings.clone());
780 }
781
782 if self.fork_session {
783 args.push("--fork-session".to_string());
784 }
785
786 if self.worktree {
787 args.push("--worktree".to_string());
788 }
789
790 if self.brief {
791 args.push("--brief".to_string());
792 }
793
794 if let Some(ref filter) = self.debug_filter {
795 args.push("--debug".to_string());
796 args.push(filter.clone());
797 }
798
799 if let Some(ref path) = self.debug_file {
800 args.push("--debug-file".to_string());
801 args.push(path.clone());
802 }
803
804 if let Some(ref betas) = self.betas {
805 args.push("--betas".to_string());
806 args.push(betas.clone());
807 }
808
809 for dir in &self.plugin_dirs {
810 args.push("--plugin-dir".to_string());
811 args.push(dir.clone());
812 }
813
814 if let Some(ref sources) = self.setting_sources {
815 args.push("--setting-sources".to_string());
816 args.push(sources.clone());
817 }
818
819 if self.tmux {
820 args.push("--tmux".to_string());
821 }
822
823 if self.bare {
824 args.push("--bare".to_string());
825 }
826
827 if self.disable_slash_commands {
828 args.push("--disable-slash-commands".to_string());
829 }
830
831 if self.include_hook_events {
832 args.push("--include-hook-events".to_string());
833 }
834
835 if self.exclude_dynamic_system_prompt_sections {
836 args.push("--exclude-dynamic-system-prompt-sections".to_string());
837 }
838
839 if let Some(ref name) = self.name {
840 args.push("--name".to_string());
841 args.push(name.clone());
842 }
843
844 if let Some(ref pr) = self.from_pr {
845 args.push("--from-pr".to_string());
846 args.push(pr.clone());
847 }
848
849 args.push("--".to_string());
851 args.push(self.prompt.clone());
852
853 args
854 }
855}
856
857impl ClaudeCommand for QueryCommand {
858 type Output = CommandOutput;
859
860 fn args(&self) -> Vec<String> {
861 self.build_args()
862 }
863
864 #[cfg(feature = "async")]
865 async fn execute(&self, claude: &Claude) -> Result<CommandOutput> {
866 exec::run_claude_with_retry(claude, self.args(), self.retry_policy.as_ref()).await
867 }
868}
869
870fn shell_quote(arg: &str) -> String {
872 if arg.contains(|c: char| c.is_whitespace() || "\"'$\\`|;<>&()[]{}".contains(c)) {
874 format!("'{}'", arg.replace("'", "'\\''"))
876 } else {
877 arg.to_string()
878 }
879}
880
881fn join_patterns(patterns: &[ToolPattern]) -> String {
882 let mut out = String::new();
883 for (i, p) in patterns.iter().enumerate() {
884 if i > 0 {
885 out.push(',');
886 }
887 out.push_str(p.as_str());
888 }
889 out
890}
891
892#[cfg(test)]
893mod tests {
894 use super::*;
895
896 #[test]
897 fn test_basic_query_args() {
898 let cmd = QueryCommand::new("hello world");
899 let args = cmd.args();
900 assert_eq!(args, vec!["--print", "--", "hello world"]);
901 }
902
903 #[test]
904 fn build_args_with_forced_json_inserts_flag_before_separator() {
905 let cmd = QueryCommand::new("hello");
911 let args = cmd.build_args_with_forced_json();
912
913 assert_eq!(
915 &args[args.len() - 2..],
916 &["--".to_string(), "hello".to_string()],
917 );
918
919 let sep = args.iter().position(|a| a == "--").expect("`--` present");
921 let fmt = args
922 .iter()
923 .position(|a| a == "--output-format")
924 .expect("--output-format present");
925 assert!(
926 fmt < sep,
927 "--output-format must come before `--` separator; got {args:?}"
928 );
929 assert_eq!(args[fmt + 1], "json");
930 }
931
932 #[test]
933 fn build_args_with_forced_json_respects_explicit_format() {
934 let cmd = QueryCommand::new("hello").output_format(OutputFormat::Text);
937 let args = cmd.build_args_with_forced_json();
938 let fmt = args
939 .iter()
940 .position(|a| a == "--output-format")
941 .expect("--output-format present");
942 assert_eq!(args[fmt + 1], "text");
943 assert_eq!(args.iter().filter(|a| *a == "--output-format").count(), 1);
945 }
946
947 #[test]
948 #[allow(deprecated)] fn test_full_query_args() {
950 let cmd = QueryCommand::new("explain this")
951 .model("sonnet")
952 .system_prompt("be concise")
953 .output_format(OutputFormat::Json)
954 .max_budget_usd(0.50)
955 .permission_mode(PermissionMode::BypassPermissions)
956 .allowed_tools(["Bash", "Read"])
957 .mcp_config("/tmp/mcp.json")
958 .effort(Effort::High)
959 .max_turns(3)
960 .no_session_persistence();
961
962 let args = cmd.args();
963 assert!(args.contains(&"--print".to_string()));
964 assert!(args.contains(&"--model".to_string()));
965 assert!(args.contains(&"sonnet".to_string()));
966 assert!(args.contains(&"--system-prompt".to_string()));
967 assert!(args.contains(&"--output-format".to_string()));
968 assert!(args.contains(&"json".to_string()));
969 assert!(!args.contains(&"--verbose".to_string()));
971 assert!(args.contains(&"--max-budget-usd".to_string()));
972 assert!(args.contains(&"--permission-mode".to_string()));
973 assert!(args.contains(&"bypassPermissions".to_string()));
974 assert!(args.contains(&"--allowed-tools".to_string()));
975 assert!(args.contains(&"Bash,Read".to_string()));
976 assert!(args.contains(&"--effort".to_string()));
977 assert!(args.contains(&"high".to_string()));
978 assert!(args.contains(&"--max-turns".to_string()));
979 assert!(args.contains(&"--no-session-persistence".to_string()));
980 assert_eq!(args.last().unwrap(), "explain this");
982 assert_eq!(args[args.len() - 2], "--");
983 }
984
985 #[test]
986 fn typed_patterns_render_in_allowed_tools() {
987 use crate::ToolPattern;
988
989 let cmd = QueryCommand::new("hi")
990 .allowed_tool(ToolPattern::tool("Read"))
991 .allowed_tool(ToolPattern::tool_with_args("Bash", "git log:*"))
992 .allowed_tool(ToolPattern::all("Write"))
993 .allowed_tool(ToolPattern::mcp("srv", "*"));
994
995 let args = cmd.args();
996 let joined = args
997 .iter()
998 .position(|a| a == "--allowed-tools")
999 .map(|i| &args[i + 1])
1000 .unwrap();
1001 assert_eq!(joined, "Read,Bash(git log:*),Write(*),mcp__srv__*");
1002 }
1003
1004 #[test]
1005 fn disallowed_tool_singular_appends() {
1006 use crate::ToolPattern;
1007
1008 let cmd = QueryCommand::new("hi")
1009 .disallowed_tool("Write")
1010 .disallowed_tool(ToolPattern::tool_with_args("Bash", "rm*"));
1011
1012 let args = cmd.args();
1013 let joined = args
1014 .iter()
1015 .position(|a| a == "--disallowed-tools")
1016 .map(|i| &args[i + 1])
1017 .unwrap();
1018 assert_eq!(joined, "Write,Bash(rm*)");
1019 }
1020
1021 #[test]
1022 fn mixed_string_and_typed_patterns_both_accepted() {
1023 use crate::ToolPattern;
1024
1025 let strs: Vec<ToolPattern> = vec!["Bash".into(), ToolPattern::all("Read")];
1029 let cmd = QueryCommand::new("hi").allowed_tools(strs);
1030 assert!(cmd.args().contains(&"--allowed-tools".to_string()));
1031 }
1032
1033 #[test]
1034 fn new_bool_flags_emit_correct_cli_args() {
1035 let args = QueryCommand::new("hi")
1036 .bare()
1037 .disable_slash_commands()
1038 .include_hook_events()
1039 .exclude_dynamic_system_prompt_sections()
1040 .args();
1041 assert!(args.contains(&"--bare".to_string()));
1042 assert!(args.contains(&"--disable-slash-commands".to_string()));
1043 assert!(args.contains(&"--include-hook-events".to_string()));
1044 assert!(args.contains(&"--exclude-dynamic-system-prompt-sections".to_string()));
1045 }
1046
1047 #[test]
1048 fn name_flag_renders_with_value() {
1049 let args = QueryCommand::new("hi").name("my session").args();
1050 let pos = args.iter().position(|a| a == "--name").unwrap();
1051 assert_eq!(args[pos + 1], "my session");
1052 }
1053
1054 #[test]
1055 fn from_pr_flag_renders_with_value() {
1056 let args = QueryCommand::new("hi").from_pr("42").args();
1057 let pos = args.iter().position(|a| a == "--from-pr").unwrap();
1058 assert_eq!(args[pos + 1], "42");
1059 }
1060
1061 #[test]
1062 fn new_bool_flags_default_to_off() {
1063 let args = QueryCommand::new("hi").args();
1064 assert!(!args.contains(&"--bare".to_string()));
1065 assert!(!args.contains(&"--disable-slash-commands".to_string()));
1066 assert!(!args.contains(&"--include-hook-events".to_string()));
1067 assert!(!args.contains(&"--exclude-dynamic-system-prompt-sections".to_string()));
1068 assert!(!args.contains(&"--name".to_string()));
1069 }
1070
1071 #[test]
1072 fn test_separator_before_prompt_prevents_greedy_flag_parsing() {
1073 let cmd = QueryCommand::new("fix the bug")
1076 .allowed_tools(["Read", "Edit", "Bash(cargo *)"])
1077 .output_format(OutputFormat::StreamJson);
1078 let args = cmd.args();
1079 let sep_pos = args.iter().position(|a| a == "--").unwrap();
1081 let prompt_pos = args.iter().position(|a| a == "fix the bug").unwrap();
1082 assert_eq!(prompt_pos, sep_pos + 1, "prompt must follow -- separator");
1083 let tools_pos = args
1085 .iter()
1086 .position(|a| a.contains("Bash(cargo *)"))
1087 .unwrap();
1088 assert!(
1089 tools_pos < sep_pos,
1090 "allowed-tools must come before -- separator"
1091 );
1092 }
1093
1094 #[test]
1095 fn test_stream_json_includes_verbose() {
1096 let cmd = QueryCommand::new("test").output_format(OutputFormat::StreamJson);
1097 let args = cmd.args();
1098 assert!(args.contains(&"--output-format".to_string()));
1099 assert!(args.contains(&"stream-json".to_string()));
1100 assert!(args.contains(&"--verbose".to_string()));
1101 }
1102
1103 #[test]
1104 fn test_to_command_string_simple() {
1105 let claude = Claude::builder()
1106 .binary("/usr/local/bin/claude")
1107 .build()
1108 .unwrap();
1109
1110 let cmd = QueryCommand::new("hello");
1111 let command_str = cmd.to_command_string(&claude);
1112
1113 assert!(command_str.starts_with("/usr/local/bin/claude"));
1114 assert!(command_str.contains("--print"));
1115 assert!(command_str.contains("hello"));
1116 }
1117
1118 #[test]
1119 fn test_to_command_string_with_spaces() {
1120 let claude = Claude::builder()
1121 .binary("/usr/local/bin/claude")
1122 .build()
1123 .unwrap();
1124
1125 let cmd = QueryCommand::new("hello world").model("sonnet");
1126 let command_str = cmd.to_command_string(&claude);
1127
1128 assert!(command_str.starts_with("/usr/local/bin/claude"));
1129 assert!(command_str.contains("--print"));
1130 assert!(command_str.contains("'hello world'"));
1132 assert!(command_str.contains("--model"));
1133 assert!(command_str.contains("sonnet"));
1134 }
1135
1136 #[test]
1137 fn test_to_command_string_with_special_chars() {
1138 let claude = Claude::builder()
1139 .binary("/usr/local/bin/claude")
1140 .build()
1141 .unwrap();
1142
1143 let cmd = QueryCommand::new("test $VAR and `cmd`");
1144 let command_str = cmd.to_command_string(&claude);
1145
1146 assert!(command_str.contains("'test $VAR and `cmd`'"));
1148 }
1149
1150 #[test]
1151 fn test_to_command_string_with_single_quotes() {
1152 let claude = Claude::builder()
1153 .binary("/usr/local/bin/claude")
1154 .build()
1155 .unwrap();
1156
1157 let cmd = QueryCommand::new("it's");
1158 let command_str = cmd.to_command_string(&claude);
1159
1160 assert!(command_str.contains("'it'\\''s'"));
1162 }
1163
1164 #[test]
1165 fn test_worktree_flag() {
1166 let cmd = QueryCommand::new("test").worktree();
1167 let args = cmd.args();
1168 assert!(args.contains(&"--worktree".to_string()));
1169 }
1170
1171 #[test]
1172 fn test_brief_flag() {
1173 let cmd = QueryCommand::new("test").brief();
1174 let args = cmd.args();
1175 assert!(args.contains(&"--brief".to_string()));
1176 }
1177
1178 #[test]
1179 fn test_debug_filter() {
1180 let cmd = QueryCommand::new("test").debug_filter("api,hooks");
1181 let args = cmd.args();
1182 assert!(args.contains(&"--debug".to_string()));
1183 assert!(args.contains(&"api,hooks".to_string()));
1184 }
1185
1186 #[test]
1187 fn test_debug_file() {
1188 let cmd = QueryCommand::new("test").debug_file("/tmp/debug.log");
1189 let args = cmd.args();
1190 assert!(args.contains(&"--debug-file".to_string()));
1191 assert!(args.contains(&"/tmp/debug.log".to_string()));
1192 }
1193
1194 #[test]
1195 fn test_betas() {
1196 let cmd = QueryCommand::new("test").betas("feature-x");
1197 let args = cmd.args();
1198 assert!(args.contains(&"--betas".to_string()));
1199 assert!(args.contains(&"feature-x".to_string()));
1200 }
1201
1202 #[test]
1203 fn test_plugin_dir_single() {
1204 let cmd = QueryCommand::new("test").plugin_dir("/plugins/foo");
1205 let args = cmd.args();
1206 assert!(args.contains(&"--plugin-dir".to_string()));
1207 assert!(args.contains(&"/plugins/foo".to_string()));
1208 }
1209
1210 #[test]
1211 fn test_plugin_dir_multiple() {
1212 let cmd = QueryCommand::new("test")
1213 .plugin_dir("/plugins/foo")
1214 .plugin_dir("/plugins/bar");
1215 let args = cmd.args();
1216 let plugin_dir_count = args.iter().filter(|a| *a == "--plugin-dir").count();
1217 assert_eq!(plugin_dir_count, 2);
1218 assert!(args.contains(&"/plugins/foo".to_string()));
1219 assert!(args.contains(&"/plugins/bar".to_string()));
1220 }
1221
1222 #[test]
1223 fn test_setting_sources() {
1224 let cmd = QueryCommand::new("test").setting_sources("user,project,local");
1225 let args = cmd.args();
1226 assert!(args.contains(&"--setting-sources".to_string()));
1227 assert!(args.contains(&"user,project,local".to_string()));
1228 }
1229
1230 #[test]
1231 fn test_tmux_flag() {
1232 let cmd = QueryCommand::new("test").tmux();
1233 let args = cmd.args();
1234 assert!(args.contains(&"--tmux".to_string()));
1235 }
1236
1237 #[test]
1240 fn shell_quote_plain_word_is_unchanged() {
1241 assert_eq!(shell_quote("simple"), "simple");
1242 assert_eq!(shell_quote(""), "");
1243 assert_eq!(shell_quote("file.rs"), "file.rs");
1244 }
1245
1246 #[test]
1247 fn shell_quote_whitespace_gets_single_quoted() {
1248 assert_eq!(shell_quote("hello world"), "'hello world'");
1249 assert_eq!(shell_quote("a\tb"), "'a\tb'");
1250 }
1251
1252 #[test]
1253 fn shell_quote_metacharacters_get_quoted() {
1254 assert_eq!(shell_quote("a|b"), "'a|b'");
1255 assert_eq!(shell_quote("$VAR"), "'$VAR'");
1256 assert_eq!(shell_quote("a;b"), "'a;b'");
1257 assert_eq!(shell_quote("(x)"), "'(x)'");
1258 }
1259
1260 #[test]
1261 fn shell_quote_embedded_single_quote_is_escaped() {
1262 assert_eq!(shell_quote("it's"), "'it'\\''s'");
1263 }
1264
1265 #[test]
1266 fn shell_quote_double_quote_gets_single_quoted() {
1267 assert_eq!(shell_quote(r#"say "hi""#), r#"'say "hi"'"#);
1268 }
1269}