1use crate::Claude;
12use crate::command::ClaudeCommand;
13use crate::command::spawn_args::SharedSpawnArgs;
14use crate::error::Result;
15use crate::exec::{self, CommandOutput};
16use crate::tool_pattern::ToolPattern;
17use crate::types::{Effort, InputFormat, OutputFormat, PermissionMode};
18
19#[derive(Debug, Clone)]
42pub struct QueryCommand {
43 prompt: String,
44 shared: SharedSpawnArgs,
47 output_format: Option<OutputFormat>,
48 tools: Vec<String>,
49 file: Vec<String>,
50 include_partial_messages: bool,
51 input_format: Option<InputFormat>,
52 settings: Option<String>,
53 fork_session: bool,
54 retry_policy: Option<crate::retry::RetryPolicy>,
55 brief: bool,
56 debug_filter: Option<String>,
57 debug_file: Option<String>,
58 betas: Option<String>,
59 plugin_dirs: Vec<String>,
60 plugin_urls: Vec<String>,
61 setting_sources: Option<String>,
62 tmux: bool,
63 bare: bool,
64 safe_mode: bool,
65 disable_slash_commands: bool,
66 include_hook_events: bool,
67 exclude_dynamic_system_prompt_sections: bool,
68 name: Option<String>,
69 from_pr: Option<String>,
70 prompt_via_stdin: bool,
71 verbose: bool,
72 prompt_suggestions: bool,
73 replay_user_messages: bool,
74}
75
76impl QueryCommand {
77 #[must_use]
79 pub fn new(prompt: impl Into<String>) -> Self {
80 Self {
81 prompt: prompt.into(),
82 shared: SharedSpawnArgs::default(),
83 output_format: None,
84 tools: Vec::new(),
85 file: Vec::new(),
86 include_partial_messages: false,
87 input_format: None,
88 settings: None,
89 fork_session: false,
90 retry_policy: None,
91 brief: false,
92 debug_filter: None,
93 debug_file: None,
94 betas: None,
95 plugin_dirs: Vec::new(),
96 plugin_urls: Vec::new(),
97 setting_sources: None,
98 tmux: false,
99 bare: false,
100 safe_mode: false,
101 disable_slash_commands: false,
102 include_hook_events: false,
103 exclude_dynamic_system_prompt_sections: false,
104 name: None,
105 from_pr: None,
106 prompt_via_stdin: false,
107 verbose: false,
108 prompt_suggestions: false,
109 replay_user_messages: false,
110 }
111 }
112
113 #[must_use]
115 pub fn model(mut self, model: impl Into<String>) -> Self {
116 self.shared.model = Some(model.into());
117 self
118 }
119
120 #[must_use]
122 pub fn system_prompt(mut self, prompt: impl Into<String>) -> Self {
123 self.shared.system_prompt = Some(prompt.into());
124 self
125 }
126
127 #[must_use]
129 pub fn append_system_prompt(mut self, prompt: impl Into<String>) -> Self {
130 self.shared.append_system_prompt = Some(prompt.into());
131 self
132 }
133
134 #[must_use]
136 pub fn output_format(mut self, format: OutputFormat) -> Self {
137 self.output_format = Some(format);
138 self
139 }
140
141 #[must_use]
143 pub fn max_budget_usd(mut self, budget: f64) -> Self {
144 self.shared.max_budget_usd = Some(budget);
145 self
146 }
147
148 #[must_use]
150 pub fn permission_mode(mut self, mode: PermissionMode) -> Self {
151 self.shared.permission_mode = Some(mode);
152 self
153 }
154
155 #[must_use]
171 pub fn allowed_tools<I, T>(mut self, tools: I) -> Self
172 where
173 I: IntoIterator<Item = T>,
174 T: Into<ToolPattern>,
175 {
176 self.shared
177 .allowed_tools
178 .extend(tools.into_iter().map(Into::into));
179 self
180 }
181
182 #[must_use]
184 pub fn allowed_tool(mut self, tool: impl Into<ToolPattern>) -> Self {
185 self.shared.allowed_tools.push(tool.into());
186 self
187 }
188
189 #[must_use]
191 pub fn disallowed_tools<I, T>(mut self, tools: I) -> Self
192 where
193 I: IntoIterator<Item = T>,
194 T: Into<ToolPattern>,
195 {
196 self.shared
197 .disallowed_tools
198 .extend(tools.into_iter().map(Into::into));
199 self
200 }
201
202 #[must_use]
204 pub fn disallowed_tool(mut self, tool: impl Into<ToolPattern>) -> Self {
205 self.shared.disallowed_tools.push(tool.into());
206 self
207 }
208
209 #[must_use]
211 pub fn mcp_config(mut self, path: impl Into<String>) -> Self {
212 self.shared.mcp_config.push(path.into());
213 self
214 }
215
216 #[must_use]
218 pub fn add_dir(mut self, dir: impl Into<String>) -> Self {
219 self.shared.add_dir.push(dir.into());
220 self
221 }
222
223 #[must_use]
225 pub fn effort(mut self, effort: Effort) -> Self {
226 self.shared.effort = Some(effort);
227 self
228 }
229
230 #[must_use]
232 pub fn max_turns(mut self, turns: u32) -> Self {
233 self.shared.max_turns = Some(turns);
234 self
235 }
236
237 #[must_use]
239 pub fn json_schema(mut self, schema: impl Into<String>) -> Self {
240 self.shared.json_schema = Some(schema.into());
241 self
242 }
243
244 #[must_use]
246 pub fn continue_session(mut self) -> Self {
247 self.shared.continue_session = true;
248 self
249 }
250
251 #[must_use]
253 pub fn resume(mut self, session_id: impl Into<String>) -> Self {
254 self.shared.resume = Some(session_id.into());
255 self
256 }
257
258 #[must_use]
260 pub fn session_id(mut self, id: impl Into<String>) -> Self {
261 self.shared.session_id = Some(id.into());
262 self
263 }
264
265 #[cfg(all(feature = "json", feature = "async"))]
273 pub(crate) fn replace_session(mut self, id: impl Into<String>) -> Self {
274 self.shared.continue_session = false;
275 self.shared.resume = Some(id.into());
276 self.shared.session_id = None;
277 self.fork_session = false;
278 self
279 }
280
281 #[must_use]
283 pub fn fallback_model(mut self, model: impl Into<String>) -> Self {
284 self.shared.fallback_model = Some(model.into());
285 self
286 }
287
288 #[must_use]
290 pub fn no_session_persistence(mut self) -> Self {
291 self.shared.no_session_persistence = true;
292 self
293 }
294
295 #[must_use]
297 pub fn dangerously_skip_permissions(mut self) -> Self {
298 self.shared.dangerously_skip_permissions = true;
299 self
300 }
301
302 #[must_use]
316 pub fn agent(mut self, agent: impl Into<String>) -> Self {
317 self.shared.agent = Some(agent.into());
318 self
319 }
320
321 #[must_use]
333 pub fn agents_json(mut self, json: impl Into<String>) -> Self {
334 self.shared.agents_json = Some(json.into());
335 self
336 }
337
338 #[must_use]
344 pub fn tools(mut self, tools: impl IntoIterator<Item = impl Into<String>>) -> Self {
345 self.tools.extend(tools.into_iter().map(Into::into));
346 self
347 }
348
349 #[must_use]
353 pub fn file(mut self, spec: impl Into<String>) -> Self {
354 self.file.push(spec.into());
355 self
356 }
357
358 #[must_use]
362 pub fn include_partial_messages(mut self) -> Self {
363 self.include_partial_messages = true;
364 self
365 }
366
367 #[must_use]
369 pub fn input_format(mut self, format: InputFormat) -> Self {
370 self.input_format = Some(format);
371 self
372 }
373
374 #[must_use]
376 pub fn strict_mcp_config(mut self) -> Self {
377 self.shared.strict_mcp_config = true;
378 self
379 }
380
381 #[must_use]
383 pub fn settings(mut self, settings: impl Into<String>) -> Self {
384 self.settings = Some(settings.into());
385 self
386 }
387
388 #[must_use]
390 pub fn fork_session(mut self) -> Self {
391 self.fork_session = true;
392 self
393 }
394
395 #[must_use]
397 pub fn worktree(mut self) -> Self {
398 self.shared.worktree = true;
399 self
400 }
401
402 #[must_use]
425 pub fn worktree_named(mut self, name: impl Into<String>) -> Self {
426 self.shared.worktree = true;
427 self.shared.worktree_name = Some(name.into());
428 self
429 }
430
431 #[must_use]
433 pub fn brief(mut self) -> Self {
434 self.brief = true;
435 self
436 }
437
438 #[must_use]
440 pub fn debug_filter(mut self, filter: impl Into<String>) -> Self {
441 self.debug_filter = Some(filter.into());
442 self
443 }
444
445 #[must_use]
447 pub fn debug_file(mut self, path: impl Into<String>) -> Self {
448 self.debug_file = Some(path.into());
449 self
450 }
451
452 #[must_use]
454 pub fn betas(mut self, betas: impl Into<String>) -> Self {
455 self.betas = Some(betas.into());
456 self
457 }
458
459 #[must_use]
461 pub fn plugin_dir(mut self, dir: impl Into<String>) -> Self {
462 self.plugin_dirs.push(dir.into());
463 self
464 }
465
466 #[must_use]
470 pub fn plugin_url(mut self, url: impl Into<String>) -> Self {
471 self.plugin_urls.push(url.into());
472 self
473 }
474
475 #[must_use]
477 pub fn setting_sources(mut self, sources: impl Into<String>) -> Self {
478 self.setting_sources = Some(sources.into());
479 self
480 }
481
482 #[must_use]
484 pub fn tmux(mut self) -> Self {
485 self.tmux = true;
486 self
487 }
488
489 #[must_use]
505 pub fn bare(mut self) -> Self {
506 self.bare = true;
507 self
508 }
509
510 #[must_use]
512 pub fn disable_slash_commands(mut self) -> Self {
513 self.disable_slash_commands = true;
514 self
515 }
516
517 #[must_use]
526 pub fn safe_mode(mut self) -> Self {
527 self.safe_mode = true;
528 self
529 }
530
531 #[must_use]
535 pub fn include_hook_events(mut self) -> Self {
536 self.include_hook_events = true;
537 self
538 }
539
540 #[must_use]
546 pub fn exclude_dynamic_system_prompt_sections(mut self) -> Self {
547 self.exclude_dynamic_system_prompt_sections = true;
548 self
549 }
550
551 #[must_use]
554 pub fn name(mut self, name: impl Into<String>) -> Self {
555 self.name = Some(name.into());
556 self
557 }
558
559 #[must_use]
566 pub fn from_pr(mut self, pr: impl Into<String>) -> Self {
567 self.from_pr = Some(pr.into());
568 self
569 }
570
571 #[must_use]
579 pub fn verbose(mut self, value: bool) -> Self {
580 self.verbose = value;
581 self
582 }
583
584 #[must_use]
590 pub fn prompt_suggestions(mut self, value: bool) -> Self {
591 self.prompt_suggestions = value;
592 self
593 }
594
595 #[must_use]
603 pub fn replay_user_messages(mut self, value: bool) -> Self {
604 self.replay_user_messages = value;
605 self
606 }
607
608 #[must_use]
631 pub fn retry(mut self, policy: crate::retry::RetryPolicy) -> Self {
632 self.retry_policy = Some(policy);
633 self
634 }
635
636 pub fn to_command_string(&self, claude: &Claude) -> String {
659 let args = self.build_args();
660 let quoted_args = args.iter().map(|arg| shell_quote(arg)).collect::<Vec<_>>();
661 format!("{} {}", claude.binary().display(), quoted_args.join(" "))
662 }
663
664 #[cfg(all(feature = "json", feature = "async"))]
669 pub async fn execute_json(&self, claude: &Claude) -> Result<crate::types::QueryResult> {
670 let args = self.build_args_with_forced_json();
671
672 let output = if self.prompt_via_stdin {
673 exec::run_claude_with_stdin_prompt(claude, args, self.prompt.clone()).await?
676 } else {
677 exec::run_claude_with_retry(claude, args, self.retry_policy.as_ref()).await?
678 };
679
680 serde_json::from_str(&output.stdout).map_err(|e| crate::error::Error::Json {
681 message: format!("failed to parse query result: {e}"),
682 source: e,
683 })
684 }
685
686 #[cfg(feature = "sync")]
693 pub fn execute_sync(&self, claude: &Claude) -> Result<CommandOutput> {
694 if self.prompt_via_stdin {
695 exec::run_claude_with_stdin_prompt_sync(claude, self.build_args(), self.prompt.clone())
698 } else {
699 exec::run_claude_with_retry_sync(claude, self.args(), self.retry_policy.as_ref())
700 }
701 }
702
703 #[cfg(all(feature = "sync", feature = "json"))]
705 pub fn execute_json_sync(&self, claude: &Claude) -> Result<crate::types::QueryResult> {
706 let args = self.build_args_with_forced_json();
707
708 let output = if self.prompt_via_stdin {
709 exec::run_claude_with_stdin_prompt_sync(claude, args, self.prompt.clone())?
712 } else {
713 exec::run_claude_with_retry_sync(claude, args, self.retry_policy.as_ref())?
714 };
715
716 serde_json::from_str(&output.stdout).map_err(|e| crate::error::Error::Json {
717 message: format!("failed to parse query result: {e}"),
718 source: e,
719 })
720 }
721
722 #[must_use]
748 pub fn prompt_via_stdin(mut self, value: bool) -> Self {
749 self.prompt_via_stdin = value;
750 self
751 }
752
753 fn build_args_with_forced_json(&self) -> Vec<String> {
761 if self.output_format.is_some() {
762 return self.build_args();
763 }
764 let mut effective = self.clone();
765 effective.output_format = Some(OutputFormat::Json);
766 effective.build_args()
767 }
768
769 fn build_args(&self) -> Vec<String> {
770 let mut args = vec!["--print".to_string()];
771
772 if let Some(ref format) = self.output_format {
773 args.push("--output-format".to_string());
774 args.push(format.as_arg().to_string());
775 }
776
777 if self.verbose || matches!(self.output_format, Some(OutputFormat::StreamJson)) {
781 args.push("--verbose".to_string());
782 }
783
784 self.shared.append_to(&mut args);
785
786 if !self.tools.is_empty() {
787 args.push("--tools".to_string());
788 args.push(self.tools.join(","));
789 }
790
791 for spec in &self.file {
792 args.push("--file".to_string());
793 args.push(spec.clone());
794 }
795
796 if self.include_partial_messages {
797 args.push("--include-partial-messages".to_string());
798 }
799
800 if let Some(ref format) = self.input_format {
801 args.push("--input-format".to_string());
802 args.push(format.as_arg().to_string());
803 }
804
805 if let Some(ref settings) = self.settings {
806 args.push("--settings".to_string());
807 args.push(settings.clone());
808 }
809
810 if self.fork_session {
811 args.push("--fork-session".to_string());
812 }
813
814 if self.brief {
815 args.push("--brief".to_string());
816 }
817
818 if let Some(ref filter) = self.debug_filter {
819 args.push("--debug".to_string());
820 args.push(filter.clone());
821 }
822
823 if let Some(ref path) = self.debug_file {
824 args.push("--debug-file".to_string());
825 args.push(path.clone());
826 }
827
828 if let Some(ref betas) = self.betas {
829 args.push("--betas".to_string());
830 args.push(betas.clone());
831 }
832
833 for dir in &self.plugin_dirs {
834 args.push("--plugin-dir".to_string());
835 args.push(dir.clone());
836 }
837
838 for url in &self.plugin_urls {
839 args.push("--plugin-url".to_string());
840 args.push(url.clone());
841 }
842
843 if let Some(ref sources) = self.setting_sources {
844 args.push("--setting-sources".to_string());
845 args.push(sources.clone());
846 }
847
848 if self.tmux {
849 args.push("--tmux".to_string());
850 }
851
852 if self.bare {
853 args.push("--bare".to_string());
854 }
855
856 if self.safe_mode {
857 args.push("--safe-mode".to_string());
858 }
859
860 if self.disable_slash_commands {
861 args.push("--disable-slash-commands".to_string());
862 }
863
864 if self.include_hook_events {
865 args.push("--include-hook-events".to_string());
866 }
867
868 if self.exclude_dynamic_system_prompt_sections {
869 args.push("--exclude-dynamic-system-prompt-sections".to_string());
870 }
871
872 if self.prompt_suggestions {
873 args.push("--prompt-suggestions".to_string());
874 }
875
876 if self.replay_user_messages {
877 args.push("--replay-user-messages".to_string());
878 }
879
880 if let Some(ref name) = self.name {
881 args.push("--name".to_string());
882 args.push(name.clone());
883 }
884
885 if let Some(ref pr) = self.from_pr {
886 args.push("--from-pr".to_string());
887 args.push(pr.clone());
888 }
889
890 if !self.prompt_via_stdin {
894 args.push("--".to_string());
895 args.push(self.prompt.clone());
896 }
897
898 args
899 }
900}
901
902impl ClaudeCommand for QueryCommand {
903 type Output = CommandOutput;
904
905 fn args(&self) -> Vec<String> {
906 self.build_args()
907 }
908
909 #[cfg(feature = "async")]
910 async fn execute(&self, claude: &Claude) -> Result<CommandOutput> {
911 if self.prompt_via_stdin {
912 let args = self.build_args(); exec::run_claude_with_stdin_prompt(claude, args, self.prompt.clone()).await
916 } else {
917 exec::run_claude_with_retry(claude, self.args(), self.retry_policy.as_ref()).await
918 }
919 }
920}
921
922fn shell_quote(arg: &str) -> String {
924 if arg.contains(|c: char| c.is_whitespace() || "\"'$\\`|;<>&()[]{}".contains(c)) {
926 format!("'{}'", arg.replace("'", "'\\''"))
928 } else {
929 arg.to_string()
930 }
931}
932
933#[cfg(test)]
934mod tests {
935 use super::*;
936
937 #[test]
938 fn test_basic_query_args() {
939 let cmd = QueryCommand::new("hello world");
940 let args = cmd.args();
941 assert_eq!(args, vec!["--print", "--", "hello world"]);
942 }
943
944 #[test]
945 fn prompt_via_stdin_omits_prompt_from_args() {
946 let cmd = QueryCommand::new("secret payload").prompt_via_stdin(true);
947 let args = cmd.args();
948 assert!(
949 !args.contains(&"secret payload".to_string()),
950 "prompt must not appear in args when prompt_via_stdin is set"
951 );
952 assert!(
953 !args.contains(&"--".to_string()),
954 "-- separator must be absent when prompt_via_stdin is set"
955 );
956 }
957
958 #[test]
959 fn prompt_via_stdin_false_keeps_prompt_in_args() {
960 let cmd = QueryCommand::new("visible prompt").prompt_via_stdin(false);
961 let args = cmd.args();
962 assert!(
963 args.contains(&"visible prompt".to_string()),
964 "prompt must still appear in args when prompt_via_stdin is false"
965 );
966 assert!(
967 args.contains(&"--".to_string()),
968 "-- separator must be present when prompt_via_stdin is false"
969 );
970 }
971
972 #[test]
973 #[cfg(feature = "async")] #[ignore = "requires a real claude binary"]
975 fn prompt_via_stdin_integration() {
976 use crate::{Claude, ClaudeCommand};
979 let rt = tokio::runtime::Runtime::new().unwrap();
980 rt.block_on(async {
981 let claude = Claude::builder().build().unwrap();
982 let out = QueryCommand::new("reply with: STDIN_OK")
983 .prompt_via_stdin(true)
984 .execute(&claude)
985 .await
986 .unwrap();
987 assert!(
988 !out.stdout.is_empty(),
989 "expected non-empty output from stdin-mode query"
990 );
991 });
992 }
993
994 #[test]
995 fn build_args_with_forced_json_inserts_flag_before_separator() {
996 let cmd = QueryCommand::new("hello");
1002 let args = cmd.build_args_with_forced_json();
1003
1004 assert_eq!(
1006 &args[args.len() - 2..],
1007 &["--".to_string(), "hello".to_string()],
1008 );
1009
1010 let sep = args.iter().position(|a| a == "--").expect("`--` present");
1012 let fmt = args
1013 .iter()
1014 .position(|a| a == "--output-format")
1015 .expect("--output-format present");
1016 assert!(
1017 fmt < sep,
1018 "--output-format must come before `--` separator; got {args:?}"
1019 );
1020 assert_eq!(args[fmt + 1], "json");
1021 }
1022
1023 #[test]
1024 fn build_args_with_forced_json_respects_explicit_format() {
1025 let cmd = QueryCommand::new("hello").output_format(OutputFormat::Text);
1028 let args = cmd.build_args_with_forced_json();
1029 let fmt = args
1030 .iter()
1031 .position(|a| a == "--output-format")
1032 .expect("--output-format present");
1033 assert_eq!(args[fmt + 1], "text");
1034 assert_eq!(args.iter().filter(|a| *a == "--output-format").count(), 1);
1036 }
1037
1038 #[test]
1039 #[allow(deprecated)] fn test_full_query_args() {
1041 let cmd = QueryCommand::new("explain this")
1042 .model("sonnet")
1043 .system_prompt("be concise")
1044 .output_format(OutputFormat::Json)
1045 .max_budget_usd(0.50)
1046 .permission_mode(PermissionMode::BypassPermissions)
1047 .allowed_tools(["Bash", "Read"])
1048 .mcp_config("/tmp/mcp.json")
1049 .effort(Effort::High)
1050 .max_turns(3)
1051 .no_session_persistence();
1052
1053 let args = cmd.args();
1054 assert!(args.contains(&"--print".to_string()));
1055 assert!(args.contains(&"--model".to_string()));
1056 assert!(args.contains(&"sonnet".to_string()));
1057 assert!(args.contains(&"--system-prompt".to_string()));
1058 assert!(args.contains(&"--output-format".to_string()));
1059 assert!(args.contains(&"json".to_string()));
1060 assert!(!args.contains(&"--verbose".to_string()));
1062 assert!(args.contains(&"--max-budget-usd".to_string()));
1063 assert!(args.contains(&"--permission-mode".to_string()));
1064 assert!(args.contains(&"bypassPermissions".to_string()));
1065 assert!(args.contains(&"--allowed-tools".to_string()));
1066 assert!(args.contains(&"Bash,Read".to_string()));
1067 assert!(args.contains(&"--effort".to_string()));
1068 assert!(args.contains(&"high".to_string()));
1069 assert!(args.contains(&"--max-turns".to_string()));
1070 assert!(args.contains(&"--no-session-persistence".to_string()));
1071 assert_eq!(args.last().unwrap(), "explain this");
1073 assert_eq!(args[args.len() - 2], "--");
1074 }
1075
1076 #[test]
1077 fn typed_patterns_render_in_allowed_tools() {
1078 use crate::ToolPattern;
1079
1080 let cmd = QueryCommand::new("hi")
1081 .allowed_tool(ToolPattern::tool("Read"))
1082 .allowed_tool(ToolPattern::tool_with_args("Bash", "git log:*"))
1083 .allowed_tool(ToolPattern::all("Write"))
1084 .allowed_tool(ToolPattern::mcp("srv", "*"));
1085
1086 let args = cmd.args();
1087 let joined = args
1088 .iter()
1089 .position(|a| a == "--allowed-tools")
1090 .map(|i| &args[i + 1])
1091 .unwrap();
1092 assert_eq!(joined, "Read,Bash(git log:*),Write(*),mcp__srv__*");
1093 }
1094
1095 #[test]
1096 fn disallowed_tool_singular_appends() {
1097 use crate::ToolPattern;
1098
1099 let cmd = QueryCommand::new("hi")
1100 .disallowed_tool("Write")
1101 .disallowed_tool(ToolPattern::tool_with_args("Bash", "rm*"));
1102
1103 let args = cmd.args();
1104 let joined = args
1105 .iter()
1106 .position(|a| a == "--disallowed-tools")
1107 .map(|i| &args[i + 1])
1108 .unwrap();
1109 assert_eq!(joined, "Write,Bash(rm*)");
1110 }
1111
1112 #[test]
1113 fn mixed_string_and_typed_patterns_both_accepted() {
1114 use crate::ToolPattern;
1115
1116 let strs: Vec<ToolPattern> = vec!["Bash".into(), ToolPattern::all("Read")];
1120 let cmd = QueryCommand::new("hi").allowed_tools(strs);
1121 assert!(cmd.args().contains(&"--allowed-tools".to_string()));
1122 }
1123
1124 #[test]
1125 fn new_bool_flags_emit_correct_cli_args() {
1126 let args = QueryCommand::new("hi")
1127 .bare()
1128 .disable_slash_commands()
1129 .include_hook_events()
1130 .exclude_dynamic_system_prompt_sections()
1131 .args();
1132 assert!(args.contains(&"--bare".to_string()));
1133 assert!(args.contains(&"--disable-slash-commands".to_string()));
1134 assert!(args.contains(&"--include-hook-events".to_string()));
1135 assert!(args.contains(&"--exclude-dynamic-system-prompt-sections".to_string()));
1136 }
1137
1138 #[test]
1139 fn name_flag_renders_with_value() {
1140 let args = QueryCommand::new("hi").name("my session").args();
1141 let pos = args.iter().position(|a| a == "--name").unwrap();
1142 assert_eq!(args[pos + 1], "my session");
1143 }
1144
1145 #[test]
1146 fn from_pr_flag_renders_with_value() {
1147 let args = QueryCommand::new("hi").from_pr("42").args();
1148 let pos = args.iter().position(|a| a == "--from-pr").unwrap();
1149 assert_eq!(args[pos + 1], "42");
1150 }
1151
1152 #[test]
1153 fn new_bool_flags_default_to_off() {
1154 let args = QueryCommand::new("hi").args();
1155 assert!(!args.contains(&"--bare".to_string()));
1156 assert!(!args.contains(&"--disable-slash-commands".to_string()));
1157 assert!(!args.contains(&"--include-hook-events".to_string()));
1158 assert!(!args.contains(&"--exclude-dynamic-system-prompt-sections".to_string()));
1159 assert!(!args.contains(&"--name".to_string()));
1160 }
1161
1162 #[test]
1163 fn test_separator_before_prompt_prevents_greedy_flag_parsing() {
1164 let cmd = QueryCommand::new("fix the bug")
1167 .allowed_tools(["Read", "Edit", "Bash(cargo *)"])
1168 .output_format(OutputFormat::StreamJson);
1169 let args = cmd.args();
1170 let sep_pos = args.iter().position(|a| a == "--").unwrap();
1172 let prompt_pos = args.iter().position(|a| a == "fix the bug").unwrap();
1173 assert_eq!(prompt_pos, sep_pos + 1, "prompt must follow -- separator");
1174 let tools_pos = args
1176 .iter()
1177 .position(|a| a.contains("Bash(cargo *)"))
1178 .unwrap();
1179 assert!(
1180 tools_pos < sep_pos,
1181 "allowed-tools must come before -- separator"
1182 );
1183 }
1184
1185 #[test]
1186 fn test_stream_json_includes_verbose() {
1187 let cmd = QueryCommand::new("test").output_format(OutputFormat::StreamJson);
1188 let args = cmd.args();
1189 assert!(args.contains(&"--output-format".to_string()));
1190 assert!(args.contains(&"stream-json".to_string()));
1191 assert!(args.contains(&"--verbose".to_string()));
1192 }
1193
1194 #[test]
1195 fn verbose_flag_emitted_when_set() {
1196 let args = QueryCommand::new("test").verbose(true).args();
1197 assert!(args.contains(&"--verbose".to_string()));
1198 }
1199
1200 #[test]
1201 fn verbose_absent_by_default_and_when_false() {
1202 assert!(
1203 !QueryCommand::new("test")
1204 .args()
1205 .contains(&"--verbose".to_string())
1206 );
1207 assert!(
1208 !QueryCommand::new("test")
1209 .verbose(false)
1210 .args()
1211 .contains(&"--verbose".to_string())
1212 );
1213 }
1214
1215 #[test]
1216 fn verbose_not_duplicated_with_stream_json() {
1217 let cmd = QueryCommand::new("test")
1220 .verbose(true)
1221 .output_format(OutputFormat::StreamJson);
1222 let count = cmd.args().iter().filter(|a| *a == "--verbose").count();
1223 assert_eq!(count, 1, "--verbose must appear exactly once");
1224 }
1225
1226 #[test]
1227 fn prompt_suggestions_flag_emitted_when_set() {
1228 let args = QueryCommand::new("test").prompt_suggestions(true).args();
1229 assert!(args.contains(&"--prompt-suggestions".to_string()));
1230 let sep = args.iter().position(|a| a == "--").unwrap();
1233 let flag = args
1234 .iter()
1235 .position(|a| a == "--prompt-suggestions")
1236 .unwrap();
1237 assert!(flag < sep, "--prompt-suggestions must precede `--`");
1238 }
1239
1240 #[test]
1241 fn prompt_suggestions_absent_by_default_and_when_false() {
1242 assert!(
1243 !QueryCommand::new("test")
1244 .args()
1245 .contains(&"--prompt-suggestions".to_string())
1246 );
1247 assert!(
1248 !QueryCommand::new("test")
1249 .prompt_suggestions(false)
1250 .args()
1251 .contains(&"--prompt-suggestions".to_string())
1252 );
1253 }
1254
1255 #[test]
1256 fn replay_user_messages_flag_emitted_when_set() {
1257 let args = QueryCommand::new("test").replay_user_messages(true).args();
1258 assert!(args.contains(&"--replay-user-messages".to_string()));
1259 }
1260
1261 #[test]
1262 fn replay_user_messages_absent_by_default_and_when_false() {
1263 assert!(
1264 !QueryCommand::new("test")
1265 .args()
1266 .contains(&"--replay-user-messages".to_string())
1267 );
1268 assert!(
1269 !QueryCommand::new("test")
1270 .replay_user_messages(false)
1271 .args()
1272 .contains(&"--replay-user-messages".to_string())
1273 );
1274 }
1275
1276 #[test]
1277 fn test_to_command_string_simple() {
1278 let claude = Claude::builder()
1279 .binary("/usr/local/bin/claude")
1280 .build()
1281 .unwrap();
1282
1283 let cmd = QueryCommand::new("hello");
1284 let command_str = cmd.to_command_string(&claude);
1285
1286 assert!(command_str.starts_with("/usr/local/bin/claude"));
1287 assert!(command_str.contains("--print"));
1288 assert!(command_str.contains("hello"));
1289 }
1290
1291 #[test]
1292 fn test_to_command_string_with_spaces() {
1293 let claude = Claude::builder()
1294 .binary("/usr/local/bin/claude")
1295 .build()
1296 .unwrap();
1297
1298 let cmd = QueryCommand::new("hello world").model("sonnet");
1299 let command_str = cmd.to_command_string(&claude);
1300
1301 assert!(command_str.starts_with("/usr/local/bin/claude"));
1302 assert!(command_str.contains("--print"));
1303 assert!(command_str.contains("'hello world'"));
1305 assert!(command_str.contains("--model"));
1306 assert!(command_str.contains("sonnet"));
1307 }
1308
1309 #[test]
1310 fn test_to_command_string_with_special_chars() {
1311 let claude = Claude::builder()
1312 .binary("/usr/local/bin/claude")
1313 .build()
1314 .unwrap();
1315
1316 let cmd = QueryCommand::new("test $VAR and `cmd`");
1317 let command_str = cmd.to_command_string(&claude);
1318
1319 assert!(command_str.contains("'test $VAR and `cmd`'"));
1321 }
1322
1323 #[test]
1324 fn test_to_command_string_with_single_quotes() {
1325 let claude = Claude::builder()
1326 .binary("/usr/local/bin/claude")
1327 .build()
1328 .unwrap();
1329
1330 let cmd = QueryCommand::new("it's");
1331 let command_str = cmd.to_command_string(&claude);
1332
1333 assert!(command_str.contains("'it'\\''s'"));
1335 }
1336
1337 #[test]
1338 fn test_worktree_flag() {
1339 let cmd = QueryCommand::new("test").worktree();
1340 let args = cmd.args();
1341 assert!(args.contains(&"--worktree".to_string()));
1342 }
1343
1344 #[test]
1345 fn test_worktree_named() {
1346 let cmd = QueryCommand::new("test").worktree_named("feature-x");
1347 let args = cmd.args();
1348 assert!(
1349 args.windows(2).any(|w| w == ["--worktree", "feature-x"]),
1350 "missing --worktree feature-x in {args:?}"
1351 );
1352 }
1353
1354 #[test]
1355 fn test_brief_flag() {
1356 let cmd = QueryCommand::new("test").brief();
1357 let args = cmd.args();
1358 assert!(args.contains(&"--brief".to_string()));
1359 }
1360
1361 #[test]
1362 fn test_debug_filter() {
1363 let cmd = QueryCommand::new("test").debug_filter("api,hooks");
1364 let args = cmd.args();
1365 assert!(args.contains(&"--debug".to_string()));
1366 assert!(args.contains(&"api,hooks".to_string()));
1367 }
1368
1369 #[test]
1370 fn test_debug_file() {
1371 let cmd = QueryCommand::new("test").debug_file("/tmp/debug.log");
1372 let args = cmd.args();
1373 assert!(args.contains(&"--debug-file".to_string()));
1374 assert!(args.contains(&"/tmp/debug.log".to_string()));
1375 }
1376
1377 #[test]
1378 fn test_betas() {
1379 let cmd = QueryCommand::new("test").betas("feature-x");
1380 let args = cmd.args();
1381 assert!(args.contains(&"--betas".to_string()));
1382 assert!(args.contains(&"feature-x".to_string()));
1383 }
1384
1385 #[test]
1386 fn test_plugin_dir_single() {
1387 let cmd = QueryCommand::new("test").plugin_dir("/plugins/foo");
1388 let args = cmd.args();
1389 assert!(args.contains(&"--plugin-dir".to_string()));
1390 assert!(args.contains(&"/plugins/foo".to_string()));
1391 }
1392
1393 #[test]
1394 fn test_plugin_dir_multiple() {
1395 let cmd = QueryCommand::new("test")
1396 .plugin_dir("/plugins/foo")
1397 .plugin_dir("/plugins/bar");
1398 let args = cmd.args();
1399 let plugin_dir_count = args.iter().filter(|a| *a == "--plugin-dir").count();
1400 assert_eq!(plugin_dir_count, 2);
1401 assert!(args.contains(&"/plugins/foo".to_string()));
1402 assert!(args.contains(&"/plugins/bar".to_string()));
1403 }
1404
1405 #[test]
1406 fn test_plugin_url_single() {
1407 let cmd = QueryCommand::new("test").plugin_url("https://example.com/p.zip");
1408 let args = cmd.args();
1409 assert!(args.contains(&"--plugin-url".to_string()));
1410 assert!(args.contains(&"https://example.com/p.zip".to_string()));
1411 }
1412
1413 #[test]
1414 fn test_plugin_url_multiple() {
1415 let cmd = QueryCommand::new("test")
1416 .plugin_url("https://example.com/a.zip")
1417 .plugin_url("https://example.com/b.zip");
1418 let args = cmd.args();
1419 let plugin_url_count = args.iter().filter(|a| *a == "--plugin-url").count();
1420 assert_eq!(plugin_url_count, 2);
1421 assert!(args.contains(&"https://example.com/a.zip".to_string()));
1422 assert!(args.contains(&"https://example.com/b.zip".to_string()));
1423 }
1424
1425 #[test]
1426 fn test_safe_mode_flag() {
1427 let cmd = QueryCommand::new("test").safe_mode();
1428 let args = cmd.args();
1429 assert!(args.contains(&"--safe-mode".to_string()));
1430 }
1431
1432 #[test]
1433 fn test_safe_mode_absent_by_default() {
1434 let cmd = QueryCommand::new("test");
1435 let args = cmd.args();
1436 assert!(!args.contains(&"--safe-mode".to_string()));
1437 }
1438
1439 #[test]
1440 fn test_setting_sources() {
1441 let cmd = QueryCommand::new("test").setting_sources("user,project,local");
1442 let args = cmd.args();
1443 assert!(args.contains(&"--setting-sources".to_string()));
1444 assert!(args.contains(&"user,project,local".to_string()));
1445 }
1446
1447 #[test]
1448 fn test_tmux_flag() {
1449 let cmd = QueryCommand::new("test").tmux();
1450 let args = cmd.args();
1451 assert!(args.contains(&"--tmux".to_string()));
1452 }
1453
1454 #[test]
1457 fn shell_quote_plain_word_is_unchanged() {
1458 assert_eq!(shell_quote("simple"), "simple");
1459 assert_eq!(shell_quote(""), "");
1460 assert_eq!(shell_quote("file.rs"), "file.rs");
1461 }
1462
1463 #[test]
1464 fn shell_quote_whitespace_gets_single_quoted() {
1465 assert_eq!(shell_quote("hello world"), "'hello world'");
1466 assert_eq!(shell_quote("a\tb"), "'a\tb'");
1467 }
1468
1469 #[test]
1470 fn shell_quote_metacharacters_get_quoted() {
1471 assert_eq!(shell_quote("a|b"), "'a|b'");
1472 assert_eq!(shell_quote("$VAR"), "'$VAR'");
1473 assert_eq!(shell_quote("a;b"), "'a;b'");
1474 assert_eq!(shell_quote("(x)"), "'(x)'");
1475 }
1476
1477 #[test]
1478 fn shell_quote_embedded_single_quote_is_escaped() {
1479 assert_eq!(shell_quote("it's"), "'it'\\''s'");
1480 }
1481
1482 #[test]
1483 fn shell_quote_double_quote_gets_single_quoted() {
1484 assert_eq!(shell_quote(r#"say "hi""#), r#"'say "hi"'"#);
1485 }
1486}