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