1use crate::Claude;
2use crate::command::ClaudeCommand;
3use crate::command::spawn_args::SharedSpawnArgs;
4use crate::error::Result;
5use crate::exec::{self, CommandOutput};
6use crate::tool_pattern::ToolPattern;
7use crate::types::{Effort, InputFormat, OutputFormat, PermissionMode};
8
9#[derive(Debug, Clone)]
32pub struct QueryCommand {
33 prompt: String,
34 shared: SharedSpawnArgs,
37 output_format: Option<OutputFormat>,
38 tools: Vec<String>,
39 file: Vec<String>,
40 include_partial_messages: bool,
41 input_format: Option<InputFormat>,
42 settings: Option<String>,
43 fork_session: bool,
44 retry_policy: Option<crate::retry::RetryPolicy>,
45 brief: bool,
46 debug_filter: Option<String>,
47 debug_file: Option<String>,
48 betas: Option<String>,
49 plugin_dirs: Vec<String>,
50 plugin_urls: Vec<String>,
51 setting_sources: Option<String>,
52 tmux: bool,
53 bare: bool,
54 safe_mode: bool,
55 disable_slash_commands: bool,
56 include_hook_events: bool,
57 exclude_dynamic_system_prompt_sections: bool,
58 name: Option<String>,
59 from_pr: Option<String>,
60 prompt_via_stdin: bool,
61 verbose: bool,
62 prompt_suggestions: bool,
63 replay_user_messages: bool,
64}
65
66impl QueryCommand {
67 #[must_use]
69 pub fn new(prompt: impl Into<String>) -> Self {
70 Self {
71 prompt: prompt.into(),
72 shared: SharedSpawnArgs::default(),
73 output_format: None,
74 tools: Vec::new(),
75 file: Vec::new(),
76 include_partial_messages: false,
77 input_format: None,
78 settings: None,
79 fork_session: false,
80 retry_policy: None,
81 brief: false,
82 debug_filter: None,
83 debug_file: None,
84 betas: None,
85 plugin_dirs: Vec::new(),
86 plugin_urls: Vec::new(),
87 setting_sources: None,
88 tmux: false,
89 bare: false,
90 safe_mode: false,
91 disable_slash_commands: false,
92 include_hook_events: false,
93 exclude_dynamic_system_prompt_sections: false,
94 name: None,
95 from_pr: None,
96 prompt_via_stdin: false,
97 verbose: false,
98 prompt_suggestions: false,
99 replay_user_messages: false,
100 }
101 }
102
103 #[must_use]
105 pub fn model(mut self, model: impl Into<String>) -> Self {
106 self.shared.model = Some(model.into());
107 self
108 }
109
110 #[must_use]
112 pub fn system_prompt(mut self, prompt: impl Into<String>) -> Self {
113 self.shared.system_prompt = Some(prompt.into());
114 self
115 }
116
117 #[must_use]
119 pub fn append_system_prompt(mut self, prompt: impl Into<String>) -> Self {
120 self.shared.append_system_prompt = Some(prompt.into());
121 self
122 }
123
124 #[must_use]
126 pub fn output_format(mut self, format: OutputFormat) -> Self {
127 self.output_format = Some(format);
128 self
129 }
130
131 #[must_use]
133 pub fn max_budget_usd(mut self, budget: f64) -> Self {
134 self.shared.max_budget_usd = Some(budget);
135 self
136 }
137
138 #[must_use]
140 pub fn permission_mode(mut self, mode: PermissionMode) -> Self {
141 self.shared.permission_mode = Some(mode);
142 self
143 }
144
145 #[must_use]
161 pub fn allowed_tools<I, T>(mut self, tools: I) -> Self
162 where
163 I: IntoIterator<Item = T>,
164 T: Into<ToolPattern>,
165 {
166 self.shared
167 .allowed_tools
168 .extend(tools.into_iter().map(Into::into));
169 self
170 }
171
172 #[must_use]
174 pub fn allowed_tool(mut self, tool: impl Into<ToolPattern>) -> Self {
175 self.shared.allowed_tools.push(tool.into());
176 self
177 }
178
179 #[must_use]
181 pub fn disallowed_tools<I, T>(mut self, tools: I) -> Self
182 where
183 I: IntoIterator<Item = T>,
184 T: Into<ToolPattern>,
185 {
186 self.shared
187 .disallowed_tools
188 .extend(tools.into_iter().map(Into::into));
189 self
190 }
191
192 #[must_use]
194 pub fn disallowed_tool(mut self, tool: impl Into<ToolPattern>) -> Self {
195 self.shared.disallowed_tools.push(tool.into());
196 self
197 }
198
199 #[must_use]
201 pub fn mcp_config(mut self, path: impl Into<String>) -> Self {
202 self.shared.mcp_config.push(path.into());
203 self
204 }
205
206 #[must_use]
208 pub fn add_dir(mut self, dir: impl Into<String>) -> Self {
209 self.shared.add_dir.push(dir.into());
210 self
211 }
212
213 #[must_use]
215 pub fn effort(mut self, effort: Effort) -> Self {
216 self.shared.effort = Some(effort);
217 self
218 }
219
220 #[must_use]
222 pub fn max_turns(mut self, turns: u32) -> Self {
223 self.shared.max_turns = Some(turns);
224 self
225 }
226
227 #[must_use]
229 pub fn json_schema(mut self, schema: impl Into<String>) -> Self {
230 self.shared.json_schema = Some(schema.into());
231 self
232 }
233
234 #[must_use]
236 pub fn continue_session(mut self) -> Self {
237 self.shared.continue_session = true;
238 self
239 }
240
241 #[must_use]
243 pub fn resume(mut self, session_id: impl Into<String>) -> Self {
244 self.shared.resume = Some(session_id.into());
245 self
246 }
247
248 #[must_use]
250 pub fn session_id(mut self, id: impl Into<String>) -> Self {
251 self.shared.session_id = Some(id.into());
252 self
253 }
254
255 #[cfg(all(feature = "json", feature = "async"))]
263 pub(crate) fn replace_session(mut self, id: impl Into<String>) -> Self {
264 self.shared.continue_session = false;
265 self.shared.resume = Some(id.into());
266 self.shared.session_id = None;
267 self.fork_session = false;
268 self
269 }
270
271 #[must_use]
273 pub fn fallback_model(mut self, model: impl Into<String>) -> Self {
274 self.shared.fallback_model = Some(model.into());
275 self
276 }
277
278 #[must_use]
280 pub fn no_session_persistence(mut self) -> Self {
281 self.shared.no_session_persistence = true;
282 self
283 }
284
285 #[must_use]
287 pub fn dangerously_skip_permissions(mut self) -> Self {
288 self.shared.dangerously_skip_permissions = true;
289 self
290 }
291
292 #[must_use]
306 pub fn agent(mut self, agent: impl Into<String>) -> Self {
307 self.shared.agent = Some(agent.into());
308 self
309 }
310
311 #[must_use]
323 pub fn agents_json(mut self, json: impl Into<String>) -> Self {
324 self.shared.agents_json = Some(json.into());
325 self
326 }
327
328 #[must_use]
334 pub fn tools(mut self, tools: impl IntoIterator<Item = impl Into<String>>) -> Self {
335 self.tools.extend(tools.into_iter().map(Into::into));
336 self
337 }
338
339 #[must_use]
343 pub fn file(mut self, spec: impl Into<String>) -> Self {
344 self.file.push(spec.into());
345 self
346 }
347
348 #[must_use]
352 pub fn include_partial_messages(mut self) -> Self {
353 self.include_partial_messages = true;
354 self
355 }
356
357 #[must_use]
359 pub fn input_format(mut self, format: InputFormat) -> Self {
360 self.input_format = Some(format);
361 self
362 }
363
364 #[must_use]
366 pub fn strict_mcp_config(mut self) -> Self {
367 self.shared.strict_mcp_config = true;
368 self
369 }
370
371 #[must_use]
373 pub fn settings(mut self, settings: impl Into<String>) -> Self {
374 self.settings = Some(settings.into());
375 self
376 }
377
378 #[must_use]
380 pub fn fork_session(mut self) -> Self {
381 self.fork_session = true;
382 self
383 }
384
385 #[must_use]
387 pub fn worktree(mut self) -> Self {
388 self.shared.worktree = true;
389 self
390 }
391
392 #[must_use]
415 pub fn worktree_named(mut self, name: impl Into<String>) -> Self {
416 self.shared.worktree = true;
417 self.shared.worktree_name = Some(name.into());
418 self
419 }
420
421 #[must_use]
423 pub fn brief(mut self) -> Self {
424 self.brief = true;
425 self
426 }
427
428 #[must_use]
430 pub fn debug_filter(mut self, filter: impl Into<String>) -> Self {
431 self.debug_filter = Some(filter.into());
432 self
433 }
434
435 #[must_use]
437 pub fn debug_file(mut self, path: impl Into<String>) -> Self {
438 self.debug_file = Some(path.into());
439 self
440 }
441
442 #[must_use]
444 pub fn betas(mut self, betas: impl Into<String>) -> Self {
445 self.betas = Some(betas.into());
446 self
447 }
448
449 #[must_use]
451 pub fn plugin_dir(mut self, dir: impl Into<String>) -> Self {
452 self.plugin_dirs.push(dir.into());
453 self
454 }
455
456 #[must_use]
460 pub fn plugin_url(mut self, url: impl Into<String>) -> Self {
461 self.plugin_urls.push(url.into());
462 self
463 }
464
465 #[must_use]
467 pub fn setting_sources(mut self, sources: impl Into<String>) -> Self {
468 self.setting_sources = Some(sources.into());
469 self
470 }
471
472 #[must_use]
474 pub fn tmux(mut self) -> Self {
475 self.tmux = true;
476 self
477 }
478
479 #[must_use]
495 pub fn bare(mut self) -> Self {
496 self.bare = true;
497 self
498 }
499
500 #[must_use]
502 pub fn disable_slash_commands(mut self) -> Self {
503 self.disable_slash_commands = true;
504 self
505 }
506
507 #[must_use]
516 pub fn safe_mode(mut self) -> Self {
517 self.safe_mode = true;
518 self
519 }
520
521 #[must_use]
525 pub fn include_hook_events(mut self) -> Self {
526 self.include_hook_events = true;
527 self
528 }
529
530 #[must_use]
536 pub fn exclude_dynamic_system_prompt_sections(mut self) -> Self {
537 self.exclude_dynamic_system_prompt_sections = true;
538 self
539 }
540
541 #[must_use]
544 pub fn name(mut self, name: impl Into<String>) -> Self {
545 self.name = Some(name.into());
546 self
547 }
548
549 #[must_use]
556 pub fn from_pr(mut self, pr: impl Into<String>) -> Self {
557 self.from_pr = Some(pr.into());
558 self
559 }
560
561 #[must_use]
569 pub fn verbose(mut self, value: bool) -> Self {
570 self.verbose = value;
571 self
572 }
573
574 #[must_use]
580 pub fn prompt_suggestions(mut self, value: bool) -> Self {
581 self.prompt_suggestions = value;
582 self
583 }
584
585 #[must_use]
593 pub fn replay_user_messages(mut self, value: bool) -> Self {
594 self.replay_user_messages = value;
595 self
596 }
597
598 #[must_use]
621 pub fn retry(mut self, policy: crate::retry::RetryPolicy) -> Self {
622 self.retry_policy = Some(policy);
623 self
624 }
625
626 pub fn to_command_string(&self, claude: &Claude) -> String {
649 let args = self.build_args();
650 let quoted_args = args.iter().map(|arg| shell_quote(arg)).collect::<Vec<_>>();
651 format!("{} {}", claude.binary().display(), quoted_args.join(" "))
652 }
653
654 #[cfg(all(feature = "json", feature = "async"))]
659 pub async fn execute_json(&self, claude: &Claude) -> Result<crate::types::QueryResult> {
660 let args = self.build_args_with_forced_json();
661
662 let output = if self.prompt_via_stdin {
663 exec::run_claude_with_stdin_prompt(claude, args, self.prompt.clone()).await?
666 } else {
667 exec::run_claude_with_retry(claude, args, self.retry_policy.as_ref()).await?
668 };
669
670 serde_json::from_str(&output.stdout).map_err(|e| crate::error::Error::Json {
671 message: format!("failed to parse query result: {e}"),
672 source: e,
673 })
674 }
675
676 #[cfg(feature = "sync")]
683 pub fn execute_sync(&self, claude: &Claude) -> Result<CommandOutput> {
684 if self.prompt_via_stdin {
685 exec::run_claude_with_stdin_prompt_sync(claude, self.build_args(), self.prompt.clone())
688 } else {
689 exec::run_claude_with_retry_sync(claude, self.args(), self.retry_policy.as_ref())
690 }
691 }
692
693 #[cfg(all(feature = "sync", feature = "json"))]
695 pub fn execute_json_sync(&self, claude: &Claude) -> Result<crate::types::QueryResult> {
696 let args = self.build_args_with_forced_json();
697
698 let output = if self.prompt_via_stdin {
699 exec::run_claude_with_stdin_prompt_sync(claude, args, self.prompt.clone())?
702 } else {
703 exec::run_claude_with_retry_sync(claude, args, self.retry_policy.as_ref())?
704 };
705
706 serde_json::from_str(&output.stdout).map_err(|e| crate::error::Error::Json {
707 message: format!("failed to parse query result: {e}"),
708 source: e,
709 })
710 }
711
712 #[must_use]
738 pub fn prompt_via_stdin(mut self, value: bool) -> Self {
739 self.prompt_via_stdin = value;
740 self
741 }
742
743 fn build_args_with_forced_json(&self) -> Vec<String> {
751 if self.output_format.is_some() {
752 return self.build_args();
753 }
754 let mut effective = self.clone();
755 effective.output_format = Some(OutputFormat::Json);
756 effective.build_args()
757 }
758
759 fn build_args(&self) -> Vec<String> {
760 let mut args = vec!["--print".to_string()];
761
762 if let Some(ref format) = self.output_format {
763 args.push("--output-format".to_string());
764 args.push(format.as_arg().to_string());
765 }
766
767 if self.verbose || matches!(self.output_format, Some(OutputFormat::StreamJson)) {
771 args.push("--verbose".to_string());
772 }
773
774 self.shared.append_to(&mut args);
775
776 if !self.tools.is_empty() {
777 args.push("--tools".to_string());
778 args.push(self.tools.join(","));
779 }
780
781 for spec in &self.file {
782 args.push("--file".to_string());
783 args.push(spec.clone());
784 }
785
786 if self.include_partial_messages {
787 args.push("--include-partial-messages".to_string());
788 }
789
790 if let Some(ref format) = self.input_format {
791 args.push("--input-format".to_string());
792 args.push(format.as_arg().to_string());
793 }
794
795 if let Some(ref settings) = self.settings {
796 args.push("--settings".to_string());
797 args.push(settings.clone());
798 }
799
800 if self.fork_session {
801 args.push("--fork-session".to_string());
802 }
803
804 if self.brief {
805 args.push("--brief".to_string());
806 }
807
808 if let Some(ref filter) = self.debug_filter {
809 args.push("--debug".to_string());
810 args.push(filter.clone());
811 }
812
813 if let Some(ref path) = self.debug_file {
814 args.push("--debug-file".to_string());
815 args.push(path.clone());
816 }
817
818 if let Some(ref betas) = self.betas {
819 args.push("--betas".to_string());
820 args.push(betas.clone());
821 }
822
823 for dir in &self.plugin_dirs {
824 args.push("--plugin-dir".to_string());
825 args.push(dir.clone());
826 }
827
828 for url in &self.plugin_urls {
829 args.push("--plugin-url".to_string());
830 args.push(url.clone());
831 }
832
833 if let Some(ref sources) = self.setting_sources {
834 args.push("--setting-sources".to_string());
835 args.push(sources.clone());
836 }
837
838 if self.tmux {
839 args.push("--tmux".to_string());
840 }
841
842 if self.bare {
843 args.push("--bare".to_string());
844 }
845
846 if self.safe_mode {
847 args.push("--safe-mode".to_string());
848 }
849
850 if self.disable_slash_commands {
851 args.push("--disable-slash-commands".to_string());
852 }
853
854 if self.include_hook_events {
855 args.push("--include-hook-events".to_string());
856 }
857
858 if self.exclude_dynamic_system_prompt_sections {
859 args.push("--exclude-dynamic-system-prompt-sections".to_string());
860 }
861
862 if self.prompt_suggestions {
863 args.push("--prompt-suggestions".to_string());
864 }
865
866 if self.replay_user_messages {
867 args.push("--replay-user-messages".to_string());
868 }
869
870 if let Some(ref name) = self.name {
871 args.push("--name".to_string());
872 args.push(name.clone());
873 }
874
875 if let Some(ref pr) = self.from_pr {
876 args.push("--from-pr".to_string());
877 args.push(pr.clone());
878 }
879
880 if !self.prompt_via_stdin {
884 args.push("--".to_string());
885 args.push(self.prompt.clone());
886 }
887
888 args
889 }
890}
891
892impl ClaudeCommand for QueryCommand {
893 type Output = CommandOutput;
894
895 fn args(&self) -> Vec<String> {
896 self.build_args()
897 }
898
899 #[cfg(feature = "async")]
900 async fn execute(&self, claude: &Claude) -> Result<CommandOutput> {
901 if self.prompt_via_stdin {
902 let args = self.build_args(); exec::run_claude_with_stdin_prompt(claude, args, self.prompt.clone()).await
906 } else {
907 exec::run_claude_with_retry(claude, self.args(), self.retry_policy.as_ref()).await
908 }
909 }
910}
911
912fn shell_quote(arg: &str) -> String {
914 if arg.contains(|c: char| c.is_whitespace() || "\"'$\\`|;<>&()[]{}".contains(c)) {
916 format!("'{}'", arg.replace("'", "'\\''"))
918 } else {
919 arg.to_string()
920 }
921}
922
923#[cfg(test)]
924mod tests {
925 use super::*;
926
927 #[test]
928 fn test_basic_query_args() {
929 let cmd = QueryCommand::new("hello world");
930 let args = cmd.args();
931 assert_eq!(args, vec!["--print", "--", "hello world"]);
932 }
933
934 #[test]
935 fn prompt_via_stdin_omits_prompt_from_args() {
936 let cmd = QueryCommand::new("secret payload").prompt_via_stdin(true);
937 let args = cmd.args();
938 assert!(
939 !args.contains(&"secret payload".to_string()),
940 "prompt must not appear in args when prompt_via_stdin is set"
941 );
942 assert!(
943 !args.contains(&"--".to_string()),
944 "-- separator must be absent when prompt_via_stdin is set"
945 );
946 }
947
948 #[test]
949 fn prompt_via_stdin_false_keeps_prompt_in_args() {
950 let cmd = QueryCommand::new("visible prompt").prompt_via_stdin(false);
951 let args = cmd.args();
952 assert!(
953 args.contains(&"visible prompt".to_string()),
954 "prompt must still appear in args when prompt_via_stdin is false"
955 );
956 assert!(
957 args.contains(&"--".to_string()),
958 "-- separator must be present when prompt_via_stdin is false"
959 );
960 }
961
962 #[test]
963 #[cfg(feature = "async")] #[ignore = "requires a real claude binary"]
965 fn prompt_via_stdin_integration() {
966 use crate::{Claude, ClaudeCommand};
969 let rt = tokio::runtime::Runtime::new().unwrap();
970 rt.block_on(async {
971 let claude = Claude::builder().build().unwrap();
972 let out = QueryCommand::new("reply with: STDIN_OK")
973 .prompt_via_stdin(true)
974 .execute(&claude)
975 .await
976 .unwrap();
977 assert!(
978 !out.stdout.is_empty(),
979 "expected non-empty output from stdin-mode query"
980 );
981 });
982 }
983
984 #[test]
985 fn build_args_with_forced_json_inserts_flag_before_separator() {
986 let cmd = QueryCommand::new("hello");
992 let args = cmd.build_args_with_forced_json();
993
994 assert_eq!(
996 &args[args.len() - 2..],
997 &["--".to_string(), "hello".to_string()],
998 );
999
1000 let sep = args.iter().position(|a| a == "--").expect("`--` present");
1002 let fmt = args
1003 .iter()
1004 .position(|a| a == "--output-format")
1005 .expect("--output-format present");
1006 assert!(
1007 fmt < sep,
1008 "--output-format must come before `--` separator; got {args:?}"
1009 );
1010 assert_eq!(args[fmt + 1], "json");
1011 }
1012
1013 #[test]
1014 fn build_args_with_forced_json_respects_explicit_format() {
1015 let cmd = QueryCommand::new("hello").output_format(OutputFormat::Text);
1018 let args = cmd.build_args_with_forced_json();
1019 let fmt = args
1020 .iter()
1021 .position(|a| a == "--output-format")
1022 .expect("--output-format present");
1023 assert_eq!(args[fmt + 1], "text");
1024 assert_eq!(args.iter().filter(|a| *a == "--output-format").count(), 1);
1026 }
1027
1028 #[test]
1029 #[allow(deprecated)] fn test_full_query_args() {
1031 let cmd = QueryCommand::new("explain this")
1032 .model("sonnet")
1033 .system_prompt("be concise")
1034 .output_format(OutputFormat::Json)
1035 .max_budget_usd(0.50)
1036 .permission_mode(PermissionMode::BypassPermissions)
1037 .allowed_tools(["Bash", "Read"])
1038 .mcp_config("/tmp/mcp.json")
1039 .effort(Effort::High)
1040 .max_turns(3)
1041 .no_session_persistence();
1042
1043 let args = cmd.args();
1044 assert!(args.contains(&"--print".to_string()));
1045 assert!(args.contains(&"--model".to_string()));
1046 assert!(args.contains(&"sonnet".to_string()));
1047 assert!(args.contains(&"--system-prompt".to_string()));
1048 assert!(args.contains(&"--output-format".to_string()));
1049 assert!(args.contains(&"json".to_string()));
1050 assert!(!args.contains(&"--verbose".to_string()));
1052 assert!(args.contains(&"--max-budget-usd".to_string()));
1053 assert!(args.contains(&"--permission-mode".to_string()));
1054 assert!(args.contains(&"bypassPermissions".to_string()));
1055 assert!(args.contains(&"--allowed-tools".to_string()));
1056 assert!(args.contains(&"Bash,Read".to_string()));
1057 assert!(args.contains(&"--effort".to_string()));
1058 assert!(args.contains(&"high".to_string()));
1059 assert!(args.contains(&"--max-turns".to_string()));
1060 assert!(args.contains(&"--no-session-persistence".to_string()));
1061 assert_eq!(args.last().unwrap(), "explain this");
1063 assert_eq!(args[args.len() - 2], "--");
1064 }
1065
1066 #[test]
1067 fn typed_patterns_render_in_allowed_tools() {
1068 use crate::ToolPattern;
1069
1070 let cmd = QueryCommand::new("hi")
1071 .allowed_tool(ToolPattern::tool("Read"))
1072 .allowed_tool(ToolPattern::tool_with_args("Bash", "git log:*"))
1073 .allowed_tool(ToolPattern::all("Write"))
1074 .allowed_tool(ToolPattern::mcp("srv", "*"));
1075
1076 let args = cmd.args();
1077 let joined = args
1078 .iter()
1079 .position(|a| a == "--allowed-tools")
1080 .map(|i| &args[i + 1])
1081 .unwrap();
1082 assert_eq!(joined, "Read,Bash(git log:*),Write(*),mcp__srv__*");
1083 }
1084
1085 #[test]
1086 fn disallowed_tool_singular_appends() {
1087 use crate::ToolPattern;
1088
1089 let cmd = QueryCommand::new("hi")
1090 .disallowed_tool("Write")
1091 .disallowed_tool(ToolPattern::tool_with_args("Bash", "rm*"));
1092
1093 let args = cmd.args();
1094 let joined = args
1095 .iter()
1096 .position(|a| a == "--disallowed-tools")
1097 .map(|i| &args[i + 1])
1098 .unwrap();
1099 assert_eq!(joined, "Write,Bash(rm*)");
1100 }
1101
1102 #[test]
1103 fn mixed_string_and_typed_patterns_both_accepted() {
1104 use crate::ToolPattern;
1105
1106 let strs: Vec<ToolPattern> = vec!["Bash".into(), ToolPattern::all("Read")];
1110 let cmd = QueryCommand::new("hi").allowed_tools(strs);
1111 assert!(cmd.args().contains(&"--allowed-tools".to_string()));
1112 }
1113
1114 #[test]
1115 fn new_bool_flags_emit_correct_cli_args() {
1116 let args = QueryCommand::new("hi")
1117 .bare()
1118 .disable_slash_commands()
1119 .include_hook_events()
1120 .exclude_dynamic_system_prompt_sections()
1121 .args();
1122 assert!(args.contains(&"--bare".to_string()));
1123 assert!(args.contains(&"--disable-slash-commands".to_string()));
1124 assert!(args.contains(&"--include-hook-events".to_string()));
1125 assert!(args.contains(&"--exclude-dynamic-system-prompt-sections".to_string()));
1126 }
1127
1128 #[test]
1129 fn name_flag_renders_with_value() {
1130 let args = QueryCommand::new("hi").name("my session").args();
1131 let pos = args.iter().position(|a| a == "--name").unwrap();
1132 assert_eq!(args[pos + 1], "my session");
1133 }
1134
1135 #[test]
1136 fn from_pr_flag_renders_with_value() {
1137 let args = QueryCommand::new("hi").from_pr("42").args();
1138 let pos = args.iter().position(|a| a == "--from-pr").unwrap();
1139 assert_eq!(args[pos + 1], "42");
1140 }
1141
1142 #[test]
1143 fn new_bool_flags_default_to_off() {
1144 let args = QueryCommand::new("hi").args();
1145 assert!(!args.contains(&"--bare".to_string()));
1146 assert!(!args.contains(&"--disable-slash-commands".to_string()));
1147 assert!(!args.contains(&"--include-hook-events".to_string()));
1148 assert!(!args.contains(&"--exclude-dynamic-system-prompt-sections".to_string()));
1149 assert!(!args.contains(&"--name".to_string()));
1150 }
1151
1152 #[test]
1153 fn test_separator_before_prompt_prevents_greedy_flag_parsing() {
1154 let cmd = QueryCommand::new("fix the bug")
1157 .allowed_tools(["Read", "Edit", "Bash(cargo *)"])
1158 .output_format(OutputFormat::StreamJson);
1159 let args = cmd.args();
1160 let sep_pos = args.iter().position(|a| a == "--").unwrap();
1162 let prompt_pos = args.iter().position(|a| a == "fix the bug").unwrap();
1163 assert_eq!(prompt_pos, sep_pos + 1, "prompt must follow -- separator");
1164 let tools_pos = args
1166 .iter()
1167 .position(|a| a.contains("Bash(cargo *)"))
1168 .unwrap();
1169 assert!(
1170 tools_pos < sep_pos,
1171 "allowed-tools must come before -- separator"
1172 );
1173 }
1174
1175 #[test]
1176 fn test_stream_json_includes_verbose() {
1177 let cmd = QueryCommand::new("test").output_format(OutputFormat::StreamJson);
1178 let args = cmd.args();
1179 assert!(args.contains(&"--output-format".to_string()));
1180 assert!(args.contains(&"stream-json".to_string()));
1181 assert!(args.contains(&"--verbose".to_string()));
1182 }
1183
1184 #[test]
1185 fn verbose_flag_emitted_when_set() {
1186 let args = QueryCommand::new("test").verbose(true).args();
1187 assert!(args.contains(&"--verbose".to_string()));
1188 }
1189
1190 #[test]
1191 fn verbose_absent_by_default_and_when_false() {
1192 assert!(
1193 !QueryCommand::new("test")
1194 .args()
1195 .contains(&"--verbose".to_string())
1196 );
1197 assert!(
1198 !QueryCommand::new("test")
1199 .verbose(false)
1200 .args()
1201 .contains(&"--verbose".to_string())
1202 );
1203 }
1204
1205 #[test]
1206 fn verbose_not_duplicated_with_stream_json() {
1207 let cmd = QueryCommand::new("test")
1210 .verbose(true)
1211 .output_format(OutputFormat::StreamJson);
1212 let count = cmd.args().iter().filter(|a| *a == "--verbose").count();
1213 assert_eq!(count, 1, "--verbose must appear exactly once");
1214 }
1215
1216 #[test]
1217 fn prompt_suggestions_flag_emitted_when_set() {
1218 let args = QueryCommand::new("test").prompt_suggestions(true).args();
1219 assert!(args.contains(&"--prompt-suggestions".to_string()));
1220 let sep = args.iter().position(|a| a == "--").unwrap();
1223 let flag = args
1224 .iter()
1225 .position(|a| a == "--prompt-suggestions")
1226 .unwrap();
1227 assert!(flag < sep, "--prompt-suggestions must precede `--`");
1228 }
1229
1230 #[test]
1231 fn prompt_suggestions_absent_by_default_and_when_false() {
1232 assert!(
1233 !QueryCommand::new("test")
1234 .args()
1235 .contains(&"--prompt-suggestions".to_string())
1236 );
1237 assert!(
1238 !QueryCommand::new("test")
1239 .prompt_suggestions(false)
1240 .args()
1241 .contains(&"--prompt-suggestions".to_string())
1242 );
1243 }
1244
1245 #[test]
1246 fn replay_user_messages_flag_emitted_when_set() {
1247 let args = QueryCommand::new("test").replay_user_messages(true).args();
1248 assert!(args.contains(&"--replay-user-messages".to_string()));
1249 }
1250
1251 #[test]
1252 fn replay_user_messages_absent_by_default_and_when_false() {
1253 assert!(
1254 !QueryCommand::new("test")
1255 .args()
1256 .contains(&"--replay-user-messages".to_string())
1257 );
1258 assert!(
1259 !QueryCommand::new("test")
1260 .replay_user_messages(false)
1261 .args()
1262 .contains(&"--replay-user-messages".to_string())
1263 );
1264 }
1265
1266 #[test]
1267 fn test_to_command_string_simple() {
1268 let claude = Claude::builder()
1269 .binary("/usr/local/bin/claude")
1270 .build()
1271 .unwrap();
1272
1273 let cmd = QueryCommand::new("hello");
1274 let command_str = cmd.to_command_string(&claude);
1275
1276 assert!(command_str.starts_with("/usr/local/bin/claude"));
1277 assert!(command_str.contains("--print"));
1278 assert!(command_str.contains("hello"));
1279 }
1280
1281 #[test]
1282 fn test_to_command_string_with_spaces() {
1283 let claude = Claude::builder()
1284 .binary("/usr/local/bin/claude")
1285 .build()
1286 .unwrap();
1287
1288 let cmd = QueryCommand::new("hello world").model("sonnet");
1289 let command_str = cmd.to_command_string(&claude);
1290
1291 assert!(command_str.starts_with("/usr/local/bin/claude"));
1292 assert!(command_str.contains("--print"));
1293 assert!(command_str.contains("'hello world'"));
1295 assert!(command_str.contains("--model"));
1296 assert!(command_str.contains("sonnet"));
1297 }
1298
1299 #[test]
1300 fn test_to_command_string_with_special_chars() {
1301 let claude = Claude::builder()
1302 .binary("/usr/local/bin/claude")
1303 .build()
1304 .unwrap();
1305
1306 let cmd = QueryCommand::new("test $VAR and `cmd`");
1307 let command_str = cmd.to_command_string(&claude);
1308
1309 assert!(command_str.contains("'test $VAR and `cmd`'"));
1311 }
1312
1313 #[test]
1314 fn test_to_command_string_with_single_quotes() {
1315 let claude = Claude::builder()
1316 .binary("/usr/local/bin/claude")
1317 .build()
1318 .unwrap();
1319
1320 let cmd = QueryCommand::new("it's");
1321 let command_str = cmd.to_command_string(&claude);
1322
1323 assert!(command_str.contains("'it'\\''s'"));
1325 }
1326
1327 #[test]
1328 fn test_worktree_flag() {
1329 let cmd = QueryCommand::new("test").worktree();
1330 let args = cmd.args();
1331 assert!(args.contains(&"--worktree".to_string()));
1332 }
1333
1334 #[test]
1335 fn test_worktree_named() {
1336 let cmd = QueryCommand::new("test").worktree_named("feature-x");
1337 let args = cmd.args();
1338 assert!(
1339 args.windows(2).any(|w| w == ["--worktree", "feature-x"]),
1340 "missing --worktree feature-x in {args:?}"
1341 );
1342 }
1343
1344 #[test]
1345 fn test_brief_flag() {
1346 let cmd = QueryCommand::new("test").brief();
1347 let args = cmd.args();
1348 assert!(args.contains(&"--brief".to_string()));
1349 }
1350
1351 #[test]
1352 fn test_debug_filter() {
1353 let cmd = QueryCommand::new("test").debug_filter("api,hooks");
1354 let args = cmd.args();
1355 assert!(args.contains(&"--debug".to_string()));
1356 assert!(args.contains(&"api,hooks".to_string()));
1357 }
1358
1359 #[test]
1360 fn test_debug_file() {
1361 let cmd = QueryCommand::new("test").debug_file("/tmp/debug.log");
1362 let args = cmd.args();
1363 assert!(args.contains(&"--debug-file".to_string()));
1364 assert!(args.contains(&"/tmp/debug.log".to_string()));
1365 }
1366
1367 #[test]
1368 fn test_betas() {
1369 let cmd = QueryCommand::new("test").betas("feature-x");
1370 let args = cmd.args();
1371 assert!(args.contains(&"--betas".to_string()));
1372 assert!(args.contains(&"feature-x".to_string()));
1373 }
1374
1375 #[test]
1376 fn test_plugin_dir_single() {
1377 let cmd = QueryCommand::new("test").plugin_dir("/plugins/foo");
1378 let args = cmd.args();
1379 assert!(args.contains(&"--plugin-dir".to_string()));
1380 assert!(args.contains(&"/plugins/foo".to_string()));
1381 }
1382
1383 #[test]
1384 fn test_plugin_dir_multiple() {
1385 let cmd = QueryCommand::new("test")
1386 .plugin_dir("/plugins/foo")
1387 .plugin_dir("/plugins/bar");
1388 let args = cmd.args();
1389 let plugin_dir_count = args.iter().filter(|a| *a == "--plugin-dir").count();
1390 assert_eq!(plugin_dir_count, 2);
1391 assert!(args.contains(&"/plugins/foo".to_string()));
1392 assert!(args.contains(&"/plugins/bar".to_string()));
1393 }
1394
1395 #[test]
1396 fn test_plugin_url_single() {
1397 let cmd = QueryCommand::new("test").plugin_url("https://example.com/p.zip");
1398 let args = cmd.args();
1399 assert!(args.contains(&"--plugin-url".to_string()));
1400 assert!(args.contains(&"https://example.com/p.zip".to_string()));
1401 }
1402
1403 #[test]
1404 fn test_plugin_url_multiple() {
1405 let cmd = QueryCommand::new("test")
1406 .plugin_url("https://example.com/a.zip")
1407 .plugin_url("https://example.com/b.zip");
1408 let args = cmd.args();
1409 let plugin_url_count = args.iter().filter(|a| *a == "--plugin-url").count();
1410 assert_eq!(plugin_url_count, 2);
1411 assert!(args.contains(&"https://example.com/a.zip".to_string()));
1412 assert!(args.contains(&"https://example.com/b.zip".to_string()));
1413 }
1414
1415 #[test]
1416 fn test_safe_mode_flag() {
1417 let cmd = QueryCommand::new("test").safe_mode();
1418 let args = cmd.args();
1419 assert!(args.contains(&"--safe-mode".to_string()));
1420 }
1421
1422 #[test]
1423 fn test_safe_mode_absent_by_default() {
1424 let cmd = QueryCommand::new("test");
1425 let args = cmd.args();
1426 assert!(!args.contains(&"--safe-mode".to_string()));
1427 }
1428
1429 #[test]
1430 fn test_setting_sources() {
1431 let cmd = QueryCommand::new("test").setting_sources("user,project,local");
1432 let args = cmd.args();
1433 assert!(args.contains(&"--setting-sources".to_string()));
1434 assert!(args.contains(&"user,project,local".to_string()));
1435 }
1436
1437 #[test]
1438 fn test_tmux_flag() {
1439 let cmd = QueryCommand::new("test").tmux();
1440 let args = cmd.args();
1441 assert!(args.contains(&"--tmux".to_string()));
1442 }
1443
1444 #[test]
1447 fn shell_quote_plain_word_is_unchanged() {
1448 assert_eq!(shell_quote("simple"), "simple");
1449 assert_eq!(shell_quote(""), "");
1450 assert_eq!(shell_quote("file.rs"), "file.rs");
1451 }
1452
1453 #[test]
1454 fn shell_quote_whitespace_gets_single_quoted() {
1455 assert_eq!(shell_quote("hello world"), "'hello world'");
1456 assert_eq!(shell_quote("a\tb"), "'a\tb'");
1457 }
1458
1459 #[test]
1460 fn shell_quote_metacharacters_get_quoted() {
1461 assert_eq!(shell_quote("a|b"), "'a|b'");
1462 assert_eq!(shell_quote("$VAR"), "'$VAR'");
1463 assert_eq!(shell_quote("a;b"), "'a;b'");
1464 assert_eq!(shell_quote("(x)"), "'(x)'");
1465 }
1466
1467 #[test]
1468 fn shell_quote_embedded_single_quote_is_escaped() {
1469 assert_eq!(shell_quote("it's"), "'it'\\''s'");
1470 }
1471
1472 #[test]
1473 fn shell_quote_double_quote_gets_single_quoted() {
1474 assert_eq!(shell_quote(r#"say "hi""#), r#"'say "hi"'"#);
1475 }
1476}