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 verbose: bool,
79 prompt_suggestions: bool,
80 replay_user_messages: bool,
81}
82
83impl QueryCommand {
84 #[must_use]
86 pub fn new(prompt: impl Into<String>) -> Self {
87 Self {
88 prompt: prompt.into(),
89 model: None,
90 system_prompt: None,
91 append_system_prompt: None,
92 output_format: None,
93 max_budget_usd: None,
94 permission_mode: None,
95 allowed_tools: Vec::new(),
96 disallowed_tools: Vec::new(),
97 mcp_config: Vec::new(),
98 add_dir: Vec::new(),
99 effort: None,
100 max_turns: None,
101 json_schema: None,
102 continue_session: false,
103 resume: None,
104 session_id: None,
105 fallback_model: None,
106 no_session_persistence: false,
107 dangerously_skip_permissions: false,
108 agent: None,
109 agents_json: None,
110 tools: Vec::new(),
111 file: Vec::new(),
112 include_partial_messages: false,
113 input_format: None,
114 strict_mcp_config: false,
115 settings: None,
116 fork_session: false,
117 retry_policy: None,
118 worktree: false,
119 worktree_name: None,
120 brief: false,
121 debug_filter: None,
122 debug_file: None,
123 betas: None,
124 plugin_dirs: Vec::new(),
125 setting_sources: None,
126 tmux: false,
127 bare: false,
128 disable_slash_commands: false,
129 include_hook_events: false,
130 exclude_dynamic_system_prompt_sections: false,
131 name: None,
132 from_pr: None,
133 prompt_via_stdin: false,
134 verbose: false,
135 prompt_suggestions: false,
136 replay_user_messages: false,
137 }
138 }
139
140 #[must_use]
142 pub fn model(mut self, model: impl Into<String>) -> Self {
143 self.model = Some(model.into());
144 self
145 }
146
147 #[must_use]
149 pub fn system_prompt(mut self, prompt: impl Into<String>) -> Self {
150 self.system_prompt = Some(prompt.into());
151 self
152 }
153
154 #[must_use]
156 pub fn append_system_prompt(mut self, prompt: impl Into<String>) -> Self {
157 self.append_system_prompt = Some(prompt.into());
158 self
159 }
160
161 #[must_use]
163 pub fn output_format(mut self, format: OutputFormat) -> Self {
164 self.output_format = Some(format);
165 self
166 }
167
168 #[must_use]
170 pub fn max_budget_usd(mut self, budget: f64) -> Self {
171 self.max_budget_usd = Some(budget);
172 self
173 }
174
175 #[must_use]
177 pub fn permission_mode(mut self, mode: PermissionMode) -> Self {
178 self.permission_mode = Some(mode);
179 self
180 }
181
182 #[must_use]
198 pub fn allowed_tools<I, T>(mut self, tools: I) -> Self
199 where
200 I: IntoIterator<Item = T>,
201 T: Into<ToolPattern>,
202 {
203 self.allowed_tools.extend(tools.into_iter().map(Into::into));
204 self
205 }
206
207 #[must_use]
209 pub fn allowed_tool(mut self, tool: impl Into<ToolPattern>) -> Self {
210 self.allowed_tools.push(tool.into());
211 self
212 }
213
214 #[must_use]
216 pub fn disallowed_tools<I, T>(mut self, tools: I) -> Self
217 where
218 I: IntoIterator<Item = T>,
219 T: Into<ToolPattern>,
220 {
221 self.disallowed_tools
222 .extend(tools.into_iter().map(Into::into));
223 self
224 }
225
226 #[must_use]
228 pub fn disallowed_tool(mut self, tool: impl Into<ToolPattern>) -> Self {
229 self.disallowed_tools.push(tool.into());
230 self
231 }
232
233 #[must_use]
235 pub fn mcp_config(mut self, path: impl Into<String>) -> Self {
236 self.mcp_config.push(path.into());
237 self
238 }
239
240 #[must_use]
242 pub fn add_dir(mut self, dir: impl Into<String>) -> Self {
243 self.add_dir.push(dir.into());
244 self
245 }
246
247 #[must_use]
249 pub fn effort(mut self, effort: Effort) -> Self {
250 self.effort = Some(effort);
251 self
252 }
253
254 #[must_use]
256 pub fn max_turns(mut self, turns: u32) -> Self {
257 self.max_turns = Some(turns);
258 self
259 }
260
261 #[must_use]
263 pub fn json_schema(mut self, schema: impl Into<String>) -> Self {
264 self.json_schema = Some(schema.into());
265 self
266 }
267
268 #[must_use]
270 pub fn continue_session(mut self) -> Self {
271 self.continue_session = true;
272 self
273 }
274
275 #[must_use]
277 pub fn resume(mut self, session_id: impl Into<String>) -> Self {
278 self.resume = Some(session_id.into());
279 self
280 }
281
282 #[must_use]
284 pub fn session_id(mut self, id: impl Into<String>) -> Self {
285 self.session_id = Some(id.into());
286 self
287 }
288
289 #[cfg(all(feature = "json", feature = "async"))]
297 pub(crate) fn replace_session(mut self, id: impl Into<String>) -> Self {
298 self.continue_session = false;
299 self.resume = Some(id.into());
300 self.session_id = None;
301 self.fork_session = false;
302 self
303 }
304
305 #[must_use]
307 pub fn fallback_model(mut self, model: impl Into<String>) -> Self {
308 self.fallback_model = Some(model.into());
309 self
310 }
311
312 #[must_use]
314 pub fn no_session_persistence(mut self) -> Self {
315 self.no_session_persistence = true;
316 self
317 }
318
319 #[must_use]
321 pub fn dangerously_skip_permissions(mut self) -> Self {
322 self.dangerously_skip_permissions = true;
323 self
324 }
325
326 #[must_use]
340 pub fn agent(mut self, agent: impl Into<String>) -> Self {
341 self.agent = Some(agent.into());
342 self
343 }
344
345 #[must_use]
357 pub fn agents_json(mut self, json: impl Into<String>) -> Self {
358 self.agents_json = Some(json.into());
359 self
360 }
361
362 #[must_use]
368 pub fn tools(mut self, tools: impl IntoIterator<Item = impl Into<String>>) -> Self {
369 self.tools.extend(tools.into_iter().map(Into::into));
370 self
371 }
372
373 #[must_use]
377 pub fn file(mut self, spec: impl Into<String>) -> Self {
378 self.file.push(spec.into());
379 self
380 }
381
382 #[must_use]
386 pub fn include_partial_messages(mut self) -> Self {
387 self.include_partial_messages = true;
388 self
389 }
390
391 #[must_use]
393 pub fn input_format(mut self, format: InputFormat) -> Self {
394 self.input_format = Some(format);
395 self
396 }
397
398 #[must_use]
400 pub fn strict_mcp_config(mut self) -> Self {
401 self.strict_mcp_config = true;
402 self
403 }
404
405 #[must_use]
407 pub fn settings(mut self, settings: impl Into<String>) -> Self {
408 self.settings = Some(settings.into());
409 self
410 }
411
412 #[must_use]
414 pub fn fork_session(mut self) -> Self {
415 self.fork_session = true;
416 self
417 }
418
419 #[must_use]
421 pub fn worktree(mut self) -> Self {
422 self.worktree = true;
423 self
424 }
425
426 #[must_use]
449 pub fn worktree_named(mut self, name: impl Into<String>) -> Self {
450 self.worktree = true;
451 self.worktree_name = Some(name.into());
452 self
453 }
454
455 #[must_use]
457 pub fn brief(mut self) -> Self {
458 self.brief = true;
459 self
460 }
461
462 #[must_use]
464 pub fn debug_filter(mut self, filter: impl Into<String>) -> Self {
465 self.debug_filter = Some(filter.into());
466 self
467 }
468
469 #[must_use]
471 pub fn debug_file(mut self, path: impl Into<String>) -> Self {
472 self.debug_file = Some(path.into());
473 self
474 }
475
476 #[must_use]
478 pub fn betas(mut self, betas: impl Into<String>) -> Self {
479 self.betas = Some(betas.into());
480 self
481 }
482
483 #[must_use]
485 pub fn plugin_dir(mut self, dir: impl Into<String>) -> Self {
486 self.plugin_dirs.push(dir.into());
487 self
488 }
489
490 #[must_use]
492 pub fn setting_sources(mut self, sources: impl Into<String>) -> Self {
493 self.setting_sources = Some(sources.into());
494 self
495 }
496
497 #[must_use]
499 pub fn tmux(mut self) -> Self {
500 self.tmux = true;
501 self
502 }
503
504 #[must_use]
520 pub fn bare(mut self) -> Self {
521 self.bare = true;
522 self
523 }
524
525 #[must_use]
527 pub fn disable_slash_commands(mut self) -> Self {
528 self.disable_slash_commands = true;
529 self
530 }
531
532 #[must_use]
536 pub fn include_hook_events(mut self) -> Self {
537 self.include_hook_events = true;
538 self
539 }
540
541 #[must_use]
547 pub fn exclude_dynamic_system_prompt_sections(mut self) -> Self {
548 self.exclude_dynamic_system_prompt_sections = true;
549 self
550 }
551
552 #[must_use]
555 pub fn name(mut self, name: impl Into<String>) -> Self {
556 self.name = Some(name.into());
557 self
558 }
559
560 #[must_use]
567 pub fn from_pr(mut self, pr: impl Into<String>) -> Self {
568 self.from_pr = Some(pr.into());
569 self
570 }
571
572 #[must_use]
580 pub fn verbose(mut self, value: bool) -> Self {
581 self.verbose = value;
582 self
583 }
584
585 #[must_use]
591 pub fn prompt_suggestions(mut self, value: bool) -> Self {
592 self.prompt_suggestions = value;
593 self
594 }
595
596 #[must_use]
604 pub fn replay_user_messages(mut self, value: bool) -> Self {
605 self.replay_user_messages = value;
606 self
607 }
608
609 #[must_use]
632 pub fn retry(mut self, policy: crate::retry::RetryPolicy) -> Self {
633 self.retry_policy = Some(policy);
634 self
635 }
636
637 pub fn to_command_string(&self, claude: &Claude) -> String {
660 let args = self.build_args();
661 let quoted_args = args.iter().map(|arg| shell_quote(arg)).collect::<Vec<_>>();
662 format!("{} {}", claude.binary().display(), quoted_args.join(" "))
663 }
664
665 #[cfg(all(feature = "json", feature = "async"))]
670 pub async fn execute_json(&self, claude: &Claude) -> Result<crate::types::QueryResult> {
671 let args = self.build_args_with_forced_json();
672
673 let output = if self.prompt_via_stdin {
674 exec::run_claude_with_stdin_prompt(claude, args, self.prompt.clone()).await?
677 } else {
678 exec::run_claude_with_retry(claude, args, self.retry_policy.as_ref()).await?
679 };
680
681 serde_json::from_str(&output.stdout).map_err(|e| crate::error::Error::Json {
682 message: format!("failed to parse query result: {e}"),
683 source: e,
684 })
685 }
686
687 #[cfg(feature = "sync")]
694 pub fn execute_sync(&self, claude: &Claude) -> Result<CommandOutput> {
695 if self.prompt_via_stdin {
696 exec::run_claude_with_stdin_prompt_sync(claude, self.build_args(), self.prompt.clone())
699 } else {
700 exec::run_claude_with_retry_sync(claude, self.args(), self.retry_policy.as_ref())
701 }
702 }
703
704 #[cfg(all(feature = "sync", feature = "json"))]
706 pub fn execute_json_sync(&self, claude: &Claude) -> Result<crate::types::QueryResult> {
707 let args = self.build_args_with_forced_json();
708
709 let output = if self.prompt_via_stdin {
710 exec::run_claude_with_stdin_prompt_sync(claude, args, self.prompt.clone())?
713 } else {
714 exec::run_claude_with_retry_sync(claude, args, self.retry_policy.as_ref())?
715 };
716
717 serde_json::from_str(&output.stdout).map_err(|e| crate::error::Error::Json {
718 message: format!("failed to parse query result: {e}"),
719 source: e,
720 })
721 }
722
723 #[must_use]
749 pub fn prompt_via_stdin(mut self, value: bool) -> Self {
750 self.prompt_via_stdin = value;
751 self
752 }
753
754 fn build_args_with_forced_json(&self) -> Vec<String> {
762 if self.output_format.is_some() {
763 return self.build_args();
764 }
765 let mut effective = self.clone();
766 effective.output_format = Some(OutputFormat::Json);
767 effective.build_args()
768 }
769
770 fn build_args(&self) -> Vec<String> {
771 let mut args = vec!["--print".to_string()];
772
773 if let Some(ref model) = self.model {
774 args.push("--model".to_string());
775 args.push(model.clone());
776 }
777
778 if let Some(ref prompt) = self.system_prompt {
779 args.push("--system-prompt".to_string());
780 args.push(prompt.clone());
781 }
782
783 if let Some(ref prompt) = self.append_system_prompt {
784 args.push("--append-system-prompt".to_string());
785 args.push(prompt.clone());
786 }
787
788 if let Some(ref format) = self.output_format {
789 args.push("--output-format".to_string());
790 args.push(format.as_arg().to_string());
791 }
792
793 if self.verbose || matches!(self.output_format, Some(OutputFormat::StreamJson)) {
797 args.push("--verbose".to_string());
798 }
799
800 if let Some(budget) = self.max_budget_usd {
801 args.push("--max-budget-usd".to_string());
802 args.push(budget.to_string());
803 }
804
805 if let Some(ref mode) = self.permission_mode {
806 args.push("--permission-mode".to_string());
807 args.push(mode.as_arg().to_string());
808 }
809
810 if !self.allowed_tools.is_empty() {
811 args.push("--allowed-tools".to_string());
812 args.push(join_patterns(&self.allowed_tools));
813 }
814
815 if !self.disallowed_tools.is_empty() {
816 args.push("--disallowed-tools".to_string());
817 args.push(join_patterns(&self.disallowed_tools));
818 }
819
820 for config in &self.mcp_config {
821 args.push("--mcp-config".to_string());
822 args.push(config.clone());
823 }
824
825 for dir in &self.add_dir {
826 args.push("--add-dir".to_string());
827 args.push(dir.clone());
828 }
829
830 if let Some(ref effort) = self.effort {
831 args.push("--effort".to_string());
832 args.push(effort.as_arg().to_string());
833 }
834
835 if let Some(turns) = self.max_turns {
836 args.push("--max-turns".to_string());
837 args.push(turns.to_string());
838 }
839
840 if let Some(ref schema) = self.json_schema {
841 args.push("--json-schema".to_string());
842 args.push(schema.clone());
843 }
844
845 if self.continue_session {
846 args.push("--continue".to_string());
847 }
848
849 if let Some(ref session_id) = self.resume {
850 args.push("--resume".to_string());
851 args.push(session_id.clone());
852 }
853
854 if let Some(ref id) = self.session_id {
855 args.push("--session-id".to_string());
856 args.push(id.clone());
857 }
858
859 if let Some(ref model) = self.fallback_model {
860 args.push("--fallback-model".to_string());
861 args.push(model.clone());
862 }
863
864 if self.no_session_persistence {
865 args.push("--no-session-persistence".to_string());
866 }
867
868 if self.dangerously_skip_permissions {
869 args.push("--dangerously-skip-permissions".to_string());
870 }
871
872 if let Some(ref agent) = self.agent {
873 args.push("--agent".to_string());
874 args.push(agent.clone());
875 }
876
877 if let Some(ref agents) = self.agents_json {
878 args.push("--agents".to_string());
879 args.push(agents.clone());
880 }
881
882 if !self.tools.is_empty() {
883 args.push("--tools".to_string());
884 args.push(self.tools.join(","));
885 }
886
887 for spec in &self.file {
888 args.push("--file".to_string());
889 args.push(spec.clone());
890 }
891
892 if self.include_partial_messages {
893 args.push("--include-partial-messages".to_string());
894 }
895
896 if let Some(ref format) = self.input_format {
897 args.push("--input-format".to_string());
898 args.push(format.as_arg().to_string());
899 }
900
901 if self.strict_mcp_config {
902 args.push("--strict-mcp-config".to_string());
903 }
904
905 if let Some(ref settings) = self.settings {
906 args.push("--settings".to_string());
907 args.push(settings.clone());
908 }
909
910 if self.fork_session {
911 args.push("--fork-session".to_string());
912 }
913
914 if self.worktree {
915 args.push("--worktree".to_string());
916 if let Some(ref name) = self.worktree_name {
917 args.push(name.clone());
918 }
919 }
920
921 if self.brief {
922 args.push("--brief".to_string());
923 }
924
925 if let Some(ref filter) = self.debug_filter {
926 args.push("--debug".to_string());
927 args.push(filter.clone());
928 }
929
930 if let Some(ref path) = self.debug_file {
931 args.push("--debug-file".to_string());
932 args.push(path.clone());
933 }
934
935 if let Some(ref betas) = self.betas {
936 args.push("--betas".to_string());
937 args.push(betas.clone());
938 }
939
940 for dir in &self.plugin_dirs {
941 args.push("--plugin-dir".to_string());
942 args.push(dir.clone());
943 }
944
945 if let Some(ref sources) = self.setting_sources {
946 args.push("--setting-sources".to_string());
947 args.push(sources.clone());
948 }
949
950 if self.tmux {
951 args.push("--tmux".to_string());
952 }
953
954 if self.bare {
955 args.push("--bare".to_string());
956 }
957
958 if self.disable_slash_commands {
959 args.push("--disable-slash-commands".to_string());
960 }
961
962 if self.include_hook_events {
963 args.push("--include-hook-events".to_string());
964 }
965
966 if self.exclude_dynamic_system_prompt_sections {
967 args.push("--exclude-dynamic-system-prompt-sections".to_string());
968 }
969
970 if self.prompt_suggestions {
971 args.push("--prompt-suggestions".to_string());
972 }
973
974 if self.replay_user_messages {
975 args.push("--replay-user-messages".to_string());
976 }
977
978 if let Some(ref name) = self.name {
979 args.push("--name".to_string());
980 args.push(name.clone());
981 }
982
983 if let Some(ref pr) = self.from_pr {
984 args.push("--from-pr".to_string());
985 args.push(pr.clone());
986 }
987
988 if !self.prompt_via_stdin {
992 args.push("--".to_string());
993 args.push(self.prompt.clone());
994 }
995
996 args
997 }
998}
999
1000impl ClaudeCommand for QueryCommand {
1001 type Output = CommandOutput;
1002
1003 fn args(&self) -> Vec<String> {
1004 self.build_args()
1005 }
1006
1007 #[cfg(feature = "async")]
1008 async fn execute(&self, claude: &Claude) -> Result<CommandOutput> {
1009 if self.prompt_via_stdin {
1010 let args = self.build_args(); exec::run_claude_with_stdin_prompt(claude, args, self.prompt.clone()).await
1014 } else {
1015 exec::run_claude_with_retry(claude, self.args(), self.retry_policy.as_ref()).await
1016 }
1017 }
1018}
1019
1020fn shell_quote(arg: &str) -> String {
1022 if arg.contains(|c: char| c.is_whitespace() || "\"'$\\`|;<>&()[]{}".contains(c)) {
1024 format!("'{}'", arg.replace("'", "'\\''"))
1026 } else {
1027 arg.to_string()
1028 }
1029}
1030
1031fn join_patterns(patterns: &[ToolPattern]) -> String {
1032 let mut out = String::new();
1033 for (i, p) in patterns.iter().enumerate() {
1034 if i > 0 {
1035 out.push(',');
1036 }
1037 out.push_str(p.as_str());
1038 }
1039 out
1040}
1041
1042#[cfg(test)]
1043mod tests {
1044 use super::*;
1045
1046 #[test]
1047 fn test_basic_query_args() {
1048 let cmd = QueryCommand::new("hello world");
1049 let args = cmd.args();
1050 assert_eq!(args, vec!["--print", "--", "hello world"]);
1051 }
1052
1053 #[test]
1054 fn prompt_via_stdin_omits_prompt_from_args() {
1055 let cmd = QueryCommand::new("secret payload").prompt_via_stdin(true);
1056 let args = cmd.args();
1057 assert!(
1058 !args.contains(&"secret payload".to_string()),
1059 "prompt must not appear in args when prompt_via_stdin is set"
1060 );
1061 assert!(
1062 !args.contains(&"--".to_string()),
1063 "-- separator must be absent when prompt_via_stdin is set"
1064 );
1065 }
1066
1067 #[test]
1068 fn prompt_via_stdin_false_keeps_prompt_in_args() {
1069 let cmd = QueryCommand::new("visible prompt").prompt_via_stdin(false);
1070 let args = cmd.args();
1071 assert!(
1072 args.contains(&"visible prompt".to_string()),
1073 "prompt must still appear in args when prompt_via_stdin is false"
1074 );
1075 assert!(
1076 args.contains(&"--".to_string()),
1077 "-- separator must be present when prompt_via_stdin is false"
1078 );
1079 }
1080
1081 #[test]
1082 #[cfg(feature = "async")] #[ignore = "requires a real claude binary"]
1084 fn prompt_via_stdin_integration() {
1085 use crate::{Claude, ClaudeCommand};
1088 let rt = tokio::runtime::Runtime::new().unwrap();
1089 rt.block_on(async {
1090 let claude = Claude::builder().build().unwrap();
1091 let out = QueryCommand::new("reply with: STDIN_OK")
1092 .prompt_via_stdin(true)
1093 .execute(&claude)
1094 .await
1095 .unwrap();
1096 assert!(
1097 !out.stdout.is_empty(),
1098 "expected non-empty output from stdin-mode query"
1099 );
1100 });
1101 }
1102
1103 #[test]
1104 fn build_args_with_forced_json_inserts_flag_before_separator() {
1105 let cmd = QueryCommand::new("hello");
1111 let args = cmd.build_args_with_forced_json();
1112
1113 assert_eq!(
1115 &args[args.len() - 2..],
1116 &["--".to_string(), "hello".to_string()],
1117 );
1118
1119 let sep = args.iter().position(|a| a == "--").expect("`--` present");
1121 let fmt = args
1122 .iter()
1123 .position(|a| a == "--output-format")
1124 .expect("--output-format present");
1125 assert!(
1126 fmt < sep,
1127 "--output-format must come before `--` separator; got {args:?}"
1128 );
1129 assert_eq!(args[fmt + 1], "json");
1130 }
1131
1132 #[test]
1133 fn build_args_with_forced_json_respects_explicit_format() {
1134 let cmd = QueryCommand::new("hello").output_format(OutputFormat::Text);
1137 let args = cmd.build_args_with_forced_json();
1138 let fmt = args
1139 .iter()
1140 .position(|a| a == "--output-format")
1141 .expect("--output-format present");
1142 assert_eq!(args[fmt + 1], "text");
1143 assert_eq!(args.iter().filter(|a| *a == "--output-format").count(), 1);
1145 }
1146
1147 #[test]
1148 #[allow(deprecated)] fn test_full_query_args() {
1150 let cmd = QueryCommand::new("explain this")
1151 .model("sonnet")
1152 .system_prompt("be concise")
1153 .output_format(OutputFormat::Json)
1154 .max_budget_usd(0.50)
1155 .permission_mode(PermissionMode::BypassPermissions)
1156 .allowed_tools(["Bash", "Read"])
1157 .mcp_config("/tmp/mcp.json")
1158 .effort(Effort::High)
1159 .max_turns(3)
1160 .no_session_persistence();
1161
1162 let args = cmd.args();
1163 assert!(args.contains(&"--print".to_string()));
1164 assert!(args.contains(&"--model".to_string()));
1165 assert!(args.contains(&"sonnet".to_string()));
1166 assert!(args.contains(&"--system-prompt".to_string()));
1167 assert!(args.contains(&"--output-format".to_string()));
1168 assert!(args.contains(&"json".to_string()));
1169 assert!(!args.contains(&"--verbose".to_string()));
1171 assert!(args.contains(&"--max-budget-usd".to_string()));
1172 assert!(args.contains(&"--permission-mode".to_string()));
1173 assert!(args.contains(&"bypassPermissions".to_string()));
1174 assert!(args.contains(&"--allowed-tools".to_string()));
1175 assert!(args.contains(&"Bash,Read".to_string()));
1176 assert!(args.contains(&"--effort".to_string()));
1177 assert!(args.contains(&"high".to_string()));
1178 assert!(args.contains(&"--max-turns".to_string()));
1179 assert!(args.contains(&"--no-session-persistence".to_string()));
1180 assert_eq!(args.last().unwrap(), "explain this");
1182 assert_eq!(args[args.len() - 2], "--");
1183 }
1184
1185 #[test]
1186 fn typed_patterns_render_in_allowed_tools() {
1187 use crate::ToolPattern;
1188
1189 let cmd = QueryCommand::new("hi")
1190 .allowed_tool(ToolPattern::tool("Read"))
1191 .allowed_tool(ToolPattern::tool_with_args("Bash", "git log:*"))
1192 .allowed_tool(ToolPattern::all("Write"))
1193 .allowed_tool(ToolPattern::mcp("srv", "*"));
1194
1195 let args = cmd.args();
1196 let joined = args
1197 .iter()
1198 .position(|a| a == "--allowed-tools")
1199 .map(|i| &args[i + 1])
1200 .unwrap();
1201 assert_eq!(joined, "Read,Bash(git log:*),Write(*),mcp__srv__*");
1202 }
1203
1204 #[test]
1205 fn disallowed_tool_singular_appends() {
1206 use crate::ToolPattern;
1207
1208 let cmd = QueryCommand::new("hi")
1209 .disallowed_tool("Write")
1210 .disallowed_tool(ToolPattern::tool_with_args("Bash", "rm*"));
1211
1212 let args = cmd.args();
1213 let joined = args
1214 .iter()
1215 .position(|a| a == "--disallowed-tools")
1216 .map(|i| &args[i + 1])
1217 .unwrap();
1218 assert_eq!(joined, "Write,Bash(rm*)");
1219 }
1220
1221 #[test]
1222 fn mixed_string_and_typed_patterns_both_accepted() {
1223 use crate::ToolPattern;
1224
1225 let strs: Vec<ToolPattern> = vec!["Bash".into(), ToolPattern::all("Read")];
1229 let cmd = QueryCommand::new("hi").allowed_tools(strs);
1230 assert!(cmd.args().contains(&"--allowed-tools".to_string()));
1231 }
1232
1233 #[test]
1234 fn new_bool_flags_emit_correct_cli_args() {
1235 let args = QueryCommand::new("hi")
1236 .bare()
1237 .disable_slash_commands()
1238 .include_hook_events()
1239 .exclude_dynamic_system_prompt_sections()
1240 .args();
1241 assert!(args.contains(&"--bare".to_string()));
1242 assert!(args.contains(&"--disable-slash-commands".to_string()));
1243 assert!(args.contains(&"--include-hook-events".to_string()));
1244 assert!(args.contains(&"--exclude-dynamic-system-prompt-sections".to_string()));
1245 }
1246
1247 #[test]
1248 fn name_flag_renders_with_value() {
1249 let args = QueryCommand::new("hi").name("my session").args();
1250 let pos = args.iter().position(|a| a == "--name").unwrap();
1251 assert_eq!(args[pos + 1], "my session");
1252 }
1253
1254 #[test]
1255 fn from_pr_flag_renders_with_value() {
1256 let args = QueryCommand::new("hi").from_pr("42").args();
1257 let pos = args.iter().position(|a| a == "--from-pr").unwrap();
1258 assert_eq!(args[pos + 1], "42");
1259 }
1260
1261 #[test]
1262 fn new_bool_flags_default_to_off() {
1263 let args = QueryCommand::new("hi").args();
1264 assert!(!args.contains(&"--bare".to_string()));
1265 assert!(!args.contains(&"--disable-slash-commands".to_string()));
1266 assert!(!args.contains(&"--include-hook-events".to_string()));
1267 assert!(!args.contains(&"--exclude-dynamic-system-prompt-sections".to_string()));
1268 assert!(!args.contains(&"--name".to_string()));
1269 }
1270
1271 #[test]
1272 fn test_separator_before_prompt_prevents_greedy_flag_parsing() {
1273 let cmd = QueryCommand::new("fix the bug")
1276 .allowed_tools(["Read", "Edit", "Bash(cargo *)"])
1277 .output_format(OutputFormat::StreamJson);
1278 let args = cmd.args();
1279 let sep_pos = args.iter().position(|a| a == "--").unwrap();
1281 let prompt_pos = args.iter().position(|a| a == "fix the bug").unwrap();
1282 assert_eq!(prompt_pos, sep_pos + 1, "prompt must follow -- separator");
1283 let tools_pos = args
1285 .iter()
1286 .position(|a| a.contains("Bash(cargo *)"))
1287 .unwrap();
1288 assert!(
1289 tools_pos < sep_pos,
1290 "allowed-tools must come before -- separator"
1291 );
1292 }
1293
1294 #[test]
1295 fn test_stream_json_includes_verbose() {
1296 let cmd = QueryCommand::new("test").output_format(OutputFormat::StreamJson);
1297 let args = cmd.args();
1298 assert!(args.contains(&"--output-format".to_string()));
1299 assert!(args.contains(&"stream-json".to_string()));
1300 assert!(args.contains(&"--verbose".to_string()));
1301 }
1302
1303 #[test]
1304 fn verbose_flag_emitted_when_set() {
1305 let args = QueryCommand::new("test").verbose(true).args();
1306 assert!(args.contains(&"--verbose".to_string()));
1307 }
1308
1309 #[test]
1310 fn verbose_absent_by_default_and_when_false() {
1311 assert!(
1312 !QueryCommand::new("test")
1313 .args()
1314 .contains(&"--verbose".to_string())
1315 );
1316 assert!(
1317 !QueryCommand::new("test")
1318 .verbose(false)
1319 .args()
1320 .contains(&"--verbose".to_string())
1321 );
1322 }
1323
1324 #[test]
1325 fn verbose_not_duplicated_with_stream_json() {
1326 let cmd = QueryCommand::new("test")
1329 .verbose(true)
1330 .output_format(OutputFormat::StreamJson);
1331 let count = cmd.args().iter().filter(|a| *a == "--verbose").count();
1332 assert_eq!(count, 1, "--verbose must appear exactly once");
1333 }
1334
1335 #[test]
1336 fn prompt_suggestions_flag_emitted_when_set() {
1337 let args = QueryCommand::new("test").prompt_suggestions(true).args();
1338 assert!(args.contains(&"--prompt-suggestions".to_string()));
1339 let sep = args.iter().position(|a| a == "--").unwrap();
1342 let flag = args
1343 .iter()
1344 .position(|a| a == "--prompt-suggestions")
1345 .unwrap();
1346 assert!(flag < sep, "--prompt-suggestions must precede `--`");
1347 }
1348
1349 #[test]
1350 fn prompt_suggestions_absent_by_default_and_when_false() {
1351 assert!(
1352 !QueryCommand::new("test")
1353 .args()
1354 .contains(&"--prompt-suggestions".to_string())
1355 );
1356 assert!(
1357 !QueryCommand::new("test")
1358 .prompt_suggestions(false)
1359 .args()
1360 .contains(&"--prompt-suggestions".to_string())
1361 );
1362 }
1363
1364 #[test]
1365 fn replay_user_messages_flag_emitted_when_set() {
1366 let args = QueryCommand::new("test").replay_user_messages(true).args();
1367 assert!(args.contains(&"--replay-user-messages".to_string()));
1368 }
1369
1370 #[test]
1371 fn replay_user_messages_absent_by_default_and_when_false() {
1372 assert!(
1373 !QueryCommand::new("test")
1374 .args()
1375 .contains(&"--replay-user-messages".to_string())
1376 );
1377 assert!(
1378 !QueryCommand::new("test")
1379 .replay_user_messages(false)
1380 .args()
1381 .contains(&"--replay-user-messages".to_string())
1382 );
1383 }
1384
1385 #[test]
1386 fn test_to_command_string_simple() {
1387 let claude = Claude::builder()
1388 .binary("/usr/local/bin/claude")
1389 .build()
1390 .unwrap();
1391
1392 let cmd = QueryCommand::new("hello");
1393 let command_str = cmd.to_command_string(&claude);
1394
1395 assert!(command_str.starts_with("/usr/local/bin/claude"));
1396 assert!(command_str.contains("--print"));
1397 assert!(command_str.contains("hello"));
1398 }
1399
1400 #[test]
1401 fn test_to_command_string_with_spaces() {
1402 let claude = Claude::builder()
1403 .binary("/usr/local/bin/claude")
1404 .build()
1405 .unwrap();
1406
1407 let cmd = QueryCommand::new("hello world").model("sonnet");
1408 let command_str = cmd.to_command_string(&claude);
1409
1410 assert!(command_str.starts_with("/usr/local/bin/claude"));
1411 assert!(command_str.contains("--print"));
1412 assert!(command_str.contains("'hello world'"));
1414 assert!(command_str.contains("--model"));
1415 assert!(command_str.contains("sonnet"));
1416 }
1417
1418 #[test]
1419 fn test_to_command_string_with_special_chars() {
1420 let claude = Claude::builder()
1421 .binary("/usr/local/bin/claude")
1422 .build()
1423 .unwrap();
1424
1425 let cmd = QueryCommand::new("test $VAR and `cmd`");
1426 let command_str = cmd.to_command_string(&claude);
1427
1428 assert!(command_str.contains("'test $VAR and `cmd`'"));
1430 }
1431
1432 #[test]
1433 fn test_to_command_string_with_single_quotes() {
1434 let claude = Claude::builder()
1435 .binary("/usr/local/bin/claude")
1436 .build()
1437 .unwrap();
1438
1439 let cmd = QueryCommand::new("it's");
1440 let command_str = cmd.to_command_string(&claude);
1441
1442 assert!(command_str.contains("'it'\\''s'"));
1444 }
1445
1446 #[test]
1447 fn test_worktree_flag() {
1448 let cmd = QueryCommand::new("test").worktree();
1449 let args = cmd.args();
1450 assert!(args.contains(&"--worktree".to_string()));
1451 }
1452
1453 #[test]
1454 fn test_worktree_named() {
1455 let cmd = QueryCommand::new("test").worktree_named("feature-x");
1456 let args = cmd.args();
1457 assert!(
1458 args.windows(2).any(|w| w == ["--worktree", "feature-x"]),
1459 "missing --worktree feature-x in {args:?}"
1460 );
1461 }
1462
1463 #[test]
1464 fn test_brief_flag() {
1465 let cmd = QueryCommand::new("test").brief();
1466 let args = cmd.args();
1467 assert!(args.contains(&"--brief".to_string()));
1468 }
1469
1470 #[test]
1471 fn test_debug_filter() {
1472 let cmd = QueryCommand::new("test").debug_filter("api,hooks");
1473 let args = cmd.args();
1474 assert!(args.contains(&"--debug".to_string()));
1475 assert!(args.contains(&"api,hooks".to_string()));
1476 }
1477
1478 #[test]
1479 fn test_debug_file() {
1480 let cmd = QueryCommand::new("test").debug_file("/tmp/debug.log");
1481 let args = cmd.args();
1482 assert!(args.contains(&"--debug-file".to_string()));
1483 assert!(args.contains(&"/tmp/debug.log".to_string()));
1484 }
1485
1486 #[test]
1487 fn test_betas() {
1488 let cmd = QueryCommand::new("test").betas("feature-x");
1489 let args = cmd.args();
1490 assert!(args.contains(&"--betas".to_string()));
1491 assert!(args.contains(&"feature-x".to_string()));
1492 }
1493
1494 #[test]
1495 fn test_plugin_dir_single() {
1496 let cmd = QueryCommand::new("test").plugin_dir("/plugins/foo");
1497 let args = cmd.args();
1498 assert!(args.contains(&"--plugin-dir".to_string()));
1499 assert!(args.contains(&"/plugins/foo".to_string()));
1500 }
1501
1502 #[test]
1503 fn test_plugin_dir_multiple() {
1504 let cmd = QueryCommand::new("test")
1505 .plugin_dir("/plugins/foo")
1506 .plugin_dir("/plugins/bar");
1507 let args = cmd.args();
1508 let plugin_dir_count = args.iter().filter(|a| *a == "--plugin-dir").count();
1509 assert_eq!(plugin_dir_count, 2);
1510 assert!(args.contains(&"/plugins/foo".to_string()));
1511 assert!(args.contains(&"/plugins/bar".to_string()));
1512 }
1513
1514 #[test]
1515 fn test_setting_sources() {
1516 let cmd = QueryCommand::new("test").setting_sources("user,project,local");
1517 let args = cmd.args();
1518 assert!(args.contains(&"--setting-sources".to_string()));
1519 assert!(args.contains(&"user,project,local".to_string()));
1520 }
1521
1522 #[test]
1523 fn test_tmux_flag() {
1524 let cmd = QueryCommand::new("test").tmux();
1525 let args = cmd.args();
1526 assert!(args.contains(&"--tmux".to_string()));
1527 }
1528
1529 #[test]
1532 fn shell_quote_plain_word_is_unchanged() {
1533 assert_eq!(shell_quote("simple"), "simple");
1534 assert_eq!(shell_quote(""), "");
1535 assert_eq!(shell_quote("file.rs"), "file.rs");
1536 }
1537
1538 #[test]
1539 fn shell_quote_whitespace_gets_single_quoted() {
1540 assert_eq!(shell_quote("hello world"), "'hello world'");
1541 assert_eq!(shell_quote("a\tb"), "'a\tb'");
1542 }
1543
1544 #[test]
1545 fn shell_quote_metacharacters_get_quoted() {
1546 assert_eq!(shell_quote("a|b"), "'a|b'");
1547 assert_eq!(shell_quote("$VAR"), "'$VAR'");
1548 assert_eq!(shell_quote("a;b"), "'a;b'");
1549 assert_eq!(shell_quote("(x)"), "'(x)'");
1550 }
1551
1552 #[test]
1553 fn shell_quote_embedded_single_quote_is_escaped() {
1554 assert_eq!(shell_quote("it's"), "'it'\\''s'");
1555 }
1556
1557 #[test]
1558 fn shell_quote_double_quote_gets_single_quoted() {
1559 assert_eq!(shell_quote(r#"say "hi""#), r#"'say "hi"'"#);
1560 }
1561}