rsb/
arg.rs

1//! arg module define the application entry arguments [Arg]
2
3use std::path::PathBuf;
4use std::time::Duration;
5
6use clap::{
7    builder::{
8        IntoResettable, OsStr, PossibleValue,
9        Resettable::{self, *},
10    },
11    ArgGroup, Parser, ValueEnum, ValueHint,
12};
13use clap_complete::Shell;
14
15fn is_number(s: &str) -> bool {
16    s.parse::<u64>().is_ok()
17}
18
19fn parse_duration(arg: &str) -> Result<Duration, std::num::ParseIntError> {
20    if is_number(arg) {
21        return Ok(Duration::from_secs(arg.parse()?));
22    }
23
24    let mut input = arg;
25    if input.ends_with("s") {
26        input = &arg[..arg.len() - 1]
27    }
28
29    let seconds = input.parse()?;
30    Ok(Duration::from_secs(seconds))
31}
32
33fn parse_percentiles(arg: &str) -> anyhow::Result<f32> {
34    let value = arg.parse::<f32>()?;
35    if value <= 0f32 || value >= 1f32 {
36        anyhow::bail!("{} must be limited to the range (0 to 1)", value);
37    }
38    Ok(value)
39}
40
41fn parse_filename_and_path(s: &str) -> anyhow::Result<(String, PathBuf)> {
42    let pos = s.find(':').ok_or(anyhow::anyhow!(
43        "invalid filename:filepath,: no `:` found in `{s}`"
44    ))?;
45    Ok((s[..pos].parse()?, s[pos + 1..].parse()?))
46}
47
48/// define output format
49#[derive(Debug, Clone, Copy)]
50pub enum OutputFormat {
51    /// Text output format
52    Text,
53    /// Json output format
54    Json,
55}
56
57impl IntoResettable<OsStr> for OutputFormat {
58    fn into_resettable(self) -> Resettable<OsStr> {
59        match self {
60            OutputFormat::Text => Value(OsStr::from("TEXT")),
61            OutputFormat::Json => Value(OsStr::from("JSON")),
62        }
63    }
64}
65
66impl ValueEnum for OutputFormat {
67    fn value_variants<'a>() -> &'a [Self] {
68        &[OutputFormat::Text, OutputFormat::Json]
69    }
70
71    fn to_possible_value<'a>(&self) -> Option<PossibleValue> {
72        Some(match self {
73            OutputFormat::Text => PossibleValue::new("TEXT"),
74            OutputFormat::Json => PossibleValue::new("JSON"),
75        })
76    }
77}
78
79/// define supported http methods
80#[derive(Debug, Clone, Copy, Eq, Hash, PartialEq)]
81pub enum Method {
82    /// Get request
83    Get,
84    /// Post request
85    Post,
86    /// put request
87    Put,
88    /// delete request
89    Delete,
90    /// head request
91    Head,
92    /// patch request
93    Patch,
94}
95
96impl Method {
97    /// convert to [reqwest::Method]
98    pub(crate) fn to_reqwest_method(self) -> reqwest::Method {
99        match self {
100            Method::Get => reqwest::Method::GET,
101            Method::Post => reqwest::Method::POST,
102            Method::Put => reqwest::Method::PUT,
103            Method::Patch => reqwest::Method::PATCH,
104            Method::Delete => reqwest::Method::DELETE,
105            Method::Head => reqwest::Method::HEAD,
106        }
107    }
108}
109
110impl IntoResettable<OsStr> for Method {
111    fn into_resettable(self) -> Resettable<OsStr> {
112        match self {
113            Method::Get => Value(OsStr::from("GET")),
114            Method::Post => Value(OsStr::from("POST")),
115            Method::Put => Value(OsStr::from("PUT")),
116            Method::Delete => Value(OsStr::from("DELETE")),
117            Method::Head => Value(OsStr::from("HEAD")),
118            Method::Patch => Value(OsStr::from("PATCH")),
119        }
120    }
121}
122
123impl ValueEnum for Method {
124    fn value_variants<'a>() -> &'a [Self] {
125        &[
126            Method::Get,
127            Method::Put,
128            Method::Post,
129            Method::Delete,
130            Method::Head,
131            Method::Patch,
132        ]
133    }
134
135    fn to_possible_value<'a>(&self) -> Option<PossibleValue> {
136        Some(match self {
137            Method::Get => PossibleValue::new("GET"),
138            Method::Put => PossibleValue::new("PUT"),
139            Method::Post => PossibleValue::new("POST"),
140            Method::Delete => PossibleValue::new("DELETE"),
141            Method::Head => PossibleValue::new("HEAD"),
142            Method::Patch => PossibleValue::new("PATCH"),
143        })
144    }
145}
146
147/// a http server benchmark tool, written in rust
148#[derive(Debug, Parser)]
149#[clap(color = concolor_clap::color_choice())]
150#[command(author, version, about, allow_missing_positional(true))]
151#[command(group(ArgGroup::new("json").args(["json_body", "json_file"])))]
152#[command(group(ArgGroup::new("text").args(["text_body", "text_file"])))]
153#[command(group(ArgGroup::new("multipart").args(["mp", "mp_file"]).multiple(true)))]
154#[command(group(ArgGroup::new("mode").args(["duration", "requests"])))]
155#[command(help_template(
156    "\
157{before-help}{name}({version}){tab}{about-with-newline}
158{usage-heading} {usage}
159
160{all-args}{after-help}\
161"
162))]
163pub struct Arg {
164    /// Maximum number of concurrent connections
165    #[arg(
166        long,
167        short,
168        default_value_t = 50,
169        help = "Maximum number of concurrent connections"
170    )]
171    pub connections: u16,
172
173    /// Socket/request timeout
174    #[arg(
175        long,
176        short,
177        value_parser = parse_duration,
178        default_value = "30s",
179        help = "Socket/request timeout"
180    )]
181    pub(crate) timeout: Duration,
182
183    /// Print latency statistics
184    #[arg(long, short, help = "Print latency statistics")]
185    pub(crate) latencies: bool,
186
187    /// custom latency percentiles
188    #[arg(
189        long,
190        num_args = 0..,
191        value_parser = parse_percentiles,
192        value_delimiter = ',',
193        default_value = "0.5,0.75,0.9,0.99",
194        help = "Custom latency percentiles"
195    )]
196    pub(crate) percentiles: Vec<f32>,
197
198    /// Request method
199    #[arg(
200        long,
201        short,
202        default_value = Method::Get,
203        value_enum,
204        help = "Request method"
205    )]
206    pub method: Method,
207
208    /// Disable HTTP keep-alive
209    #[arg(long, short = 'a', help = "Disable HTTP keep-alive")]
210    pub(crate) disable_keep_alive: bool,
211
212    #[arg(
213        long,
214        short = 'H',
215        num_args = 0..,
216        value_delimiter = ',',
217        help = "HTTP headers to use, example: -H=k:v,k1:v1 -H=k2:v2"
218    )]
219    pub(crate) headers: Vec<String>,
220
221    /// Number of requests
222    #[arg(
223        long,
224        short = 'n',
225        help = "Number of requests",
226        required_unless_present_any(["duration", "completions"])
227    )]
228    pub requests: Option<u64>,
229
230    /// Duration of test
231    #[arg(
232        long,
233        short = 'd',
234        value_parser = parse_duration,
235        help = "Duration of test",
236        required_unless_present_any(["requests", "completions"])
237    )]
238    pub duration: Option<Duration>,
239
240    /// Rate limit in requests per second
241    #[arg(long, short = 'r', help = "Rate limit in requests per second")]
242    pub(crate) rate: Option<u16>,
243
244    /// Path to the client's TLS Certificate
245    #[arg(
246        long,
247        value_hint = ValueHint::FilePath,
248        requires("key"),
249        help = "Path to the client's TLS Certificate"
250    )]
251    pub(crate) cert: Option<PathBuf>,
252
253    /// Path to the client's TLS Certificate Private Key
254    #[arg(
255        long,
256        requires("cert"),
257        value_hint = ValueHint::FilePath,
258        help = "Path to the client's TLS Certificate Private Key"
259    )]
260    pub(crate) key: Option<PathBuf>,
261
262    /// Controls whether a client verifies the server's
263    /// certificate chain and host name
264    #[arg(
265        long,
266        short = 'k',
267        help = "Controls whether a client verifies the server's certificate chain and host name"
268    )]
269    pub(crate) insecure: bool,
270
271    /// File to use as json request body
272    #[arg(
273        long,
274        value_hint = ValueHint::FilePath,
275        conflicts_with_all(["mp_file", "mp", "form", "text_body", "text_file", "json_body", "json_command"]),
276        help = "File to use as Request body for ContentType: application/json"
277    )]
278    pub(crate) json_file: Option<PathBuf>,
279
280    /// JSON request body
281    #[arg(
282        long,
283        conflicts_with_all(["mp_file", "mp", "form", "text_body", "text_file", "json_file", "json_command"]),
284        help = "Request body for ContentType: application/json")]
285    pub(crate) json_body: Option<String>,
286
287    /// JSON request body
288    #[arg(
289    long,
290    conflicts_with_all(["mp_file", "mp", "form", "text_body", "text_file", "json_file", "json_body"]),
291    help = "Build request body from external command for ContentType: application/json")]
292    pub(crate) json_command: Option<String>,
293
294    /// File to use as text request Body
295    #[arg(
296        long,
297        value_hint = ValueHint::FilePath,
298        conflicts_with_all(["mp_file", "mp", "form", "text_body", "json_file", "json_body"]),
299        help = "File to use as Request body for ContentType: text/plain"
300    )]
301    pub(crate) text_file: Option<PathBuf>,
302
303    /// Text request body
304    #[arg(
305        long,
306        conflicts_with_all(["mp_file", "mp", "form", "text_file", "json_file", "json_body"]),
307        help = "Request body for ContentType: text/plain"
308    )]
309    pub(crate) text_body: Option<String>,
310
311    /// multipart body parameters
312    #[arg(
313        long,
314        num_args = 0..,
315        value_delimiter = ',',
316        conflicts_with_all(["form", "text_body", "text_file", "json_file", "json_body"]),
317        help = "Multipart body parameters, Content-Type: multipart/form-data, example: --mp=k1:v1,k2:v2"
318    )]
319    pub(crate) mp: Vec<String>,
320
321    /// multipart body file
322    #[arg(
323        long,
324        num_args = 0..,
325        value_delimiter = ',',
326        value_parser = parse_filename_and_path,
327        conflicts_with_all(["form", "text_body", "text_file", "json_file", "json_body"]),
328        help = "Multipart body files, Content-Type: multipart/form-data, example: --mp-file=t1:t1.txt,filename2:t2.txt"
329    )]
330    pub(crate) mp_file: Vec<(String, PathBuf)>,
331
332    /// form request parameters
333    #[arg(
334        long,
335        num_args = 0..,
336        value_delimiter = ',',
337        conflicts_with_all(["mp_file", "mp", "text_body", "text_file", "json_file", "json_file"]),
338        help = "Form request parameters, Content-Type: application/x-www-form-urlencoded, example: --form=k:v,k1:v1"
339    )]
340    pub(crate) form: Vec<String>,
341
342    /// Output Format
343    #[arg(
344        long,
345        default_value = OutputFormat::Text,
346        value_enum,
347        help = "Output format"
348    )]
349    pub output_format: OutputFormat,
350
351    /// for shell autocompletion, supports: bash, shell, powershell, zsh and
352    /// elvish
353    #[arg(long, value_enum)]
354    pub completions: Option<Shell>,
355
356    /// Target Url
357    #[arg(
358        required_unless_present("completions"), 
359        value_hint = ValueHint::Url,
360        help = "Target Url"
361    )]
362    pub url: Option<String>,
363}
364
365#[cfg(test)]
366mod tests {
367    use clap::{Command, CommandFactory};
368
369    use super::*;
370    const URI: &str = "https://localhost/test";
371    const BINARY: &str = "rsb";
372
373    #[test]
374    fn test_is_number() {
375        assert!(is_number("12234"));
376        assert!(!is_number("12234s"));
377    }
378
379    #[test]
380    fn test_parse_duration() {
381        assert!(parse_duration("123").is_ok());
382        assert!(parse_duration("123s").is_ok());
383        assert!(parse_duration("123x").is_err());
384    }
385
386    #[test]
387    fn test_method_choices() {
388        let mut cmd = Arg::command();
389        let methods = vec!["GET", "PUT", "POST", "DELETE", "HEAD", "PATCH"];
390        for method in methods {
391            let args = vec![BINARY, "-n", "20", "-m", method, URI];
392            let result = cmd.try_get_matches_from_mut(args);
393            assert!(result.is_ok());
394        }
395
396        let result = cmd.try_get_matches_from_mut(vec![
397            BINARY, "-n", "20", "-m", "get", URI,
398        ]);
399        assert!(result.as_ref().is_err());
400        let err_msg = result.err().unwrap().to_string();
401        assert!(err_msg
402            .contains("possible values: GET, PUT, POST, DELETE, HEAD, PATCH"));
403    }
404
405    #[test]
406    fn test_required_parameters() {
407        let mut cmd = Arg::command();
408        let result = cmd.try_get_matches_from_mut(vec!["rsb"]);
409        assert!(result.as_ref().is_err());
410        let err_msg = result.err().unwrap().to_string();
411        assert!(err_msg.contains(
412            "error: the following required arguments were not provided:
413  --requests <REQUESTS>
414  --duration <DURATION>
415  <URL>"
416        ))
417    }
418
419    #[test]
420    fn test_must_provide_requests_or_duration_parameters() {
421        let mut cmd = Arg::command();
422
423        // neither provided
424        let result = cmd.try_get_matches_from_mut(vec![BINARY, URI]);
425        assert!(result.as_ref().is_err());
426        let err_msg = result.err().unwrap().to_string();
427        assert!(err_msg.contains(
428            "error: the following required arguments were not provided:
429  --requests <REQUESTS>
430  --duration <DURATION>"
431        ));
432
433        // both provide
434        let result = cmd.try_get_matches_from_mut(vec![
435            BINARY, "-n", "20", "-d", "300", URI,
436        ]);
437        assert!(result.as_ref().is_err());
438        let err_msg = result.err().unwrap().to_string();
439        assert!(err_msg.contains(
440            "error: the argument '--requests <REQUESTS>' \
441        cannot be used with '--duration <DURATION>'"
442        ));
443    }
444
445    #[test]
446    fn test_require_key_and_cert_at_the_same_time() {
447        let mut cmd = Arg::command();
448
449        // only provide key
450        let result = cmd.try_get_matches_from_mut(vec![
451            BINARY, "-n", "20", "--key", "key.crt", URI,
452        ]);
453        assert!(result.as_ref().is_err());
454        let err_msg = result.err().unwrap().to_string();
455        assert!(err_msg.contains(
456            "error: the following required arguments were not provided:
457  --cert <CERT>"
458        ));
459
460        // only provide cert
461        let result = cmd.try_get_matches_from_mut(vec![
462            BINARY, "-n", "20", "--cert", "cert.crt", URI,
463        ]);
464        assert!(result.as_ref().is_err());
465        let err_msg = result.err().unwrap().to_string();
466        assert!(err_msg.contains(
467            "error: the following required arguments were not provided:
468  --key <KEY>"
469        ));
470
471        // both provided
472        let result = cmd.try_get_matches_from_mut(vec![
473            BINARY, "-n", "20", "--cert", "cert.crt", "--key", "key.crt", URI,
474        ]);
475        assert!(result.as_ref().is_ok());
476    }
477
478    #[test]
479    fn test_json_body_or_json_file() {
480        let mut cmd = Arg::command();
481
482        // both provided
483        let result = cmd.try_get_matches_from_mut(vec![
484            BINARY,
485            "-n",
486            "20",
487            "--json-body",
488            "jsonBody",
489            "--json-file",
490            "jsonBody.txt",
491            URI,
492        ]);
493        assert!(result.as_ref().is_err());
494
495        // only json body
496        let result = cmd.try_get_matches_from_mut(vec![
497            BINARY,
498            "-n",
499            "20",
500            "--json-body",
501            "jsonBody",
502            URI,
503        ]);
504        assert!(result.as_ref().is_ok());
505
506        // only json body
507        let result = cmd.try_get_matches_from_mut(vec![
508            BINARY,
509            "-n",
510            "20",
511            "--json-file",
512            "jsonBody.txt",
513            URI,
514        ]);
515        assert!(result.as_ref().is_ok());
516    }
517
518    #[test]
519    fn test_json_body_conflicts_with_other_body() {
520        let cmd = Arg::command();
521        let args =
522            vec![BINARY, "-n", "20", "--json-body", "jb", "xx", "xx", URI];
523        let conflicts_params = vec![
524            "--mp-file",
525            "--mp",
526            "--form",
527            "--text-body",
528            "--text-file",
529            "--json-file",
530            "--json-command",
531        ];
532        validate_args_conflict(conflicts_params, args, cmd);
533    }
534
535    #[test]
536    fn test_json_command_conflicts_with_other_body() {
537        let cmd = Arg::command();
538        let args = vec![
539            BINARY,
540            "-n",
541            "20",
542            "--json-command",
543            "/usr/bin/command generate",
544            "xx",
545            "xx",
546            URI,
547        ];
548        let conflicts_params = vec![
549            "--mp-file",
550            "--mp",
551            "--form",
552            "--text-body",
553            "--text-file",
554            "--json-file",
555            "--json-body",
556        ];
557        validate_args_conflict(conflicts_params, args, cmd);
558    }
559
560    #[test]
561    fn test_json_file_conflicts_with_other_body() {
562        let cmd = Arg::command();
563        let args =
564            vec![BINARY, "-n", "20", "--json-file", "jb", "xx", "xx", URI];
565        let conflicts_params = vec![
566            "--mp-file",
567            "--mp",
568            "--form",
569            "--text-body",
570            "--text-file",
571            "--json-body",
572            "--json-command",
573        ];
574        validate_args_conflict(conflicts_params, args, cmd);
575    }
576
577    #[test]
578    fn test_text_body_or_text_file() {
579        let mut cmd = Arg::command();
580
581        // both provided
582        let result = cmd.try_get_matches_from_mut(vec![
583            BINARY,
584            "-n",
585            "20",
586            "--text-body",
587            "text",
588            "--text-file",
589            "ts.txt",
590            URI,
591        ]);
592        assert!(result.as_ref().is_err());
593
594        // only json body
595        let result = cmd.try_get_matches_from_mut(vec![
596            BINARY,
597            "-n",
598            "20",
599            "--text-body",
600            "text",
601            URI,
602        ]);
603        assert!(result.as_ref().is_ok());
604
605        // only json body
606        let result = cmd.try_get_matches_from_mut(vec![
607            BINARY,
608            "-n",
609            "20",
610            "--text-file",
611            "jsonBody.txt",
612            URI,
613        ]);
614        assert!(result.as_ref().is_ok());
615    }
616
617    #[test]
618    fn test_text_body_conflicts_with_other_body() {
619        let cmd = Arg::command();
620        let args =
621            vec![BINARY, "-n", "20", "--text-body", "jb", "xx", "xx", URI];
622        let conflicts_params = vec![
623            "--mp-file",
624            "--mp",
625            "--form",
626            "--json-body",
627            "--text-file",
628            "--json-file",
629        ];
630        validate_args_conflict(conflicts_params, args, cmd);
631    }
632
633    fn validate_args_conflict<'a>(
634        conflicts_params: Vec<&'a str>,
635        mut args: Vec<&'a str>,
636        mut cmd: Command,
637    ) {
638        for cp in conflicts_params {
639            args[5] = cp;
640            if cp == "--mp-file" {
641                args[6] = "filename1:file2.txt";
642            }
643            let result = cmd.try_get_matches_from_mut(args.clone());
644            assert!(result.as_ref().is_err());
645            let err_msg = result.err().unwrap().to_string();
646            let fragment = format!("cannot be used with '{cp}");
647            assert!(err_msg.contains(&fragment));
648        }
649    }
650
651    #[test]
652    fn test_text_file_conflicts_with_other_body() {
653        let cmd = Arg::command();
654        let args =
655            vec![BINARY, "-n", "20", "--text-file", "jb", "xx", "xx", URI];
656        let conflicts_params = vec![
657            "--mp-file",
658            "--mp",
659            "--form",
660            "--text-body",
661            "--json-file",
662            "--json-body",
663            "--json-command",
664        ];
665        validate_args_conflict(conflicts_params, args, cmd);
666    }
667
668    #[test]
669    fn test_multipart_param_exist_with_multipart_file() {
670        let mut cmd = Arg::command();
671        let args = vec![
672            BINARY,
673            "-n",
674            "20",
675            "--mp=k1:v1,k2:v2",
676            "--mp-file=t1:text1.txt,t2:text2.txt",
677            URI,
678        ];
679        let result = cmd.try_get_matches_from_mut(args);
680        assert!(result.as_ref().is_ok());
681        let mps = result
682            .as_ref()
683            .unwrap()
684            .get_many::<String>("mp")
685            .unwrap()
686            .collect::<Vec<_>>();
687        assert_eq!(mps, ["k1:v1", "k2:v2"]);
688
689        let mp_files = result
690            .as_ref()
691            .unwrap()
692            .get_many::<(String, PathBuf)>("mp_file")
693            .unwrap()
694            .collect::<Vec<_>>();
695        let mp_files = mp_files
696            .iter()
697            .map(|x| (x.0.as_str(), x.1.to_str().unwrap()))
698            .collect::<Vec<_>>();
699        assert_eq!(mp_files, [("t1", "text1.txt"), ("t2", "text2.txt")]);
700    }
701
702    #[test]
703    fn test_mp_conflicts_with_other_body() {
704        let cmd = Arg::command();
705        let args = vec![
706            BINARY,
707            "-n",
708            "20",
709            "--mp=k1:v1,k2:v2",
710            "--mp-file=text1:text1.txt",
711            "xx",
712            "xx",
713            URI,
714        ];
715        let conflicts_params = vec![
716            "--text-file",
717            "--form",
718            "--text-body",
719            "--json-file",
720            "--json-body",
721            "--json-command",
722        ];
723        validate_args_conflict(conflicts_params, args, cmd);
724    }
725
726    #[test]
727    fn test_parse_form_params() {
728        let mut cmd = Arg::command();
729
730        // style1
731        let args = vec![BINARY, "-n", "20", "--form=k1:v1,k2:v2", URI];
732        let result = cmd.try_get_matches_from_mut(args);
733        assert!(result.as_ref().is_ok());
734        let forms = result
735            .as_ref()
736            .unwrap()
737            .get_many::<String>("form")
738            .unwrap()
739            .collect::<Vec<_>>();
740        assert_eq!(forms, ["k1:v1", "k2:v2"]);
741
742        // style2
743        let args =
744            vec![BINARY, "-n", "20", "--form=k1:v1", "--form=k2:v2", URI];
745        let result = cmd.try_get_matches_from_mut(args);
746        assert!(result.as_ref().is_ok());
747        let forms = result
748            .as_ref()
749            .unwrap()
750            .get_many::<String>("form")
751            .unwrap()
752            .collect::<Vec<_>>();
753        assert_eq!(forms, ["k1:v1", "k2:v2"]);
754    }
755
756    #[test]
757    fn test_form_conflicts_with_other_body() {
758        let cmd = Arg::command();
759        let args = vec![
760            BINARY,
761            "-n",
762            "20",
763            "--form=k1:v1,k2:v2",
764            "--form=k3:v3,k4:v4",
765            "xx",
766            "xx",
767            URI,
768        ];
769        let conflicts_params = vec![
770            "--text-file",
771            "--mp-file",
772            "--mp",
773            "--text-body",
774            "--json-file",
775            "--json-body",
776            "--json-command",
777        ];
778        validate_args_conflict(conflicts_params, args, cmd);
779    }
780
781    #[test]
782    fn test_parse_headers_params() {
783        let mut cmd = Arg::command();
784
785        // style1
786        let args =
787            vec![BINARY, "-n", "20", "-H=k1:v1,k2:v2", "--headers=k3:v3", URI];
788        let result = cmd.try_get_matches_from_mut(args);
789        assert!(result.as_ref().is_ok());
790        let headers = result
791            .as_ref()
792            .unwrap()
793            .get_many::<String>("headers")
794            .unwrap()
795            .collect::<Vec<_>>();
796        assert_eq!(headers, ["k1:v1", "k2:v2", "k3:v3"]);
797    }
798
799    #[test]
800    fn test_parse_percentiles_params() {
801        let mut cmd = Arg::command();
802
803        // default percentiles
804        let args = vec![BINARY, "-n", "20", URI];
805        let result = cmd.try_get_matches_from_mut(args);
806        assert!(result.as_ref().is_ok());
807        let percentiles = result
808            .as_ref()
809            .unwrap()
810            .get_many::<f32>("percentiles")
811            .unwrap()
812            .collect::<Vec<_>>();
813        assert_eq!(format!("{percentiles:?}"), "[0.5, 0.75, 0.9, 0.99]");
814
815        // custom percentiles
816        let args =
817            vec![BINARY, "-n", "20", "--percentiles=0.60,0.80,0.90,0.95", URI];
818        let result = cmd.try_get_matches_from_mut(args);
819        assert!(result.as_ref().is_ok());
820        let percentiles = result
821            .as_ref()
822            .unwrap()
823            .get_many::<f32>("percentiles")
824            .unwrap()
825            .collect::<Vec<_>>();
826        assert_eq!(format!("{percentiles:?}"), "[0.6, 0.8, 0.9, 0.95]");
827
828        // given wrong percent, negative
829        let args = vec![BINARY, "-n", "20", "--percentiles=-0.1", URI];
830        let result = cmd.try_get_matches_from_mut(args);
831        assert!(result
832            .err()
833            .unwrap()
834            .to_string()
835            .contains("-0.1 must be limited to the range (0 to 1)"));
836
837        // give wrong percent, that's gte 1
838        let args = vec![BINARY, "-n", "20", "--percentiles=1", URI];
839        let result = cmd.try_get_matches_from_mut(args);
840        assert!(result
841            .err()
842            .unwrap()
843            .to_string()
844            .contains("1 must be limited to the range (0 to 1)"));
845    }
846
847    #[test]
848    fn test_parse_output_format() {
849        let mut cmd = Arg::command();
850
851        // TEXT
852        let args = vec![BINARY, "-n", "20", "--output-format", "TEXT", URI];
853        let result = cmd.try_get_matches_from_mut(args);
854        assert!(result.as_ref().is_ok());
855
856        // JSON
857        let args = vec![BINARY, "-n", "20", "--output-format", "JSON", URI];
858        let result = cmd.try_get_matches_from_mut(args);
859        assert!(result.as_ref().is_ok());
860
861        // OTHER
862        let args = vec![BINARY, "-n", "20", "--output-format", "OTHER", URI];
863        let result = cmd.try_get_matches_from_mut(args);
864        assert!(result.as_ref().is_err());
865    }
866}