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    #[cfg(feature = "json")]
245    pub(crate) fn replace_session(mut self, id: impl Into<String>) -> Self {
246        self.continue_session = false;
247        self.resume = Some(id.into());
248        self.session_id = None;
249        self.fork_session = false;
250        self
251    }
252
253    /// Set a fallback model for when the primary model is overloaded.
254    #[must_use]
255    pub fn fallback_model(mut self, model: impl Into<String>) -> Self {
256        self.fallback_model = Some(model.into());
257        self
258    }
259
260    /// Disable session persistence (sessions won't be saved to disk).
261    #[must_use]
262    pub fn no_session_persistence(mut self) -> Self {
263        self.no_session_persistence = true;
264        self
265    }
266
267    /// Bypass all permission checks. Only use in sandboxed environments.
268    #[must_use]
269    pub fn dangerously_skip_permissions(mut self) -> Self {
270        self.dangerously_skip_permissions = true;
271        self
272    }
273
274    /// Set the agent for the session.
275    #[must_use]
276    pub fn agent(mut self, agent: impl Into<String>) -> Self {
277        self.agent = Some(agent.into());
278        self
279    }
280
281    /// Set custom agents as a JSON object.
282    ///
283    /// Example: `{"reviewer": {"description": "Reviews code", "prompt": "You are a code reviewer"}}`
284    #[must_use]
285    pub fn agents_json(mut self, json: impl Into<String>) -> Self {
286        self.agents_json = Some(json.into());
287        self
288    }
289
290    /// Set the list of available built-in tools.
291    ///
292    /// Use `""` to disable all tools, `"default"` for all tools, or
293    /// specific tool names like `["Bash", "Edit", "Read"]`.
294    /// This is different from `allowed_tools` which controls MCP tool permissions.
295    #[must_use]
296    pub fn tools(mut self, tools: impl IntoIterator<Item = impl Into<String>>) -> Self {
297        self.tools.extend(tools.into_iter().map(Into::into));
298        self
299    }
300
301    /// Add a file resource to download at startup.
302    ///
303    /// Format: `file_id:relative_path` (e.g. `file_abc:doc.txt`).
304    #[must_use]
305    pub fn file(mut self, spec: impl Into<String>) -> Self {
306        self.file.push(spec.into());
307        self
308    }
309
310    /// Include partial message chunks as they arrive.
311    ///
312    /// Only works with `--output-format stream-json`.
313    #[must_use]
314    pub fn include_partial_messages(mut self) -> Self {
315        self.include_partial_messages = true;
316        self
317    }
318
319    /// Set the input format.
320    #[must_use]
321    pub fn input_format(mut self, format: InputFormat) -> Self {
322        self.input_format = Some(format);
323        self
324    }
325
326    /// Only use MCP servers from `--mcp-config`, ignoring all other MCP configurations.
327    #[must_use]
328    pub fn strict_mcp_config(mut self) -> Self {
329        self.strict_mcp_config = true;
330        self
331    }
332
333    /// Path to a settings JSON file or a JSON string.
334    #[must_use]
335    pub fn settings(mut self, settings: impl Into<String>) -> Self {
336        self.settings = Some(settings.into());
337        self
338    }
339
340    /// When resuming, create a new session ID instead of reusing the original.
341    #[must_use]
342    pub fn fork_session(mut self) -> Self {
343        self.fork_session = true;
344        self
345    }
346
347    /// Create a new git worktree for this session, providing an isolated working directory.
348    #[must_use]
349    pub fn worktree(mut self) -> Self {
350        self.worktree = true;
351        self
352    }
353
354    /// Enable brief mode, which activates the SendUserMessage tool for agent-to-user communication.
355    #[must_use]
356    pub fn brief(mut self) -> Self {
357        self.brief = true;
358        self
359    }
360
361    /// Enable debug logging with an optional filter (e.g., "api,hooks").
362    #[must_use]
363    pub fn debug_filter(mut self, filter: impl Into<String>) -> Self {
364        self.debug_filter = Some(filter.into());
365        self
366    }
367
368    /// Write debug logs to the specified file path.
369    #[must_use]
370    pub fn debug_file(mut self, path: impl Into<String>) -> Self {
371        self.debug_file = Some(path.into());
372        self
373    }
374
375    /// Beta feature headers for API key authentication.
376    #[must_use]
377    pub fn betas(mut self, betas: impl Into<String>) -> Self {
378        self.betas = Some(betas.into());
379        self
380    }
381
382    /// Load plugins from the specified directory for this session.
383    #[must_use]
384    pub fn plugin_dir(mut self, dir: impl Into<String>) -> Self {
385        self.plugin_dirs.push(dir.into());
386        self
387    }
388
389    /// Comma-separated list of setting sources to load (e.g., "user,project,local").
390    #[must_use]
391    pub fn setting_sources(mut self, sources: impl Into<String>) -> Self {
392        self.setting_sources = Some(sources.into());
393        self
394    }
395
396    /// Create a tmux session for the worktree.
397    #[must_use]
398    pub fn tmux(mut self) -> Self {
399        self.tmux = true;
400        self
401    }
402
403    /// Set a per-command retry policy, overriding the client default.
404    ///
405    /// # Example
406    ///
407    /// ```no_run
408    /// use claude_wrapper::{Claude, ClaudeCommand, QueryCommand, RetryPolicy};
409    /// use std::time::Duration;
410    ///
411    /// # async fn example() -> claude_wrapper::Result<()> {
412    /// let claude = Claude::builder().build()?;
413    ///
414    /// let output = QueryCommand::new("explain quicksort")
415    ///     .retry(RetryPolicy::new()
416    ///         .max_attempts(5)
417    ///         .initial_backoff(Duration::from_secs(2))
418    ///         .exponential()
419    ///         .retry_on_timeout(true))
420    ///     .execute(&claude)
421    ///     .await?;
422    /// # Ok(())
423    /// # }
424    /// ```
425    #[must_use]
426    pub fn retry(mut self, policy: crate::retry::RetryPolicy) -> Self {
427        self.retry_policy = Some(policy);
428        self
429    }
430
431    /// Return the full command as a string that could be run in a shell.
432    ///
433    /// Constructs a command string using the binary path from the Claude instance
434    /// and the arguments from this query. Arguments containing spaces or special
435    /// shell characters are shell-quoted to be safe for shell execution.
436    ///
437    /// # Example
438    ///
439    /// ```no_run
440    /// use claude_wrapper::{Claude, QueryCommand};
441    ///
442    /// # async fn example() -> claude_wrapper::Result<()> {
443    /// let claude = Claude::builder().build()?;
444    ///
445    /// let cmd = QueryCommand::new("explain quicksort")
446    ///     .model("sonnet");
447    ///
448    /// let command_str = cmd.to_command_string(&claude);
449    /// println!("Would run: {}", command_str);
450    /// # Ok(())
451    /// # }
452    /// ```
453    pub fn to_command_string(&self, claude: &Claude) -> String {
454        let args = self.build_args();
455        let quoted_args = args.iter().map(|arg| shell_quote(arg)).collect::<Vec<_>>();
456        format!("{} {}", claude.binary().display(), quoted_args.join(" "))
457    }
458
459    /// Execute the query and parse the JSON result.
460    ///
461    /// This is a convenience method that sets `OutputFormat::Json` and
462    /// deserializes the response into a [`QueryResult`](crate::types::QueryResult).
463    #[cfg(feature = "json")]
464    pub async fn execute_json(&self, claude: &Claude) -> Result<crate::types::QueryResult> {
465        // Build args with JSON output format forced
466        let mut args = self.build_args();
467
468        // Override output format to json if not already set
469        if self.output_format.is_none() {
470            args.push("--output-format".to_string());
471            args.push("json".to_string());
472        }
473
474        let output = exec::run_claude_with_retry(claude, args, self.retry_policy.as_ref()).await?;
475
476        serde_json::from_str(&output.stdout).map_err(|e| crate::error::Error::Json {
477            message: format!("failed to parse query result: {e}"),
478            source: e,
479        })
480    }
481
482    fn build_args(&self) -> Vec<String> {
483        let mut args = vec!["--print".to_string()];
484
485        if let Some(ref model) = self.model {
486            args.push("--model".to_string());
487            args.push(model.clone());
488        }
489
490        if let Some(ref prompt) = self.system_prompt {
491            args.push("--system-prompt".to_string());
492            args.push(prompt.clone());
493        }
494
495        if let Some(ref prompt) = self.append_system_prompt {
496            args.push("--append-system-prompt".to_string());
497            args.push(prompt.clone());
498        }
499
500        if let Some(ref format) = self.output_format {
501            args.push("--output-format".to_string());
502            args.push(format.as_arg().to_string());
503            // CLI v2.1.72+ requires --verbose when using stream-json with --print
504            if matches!(format, OutputFormat::StreamJson) {
505                args.push("--verbose".to_string());
506            }
507        }
508
509        if let Some(budget) = self.max_budget_usd {
510            args.push("--max-budget-usd".to_string());
511            args.push(budget.to_string());
512        }
513
514        if let Some(ref mode) = self.permission_mode {
515            args.push("--permission-mode".to_string());
516            args.push(mode.as_arg().to_string());
517        }
518
519        if !self.allowed_tools.is_empty() {
520            args.push("--allowed-tools".to_string());
521            args.push(self.allowed_tools.join(","));
522        }
523
524        if !self.disallowed_tools.is_empty() {
525            args.push("--disallowed-tools".to_string());
526            args.push(self.disallowed_tools.join(","));
527        }
528
529        for config in &self.mcp_config {
530            args.push("--mcp-config".to_string());
531            args.push(config.clone());
532        }
533
534        for dir in &self.add_dir {
535            args.push("--add-dir".to_string());
536            args.push(dir.clone());
537        }
538
539        if let Some(ref effort) = self.effort {
540            args.push("--effort".to_string());
541            args.push(effort.as_arg().to_string());
542        }
543
544        if let Some(turns) = self.max_turns {
545            args.push("--max-turns".to_string());
546            args.push(turns.to_string());
547        }
548
549        if let Some(ref schema) = self.json_schema {
550            args.push("--json-schema".to_string());
551            args.push(schema.clone());
552        }
553
554        if self.continue_session {
555            args.push("--continue".to_string());
556        }
557
558        if let Some(ref session_id) = self.resume {
559            args.push("--resume".to_string());
560            args.push(session_id.clone());
561        }
562
563        if let Some(ref id) = self.session_id {
564            args.push("--session-id".to_string());
565            args.push(id.clone());
566        }
567
568        if let Some(ref model) = self.fallback_model {
569            args.push("--fallback-model".to_string());
570            args.push(model.clone());
571        }
572
573        if self.no_session_persistence {
574            args.push("--no-session-persistence".to_string());
575        }
576
577        if self.dangerously_skip_permissions {
578            args.push("--dangerously-skip-permissions".to_string());
579        }
580
581        if let Some(ref agent) = self.agent {
582            args.push("--agent".to_string());
583            args.push(agent.clone());
584        }
585
586        if let Some(ref agents) = self.agents_json {
587            args.push("--agents".to_string());
588            args.push(agents.clone());
589        }
590
591        if !self.tools.is_empty() {
592            args.push("--tools".to_string());
593            args.push(self.tools.join(","));
594        }
595
596        for spec in &self.file {
597            args.push("--file".to_string());
598            args.push(spec.clone());
599        }
600
601        if self.include_partial_messages {
602            args.push("--include-partial-messages".to_string());
603        }
604
605        if let Some(ref format) = self.input_format {
606            args.push("--input-format".to_string());
607            args.push(format.as_arg().to_string());
608        }
609
610        if self.strict_mcp_config {
611            args.push("--strict-mcp-config".to_string());
612        }
613
614        if let Some(ref settings) = self.settings {
615            args.push("--settings".to_string());
616            args.push(settings.clone());
617        }
618
619        if self.fork_session {
620            args.push("--fork-session".to_string());
621        }
622
623        if self.worktree {
624            args.push("--worktree".to_string());
625        }
626
627        if self.brief {
628            args.push("--brief".to_string());
629        }
630
631        if let Some(ref filter) = self.debug_filter {
632            args.push("--debug".to_string());
633            args.push(filter.clone());
634        }
635
636        if let Some(ref path) = self.debug_file {
637            args.push("--debug-file".to_string());
638            args.push(path.clone());
639        }
640
641        if let Some(ref betas) = self.betas {
642            args.push("--betas".to_string());
643            args.push(betas.clone());
644        }
645
646        for dir in &self.plugin_dirs {
647            args.push("--plugin-dir".to_string());
648            args.push(dir.clone());
649        }
650
651        if let Some(ref sources) = self.setting_sources {
652            args.push("--setting-sources".to_string());
653            args.push(sources.clone());
654        }
655
656        if self.tmux {
657            args.push("--tmux".to_string());
658        }
659
660        // Separator to prevent flags like --allowed-tools from consuming the prompt.
661        args.push("--".to_string());
662        args.push(self.prompt.clone());
663
664        args
665    }
666}
667
668impl ClaudeCommand for QueryCommand {
669    type Output = CommandOutput;
670
671    fn args(&self) -> Vec<String> {
672        self.build_args()
673    }
674
675    async fn execute(&self, claude: &Claude) -> Result<CommandOutput> {
676        exec::run_claude_with_retry(claude, self.args(), self.retry_policy.as_ref()).await
677    }
678}
679
680/// Shell-quote an argument if it contains spaces or special characters.
681fn shell_quote(arg: &str) -> String {
682    // Check if the argument needs quoting (contains whitespace or shell metacharacters)
683    if arg.contains(|c: char| c.is_whitespace() || "\"'$\\`|;<>&()[]{}".contains(c)) {
684        // Use single quotes and escape any existing single quotes
685        format!("'{}'", arg.replace("'", "'\\''"))
686    } else {
687        arg.to_string()
688    }
689}
690
691#[cfg(test)]
692mod tests {
693    use super::*;
694
695    #[test]
696    fn test_basic_query_args() {
697        let cmd = QueryCommand::new("hello world");
698        let args = cmd.args();
699        assert_eq!(args, vec!["--print", "--", "hello world"]);
700    }
701
702    #[test]
703    #[allow(deprecated)] // exercises PermissionMode::BypassPermissions directly; prefer dangerous::DangerousClient in new code
704    fn test_full_query_args() {
705        let cmd = QueryCommand::new("explain this")
706            .model("sonnet")
707            .system_prompt("be concise")
708            .output_format(OutputFormat::Json)
709            .max_budget_usd(0.50)
710            .permission_mode(PermissionMode::BypassPermissions)
711            .allowed_tools(["Bash", "Read"])
712            .mcp_config("/tmp/mcp.json")
713            .effort(Effort::High)
714            .max_turns(3)
715            .no_session_persistence();
716
717        let args = cmd.args();
718        assert!(args.contains(&"--print".to_string()));
719        assert!(args.contains(&"--model".to_string()));
720        assert!(args.contains(&"sonnet".to_string()));
721        assert!(args.contains(&"--system-prompt".to_string()));
722        assert!(args.contains(&"--output-format".to_string()));
723        assert!(args.contains(&"json".to_string()));
724        // json format should NOT include --verbose (only stream-json needs it)
725        assert!(!args.contains(&"--verbose".to_string()));
726        assert!(args.contains(&"--max-budget-usd".to_string()));
727        assert!(args.contains(&"--permission-mode".to_string()));
728        assert!(args.contains(&"bypassPermissions".to_string()));
729        assert!(args.contains(&"--allowed-tools".to_string()));
730        assert!(args.contains(&"Bash,Read".to_string()));
731        assert!(args.contains(&"--effort".to_string()));
732        assert!(args.contains(&"high".to_string()));
733        assert!(args.contains(&"--max-turns".to_string()));
734        assert!(args.contains(&"--no-session-persistence".to_string()));
735        // Prompt is last, preceded by -- separator
736        assert_eq!(args.last().unwrap(), "explain this");
737        assert_eq!(args[args.len() - 2], "--");
738    }
739
740    #[test]
741    fn test_separator_before_prompt_prevents_greedy_flag_parsing() {
742        // Regression: --allowed-tools was consuming the prompt as a tool name
743        // when the prompt appeared after it without a -- separator.
744        let cmd = QueryCommand::new("fix the bug")
745            .allowed_tools(["Read", "Edit", "Bash(cargo *)"])
746            .output_format(OutputFormat::StreamJson);
747        let args = cmd.args();
748        // -- separator must appear before the prompt
749        let sep_pos = args.iter().position(|a| a == "--").unwrap();
750        let prompt_pos = args.iter().position(|a| a == "fix the bug").unwrap();
751        assert_eq!(prompt_pos, sep_pos + 1, "prompt must follow -- separator");
752        // --allowed-tools value must appear before the separator
753        let tools_pos = args
754            .iter()
755            .position(|a| a.contains("Bash(cargo *)"))
756            .unwrap();
757        assert!(
758            tools_pos < sep_pos,
759            "allowed-tools must come before -- separator"
760        );
761    }
762
763    #[test]
764    fn test_stream_json_includes_verbose() {
765        let cmd = QueryCommand::new("test").output_format(OutputFormat::StreamJson);
766        let args = cmd.args();
767        assert!(args.contains(&"--output-format".to_string()));
768        assert!(args.contains(&"stream-json".to_string()));
769        assert!(args.contains(&"--verbose".to_string()));
770    }
771
772    #[test]
773    fn test_to_command_string_simple() {
774        let claude = Claude::builder()
775            .binary("/usr/local/bin/claude")
776            .build()
777            .unwrap();
778
779        let cmd = QueryCommand::new("hello");
780        let command_str = cmd.to_command_string(&claude);
781
782        assert!(command_str.starts_with("/usr/local/bin/claude"));
783        assert!(command_str.contains("--print"));
784        assert!(command_str.contains("hello"));
785    }
786
787    #[test]
788    fn test_to_command_string_with_spaces() {
789        let claude = Claude::builder()
790            .binary("/usr/local/bin/claude")
791            .build()
792            .unwrap();
793
794        let cmd = QueryCommand::new("hello world").model("sonnet");
795        let command_str = cmd.to_command_string(&claude);
796
797        assert!(command_str.starts_with("/usr/local/bin/claude"));
798        assert!(command_str.contains("--print"));
799        // Prompt with spaces should be quoted
800        assert!(command_str.contains("'hello world'"));
801        assert!(command_str.contains("--model"));
802        assert!(command_str.contains("sonnet"));
803    }
804
805    #[test]
806    fn test_to_command_string_with_special_chars() {
807        let claude = Claude::builder()
808            .binary("/usr/local/bin/claude")
809            .build()
810            .unwrap();
811
812        let cmd = QueryCommand::new("test $VAR and `cmd`");
813        let command_str = cmd.to_command_string(&claude);
814
815        // Arguments with special shell characters should be quoted
816        assert!(command_str.contains("'test $VAR and `cmd`'"));
817    }
818
819    #[test]
820    fn test_to_command_string_with_single_quotes() {
821        let claude = Claude::builder()
822            .binary("/usr/local/bin/claude")
823            .build()
824            .unwrap();
825
826        let cmd = QueryCommand::new("it's");
827        let command_str = cmd.to_command_string(&claude);
828
829        // Single quotes should be escaped in shell
830        assert!(command_str.contains("'it'\\''s'"));
831    }
832
833    #[test]
834    fn test_worktree_flag() {
835        let cmd = QueryCommand::new("test").worktree();
836        let args = cmd.args();
837        assert!(args.contains(&"--worktree".to_string()));
838    }
839
840    #[test]
841    fn test_brief_flag() {
842        let cmd = QueryCommand::new("test").brief();
843        let args = cmd.args();
844        assert!(args.contains(&"--brief".to_string()));
845    }
846
847    #[test]
848    fn test_debug_filter() {
849        let cmd = QueryCommand::new("test").debug_filter("api,hooks");
850        let args = cmd.args();
851        assert!(args.contains(&"--debug".to_string()));
852        assert!(args.contains(&"api,hooks".to_string()));
853    }
854
855    #[test]
856    fn test_debug_file() {
857        let cmd = QueryCommand::new("test").debug_file("/tmp/debug.log");
858        let args = cmd.args();
859        assert!(args.contains(&"--debug-file".to_string()));
860        assert!(args.contains(&"/tmp/debug.log".to_string()));
861    }
862
863    #[test]
864    fn test_betas() {
865        let cmd = QueryCommand::new("test").betas("feature-x");
866        let args = cmd.args();
867        assert!(args.contains(&"--betas".to_string()));
868        assert!(args.contains(&"feature-x".to_string()));
869    }
870
871    #[test]
872    fn test_plugin_dir_single() {
873        let cmd = QueryCommand::new("test").plugin_dir("/plugins/foo");
874        let args = cmd.args();
875        assert!(args.contains(&"--plugin-dir".to_string()));
876        assert!(args.contains(&"/plugins/foo".to_string()));
877    }
878
879    #[test]
880    fn test_plugin_dir_multiple() {
881        let cmd = QueryCommand::new("test")
882            .plugin_dir("/plugins/foo")
883            .plugin_dir("/plugins/bar");
884        let args = cmd.args();
885        let plugin_dir_count = args.iter().filter(|a| *a == "--plugin-dir").count();
886        assert_eq!(plugin_dir_count, 2);
887        assert!(args.contains(&"/plugins/foo".to_string()));
888        assert!(args.contains(&"/plugins/bar".to_string()));
889    }
890
891    #[test]
892    fn test_setting_sources() {
893        let cmd = QueryCommand::new("test").setting_sources("user,project,local");
894        let args = cmd.args();
895        assert!(args.contains(&"--setting-sources".to_string()));
896        assert!(args.contains(&"user,project,local".to_string()));
897    }
898
899    #[test]
900    fn test_tmux_flag() {
901        let cmd = QueryCommand::new("test").tmux();
902        let args = cmd.args();
903        assert!(args.contains(&"--tmux".to_string()));
904    }
905
906    // ─── shell_quote unit tests (#455) ───
907
908    #[test]
909    fn shell_quote_plain_word_is_unchanged() {
910        assert_eq!(shell_quote("simple"), "simple");
911        assert_eq!(shell_quote(""), "");
912        assert_eq!(shell_quote("file.rs"), "file.rs");
913    }
914
915    #[test]
916    fn shell_quote_whitespace_gets_single_quoted() {
917        assert_eq!(shell_quote("hello world"), "'hello world'");
918        assert_eq!(shell_quote("a\tb"), "'a\tb'");
919    }
920
921    #[test]
922    fn shell_quote_metacharacters_get_quoted() {
923        assert_eq!(shell_quote("a|b"), "'a|b'");
924        assert_eq!(shell_quote("$VAR"), "'$VAR'");
925        assert_eq!(shell_quote("a;b"), "'a;b'");
926        assert_eq!(shell_quote("(x)"), "'(x)'");
927    }
928
929    #[test]
930    fn shell_quote_embedded_single_quote_is_escaped() {
931        assert_eq!(shell_quote("it's"), "'it'\\''s'");
932    }
933
934    #[test]
935    fn shell_quote_double_quote_gets_single_quoted() {
936        assert_eq!(shell_quote(r#"say "hi""#), r#"'say "hi"'"#);
937    }
938}