quest_cli/
cli.rs

1use std::path::PathBuf;
2
3use anyhow::{Context, Result};
4use clap::{Args, Parser, Subcommand};
5use duration_string::DurationString;
6use secrecy::SecretString;
7use serde::Deserialize;
8use url::Url;
9
10use crate::{
11    builder::{QuestClientBuilder, QuestRequestBuilder},
12    quest::{QuestCommand, QuestFile, QuestUrl},
13    types::{FormField, StringOrFile},
14};
15
16#[derive(Clone, Debug, Parser)]
17#[command(name = "quest")]
18#[command(version, about = "Cli for all the http fetch (re)quests you may go on.", long_about = None)]
19pub struct QuestCli {
20    #[arg(
21        short,
22        long,
23        global = true,
24        default_value = ".env",
25        help = "Load environment variables from file"
26    )]
27    env: PathBuf,
28    #[clap(flatten)]
29    pub options: RequestOptions,
30
31    #[command(subcommand)]
32    command: Command,
33}
34
35impl QuestCli {
36    pub fn init_logging(self) -> Self {
37        env_logger::init();
38        self
39    }
40    fn list_quests(quest_file: QuestFile) -> Result<()> {
41        use colored::Colorize;
42        use std::io::Write;
43
44        // Collect quest data for formatting
45        let mut quest_data: Vec<(String, String, String)> = Vec::new();
46
47        for (name, command) in quest_file.iter() {
48            let (method, url) = match command {
49                QuestCommand::Get { url_spec, .. } => ("GET", url_spec.to_url()?),
50                QuestCommand::Post { url_spec, .. } => ("POST", url_spec.to_url()?),
51                QuestCommand::Put { url_spec, .. } => ("PUT", url_spec.to_url()?),
52                QuestCommand::Delete { url_spec, .. } => ("DELETE", url_spec.to_url()?),
53                QuestCommand::Patch { url_spec, .. } => ("PATCH", url_spec.to_url()?),
54            };
55
56            quest_data.push((name.clone(), method.to_string(), url.to_string()));
57        }
58
59        let stdout = std::io::stdout();
60        let mut handle = stdout.lock();
61
62        if quest_data.is_empty() {
63            writeln!(handle, "No quests found in the quest file.")?;
64            return Ok(());
65        }
66
67        // Calculate column widths
68        let max_name_width = quest_data
69            .iter()
70            .map(|(name, _, _)| name.len())
71            .max()
72            .unwrap_or(4)
73            .max(4); // "NAME" header is 4 chars
74
75        let max_method_width = 6; // "DELETE" is longest method
76
77        // Print header
78        writeln!(
79            handle,
80            "{:<name_width$}  {:<method_width$}  {}",
81            "NAME".bold(),
82            "METHOD".bold(),
83            "URL".bold(),
84            name_width = max_name_width,
85            method_width = max_method_width
86        )?;
87
88        // Print separator
89        writeln!(
90            handle,
91            "{}  {}  {}",
92            "─".repeat(max_name_width),
93            "─".repeat(max_method_width),
94            "─".repeat(40)
95        )?;
96
97        // Print each quest
98        for (name, method, url) in quest_data {
99            let colored_method = match method.as_str() {
100                "GET" => method.green().bold(),
101                "POST" => method.blue().bold(),
102                "PUT" => method.yellow().bold(),
103                "DELETE" => method.red().bold(),
104                "PATCH" => method.magenta().bold(),
105                _ => method.white().bold(),
106            };
107
108            writeln!(
109                handle,
110                "{:<name_width$}  {:<method_width$}  {}",
111                name.cyan(),
112                colored_method,
113                url.bright_black(),
114                name_width = max_name_width,
115                method_width = max_method_width
116            )?;
117        }
118
119        handle.flush()?;
120        Ok(())
121    }
122
123    pub fn execute(self) -> Result<()> {
124        // Load environment variables from file if it exists
125        if self.env.exists() {
126            dotenvy::from_path(&self.env).ok();
127            log::debug!("Loaded environment variables from {}", self.env.display());
128        }
129
130        let options = self.options;
131        match self.command {
132            Command::List { file } => {
133                // Load quest file
134                let quest_file = QuestFile::load(&file)
135                    .with_context(|| format!("Failed to load quest file: {}", file.display()))?;
136
137                Self::list_quests(quest_file)?;
138                Ok(())
139            }
140            Command::Go { name, file } => {
141                // 1. Load quest file
142                let quest_file = QuestFile::load(&file)
143                    .with_context(|| format!("Failed to load quest file: {}", file.display()))?;
144
145                // 2. Find quest by name
146                let quest_command = quest_file
147                    .get(&name)
148                    .ok_or_else(|| anyhow::anyhow!("Quest '{}' not found.", name))?
149                    .clone();
150
151                // 3. Execute the quest command (merging happens in execute_quest_command)
152                log::info!("Executing quest '{}' from {}", name, file.display());
153                Self::execute_quest_command(options, quest_command)
154            }
155            Command::Get { url } => {
156                let quest = QuestCommand::Get {
157                    url_spec: QuestUrl::Direct { url },
158                    options: RequestOptions::default(),
159                };
160                Self::execute_quest_command(options, quest)
161            }
162            Command::Post { url } => {
163                let quest = QuestCommand::Post {
164                    url_spec: QuestUrl::Direct { url },
165                    body: BodyOptions::default(),
166                    options: RequestOptions::default(),
167                };
168                Self::execute_quest_command(options, quest)
169            }
170            Command::Put { url } => {
171                let quest = QuestCommand::Put {
172                    url_spec: QuestUrl::Direct { url },
173                    body: BodyOptions::default(),
174                    options: RequestOptions::default(),
175                };
176                Self::execute_quest_command(options, quest)
177            }
178            Command::Delete { url } => {
179                let quest = QuestCommand::Delete {
180                    url_spec: QuestUrl::Direct { url },
181                    options: RequestOptions::default(),
182                };
183                Self::execute_quest_command(options, quest)
184            }
185            Command::Patch { url } => {
186                let quest = QuestCommand::Patch {
187                    url_spec: QuestUrl::Direct { url },
188                    body: BodyOptions::default(),
189                    options: RequestOptions::default(),
190                };
191                Self::execute_quest_command(options, quest)
192            }
193        }
194    }
195
196    fn log_request_verbose(
197        url: &Url,
198        method: &str,
199        headers: &HeaderOptions,
200        auth: &AuthOptions,
201    ) -> Result<()> {
202        use colored::Colorize;
203        use std::io::Write;
204
205        let stderr = std::io::stderr();
206        let mut handle = stderr.lock();
207
208        // Request line in cyan/bold
209        writeln!(
210            handle,
211            "{} {} HTTP/1.1",
212            method.cyan().bold(),
213            url.as_str().cyan().bold()
214        )?;
215
216        // Headers in blue
217        writeln!(
218            handle,
219            "{} {}",
220            "Host:".blue(),
221            url.host_str().unwrap_or("unknown")
222        )?;
223
224        // Show headers that will be sent
225        if let Some(user_agent) = &headers.user_agent {
226            writeln!(handle, "{} {}", "User-Agent:".blue(), user_agent)?;
227        } else {
228            writeln!(handle, "{} quest/0.1.0", "User-Agent:".blue())?;
229        }
230
231        for header in &headers.header {
232            if let Some((key, value)) = header.split_once(':') {
233                writeln!(
234                    handle,
235                    "{} {}",
236                    format!("{}:", key.trim()).blue(),
237                    value.trim()
238                )?;
239            }
240        }
241
242        // Log authorization headers with redaction
243        if auth.bearer.is_some() {
244            writeln!(handle, "{} Bearer [REDACTED]", "Authorization:".blue())?;
245        } else if auth.auth.is_some() || auth.basic.is_some() {
246            writeln!(handle, "{} Basic [REDACTED]", "Authorization:".blue())?;
247        }
248
249        // Log Referer header if present
250        if let Some(referer) = &headers.referer {
251            writeln!(handle, "{} {}", "Referer:".blue(), referer)?;
252        }
253
254        if let Some(accept) = &headers.accept {
255            writeln!(handle, "{} {}", "Accept:".blue(), accept)?;
256        }
257
258        if let Some(content_type) = &headers.content_type {
259            writeln!(handle, "{} {}", "Content-Type:".blue(), content_type)?;
260        }
261
262        writeln!(handle)?;
263        handle.flush()?;
264        Ok(())
265    }
266
267    fn log_response_verbose(response: &reqwest::blocking::Response) -> Result<()> {
268        use colored::Colorize;
269        use std::io::Write;
270
271        let stderr = std::io::stderr();
272        let mut handle = stderr.lock();
273
274        // Status line in green/bold for success, red/bold for errors
275        let status = response.status();
276        let status_line = format!(
277            "HTTP/1.1 {} {}",
278            status.as_u16(),
279            status.canonical_reason().unwrap_or("")
280        );
281
282        if status.is_success() {
283            writeln!(handle, "{}", status_line.green().bold())?;
284        } else if status.is_client_error() || status.is_server_error() {
285            writeln!(handle, "{}", status_line.red().bold())?;
286        } else {
287            writeln!(handle, "{}", status_line.cyan().bold())?;
288        }
289
290        // Headers in cyan
291        for (name, value) in response.headers() {
292            if let Ok(val_str) = value.to_str() {
293                writeln!(handle, "{} {}", format!("{}:", name).cyan(), val_str)?;
294            }
295        }
296
297        writeln!(handle)?;
298        handle.flush()?;
299        Ok(())
300    }
301
302    fn execute_quest_command(cli_options: RequestOptions, quest: QuestCommand) -> Result<()> {
303        // 1. Extract quest file options and body, transfer body into options
304        let (mut quest_options, quest_body) = match &quest {
305            QuestCommand::Get { options, .. } => (options.clone(), None),
306            QuestCommand::Post { options, body, .. } => (options.clone(), Some(body)),
307            QuestCommand::Put { options, body, .. } => (options.clone(), Some(body)),
308            QuestCommand::Delete { options, .. } => (options.clone(), None),
309            QuestCommand::Patch { options, body, .. } => (options.clone(), Some(body)),
310        };
311
312        // Transfer quest body into quest_options before merge
313        if let Some(body) = quest_body {
314            quest_options.body = body.clone();
315        }
316
317        // 2. Merge quest options (including body) with CLI options
318        quest_options.merge_with(&cli_options)?;
319
320        // 3. Build the client with merged options
321        let client = QuestClientBuilder::new().apply(&quest_options)?.build()?;
322
323        // 4. Build the request based on the quest command
324        let (request_builder, method, url) = match &quest {
325            QuestCommand::Get { url_spec, .. } => {
326                let url = url_spec.to_url()?;
327                (client.get(url.as_str()), "GET", url)
328            }
329            QuestCommand::Post { url_spec, .. } => {
330                let url = url_spec.to_url()?;
331                (client.post(url.as_str()), "POST", url)
332            }
333            QuestCommand::Put { url_spec, .. } => {
334                let url = url_spec.to_url()?;
335                (client.put(url.as_str()), "PUT", url)
336            }
337            QuestCommand::Delete { url_spec, .. } => {
338                let url = url_spec.to_url()?;
339                (client.delete(url.as_str()), "DELETE", url)
340            }
341            QuestCommand::Patch { url_spec, .. } => {
342                let url = url_spec.to_url()?;
343                (client.patch(url.as_str()), "PATCH", url)
344            }
345        };
346
347        // Show request details if verbose
348        if quest_options.output.verbose {
349            Self::log_request_verbose(
350                &url,
351                method,
352                &quest_options.headers,
353                &quest_options.authorization,
354            )?;
355        }
356
357        // 5. Apply merged options (including body) to request
358        let request = QuestRequestBuilder::from_request(request_builder).apply(&quest_options)?;
359
360        // 6. Send the request
361        let response = request.send()?;
362
363        // 7. Handle the response
364        Self::handle_response(response, &quest_options.output)?;
365
366        Ok(())
367    }
368
369    fn handle_response(
370        response: reqwest::blocking::Response,
371        output_opts: &OutputOptions,
372    ) -> Result<()> {
373        use std::io::Write;
374
375        if output_opts.verbose {
376            Self::log_response_verbose(&response)?;
377        }
378
379        let content = response.bytes()?;
380
381        let output = if !output_opts.simple
382            && output_opts.output.is_none()
383            && let Ok(json) = serde_json::from_slice::<serde_json::Value>(&content)
384            && let Ok(formatted) = colored_json::to_colored_json_auto(&json)
385        {
386            Output::Text(formatted)
387        } else if let Ok(text) = String::from_utf8(content.to_vec()) {
388            Output::Text(text)
389        } else {
390            Output::Bytes(content.to_vec())
391        };
392
393        match (output, &output_opts.output) {
394            (output, Some(path)) => {
395                let bytes = output.into_bytes();
396                let mut file = std::fs::File::create(path)
397                    .with_context(|| format!("Failed to create output file: {}", path.display()))?;
398                file.write_all(&bytes)?;
399            }
400            (Output::Text(s), None) => {
401                let mut stdout = std::io::stdout().lock();
402                writeln!(stdout, "{s}").or_else(|e| {
403                    if e.kind() == std::io::ErrorKind::BrokenPipe {
404                        Ok(())
405                    } else {
406                        Err(e)
407                    }
408                })?
409            }
410            (Output::Bytes(v), None) => {
411                anyhow::bail!("[Binary data: {} bytes - use --output to save]", v.len());
412            }
413        }
414
415        Ok(())
416    }
417}
418
419enum Output {
420    Text(String),
421    Bytes(Vec<u8>),
422}
423
424impl Output {
425    pub fn into_bytes(self) -> Vec<u8> {
426        match self {
427            Self::Bytes(v) => v,
428            Self::Text(s) => s.into_bytes(),
429        }
430    }
431}
432
433#[derive(Debug, Subcommand, Clone)]
434pub enum Command {
435    Get {
436        url: Url,
437    },
438    Post {
439        url: Url,
440    },
441    Put {
442        url: Url,
443    },
444    Delete {
445        url: Url,
446    },
447    Patch {
448        url: Url,
449    },
450    /// Run a named quest from a quest file
451    Go {
452        /// Quest name to execute
453        name: String,
454
455        #[arg(
456            short,
457            long,
458            default_value = ".quests.yaml",
459            help = "Quest file to load from"
460        )]
461        file: PathBuf,
462    },
463    /// List all quests from a quest file
464    List {
465        #[arg(
466            short,
467            long,
468            default_value = ".quests.yaml",
469            help = "Quest file to load from"
470        )]
471        file: PathBuf,
472    },
473}
474
475#[derive(Debug, Args, Clone, Default, Deserialize)]
476#[serde(default)]
477pub struct RequestOptions {
478    #[serde(flatten)]
479    #[clap(flatten)]
480    pub authorization: AuthOptions,
481    #[serde(flatten)]
482    #[clap(flatten)]
483    pub headers: HeaderOptions,
484    #[serde(flatten)]
485    #[clap(flatten)]
486    pub params: ParamOptions,
487    #[serde(flatten)]
488    #[clap(flatten)]
489    pub body: BodyOptions,
490    #[serde(flatten)]
491    #[clap(flatten)]
492    pub timeouts: TimeoutOptions,
493    #[serde(flatten)]
494    #[clap(flatten)]
495    pub redirects: RedirectOptions,
496    #[serde(flatten)]
497    #[clap(flatten)]
498    pub tls: TlsOptions,
499    #[serde(flatten)]
500    #[clap(flatten)]
501    pub proxy: ProxyOptions,
502    #[serde(flatten)]
503    #[clap(flatten)]
504    pub output: OutputOptions,
505    #[serde(flatten)]
506    #[clap(flatten)]
507    pub compression: CompressionOptions,
508}
509
510#[derive(Debug, Args, Clone, Default, Deserialize)]
511#[serde(default)]
512pub struct AuthOptions {
513    #[arg(short, long, global = true)]
514    pub auth: Option<SecretString>,
515    #[arg(long, global = true)]
516    pub basic: Option<SecretString>,
517    #[arg(long, global = true)]
518    pub bearer: Option<SecretString>,
519}
520
521#[derive(Debug, Args, Clone, Default, Deserialize)]
522#[serde(default)]
523pub struct HeaderOptions {
524    #[serde(rename = "headers")]
525    #[arg(
526        short = 'H',
527        long = "header",
528        global = true,
529        help = "Custom header (repeatable)"
530    )]
531    pub header: Vec<String>,
532    #[arg(
533        short = 'U',
534        long = "user-agent",
535        global = true,
536        help = "Set User-Agent header"
537    )]
538    pub user_agent: Option<String>,
539    #[arg(
540        short = 'R',
541        long = "referer",
542        global = true,
543        help = "Set Referer header"
544    )]
545    pub referer: Option<String>,
546    #[arg(long = "content-type", global = true, help = "Set Content-Type header")]
547    pub content_type: Option<String>,
548    #[arg(long = "accept", global = true, help = "Set Accept header")]
549    pub accept: Option<String>,
550}
551
552#[derive(Debug, Args, Clone, Default, Deserialize)]
553#[serde(default)]
554pub struct ParamOptions {
555    #[serde(rename = "params")]
556    #[arg(
557        short = 'p',
558        long = "param",
559        global = true,
560        help = "Query parameter (repeatable)"
561    )]
562    pub param: Vec<String>,
563}
564
565#[derive(Debug, Args, Clone, Default, Deserialize)]
566#[serde(default)]
567pub struct TimeoutOptions {
568    #[arg(
569        short = 't',
570        long = "timeout",
571        global = true,
572        help = "Overall request timeout (e.g., '30s', '1m')"
573    )]
574    pub timeout: Option<DurationString>,
575    #[arg(
576        long = "connect-timeout",
577        global = true,
578        help = "Connection timeout (e.g., '10s')"
579    )]
580    pub connect_timeout: Option<DurationString>,
581}
582
583#[derive(Debug, Args, Clone, Default, Deserialize)]
584#[serde(default)]
585pub struct BodyOptions {
586    #[arg(
587        short = 'j',
588        long = "json",
589        group = "body",
590        global = true,
591        help = "Send data as JSON (auto sets Content-Type)",
592        value_hint = clap::ValueHint::FilePath
593    )]
594    pub json: Option<StringOrFile>,
595    #[arg(
596        short = 'F',
597        long = "form",
598        group = "body",
599        global = true,
600        help = "Form data (repeatable)"
601    )]
602    pub form: Vec<FormField>,
603    #[arg(
604        long = "raw",
605        group = "body",
606        global = true,
607        help = "Send raw data without processing",
608        value_hint = clap::ValueHint::FilePath
609    )]
610    pub raw: Option<StringOrFile>,
611    #[arg(
612        long = "binary",
613        group = "body",
614        global = true,
615        help = "Send binary data",
616        value_hint = clap::ValueHint::FilePath
617    )]
618    pub binary: Option<StringOrFile>,
619}
620
621#[derive(Debug, Args, Clone, Default, Deserialize)]
622#[serde(default)]
623pub struct RedirectOptions {
624    #[arg(
625        short = 'L',
626        long = "location",
627        global = true,
628        help = "Follow redirects"
629    )]
630    pub location: bool,
631    #[arg(
632        long = "max-redirects",
633        global = true,
634        help = "Maximum number of redirects to follow"
635    )]
636    pub max_redirects: Option<u32>,
637}
638
639#[derive(Debug, Args, Clone, Default, Deserialize)]
640#[serde(default)]
641pub struct TlsOptions {
642    #[arg(
643        short = 'k',
644        long = "insecure",
645        global = true,
646        help = "Skip TLS verification"
647    )]
648    pub insecure: bool,
649    #[arg(
650        long = "cert",
651        global = true,
652        help = "Client certificate file (PEM format)"
653    )]
654    pub cert: Option<PathBuf>,
655    #[arg(
656        long = "key",
657        global = true,
658        help = "Client certificate key file (PEM format)"
659    )]
660    pub key: Option<PathBuf>,
661    #[arg(
662        long = "cacert",
663        global = true,
664        help = "CA certificate to verify peer against"
665    )]
666    pub cacert: Option<PathBuf>,
667}
668
669#[derive(Debug, Args, Clone, Default, Deserialize)]
670#[serde(default)]
671pub struct ProxyOptions {
672    #[arg(short = 'x', long = "proxy", global = true, help = "Proxy server URL")]
673    pub proxy: Option<Url>,
674    #[arg(long = "proxy-auth", global = true, help = "Proxy authentication")]
675    pub proxy_auth: Option<SecretString>,
676}
677
678#[derive(Debug, Args, Clone, Default, Deserialize)]
679#[serde(default)]
680pub struct OutputOptions {
681    #[arg(
682        short = 'o',
683        long = "output",
684        global = true,
685        help = "Write output to file instead of stdout"
686    )]
687    pub output: Option<PathBuf>,
688
689    #[arg(
690        short,
691        long = "verbose",
692        global = true,
693        help = "Show detailed request/response info"
694    )]
695    pub verbose: bool,
696    #[arg(
697        short,
698        long = "simple",
699        global = true,
700        help = "Show response without color formatting"
701    )]
702    pub simple: bool,
703}
704
705#[derive(Debug, Args, Clone, Default, Deserialize)]
706#[serde(default)]
707pub struct CompressionOptions {
708    #[arg(
709        long = "compressed",
710        global = true,
711        help = "Request compressed response (gzip, deflate, br)"
712    )]
713    pub compressed: bool,
714}
715
716// Merge implementations for combining quest options with CLI options
717impl RequestOptions {
718    pub fn merge_with(&mut self, cli_options: &RequestOptions) -> Result<&Self> {
719        self.authorization.merge_with(&cli_options.authorization)?;
720        self.headers.merge_with(&cli_options.headers)?;
721        self.params.merge_with(&cli_options.params)?;
722        self.body.merge_with(&cli_options.body)?;
723        self.timeouts.merge_with(&cli_options.timeouts)?;
724        self.redirects.merge_with(&cli_options.redirects)?;
725        self.tls.merge_with(&cli_options.tls)?;
726        self.proxy.merge_with(&cli_options.proxy)?;
727        self.output.merge_with(&cli_options.output)?;
728        self.compression.merge_with(&cli_options.compression)?;
729
730        Ok(self)
731    }
732}
733
734impl AuthOptions {
735    pub fn merge_with(&mut self, cli: &AuthOptions) -> Result<&Self> {
736        if cli.auth.is_some() {
737            self.auth = cli.auth.clone();
738        }
739        if cli.basic.is_some() {
740            self.basic = cli.basic.clone();
741        }
742        if cli.bearer.is_some() {
743            self.bearer = cli.bearer.clone();
744        }
745
746        Ok(self)
747    }
748}
749
750impl HeaderOptions {
751    pub fn merge_with(&mut self, cli: &HeaderOptions) -> Result<&Self> {
752        // Collections: simple concatenation
753        self.header.extend(cli.header.clone());
754
755        // Scalar overrides
756        if cli.user_agent.is_some() {
757            self.user_agent = cli.user_agent.clone();
758        }
759        if cli.referer.is_some() {
760            self.referer = cli.referer.clone();
761        }
762        if cli.content_type.is_some() {
763            self.content_type = cli.content_type.clone();
764        }
765        if cli.accept.is_some() {
766            self.accept = cli.accept.clone();
767        }
768
769        Ok(self)
770    }
771}
772
773impl ParamOptions {
774    pub fn merge_with(&mut self, cli: &ParamOptions) -> Result<&Self> {
775        // Use BTreeSet to deduplicate based on entire "key=value" string
776        // This allows foo=bar and foo=different to coexist, but deduplicates exact matches
777        use std::collections::{BTreeMap, BTreeSet};
778
779        let mut params: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
780
781        for param in &self.param {
782            let (key, value) = param.split_once("=").ok_or_else(|| anyhow::anyhow!(
783                "Invalid parameter format: '{}'. Expected format: 'key=value' (must contain an equals sign)",
784                param
785            ))?;
786
787            params
788                .entry(key.trim().to_string())
789                .or_default()
790                .insert(value.trim().to_string());
791        }
792
793        let mut cli_params: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
794
795        for param in &cli.param {
796            let (key, value) = param.split_once("=").ok_or_else(|| anyhow::anyhow!(
797                "Invalid parameter format: '{}'. Expected format: 'key=value' (must contain an equals sign)",
798                param
799            ))?;
800
801            cli_params
802                .entry(key.trim().to_string())
803                .or_default()
804                .insert(value.trim().to_string());
805        }
806
807        params.extend(cli_params);
808
809        // Convert back to Vec (sorted order from BTreeSet)
810        self.param = params
811            .into_iter()
812            .flat_map(|(key, values)| {
813                values
814                    .into_iter()
815                    .map(|value| format!("{key}={value}"))
816                    .collect::<Vec<_>>()
817            })
818            .collect();
819
820        Ok(self)
821    }
822}
823
824impl TimeoutOptions {
825    pub fn merge_with(&mut self, cli: &TimeoutOptions) -> Result<&Self> {
826        if cli.timeout.is_some() {
827            self.timeout = cli.timeout;
828        }
829        if cli.connect_timeout.is_some() {
830            self.connect_timeout = cli.connect_timeout;
831        }
832
833        Ok(self)
834    }
835}
836
837impl RedirectOptions {
838    pub fn merge_with(&mut self, cli: &RedirectOptions) -> Result<&Self> {
839        if cli.location {
840            self.location = cli.location;
841        }
842        if cli.max_redirects.is_some() {
843            self.max_redirects = cli.max_redirects;
844        }
845
846        Ok(self)
847    }
848}
849
850impl TlsOptions {
851    pub fn merge_with(&mut self, cli: &TlsOptions) -> Result<&Self> {
852        if cli.insecure {
853            self.insecure = cli.insecure;
854        }
855        if cli.cert.is_some() {
856            self.cert = cli.cert.clone();
857        }
858        if cli.key.is_some() {
859            self.key = cli.key.clone();
860        }
861        if cli.cacert.is_some() {
862            self.cacert = cli.cacert.clone();
863        }
864
865        Ok(self)
866    }
867}
868
869impl ProxyOptions {
870    pub fn merge_with(&mut self, cli: &ProxyOptions) -> Result<&Self> {
871        if cli.proxy.is_some() {
872            self.proxy = cli.proxy.clone();
873        }
874        if cli.proxy_auth.is_some() {
875            self.proxy_auth = cli.proxy_auth.clone();
876        }
877
878        Ok(self)
879    }
880}
881
882impl OutputOptions {
883    pub fn merge_with(&mut self, cli: &OutputOptions) -> Result<&Self> {
884        if cli.output.is_some() {
885            self.output = cli.output.clone();
886        }
887        if cli.verbose {
888            self.verbose = cli.verbose;
889        }
890        if cli.simple {
891            self.simple = cli.simple;
892        }
893
894        Ok(self)
895    }
896}
897
898impl CompressionOptions {
899    pub fn merge_with(&mut self, cli: &CompressionOptions) -> Result<&Self> {
900        if cli.compressed {
901            self.compressed = cli.compressed;
902        }
903
904        Ok(self)
905    }
906}
907
908impl BodyOptions {
909    pub fn merge_with(&mut self, cli: &BodyOptions) -> Result<&Self> {
910        // Body options are mutually exclusive (clap group)
911        // If CLI provides any body option, it completely replaces quest body
912        if cli.json.is_some() | !cli.form.is_empty() | cli.raw.is_some() | cli.binary.is_some() {
913            *self = cli.clone();
914        }
915
916        // If CLI has no body options, keep quest body unchanged
917        Ok(self)
918    }
919}