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::tool_pattern::ToolPattern;
6use crate::types::{Effort, InputFormat, OutputFormat, PermissionMode};
7
8/// Builder for `claude -p <prompt>` (oneshot print-mode queries).
9///
10/// This is the primary command for programmatic use. It runs a single
11/// prompt through Claude and returns the result.
12///
13/// # Example
14///
15/// ```no_run
16/// use claude_wrapper::{Claude, ClaudeCommand, QueryCommand, OutputFormat};
17///
18/// # async fn example() -> claude_wrapper::Result<()> {
19/// let claude = Claude::builder().build()?;
20///
21/// let output = QueryCommand::new("explain this error: file not found")
22///     .model("sonnet")
23///     .output_format(OutputFormat::Json)
24///     .max_turns(1)
25///     .execute(&claude)
26///     .await?;
27/// # Ok(())
28/// # }
29/// ```
30#[derive(Debug, Clone)]
31pub struct QueryCommand {
32    prompt: String,
33    model: Option<String>,
34    system_prompt: Option<String>,
35    append_system_prompt: Option<String>,
36    output_format: Option<OutputFormat>,
37    max_budget_usd: Option<f64>,
38    permission_mode: Option<PermissionMode>,
39    allowed_tools: Vec<ToolPattern>,
40    disallowed_tools: Vec<ToolPattern>,
41    mcp_config: Vec<String>,
42    add_dir: Vec<String>,
43    effort: Option<Effort>,
44    max_turns: Option<u32>,
45    json_schema: Option<String>,
46    continue_session: bool,
47    resume: Option<String>,
48    session_id: Option<String>,
49    fallback_model: Option<String>,
50    no_session_persistence: bool,
51    dangerously_skip_permissions: bool,
52    agent: Option<String>,
53    agents_json: Option<String>,
54    tools: Vec<String>,
55    file: Vec<String>,
56    include_partial_messages: bool,
57    input_format: Option<InputFormat>,
58    strict_mcp_config: bool,
59    settings: Option<String>,
60    fork_session: bool,
61    retry_policy: Option<crate::retry::RetryPolicy>,
62    worktree: bool,
63    worktree_name: Option<String>,
64    brief: bool,
65    debug_filter: Option<String>,
66    debug_file: Option<String>,
67    betas: Option<String>,
68    plugin_dirs: Vec<String>,
69    setting_sources: Option<String>,
70    tmux: bool,
71    bare: bool,
72    disable_slash_commands: bool,
73    include_hook_events: bool,
74    exclude_dynamic_system_prompt_sections: bool,
75    name: Option<String>,
76    from_pr: Option<String>,
77    prompt_via_stdin: bool,
78}
79
80impl QueryCommand {
81    /// Create a new query command with the given prompt.
82    #[must_use]
83    pub fn new(prompt: impl Into<String>) -> Self {
84        Self {
85            prompt: prompt.into(),
86            model: None,
87            system_prompt: None,
88            append_system_prompt: None,
89            output_format: None,
90            max_budget_usd: None,
91            permission_mode: None,
92            allowed_tools: Vec::new(),
93            disallowed_tools: Vec::new(),
94            mcp_config: Vec::new(),
95            add_dir: Vec::new(),
96            effort: None,
97            max_turns: None,
98            json_schema: None,
99            continue_session: false,
100            resume: None,
101            session_id: None,
102            fallback_model: None,
103            no_session_persistence: false,
104            dangerously_skip_permissions: false,
105            agent: None,
106            agents_json: None,
107            tools: Vec::new(),
108            file: Vec::new(),
109            include_partial_messages: false,
110            input_format: None,
111            strict_mcp_config: false,
112            settings: None,
113            fork_session: false,
114            retry_policy: None,
115            worktree: false,
116            worktree_name: None,
117            brief: false,
118            debug_filter: None,
119            debug_file: None,
120            betas: None,
121            plugin_dirs: Vec::new(),
122            setting_sources: None,
123            tmux: false,
124            bare: false,
125            disable_slash_commands: false,
126            include_hook_events: false,
127            exclude_dynamic_system_prompt_sections: false,
128            name: None,
129            from_pr: None,
130            prompt_via_stdin: false,
131        }
132    }
133
134    /// Set the model to use (e.g. "sonnet", "opus", or a full model ID).
135    #[must_use]
136    pub fn model(mut self, model: impl Into<String>) -> Self {
137        self.model = Some(model.into());
138        self
139    }
140
141    /// Set a custom system prompt (replaces the default).
142    #[must_use]
143    pub fn system_prompt(mut self, prompt: impl Into<String>) -> Self {
144        self.system_prompt = Some(prompt.into());
145        self
146    }
147
148    /// Append to the default system prompt.
149    #[must_use]
150    pub fn append_system_prompt(mut self, prompt: impl Into<String>) -> Self {
151        self.append_system_prompt = Some(prompt.into());
152        self
153    }
154
155    /// Set the output format.
156    #[must_use]
157    pub fn output_format(mut self, format: OutputFormat) -> Self {
158        self.output_format = Some(format);
159        self
160    }
161
162    /// Set the maximum budget in USD.
163    #[must_use]
164    pub fn max_budget_usd(mut self, budget: f64) -> Self {
165        self.max_budget_usd = Some(budget);
166        self
167    }
168
169    /// Set the permission mode.
170    #[must_use]
171    pub fn permission_mode(mut self, mode: PermissionMode) -> Self {
172        self.permission_mode = Some(mode);
173        self
174    }
175
176    /// Add allowed tool patterns.
177    ///
178    /// Accepts anything convertible into [`ToolPattern`], including
179    /// bare strings (e.g. `"Bash"`, `"Bash(git log:*)"`,
180    /// `"mcp__my-server__*"`) and values produced by
181    /// [`ToolPattern`]'s constructors.
182    ///
183    /// ```
184    /// use claude_wrapper::{QueryCommand, ToolPattern};
185    ///
186    /// let cmd = QueryCommand::new("hi")
187    ///     .allowed_tools(["Bash", "Read"]) // raw strings still work
188    ///     .allowed_tool(ToolPattern::tool_with_args("Bash", "git log:*"))
189    ///     .allowed_tool(ToolPattern::all("Write"));
190    /// ```
191    #[must_use]
192    pub fn allowed_tools<I, T>(mut self, tools: I) -> Self
193    where
194        I: IntoIterator<Item = T>,
195        T: Into<ToolPattern>,
196    {
197        self.allowed_tools.extend(tools.into_iter().map(Into::into));
198        self
199    }
200
201    /// Add a single allowed tool pattern.
202    #[must_use]
203    pub fn allowed_tool(mut self, tool: impl Into<ToolPattern>) -> Self {
204        self.allowed_tools.push(tool.into());
205        self
206    }
207
208    /// Add disallowed tool patterns.
209    #[must_use]
210    pub fn disallowed_tools<I, T>(mut self, tools: I) -> Self
211    where
212        I: IntoIterator<Item = T>,
213        T: Into<ToolPattern>,
214    {
215        self.disallowed_tools
216            .extend(tools.into_iter().map(Into::into));
217        self
218    }
219
220    /// Add a single disallowed tool pattern.
221    #[must_use]
222    pub fn disallowed_tool(mut self, tool: impl Into<ToolPattern>) -> Self {
223        self.disallowed_tools.push(tool.into());
224        self
225    }
226
227    /// Add an MCP config file path.
228    #[must_use]
229    pub fn mcp_config(mut self, path: impl Into<String>) -> Self {
230        self.mcp_config.push(path.into());
231        self
232    }
233
234    /// Add an additional directory for tool access.
235    #[must_use]
236    pub fn add_dir(mut self, dir: impl Into<String>) -> Self {
237        self.add_dir.push(dir.into());
238        self
239    }
240
241    /// Set the effort level.
242    #[must_use]
243    pub fn effort(mut self, effort: Effort) -> Self {
244        self.effort = Some(effort);
245        self
246    }
247
248    /// Set the maximum number of turns.
249    #[must_use]
250    pub fn max_turns(mut self, turns: u32) -> Self {
251        self.max_turns = Some(turns);
252        self
253    }
254
255    /// Set a JSON schema for structured output validation.
256    #[must_use]
257    pub fn json_schema(mut self, schema: impl Into<String>) -> Self {
258        self.json_schema = Some(schema.into());
259        self
260    }
261
262    /// Continue the most recent conversation.
263    #[must_use]
264    pub fn continue_session(mut self) -> Self {
265        self.continue_session = true;
266        self
267    }
268
269    /// Resume a specific session by ID.
270    #[must_use]
271    pub fn resume(mut self, session_id: impl Into<String>) -> Self {
272        self.resume = Some(session_id.into());
273        self
274    }
275
276    /// Use a specific session ID.
277    #[must_use]
278    pub fn session_id(mut self, id: impl Into<String>) -> Self {
279        self.session_id = Some(id.into());
280        self
281    }
282
283    /// Clear every session-related flag and set `--resume` to the given id.
284    ///
285    /// Used by `Session::execute` to override whatever session flags the
286    /// caller may have set on their command (including a stale `--resume`,
287    /// `--continue`, `--session-id`, or `--fork-session`). Keeping the
288    /// override logic in one place prevents conflicting flags from reaching
289    /// the CLI.
290    #[cfg(all(feature = "json", feature = "async"))]
291    pub(crate) fn replace_session(mut self, id: impl Into<String>) -> Self {
292        self.continue_session = false;
293        self.resume = Some(id.into());
294        self.session_id = None;
295        self.fork_session = false;
296        self
297    }
298
299    /// Set a fallback model for when the primary model is overloaded.
300    #[must_use]
301    pub fn fallback_model(mut self, model: impl Into<String>) -> Self {
302        self.fallback_model = Some(model.into());
303        self
304    }
305
306    /// Disable session persistence (sessions won't be saved to disk).
307    #[must_use]
308    pub fn no_session_persistence(mut self) -> Self {
309        self.no_session_persistence = true;
310        self
311    }
312
313    /// Bypass all permission checks. Only use in sandboxed environments.
314    #[must_use]
315    pub fn dangerously_skip_permissions(mut self) -> Self {
316        self.dangerously_skip_permissions = true;
317        self
318    }
319
320    /// Pin the session to a named subagent (`--agent <name>`).
321    ///
322    /// `name` is resolved by the CLI in this order: inline
323    /// definitions from [`Self::agents_json`], then user-level
324    /// `~/.claude/agents/<name>.md` files, then project-level dirs
325    /// loaded by the active `--setting-sources`.
326    ///
327    /// **Caveat**: as of Claude Code 2.1.143, the CLI silently
328    /// ignores an unknown `name` and falls back to the default
329    /// behavior -- no warning, no error. Callers that want a hard
330    /// "agent must exist" semantics should validate the name out of
331    /// band (e.g. via [`crate::artifacts::AgentsRoot::get`]) before
332    /// passing it here.
333    #[must_use]
334    pub fn agent(mut self, agent: impl Into<String>) -> Self {
335        self.agent = Some(agent.into());
336        self
337    }
338
339    /// Inline subagent definitions for this session
340    /// (`--agents <json>`).
341    ///
342    /// `json` is a JSON object keyed by agent name, with each value
343    /// carrying at least `description` and `prompt`. Inline
344    /// definitions take precedence over on-disk
345    /// `~/.claude/agents/*.md` of the same name. Pass [`Self::agent`]
346    /// to select which one to use as the session's persona.
347    ///
348    /// Example: `{"reviewer": {"description": "Reviews code",
349    /// "prompt": "You are a code reviewer"}}`.
350    #[must_use]
351    pub fn agents_json(mut self, json: impl Into<String>) -> Self {
352        self.agents_json = Some(json.into());
353        self
354    }
355
356    /// Set the list of available built-in tools.
357    ///
358    /// Use `""` to disable all tools, `"default"` for all tools, or
359    /// specific tool names like `["Bash", "Edit", "Read"]`.
360    /// This is different from `allowed_tools` which controls MCP tool permissions.
361    #[must_use]
362    pub fn tools(mut self, tools: impl IntoIterator<Item = impl Into<String>>) -> Self {
363        self.tools.extend(tools.into_iter().map(Into::into));
364        self
365    }
366
367    /// Add a file resource to download at startup.
368    ///
369    /// Format: `file_id:relative_path` (e.g. `file_abc:doc.txt`).
370    #[must_use]
371    pub fn file(mut self, spec: impl Into<String>) -> Self {
372        self.file.push(spec.into());
373        self
374    }
375
376    /// Include partial message chunks as they arrive.
377    ///
378    /// Only works with `--output-format stream-json`.
379    #[must_use]
380    pub fn include_partial_messages(mut self) -> Self {
381        self.include_partial_messages = true;
382        self
383    }
384
385    /// Set the input format.
386    #[must_use]
387    pub fn input_format(mut self, format: InputFormat) -> Self {
388        self.input_format = Some(format);
389        self
390    }
391
392    /// Only use MCP servers from `--mcp-config`, ignoring all other MCP configurations.
393    #[must_use]
394    pub fn strict_mcp_config(mut self) -> Self {
395        self.strict_mcp_config = true;
396        self
397    }
398
399    /// Path to a settings JSON file or a JSON string.
400    #[must_use]
401    pub fn settings(mut self, settings: impl Into<String>) -> Self {
402        self.settings = Some(settings.into());
403        self
404    }
405
406    /// When resuming, create a new session ID instead of reusing the original.
407    #[must_use]
408    pub fn fork_session(mut self) -> Self {
409        self.fork_session = true;
410        self
411    }
412
413    /// Create a new git worktree for this session, providing an isolated working directory.
414    #[must_use]
415    pub fn worktree(mut self) -> Self {
416        self.worktree = true;
417        self
418    }
419
420    /// Create a new git worktree with an explicit name, providing an
421    /// isolated working directory.
422    ///
423    /// Equivalent to [`Self::worktree`] but emits `--worktree NAME`,
424    /// pinning the worktree's directory/branch name rather than
425    /// letting the CLI auto-generate one.
426    ///
427    /// # Example
428    ///
429    /// ```no_run
430    /// use claude_wrapper::{Claude, ClaudeCommand, QueryCommand};
431    ///
432    /// # async fn example() -> claude_wrapper::Result<()> {
433    /// let claude = Claude::builder().build()?;
434    ///
435    /// let output = QueryCommand::new("refactor the parser")
436    ///     .worktree_named("parser-refactor")
437    ///     .execute(&claude)
438    ///     .await?;
439    /// # Ok(())
440    /// # }
441    /// ```
442    #[must_use]
443    pub fn worktree_named(mut self, name: impl Into<String>) -> Self {
444        self.worktree = true;
445        self.worktree_name = Some(name.into());
446        self
447    }
448
449    /// Enable brief mode, which activates the SendUserMessage tool for agent-to-user communication.
450    #[must_use]
451    pub fn brief(mut self) -> Self {
452        self.brief = true;
453        self
454    }
455
456    /// Enable debug logging with an optional filter (e.g., "api,hooks").
457    #[must_use]
458    pub fn debug_filter(mut self, filter: impl Into<String>) -> Self {
459        self.debug_filter = Some(filter.into());
460        self
461    }
462
463    /// Write debug logs to the specified file path.
464    #[must_use]
465    pub fn debug_file(mut self, path: impl Into<String>) -> Self {
466        self.debug_file = Some(path.into());
467        self
468    }
469
470    /// Beta feature headers for API key authentication.
471    #[must_use]
472    pub fn betas(mut self, betas: impl Into<String>) -> Self {
473        self.betas = Some(betas.into());
474        self
475    }
476
477    /// Load plugins from the specified directory for this session.
478    #[must_use]
479    pub fn plugin_dir(mut self, dir: impl Into<String>) -> Self {
480        self.plugin_dirs.push(dir.into());
481        self
482    }
483
484    /// Comma-separated list of setting sources to load (e.g., "user,project,local").
485    #[must_use]
486    pub fn setting_sources(mut self, sources: impl Into<String>) -> Self {
487        self.setting_sources = Some(sources.into());
488        self
489    }
490
491    /// Create a tmux session for the worktree.
492    #[must_use]
493    pub fn tmux(mut self) -> Self {
494        self.tmux = true;
495        self
496    }
497
498    /// Run in minimal mode (`--bare`).
499    ///
500    /// Skips hooks, LSP, plugin sync, attribution, auto-memory,
501    /// background prefetches, keychain reads, and CLAUDE.md
502    /// auto-discovery. Sets `CLAUDE_CODE_SIMPLE=1` inside the child.
503    /// Anthropic auth is restricted to `ANTHROPIC_API_KEY` or
504    /// `apiKeyHelper` via `--settings`; OAuth and keychain are never
505    /// read. Third-party providers (Bedrock/Vertex/Foundry) use their
506    /// own credentials as normal.
507    ///
508    /// Intended for headless/CI use where you want deterministic
509    /// context: provide everything explicitly via `--system-prompt`,
510    /// `--append-system-prompt`, `--add-dir`, `--mcp-config`,
511    /// `--settings`, `--agents`, and `--plugin-dir`. Skills still
512    /// resolve via explicit `/skill-name` references.
513    #[must_use]
514    pub fn bare(mut self) -> Self {
515        self.bare = true;
516        self
517    }
518
519    /// Disable all slash-command skills (`--disable-slash-commands`).
520    #[must_use]
521    pub fn disable_slash_commands(mut self) -> Self {
522        self.disable_slash_commands = true;
523        self
524    }
525
526    /// Include every hook lifecycle event in the stream-json output
527    /// (`--include-hook-events`). Only meaningful with
528    /// `OutputFormat::StreamJson`.
529    #[must_use]
530    pub fn include_hook_events(mut self) -> Self {
531        self.include_hook_events = true;
532        self
533    }
534
535    /// Move per-machine sections (cwd, env info, memory paths, git
536    /// status) out of the system prompt and into the first user
537    /// message (`--exclude-dynamic-system-prompt-sections`). Improves
538    /// cross-user prompt-cache reuse. Only applies with the default
539    /// system prompt; ignored with `--system-prompt`.
540    #[must_use]
541    pub fn exclude_dynamic_system_prompt_sections(mut self) -> Self {
542        self.exclude_dynamic_system_prompt_sections = true;
543        self
544    }
545
546    /// Set a display name for this session (`--name`). Shown in the
547    /// prompt box, `/resume` picker, and terminal title.
548    #[must_use]
549    pub fn name(mut self, name: impl Into<String>) -> Self {
550        self.name = Some(name.into());
551        self
552    }
553
554    /// Resume a session linked to a PR by number or URL
555    /// (`--from-pr <value>`).
556    ///
557    /// This wrapper only supports the valued form; the CLI's
558    /// no-value mode opens an interactive picker and would hang a
559    /// headless caller.
560    #[must_use]
561    pub fn from_pr(mut self, pr: impl Into<String>) -> Self {
562        self.from_pr = Some(pr.into());
563        self
564    }
565
566    /// Set a per-command retry policy, overriding the client default.
567    ///
568    /// # Example
569    ///
570    /// ```no_run
571    /// use claude_wrapper::{Claude, ClaudeCommand, QueryCommand, RetryPolicy};
572    /// use std::time::Duration;
573    ///
574    /// # async fn example() -> claude_wrapper::Result<()> {
575    /// let claude = Claude::builder().build()?;
576    ///
577    /// let output = QueryCommand::new("explain quicksort")
578    ///     .retry(RetryPolicy::new()
579    ///         .max_attempts(5)
580    ///         .initial_backoff(Duration::from_secs(2))
581    ///         .exponential()
582    ///         .retry_on_timeout(true))
583    ///     .execute(&claude)
584    ///     .await?;
585    /// # Ok(())
586    /// # }
587    /// ```
588    #[must_use]
589    pub fn retry(mut self, policy: crate::retry::RetryPolicy) -> Self {
590        self.retry_policy = Some(policy);
591        self
592    }
593
594    /// Return the full command as a string that could be run in a shell.
595    ///
596    /// Constructs a command string using the binary path from the Claude instance
597    /// and the arguments from this query. Arguments containing spaces or special
598    /// shell characters are shell-quoted to be safe for shell execution.
599    ///
600    /// # Example
601    ///
602    /// ```no_run
603    /// use claude_wrapper::{Claude, QueryCommand};
604    ///
605    /// # async fn example() -> claude_wrapper::Result<()> {
606    /// let claude = Claude::builder().build()?;
607    ///
608    /// let cmd = QueryCommand::new("explain quicksort")
609    ///     .model("sonnet");
610    ///
611    /// let command_str = cmd.to_command_string(&claude);
612    /// println!("Would run: {}", command_str);
613    /// # Ok(())
614    /// # }
615    /// ```
616    pub fn to_command_string(&self, claude: &Claude) -> String {
617        let args = self.build_args();
618        let quoted_args = args.iter().map(|arg| shell_quote(arg)).collect::<Vec<_>>();
619        format!("{} {}", claude.binary().display(), quoted_args.join(" "))
620    }
621
622    /// Execute the query and parse the JSON result.
623    ///
624    /// This is a convenience method that sets `OutputFormat::Json` and
625    /// deserializes the response into a [`QueryResult`](crate::types::QueryResult).
626    #[cfg(all(feature = "json", feature = "async"))]
627    pub async fn execute_json(&self, claude: &Claude) -> Result<crate::types::QueryResult> {
628        let args = self.build_args_with_forced_json();
629
630        let output = if self.prompt_via_stdin {
631            // Retry is skipped for stdin mode: the stdin pipe is consumed
632            // after the first attempt and cannot be rewound.
633            exec::run_claude_with_stdin_prompt(claude, args, self.prompt.clone()).await?
634        } else {
635            exec::run_claude_with_retry(claude, args, self.retry_policy.as_ref()).await?
636        };
637
638        serde_json::from_str(&output.stdout).map_err(|e| crate::error::Error::Json {
639            message: format!("failed to parse query result: {e}"),
640            source: e,
641        })
642    }
643
644    /// Blocking analog of [`QueryCommand::execute`] that honours the
645    /// configured [`RetryPolicy`](crate::retry::RetryPolicy).
646    ///
647    /// Overrides the blanket
648    /// [`ClaudeCommandSyncExt::execute_sync`](crate::ClaudeCommandSyncExt)
649    /// impl so retries still fire on the sync path.
650    #[cfg(feature = "sync")]
651    pub fn execute_sync(&self, claude: &Claude) -> Result<CommandOutput> {
652        if self.prompt_via_stdin {
653            // Retry is skipped for stdin mode: the stdin pipe is consumed
654            // after the first attempt and cannot be rewound.
655            exec::run_claude_with_stdin_prompt_sync(claude, self.build_args(), self.prompt.clone())
656        } else {
657            exec::run_claude_with_retry_sync(claude, self.args(), self.retry_policy.as_ref())
658        }
659    }
660
661    /// Blocking mirror of [`QueryCommand::execute_json`].
662    #[cfg(all(feature = "sync", feature = "json"))]
663    pub fn execute_json_sync(&self, claude: &Claude) -> Result<crate::types::QueryResult> {
664        let args = self.build_args_with_forced_json();
665
666        let output = if self.prompt_via_stdin {
667            // Retry is skipped for stdin mode: the stdin pipe is consumed
668            // after the first attempt and cannot be rewound.
669            exec::run_claude_with_stdin_prompt_sync(claude, args, self.prompt.clone())?
670        } else {
671            exec::run_claude_with_retry_sync(claude, args, self.retry_policy.as_ref())?
672        };
673
674        serde_json::from_str(&output.stdout).map_err(|e| crate::error::Error::Json {
675            message: format!("failed to parse query result: {e}"),
676            source: e,
677        })
678    }
679
680    /// Route the prompt through stdin rather than argv.
681    ///
682    /// When set, the prompt body does not appear in the spawned
683    /// process's argument list (`ps`, `/proc/PID/cmdline`, APM
684    /// agents). Use this for any prompt that contains sensitive
685    /// content: private code, internal design notes, orchestrator
686    /// dispatch specs.
687    ///
688    /// Requires that `claude --print` read from stdin when no
689    /// positional prompt is supplied (verified as of claude 2.1.x).
690    ///
691    /// Note: retry is skipped when stdin mode is active -- the stdin
692    /// pipe is consumed after the first attempt and cannot be rewound.
693    ///
694    /// # Example
695    /// ```no_run
696    /// use claude_wrapper::{Claude, ClaudeCommand, QueryCommand};
697    /// # async fn example() -> claude_wrapper::Result<()> {
698    /// let claude = Claude::builder().build()?;
699    /// let out = QueryCommand::new("my secret prompt")
700    ///     .prompt_via_stdin(true)
701    ///     .execute(&claude)
702    ///     .await?;
703    /// # Ok(()) }
704    /// ```
705    #[must_use]
706    pub fn prompt_via_stdin(mut self, value: bool) -> Self {
707        self.prompt_via_stdin = value;
708        self
709    }
710
711    /// Like [`Self::build_args`], but if `output_format` is unset on
712    /// this command, force it to `json`. The naive approach -- call
713    /// `build_args` then `args.push("--output-format")` -- breaks
714    /// because `build_args` already appended `--` and the prompt at
715    /// the end, so the late flag becomes positional and is eaten as
716    /// part of the prompt. We clone-and-set instead so the flag
717    /// lands in its proper slot before `--`.
718    fn build_args_with_forced_json(&self) -> Vec<String> {
719        if self.output_format.is_some() {
720            return self.build_args();
721        }
722        let mut effective = self.clone();
723        effective.output_format = Some(OutputFormat::Json);
724        effective.build_args()
725    }
726
727    fn build_args(&self) -> Vec<String> {
728        let mut args = vec!["--print".to_string()];
729
730        if let Some(ref model) = self.model {
731            args.push("--model".to_string());
732            args.push(model.clone());
733        }
734
735        if let Some(ref prompt) = self.system_prompt {
736            args.push("--system-prompt".to_string());
737            args.push(prompt.clone());
738        }
739
740        if let Some(ref prompt) = self.append_system_prompt {
741            args.push("--append-system-prompt".to_string());
742            args.push(prompt.clone());
743        }
744
745        if let Some(ref format) = self.output_format {
746            args.push("--output-format".to_string());
747            args.push(format.as_arg().to_string());
748            // CLI v2.1.72+ requires --verbose when using stream-json with --print
749            if matches!(format, OutputFormat::StreamJson) {
750                args.push("--verbose".to_string());
751            }
752        }
753
754        if let Some(budget) = self.max_budget_usd {
755            args.push("--max-budget-usd".to_string());
756            args.push(budget.to_string());
757        }
758
759        if let Some(ref mode) = self.permission_mode {
760            args.push("--permission-mode".to_string());
761            args.push(mode.as_arg().to_string());
762        }
763
764        if !self.allowed_tools.is_empty() {
765            args.push("--allowed-tools".to_string());
766            args.push(join_patterns(&self.allowed_tools));
767        }
768
769        if !self.disallowed_tools.is_empty() {
770            args.push("--disallowed-tools".to_string());
771            args.push(join_patterns(&self.disallowed_tools));
772        }
773
774        for config in &self.mcp_config {
775            args.push("--mcp-config".to_string());
776            args.push(config.clone());
777        }
778
779        for dir in &self.add_dir {
780            args.push("--add-dir".to_string());
781            args.push(dir.clone());
782        }
783
784        if let Some(ref effort) = self.effort {
785            args.push("--effort".to_string());
786            args.push(effort.as_arg().to_string());
787        }
788
789        if let Some(turns) = self.max_turns {
790            args.push("--max-turns".to_string());
791            args.push(turns.to_string());
792        }
793
794        if let Some(ref schema) = self.json_schema {
795            args.push("--json-schema".to_string());
796            args.push(schema.clone());
797        }
798
799        if self.continue_session {
800            args.push("--continue".to_string());
801        }
802
803        if let Some(ref session_id) = self.resume {
804            args.push("--resume".to_string());
805            args.push(session_id.clone());
806        }
807
808        if let Some(ref id) = self.session_id {
809            args.push("--session-id".to_string());
810            args.push(id.clone());
811        }
812
813        if let Some(ref model) = self.fallback_model {
814            args.push("--fallback-model".to_string());
815            args.push(model.clone());
816        }
817
818        if self.no_session_persistence {
819            args.push("--no-session-persistence".to_string());
820        }
821
822        if self.dangerously_skip_permissions {
823            args.push("--dangerously-skip-permissions".to_string());
824        }
825
826        if let Some(ref agent) = self.agent {
827            args.push("--agent".to_string());
828            args.push(agent.clone());
829        }
830
831        if let Some(ref agents) = self.agents_json {
832            args.push("--agents".to_string());
833            args.push(agents.clone());
834        }
835
836        if !self.tools.is_empty() {
837            args.push("--tools".to_string());
838            args.push(self.tools.join(","));
839        }
840
841        for spec in &self.file {
842            args.push("--file".to_string());
843            args.push(spec.clone());
844        }
845
846        if self.include_partial_messages {
847            args.push("--include-partial-messages".to_string());
848        }
849
850        if let Some(ref format) = self.input_format {
851            args.push("--input-format".to_string());
852            args.push(format.as_arg().to_string());
853        }
854
855        if self.strict_mcp_config {
856            args.push("--strict-mcp-config".to_string());
857        }
858
859        if let Some(ref settings) = self.settings {
860            args.push("--settings".to_string());
861            args.push(settings.clone());
862        }
863
864        if self.fork_session {
865            args.push("--fork-session".to_string());
866        }
867
868        if self.worktree {
869            args.push("--worktree".to_string());
870            if let Some(ref name) = self.worktree_name {
871                args.push(name.clone());
872            }
873        }
874
875        if self.brief {
876            args.push("--brief".to_string());
877        }
878
879        if let Some(ref filter) = self.debug_filter {
880            args.push("--debug".to_string());
881            args.push(filter.clone());
882        }
883
884        if let Some(ref path) = self.debug_file {
885            args.push("--debug-file".to_string());
886            args.push(path.clone());
887        }
888
889        if let Some(ref betas) = self.betas {
890            args.push("--betas".to_string());
891            args.push(betas.clone());
892        }
893
894        for dir in &self.plugin_dirs {
895            args.push("--plugin-dir".to_string());
896            args.push(dir.clone());
897        }
898
899        if let Some(ref sources) = self.setting_sources {
900            args.push("--setting-sources".to_string());
901            args.push(sources.clone());
902        }
903
904        if self.tmux {
905            args.push("--tmux".to_string());
906        }
907
908        if self.bare {
909            args.push("--bare".to_string());
910        }
911
912        if self.disable_slash_commands {
913            args.push("--disable-slash-commands".to_string());
914        }
915
916        if self.include_hook_events {
917            args.push("--include-hook-events".to_string());
918        }
919
920        if self.exclude_dynamic_system_prompt_sections {
921            args.push("--exclude-dynamic-system-prompt-sections".to_string());
922        }
923
924        if let Some(ref name) = self.name {
925            args.push("--name".to_string());
926            args.push(name.clone());
927        }
928
929        if let Some(ref pr) = self.from_pr {
930            args.push("--from-pr".to_string());
931            args.push(pr.clone());
932        }
933
934        // Separator to prevent flags like --allowed-tools from consuming the prompt.
935        // When prompt_via_stdin is set, the prompt is sent via stdin after spawn
936        // rather than appearing in argv (avoids ps/APM/crash-dump leakage).
937        if !self.prompt_via_stdin {
938            args.push("--".to_string());
939            args.push(self.prompt.clone());
940        }
941
942        args
943    }
944}
945
946impl ClaudeCommand for QueryCommand {
947    type Output = CommandOutput;
948
949    fn args(&self) -> Vec<String> {
950        self.build_args()
951    }
952
953    #[cfg(feature = "async")]
954    async fn execute(&self, claude: &Claude) -> Result<CommandOutput> {
955        if self.prompt_via_stdin {
956            // Retry is skipped for stdin mode: the stdin pipe is consumed
957            // after the first attempt and cannot be rewound.
958            let args = self.build_args(); // prompt not in args
959            exec::run_claude_with_stdin_prompt(claude, args, self.prompt.clone()).await
960        } else {
961            exec::run_claude_with_retry(claude, self.args(), self.retry_policy.as_ref()).await
962        }
963    }
964}
965
966/// Shell-quote an argument if it contains spaces or special characters.
967fn shell_quote(arg: &str) -> String {
968    // Check if the argument needs quoting (contains whitespace or shell metacharacters)
969    if arg.contains(|c: char| c.is_whitespace() || "\"'$\\`|;<>&()[]{}".contains(c)) {
970        // Use single quotes and escape any existing single quotes
971        format!("'{}'", arg.replace("'", "'\\''"))
972    } else {
973        arg.to_string()
974    }
975}
976
977fn join_patterns(patterns: &[ToolPattern]) -> String {
978    let mut out = String::new();
979    for (i, p) in patterns.iter().enumerate() {
980        if i > 0 {
981            out.push(',');
982        }
983        out.push_str(p.as_str());
984    }
985    out
986}
987
988#[cfg(test)]
989mod tests {
990    use super::*;
991
992    #[test]
993    fn test_basic_query_args() {
994        let cmd = QueryCommand::new("hello world");
995        let args = cmd.args();
996        assert_eq!(args, vec!["--print", "--", "hello world"]);
997    }
998
999    #[test]
1000    fn prompt_via_stdin_omits_prompt_from_args() {
1001        let cmd = QueryCommand::new("secret payload").prompt_via_stdin(true);
1002        let args = cmd.args();
1003        assert!(
1004            !args.contains(&"secret payload".to_string()),
1005            "prompt must not appear in args when prompt_via_stdin is set"
1006        );
1007        assert!(
1008            !args.contains(&"--".to_string()),
1009            "-- separator must be absent when prompt_via_stdin is set"
1010        );
1011    }
1012
1013    #[test]
1014    fn prompt_via_stdin_false_keeps_prompt_in_args() {
1015        let cmd = QueryCommand::new("visible prompt").prompt_via_stdin(false);
1016        let args = cmd.args();
1017        assert!(
1018            args.contains(&"visible prompt".to_string()),
1019            "prompt must still appear in args when prompt_via_stdin is false"
1020        );
1021        assert!(
1022            args.contains(&"--".to_string()),
1023            "-- separator must be present when prompt_via_stdin is false"
1024        );
1025    }
1026
1027    #[test]
1028    #[ignore = "requires a real claude binary"]
1029    fn prompt_via_stdin_integration() {
1030        // Verify round-trip: prompt sent via stdin produces a valid response.
1031        // Run with: cargo test --lib -p claude-wrapper -- --ignored prompt_via_stdin_integration
1032        use crate::{Claude, ClaudeCommand};
1033        let rt = tokio::runtime::Runtime::new().unwrap();
1034        rt.block_on(async {
1035            let claude = Claude::builder().build().unwrap();
1036            let out = QueryCommand::new("reply with: STDIN_OK")
1037                .prompt_via_stdin(true)
1038                .execute(&claude)
1039                .await
1040                .unwrap();
1041            assert!(
1042                !out.stdout.is_empty(),
1043                "expected non-empty output from stdin-mode query"
1044            );
1045        });
1046    }
1047
1048    #[test]
1049    fn build_args_with_forced_json_inserts_flag_before_separator() {
1050        // Regression: prior to this fix, execute_json appended
1051        // --output-format json AFTER build_args's `-- prompt` tail,
1052        // so the flag was treated as positional and eaten as part
1053        // of the prompt. With the fix the flag must land BEFORE the
1054        // `--` separator.
1055        let cmd = QueryCommand::new("hello");
1056        let args = cmd.build_args_with_forced_json();
1057
1058        // The trailing pair must still be the separator + prompt.
1059        assert_eq!(
1060            &args[args.len() - 2..],
1061            &["--".to_string(), "hello".to_string()],
1062        );
1063
1064        // --output-format json must appear BEFORE `--`.
1065        let sep = args.iter().position(|a| a == "--").expect("`--` present");
1066        let fmt = args
1067            .iter()
1068            .position(|a| a == "--output-format")
1069            .expect("--output-format present");
1070        assert!(
1071            fmt < sep,
1072            "--output-format must come before `--` separator; got {args:?}"
1073        );
1074        assert_eq!(args[fmt + 1], "json");
1075    }
1076
1077    #[test]
1078    fn build_args_with_forced_json_respects_explicit_format() {
1079        // If the caller already set output_format on the builder,
1080        // the helper must NOT override it.
1081        let cmd = QueryCommand::new("hello").output_format(OutputFormat::Text);
1082        let args = cmd.build_args_with_forced_json();
1083        let fmt = args
1084            .iter()
1085            .position(|a| a == "--output-format")
1086            .expect("--output-format present");
1087        assert_eq!(args[fmt + 1], "text");
1088        // Just one occurrence -- not double-pushed.
1089        assert_eq!(args.iter().filter(|a| *a == "--output-format").count(), 1);
1090    }
1091
1092    #[test]
1093    #[allow(deprecated)] // exercises PermissionMode::BypassPermissions directly; prefer dangerous::DangerousClient in new code
1094    fn test_full_query_args() {
1095        let cmd = QueryCommand::new("explain this")
1096            .model("sonnet")
1097            .system_prompt("be concise")
1098            .output_format(OutputFormat::Json)
1099            .max_budget_usd(0.50)
1100            .permission_mode(PermissionMode::BypassPermissions)
1101            .allowed_tools(["Bash", "Read"])
1102            .mcp_config("/tmp/mcp.json")
1103            .effort(Effort::High)
1104            .max_turns(3)
1105            .no_session_persistence();
1106
1107        let args = cmd.args();
1108        assert!(args.contains(&"--print".to_string()));
1109        assert!(args.contains(&"--model".to_string()));
1110        assert!(args.contains(&"sonnet".to_string()));
1111        assert!(args.contains(&"--system-prompt".to_string()));
1112        assert!(args.contains(&"--output-format".to_string()));
1113        assert!(args.contains(&"json".to_string()));
1114        // json format should NOT include --verbose (only stream-json needs it)
1115        assert!(!args.contains(&"--verbose".to_string()));
1116        assert!(args.contains(&"--max-budget-usd".to_string()));
1117        assert!(args.contains(&"--permission-mode".to_string()));
1118        assert!(args.contains(&"bypassPermissions".to_string()));
1119        assert!(args.contains(&"--allowed-tools".to_string()));
1120        assert!(args.contains(&"Bash,Read".to_string()));
1121        assert!(args.contains(&"--effort".to_string()));
1122        assert!(args.contains(&"high".to_string()));
1123        assert!(args.contains(&"--max-turns".to_string()));
1124        assert!(args.contains(&"--no-session-persistence".to_string()));
1125        // Prompt is last, preceded by -- separator
1126        assert_eq!(args.last().unwrap(), "explain this");
1127        assert_eq!(args[args.len() - 2], "--");
1128    }
1129
1130    #[test]
1131    fn typed_patterns_render_in_allowed_tools() {
1132        use crate::ToolPattern;
1133
1134        let cmd = QueryCommand::new("hi")
1135            .allowed_tool(ToolPattern::tool("Read"))
1136            .allowed_tool(ToolPattern::tool_with_args("Bash", "git log:*"))
1137            .allowed_tool(ToolPattern::all("Write"))
1138            .allowed_tool(ToolPattern::mcp("srv", "*"));
1139
1140        let args = cmd.args();
1141        let joined = args
1142            .iter()
1143            .position(|a| a == "--allowed-tools")
1144            .map(|i| &args[i + 1])
1145            .unwrap();
1146        assert_eq!(joined, "Read,Bash(git log:*),Write(*),mcp__srv__*");
1147    }
1148
1149    #[test]
1150    fn disallowed_tool_singular_appends() {
1151        use crate::ToolPattern;
1152
1153        let cmd = QueryCommand::new("hi")
1154            .disallowed_tool("Write")
1155            .disallowed_tool(ToolPattern::tool_with_args("Bash", "rm*"));
1156
1157        let args = cmd.args();
1158        let joined = args
1159            .iter()
1160            .position(|a| a == "--disallowed-tools")
1161            .map(|i| &args[i + 1])
1162            .unwrap();
1163        assert_eq!(joined, "Write,Bash(rm*)");
1164    }
1165
1166    #[test]
1167    fn mixed_string_and_typed_patterns_both_accepted() {
1168        use crate::ToolPattern;
1169
1170        // Smoke test for API ergonomics: one plural call with mixed
1171        // inputs should compile even though the builder is generic
1172        // over T: Into<ToolPattern>.
1173        let strs: Vec<ToolPattern> = vec!["Bash".into(), ToolPattern::all("Read")];
1174        let cmd = QueryCommand::new("hi").allowed_tools(strs);
1175        assert!(cmd.args().contains(&"--allowed-tools".to_string()));
1176    }
1177
1178    #[test]
1179    fn new_bool_flags_emit_correct_cli_args() {
1180        let args = QueryCommand::new("hi")
1181            .bare()
1182            .disable_slash_commands()
1183            .include_hook_events()
1184            .exclude_dynamic_system_prompt_sections()
1185            .args();
1186        assert!(args.contains(&"--bare".to_string()));
1187        assert!(args.contains(&"--disable-slash-commands".to_string()));
1188        assert!(args.contains(&"--include-hook-events".to_string()));
1189        assert!(args.contains(&"--exclude-dynamic-system-prompt-sections".to_string()));
1190    }
1191
1192    #[test]
1193    fn name_flag_renders_with_value() {
1194        let args = QueryCommand::new("hi").name("my session").args();
1195        let pos = args.iter().position(|a| a == "--name").unwrap();
1196        assert_eq!(args[pos + 1], "my session");
1197    }
1198
1199    #[test]
1200    fn from_pr_flag_renders_with_value() {
1201        let args = QueryCommand::new("hi").from_pr("42").args();
1202        let pos = args.iter().position(|a| a == "--from-pr").unwrap();
1203        assert_eq!(args[pos + 1], "42");
1204    }
1205
1206    #[test]
1207    fn new_bool_flags_default_to_off() {
1208        let args = QueryCommand::new("hi").args();
1209        assert!(!args.contains(&"--bare".to_string()));
1210        assert!(!args.contains(&"--disable-slash-commands".to_string()));
1211        assert!(!args.contains(&"--include-hook-events".to_string()));
1212        assert!(!args.contains(&"--exclude-dynamic-system-prompt-sections".to_string()));
1213        assert!(!args.contains(&"--name".to_string()));
1214    }
1215
1216    #[test]
1217    fn test_separator_before_prompt_prevents_greedy_flag_parsing() {
1218        // Regression: --allowed-tools was consuming the prompt as a tool name
1219        // when the prompt appeared after it without a -- separator.
1220        let cmd = QueryCommand::new("fix the bug")
1221            .allowed_tools(["Read", "Edit", "Bash(cargo *)"])
1222            .output_format(OutputFormat::StreamJson);
1223        let args = cmd.args();
1224        // -- separator must appear before the prompt
1225        let sep_pos = args.iter().position(|a| a == "--").unwrap();
1226        let prompt_pos = args.iter().position(|a| a == "fix the bug").unwrap();
1227        assert_eq!(prompt_pos, sep_pos + 1, "prompt must follow -- separator");
1228        // --allowed-tools value must appear before the separator
1229        let tools_pos = args
1230            .iter()
1231            .position(|a| a.contains("Bash(cargo *)"))
1232            .unwrap();
1233        assert!(
1234            tools_pos < sep_pos,
1235            "allowed-tools must come before -- separator"
1236        );
1237    }
1238
1239    #[test]
1240    fn test_stream_json_includes_verbose() {
1241        let cmd = QueryCommand::new("test").output_format(OutputFormat::StreamJson);
1242        let args = cmd.args();
1243        assert!(args.contains(&"--output-format".to_string()));
1244        assert!(args.contains(&"stream-json".to_string()));
1245        assert!(args.contains(&"--verbose".to_string()));
1246    }
1247
1248    #[test]
1249    fn test_to_command_string_simple() {
1250        let claude = Claude::builder()
1251            .binary("/usr/local/bin/claude")
1252            .build()
1253            .unwrap();
1254
1255        let cmd = QueryCommand::new("hello");
1256        let command_str = cmd.to_command_string(&claude);
1257
1258        assert!(command_str.starts_with("/usr/local/bin/claude"));
1259        assert!(command_str.contains("--print"));
1260        assert!(command_str.contains("hello"));
1261    }
1262
1263    #[test]
1264    fn test_to_command_string_with_spaces() {
1265        let claude = Claude::builder()
1266            .binary("/usr/local/bin/claude")
1267            .build()
1268            .unwrap();
1269
1270        let cmd = QueryCommand::new("hello world").model("sonnet");
1271        let command_str = cmd.to_command_string(&claude);
1272
1273        assert!(command_str.starts_with("/usr/local/bin/claude"));
1274        assert!(command_str.contains("--print"));
1275        // Prompt with spaces should be quoted
1276        assert!(command_str.contains("'hello world'"));
1277        assert!(command_str.contains("--model"));
1278        assert!(command_str.contains("sonnet"));
1279    }
1280
1281    #[test]
1282    fn test_to_command_string_with_special_chars() {
1283        let claude = Claude::builder()
1284            .binary("/usr/local/bin/claude")
1285            .build()
1286            .unwrap();
1287
1288        let cmd = QueryCommand::new("test $VAR and `cmd`");
1289        let command_str = cmd.to_command_string(&claude);
1290
1291        // Arguments with special shell characters should be quoted
1292        assert!(command_str.contains("'test $VAR and `cmd`'"));
1293    }
1294
1295    #[test]
1296    fn test_to_command_string_with_single_quotes() {
1297        let claude = Claude::builder()
1298            .binary("/usr/local/bin/claude")
1299            .build()
1300            .unwrap();
1301
1302        let cmd = QueryCommand::new("it's");
1303        let command_str = cmd.to_command_string(&claude);
1304
1305        // Single quotes should be escaped in shell
1306        assert!(command_str.contains("'it'\\''s'"));
1307    }
1308
1309    #[test]
1310    fn test_worktree_flag() {
1311        let cmd = QueryCommand::new("test").worktree();
1312        let args = cmd.args();
1313        assert!(args.contains(&"--worktree".to_string()));
1314    }
1315
1316    #[test]
1317    fn test_worktree_named() {
1318        let cmd = QueryCommand::new("test").worktree_named("feature-x");
1319        let args = cmd.args();
1320        assert!(
1321            args.windows(2).any(|w| w == ["--worktree", "feature-x"]),
1322            "missing --worktree feature-x in {args:?}"
1323        );
1324    }
1325
1326    #[test]
1327    fn test_brief_flag() {
1328        let cmd = QueryCommand::new("test").brief();
1329        let args = cmd.args();
1330        assert!(args.contains(&"--brief".to_string()));
1331    }
1332
1333    #[test]
1334    fn test_debug_filter() {
1335        let cmd = QueryCommand::new("test").debug_filter("api,hooks");
1336        let args = cmd.args();
1337        assert!(args.contains(&"--debug".to_string()));
1338        assert!(args.contains(&"api,hooks".to_string()));
1339    }
1340
1341    #[test]
1342    fn test_debug_file() {
1343        let cmd = QueryCommand::new("test").debug_file("/tmp/debug.log");
1344        let args = cmd.args();
1345        assert!(args.contains(&"--debug-file".to_string()));
1346        assert!(args.contains(&"/tmp/debug.log".to_string()));
1347    }
1348
1349    #[test]
1350    fn test_betas() {
1351        let cmd = QueryCommand::new("test").betas("feature-x");
1352        let args = cmd.args();
1353        assert!(args.contains(&"--betas".to_string()));
1354        assert!(args.contains(&"feature-x".to_string()));
1355    }
1356
1357    #[test]
1358    fn test_plugin_dir_single() {
1359        let cmd = QueryCommand::new("test").plugin_dir("/plugins/foo");
1360        let args = cmd.args();
1361        assert!(args.contains(&"--plugin-dir".to_string()));
1362        assert!(args.contains(&"/plugins/foo".to_string()));
1363    }
1364
1365    #[test]
1366    fn test_plugin_dir_multiple() {
1367        let cmd = QueryCommand::new("test")
1368            .plugin_dir("/plugins/foo")
1369            .plugin_dir("/plugins/bar");
1370        let args = cmd.args();
1371        let plugin_dir_count = args.iter().filter(|a| *a == "--plugin-dir").count();
1372        assert_eq!(plugin_dir_count, 2);
1373        assert!(args.contains(&"/plugins/foo".to_string()));
1374        assert!(args.contains(&"/plugins/bar".to_string()));
1375    }
1376
1377    #[test]
1378    fn test_setting_sources() {
1379        let cmd = QueryCommand::new("test").setting_sources("user,project,local");
1380        let args = cmd.args();
1381        assert!(args.contains(&"--setting-sources".to_string()));
1382        assert!(args.contains(&"user,project,local".to_string()));
1383    }
1384
1385    #[test]
1386    fn test_tmux_flag() {
1387        let cmd = QueryCommand::new("test").tmux();
1388        let args = cmd.args();
1389        assert!(args.contains(&"--tmux".to_string()));
1390    }
1391
1392    // ─── shell_quote unit tests (#455) ───
1393
1394    #[test]
1395    fn shell_quote_plain_word_is_unchanged() {
1396        assert_eq!(shell_quote("simple"), "simple");
1397        assert_eq!(shell_quote(""), "");
1398        assert_eq!(shell_quote("file.rs"), "file.rs");
1399    }
1400
1401    #[test]
1402    fn shell_quote_whitespace_gets_single_quoted() {
1403        assert_eq!(shell_quote("hello world"), "'hello world'");
1404        assert_eq!(shell_quote("a\tb"), "'a\tb'");
1405    }
1406
1407    #[test]
1408    fn shell_quote_metacharacters_get_quoted() {
1409        assert_eq!(shell_quote("a|b"), "'a|b'");
1410        assert_eq!(shell_quote("$VAR"), "'$VAR'");
1411        assert_eq!(shell_quote("a;b"), "'a;b'");
1412        assert_eq!(shell_quote("(x)"), "'(x)'");
1413    }
1414
1415    #[test]
1416    fn shell_quote_embedded_single_quote_is_escaped() {
1417        assert_eq!(shell_quote("it's"), "'it'\\''s'");
1418    }
1419
1420    #[test]
1421    fn shell_quote_double_quote_gets_single_quoted() {
1422        assert_eq!(shell_quote(r#"say "hi""#), r#"'say "hi"'"#);
1423    }
1424}