Skip to main content

claude_wrapper/command/
query.rs

1use crate::Claude;
2use crate::command::ClaudeCommand;
3use crate::error::Result;
4use crate::exec::{self, CommandOutput};
5use crate::types::{Effort, InputFormat, OutputFormat, PermissionMode};
6
7/// Builder for `claude -p <prompt>` (oneshot print-mode queries).
8///
9/// This is the primary command for programmatic use. It runs a single
10/// prompt through Claude and returns the result.
11///
12/// # Example
13///
14/// ```no_run
15/// use claude_wrapper::{Claude, ClaudeCommand, QueryCommand, OutputFormat};
16///
17/// # async fn example() -> claude_wrapper::Result<()> {
18/// let claude = Claude::builder().build()?;
19///
20/// let output = QueryCommand::new("explain this error: file not found")
21///     .model("sonnet")
22///     .output_format(OutputFormat::Json)
23///     .max_turns(1)
24///     .execute(&claude)
25///     .await?;
26/// # Ok(())
27/// # }
28/// ```
29#[derive(Debug, Clone)]
30pub struct QueryCommand {
31    prompt: String,
32    model: Option<String>,
33    system_prompt: Option<String>,
34    append_system_prompt: Option<String>,
35    output_format: Option<OutputFormat>,
36    max_budget_usd: Option<f64>,
37    permission_mode: Option<PermissionMode>,
38    allowed_tools: Vec<String>,
39    disallowed_tools: Vec<String>,
40    mcp_config: Vec<String>,
41    add_dir: Vec<String>,
42    effort: Option<Effort>,
43    max_turns: Option<u32>,
44    json_schema: Option<String>,
45    continue_session: bool,
46    resume: Option<String>,
47    session_id: Option<String>,
48    fallback_model: Option<String>,
49    no_session_persistence: bool,
50    dangerously_skip_permissions: bool,
51    agent: Option<String>,
52    agents_json: Option<String>,
53    tools: Vec<String>,
54    file: Vec<String>,
55    include_partial_messages: bool,
56    input_format: Option<InputFormat>,
57    strict_mcp_config: bool,
58    settings: Option<String>,
59    fork_session: bool,
60    retry_policy: Option<crate::retry::RetryPolicy>,
61    worktree: bool,
62    brief: bool,
63    debug_filter: Option<String>,
64    debug_file: Option<String>,
65    betas: Option<String>,
66    plugin_dirs: Vec<String>,
67    setting_sources: Option<String>,
68    tmux: bool,
69}
70
71impl QueryCommand {
72    /// Create a new query command with the given prompt.
73    #[must_use]
74    pub fn new(prompt: impl Into<String>) -> Self {
75        Self {
76            prompt: prompt.into(),
77            model: None,
78            system_prompt: None,
79            append_system_prompt: None,
80            output_format: None,
81            max_budget_usd: None,
82            permission_mode: None,
83            allowed_tools: Vec::new(),
84            disallowed_tools: Vec::new(),
85            mcp_config: Vec::new(),
86            add_dir: Vec::new(),
87            effort: None,
88            max_turns: None,
89            json_schema: None,
90            continue_session: false,
91            resume: None,
92            session_id: None,
93            fallback_model: None,
94            no_session_persistence: false,
95            dangerously_skip_permissions: false,
96            agent: None,
97            agents_json: None,
98            tools: Vec::new(),
99            file: Vec::new(),
100            include_partial_messages: false,
101            input_format: None,
102            strict_mcp_config: false,
103            settings: None,
104            fork_session: false,
105            retry_policy: None,
106            worktree: false,
107            brief: false,
108            debug_filter: None,
109            debug_file: None,
110            betas: None,
111            plugin_dirs: Vec::new(),
112            setting_sources: None,
113            tmux: false,
114        }
115    }
116
117    /// Set the model to use (e.g. "sonnet", "opus", or a full model ID).
118    #[must_use]
119    pub fn model(mut self, model: impl Into<String>) -> Self {
120        self.model = Some(model.into());
121        self
122    }
123
124    /// Set a custom system prompt (replaces the default).
125    #[must_use]
126    pub fn system_prompt(mut self, prompt: impl Into<String>) -> Self {
127        self.system_prompt = Some(prompt.into());
128        self
129    }
130
131    /// Append to the default system prompt.
132    #[must_use]
133    pub fn append_system_prompt(mut self, prompt: impl Into<String>) -> Self {
134        self.append_system_prompt = Some(prompt.into());
135        self
136    }
137
138    /// Set the output format.
139    #[must_use]
140    pub fn output_format(mut self, format: OutputFormat) -> Self {
141        self.output_format = Some(format);
142        self
143    }
144
145    /// Set the maximum budget in USD.
146    #[must_use]
147    pub fn max_budget_usd(mut self, budget: f64) -> Self {
148        self.max_budget_usd = Some(budget);
149        self
150    }
151
152    /// Set the permission mode.
153    #[must_use]
154    pub fn permission_mode(mut self, mode: PermissionMode) -> Self {
155        self.permission_mode = Some(mode);
156        self
157    }
158
159    /// Add allowed tools (e.g. "Bash", "Read", "mcp__my-server__*").
160    #[must_use]
161    pub fn allowed_tools(mut self, tools: impl IntoIterator<Item = impl Into<String>>) -> Self {
162        self.allowed_tools.extend(tools.into_iter().map(Into::into));
163        self
164    }
165
166    /// Add a single allowed tool.
167    #[must_use]
168    pub fn allowed_tool(mut self, tool: impl Into<String>) -> Self {
169        self.allowed_tools.push(tool.into());
170        self
171    }
172
173    /// Add disallowed tools.
174    #[must_use]
175    pub fn disallowed_tools(mut self, tools: impl IntoIterator<Item = impl Into<String>>) -> Self {
176        self.disallowed_tools
177            .extend(tools.into_iter().map(Into::into));
178        self
179    }
180
181    /// Add an MCP config file path.
182    #[must_use]
183    pub fn mcp_config(mut self, path: impl Into<String>) -> Self {
184        self.mcp_config.push(path.into());
185        self
186    }
187
188    /// Add an additional directory for tool access.
189    #[must_use]
190    pub fn add_dir(mut self, dir: impl Into<String>) -> Self {
191        self.add_dir.push(dir.into());
192        self
193    }
194
195    /// Set the effort level.
196    #[must_use]
197    pub fn effort(mut self, effort: Effort) -> Self {
198        self.effort = Some(effort);
199        self
200    }
201
202    /// Set the maximum number of turns.
203    #[must_use]
204    pub fn max_turns(mut self, turns: u32) -> Self {
205        self.max_turns = Some(turns);
206        self
207    }
208
209    /// Set a JSON schema for structured output validation.
210    #[must_use]
211    pub fn json_schema(mut self, schema: impl Into<String>) -> Self {
212        self.json_schema = Some(schema.into());
213        self
214    }
215
216    /// Continue the most recent conversation.
217    #[must_use]
218    pub fn continue_session(mut self) -> Self {
219        self.continue_session = true;
220        self
221    }
222
223    /// Resume a specific session by ID.
224    #[must_use]
225    pub fn resume(mut self, session_id: impl Into<String>) -> Self {
226        self.resume = Some(session_id.into());
227        self
228    }
229
230    /// Use a specific session ID.
231    #[must_use]
232    pub fn session_id(mut self, id: impl Into<String>) -> Self {
233        self.session_id = Some(id.into());
234        self
235    }
236
237    /// Clear every session-related flag and set `--resume` to the given id.
238    ///
239    /// Used by `Session::execute` to override whatever session flags the
240    /// caller may have set on their command (including a stale `--resume`,
241    /// `--continue`, `--session-id`, or `--fork-session`). Keeping the
242    /// override logic in one place prevents conflicting flags from reaching
243    /// the CLI.
244    pub(crate) fn replace_session(mut self, id: impl Into<String>) -> Self {
245        self.continue_session = false;
246        self.resume = Some(id.into());
247        self.session_id = None;
248        self.fork_session = false;
249        self
250    }
251
252    /// Set a fallback model for when the primary model is overloaded.
253    #[must_use]
254    pub fn fallback_model(mut self, model: impl Into<String>) -> Self {
255        self.fallback_model = Some(model.into());
256        self
257    }
258
259    /// Disable session persistence (sessions won't be saved to disk).
260    #[must_use]
261    pub fn no_session_persistence(mut self) -> Self {
262        self.no_session_persistence = true;
263        self
264    }
265
266    /// Bypass all permission checks. Only use in sandboxed environments.
267    #[must_use]
268    pub fn dangerously_skip_permissions(mut self) -> Self {
269        self.dangerously_skip_permissions = true;
270        self
271    }
272
273    /// Set the agent for the session.
274    #[must_use]
275    pub fn agent(mut self, agent: impl Into<String>) -> Self {
276        self.agent = Some(agent.into());
277        self
278    }
279
280    /// Set custom agents as a JSON object.
281    ///
282    /// Example: `{"reviewer": {"description": "Reviews code", "prompt": "You are a code reviewer"}}`
283    #[must_use]
284    pub fn agents_json(mut self, json: impl Into<String>) -> Self {
285        self.agents_json = Some(json.into());
286        self
287    }
288
289    /// Set the list of available built-in tools.
290    ///
291    /// Use `""` to disable all tools, `"default"` for all tools, or
292    /// specific tool names like `["Bash", "Edit", "Read"]`.
293    /// This is different from `allowed_tools` which controls MCP tool permissions.
294    #[must_use]
295    pub fn tools(mut self, tools: impl IntoIterator<Item = impl Into<String>>) -> Self {
296        self.tools.extend(tools.into_iter().map(Into::into));
297        self
298    }
299
300    /// Add a file resource to download at startup.
301    ///
302    /// Format: `file_id:relative_path` (e.g. `file_abc:doc.txt`).
303    #[must_use]
304    pub fn file(mut self, spec: impl Into<String>) -> Self {
305        self.file.push(spec.into());
306        self
307    }
308
309    /// Include partial message chunks as they arrive.
310    ///
311    /// Only works with `--output-format stream-json`.
312    #[must_use]
313    pub fn include_partial_messages(mut self) -> Self {
314        self.include_partial_messages = true;
315        self
316    }
317
318    /// Set the input format.
319    #[must_use]
320    pub fn input_format(mut self, format: InputFormat) -> Self {
321        self.input_format = Some(format);
322        self
323    }
324
325    /// Only use MCP servers from `--mcp-config`, ignoring all other MCP configurations.
326    #[must_use]
327    pub fn strict_mcp_config(mut self) -> Self {
328        self.strict_mcp_config = true;
329        self
330    }
331
332    /// Path to a settings JSON file or a JSON string.
333    #[must_use]
334    pub fn settings(mut self, settings: impl Into<String>) -> Self {
335        self.settings = Some(settings.into());
336        self
337    }
338
339    /// When resuming, create a new session ID instead of reusing the original.
340    #[must_use]
341    pub fn fork_session(mut self) -> Self {
342        self.fork_session = true;
343        self
344    }
345
346    /// Create a new git worktree for this session, providing an isolated working directory.
347    #[must_use]
348    pub fn worktree(mut self) -> Self {
349        self.worktree = true;
350        self
351    }
352
353    /// Enable brief mode, which activates the SendUserMessage tool for agent-to-user communication.
354    #[must_use]
355    pub fn brief(mut self) -> Self {
356        self.brief = true;
357        self
358    }
359
360    /// Enable debug logging with an optional filter (e.g., "api,hooks").
361    #[must_use]
362    pub fn debug_filter(mut self, filter: impl Into<String>) -> Self {
363        self.debug_filter = Some(filter.into());
364        self
365    }
366
367    /// Write debug logs to the specified file path.
368    #[must_use]
369    pub fn debug_file(mut self, path: impl Into<String>) -> Self {
370        self.debug_file = Some(path.into());
371        self
372    }
373
374    /// Beta feature headers for API key authentication.
375    #[must_use]
376    pub fn betas(mut self, betas: impl Into<String>) -> Self {
377        self.betas = Some(betas.into());
378        self
379    }
380
381    /// Load plugins from the specified directory for this session.
382    #[must_use]
383    pub fn plugin_dir(mut self, dir: impl Into<String>) -> Self {
384        self.plugin_dirs.push(dir.into());
385        self
386    }
387
388    /// Comma-separated list of setting sources to load (e.g., "user,project,local").
389    #[must_use]
390    pub fn setting_sources(mut self, sources: impl Into<String>) -> Self {
391        self.setting_sources = Some(sources.into());
392        self
393    }
394
395    /// Create a tmux session for the worktree.
396    #[must_use]
397    pub fn tmux(mut self) -> Self {
398        self.tmux = true;
399        self
400    }
401
402    /// Set a per-command retry policy, overriding the client default.
403    ///
404    /// # Example
405    ///
406    /// ```no_run
407    /// use claude_wrapper::{Claude, ClaudeCommand, QueryCommand, RetryPolicy};
408    /// use std::time::Duration;
409    ///
410    /// # async fn example() -> claude_wrapper::Result<()> {
411    /// let claude = Claude::builder().build()?;
412    ///
413    /// let output = QueryCommand::new("explain quicksort")
414    ///     .retry(RetryPolicy::new()
415    ///         .max_attempts(5)
416    ///         .initial_backoff(Duration::from_secs(2))
417    ///         .exponential()
418    ///         .retry_on_timeout(true))
419    ///     .execute(&claude)
420    ///     .await?;
421    /// # Ok(())
422    /// # }
423    /// ```
424    #[must_use]
425    pub fn retry(mut self, policy: crate::retry::RetryPolicy) -> Self {
426        self.retry_policy = Some(policy);
427        self
428    }
429
430    /// Return the full command as a string that could be run in a shell.
431    ///
432    /// Constructs a command string using the binary path from the Claude instance
433    /// and the arguments from this query. Arguments containing spaces or special
434    /// shell characters are shell-quoted to be safe for shell execution.
435    ///
436    /// # Example
437    ///
438    /// ```no_run
439    /// use claude_wrapper::{Claude, QueryCommand};
440    ///
441    /// # async fn example() -> claude_wrapper::Result<()> {
442    /// let claude = Claude::builder().build()?;
443    ///
444    /// let cmd = QueryCommand::new("explain quicksort")
445    ///     .model("sonnet");
446    ///
447    /// let command_str = cmd.to_command_string(&claude);
448    /// println!("Would run: {}", command_str);
449    /// # Ok(())
450    /// # }
451    /// ```
452    pub fn to_command_string(&self, claude: &Claude) -> String {
453        let args = self.build_args();
454        let quoted_args = args.iter().map(|arg| shell_quote(arg)).collect::<Vec<_>>();
455        format!("{} {}", claude.binary().display(), quoted_args.join(" "))
456    }
457
458    /// Execute the query and parse the JSON result.
459    ///
460    /// This is a convenience method that sets `OutputFormat::Json` and
461    /// deserializes the response into a [`QueryResult`](crate::types::QueryResult).
462    #[cfg(feature = "json")]
463    pub async fn execute_json(&self, claude: &Claude) -> Result<crate::types::QueryResult> {
464        // Build args with JSON output format forced
465        let mut args = self.build_args();
466
467        // Override output format to json if not already set
468        if self.output_format.is_none() {
469            args.push("--output-format".to_string());
470            args.push("json".to_string());
471        }
472
473        let output = exec::run_claude_with_retry(claude, args, self.retry_policy.as_ref()).await?;
474
475        serde_json::from_str(&output.stdout).map_err(|e| crate::error::Error::Json {
476            message: format!("failed to parse query result: {e}"),
477            source: e,
478        })
479    }
480
481    fn build_args(&self) -> Vec<String> {
482        let mut args = vec!["--print".to_string()];
483
484        if let Some(ref model) = self.model {
485            args.push("--model".to_string());
486            args.push(model.clone());
487        }
488
489        if let Some(ref prompt) = self.system_prompt {
490            args.push("--system-prompt".to_string());
491            args.push(prompt.clone());
492        }
493
494        if let Some(ref prompt) = self.append_system_prompt {
495            args.push("--append-system-prompt".to_string());
496            args.push(prompt.clone());
497        }
498
499        if let Some(ref format) = self.output_format {
500            args.push("--output-format".to_string());
501            args.push(format.as_arg().to_string());
502            // CLI v2.1.72+ requires --verbose when using stream-json with --print
503            if matches!(format, OutputFormat::StreamJson) {
504                args.push("--verbose".to_string());
505            }
506        }
507
508        if let Some(budget) = self.max_budget_usd {
509            args.push("--max-budget-usd".to_string());
510            args.push(budget.to_string());
511        }
512
513        if let Some(ref mode) = self.permission_mode {
514            args.push("--permission-mode".to_string());
515            args.push(mode.as_arg().to_string());
516        }
517
518        if !self.allowed_tools.is_empty() {
519            args.push("--allowed-tools".to_string());
520            args.push(self.allowed_tools.join(","));
521        }
522
523        if !self.disallowed_tools.is_empty() {
524            args.push("--disallowed-tools".to_string());
525            args.push(self.disallowed_tools.join(","));
526        }
527
528        for config in &self.mcp_config {
529            args.push("--mcp-config".to_string());
530            args.push(config.clone());
531        }
532
533        for dir in &self.add_dir {
534            args.push("--add-dir".to_string());
535            args.push(dir.clone());
536        }
537
538        if let Some(ref effort) = self.effort {
539            args.push("--effort".to_string());
540            args.push(effort.as_arg().to_string());
541        }
542
543        if let Some(turns) = self.max_turns {
544            args.push("--max-turns".to_string());
545            args.push(turns.to_string());
546        }
547
548        if let Some(ref schema) = self.json_schema {
549            args.push("--json-schema".to_string());
550            args.push(schema.clone());
551        }
552
553        if self.continue_session {
554            args.push("--continue".to_string());
555        }
556
557        if let Some(ref session_id) = self.resume {
558            args.push("--resume".to_string());
559            args.push(session_id.clone());
560        }
561
562        if let Some(ref id) = self.session_id {
563            args.push("--session-id".to_string());
564            args.push(id.clone());
565        }
566
567        if let Some(ref model) = self.fallback_model {
568            args.push("--fallback-model".to_string());
569            args.push(model.clone());
570        }
571
572        if self.no_session_persistence {
573            args.push("--no-session-persistence".to_string());
574        }
575
576        if self.dangerously_skip_permissions {
577            args.push("--dangerously-skip-permissions".to_string());
578        }
579
580        if let Some(ref agent) = self.agent {
581            args.push("--agent".to_string());
582            args.push(agent.clone());
583        }
584
585        if let Some(ref agents) = self.agents_json {
586            args.push("--agents".to_string());
587            args.push(agents.clone());
588        }
589
590        if !self.tools.is_empty() {
591            args.push("--tools".to_string());
592            args.push(self.tools.join(","));
593        }
594
595        for spec in &self.file {
596            args.push("--file".to_string());
597            args.push(spec.clone());
598        }
599
600        if self.include_partial_messages {
601            args.push("--include-partial-messages".to_string());
602        }
603
604        if let Some(ref format) = self.input_format {
605            args.push("--input-format".to_string());
606            args.push(format.as_arg().to_string());
607        }
608
609        if self.strict_mcp_config {
610            args.push("--strict-mcp-config".to_string());
611        }
612
613        if let Some(ref settings) = self.settings {
614            args.push("--settings".to_string());
615            args.push(settings.clone());
616        }
617
618        if self.fork_session {
619            args.push("--fork-session".to_string());
620        }
621
622        if self.worktree {
623            args.push("--worktree".to_string());
624        }
625
626        if self.brief {
627            args.push("--brief".to_string());
628        }
629
630        if let Some(ref filter) = self.debug_filter {
631            args.push("--debug".to_string());
632            args.push(filter.clone());
633        }
634
635        if let Some(ref path) = self.debug_file {
636            args.push("--debug-file".to_string());
637            args.push(path.clone());
638        }
639
640        if let Some(ref betas) = self.betas {
641            args.push("--betas".to_string());
642            args.push(betas.clone());
643        }
644
645        for dir in &self.plugin_dirs {
646            args.push("--plugin-dir".to_string());
647            args.push(dir.clone());
648        }
649
650        if let Some(ref sources) = self.setting_sources {
651            args.push("--setting-sources".to_string());
652            args.push(sources.clone());
653        }
654
655        if self.tmux {
656            args.push("--tmux".to_string());
657        }
658
659        // Separator to prevent flags like --allowed-tools from consuming the prompt.
660        args.push("--".to_string());
661        args.push(self.prompt.clone());
662
663        args
664    }
665}
666
667impl ClaudeCommand for QueryCommand {
668    type Output = CommandOutput;
669
670    fn args(&self) -> Vec<String> {
671        self.build_args()
672    }
673
674    async fn execute(&self, claude: &Claude) -> Result<CommandOutput> {
675        exec::run_claude_with_retry(claude, self.args(), self.retry_policy.as_ref()).await
676    }
677}
678
679/// Shell-quote an argument if it contains spaces or special characters.
680fn shell_quote(arg: &str) -> String {
681    // Check if the argument needs quoting (contains whitespace or shell metacharacters)
682    if arg.contains(|c: char| c.is_whitespace() || "\"'$\\`|;<>&()[]{}".contains(c)) {
683        // Use single quotes and escape any existing single quotes
684        format!("'{}'", arg.replace("'", "'\\''"))
685    } else {
686        arg.to_string()
687    }
688}
689
690#[cfg(test)]
691mod tests {
692    use super::*;
693
694    #[test]
695    fn test_basic_query_args() {
696        let cmd = QueryCommand::new("hello world");
697        let args = cmd.args();
698        assert_eq!(args, vec!["--print", "--", "hello world"]);
699    }
700
701    #[test]
702    fn test_full_query_args() {
703        let cmd = QueryCommand::new("explain this")
704            .model("sonnet")
705            .system_prompt("be concise")
706            .output_format(OutputFormat::Json)
707            .max_budget_usd(0.50)
708            .permission_mode(PermissionMode::BypassPermissions)
709            .allowed_tools(["Bash", "Read"])
710            .mcp_config("/tmp/mcp.json")
711            .effort(Effort::High)
712            .max_turns(3)
713            .no_session_persistence();
714
715        let args = cmd.args();
716        assert!(args.contains(&"--print".to_string()));
717        assert!(args.contains(&"--model".to_string()));
718        assert!(args.contains(&"sonnet".to_string()));
719        assert!(args.contains(&"--system-prompt".to_string()));
720        assert!(args.contains(&"--output-format".to_string()));
721        assert!(args.contains(&"json".to_string()));
722        // json format should NOT include --verbose (only stream-json needs it)
723        assert!(!args.contains(&"--verbose".to_string()));
724        assert!(args.contains(&"--max-budget-usd".to_string()));
725        assert!(args.contains(&"--permission-mode".to_string()));
726        assert!(args.contains(&"bypassPermissions".to_string()));
727        assert!(args.contains(&"--allowed-tools".to_string()));
728        assert!(args.contains(&"Bash,Read".to_string()));
729        assert!(args.contains(&"--effort".to_string()));
730        assert!(args.contains(&"high".to_string()));
731        assert!(args.contains(&"--max-turns".to_string()));
732        assert!(args.contains(&"--no-session-persistence".to_string()));
733        // Prompt is last, preceded by -- separator
734        assert_eq!(args.last().unwrap(), "explain this");
735        assert_eq!(args[args.len() - 2], "--");
736    }
737
738    #[test]
739    fn test_separator_before_prompt_prevents_greedy_flag_parsing() {
740        // Regression: --allowed-tools was consuming the prompt as a tool name
741        // when the prompt appeared after it without a -- separator.
742        let cmd = QueryCommand::new("fix the bug")
743            .allowed_tools(["Read", "Edit", "Bash(cargo *)"])
744            .output_format(OutputFormat::StreamJson);
745        let args = cmd.args();
746        // -- separator must appear before the prompt
747        let sep_pos = args.iter().position(|a| a == "--").unwrap();
748        let prompt_pos = args.iter().position(|a| a == "fix the bug").unwrap();
749        assert_eq!(prompt_pos, sep_pos + 1, "prompt must follow -- separator");
750        // --allowed-tools value must appear before the separator
751        let tools_pos = args
752            .iter()
753            .position(|a| a.contains("Bash(cargo *)"))
754            .unwrap();
755        assert!(
756            tools_pos < sep_pos,
757            "allowed-tools must come before -- separator"
758        );
759    }
760
761    #[test]
762    fn test_stream_json_includes_verbose() {
763        let cmd = QueryCommand::new("test").output_format(OutputFormat::StreamJson);
764        let args = cmd.args();
765        assert!(args.contains(&"--output-format".to_string()));
766        assert!(args.contains(&"stream-json".to_string()));
767        assert!(args.contains(&"--verbose".to_string()));
768    }
769
770    #[test]
771    fn test_to_command_string_simple() {
772        let claude = Claude::builder()
773            .binary("/usr/local/bin/claude")
774            .build()
775            .unwrap();
776
777        let cmd = QueryCommand::new("hello");
778        let command_str = cmd.to_command_string(&claude);
779
780        assert!(command_str.starts_with("/usr/local/bin/claude"));
781        assert!(command_str.contains("--print"));
782        assert!(command_str.contains("hello"));
783    }
784
785    #[test]
786    fn test_to_command_string_with_spaces() {
787        let claude = Claude::builder()
788            .binary("/usr/local/bin/claude")
789            .build()
790            .unwrap();
791
792        let cmd = QueryCommand::new("hello world").model("sonnet");
793        let command_str = cmd.to_command_string(&claude);
794
795        assert!(command_str.starts_with("/usr/local/bin/claude"));
796        assert!(command_str.contains("--print"));
797        // Prompt with spaces should be quoted
798        assert!(command_str.contains("'hello world'"));
799        assert!(command_str.contains("--model"));
800        assert!(command_str.contains("sonnet"));
801    }
802
803    #[test]
804    fn test_to_command_string_with_special_chars() {
805        let claude = Claude::builder()
806            .binary("/usr/local/bin/claude")
807            .build()
808            .unwrap();
809
810        let cmd = QueryCommand::new("test $VAR and `cmd`");
811        let command_str = cmd.to_command_string(&claude);
812
813        // Arguments with special shell characters should be quoted
814        assert!(command_str.contains("'test $VAR and `cmd`'"));
815    }
816
817    #[test]
818    fn test_to_command_string_with_single_quotes() {
819        let claude = Claude::builder()
820            .binary("/usr/local/bin/claude")
821            .build()
822            .unwrap();
823
824        let cmd = QueryCommand::new("it's");
825        let command_str = cmd.to_command_string(&claude);
826
827        // Single quotes should be escaped in shell
828        assert!(command_str.contains("'it'\\''s'"));
829    }
830
831    #[test]
832    fn test_worktree_flag() {
833        let cmd = QueryCommand::new("test").worktree();
834        let args = cmd.args();
835        assert!(args.contains(&"--worktree".to_string()));
836    }
837
838    #[test]
839    fn test_brief_flag() {
840        let cmd = QueryCommand::new("test").brief();
841        let args = cmd.args();
842        assert!(args.contains(&"--brief".to_string()));
843    }
844
845    #[test]
846    fn test_debug_filter() {
847        let cmd = QueryCommand::new("test").debug_filter("api,hooks");
848        let args = cmd.args();
849        assert!(args.contains(&"--debug".to_string()));
850        assert!(args.contains(&"api,hooks".to_string()));
851    }
852
853    #[test]
854    fn test_debug_file() {
855        let cmd = QueryCommand::new("test").debug_file("/tmp/debug.log");
856        let args = cmd.args();
857        assert!(args.contains(&"--debug-file".to_string()));
858        assert!(args.contains(&"/tmp/debug.log".to_string()));
859    }
860
861    #[test]
862    fn test_betas() {
863        let cmd = QueryCommand::new("test").betas("feature-x");
864        let args = cmd.args();
865        assert!(args.contains(&"--betas".to_string()));
866        assert!(args.contains(&"feature-x".to_string()));
867    }
868
869    #[test]
870    fn test_plugin_dir_single() {
871        let cmd = QueryCommand::new("test").plugin_dir("/plugins/foo");
872        let args = cmd.args();
873        assert!(args.contains(&"--plugin-dir".to_string()));
874        assert!(args.contains(&"/plugins/foo".to_string()));
875    }
876
877    #[test]
878    fn test_plugin_dir_multiple() {
879        let cmd = QueryCommand::new("test")
880            .plugin_dir("/plugins/foo")
881            .plugin_dir("/plugins/bar");
882        let args = cmd.args();
883        let plugin_dir_count = args.iter().filter(|a| *a == "--plugin-dir").count();
884        assert_eq!(plugin_dir_count, 2);
885        assert!(args.contains(&"/plugins/foo".to_string()));
886        assert!(args.contains(&"/plugins/bar".to_string()));
887    }
888
889    #[test]
890    fn test_setting_sources() {
891        let cmd = QueryCommand::new("test").setting_sources("user,project,local");
892        let args = cmd.args();
893        assert!(args.contains(&"--setting-sources".to_string()));
894        assert!(args.contains(&"user,project,local".to_string()));
895    }
896
897    #[test]
898    fn test_tmux_flag() {
899        let cmd = QueryCommand::new("test").tmux();
900        let args = cmd.args();
901        assert!(args.contains(&"--tmux".to_string()));
902    }
903
904    // ─── shell_quote unit tests (#455) ───
905
906    #[test]
907    fn shell_quote_plain_word_is_unchanged() {
908        assert_eq!(shell_quote("simple"), "simple");
909        assert_eq!(shell_quote(""), "");
910        assert_eq!(shell_quote("file.rs"), "file.rs");
911    }
912
913    #[test]
914    fn shell_quote_whitespace_gets_single_quoted() {
915        assert_eq!(shell_quote("hello world"), "'hello world'");
916        assert_eq!(shell_quote("a\tb"), "'a\tb'");
917    }
918
919    #[test]
920    fn shell_quote_metacharacters_get_quoted() {
921        assert_eq!(shell_quote("a|b"), "'a|b'");
922        assert_eq!(shell_quote("$VAR"), "'$VAR'");
923        assert_eq!(shell_quote("a;b"), "'a;b'");
924        assert_eq!(shell_quote("(x)"), "'(x)'");
925    }
926
927    #[test]
928    fn shell_quote_embedded_single_quote_is_escaped() {
929        assert_eq!(shell_quote("it's"), "'it'\\''s'");
930    }
931
932    #[test]
933    fn shell_quote_double_quote_gets_single_quoted() {
934        assert_eq!(shell_quote(r#"say "hi""#), r#"'say "hi"'"#);
935    }
936}