1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

一日一回cargo script安定化してくれの舞② クレート紹介編

Posted at

ChatGPT_cli.gif

こんにチュア!本記事は hooqアドベントカレンダー 7日目の記事です!

hooqというのは筆者が作成した ? 演算子と式の間にメソッドを挿入できる属性マクロです!

本記事では 昨日の記事 の続きということで、良い機会なので筆者がcargo scriptで良く使うクレート群を紹介します。

cargo scriptでよく使うクレート紹介

clapに関しては昨日の記事以前書いた記事へのリンクを貼りました(なんかややこしいw)が、clapやdialoguer・indicatif以外にも便利なクレートは色々あります!

紹介するためのサンプルスクリプトを作成しました。OpenAI APIを叩いてChatGPTと対話するプログラムです!実行すると冒頭掲載のGIF画像のように対話できます1

今回のクレート紹介はこのサンプルを実例として実際にどういう風に使っているかを紹介します。

chat_gpt.rs
#!/usr/bin/env -S cargo +nightly -q -Zscript run --release --manifest-path
---
[dependencies]
clap = { version = "4.5.53", features = ["derive"] }
anyhow = "1.0.100"
hooq = "0.3.0"
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.145"
toml = "0.9.8"
tokio = { version = "1.48.0", features = ["full"] }
reqwest = { version = "0.12.24", features = ["json"] }
dialoguer = "0.12.0"
indicatif = "0.18.3"
---

use anyhow::Result;
use clap::Parser;
use hooq::hooq;
use serde_json::json;
use std::path::PathBuf;

#[derive(Debug, Parser)]
struct Args {
    #[arg(short, long, default_value = "gpt-4.1")]
    model: String,

    #[arg(short, long)]
    api_key: Option<String>,

    #[arg(short, long)]
    output: Option<PathBuf>,
}

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

#[hooq(anyhow)]
async fn query(api_key: &str, model: &str, conversation: &mut Vec<Message>) -> Result<Message> {
    let res = reqwest::Client::new()
        .post("https://api.openai.com/v1/responses")
        .bearer_auth(api_key)
        .json(&json!({
            "model": model,
            "input": conversation,
        }))
        .send()
        .await?;

    let body: serde_json::Value = res.json().await?;

    #[hooq::method(.$so_far.with_context(|| format!("API response: {:#?}", body)))]
    let reply_content = body["output"][0]["content"][0]["text"]
        .as_str()?
        .to_string();

    let reply_message = Message {
        role: "assistant".to_string(),
        content: reply_content,
    };

    conversation.push(reply_message.clone());

    Ok(reply_message)
}

#[hooq(anyhow)]
#[tokio::main]
async fn main() -> Result<()> {
    let Args { model, api_key, output } = Args::parse();

    #[hooq::method(.$so_far.context("API key not provided"))]
    let api_key = api_key
        .or_else(|| std::env::var("OPENAI_API_KEY").ok())?;

    let mut conversation: Vec<Message> = Vec::new();

    loop {
        let user_message = tokio::task::spawn_blocking(|| {
            dialoguer::Input::<String>::new()
                .with_prompt("You")
                .interact_text()
        }).await??;

        if user_message == "/exit" {
            break;
        }

        conversation.push(Message {
            role: "user".to_string(),
            content: user_message,
        });

        let spinner = indicatif::ProgressBar::new_spinner();
        spinner.set_message("Waiting for response...");
        spinner.enable_steady_tick(tokio::time::Duration::from_millis(100));

        let reply = query(&api_key, &model, &mut conversation).await?;

        spinner.finish_and_clear();

        println!("Assistant: {}", reply.content);
    }

    if let Some(output_path) = output {

        #[derive(serde::Serialize)]
        struct Conversations {
            messages: Vec<Message>,
        }

        let serialized = toml::to_string(&Conversations { messages: conversation })?;
        tokio::fs::write(output_path, serialized).await?;
    }

    Ok(())
}

Gist: https://gist.github.com/anotherhollow1125/dda312002226c3405a28c1f8f1c490e8

ChatGPTをCLIから呼んでみるプログラム自体は今までも 何度か組んだことがあった のですが、まとめなおして作成した今回のプログラムはなかなか読みやすそうな自信があります!

紹介するクレートの早見表です。

クレート名 グループ 役割
clap CLI周り 必須級コマンドライン引数パーサー
dialoguer CLI周り プロンプトを出してユーザーからの入力を受ける
indicatif CLI周り 処理進捗/処理中の表示
anyhow エラー周り とにかくエラーをいい感じに扱うためのクレート
hooq エラー周り cargo scriptでエラートレースを表示するためのクレート
serde ファイル読み書き Rustの構造体をjson・tomlなど別構造に変換するためのユーティリティ
serde_json ファイル読み書き json読み書きユーティリティ
toml ファイル読み書き toml読み書きユーティリティ
tokio 非同期周り Rust非同期ランタイムのデファクトスタンダード
reqwest API叩き周り お手軽にWebエンドポイントにリクエストを送るクレート

では順に紹介します!

CLI周りをきれいに記述するクレート

Rustでcargo script・CLIアプリをきれいに書くために使われるクレートです!

可読性マシマシなCLIアプリができます。

clap

言わずもがなRust CLIアプリ界のエースで4番!clapです。

何が最高かというと derive featureを有効にしている場合はコマンドライン引数やオプションを構造体という形で 宣言的に 書けることです。

Rust
#[derive(Debug, Parser)]
struct Args {
    #[arg(short, long, default_value = "gpt-4.1")]
    model: String,

    #[arg(short, long)]
    api_key: Option<String>,

    #[arg(short, long)]
    output: Option<PathBuf>,
}

これでヘルプコマンドまで生えます!オプションの指定方法も「 Option 型だからない時もある」とわかるようになっていたりと、とにかくあらゆる言語の中で一番わかりやすいオプション宣言だと思っています!

言語問わずclapより優れてると言えるコマンドライン引数記述があるならぜひ教えてほしいレベルですね。ないと思います。

dialoguer

CLIアプリにおいてユーザー入力を取り扱う受付係です!dialoguerを使えばとてもシンプルにプロンプトを出せます。

Rust
let user_message = tokio::task::spawn_blocking(|| {
    dialoguer::Input::<String>::new()
        .with_prompt("You")
        .interact_text()
}).await??;

今回、全体が非同期アプリなのでお行儀良いコーディングのために一応 tokio::task::spawn_blocking で包むようにしています。(今回の場合だと特に意味はないかも...)

ユーザーの入力がそのまま返り値になるので、Pythonの input のような書き心地を提供してくれています!

indicatif

dialoguerが受付係でその入力を受けて裏方で処理が走るのだとすると、さしずめ受付と裏方の連絡係がindicatifです!

Rust
let spinner = indicatif::ProgressBar::new_spinner();
spinner.set_message("Waiting for response...");
spinner.enable_steady_tick(tokio::time::Duration::from_millis(100));

let reply = query(&api_key, &model, &mut conversation).await?;

spinner.finish_and_clear();

GIF画像で Waiting for response... という文言とスピナーが出ていると思います。時間がかかる処理を行う時、ユーザーにプログラムがフリーズしていないことを伝えるのはUI/UXを考えると重要です。

indicatifでお手軽にUXを爆上げできるというわけです。お気に入りのクレートです!

お手軽エラーハンドリングのためのクレート

cargo scriptで書くような規模のプログラムでも、 Result 型で関数の失敗可能性を表すことと、 ? 演算子でその位置を示すというのは、.unwrap() で書くよりもプログラムの見通しを良くしてくれます!

ハンドリングをサボりつつ Result 型・ ? 演算子による可読性向上の恩恵を受けるためのクレートを2つ紹介します!

Rust
use anyhow::Result;
use hooq::hooq;

// ...

#[hooq(anyhow)]
async fn query(api_key: &str, model: &str, conversation: &mut Vec<Message>) -> Result<Message> {
    // ...

    let body: serde_json::Value = res.json().await?;

    #[hooq::method(.$so_far.with_context(|| format!("API response: {:#?}", body)))]
    let reply_content = body["output"][0]["content"][0]["text"]
        .as_str()?
        .to_string();

    // ...

    Ok(reply_message)
}

anyhow

Rustで雑エラーハンドリングを行うための最も有名なクレートですね。 anyhow::Error は内部的には Box<dyn std::error::Error> 、すなわちトレイトオブジェクトが使われています。match式での細かいハンドリングをせずmain関数の最上位まで持っていく使い方をすると楽です。

特にバックトレースにより丁寧な表示を望む場合、color-eyreクレートの利用をお勧めします。(しかし!特にcargo scriptにおいてはバックトレースだとエラー発生行が取得できずあまり意味がない点にお気をつけください。)

hooq

筆者が作成したクレートです!

cargo scriptにてエラートレースを得たい場合、バックトレースはまともな情報を表示してくれないので、筆者が確認している限りでは「anyhow::Context::with_contextをすべての ? 演算子に挟んで line!() マクロで行数をスタックさせておく方法」ぐらいしかありません。

それを代わりにやってくれるのがhooqマクロです!

詳しくは2日目の記事を読んでいただけたら幸いです :bow:

ファイル入出力のためのクレート

ここからはcargo scriptで良く利用するというだけで、Rustを使っていれば皆知っているものばかりなクレートの紹介になります!しかしその常識を記事にしていくことが大切だと考え、ここに記すことにしました。

serde

「Rustの構造体・列挙体 ↔ jsonやtomlなどのフォーマットファイル」

この相互変換を行うためのものとしてRustのクレートの中でも最も有名な部類でしょう。

Rust
#[derive(serde::Serialize, Clone)]
struct Message {
    role: String,
    content: String,
}
Rust
#[derive(serde::Serialize)]
struct Conversations {
    messages: Vec<Message>,
}

今回は「jsonへのシリアライズ」「tomlへのシリアライズ」が必要なため、各構造体について serde::Serialize を付与しています。

serde_json

jsonとの相互変換でお世話になるクレートです。

Rust
let res = reqwest::Client::new()
    .post("https://api.openai.com/v1/responses")
    .bearer_auth(api_key)
    .json(&json!({
        "model": model,
        "input": conversation,
    }))
    .send()
    .await?;

let body: serde_json::Value = res.json().await?;

#[hooq::method(.$so_far.with_context(|| format!("API response: {:#?}", body)))]
let reply_content = body["output"][0]["content"][0]["text"]
    .as_str()?
    .to_string();

json! マクロや添え字アクセスがとても便利です。

toml

tomlとの相互変換でお世話になるクレートです。

Rust
if let Some(output_path) = output {

    #[derive(serde::Serialize)]
    struct Conversations {
        messages: Vec<Message>,
    }

    let serialized = toml::to_string(&Conversations { messages: conversation })?;
    tokio::fs::write(output_path, serialized).await?;
}

tomlはRust文化圏だとjsonよりも良く見るフォーマットかもしれません。

慣れればjsonより読みやすく、人間が書き換えたり確認したりするファイルの形式に向いています。

Webアクセス周り

今回のスクリプトで利用していたので一応の紹介になります!ちょっとしたCLIツールでWeb APIを叩くこと結構ありますよね...?

tokio

Rust
#[hooq(anyhow)]
#[tokio::main]
async fn main() -> Result<()> {
    // ...

    loop {
        let user_message = tokio::task::spawn_blocking(|| {
            dialoguer::Input::<String>::new()
                .with_prompt("You")
                .interact_text()
        }).await??;

        // ...
    }

    // ...

    if let Some(output_path) = output {
        // ...

        tokio::fs::write(output_path, serialized).await?;
    }

    Ok(())
}

とても有名なRustの非同期ランタイムクレートです!

tokio::fs::write が良い例ですが、同期文脈で使うブロッキングIO周りのクレートや関数が、tokioクレート経由で提供されていたりということは結構多く、実際非同期ではそちらを使った方が良いです。他の人が書いたtokio利用コードや、クレート探索は結構収穫があるでしょう。

reqwest

RustでWeb APIを叩く際に用いられるクレートです!hyperをラップしているらしい...?(要出典)

Rust
let res = reqwest::Client::new()
    .post("https://api.openai.com/v1/responses")
    .bearer_auth(api_key)
    .json(&json!({
        "model": model,
        "input": conversation,
    }))
    .send()
    .await?;

blocking featureを有効にするとtokio抜きでも利用できるようになります。

ただ今回みたいにレスポンスが返ってくるまでの間スピナーを回したいなどの事情があるならば、デフォルトである非同期の方を利用しておくのが安牌でしょう。

Rustは非同期の 基礎 についての学習コストは他言語よりも低いため、「処理中にスピナーを回す」みたいなのが特に苦労せず書ける感じがするのもcargo scriptの魅力だと感じます。

まとめ

他にもregex2handlebarsなどまれによく利用するクレートはいくつかありますが、本当によく利用するものはこのページで揃えられたのではないかと思います!

VS Codeがcargo scriptを本格サポートしCargo.tomlパート管理も行ってくれるようになれば、お手軽にスクリプトを書ける言語としてRustはかなりよい候補になるはずです。

とても強い信念があるので今日も舞います!ここまで読んでいただきありがとうございました :bow:

  1. GIF画像なので読み込みに少し時間がかかるかもしれません

  2. regexクレートが必要になるのは公式が組み込んでくれていないせい...という部分は正直ありそうです。

1
0
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
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?