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 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 let max_name_width = quest_data
69 .iter()
70 .map(|(name, _, _)| name.len())
71 .max()
72 .unwrap_or(4)
73 .max(4); let max_method_width = 6; 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 writeln!(
90 handle,
91 "{} {} {}",
92 "─".repeat(max_name_width),
93 "─".repeat(max_method_width),
94 "─".repeat(40)
95 )?;
96
97 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 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 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 let quest_file = QuestFile::load(&file)
143 .with_context(|| format!("Failed to load quest file: {}", file.display()))?;
144
145 let mut quest_command = quest_file
147 .get(&name)
148 .ok_or_else(|| anyhow::anyhow!("Quest '{}' not found.", name))?
149 .clone();
150
151 match &mut quest_command {
153 QuestCommand::Get { options: qopts, .. }
154 | QuestCommand::Delete { options: qopts, .. } => {
155 qopts.merge_with(&options);
156 }
157 QuestCommand::Post { options: qopts, .. }
158 | QuestCommand::Put { options: qopts, .. }
159 | QuestCommand::Patch { options: qopts, .. } => {
160 qopts.merge_with(&options);
161 }
162 }
163
164 log::info!("Executing quest '{}' from {}", name, file.display());
166 Self::execute_quest_command(options, quest_command)
167 }
168 Command::Get { url } => {
169 let quest = QuestCommand::Get {
170 url_spec: QuestUrl::Direct { url },
171 options: RequestOptions::default(),
172 };
173 Self::execute_quest_command(options, quest)
174 }
175 Command::Post { url, body } => {
176 let quest = QuestCommand::Post {
177 url_spec: QuestUrl::Direct { url },
178 body,
179 options: RequestOptions::default(),
180 };
181 Self::execute_quest_command(options, quest)
182 }
183 Command::Put { url, body } => {
184 let quest = QuestCommand::Put {
185 url_spec: QuestUrl::Direct { url },
186 body,
187 options: RequestOptions::default(),
188 };
189 Self::execute_quest_command(options, quest)
190 }
191 Command::Delete { url } => {
192 let quest = QuestCommand::Delete {
193 url_spec: QuestUrl::Direct { url },
194 options: RequestOptions::default(),
195 };
196 Self::execute_quest_command(options, quest)
197 }
198 Command::Patch { url, body } => {
199 let quest = QuestCommand::Patch {
200 url_spec: QuestUrl::Direct { url },
201 body,
202 options: RequestOptions::default(),
203 };
204 Self::execute_quest_command(options, quest)
205 }
206 }
207 }
208
209 fn log_request_verbose(
210 url: &Url,
211 method: &str,
212 headers: &HeaderOptions,
213 auth: &AuthOptions,
214 ) -> Result<()> {
215 use colored::Colorize;
216 use std::io::Write;
217
218 let stderr = std::io::stderr();
219 let mut handle = stderr.lock();
220
221 writeln!(
223 handle,
224 "{} {} HTTP/1.1",
225 method.cyan().bold(),
226 url.as_str().cyan().bold()
227 )?;
228
229 writeln!(
231 handle,
232 "{} {}",
233 "Host:".blue(),
234 url.host_str().unwrap_or("unknown")
235 )?;
236
237 if let Some(user_agent) = &headers.user_agent {
239 writeln!(handle, "{} {}", "User-Agent:".blue(), user_agent)?;
240 } else {
241 writeln!(handle, "{} quest/0.1.0", "User-Agent:".blue())?;
242 }
243
244 for header in &headers.header {
245 if let Some((key, value)) = header.split_once(':') {
246 writeln!(
247 handle,
248 "{} {}",
249 format!("{}:", key.trim()).blue(),
250 value.trim()
251 )?;
252 }
253 }
254
255 if auth.bearer.is_some() {
257 writeln!(handle, "{} Bearer [REDACTED]", "Authorization:".blue())?;
258 } else if auth.auth.is_some() || auth.basic.is_some() {
259 writeln!(handle, "{} Basic [REDACTED]", "Authorization:".blue())?;
260 }
261
262 if let Some(referer) = &headers.referer {
264 writeln!(handle, "{} {}", "Referer:".blue(), referer)?;
265 }
266
267 if let Some(accept) = &headers.accept {
268 writeln!(handle, "{} {}", "Accept:".blue(), accept)?;
269 }
270
271 if let Some(content_type) = &headers.content_type {
272 writeln!(handle, "{} {}", "Content-Type:".blue(), content_type)?;
273 }
274
275 writeln!(handle)?;
276 handle.flush()?;
277 Ok(())
278 }
279
280 fn log_response_verbose(response: &reqwest::blocking::Response) -> Result<()> {
281 use colored::Colorize;
282 use std::io::Write;
283
284 let stderr = std::io::stderr();
285 let mut handle = stderr.lock();
286
287 let status = response.status();
289 let status_line = format!(
290 "HTTP/1.1 {} {}",
291 status.as_u16(),
292 status.canonical_reason().unwrap_or("")
293 );
294
295 if status.is_success() {
296 writeln!(handle, "{}", status_line.green().bold())?;
297 } else if status.is_client_error() || status.is_server_error() {
298 writeln!(handle, "{}", status_line.red().bold())?;
299 } else {
300 writeln!(handle, "{}", status_line.cyan().bold())?;
301 }
302
303 for (name, value) in response.headers() {
305 if let Ok(val_str) = value.to_str() {
306 writeln!(handle, "{} {}", format!("{}:", name).cyan(), val_str)?;
307 }
308 }
309
310 writeln!(handle)?;
311 handle.flush()?;
312 Ok(())
313 }
314
315 fn execute_quest_command(cli_options: RequestOptions, quest: QuestCommand) -> Result<()> {
316 let mut quest_options = match &quest {
318 QuestCommand::Get { options, .. } => options,
319 QuestCommand::Post { options, .. } => options,
320 QuestCommand::Put { options, .. } => options,
321 QuestCommand::Delete { options, .. } => options,
322 QuestCommand::Patch { options, .. } => options,
323 }
324 .clone();
325 quest_options.merge_with(&cli_options);
326
327 let client = QuestClientBuilder::new().apply(&quest_options)?.build()?;
329
330 let (request_builder, body_options, method, url) = match &quest {
332 QuestCommand::Get { url_spec, .. } => {
333 let url = url_spec.to_url()?;
334 (client.get(url.as_str()), None, "GET", url)
335 }
336 QuestCommand::Post { url_spec, body, .. } => {
337 let url = url_spec.to_url()?;
338 (client.post(url.as_str()), Some(body.clone()), "POST", url)
339 }
340 QuestCommand::Put { url_spec, body, .. } => {
341 let url = url_spec.to_url()?;
342 (client.put(url.as_str()), Some(body.clone()), "PUT", url)
343 }
344 QuestCommand::Delete { url_spec, .. } => {
345 let url = url_spec.to_url()?;
346 (client.delete(url.as_str()), None, "DELETE", url)
347 }
348 QuestCommand::Patch { url_spec, body, .. } => {
349 let url = url_spec.to_url()?;
350 (client.patch(url.as_str()), Some(body.clone()), "PATCH", url)
351 }
352 };
353
354 if quest_options.output.verbose {
356 Self::log_request_verbose(
357 &url,
358 method,
359 &quest_options.headers,
360 &quest_options.authorization,
361 )?;
362 }
363
364 let mut request =
366 QuestRequestBuilder::from_request(request_builder).apply(&quest_options)?;
367
368 if let Some(body) = body_options {
370 request = request.apply(&body)?;
371 }
372
373 let response = request.send()?;
375
376 Self::handle_response(response, &quest_options.output)?;
378
379 Ok(())
380 }
381
382 fn handle_response(
383 response: reqwest::blocking::Response,
384 output_opts: &OutputOptions,
385 ) -> Result<()> {
386 use std::io::Write;
387
388 let mut output_parts = Vec::new();
390
391 if output_opts.include {
393 let status_line = format!(
394 "HTTP/1.1 {} {}\n",
395 response.status().as_u16(),
396 response.status().canonical_reason().unwrap_or("")
397 );
398 output_parts.push(status_line);
399
400 for (name, value) in response.headers() {
401 if let Ok(val_str) = value.to_str() {
402 output_parts.push(format!("{}: {}\n", name, val_str));
403 }
404 }
405 output_parts.push("\n".to_string());
406 }
407
408 if output_opts.verbose {
409 Self::log_response_verbose(&response)?;
410 }
411
412 let body_text = if response
414 .headers()
415 .get("content-type")
416 .and_then(|v| v.to_str().ok())
417 .map(|ct| ct.contains("application/json") || ct.contains("application/vnd.api+json"))
418 .unwrap_or(false)
419 && !output_opts.simple
420 {
421 let json_value = response.json::<serde_json::Value>()?;
423 colored_json::to_colored_json_auto(&json_value)?
424 } else {
425 response.text()?
427 };
428
429 output_parts.push(body_text);
430
431 let full_output = output_parts.join("");
432
433 if let Some(output_file) = &output_opts.output {
435 let mut file = std::fs::File::create(output_file).with_context(|| {
436 format!("Failed to create output file: {}", output_file.display())
437 })?;
438 file.write_all(full_output.as_bytes())?;
439 } else {
440 println!("{full_output}");
441 }
442
443 Ok(())
444 }
445}
446
447#[derive(Debug, Subcommand, Clone)]
448pub enum Command {
449 Get {
450 url: Url,
451 },
452 Post {
453 url: Url,
454 #[clap(flatten)]
455 body: BodyOptions,
456 },
457 Put {
458 url: Url,
459 #[clap(flatten)]
460 body: BodyOptions,
461 },
462 Delete {
463 url: Url,
464 },
465 Patch {
466 url: Url,
467 #[clap(flatten)]
468 body: BodyOptions,
469 },
470 Go {
472 name: String,
474
475 #[arg(
476 short,
477 long,
478 default_value = ".quests.yaml",
479 help = "Quest file to load from"
480 )]
481 file: PathBuf,
482 },
483 List {
485 #[arg(
486 short,
487 long,
488 default_value = ".quests.yaml",
489 help = "Quest file to load from"
490 )]
491 file: PathBuf,
492 },
493}
494
495#[derive(Debug, Args, Clone, Default, Deserialize)]
496#[serde(default)]
497pub struct RequestOptions {
498 #[serde(flatten)]
499 #[clap(flatten)]
500 pub authorization: AuthOptions,
501 #[serde(flatten)]
502 #[clap(flatten)]
503 pub headers: HeaderOptions,
504 #[serde(flatten)]
505 #[clap(flatten)]
506 pub params: ParamOptions,
507 #[serde(flatten)]
508 #[clap(flatten)]
509 pub timeouts: TimeoutOptions,
510 #[serde(flatten)]
511 #[clap(flatten)]
512 pub redirects: RedirectOptions,
513 #[serde(flatten)]
514 #[clap(flatten)]
515 pub tls: TlsOptions,
516 #[serde(flatten)]
517 #[clap(flatten)]
518 pub proxy: ProxyOptions,
519 #[serde(flatten)]
520 #[clap(flatten)]
521 pub output: OutputOptions,
522 #[serde(flatten)]
523 #[clap(flatten)]
524 pub compression: CompressionOptions,
525}
526
527#[derive(Debug, Args, Clone, Default, Deserialize)]
528#[serde(default)]
529pub struct AuthOptions {
530 #[arg(short, long, global = true)]
531 pub auth: Option<SecretString>,
532 #[arg(long, global = true)]
533 pub basic: Option<SecretString>,
534 #[arg(long, global = true)]
535 pub bearer: Option<SecretString>,
536}
537
538#[derive(Debug, Args, Clone, Default, Deserialize)]
539#[serde(default)]
540pub struct HeaderOptions {
541 #[serde(rename = "headers")]
542 #[arg(
543 short = 'H',
544 long = "header",
545 global = true,
546 help = "Custom header (repeatable)"
547 )]
548 pub header: Vec<String>,
549 #[arg(
550 short = 'U',
551 long = "user-agent",
552 global = true,
553 help = "Set User-Agent header"
554 )]
555 pub user_agent: Option<String>,
556 #[arg(
557 short = 'R',
558 long = "referer",
559 global = true,
560 help = "Set Referer header"
561 )]
562 pub referer: Option<String>,
563 #[arg(long = "content-type", global = true, help = "Set Content-Type header")]
564 pub content_type: Option<String>,
565 #[arg(long = "accept", global = true, help = "Set Accept header")]
566 pub accept: Option<String>,
567}
568
569#[derive(Debug, Args, Clone, Default, Deserialize)]
570#[serde(default)]
571pub struct ParamOptions {
572 #[serde(rename = "params")]
573 #[arg(
574 short = 'p',
575 long = "param",
576 global = true,
577 help = "Query parameter (repeatable)"
578 )]
579 pub param: Vec<String>,
580}
581
582#[derive(Debug, Args, Clone, Default, Deserialize)]
583#[serde(default)]
584pub struct TimeoutOptions {
585 #[arg(
586 short = 't',
587 long = "timeout",
588 global = true,
589 help = "Overall request timeout (e.g., '30s', '1m')"
590 )]
591 pub timeout: Option<DurationString>,
592 #[arg(
593 long = "connect-timeout",
594 global = true,
595 help = "Connection timeout (e.g., '10s')"
596 )]
597 pub connect_timeout: Option<DurationString>,
598}
599
600#[derive(Debug, Args, Clone, Default, Deserialize)]
601#[serde(default)]
602pub struct BodyOptions {
603 #[arg(
604 short = 'j',
605 long = "json",
606 group = "body",
607 help = "Send data as JSON (auto sets Content-Type)",
608 value_hint = clap::ValueHint::FilePath
609 )]
610 pub json: Option<StringOrFile>,
611 #[arg(
612 short = 'F',
613 long = "form",
614 group = "body",
615 help = "Form data (repeatable)"
616 )]
617 pub form: Vec<FormField>,
618 #[arg(
619 long = "raw",
620 group = "body",
621 help = "Send raw data without processing",
622 value_hint = clap::ValueHint::FilePath
623 )]
624 pub raw: Option<StringOrFile>,
625 #[arg(
626 long = "binary",
627 group = "body",
628 help = "Send binary data",
629 value_hint = clap::ValueHint::FilePath
630 )]
631 pub binary: Option<StringOrFile>,
632}
633
634#[derive(Debug, Args, Clone, Default, Deserialize)]
635#[serde(default)]
636pub struct RedirectOptions {
637 #[arg(
638 short = 'L',
639 long = "location",
640 global = true,
641 help = "Follow redirects"
642 )]
643 pub location: bool,
644 #[arg(
645 long = "max-redirects",
646 global = true,
647 help = "Maximum number of redirects to follow"
648 )]
649 pub max_redirects: Option<u32>,
650}
651
652#[derive(Debug, Args, Clone, Default, Deserialize)]
653#[serde(default)]
654pub struct TlsOptions {
655 #[arg(
656 short = 'k',
657 long = "insecure",
658 global = true,
659 help = "Skip TLS verification"
660 )]
661 pub insecure: bool,
662 #[arg(
663 long = "cert",
664 global = true,
665 help = "Client certificate file (PEM format)"
666 )]
667 pub cert: Option<PathBuf>,
668 #[arg(
669 long = "key",
670 global = true,
671 help = "Client certificate key file (PEM format)"
672 )]
673 pub key: Option<PathBuf>,
674 #[arg(
675 long = "cacert",
676 global = true,
677 help = "CA certificate to verify peer against"
678 )]
679 pub cacert: Option<PathBuf>,
680}
681
682#[derive(Debug, Args, Clone, Default, Deserialize)]
683#[serde(default)]
684pub struct ProxyOptions {
685 #[arg(short = 'x', long = "proxy", global = true, help = "Proxy server URL")]
686 pub proxy: Option<Url>,
687 #[arg(long = "proxy-auth", global = true, help = "Proxy authentication")]
688 pub proxy_auth: Option<SecretString>,
689}
690
691#[derive(Debug, Args, Clone, Default, Deserialize)]
692#[serde(default)]
693pub struct OutputOptions {
694 #[arg(
695 short = 'o',
696 long = "output",
697 global = true,
698 help = "Write output to file instead of stdout"
699 )]
700 pub output: Option<PathBuf>,
701 #[arg(
702 short = 'i',
703 long = "include",
704 global = true,
705 help = "Include response headers in output"
706 )]
707 pub include: bool,
708 #[arg(
709 short,
710 long = "verbose",
711 global = true,
712 help = "Show detailed request/response info"
713 )]
714 pub verbose: bool,
715 #[arg(
716 short,
717 long = "simple",
718 global = true,
719 help = "Show response without color formatting"
720 )]
721 pub simple: bool,
722}
723
724#[derive(Debug, Args, Clone, Default, Deserialize)]
725#[serde(default)]
726pub struct CompressionOptions {
727 #[arg(
728 long = "compressed",
729 global = true,
730 help = "Request compressed response (gzip, deflate, br)"
731 )]
732 pub compressed: bool,
733}
734
735impl RequestOptions {
737 pub fn merge_with(&mut self, cli_options: &RequestOptions) {
738 self.authorization.merge_with(&cli_options.authorization);
739 self.headers.merge_with(&cli_options.headers);
740 self.params.merge_with(&cli_options.params);
741 self.timeouts.merge_with(&cli_options.timeouts);
742 self.redirects.merge_with(&cli_options.redirects);
743 self.tls.merge_with(&cli_options.tls);
744 self.proxy.merge_with(&cli_options.proxy);
745 self.output.merge_with(&cli_options.output);
746 self.compression.merge_with(&cli_options.compression);
747 }
748}
749
750impl AuthOptions {
751 pub fn merge_with(&mut self, cli: &AuthOptions) {
752 if cli.auth.is_some() {
753 self.auth = cli.auth.clone();
754 }
755 if cli.basic.is_some() {
756 self.basic = cli.basic.clone();
757 }
758 if cli.bearer.is_some() {
759 self.bearer = cli.bearer.clone();
760 }
761 }
762}
763
764impl HeaderOptions {
765 pub fn merge_with(&mut self, cli: &HeaderOptions) {
766 self.header.extend(cli.header.clone());
768
769 if cli.user_agent.is_some() {
771 self.user_agent = cli.user_agent.clone();
772 }
773 if cli.referer.is_some() {
774 self.referer = cli.referer.clone();
775 }
776 if cli.content_type.is_some() {
777 self.content_type = cli.content_type.clone();
778 }
779 if cli.accept.is_some() {
780 self.accept = cli.accept.clone();
781 }
782 }
783}
784
785impl ParamOptions {
786 pub fn merge_with(&mut self, cli: &ParamOptions) {
787 self.param.extend(cli.param.clone());
789 }
790}
791
792impl TimeoutOptions {
793 pub fn merge_with(&mut self, cli: &TimeoutOptions) {
794 if cli.timeout.is_some() {
795 self.timeout = cli.timeout;
796 }
797 if cli.connect_timeout.is_some() {
798 self.connect_timeout = cli.connect_timeout;
799 }
800 }
801}
802
803impl RedirectOptions {
804 pub fn merge_with(&mut self, cli: &RedirectOptions) {
805 if cli.location {
806 self.location = cli.location;
807 }
808 if cli.max_redirects.is_some() {
809 self.max_redirects = cli.max_redirects;
810 }
811 }
812}
813
814impl TlsOptions {
815 pub fn merge_with(&mut self, cli: &TlsOptions) {
816 if cli.insecure {
817 self.insecure = cli.insecure;
818 }
819 if cli.cert.is_some() {
820 self.cert = cli.cert.clone();
821 }
822 if cli.key.is_some() {
823 self.key = cli.key.clone();
824 }
825 if cli.cacert.is_some() {
826 self.cacert = cli.cacert.clone();
827 }
828 }
829}
830
831impl ProxyOptions {
832 pub fn merge_with(&mut self, cli: &ProxyOptions) {
833 if cli.proxy.is_some() {
834 self.proxy = cli.proxy.clone();
835 }
836 if cli.proxy_auth.is_some() {
837 self.proxy_auth = cli.proxy_auth.clone();
838 }
839 }
840}
841
842impl OutputOptions {
843 pub fn merge_with(&mut self, cli: &OutputOptions) {
844 if cli.output.is_some() {
845 self.output = cli.output.clone();
846 }
847 if cli.include {
848 self.include = cli.include;
849 }
850 if cli.verbose {
851 self.verbose = cli.verbose;
852 }
853 if cli.simple {
854 self.simple = cli.simple;
855 }
856 }
857}
858
859impl CompressionOptions {
860 pub fn merge_with(&mut self, cli: &CompressionOptions) {
861 if cli.compressed {
862 self.compressed = cli.compressed;
863 }
864 }
865}