Rust命令行库Clap快速入门教程

文章目录

几乎所有编程语言都是支持命令行库, Rust自然也不例外, 不过Rust标准库不支持,而是第三方库支持,比较常用和主流的是Clap这个库, 通过它可以很简单的组建自己的命令行工具,这样就不用花太多时间放在参数处理上了。

命令行工具的接口一般比较简单, 参数无非两个部分, 可选参数, 位置参数, 以下面的命令为例。

每门编程语言有自己的想法, 库作者也一样, 不同的语言对于我所说的两个参数有不同的名字, 比如Option, Argument, Flags等, 这里仅以中文名作为统一说法。

ls -r /var
ls --reverse /var

上面两条命令的结果是一样的, 这条命令包括三个部分, 命令行工具本体, 可选参数位置参数

其中本体自然是ls, 可选参数是-r--reverse, 位置参数是/var

本文依赖如下

[dependencies]
clap = { version = "4.4.13", features = ["derive"] }

快速入门

Clap支持两种方式构建命令行工具, 一种是使用衍生宏, 一种是使用Builder模式(即不断的调用方法设置参数), 本文只演示第一种, 作者也推荐这一种。

use clap::Parser;

#[derive(Parser)]
#[command(name = "youerning")]
#[command(author = "youerning.top")]
#[command(version = "1.0")]
#[command(about = "a tutorial of crate clap", long_about = None)]
struct Cli {
    // 注意下面的注释是三个斜杠!!!
    /// use which method 
    #[arg(short, long)]
    method: Option<String>,
    
    /// Optional name to call
    name: Option<String>,
}

fn main() {
    let cli = Cli::parse();
    let method = match cli.method {
        Some(method) => method,
        None => "hello".to_owned(),
    };
    let name = match cli.name {
        Some(name) => name,
        None => "world".to_owned(),
    };
    println!("{method} {name}");
}

上面的代码没有参数时输出如下:

hello world

使用参数时如下

quickstart -m wtf Tom
wtf Tom

使用--help时输出如下

a tutorial of crate clap

Usage: quickstart.exe [OPTIONS] [NAME]

Arguments:
  [NAME]  Optional name to call

Options:
  -m, --method <METHOD>  use which method
  -h, --help             Print help
  -V, --version          Print version

可以发现, Clap为我们添加了必要的参数和说明, 这符合预期, 没有什么奇怪的。

元数据

一般来说命令行会带有一些元数据, 比如使用的说明文档,命令行的版本等,下面是Clap支持的一些参数。

#[command(name = "youerning")]
#[command(author = "youerning.top")]
#[command(version = "1.0")]
#[command(about = "a tutorial of crate clap", long_about = None)]
struct Cli {
    // 省略。。。。
}

上面分别是名字, 作者, 版本, 命令行说明等参数。

可选参数

可选参数一般有缩写和全写两种形式, 前者使用单横杠-, 比如-r, 后者使用双横杠--, 比如--reverse, 两者作用是一样的, 只是调用命令时的方式不一样而已。

值得注意的是,这里所说的约定是linux平台的约定, 而约定总有例外,比如golang标准库的单横杠就支持全写, 比如-reverse, 实在是异类,所以我喜欢用golang的第三方库cobra

为啥可选参数有简短好用的缩写还要全写呢? 因为缩写一般只有一个字符,而因为只有26个字符呀,只用缩写不够用,而且可能会冲突。

默认情况下结构体的字段都是位置参数,想要将其转换成可选参数需要在前面设置一个宏属性,比如#[arg(long, short)]

值得注意的是: 可以只选long或者short

use clap::Parser;

#[derive(Parser)]
struct Cli {
    // 注意下面的注释是三个斜杠!!!
    /// options1
    #[arg(long, short='x')]
    option1: String,

    /// options2
    #[arg(long, short)]
    option2: Option<String>,

    /// options3
    #[arg(long="option", short='y', default_value="option3")]
    option3: String,
}

fn main() {
    let cli = Cli::parse();
    let option1 = cli.option1;
    println!("options {option1}");
    let option2 = match cli.option2 {
        Some(option) => option,
        None => "option2".to_owned(),
    };
    let option3 = cli.option3;
    println!("option1: '{option1}'");
    println!("option2: '{option2}'");
    println!("option3: '{option3}'");
}

上面的代码帮助文档如下

Usage: option.exe [OPTIONS] --option1 <OPTION1>

Options:
  -x, --option1 <OPTION1>  options1
  -o, --option2 <OPTION2>  options2
  -y, --option <OPTION3>   options3 [default: option3]
  -h, --help               Print help

其中第一个参数是强制的,因为它没有默认值也没有用Option枚举类型包裹, 第二个参数是可选的, 因为它被Option枚举类型包裹, 第三个参数是可选的,因为它有默认参数。

除此之外,因为三个参数的第一个字母都是o(缩写默认使用第一个字母), 所以会冲突,那么就需要额外指定一个不冲突的名字,比如这里的xy, 注意要使用单引号''包裹, 而全写可选参数需要用双引号""包裹。

位置参数

位置参数能做的不多,也不需要额外的配置,Clap会根据参数类型来设置参数。

use clap::Parser;

#[derive(Parser)]
struct Cli {
    // 注意下面的注释是三个斜杠!!!
    /// argument of name
    name: String,
    /// argument of names
    names: Vec<String>,
}

fn main() {
    let cli = Cli::parse();
    let name = cli.name;
    let names = cli.names;
    println!("name: {name}");
    println!("names: {names:?}");
}

--help的帮助信息如下

Usage: argument.exe <NAME> [NAMES]...

Arguments:
  <NAME>      argument of name
  [NAMES]...  argument of names

Options:
  -h, --help  Print help

可以使用以下命令调用

argument.exe youerning name1 name2 name3
# 输出如下
name: youerning
names: ["name1", "name2", "name3"]

参数的位置根据从上到下的顺序指定,值得注意的是,不能在位置参数中设置两个接受列表的值,比如下面这样。

struct Cli {
    // 注意下面的注释是三个斜杠!!!
    /// argument of name
    name: String,
    /// argument of names
    names: Vec<String>,
    names2: Vec<String>,
}

因为两个列表的话会产生歧义,前面的列表应该包含哪部分,后面的列表应该包含哪部分?

参数验证

命令行解析有许多常见的验证模式,自己写来肯定是很讨厌的,所以Clap提供了一系列的参数验证设置,完整列表可以参考:https://docs.rs/clap/latest/clap/_derive/_tutorial/chapter_3/index.html,它大致支持以下四种类型

  • 枚举值验证
  • 参数值验证
  • 参数关联
  • 自定义验证

枚举值验证

use clap::{Parser, ValueEnum};

#[derive(Parser)]
#[command(author, version, about, long_about = None)]
struct Cli {
    /// What mode to run the program in
    #[arg(value_enum)]
    mode: Mode,

    /// options of mode
    #[arg(value_enum, short, long)]
    mode2: Option<Mode>,
    
}

// 注意要配置以下衍生宏
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)]
enum Mode {
    Fast,
    Slow,
}

fn main() {
    let cli = Cli::parse();

    match cli.mode {
        Mode::Fast => {
            println!("you are fast");
        }
        Mode::Slow => {
            println!("you are slow");
        }
    }

    match cli.mode2 {
        Some(mode) => {
            match mode {
                Mode::Fast => {
                    println!("you are fast");
                }
                Mode::Slow => {
                    println!("you are slow");
                }
            }
        }
        None => {
            println!("no mode2")
        }
    }
}

--help的帮助信息如下

Usage: validate1.exe --mode2 <MODE2> <MODE>

Arguments:
  <MODE>  What mode to run the program in [possible values: fast, slow]

Options:
  -m, --mode2 <MODE2>  options of mode [possible values: fast, slow]
  -h, --help           Print help
  -V, --version        Print version

枚举验证和一般参数或者位置参数的主要区别是要配置#[arg(value_enum)]以说明使用的是枚举模式, 当然也可以添加long, short等将其转换成可选参数

参数值验证

常见的就是数字类型的范围了。

use clap::Parser;

#[derive(Parser)]
struct Cli {
    /// Network port to use
    #[arg(value_parser = clap::value_parser!(u16).range(1..))]
    port: u16,
}

fn main() {
    let cli = Cli::parse();

    println!("PORT = {}", cli.port);
}

上面只指定了从1开始,为啥不指定结束范围呢? 因为u16最大只支持65535, 所以更大的值会报错,也就无需额外的指定了。

参数关联

这部分主要是配置关联的参数,比如参数1和参数2互斥,或者参数1依赖参数2,这个一般用的不多,这里就不展示了,可以直接看官方文档。

自定义验证

这里也略过了。。。。

子命令

下面的示例一共有两个子命令,addremove

use clap::{Parser, Subcommand};

#[derive(Parser)]
struct Cli {
    #[arg(short, long, default_value="0")]
    verbose: u8,
    #[command(subcommand)]
    command: Commands,
}

#[derive(Subcommand)]
enum Commands {
    /// Adds files to myapp
    Add { name: Option<String> },
    /// Remove files from myapp
    Remove { 
        names: Vec<String>,
        #[arg(short, long, default_value="false")]
        force: bool
    },
}

fn main() {
    let cli = Cli::parse();

    match &cli.command {
        Commands::Add { name } => {
            if name.is_none() {
                println!("'请选择你要自家的文件名")
            } else {
                println!("'你要增加的文件名是: {name:?}")
            }
            
        },
        Commands::Remove { 
            names ,
            force,
            } => {
            println!("你要删除的文件名有: {names:?}, 强制执行么? {force}")
        },
    }
}

没有指定子命令的--help帮助信息如下

Usage: subcommand.exe [OPTIONS] <COMMAND>

Commands:
  add     Adds files to myapp
  remove  Remove files from myapp
  help    Print this message or the help of the given subcommand(s)

Options:
  -v, --verbose <VERBOSE>  [default: 0]
  -h, --help               Print help

remove子命令的帮助信息如下

Remove files from myapp

Usage: subcommand.exe remove [OPTIONS] [NAMES]...

Arguments:
  [NAMES]...

Options:
  -f, --force
  -h, --help   Print help

测试

官方的这部分写的有点奇怪,大家可以参考以下代码。

use clap::Parser;

#[derive(Parser)]
#[command(about = "扫描目标的指定端口", long_about = None)]
struct Cli {
    /// the target need to be connected
    target: String,
    
    /// timeout in millisecond
    #[arg(long, short, default_value="80")]
    port: u16,
}

fn main() {
    let cli = Cli::parse();
    let target = cli.target;
    let port = cli.port;
    println!("尝试扫描目标[{target}]的端口[{port}]")
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    #[should_panic]
    fn should_panic() {
        // 因为没有指定target
        let _ = Cli::try_parse_from(["test"]).unwrap();
    }

    #[test]
    #[should_panic]
    fn should_panic2() {
        // 因为端口号大于65535
        let _ = Cli::try_parse_from(["test", "-p", "655361"]).unwrap();
    }

    #[test]
    fn test_right_args() {
        let cli = Cli::try_parse_from(["test", "baidu.com"]).unwrap();
        assert_eq!(cli.target, "baidu.com");
        assert_eq!(cli.port, 80);

        let cli = Cli::try_parse_from(["test", "baidu.com", "--port", "443"]).unwrap();
        assert_eq!(cli.target, "baidu.com");
        assert_eq!(cli.port, 443);
    }
}

测试一般有测两个方向,正确的要测,错误的也要测,不然的话可能出现未知错误,比如错误的参数也正确执行了。

总结

每门编程语言有自己的习惯和语法,所以配置方式会有所不同,但是大致方向是差不多的,比如可选参数,位置参数两个类型的参数,然后按照这个框架去学习一般能很快的掌握的。

之前还写过Python的Typer和Golang的Cobra库,现在钟情于Rust, 所以总算写了对应的文章, 之前关于Python和Golang对应的命令行库有兴趣的可以看看。

参考链接