LoginSignup
63
50

【Rust】✨CLIアプリ向けクレート4選✨ ~ clap, dialoguer, indicatif, console ~

Last updated at Posted at 2024-05-14

本記事では、RustでCLIアプリケーションを作成する際に便利なクレートを4つ紹介いたします! :crab: :qiitan:

筆者は大のRust好きですが、それを抜きにしても CLIアプリ制作はRustが一番楽 と言い切ってしまえるぐらいのポテンシャルがこのクレート達(特にclap)にはあります!筆者にとって使用頻度が高い順に彼らを紹介していきたいと思います。

クレート名 概要 項目へ
clap コマンドライン引数パーサー :fast_forward:
dialoguer ユーザー入力ダイアログ :fast_forward:
indicatif プログレスバー表示 :fast_forward:
console 装飾・ANSIエスケープ :fast_forward:
その他 serde, log, etc... :fast_forward:

とにかく clapクレートが便利 です!
clapを使うだけで一瞬でCLIアプリケーションの体裁が整います :clap:

他3つは全てconsole-rsが提供するユーティリティクレートで、かゆいところに手が届くイメージです :truck:

4つのクレートを全て活用すると次のデモのようなCLIアプリケーションも簡単(※当社比)に作れます!

langage_monster_6.gif

asciinema

リポジトリ: https://github.com/anotherhollow1125/lang_monster

Gif中の次のシーンにて本記事で紹介するクレートが使われています。

  • ./lang_monster --help
    • clapクレートによりヘルプオプションが追加されています
  • アセットダウンロードプログレスバー
    • indicatifクレートを使って表示しています
  • 左上始点でセリフ表示・色付き文字
    • consoleクレートにてANSIエスケープを出力することで実現しています
  • 主人公名ライバル名の選択・入力
    • dialoguerクレートを使って実現しています
  • オプション入力 (./lang_monster -t auto -s fast -u Rust -r Go)
    • clapクレートでパースしています

RustでCLIアプリケーションを作成するためのチュートリアルは色々あります。特にRust CLI WGがまとめているCommand Line Applications in Rustは各種トピックがバランスよく散りばめられている感じで良かったりするので、今さら普通にまとめてもn番煎じかもしれません。

そこで本記事のオリジナリティとして、CLI "らしさ" を備えたクレートをおすすめ順に、豊富なサンプルコードとともに紹介していければなと思います!

環境・前提

Rustの環境構築に関しては省略させていただきます。

ちなみにインストールはほぼ一瞬です!: はじめに - Rustプログラミング言語

$ cargo --version
cargo 1.77.2 (e52e36006 2024-03-26)
$ cargo new project

では、
ゆめと ぼうけんと!
Rust CLIアプリ制作 のせかいへ!
レッツゴー!

お手軽パーサー clap

clapはCLIアプリの要であろう、コマンドライン引数をパースするためのライブラリです!Command Line Argument Parser略してCLAP (多分)

cargo add clap --features derive

とコマンドを打ってクレートを追加し、構造体に少しだけ細工をするだけで一気にCLIアプリケーションもどきが作れる、それがclapです! :clap: :clap_tone1: :clap_tone2: お手軽すぎる

cargo addでの追加とCargo.tomlに直接追記する追加どちらでも変わらないため、次節以降はCargo.tomlファイルを提示するに留めます

--help オプションと --version オプション

例えば、clap::Parserを構造体にderiveしてparseメソッドを呼び出すだけでヘルプオプションが生えます。

main.rs
use clap::Parser;

#[derive(Parser)]
struct Args;

fn main() {
    let _ = Args::parse();
}

実行・出力例

$ cargo run -q -- --help
Usage: project

Options:
  -h, --help  Print help

cargo runによる実行時、cargoではなくプログラムへ引数を渡すには、間に--が必要です。なお、cargoコマンドに渡している-qcargoコマンド自体の出力(コンパイル情報等)を抑制するものです。

「一瞬でヘルプオプションが生えただと...?貴様何をした...?!」

「何って...トレイトをderiveしただけですが...また俺何かやっちゃいましたか...? :sweat_smile:

Cargo.toml に書いた description をヘルプコマンドで表示することも可能です!ついでに --version オプションも生やしてみます。

Cargo.toml
[package]
name = "project"
+ version = "3.3.4"
edition = "2021"
+ description = "clapクレートデモ用プロジェクト"

[dependencies]
clap = { version = "4.5.4", features = ["derive"] }
main.rs
use clap::Parser;

#[derive(Parser)]
+ #[command(version, about)]
struct Args;

fn main() {
    let _ = Args::parse();
}

実行・出力例

$ cargo run -q -- --help   
clapクレートデモ用プロジェクト

Usage: project

Options:
  -h, --help     Print help
  -V, --version  Print version
$ cargo run -q -- --version
project 3.3.4

何の機能もないアプリなのにこれだけでそれっぽいです!

ポジショナル引数

コマンドライン引数を追加していき、さらにそれっぽくしてみます。

main.rs
use clap::Parser;

#[derive(Parser, Debug)]
#[command(version, about)]
struct Args {
    /// ポジショナル引数
    quote: String,
}

fn main() {
    let args = Args::parse();

    println!("{:?}", args);
}

実行・出力例

$ cargo run -q -- おひたし
Args { quote: "おひたし" }

下記どちらかの方法で省略可能にもできます。

  • #[arg(default_value = "~")]#[arg(default_value_t = ~)]によってデフォルト値を付与
  • Option型にする
Rust
#[derive(Parser, Debug)]
#[command(version, about)]
struct Args {
    /// ポジショナル引数
    quote: String,

    /// 省略可能ポジショナル引数
    /// デフォルト値付き
    #[arg(default_value_t = 1)]
    repeat_num: usize,

    /// 省略可能ポジショナル引数
    /// Option型
    lines_num: Option<usize>,
}
実装コード全体例
main.rs
use clap::Parser;

#[derive(Parser, Debug)]
#[command(version, about)]
struct Args {
    /// ポジショナル引数
    quote: String,

    /// 省略可能ポジショナル引数
    /// デフォルト値付き
    #[arg(default_value_t = 1)]
    repeat_num: usize,

    /// 省略可能ポジショナル引数
    /// Option型
    lines_num: Option<usize>,
}

fn main() {
    let args = Args::parse();

    println!("{:?}", args);

    let Args {
        quote,
        repeat_num,
        lines_num,
    } = args;

    for _ in 0..lines_num.unwrap_or(1) {
        println!("{}", quote.repeat(repeat_num));
    }
}

///で始まるドキュメンテーションコメントをフィールドにつけると、--helpでコメントの内容を表示してくれます!

実行・出力例

$ cargo run -q -- --help
clapクレートデモ用プロジェクト

Usage: project <QUOTE> [REPEAT_NUM] [LINES_NUM]

Arguments:
  <QUOTE>       ポジショナル引数
  [REPEAT_NUM]  省略可能ポジショナル引数 デフォルト値付き [default: 1]
  [LINES_NUM]   省略可能ポジショナル引数 Option型

Options:
  -h, --help     Print help
  -V, --version  Print version
$ cargo run -q -- おひたし 2
Args { quote: "おひたし", repeat_num: 2, lines_num: None }
おひたしおひたし
$ cargo run -q -- おひたし 3 2
Args { quote: "おひたし", repeat_num: 3, lines_num: Some(2) }
おひたしおひたしおひたし
おひたしおひたしおひたし

またVec型を使用することで複数の値を受け取れるようにできます!

Rust
#[derive(Parser, Debug)]
#[command(version, about)]
struct Args {
    quotes: Vec<String>,
}
実装コード全体例
main.rs
use clap::Parser;

#[derive(Parser, Debug)]
#[command(version, about)]
struct Args {
    quotes: Vec<String>,
}

fn main() {
    let args = Args::parse();

    println!("{:?}", args);
}

実行・出力例

$ cargo run -q -- --help
clapクレートデモ用プロジェクト

Usage: project [QUOTES]...

Arguments:
  [QUOTES]...  

Options:
  -h, --help     Print help
  -V, --version  Print version
$ cargo run -q -- おひたし アジフライ とんかつ
Args { quotes: ["おひたし", "アジフライ", "とんかつ"] }

Option型やVec型がコマンドライン引数の与えられ方の実態と一致しているのがとても良いですね、直感的でわかりやすいです!

フラグ引数

さっきの例ではポジション引数であるがゆえに(順番を入れ替えない場合)複数引数とその他の引数の共存が不可能です...

フラグ引数にすることでこの問題は回避できます!フラグ引数にするには#[arg(short)]#[arg(long)]を指定します。また、long = "〇〇"とすれば変数名とは異なる引数名を設定することも可能です。

Rust
#[derive(Parser, Debug)]
#[command(version, about)]
struct Args {
    /// 複数値ポジショナル引数
    quotes: Vec<String>,

    /// 省略不可能フラグ引数 -f
    #[arg(short)]
    flag: bool,

    /// 省略可能フラグ引数 -r, --repeat_num
    /// デフォルト値付き
    #[arg(short, long, default_value_t = 1)]
    repeat_num: usize,

    /// 省略可能フラグ引数 -l, --lines
    /// Option型
    #[arg(short, long = "lines")]
    lines_num: Option<usize>,
}
実装コード全体例
main.rs
use clap::Parser;

#[derive(Parser, Debug)]
#[command(version, about)]
struct Args {
    /// 複数値ポジショナル引数
    quotes: Vec<String>,

    /// 省略不可能フラグ引数 -f
    #[arg(short)]
    flag: bool,

    /// 省略可能フラグ引数 -r, --repeat_num
    /// デフォルト値付き
    #[arg(short, long, default_value_t = 1)]
    repeat_num: usize,

    /// 省略可能フラグ引数 -l, --lines
    /// Option型
    #[arg(short, long = "lines")]
    lines_num: Option<usize>,
}

fn main() {
    let args = Args::parse();

    println!("{:?}", args);

    let Args {
        quotes,
        flag: _,
        repeat_num,
        lines_num,
    } = args;

    for _ in 0..lines_num.unwrap_or(1) {
        for quote in quotes.iter() {
            print!("{} ", quote.repeat(repeat_num));
        }
        println!();
    }
}

実行・出力例

$ cargo run -q -- --help
clapクレートデモ用プロジェクト

Usage: project [OPTIONS] [QUOTES]...

Arguments:
  [QUOTES]...  複数値ポジショナル引数

Options:
  -f                             省略不可能フラグ引数 -f
  -r, --repeat-num <REPEAT_NUM>  省略可能フラグ引数 -r, --repeat_num デフォルト値付き [default: 1]
  -l, --lines <LINES_NUM>        省略可能フラグ引数 -l, --lines Option型
  -h, --help                     Print help
  -V, --version                  Print version
$ cargo run -q -- おひたし アジフライ とんかつ -r 2 --lines 3 -f
Args { quotes: ["おひたし", "アジフライ", "とんかつ"], flag: true, repeat_num: 2, lines_num: Some(3) }
おひたしおひたし アジフライアジフライ とんかつとんかつ 
おひたしおひたし アジフライアジフライ とんかつとんかつ 
おひたしおひたし アジフライアジフライ とんかつとんかつ 

複雑なフラグ引数も構造体で簡単に管理できています!

バリデーション

バリデーション、すなわち引数が想定した値であるかの検証もお手軽に追加できます!

列挙型で規定値のみ許可

取りうる値も型にしたい...そんなお望み、叶えます!列挙型にclap::ValueEnumを実装することで、バリアントがそのまま候補値になります。また、実質的に文字列引数のバリデーションがかかることになります。

Rust
#[derive(ValueEnum, Debug, Clone)]
enum LogLevel {
    Error,
    Warn,
    Info,
    Debug,
}

#[derive(Parser, Debug)]
struct Args {
    #[arg(short = 'L', long, default_value = "error")]
    log_level: LogLevel,
}
実装コード全体例
main.rs
use clap::{Parser, ValueEnum};

#[derive(ValueEnum, Debug, Clone)]
enum LogLevel {
    Error,
    Warn,
    Info,
    Debug,
}

#[derive(Parser, Debug)]
struct Args {
    #[arg(short = 'L', long, default_value = "error")]
    log_level: LogLevel,
}

fn main() {
    let args = Args::parse();

    println!("{:?}", args);
}

実行・出力例

$ cargo run -q -- --help
Usage: project [OPTIONS]

Options:
  -L, --log-level <LOG_LEVEL>  [default: error] [possible values: error, warn, info, debug]
  -h, --help                   Print help
$ cargo run -q -- -L debug
Args { log_level: Debug }
$ cargo run -q -- -L critical
error: invalid value 'critical' for '--log-level <LOG_LEVEL>'
  [possible values: error, warn, info, debug]

For more information, try '--help'.

カスタムバリデーション

列挙型にできないような値のバリデーションも属性風マクロ value_parser を介して指定することが可能です!clap自体が用意しているパーサーの他、トレイトFn(&str) -> Result<T, E>が実装されている関数やクロージャを指定することができます。以下の例ではRegex::newがちょうど適していたためそのまま渡しています。

Rust
#[derive(Parser, Debug)]
struct Args {
    #[arg(
        short, long, default_value_t = 1,
        value_parser = clap::value_parser!(u64).range(1..))
    ]
    natural_number: u64,

    #[arg(short, long, value_parser = Regex::new)]
    regex: Option<Regex>,
}
実装コード全体例
main.rs
use clap::Parser;
use regex::Regex;

#[derive(Parser, Debug)]
struct Args {
    #[arg(
        short, long, default_value_t = 1,
        value_parser = clap::value_parser!(u64).range(1..))
    ]
    natural_number: u64,

    #[arg(short, long, value_parser = Regex::new)]
    regex: Option<Regex>,
}

fn main() {
    let args = Args::parse();

    println!("{:?}", args);
}

実行・出力例

$ cargo run -q -- --help
Usage: project [OPTIONS]

Options:
  -n, --natural-number <NATURAL_NUMBER>  [default: 1]
  -r, --regex <REGEX>                    
  -h, --help                             Print help
$ cargo run -q -- -n 100 -r "^(.*)$"
Args { natural_number: 100, regex: Some(Regex("^(.*)$")) }
$ cargo run -q -- -n 0
error: invalid value '0' for '--natural-number <NATURAL_NUMBER>': 0 is not in 1..18446744073709551615

For more information, try '--help'.
$ cargo run -q -- -r "^(.*$"
error: invalid value '^(.*$' for '--regex <REGEX>': regex parse error:
    ^(.*$
     ^
error: unclosed group

For more information, try '--help'.

おまけ 好きな惣菜発表ドラゴン

好きな総菜発表ドラゴン ソースコード全体

リポジトリ: https://github.com/anotherhollow1125/fav_say_dragon

お借りした素材

Cargo.toml
[package]
name = "fav_say_dragon"
version = "0.1.0"
edition = "2021"
description = "好きな総菜発表ドラゴンsay"

[dependencies]
clap = { version = "4.5.4", features = ["derive"] }
console = "0.15.8"
anyhow = "1.0.83"
toml = "0.8.12"
serde = { version = "1.0.200", features = ["derive"] }
main.rs
use anyhow::Result;
use clap::{Parser, Subcommand};
use console::{Alignment, Term};
use serde::Deserialize;
use std::path::{Path, PathBuf};
use std::thread::sleep;
use std::time::Duration;

#[derive(Parser)]
#[command(version, about, flatten_help = true)]
struct Args {
    #[command(subcommand)]
    sub: Command,
}

#[derive(Subcommand, Clone, Debug)]
enum Command {
    /// 一度に出力
    Say {
        /// おかず
        side_dish: String,
        /// キャプション
        caption: Option<String>,
    },
    /// アニメーション出力
    Anime {
        /// おかず
        side_dishes: Vec<String>,

        /// プレキャプション
        #[arg(short, long)]
        pre_captions: Vec<String>,

        /// アフターキャプション
        #[arg(short = 'A', long)]
        after_captions: Vec<String>,

        /// ファイルからスクリプトを読み込む
        #[arg(short = 'f', long,
            conflicts_with_all(["side_dishes", "pre_captions", "after_captions"]))
        ]
        script_file: Option<PathBuf>,

        /// インターバル (ms)
        #[arg(
            short, long,
            default_value_t = 1000,
            value_parser = clap::value_parser!(u64).range(10..))
        ]
        interval: u64,
    },
}

#[derive(Deserialize, Debug)]
struct Script {
    side_dishes: Vec<String>,
    pre_captions: Vec<String>,
    after_captions: Vec<String>,
}

impl Script {
    fn load(path: &Path) -> Result<Self> {
        let script = std::fs::read_to_string(path)?;
        Ok(toml::from_str(&script)?)
    }
}

fn main() -> Result<()> {
    match Args::parse().sub {
        Command::Say { side_dish, caption } => say(&side_dish, caption.as_deref())?,
        Command::Anime {
            side_dishes,
            pre_captions,
            after_captions,
            script_file,
            interval,
        } => anime(
            side_dishes,
            pre_captions,
            after_captions,
            script_file,
            interval,
        )?,
    }

    Ok(())
}

fn say(side_dish: &str, caption: Option<&str>) -> Result<()> {
    let term = Term::stdout();
    let terminal_width = term.size().1 as usize;

    let dragon = create_dragon(side_dish, terminal_width);
    for line in dragon {
        term.write_line(&line)?;
    }
    let caption = console::pad_str(caption.unwrap_or(""), 60, Alignment::Center, None);
    term.write_line(&caption)?;

    Ok(())
}

fn anime(
    side_dishes: Vec<String>,
    pre_captions: Vec<String>,
    after_captions: Vec<String>,
    script_file: Option<PathBuf>,
    interval: u64,
) -> Result<()> {
    let (side_dishes, pre_captions, after_captions) = match script_file {
        Some(path) => {
            let Script {
                side_dishes,
                pre_captions,
                after_captions,
            } = Script::load(&path)?;
            (side_dishes, pre_captions, after_captions)
        }
        None => (side_dishes, pre_captions, after_captions),
    };

    let term = Term::stdout();
    let terminal_width = term.size().1 as usize;
    term.clear_screen()?;
    let empty_dragon = create_dragon("", terminal_width);
    let mut printed_flag = false;

    let mut pre_captions_iter = pre_captions.into_iter().peekable();
    while let Some(pre_caption) = pre_captions_iter.next() {
        for line in empty_dragon.iter() {
            term.write_line(line)?;
        }
        let pre_caption = console::pad_str(&pre_caption, 60, Alignment::Center, None);
        term.write_line(&pre_caption)?;
        printed_flag = true;

        if pre_captions_iter.peek().is_some() {
            clear_dragon(interval, &term, &mut printed_flag)?;
        }
    }

    let mut side_dish_iter = side_dishes.iter().peekable();

    if printed_flag && side_dish_iter.peek().is_some() {
        clear_dragon(interval, &term, &mut printed_flag)?;
    }

    while let Some(side_dish) = side_dish_iter.next() {
        let dragon = create_dragon(side_dish, terminal_width);
        for line in dragon {
            term.write_line(&line)?;
        }
        let empty_line = console::pad_str("", 60, Alignment::Center, None);
        term.write_line(&empty_line)?;
        printed_flag = true;

        if side_dish_iter.peek().is_some() {
            clear_dragon(interval, &term, &mut printed_flag)?;
        }
    }

    let mut after_captions_iter = after_captions.into_iter().peekable();

    if printed_flag && after_captions_iter.peek().is_some() {
        clear_dragon(interval, &term, &mut printed_flag)?;
    }

    while let Some(after_caption) = after_captions_iter.next() {
        for line in empty_dragon.iter() {
            term.write_line(line)?;
        }
        let after_caption = console::pad_str(&after_caption, 60, Alignment::Center, None);
        term.write_line(&after_caption)?;

        if after_captions_iter.peek().is_some() {
            clear_dragon(interval, &term, &mut printed_flag)?;
        }
    }

    Ok(())
}

fn clear_dragon(interval: u64, term: &Term, printed_flag: &mut bool) -> Result<()> {
    sleep(Duration::from_millis(interval));
    term.clear_screen()?;
    *printed_flag = false;

    Ok(())
}

fn create_dragon(side_dish: &str, terminal_width: usize) -> Vec<String> {
    let lines: Vec<String> = match side_dish.lines().count() {
        0 => vec!["".to_string(), "".to_string()],
        1 => {
            let empty = Vec::new();
            let side_dish: Vec<char> = side_dish.chars().collect();
            match side_dish.len() {
                0..=16 => vec![side_dish.as_slice(), empty.as_slice()],
                17..=32 => vec![&side_dish[..16], &side_dish[16..]],
                _ => vec![&side_dish[..16], &side_dish[16..32]],
            }
            .into_iter()
            .map(|s| s.iter().collect::<String>())
            .collect()
        }
        _ => {
            let mut lines: Vec<String> = side_dish.lines().rev().map(|s| s.to_string()).collect();
            let line0 = lines.pop().unwrap_or("".to_string());
            let line1 = lines.pop().unwrap_or("".to_string());
            vec![line0, line1]
        }
    }
    .into_iter()
    .map(|s| console::pad_str(&s, 20, Alignment::Center, None).to_string())
    .collect();

    #[rustfmt::skip]
    let dragon = "                                          ,. 、
                                        く  r',ゝ
r' ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ヽ                   ,ゝー'、
|                    |          、      /      ヽ.
|                    |        く、`ヽ/  ∩       |
|$line1$ >        `>             |
|$line2$|         く´ , -'7         レ个ー─┐
|                    |          `´   //  /      ー个ー─'7
|                    |               //  /         |    (
ゝ__________ノ              //  /'┤      |ヽv'⌒ヽ、ゝ
                                   くノ  lー┤       ヽ.
                                    `^^'ー┤          ▽_
                                    ((    )          ヽ乙_
                                    ((    )ヽ、          ヽレl
                                    ≧__ゝ    `゙ー-=、.__,ゝ";

    dragon
        .replace("$line1$", &lines[0])
        .replace("$line2$", &lines[1])
        .lines()
        .map(|line| console::pad_str(line, terminal_width, Alignment::Left, None).to_string())
        .collect()
}
commands.toml
pre_captions = ["好きなコマンド発表ドラゴンが", "好きなコマンドを発表します"]

side_dishes = ["jq", "grep", "cat", "python3\n-m http.server"]

after_captions = ["なんの略称かわからないコマンドも", "好き 好き 大好き"]

以下、発表ドラゴンのコマンドライン引数部分を抜き出したものになります。

Rust
#[derive(Parser)]
#[command(version, about, flatten_help = true)]
struct Args {
    #[command(subcommand)]
    sub: Command,
}

#[derive(Subcommand, Clone, Debug)]
enum Command {
    /// 一度に出力
    Say {
        /// おかず
        side_dish: String,
        /// キャプション
        caption: Option<String>,
    },
    /// アニメーション出力
    Anime {
        /// おかず
        side_dishes: Vec<String>,

        /// プレキャプション
        #[arg(short, long)]
        pre_captions: Vec<String>,

        /// アフターキャプション
        #[arg(short = 'A', long)]
        after_captions: Vec<String>,

        /// ファイルからスクリプトを読み込む
        #[arg(short = 'f', long,
            conflicts_with_all(["side_dishes", "pre_captions", "after_captions"]))
        ]
        script_file: Option<PathBuf>,

        /// インターバル (ms)
        #[arg(
            short, long,
            default_value_t = 1000,
            value_parser = clap::value_parser!(u64).range(10..))
        ]
        interval: u64,
    },
}

script_fileオプションがお気に入りです!String型としてではなくて受け取る構造体の時点でPathBuf型として記述することができており、#[derive(Parser)]を利用しているメリットを十分に感じられてスッキリします!

clapまとめ

clapを使うことでそれっぽいCLIアプリもどきをたくさん量産できました :crab: :crab: :crab:

clapにはサブコマンドや引数をまとめる機能など、本記事で紹介していない機能がたくさんあります!また、本記事で紹介したのは宣言的にコマンドライン引数の設定を記述できるderiveによる方法でしたが、もう少し柔軟に(例えば、ランタイム時などに)設定を行えるbuilderによる方法もあります。

かなり詳細なチュートリアルがあるので、気になった方は公式ドキュメントの方を参照してみてください。

本記事で紹介したかった内容の9割はclapでしたので以降は少し軽めになっていきますが、dialoguer, indicatif, consoleも見ていきます :eyes:

インタラクティブ! dialoguer

dialoguerは、対話的にユーザーからの入力を受け取る際に活躍するクレートです!

大体のCLIアプリはコマンドライン引数さえ受け取れれば後は出力のみという場合が多いかと思いますが、それで事足りずユーザーと対話する必要が出てきた際はこのdialoguerクレートの出番というわけです。

本記事では選択肢を提示してユーザーに選択してもらうSelectと、ユーザーからの標準入力を受け取るInputを紹介します。

Select

文字列スライスを items メソッドで渡したのち、その選択肢を interact メソッドにて表示します。ユーザーからの選択は文字列ではなく配列のインデックスとして受け取ります。

Rust
let choices = &["はい", "いいえ"];
let choice: usize = Select::new() // 選択したインデックスが返る
    .items(choices)
    .default(0)
    .interact()?;
ソースコード全体
Cargo.toml
[package]
name = "world_half"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
dialoguer = "0.11.0"
main.rs
use dialoguer::Select;

fn main() {
    println!(
        "もし、わしの味方になれば世界の半分を勇者にやろう。
どうじゃ? わしの味方になるか?"
    );

    loop {
        let choices = &["はい", "いいえ"];
        let choice = Select::new().items(choices).default(0).interact().unwrap();

        match choice {
            0 => {
                println!(
                    "
では世界の半分、闇の世界を与えよう!
お前の旅は終わった。さぁ、ゆっくり休むがよい!
わあっはっはっはっ。
$ rm -rf /"
                );
                break;
            }
            _ => {
                println!("まぁまぁそう言わず、わしの味方になってくれまいか...?");
            }
        }
    }
}

列挙型等を渡せないのは残念ですが、シンプルに選択プロンプトを出せる良い機能です!

Input

標準入力から文字列を受け取れるシンプルな機能になります!

Rust
let input: String = Input::new()
    .with_prompt("You")
    .interact_text()?;
ソースコード全体

リポジトリ: https://github.com/anotherhollow1125/chatgpt_demo

Cargo.toml
[package]
name = "chatgpt_demo"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
anyhow = "1.0.83"
dialoguer = "0.11.0"
dotenvy = "0.15.7"
reqwest = { version = "0.12.4", features = ["json"] }
serde = { version = "1.0.201", features = ["derive"] }
serde_json = "1.0.117"
tokio = { version = "1.37.0", features = ["full"] }
main.rs
use anyhow::Result;
use dialoguer::Input;
use reqwest::{Client, RequestBuilder};

#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
enum Role {
    System,
    User,
    Assistant,
}

#[derive(Debug, serde::Serialize, serde::Deserialize, Clone)]
struct Message {
    role: Role,
    content: String,
}

#[derive(Debug, serde::Serialize, serde::Deserialize)]
struct RequestBody {
    model: String,
    messages: Vec<Message>,
}

#[derive(Debug, serde::Serialize, serde::Deserialize)]
struct Choice {
    index: u64,
    message: Message,
    finish_reason: String,
}

#[derive(Debug, serde::Serialize, serde::Deserialize)]
struct Usage {
    prompt_tokens: u64,
    completion_tokens: u64,
    total_tokens: u64,
}

#[derive(Debug, serde::Serialize, serde::Deserialize)]
struct ResponseBody {
    id: String,
    object: String,
    created: u64,
    choices: Vec<Choice>,
    usage: Usage,
}

fn common_header(api_key: &str) -> RequestBuilder {
    let api_key_field = format!("Bearer {}", api_key);

    Client::new()
        .post("https://api.openai.com/v1/chat/completions")
        .header("Content-Type", "application/json")
        .header("Authorization", api_key_field.as_str())
}

async fn query(api_key: &str, input_messages: &[Message]) -> Result<Message> {
    let mut response_body = common_header(api_key)
        .json(&RequestBody {
            model: "gpt-4-turbo".to_string(),
            messages: Vec::from(input_messages),
        })
        .send()
        .await?
        .json::<ResponseBody>()
        .await?;

    let res = response_body.choices.remove(0).message;
    Ok(res)
}

#[tokio::main]
async fn main() -> Result<()> {
    dotenvy::dotenv().ok();

    let api_key = std::env::var("CHATGPT_APIKEY")?;

    let mut messages = vec![Message {
        role: Role::System,
        content: "You are a helpful assistant.".to_string(),
    }];

    loop {
        let input = Input::new()
            .with_prompt("You")
            .interact_text()
            .unwrap_or_else(|_| "quit".to_string());

        if input == "quit" {
            break;
        }

        messages.push(Message {
            role: Role::User,
            content: input,
        });

        let response = query(&api_key, &messages).await?;

        println!("ChatGPT: {}", response.content);

        messages.push(response);
    }

    Ok(())
}

ChatGPT APIを叩くCLIアプリ例

記事とは関係ないですが、なんと記事を書いている途中で新しいモデルの gpt-4o が使えるようになりました...!同じコードでそのまま該当部分を差し替えるだけでおkです!

ほとんどクエリのための構造体の定義で行数を持っていかれていますが、入出力部分はシンプルな作りになっています。

ちなみにライブラリを使わずに「プロンプトを出して改行せずに標準入力を受け取る」のを実現するには、print!マクロとstd::io::Write::flushを使う必要があり不格好です...

dialoguer::Input を使わない入力
main.rs
use std::io::{stdout, Write};

fn main() {
    let mut out = stdout();
    print!("You: ");
    out.flush().unwrap();

    let mut input = String::new();
    std::io::stdin().read_line(&mut input).unwrap();

    let input = input.trim();

    println!("Echo: {}", input);
}

Inputならユーザーによる標準入力処理をスッキリ書けるというわけです!

その他

その他にもConfirm, FuzzySelect, MultiSelect, Sort, Password, Input with History, Editorといった便利な構造体が用意されていますが、全部説明していくのは大変なため、一つのサンプルコードにまとめてみました!

ソースコード

リポジトリ: https://github.com/anotherhollow1125/diag_all_in_one

Cargo.toml
[package]
name = "diag_all_in_one"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
anyhow = "1.0.83"
console = "0.15.8"
dialoguer = { version = "0.11.0", features = ["history", "fuzzy-select"] }
main.rs
use anyhow::Result;
use console::Term;
use dialoguer::{BasicHistory, Confirm, Editor, FuzzySelect, Input, MultiSelect, Password, Sort};

fn confirm_demo() -> Result<()> {
    println!(
        "
【Confirm】

[y/n] 形式で質問する
"
    );

    let confirmation = Confirm::new()
        .with_prompt("縮小用廃棄物投入プロトコルを実施しますか?")
        .show_default(true)
        .wait_for_newline(true)
        .interact()?;

    if confirmation {
        println!("プロトコルを開始しました");
    } else {
        println!("中止しました");
    }

    Ok(())
}

fn fuzzyselect_demo() -> Result<()> {
    println!(
        "
【FuzzySelect】

入力による選択で、徐々に絞り込まれていく
"
    );

    let items = ["Python", "R", "Rust", "Ruby", "Ruby on Rails"];

    let selection = FuzzySelect::new()
        .with_prompt("What do you use?")
        .items(&items)
        .interact()?;

    println!("You chose: {}", items[selection]);

    Ok(())
}

fn multiselect_demo() -> Result<()> {
    println!(
        "
【MultiSelect】

チェックボックスによる複数選択ができる
"
    );

    let items = [
        "Basic",
        "Extension for VSCode",
        "Extension for Rust",
        "Utility for Git",
    ];

    let selections = MultiSelect::new()
        .with_prompt("インストールする項目を選んでください (スペースで選択/解除)")
        .item_checked(items[0], true)
        .items(&items[1..])
        .interact()?;

    println!("以下をインストールします:");
    for selection in selections {
        println!("{}", items[selection]);
    }

    Ok(())
}

fn sort_demo() -> Result<()> {
    println!(
        "
【Sort】

ユーザーによりソートされたインデックスを返す
"
    );

    let items = vec!["SSD-1", "HDD-1", "HDD-2", "USB", "DVD"];

    let order = Sort::new()
        .with_prompt("Boot Device Priority (Space key: select, Arrow keys: move)")
        .items(&items)
        .interact()?;

    let mut new_priority: Vec<_> = order
        .into_iter()
        .enumerate()
        .map(|(priority, i)| (priority, items[i]))
        .collect();

    new_priority.sort_by_key(|(priority, _)| *priority);

    let boot_devices = new_priority
        .iter()
        .map(|(_, item)| item)
        .collect::<Vec<_>>();

    println!("Sorted Vector: {:?}", boot_devices);

    Ok(())
}

fn password_demo() -> Result<()> {
    println!(
        "
【Password】

Inputと同様に入力を受け付けるが、入力内容を表示しない
"
    );

    let password = Password::new()
        .with_prompt("Enter your password")
        .interact()?;

    let password_confirm = Password::new()
        .with_prompt("Confirm your password")
        .interact()?;

    if password == password_confirm {
        println!("Password is confirmed");
    } else {
        println!("Password is not confirmed");
    }

    Ok(())
}

fn history_demo() -> Result<()> {
    println!(
        "
【History Trait (BasicHistory)】

過去の入力を履歴として保存する。
矢印キーにより過去の入力を呼び出せる
"
    );

    let mut history = BasicHistory::new().no_duplicates(true);

    loop {
        let input: String = Input::new()
            .with_prompt("Enter something")
            .history_with(&mut history)
            .interact_text()?;

        if input == "exit" {
            break;
        }
    }

    Ok(())
}

fn editor_demo() -> Result<()> {
    let content = Editor::new().edit(
        "
【Editor】

Vim, Emacsなどのデフォルトエディタを開き、入力を受け付ける
",
    )?;

    println!(
        "Edited content:
{}
",
        content.unwrap_or("None".to_string())
    );

    Ok(())
}

fn main() -> Result<()> {
    let term = Term::stdout();
    term.clear_screen()?;

    confirm_demo()?;
    let _ = term.read_key()?;
    term.clear_screen()?;

    fuzzyselect_demo()?;
    let _ = term.read_key()?;
    term.clear_screen()?;

    multiselect_demo()?;
    let _ = term.read_key()?;
    term.clear_screen()?;

    sort_demo()?;
    let _ = term.read_key()?;
    term.clear_screen()?;

    password_demo()?;
    let _ = term.read_key()?;
    term.clear_screen()?;

    history_demo()?;
    let _ = term.read_key()?;
    term.clear_screen()?;

    editor_demo()?;

    Ok(())
}
構造体名 概要
Confirm [y/n] 形式で質問する
FuzzySelect 入力による選択で、徐々に絞り込まれていく
MultiSelect チェックボックスによる複数選択ができる
Sort ユーザーによりソートされたインデックスを返す
Password Inputと同様に入力を受け付けるが、入力内容を表示しない
BaisicHistory 過去の入力を履歴として保存する
Editor Vim, Emacsなどのデフォルトエディタを開き、入力を受け付ける

dialoguer まとめ

dialoguerを使うことで、ライブラリなしだと多少煩雑なユーザーからの標準入力を扱えるのを示せました。

対話型CLIアプリもこれで安心して作れます!

進捗どうですか? indicatif

重い処理や、複数のタスクからなる処理を実行する際、実行を開始した後になって「ちゃんとプログラムが動いていることを確かめる機構を付けておくべきだった...」と後悔した経験は誰しもあるのではないでしょうか?

タスクバーの表示というのはまさにそのためにあるものでしょう。というわけで、プログレスバーをお手軽に表示できる indicatif クレートの紹介です!

ソースコード

リポジトリ: https://github.com/anotherhollow1125/almost_done

Cargo.toml
[package]
name = "almost_done"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
indicatif = "0.17.8"
main.rs
use std::cmp::min;
use std::thread;
use std::time::Duration;

use indicatif::{MultiProgress, ProgressBar, ProgressStyle};

fn pure_pb(
    m: &mut MultiProgress,
    template: &str,
    msg: &'static str,
    total_size: u64,
    speed: u64,
    prog_char: Option<&str>,
) -> thread::JoinHandle<()> {
    let pb = m.add(ProgressBar::new(total_size));
    pb.set_style(
        ProgressStyle::with_template(template)
            .unwrap()
            .progress_chars(prog_char.unwrap_or("#>-")),
    );
    pb.set_message(msg);

    thread::spawn(move || {
        let mut downloaded = 0;

        while downloaded < total_size {
            let new = min(downloaded + speed, total_size);
            downloaded = new;
            pb.set_position(new);
            thread::sleep(Duration::from_millis(12));
        }

        pb.finish_with_message("downloaded");
    })
}

fn stop_pb(
    m: &mut MultiProgress,
    template: &str,
    msg: &'static str,
    total_size: u64,
    speed: u64,
    slow_speed: u64,
    prog_char: Option<&str>,
) -> thread::JoinHandle<()> {
    let pb = m.add(ProgressBar::new(total_size));
    pb.set_style(
        ProgressStyle::with_template(template)
            .unwrap()
            .progress_chars(prog_char.unwrap_or("#>-")),
    );
    pb.set_message(msg);

    thread::spawn(move || {
        let mut downloaded = 0;
        let mut s = speed;
        let mut wait;

        while downloaded < total_size {
            let new = min(downloaded + s, total_size);
            downloaded = new;
            pb.set_position(new);
            if total_size - downloaded >= speed * 3 {
                s = speed;
                wait = 12;
            } else {
                s = slow_speed;
                wait = 1200;
            };

            thread::sleep(Duration::from_millis(wait));
        }

        pb.finish_with_message("downloaded");
    })
}

fn main() {
    let mut m = MultiProgress::new();
    let template = "{spinner:.green} [{elapsed_precise}] {msg} {percent:>3}% [{bar:20.cyan/blue}] {bytes}/{total_bytes} ({bytes_per_sec}, {eta})";
    let h1 = pure_pb(&mut m, template, "contents 1", 1000, 1, None);
    thread::sleep(Duration::from_millis(500));
    let h2 = pure_pb(&mut m, template, "contents 2", 1000, 10, Some("|:_"));
    thread::sleep(Duration::from_millis(500));
    let h3 = stop_pb(&mut m, template, "contents 3", 1000000, 5000, 1, None);

    h1.join().unwrap();
    h2.join().unwrap();
    h3.join().unwrap();
}

進捗は主に以下の方法で管理できます。

  • inc メソッド等を呼び更新する方法
  • イテレータをラップして要素処理ごとに更新される方法

なお、マルチスレッド処理が考慮されており内部的には Arc が使われているようです。もとい上記例でもマルチスレッドであるように、プログレスバーを表示するようなアプリはどれもマルチスレッドにしがちでしょうから、当然の仕様なのかもしれません。

inc メソッドによる方法

Rust
let bar = ProgressBar::new(1000);
for _ in 0..1000 {
    bar.inc(1);
    sleep(Duration::from_millis(1));
}
bar.finish_and_clear();

進捗のたびに inc メソッドを呼び出しインクリメントする方法です。

イテレータによる方法

indicatif::ProgressIteratorトレイトを読み込むことにより、イテレータに progress メソッドを生やす方法です。イテレートのたびにインクリメントされます。

for文で読み込むも良し、mapfor_each を呼び出す前に挟んでも良しです!

Rust
use indicatif::ProgressIterator;

// iter progress bar 1
for _ in (0..1000).progress() {
    sleep(Duration::from_millis(1));
}

// iter progress bar 2
(0..1000).progress().for_each(|_| {
    sleep(Duration::from_millis(1));
});

上記で作られるプログレスバーは inc メソッドによるものと同じになります。

ソースコード全体
Cargo.toml
[package]
name = "indicatif_pg"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
indicatif = "0.17.8"
main.rs
use indicatif::ProgressBar;
use indicatif::ProgressIterator;
use std::thread::sleep;
use std::time::Duration;

fn main() {
    // normal progress bar
    let bar = ProgressBar::new(1000);
    for _ in 0..1000 {
        bar.inc(1);
        sleep(Duration::from_millis(1));
    }
    bar.finish_and_clear();

    // iter progress bar 1
    for _ in (0..1000).progress() {
        sleep(Duration::from_millis(1));
    }

    // iter progress bar 2
    (0..1000).progress().for_each(|_| {
        sleep(Duration::from_millis(1));
    });
}

プログレスバーのフォーマット

プログレスバーとして表示する情報は細かく設定することが可能です。本記事では詳細には触れず軽く紹介します!

indicatif::style::ProgressStyle::with_template にて表示する情報のテンプレート指定、progress_chars にてプログレスバーの完了/途中/未着手部分の表示文字の変更ができます(下記例では .progress_chars("#>-") )。

テンプレートについては https://docs.rs/indicatif/latest/indicatif/#templates に詳細が書いています。本節最初に示した例のプログレスバーは次のようなフォーマットになっています。

{spinner:.green} [{elapsed_precise}] {msg} {percent:>3}% [{bar:20.cyan/blue}] {bytes}/{total_bytes} ({bytes_per_sec}, {eta})
実際の表示
⠐ [00:00:05] contents 1  48% [#########>----------] 485 B/1000 B (82 B/s, 6s)

{spinner:.green} がわかりやすいですが、 {項目:スタイル} という形になっています。スタイルの方は桁数、アライメント(右揃え左揃え)やカラーを指定できます。項目については下記に示すようなものがあります(一部のみ示しています)。

項目 概要
spinner 処理が止まらずに動いていることを示すためのぐるぐる
elapsed / elapsed_precise 経過時間
msg / wide_msg メッセージ。wide_*の方は指定するとターミナル幅いっぱいに取る
percent / percent_precise 達成割合
bar / wide_bar プログレスバー本体。msg同様wide_は幅いっぱいに取る
bytes 経過バイト
bytes_per_sec 現在の処理速度
eta 残り時間

indicatif まとめ

ただプログレスバーを示してくれるだけではなく、経過時間や処理速度等までお手軽に示せるindicatifは、重たい処理を持つアプリの味方です。

途中の処理状態が見えるか見えないかはUXに大きく関わる部分ですから、使えそうなシーンがあれば積極的に使いたいクレートでした!

ターミナル色付け職人 console

dialoguerも一部そうでしたが、特にindicatifが提供してくれるプログレスバーなどは、キャリッジリターンやANSIエスケープを用いて表示する必要があり、これを直接文字列に含めて操作を行うのは至難の業です。だからこそdialoguerやindicatifの存在がありがたいのでした。

このconsoleは、そんなANSIエスケープをコードベースで行えるようにしてくれるクレートです!具体的には、ターミナル上のカーソル移動(ないし文字列削除・上書き)、色付き文字の出力ができます。それ以外にも、絵文字の安全なサポート、ユニコード文字列幅を考慮したパディング機能を備え、実装者のわがままを最後まで叶えてくれます!

Rust
let term = Term::stdout();

term.clear_screen()?;

let hands = [
    format!("{} {}", Emoji("🔥", "F"), style("fire").red()),
    format!("{} {}", Emoji("💧", "W"), style("water").cyan()),
    format!("{} {}", Emoji("🌿", "L"), style("leaf").green()),
];
ソースコード全体
Cargo.toml
[package]
name = "janken"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
console = "0.15.8"
dialoguer = "0.11.0"
rand = "0.8.5"
anyhow = "1.0.83"
main.rs
use anyhow::Result;
use console::{style, Emoji, Term};
use dialoguer::Select;
use rand::prelude::*;

fn main() -> Result<()> {
    let term = Term::stdout();

    term.clear_screen()?;

    let hands = [
        format!("{} {}", Emoji("🔥", "F"), style("fire").red()),
        format!("{} {}", Emoji("💧", "W"), style("water").cyan()),
        format!("{} {}", Emoji("🌿", "L"), style("leaf").green()),
    ];

    let mut rng = rand::thread_rng();
    let computer_choice = rng.gen_range(0..3);

    let selection = Select::new()
        .with_prompt("Choose your hand")
        .items(&hands)
        .default(0)
        .interact()?;

    println!("  Computer chose: {}", hands[computer_choice]);
    println!(
        "{}",
        match (selection + 3 - computer_choice) % 3 {
            0 => "It's a draw!",
            1 => "You win!",
            _ => "You lose...",
        }
    );

    Ok(())
}

ここからはconsoleクレートのおすすめ機能を紹介します。ソースコードは上記例と重複する部分が多いので省略します。適宜上記例を見ていただければと思います。

カーソル操作・文字の削除

本記事で出してきた例においても何回か活用してきた機能です(language monster以外だと好きな惣菜発表ドラゴン等)。

Term構造体はたくさんメソッドを持っていますが、特に下記に挙げたものは使いやすい印象でした。

メソッド 概要
clear_screen 全画面クリア。なんだかんだこのメソッドを使う方法が一番悩まずに済みました
hide_cursor / show_cursor アニメーション中等はカーソルがちらつくので隠すといい感じです
read_key キー入力を受けたら先に進むアニメーションを作るのに役立ちました

色付き文字

ターミナル生活に彩りを与えてくれる色付き文字出力機能は、Style及びStyledObjectとして与えられています。console::style関数が文字列のStyledObjectを返してくれ、StyledObjectは色を付けるメソッドを呼び出せるので、例えばstyle("赤文字").red()のように書くことができます。

StyledObjectは std::fmt::Display を実装しているので、 format! マクロで文字列にしたり、println! マクロでそのまま表示してあげると色付き文字が出力されます。

console::pad_str

アライメントを考慮しつつ、文字列が所定の長さになるようにスペースでパディングしてくれるユーティリティです。

公式ドキュメントではトップページ以外で明記されていなかったのですが、内部ではunicode_widthが用いられており、全角文字でも適切な長さにパディングしてくれます!language monsterではこの関数をフル活用していました。

console まとめ

ターミナル上で何でもありな出力を生み出してくれるconsoleの紹介でした!dialoguerやindicatifでも満足できない時に使ってみると良いかもしれません。

その他便利なクレート

4選に入れるか悩んだクレート群です。CLIに関連した機能とは限らないけども、CLIアプリを作成する際に役立つものが多めです。一応触れておいたほうが良いかなぁという思いより本節を書いています。

軽く概要を紹介したいと思います!

serde

clap以上に有名で説明不要かもしれないRustのシリアライザ/デシリアライザ (略してser/de)になります!derive featureを有効にして入れたのち、構造体に serde::Serialize, serde::Deserialize をderiveする使い方が一般的です。

Rust
#[derive(serde::Serialize, serde::Deserialize)]
struct Article {
    title: String,
    body: String,
}

このように記述し、serde_jsontoml クレートの力も借りると、下記のようなファイルの読み書きが可能になります。

JSON
{
  "title":"タイトル",
  "body":"ボディ"
}
toml
title = "タイトル"
body = "ボディ"
活用例
Cargo.toml
[package]
name = "serde_pg"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
clap = { version = "4.5.4", features = ["derive"] }
serde = { version = "1.0.201", features = ["derive"] }
serde_json = "1.0.117"
toml = "0.8.12"
main.rs
use clap::Parser;
use std::path::PathBuf;

#[derive(Parser)]
#[command(about, version)]
struct Args {
    save_path: PathBuf,

    #[command(flatten)]
    article: Article,
}

#[derive(serde::Serialize, serde::Deserialize, clap::Args)]
struct Article {
    title: String,
    body: String,
}

fn main() {
    let Args { save_path, article } = Args::parse();

    println!("Save path: {:?}", save_path);

    let content = match save_path.extension() {
        Some(ext) if ext == "json" => serde_json::to_string(&article).unwrap(),
        Some(ext) if ext == "toml" => toml::to_string(&article).unwrap(),
        _ => {
            eprintln!("Unsupported file extension");
            return;
        }
    };

    println!("Content: \n===\n{}\n===", content);

    std::fs::write(save_path, content).unwrap();
}

json, yaml, toml等、フォーマットが定められたファイルの読み書き時にはほぼデファクトで必須なクレートになっています!

環境変数関連

CLIアプリケーションでは環境変数による設定もつきものです。環境変数周りの便利クレートを紹介します。

本節のサンプルコード全体
Cargo.toml
[package]
name = "env_pg"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
dotenvy = "0.15.7"
envy = "0.4.2"
serde = { version = "1.0.201", features = ["derive"] }
main.rs
use dotenvy::dotenv;
use serde::Deserialize;

#[derive(Deserialize, Debug)]
struct Config {
    ip: String,
    port: u16,
}

fn main() {
    dotenv().ok();

    let config = envy::prefixed("APP_").from_env::<Config>().unwrap();

    println!("IP: {}, Port: {}", config.ip, config.port);
}
.env
APP_IP="localhost"
APP_PORT=3000

envy

clapの環境変数版とも言えるクレートで、環境変数を構造体にパースしてくれます!構造体には serde::Deserialize が実装されている必要があります。

Rust
#[derive(Deserialize, Debug)]
struct Config {
    ip: String,
    port: u16,
}

let config = envy::prefixed("APP_").from_env::<Config>().unwrap();

dotenvy (dotenv)

.env ファイルに書かれた設定を読み込み、環境変数として扱ってくれるクレートです。他言語でもよくあるやつ。

.env
APP_IP="localhost"
APP_PORT=3000

に対して、

Rust
dotenvy::dotenv().ok();

APP_IPAPP_PORTが環境変数として扱えるようになります!

2024/05/14 現在

以前はdotenvが使われていたのですが、メンテナンスがされなくなった関係でフォークして開発が続けられているのがdotenvyになります。

本記事が古くなって状況が変わっている可能性があるので、一応両方のクレートについて調べてから使っていただきたいです。

env_logger / log

CLIアプリの丁寧な出力と言えばロギングです!見やすいフォーマットで標準エラー出力にログを吐いてくれる機能を簡単に紹介します!

Cargo.toml
Cargo.toml
[package]
name = "log_pg"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
dotenvy = "0.15.7"
env_logger = "0.11.3"
log = "0.4.21"
.env
RUST_LOG=INFO
main.rs
use log::{debug, error, info, trace, warn};

fn main() {
    dotenvy::dotenv().ok();

    env_logger::init();

    trace!("This is a trace message");
    debug!("This is a debug message");
    info!("This is an info message");
    warn!("This is a warning message");
    error!("This is an error message");
}
実行結果
[2024-05-13T23:42:29Z INFO  log_pg] This is an info message
[2024-05-13T23:42:29Z WARN  log_pg] This is a warning message
[2024-05-13T23:42:29Z ERROR log_pg] This is an error message

logクレートは抽象化されたログ出力機構で、ログを出力したい場所にてlogクレートのマクロを呼び出します。

env_loggerクレートは実際にログを出力してくれる役割を担うクレートの一つです。環境変数 RUST_LOGにしたがって出力するログのレベルを決定します。上記コードでは.envにて設定したログレベル(INFO)以上のログを出力しています。

また、ログのフォーマットもenv_loggerからカスタム可能です。


以上、4選には選ばなかったもののCLI制作に役立つクレートたちでした!

この他、冒頭で紹介したCommand Line Applications in Rustには本記事で紹介しなかった便利クレートが紹介されている他、コマンドの公開方法やドキュメントの生成方法などさらに役立つ情報が載っています、気になった方は読んでみてください!

まとめ・所感

RustでのCLIアプリケーション制作のポテンシャルが伝わったでしょうか...?CLIアプリケーションさえ作れれば、Tauriを用いることでGUI化できますし、応用の幅は無限にあります!

clapをフル活用するまでは筆者もPython等別な言語でCLIアプリを作ることもあったのですが、clapを知ってからは便利すぎてプロトタイプ段階のアプリも全部Rustで書くようになりました。何より、clapを初め本記事で紹介したクレートによるCLIアプリ制作が楽しすぎて、本記事完成までめちゃくちゃ時間がかかってしまったほどです...!

どうかこのワクワクが伝わって、本記事が読者の皆様のCLIアプリ制作の一助になりますように✨

ここまで読んでいただき、誠にありがとうございました!

63
50
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
63
50