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 plugin_urls: Vec<String>,
70 setting_sources: Option<String>,
71 tmux: bool,
72 bare: bool,
73 safe_mode: bool,
74 disable_slash_commands: bool,
75 include_hook_events: bool,
76 exclude_dynamic_system_prompt_sections: bool,
77 name: Option<String>,
78 from_pr: Option<String>,
79 prompt_via_stdin: bool,
80 verbose: bool,
81 prompt_suggestions: bool,
82 replay_user_messages: bool,
83}
84
85impl QueryCommand {
86 #[must_use]
88 pub fn new(prompt: impl Into<String>) -> Self {
89 Self {
90 prompt: prompt.into(),
91 model: None,
92 system_prompt: None,
93 append_system_prompt: None,
94 output_format: None,
95 max_budget_usd: None,
96 permission_mode: None,
97 allowed_tools: Vec::new(),
98 disallowed_tools: Vec::new(),
99 mcp_config: Vec::new(),
100 add_dir: Vec::new(),
101 effort: None,
102 max_turns: None,
103 json_schema: None,
104 continue_session: false,
105 resume: None,
106 session_id: None,
107 fallback_model: None,
108 no_session_persistence: false,
109 dangerously_skip_permissions: false,
110 agent: None,
111 agents_json: None,
112 tools: Vec::new(),
113 file: Vec::new(),
114 include_partial_messages: false,
115 input_format: None,
116 strict_mcp_config: false,
117 settings: None,
118 fork_session: false,
119 retry_policy: None,
120 worktree: false,
121 worktree_name: None,
122 brief: false,
123 debug_filter: None,
124 debug_file: None,
125 betas: None,
126 plugin_dirs: Vec::new(),
127 plugin_urls: Vec::new(),
128 setting_sources: None,
129 tmux: false,
130 bare: false,
131 safe_mode: false,
132 disable_slash_commands: false,
133 include_hook_events: false,
134 exclude_dynamic_system_prompt_sections: false,
135 name: None,
136 from_pr: None,
137 prompt_via_stdin: false,
138 verbose: false,
139 prompt_suggestions: false,
140 replay_user_messages: false,
141 }
142 }
143
144 #[must_use]
146 pub fn model(mut self, model: impl Into<String>) -> Self {
147 self.model = Some(model.into());
148 self
149 }
150
151 #[must_use]
153 pub fn system_prompt(mut self, prompt: impl Into<String>) -> Self {
154 self.system_prompt = Some(prompt.into());
155 self
156 }
157
158 #[must_use]
160 pub fn append_system_prompt(mut self, prompt: impl Into<String>) -> Self {
161 self.append_system_prompt = Some(prompt.into());
162 self
163 }
164
165 #[must_use]
167 pub fn output_format(mut self, format: OutputFormat) -> Self {
168 self.output_format = Some(format);
169 self
170 }
171
172 #[must_use]
174 pub fn max_budget_usd(mut self, budget: f64) -> Self {
175 self.max_budget_usd = Some(budget);
176 self
177 }
178
179 #[must_use]
181 pub fn permission_mode(mut self, mode: PermissionMode) -> Self {
182 self.permission_mode = Some(mode);
183 self
184 }
185
186 #[must_use]
202 pub fn allowed_tools<I, T>(mut self, tools: I) -> Self
203 where
204 I: IntoIterator<Item = T>,
205 T: Into<ToolPattern>,
206 {
207 self.allowed_tools.extend(tools.into_iter().map(Into::into));
208 self
209 }
210
211 #[must_use]
213 pub fn allowed_tool(mut self, tool: impl Into<ToolPattern>) -> Self {
214 self.allowed_tools.push(tool.into());
215 self
216 }
217
218 #[must_use]
220 pub fn disallowed_tools<I, T>(mut self, tools: I) -> Self
221 where
222 I: IntoIterator<Item = T>,
223 T: Into<ToolPattern>,
224 {
225 self.disallowed_tools
226 .extend(tools.into_iter().map(Into::into));
227 self
228 }
229
230 #[must_use]
232 pub fn disallowed_tool(mut self, tool: impl Into<ToolPattern>) -> Self {
233 self.disallowed_tools.push(tool.into());
234 self
235 }
236
237 #[must_use]
239 pub fn mcp_config(mut self, path: impl Into<String>) -> Self {
240 self.mcp_config.push(path.into());
241 self
242 }
243
244 #[must_use]
246 pub fn add_dir(mut self, dir: impl Into<String>) -> Self {
247 self.add_dir.push(dir.into());
248 self
249 }
250
251 #[must_use]
253 pub fn effort(mut self, effort: Effort) -> Self {
254 self.effort = Some(effort);
255 self
256 }
257
258 #[must_use]
260 pub fn max_turns(mut self, turns: u32) -> Self {
261 self.max_turns = Some(turns);
262 self
263 }
264
265 #[must_use]
267 pub fn json_schema(mut self, schema: impl Into<String>) -> Self {
268 self.json_schema = Some(schema.into());
269 self
270 }
271
272 #[must_use]
274 pub fn continue_session(mut self) -> Self {
275 self.continue_session = true;
276 self
277 }
278
279 #[must_use]
281 pub fn resume(mut self, session_id: impl Into<String>) -> Self {
282 self.resume = Some(session_id.into());
283 self
284 }
285
286 #[must_use]
288 pub fn session_id(mut self, id: impl Into<String>) -> Self {
289 self.session_id = Some(id.into());
290 self
291 }
292
293 #[cfg(all(feature = "json", feature = "async"))]
301 pub(crate) fn replace_session(mut self, id: impl Into<String>) -> Self {
302 self.continue_session = false;
303 self.resume = Some(id.into());
304 self.session_id = None;
305 self.fork_session = false;
306 self
307 }
308
309 #[must_use]
311 pub fn fallback_model(mut self, model: impl Into<String>) -> Self {
312 self.fallback_model = Some(model.into());
313 self
314 }
315
316 #[must_use]
318 pub fn no_session_persistence(mut self) -> Self {
319 self.no_session_persistence = true;
320 self
321 }
322
323 #[must_use]
325 pub fn dangerously_skip_permissions(mut self) -> Self {
326 self.dangerously_skip_permissions = true;
327 self
328 }
329
330 #[must_use]
344 pub fn agent(mut self, agent: impl Into<String>) -> Self {
345 self.agent = Some(agent.into());
346 self
347 }
348
349 #[must_use]
361 pub fn agents_json(mut self, json: impl Into<String>) -> Self {
362 self.agents_json = Some(json.into());
363 self
364 }
365
366 #[must_use]
372 pub fn tools(mut self, tools: impl IntoIterator<Item = impl Into<String>>) -> Self {
373 self.tools.extend(tools.into_iter().map(Into::into));
374 self
375 }
376
377 #[must_use]
381 pub fn file(mut self, spec: impl Into<String>) -> Self {
382 self.file.push(spec.into());
383 self
384 }
385
386 #[must_use]
390 pub fn include_partial_messages(mut self) -> Self {
391 self.include_partial_messages = true;
392 self
393 }
394
395 #[must_use]
397 pub fn input_format(mut self, format: InputFormat) -> Self {
398 self.input_format = Some(format);
399 self
400 }
401
402 #[must_use]
404 pub fn strict_mcp_config(mut self) -> Self {
405 self.strict_mcp_config = true;
406 self
407 }
408
409 #[must_use]
411 pub fn settings(mut self, settings: impl Into<String>) -> Self {
412 self.settings = Some(settings.into());
413 self
414 }
415
416 #[must_use]
418 pub fn fork_session(mut self) -> Self {
419 self.fork_session = true;
420 self
421 }
422
423 #[must_use]
425 pub fn worktree(mut self) -> Self {
426 self.worktree = true;
427 self
428 }
429
430 #[must_use]
453 pub fn worktree_named(mut self, name: impl Into<String>) -> Self {
454 self.worktree = true;
455 self.worktree_name = Some(name.into());
456 self
457 }
458
459 #[must_use]
461 pub fn brief(mut self) -> Self {
462 self.brief = true;
463 self
464 }
465
466 #[must_use]
468 pub fn debug_filter(mut self, filter: impl Into<String>) -> Self {
469 self.debug_filter = Some(filter.into());
470 self
471 }
472
473 #[must_use]
475 pub fn debug_file(mut self, path: impl Into<String>) -> Self {
476 self.debug_file = Some(path.into());
477 self
478 }
479
480 #[must_use]
482 pub fn betas(mut self, betas: impl Into<String>) -> Self {
483 self.betas = Some(betas.into());
484 self
485 }
486
487 #[must_use]
489 pub fn plugin_dir(mut self, dir: impl Into<String>) -> Self {
490 self.plugin_dirs.push(dir.into());
491 self
492 }
493
494 #[must_use]
498 pub fn plugin_url(mut self, url: impl Into<String>) -> Self {
499 self.plugin_urls.push(url.into());
500 self
501 }
502
503 #[must_use]
505 pub fn setting_sources(mut self, sources: impl Into<String>) -> Self {
506 self.setting_sources = Some(sources.into());
507 self
508 }
509
510 #[must_use]
512 pub fn tmux(mut self) -> Self {
513 self.tmux = true;
514 self
515 }
516
517 #[must_use]
533 pub fn bare(mut self) -> Self {
534 self.bare = true;
535 self
536 }
537
538 #[must_use]
540 pub fn disable_slash_commands(mut self) -> Self {
541 self.disable_slash_commands = true;
542 self
543 }
544
545 #[must_use]
554 pub fn safe_mode(mut self) -> Self {
555 self.safe_mode = true;
556 self
557 }
558
559 #[must_use]
563 pub fn include_hook_events(mut self) -> Self {
564 self.include_hook_events = true;
565 self
566 }
567
568 #[must_use]
574 pub fn exclude_dynamic_system_prompt_sections(mut self) -> Self {
575 self.exclude_dynamic_system_prompt_sections = true;
576 self
577 }
578
579 #[must_use]
582 pub fn name(mut self, name: impl Into<String>) -> Self {
583 self.name = Some(name.into());
584 self
585 }
586
587 #[must_use]
594 pub fn from_pr(mut self, pr: impl Into<String>) -> Self {
595 self.from_pr = Some(pr.into());
596 self
597 }
598
599 #[must_use]
607 pub fn verbose(mut self, value: bool) -> Self {
608 self.verbose = value;
609 self
610 }
611
612 #[must_use]
618 pub fn prompt_suggestions(mut self, value: bool) -> Self {
619 self.prompt_suggestions = value;
620 self
621 }
622
623 #[must_use]
631 pub fn replay_user_messages(mut self, value: bool) -> Self {
632 self.replay_user_messages = value;
633 self
634 }
635
636 #[must_use]
659 pub fn retry(mut self, policy: crate::retry::RetryPolicy) -> Self {
660 self.retry_policy = Some(policy);
661 self
662 }
663
664 pub fn to_command_string(&self, claude: &Claude) -> String {
687 let args = self.build_args();
688 let quoted_args = args.iter().map(|arg| shell_quote(arg)).collect::<Vec<_>>();
689 format!("{} {}", claude.binary().display(), quoted_args.join(" "))
690 }
691
692 #[cfg(all(feature = "json", feature = "async"))]
697 pub async fn execute_json(&self, claude: &Claude) -> Result<crate::types::QueryResult> {
698 let args = self.build_args_with_forced_json();
699
700 let output = if self.prompt_via_stdin {
701 exec::run_claude_with_stdin_prompt(claude, args, self.prompt.clone()).await?
704 } else {
705 exec::run_claude_with_retry(claude, args, self.retry_policy.as_ref()).await?
706 };
707
708 serde_json::from_str(&output.stdout).map_err(|e| crate::error::Error::Json {
709 message: format!("failed to parse query result: {e}"),
710 source: e,
711 })
712 }
713
714 #[cfg(feature = "sync")]
721 pub fn execute_sync(&self, claude: &Claude) -> Result<CommandOutput> {
722 if self.prompt_via_stdin {
723 exec::run_claude_with_stdin_prompt_sync(claude, self.build_args(), self.prompt.clone())
726 } else {
727 exec::run_claude_with_retry_sync(claude, self.args(), self.retry_policy.as_ref())
728 }
729 }
730
731 #[cfg(all(feature = "sync", feature = "json"))]
733 pub fn execute_json_sync(&self, claude: &Claude) -> Result<crate::types::QueryResult> {
734 let args = self.build_args_with_forced_json();
735
736 let output = if self.prompt_via_stdin {
737 exec::run_claude_with_stdin_prompt_sync(claude, args, self.prompt.clone())?
740 } else {
741 exec::run_claude_with_retry_sync(claude, args, self.retry_policy.as_ref())?
742 };
743
744 serde_json::from_str(&output.stdout).map_err(|e| crate::error::Error::Json {
745 message: format!("failed to parse query result: {e}"),
746 source: e,
747 })
748 }
749
750 #[must_use]
776 pub fn prompt_via_stdin(mut self, value: bool) -> Self {
777 self.prompt_via_stdin = value;
778 self
779 }
780
781 fn build_args_with_forced_json(&self) -> Vec<String> {
789 if self.output_format.is_some() {
790 return self.build_args();
791 }
792 let mut effective = self.clone();
793 effective.output_format = Some(OutputFormat::Json);
794 effective.build_args()
795 }
796
797 fn build_args(&self) -> Vec<String> {
798 let mut args = vec!["--print".to_string()];
799
800 if let Some(ref model) = self.model {
801 args.push("--model".to_string());
802 args.push(model.clone());
803 }
804
805 if let Some(ref prompt) = self.system_prompt {
806 args.push("--system-prompt".to_string());
807 args.push(prompt.clone());
808 }
809
810 if let Some(ref prompt) = self.append_system_prompt {
811 args.push("--append-system-prompt".to_string());
812 args.push(prompt.clone());
813 }
814
815 if let Some(ref format) = self.output_format {
816 args.push("--output-format".to_string());
817 args.push(format.as_arg().to_string());
818 }
819
820 if self.verbose || matches!(self.output_format, Some(OutputFormat::StreamJson)) {
824 args.push("--verbose".to_string());
825 }
826
827 if let Some(budget) = self.max_budget_usd {
828 args.push("--max-budget-usd".to_string());
829 args.push(budget.to_string());
830 }
831
832 if let Some(ref mode) = self.permission_mode {
833 args.push("--permission-mode".to_string());
834 args.push(mode.as_arg().to_string());
835 }
836
837 if !self.allowed_tools.is_empty() {
838 args.push("--allowed-tools".to_string());
839 args.push(join_patterns(&self.allowed_tools));
840 }
841
842 if !self.disallowed_tools.is_empty() {
843 args.push("--disallowed-tools".to_string());
844 args.push(join_patterns(&self.disallowed_tools));
845 }
846
847 for config in &self.mcp_config {
848 args.push("--mcp-config".to_string());
849 args.push(config.clone());
850 }
851
852 for dir in &self.add_dir {
853 args.push("--add-dir".to_string());
854 args.push(dir.clone());
855 }
856
857 if let Some(ref effort) = self.effort {
858 args.push("--effort".to_string());
859 args.push(effort.as_arg().to_string());
860 }
861
862 if let Some(turns) = self.max_turns {
863 args.push("--max-turns".to_string());
864 args.push(turns.to_string());
865 }
866
867 if let Some(ref schema) = self.json_schema {
868 args.push("--json-schema".to_string());
869 args.push(schema.clone());
870 }
871
872 if self.continue_session {
873 args.push("--continue".to_string());
874 }
875
876 if let Some(ref session_id) = self.resume {
877 args.push("--resume".to_string());
878 args.push(session_id.clone());
879 }
880
881 if let Some(ref id) = self.session_id {
882 args.push("--session-id".to_string());
883 args.push(id.clone());
884 }
885
886 if let Some(ref model) = self.fallback_model {
887 args.push("--fallback-model".to_string());
888 args.push(model.clone());
889 }
890
891 if self.no_session_persistence {
892 args.push("--no-session-persistence".to_string());
893 }
894
895 if self.dangerously_skip_permissions {
896 args.push("--dangerously-skip-permissions".to_string());
897 }
898
899 if let Some(ref agent) = self.agent {
900 args.push("--agent".to_string());
901 args.push(agent.clone());
902 }
903
904 if let Some(ref agents) = self.agents_json {
905 args.push("--agents".to_string());
906 args.push(agents.clone());
907 }
908
909 if !self.tools.is_empty() {
910 args.push("--tools".to_string());
911 args.push(self.tools.join(","));
912 }
913
914 for spec in &self.file {
915 args.push("--file".to_string());
916 args.push(spec.clone());
917 }
918
919 if self.include_partial_messages {
920 args.push("--include-partial-messages".to_string());
921 }
922
923 if let Some(ref format) = self.input_format {
924 args.push("--input-format".to_string());
925 args.push(format.as_arg().to_string());
926 }
927
928 if self.strict_mcp_config {
929 args.push("--strict-mcp-config".to_string());
930 }
931
932 if let Some(ref settings) = self.settings {
933 args.push("--settings".to_string());
934 args.push(settings.clone());
935 }
936
937 if self.fork_session {
938 args.push("--fork-session".to_string());
939 }
940
941 if self.worktree {
942 args.push("--worktree".to_string());
943 if let Some(ref name) = self.worktree_name {
944 args.push(name.clone());
945 }
946 }
947
948 if self.brief {
949 args.push("--brief".to_string());
950 }
951
952 if let Some(ref filter) = self.debug_filter {
953 args.push("--debug".to_string());
954 args.push(filter.clone());
955 }
956
957 if let Some(ref path) = self.debug_file {
958 args.push("--debug-file".to_string());
959 args.push(path.clone());
960 }
961
962 if let Some(ref betas) = self.betas {
963 args.push("--betas".to_string());
964 args.push(betas.clone());
965 }
966
967 for dir in &self.plugin_dirs {
968 args.push("--plugin-dir".to_string());
969 args.push(dir.clone());
970 }
971
972 for url in &self.plugin_urls {
973 args.push("--plugin-url".to_string());
974 args.push(url.clone());
975 }
976
977 if let Some(ref sources) = self.setting_sources {
978 args.push("--setting-sources".to_string());
979 args.push(sources.clone());
980 }
981
982 if self.tmux {
983 args.push("--tmux".to_string());
984 }
985
986 if self.bare {
987 args.push("--bare".to_string());
988 }
989
990 if self.safe_mode {
991 args.push("--safe-mode".to_string());
992 }
993
994 if self.disable_slash_commands {
995 args.push("--disable-slash-commands".to_string());
996 }
997
998 if self.include_hook_events {
999 args.push("--include-hook-events".to_string());
1000 }
1001
1002 if self.exclude_dynamic_system_prompt_sections {
1003 args.push("--exclude-dynamic-system-prompt-sections".to_string());
1004 }
1005
1006 if self.prompt_suggestions {
1007 args.push("--prompt-suggestions".to_string());
1008 }
1009
1010 if self.replay_user_messages {
1011 args.push("--replay-user-messages".to_string());
1012 }
1013
1014 if let Some(ref name) = self.name {
1015 args.push("--name".to_string());
1016 args.push(name.clone());
1017 }
1018
1019 if let Some(ref pr) = self.from_pr {
1020 args.push("--from-pr".to_string());
1021 args.push(pr.clone());
1022 }
1023
1024 if !self.prompt_via_stdin {
1028 args.push("--".to_string());
1029 args.push(self.prompt.clone());
1030 }
1031
1032 args
1033 }
1034}
1035
1036impl ClaudeCommand for QueryCommand {
1037 type Output = CommandOutput;
1038
1039 fn args(&self) -> Vec<String> {
1040 self.build_args()
1041 }
1042
1043 #[cfg(feature = "async")]
1044 async fn execute(&self, claude: &Claude) -> Result<CommandOutput> {
1045 if self.prompt_via_stdin {
1046 let args = self.build_args(); exec::run_claude_with_stdin_prompt(claude, args, self.prompt.clone()).await
1050 } else {
1051 exec::run_claude_with_retry(claude, self.args(), self.retry_policy.as_ref()).await
1052 }
1053 }
1054}
1055
1056fn shell_quote(arg: &str) -> String {
1058 if arg.contains(|c: char| c.is_whitespace() || "\"'$\\`|;<>&()[]{}".contains(c)) {
1060 format!("'{}'", arg.replace("'", "'\\''"))
1062 } else {
1063 arg.to_string()
1064 }
1065}
1066
1067fn join_patterns(patterns: &[ToolPattern]) -> String {
1068 let mut out = String::new();
1069 for (i, p) in patterns.iter().enumerate() {
1070 if i > 0 {
1071 out.push(',');
1072 }
1073 out.push_str(p.as_str());
1074 }
1075 out
1076}
1077
1078#[cfg(test)]
1079mod tests {
1080 use super::*;
1081
1082 #[test]
1083 fn test_basic_query_args() {
1084 let cmd = QueryCommand::new("hello world");
1085 let args = cmd.args();
1086 assert_eq!(args, vec!["--print", "--", "hello world"]);
1087 }
1088
1089 #[test]
1090 fn prompt_via_stdin_omits_prompt_from_args() {
1091 let cmd = QueryCommand::new("secret payload").prompt_via_stdin(true);
1092 let args = cmd.args();
1093 assert!(
1094 !args.contains(&"secret payload".to_string()),
1095 "prompt must not appear in args when prompt_via_stdin is set"
1096 );
1097 assert!(
1098 !args.contains(&"--".to_string()),
1099 "-- separator must be absent when prompt_via_stdin is set"
1100 );
1101 }
1102
1103 #[test]
1104 fn prompt_via_stdin_false_keeps_prompt_in_args() {
1105 let cmd = QueryCommand::new("visible prompt").prompt_via_stdin(false);
1106 let args = cmd.args();
1107 assert!(
1108 args.contains(&"visible prompt".to_string()),
1109 "prompt must still appear in args when prompt_via_stdin is false"
1110 );
1111 assert!(
1112 args.contains(&"--".to_string()),
1113 "-- separator must be present when prompt_via_stdin is false"
1114 );
1115 }
1116
1117 #[test]
1118 #[cfg(feature = "async")] #[ignore = "requires a real claude binary"]
1120 fn prompt_via_stdin_integration() {
1121 use crate::{Claude, ClaudeCommand};
1124 let rt = tokio::runtime::Runtime::new().unwrap();
1125 rt.block_on(async {
1126 let claude = Claude::builder().build().unwrap();
1127 let out = QueryCommand::new("reply with: STDIN_OK")
1128 .prompt_via_stdin(true)
1129 .execute(&claude)
1130 .await
1131 .unwrap();
1132 assert!(
1133 !out.stdout.is_empty(),
1134 "expected non-empty output from stdin-mode query"
1135 );
1136 });
1137 }
1138
1139 #[test]
1140 fn build_args_with_forced_json_inserts_flag_before_separator() {
1141 let cmd = QueryCommand::new("hello");
1147 let args = cmd.build_args_with_forced_json();
1148
1149 assert_eq!(
1151 &args[args.len() - 2..],
1152 &["--".to_string(), "hello".to_string()],
1153 );
1154
1155 let sep = args.iter().position(|a| a == "--").expect("`--` present");
1157 let fmt = args
1158 .iter()
1159 .position(|a| a == "--output-format")
1160 .expect("--output-format present");
1161 assert!(
1162 fmt < sep,
1163 "--output-format must come before `--` separator; got {args:?}"
1164 );
1165 assert_eq!(args[fmt + 1], "json");
1166 }
1167
1168 #[test]
1169 fn build_args_with_forced_json_respects_explicit_format() {
1170 let cmd = QueryCommand::new("hello").output_format(OutputFormat::Text);
1173 let args = cmd.build_args_with_forced_json();
1174 let fmt = args
1175 .iter()
1176 .position(|a| a == "--output-format")
1177 .expect("--output-format present");
1178 assert_eq!(args[fmt + 1], "text");
1179 assert_eq!(args.iter().filter(|a| *a == "--output-format").count(), 1);
1181 }
1182
1183 #[test]
1184 #[allow(deprecated)] fn test_full_query_args() {
1186 let cmd = QueryCommand::new("explain this")
1187 .model("sonnet")
1188 .system_prompt("be concise")
1189 .output_format(OutputFormat::Json)
1190 .max_budget_usd(0.50)
1191 .permission_mode(PermissionMode::BypassPermissions)
1192 .allowed_tools(["Bash", "Read"])
1193 .mcp_config("/tmp/mcp.json")
1194 .effort(Effort::High)
1195 .max_turns(3)
1196 .no_session_persistence();
1197
1198 let args = cmd.args();
1199 assert!(args.contains(&"--print".to_string()));
1200 assert!(args.contains(&"--model".to_string()));
1201 assert!(args.contains(&"sonnet".to_string()));
1202 assert!(args.contains(&"--system-prompt".to_string()));
1203 assert!(args.contains(&"--output-format".to_string()));
1204 assert!(args.contains(&"json".to_string()));
1205 assert!(!args.contains(&"--verbose".to_string()));
1207 assert!(args.contains(&"--max-budget-usd".to_string()));
1208 assert!(args.contains(&"--permission-mode".to_string()));
1209 assert!(args.contains(&"bypassPermissions".to_string()));
1210 assert!(args.contains(&"--allowed-tools".to_string()));
1211 assert!(args.contains(&"Bash,Read".to_string()));
1212 assert!(args.contains(&"--effort".to_string()));
1213 assert!(args.contains(&"high".to_string()));
1214 assert!(args.contains(&"--max-turns".to_string()));
1215 assert!(args.contains(&"--no-session-persistence".to_string()));
1216 assert_eq!(args.last().unwrap(), "explain this");
1218 assert_eq!(args[args.len() - 2], "--");
1219 }
1220
1221 #[test]
1222 fn typed_patterns_render_in_allowed_tools() {
1223 use crate::ToolPattern;
1224
1225 let cmd = QueryCommand::new("hi")
1226 .allowed_tool(ToolPattern::tool("Read"))
1227 .allowed_tool(ToolPattern::tool_with_args("Bash", "git log:*"))
1228 .allowed_tool(ToolPattern::all("Write"))
1229 .allowed_tool(ToolPattern::mcp("srv", "*"));
1230
1231 let args = cmd.args();
1232 let joined = args
1233 .iter()
1234 .position(|a| a == "--allowed-tools")
1235 .map(|i| &args[i + 1])
1236 .unwrap();
1237 assert_eq!(joined, "Read,Bash(git log:*),Write(*),mcp__srv__*");
1238 }
1239
1240 #[test]
1241 fn disallowed_tool_singular_appends() {
1242 use crate::ToolPattern;
1243
1244 let cmd = QueryCommand::new("hi")
1245 .disallowed_tool("Write")
1246 .disallowed_tool(ToolPattern::tool_with_args("Bash", "rm*"));
1247
1248 let args = cmd.args();
1249 let joined = args
1250 .iter()
1251 .position(|a| a == "--disallowed-tools")
1252 .map(|i| &args[i + 1])
1253 .unwrap();
1254 assert_eq!(joined, "Write,Bash(rm*)");
1255 }
1256
1257 #[test]
1258 fn mixed_string_and_typed_patterns_both_accepted() {
1259 use crate::ToolPattern;
1260
1261 let strs: Vec<ToolPattern> = vec!["Bash".into(), ToolPattern::all("Read")];
1265 let cmd = QueryCommand::new("hi").allowed_tools(strs);
1266 assert!(cmd.args().contains(&"--allowed-tools".to_string()));
1267 }
1268
1269 #[test]
1270 fn new_bool_flags_emit_correct_cli_args() {
1271 let args = QueryCommand::new("hi")
1272 .bare()
1273 .disable_slash_commands()
1274 .include_hook_events()
1275 .exclude_dynamic_system_prompt_sections()
1276 .args();
1277 assert!(args.contains(&"--bare".to_string()));
1278 assert!(args.contains(&"--disable-slash-commands".to_string()));
1279 assert!(args.contains(&"--include-hook-events".to_string()));
1280 assert!(args.contains(&"--exclude-dynamic-system-prompt-sections".to_string()));
1281 }
1282
1283 #[test]
1284 fn name_flag_renders_with_value() {
1285 let args = QueryCommand::new("hi").name("my session").args();
1286 let pos = args.iter().position(|a| a == "--name").unwrap();
1287 assert_eq!(args[pos + 1], "my session");
1288 }
1289
1290 #[test]
1291 fn from_pr_flag_renders_with_value() {
1292 let args = QueryCommand::new("hi").from_pr("42").args();
1293 let pos = args.iter().position(|a| a == "--from-pr").unwrap();
1294 assert_eq!(args[pos + 1], "42");
1295 }
1296
1297 #[test]
1298 fn new_bool_flags_default_to_off() {
1299 let args = QueryCommand::new("hi").args();
1300 assert!(!args.contains(&"--bare".to_string()));
1301 assert!(!args.contains(&"--disable-slash-commands".to_string()));
1302 assert!(!args.contains(&"--include-hook-events".to_string()));
1303 assert!(!args.contains(&"--exclude-dynamic-system-prompt-sections".to_string()));
1304 assert!(!args.contains(&"--name".to_string()));
1305 }
1306
1307 #[test]
1308 fn test_separator_before_prompt_prevents_greedy_flag_parsing() {
1309 let cmd = QueryCommand::new("fix the bug")
1312 .allowed_tools(["Read", "Edit", "Bash(cargo *)"])
1313 .output_format(OutputFormat::StreamJson);
1314 let args = cmd.args();
1315 let sep_pos = args.iter().position(|a| a == "--").unwrap();
1317 let prompt_pos = args.iter().position(|a| a == "fix the bug").unwrap();
1318 assert_eq!(prompt_pos, sep_pos + 1, "prompt must follow -- separator");
1319 let tools_pos = args
1321 .iter()
1322 .position(|a| a.contains("Bash(cargo *)"))
1323 .unwrap();
1324 assert!(
1325 tools_pos < sep_pos,
1326 "allowed-tools must come before -- separator"
1327 );
1328 }
1329
1330 #[test]
1331 fn test_stream_json_includes_verbose() {
1332 let cmd = QueryCommand::new("test").output_format(OutputFormat::StreamJson);
1333 let args = cmd.args();
1334 assert!(args.contains(&"--output-format".to_string()));
1335 assert!(args.contains(&"stream-json".to_string()));
1336 assert!(args.contains(&"--verbose".to_string()));
1337 }
1338
1339 #[test]
1340 fn verbose_flag_emitted_when_set() {
1341 let args = QueryCommand::new("test").verbose(true).args();
1342 assert!(args.contains(&"--verbose".to_string()));
1343 }
1344
1345 #[test]
1346 fn verbose_absent_by_default_and_when_false() {
1347 assert!(
1348 !QueryCommand::new("test")
1349 .args()
1350 .contains(&"--verbose".to_string())
1351 );
1352 assert!(
1353 !QueryCommand::new("test")
1354 .verbose(false)
1355 .args()
1356 .contains(&"--verbose".to_string())
1357 );
1358 }
1359
1360 #[test]
1361 fn verbose_not_duplicated_with_stream_json() {
1362 let cmd = QueryCommand::new("test")
1365 .verbose(true)
1366 .output_format(OutputFormat::StreamJson);
1367 let count = cmd.args().iter().filter(|a| *a == "--verbose").count();
1368 assert_eq!(count, 1, "--verbose must appear exactly once");
1369 }
1370
1371 #[test]
1372 fn prompt_suggestions_flag_emitted_when_set() {
1373 let args = QueryCommand::new("test").prompt_suggestions(true).args();
1374 assert!(args.contains(&"--prompt-suggestions".to_string()));
1375 let sep = args.iter().position(|a| a == "--").unwrap();
1378 let flag = args
1379 .iter()
1380 .position(|a| a == "--prompt-suggestions")
1381 .unwrap();
1382 assert!(flag < sep, "--prompt-suggestions must precede `--`");
1383 }
1384
1385 #[test]
1386 fn prompt_suggestions_absent_by_default_and_when_false() {
1387 assert!(
1388 !QueryCommand::new("test")
1389 .args()
1390 .contains(&"--prompt-suggestions".to_string())
1391 );
1392 assert!(
1393 !QueryCommand::new("test")
1394 .prompt_suggestions(false)
1395 .args()
1396 .contains(&"--prompt-suggestions".to_string())
1397 );
1398 }
1399
1400 #[test]
1401 fn replay_user_messages_flag_emitted_when_set() {
1402 let args = QueryCommand::new("test").replay_user_messages(true).args();
1403 assert!(args.contains(&"--replay-user-messages".to_string()));
1404 }
1405
1406 #[test]
1407 fn replay_user_messages_absent_by_default_and_when_false() {
1408 assert!(
1409 !QueryCommand::new("test")
1410 .args()
1411 .contains(&"--replay-user-messages".to_string())
1412 );
1413 assert!(
1414 !QueryCommand::new("test")
1415 .replay_user_messages(false)
1416 .args()
1417 .contains(&"--replay-user-messages".to_string())
1418 );
1419 }
1420
1421 #[test]
1422 fn test_to_command_string_simple() {
1423 let claude = Claude::builder()
1424 .binary("/usr/local/bin/claude")
1425 .build()
1426 .unwrap();
1427
1428 let cmd = QueryCommand::new("hello");
1429 let command_str = cmd.to_command_string(&claude);
1430
1431 assert!(command_str.starts_with("/usr/local/bin/claude"));
1432 assert!(command_str.contains("--print"));
1433 assert!(command_str.contains("hello"));
1434 }
1435
1436 #[test]
1437 fn test_to_command_string_with_spaces() {
1438 let claude = Claude::builder()
1439 .binary("/usr/local/bin/claude")
1440 .build()
1441 .unwrap();
1442
1443 let cmd = QueryCommand::new("hello world").model("sonnet");
1444 let command_str = cmd.to_command_string(&claude);
1445
1446 assert!(command_str.starts_with("/usr/local/bin/claude"));
1447 assert!(command_str.contains("--print"));
1448 assert!(command_str.contains("'hello world'"));
1450 assert!(command_str.contains("--model"));
1451 assert!(command_str.contains("sonnet"));
1452 }
1453
1454 #[test]
1455 fn test_to_command_string_with_special_chars() {
1456 let claude = Claude::builder()
1457 .binary("/usr/local/bin/claude")
1458 .build()
1459 .unwrap();
1460
1461 let cmd = QueryCommand::new("test $VAR and `cmd`");
1462 let command_str = cmd.to_command_string(&claude);
1463
1464 assert!(command_str.contains("'test $VAR and `cmd`'"));
1466 }
1467
1468 #[test]
1469 fn test_to_command_string_with_single_quotes() {
1470 let claude = Claude::builder()
1471 .binary("/usr/local/bin/claude")
1472 .build()
1473 .unwrap();
1474
1475 let cmd = QueryCommand::new("it's");
1476 let command_str = cmd.to_command_string(&claude);
1477
1478 assert!(command_str.contains("'it'\\''s'"));
1480 }
1481
1482 #[test]
1483 fn test_worktree_flag() {
1484 let cmd = QueryCommand::new("test").worktree();
1485 let args = cmd.args();
1486 assert!(args.contains(&"--worktree".to_string()));
1487 }
1488
1489 #[test]
1490 fn test_worktree_named() {
1491 let cmd = QueryCommand::new("test").worktree_named("feature-x");
1492 let args = cmd.args();
1493 assert!(
1494 args.windows(2).any(|w| w == ["--worktree", "feature-x"]),
1495 "missing --worktree feature-x in {args:?}"
1496 );
1497 }
1498
1499 #[test]
1500 fn test_brief_flag() {
1501 let cmd = QueryCommand::new("test").brief();
1502 let args = cmd.args();
1503 assert!(args.contains(&"--brief".to_string()));
1504 }
1505
1506 #[test]
1507 fn test_debug_filter() {
1508 let cmd = QueryCommand::new("test").debug_filter("api,hooks");
1509 let args = cmd.args();
1510 assert!(args.contains(&"--debug".to_string()));
1511 assert!(args.contains(&"api,hooks".to_string()));
1512 }
1513
1514 #[test]
1515 fn test_debug_file() {
1516 let cmd = QueryCommand::new("test").debug_file("/tmp/debug.log");
1517 let args = cmd.args();
1518 assert!(args.contains(&"--debug-file".to_string()));
1519 assert!(args.contains(&"/tmp/debug.log".to_string()));
1520 }
1521
1522 #[test]
1523 fn test_betas() {
1524 let cmd = QueryCommand::new("test").betas("feature-x");
1525 let args = cmd.args();
1526 assert!(args.contains(&"--betas".to_string()));
1527 assert!(args.contains(&"feature-x".to_string()));
1528 }
1529
1530 #[test]
1531 fn test_plugin_dir_single() {
1532 let cmd = QueryCommand::new("test").plugin_dir("/plugins/foo");
1533 let args = cmd.args();
1534 assert!(args.contains(&"--plugin-dir".to_string()));
1535 assert!(args.contains(&"/plugins/foo".to_string()));
1536 }
1537
1538 #[test]
1539 fn test_plugin_dir_multiple() {
1540 let cmd = QueryCommand::new("test")
1541 .plugin_dir("/plugins/foo")
1542 .plugin_dir("/plugins/bar");
1543 let args = cmd.args();
1544 let plugin_dir_count = args.iter().filter(|a| *a == "--plugin-dir").count();
1545 assert_eq!(plugin_dir_count, 2);
1546 assert!(args.contains(&"/plugins/foo".to_string()));
1547 assert!(args.contains(&"/plugins/bar".to_string()));
1548 }
1549
1550 #[test]
1551 fn test_plugin_url_single() {
1552 let cmd = QueryCommand::new("test").plugin_url("https://example.com/p.zip");
1553 let args = cmd.args();
1554 assert!(args.contains(&"--plugin-url".to_string()));
1555 assert!(args.contains(&"https://example.com/p.zip".to_string()));
1556 }
1557
1558 #[test]
1559 fn test_plugin_url_multiple() {
1560 let cmd = QueryCommand::new("test")
1561 .plugin_url("https://example.com/a.zip")
1562 .plugin_url("https://example.com/b.zip");
1563 let args = cmd.args();
1564 let plugin_url_count = args.iter().filter(|a| *a == "--plugin-url").count();
1565 assert_eq!(plugin_url_count, 2);
1566 assert!(args.contains(&"https://example.com/a.zip".to_string()));
1567 assert!(args.contains(&"https://example.com/b.zip".to_string()));
1568 }
1569
1570 #[test]
1571 fn test_safe_mode_flag() {
1572 let cmd = QueryCommand::new("test").safe_mode();
1573 let args = cmd.args();
1574 assert!(args.contains(&"--safe-mode".to_string()));
1575 }
1576
1577 #[test]
1578 fn test_safe_mode_absent_by_default() {
1579 let cmd = QueryCommand::new("test");
1580 let args = cmd.args();
1581 assert!(!args.contains(&"--safe-mode".to_string()));
1582 }
1583
1584 #[test]
1585 fn test_setting_sources() {
1586 let cmd = QueryCommand::new("test").setting_sources("user,project,local");
1587 let args = cmd.args();
1588 assert!(args.contains(&"--setting-sources".to_string()));
1589 assert!(args.contains(&"user,project,local".to_string()));
1590 }
1591
1592 #[test]
1593 fn test_tmux_flag() {
1594 let cmd = QueryCommand::new("test").tmux();
1595 let args = cmd.args();
1596 assert!(args.contains(&"--tmux".to_string()));
1597 }
1598
1599 #[test]
1602 fn shell_quote_plain_word_is_unchanged() {
1603 assert_eq!(shell_quote("simple"), "simple");
1604 assert_eq!(shell_quote(""), "");
1605 assert_eq!(shell_quote("file.rs"), "file.rs");
1606 }
1607
1608 #[test]
1609 fn shell_quote_whitespace_gets_single_quoted() {
1610 assert_eq!(shell_quote("hello world"), "'hello world'");
1611 assert_eq!(shell_quote("a\tb"), "'a\tb'");
1612 }
1613
1614 #[test]
1615 fn shell_quote_metacharacters_get_quoted() {
1616 assert_eq!(shell_quote("a|b"), "'a|b'");
1617 assert_eq!(shell_quote("$VAR"), "'$VAR'");
1618 assert_eq!(shell_quote("a;b"), "'a;b'");
1619 assert_eq!(shell_quote("(x)"), "'(x)'");
1620 }
1621
1622 #[test]
1623 fn shell_quote_embedded_single_quote_is_escaped() {
1624 assert_eq!(shell_quote("it's"), "'it'\\''s'");
1625 }
1626
1627 #[test]
1628 fn shell_quote_double_quote_gets_single_quoted() {
1629 assert_eq!(shell_quote(r#"say "hi""#), r#"'say "hi"'"#);
1630 }
1631}