[2023年3月版]Chat GPTに'xc'っぽいツールをRustで作成させてみた

2023/03/11に公開

Chat GPTに簡単なコマンドラインツールをRustで生成させてみたい

Chat GPT で何か簡単なプログラムを出力させてみたいと思いつつ、なにかよさげな"モデル"がないか探していたところちょうどよさげなツール xc コメンドを発見いたしました。

シンプルながら便利なのでこれを題材に、仕様をさくっと書き起こしてテストケースと実装コードを生成させてみたいと思います。

xc の紹介

まずは xc についてざっくり紹介ですが、以下が公式サイトです。

Github: https://github.com/joerdav/xc
ドキュメント: https://xcfile.dev/

goで実装されているmarkdownの内のコードブロックをシェルから直接、実行できてしまうというシンプルながら強力なツールです。自分は Makefile を多用してますが、README.mdでまとめられるので素敵です。めっちゃ捗ります。

仕様がシンプルなので似たようなものをrustで生成させてみたいと思います。

ついでに機能も追加してみたいと思います。

準備

そもそもRustが入っていなかったのでasdfにプラグインを追加してrust環境を構築します。

$ asdf plugin-add rust https://github.com/asdf-community/asdf-rust.git
$ asdf install rust latest
∗ Downloading...
∗ Installing...
install: creating uninstall script at /home/roomtv/.asdf/installs/rust/1.67.1/lib/rustlib/uninstall.sh
install: installing component 'rustc'
install: installing component 'rust-std-x86_64-unknown-linux-gnu'
install: installing component 'rust-docs'
install: installing component 'rust-docs-json-preview'
install: installing component 'rust-demangler-preview'
install: installing component 'cargo'
install: installing component 'rustfmt-preview'
install: installing component 'rls-preview'
install: installing component 'rust-analyzer-preview'
install: installing component 'llvm-tools-preview'
install: installing component 'clippy-preview'
install: installing component 'rust-analysis-x86_64-unknown-linux-gnu'
install: WARNING: failed to run ldconfig. this may happen when not installing as root. run with --verbose to see the error

    rust installed.

The installation was successful!

で新規に作業フォルダを作成します。

$ mkdir xmd
$ cd xmd
$ git init
$ git branch -m main

rust の最新バージョンをローカルのみに有効にします。

$ asdf local rust latest
$ cargo version
cargo 1.67.1 (8ecd4f20a 2023-01-10)
$ cargo init
     Created binary (application) package

はい完了です。

プロンプト開始 (エンカウンター!)

続いて、以下のページを参考に
https://zenn.dev/fumi_sagawa/articles/932bcaafd28c53

ChatGPTにRustプログラムを吐かせるプロンプトは以下のようにしてみました。

あなたは優秀なプログラマーです。
以下の仕様をもとにRust言語のテストコードを作成してください。
また、仕様を満たしあなたが出力したテストコードを通過するプログラムをRust言語で作成してください。

## 仕様
### 前提条件
- タスクランナーのコマンドラインツール

### 動作
- 同一ディレクトリ内の README.md という Markdown 形式のファイルを読み込む。
- "```" で囲まれた箇所をコードブロックとする。
- "###" で始まり次の "###"の間でコードブロックを含む範囲をタスク定義ブロックとする。 
- タスク定義ブロックの "### "以降の文字列をタスク名とする。 
- タスク定義ブロックの "### "の次の行からコードブロックまでの間の行はヘルプ文とする
- タスク名が渡されたらタスク定義ブロック内のコードブロックをシェルスクリプトとして実行する
- タスク名が渡されなければ、すべての"タスク名 タブ ヘルプ文"を出力する
- README.md というファイルがなければ "README.md がありません"と出力する

なお出力形式は以下とします。
テストコードは同値分割法(Equivalence Partitioning Testing)と境界値分析(Boundary value analysis)を用いてください。

## 出力形式
- 概要
- テストコード
- 実装プログラム

私たちがこのタスクで最高の結果を出すためにステップバイステップで考えてください。

で、出力はだいたい以下のような感じでした(途中、途切れていたり再出力して文言が変わったりするので適当に抜粋してます。)

以下が、仕様を満たすためのRust言語のテストコードです。

#[cfg(test)]
mod tests {
    use std::process::Command;
    use std::path::Path;
    use std::fs;

    #[test]
    fn test_readme_tasks() {
        let file_name = "README.md";
        let expected_output = "README.md がありません\n";
        let readme_tasks = "### Task 1\nTask 1 Description\n```echo \"Task 1 Execution\"```\n\n### Task 2\nTask 2 Description\n```echo \"Task 2 Execution\"```\n";

        // Prepare test directory with README.md file
        let dir = tempfile::tempdir().unwrap();
        let readme_path = Path::new(dir.path()).join(file_name);
        fs::write(readme_path.clone(), readme_tasks).unwrap();

        // Test case 1: README.md file does not exist
        fs::remove_file(readme_path.clone()).unwrap();
        let output = Command::new("cargo")
            .args(&["run", "--"])
            .output()
            .unwrap();
        assert_eq!(String::from_utf8_lossy(&output.stdout), expected_output);

        // Test case 2: Task name is not provided
        let expected_output = "Task 1\tTask 1 Description\nTask 2\tTask 2 Description\n";
        let output = Command::new("cargo")
            .args(&["run", "--", readme_path.to_str().unwrap()])
            .output()
            .unwrap();
        assert_eq!(String::from_utf8_lossy(&output.stdout), expected_output);

        // Test case 3: Task name is provided and exists
        let expected_output = "Task 2 Execution\n";
        let output = Command::new("cargo")
            .args(&["run", "--", readme_path.to_str().unwrap(), "Task 2"])
            .output()
            .unwrap();
        assert_eq!(String::from_utf8_lossy(&output.stdout), expected_output);

        // Test case 4: Task name is provided but does not exist
        let expected_output = "Task 3 does not exist\n";
        let output = Command::new("cargo")
            .args(&["run", "--", readme_path.to_str().unwrap(), "Task 3"])
            .output()
            .unwrap();
        assert_eq!(String::from_utf8_lossy(&output.stdout), expected_output);
    }
}

以下は、clapというライブラリを使用して仕様を満たすRustのプログラムの例です。実行すると、README.md内に定義されているタスクを実行するか、またはすべてのタスクのヘルプ文を表示します。

extern crate clap;
extern crate regex;

use clap::{Arg, App};
use regex::Regex;
use std::process::Command;
use std::{fs, env};

fn main() {
    let matches = App::new("Task Runner")
                          .version("0.1.0")
                          .author("Your Name")
                          .about("A command line task runner")
                          .arg(Arg::with_name("task")
                               .short("t")
                               .long("task")
                               .value_name("TASK_NAME")
                               .help("Runs the specified task")
                               .takes_value(true))
                          .get_matches();

    let file_name = "README.md";

    let content = match fs::read_to_string(file_name) {
        Ok(c) => c,
        Err(_) => {
            println!("{} not found", file_name);
            return;
        }
    };

    let regex = Regex::new(r"### (.+?)\n(.+?)```(.+?)```").unwrap();
    let mut tasks: Vec<(&str, &str)> = Vec::new();

    for capture in regex.captures_iter(&content) {
        tasks.push((&capture[1], &capture[2]));
    }

    if let Some(task_name) = matches.value_of("task") {
        for (name, command) in tasks {
            if name == task_name {
                let output = Command::new("sh")
                                     .arg("-c")
                                     .arg(command)
                                     .output()
                                     .expect("failed to execute process");

                println!("{}", String::from_utf8_lossy(&output.stdout));
                println!("{}", String::from_utf8_lossy(&output.stderr));
                return;
            }
        }

        println!("No such task found: {}", task_name);
    } else {
        for (name, help) in tasks {
            println!("{}:\t{}", name, help);
        }
    }
}

お~!それっぽいのがいきなり出てきました!

Rust プロジェクトにコード追加

ファイルの作成やら依存関係やらはさすがに手でやります。これくらいは仕事しないと。
https://crates.io/clapregex を引っ張ってきました。

cargo.toml
...
[dependencies]
clap = "4.1.8"
regex = "1.7.1"

で、ファイルを作成しテストを流してみます。

$ cargo test
   Compiling xmd v0.1.0 (/home/roomtv/works/xmd)
error[E0432]: unresolved import `clap::App`
 --> src/main.rs:1:12
  |
1 | use clap::{App, Arg};
  |            ^^^ no `App` in the root

error[E0599]: no function or associated item named `with_name` found for struct `Arg` in the current scope
  --> src/main.rs:22:19
   |
22 |         .arg(Arg::with_name("task")
   |                   ^^^^^^^^^ function or associated item not found in `Arg`

error[E0599]: no function or associated item named `with_name` found for struct `Arg` in the current scope
  --> src/main.rs:33:90
   |
33 |                 app = app.subcommand(App::new(current_task).about(current_help).arg(Arg::with_name("cmd")));
   |                                                                                          ^^^^^^^^^ function or associated item not found in `Arg`

error[E0308]: mismatched types
  --> src/main.rs:54:28
   |
54 |             current_help = format!("{}\n{}", current_help, line);
   |                            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ expected `&str`, found struct `std::string::String`
   |
   = note: this error originates in the macro `format` (in Nightly builds, run with -Z macro-backtrace for more info)

error[E0599]: no function or associated item named `with_name` found for struct `Arg` in the current scope
  --> src/main.rs:59:82
   |
59 |         app = app.subcommand(App::new(current_task).about(current_help).arg(Arg::with_name("cmd")));
   |                                                                                  ^^^^^^^^^ function or associated item not found in `Arg`

Some errors have detailed explanations: E0308, E0432, E0599.
For more information about an error, try `rustc --explain E0308`.
error: could not compile `xmd` due to 5 previous errors

まぁ、コンパイルエラー出ますよね。
clap::{App, Arg};はないので以下のように修正させます。

clap::Appは廃止されました。"use clap::{Parser, Subcommand};" を使ってプログラムを修正してください。

すると・・・

以下は修正版のプログラムです。

use std::{fs, env};
use clap::{ArgEnum, Parser, Subcommand};

#[derive(ArgEnum, Copy, Clone, Debug)]
enum Task {
    #[arg_enum(alias = "help")]
    Help,
}

#[derive(Parser, Debug)]
#[clap(name = "task-runner")]
struct Opt {
    #[clap(subcommand)]
    subcmd: Option<SubCommand>,
}

#[derive(Parser, Debug)]
enum SubCommand {
    #[clap(about = "Run a task")]
    Run {
        #[clap(possible_values(Task::VARIANTS))]
        task: Task,
    }
}

fn main() {
    let args: Vec<String> = env::args().collect();

    let opt = Opt::parse_from(args);

    match opt.subcmd {
        Some(SubCommand::Run { task }) => {
            let readme = match fs::read_to_string("README.md") {
                Ok(content) => content,
                Err(e) => {
                    eprintln!("Error reading README.md file: {:?}", e);
                    return;
                }
            };

            let blocks = readme.split("### ");

            for block in blocks {
                let lines: Vec<&str> = block.lines().collect();
                if lines.is_empty() {
                    continue;
                }

                let task_name = lines[0].trim();
                if task_name != task.to_string() {
                    continue;
                }

                let help_text: Vec<&str> = lines[1..]
                    .iter()
                    .take_while(|l| !l.trim_start().starts_with("```"))
                    .map(|l| l.trim())
                    .filter(|l| !l.is_empty())
                    .collect();

                println!("Help text for task '{}':", task_name);
                for line in help_text {
                    println!(" - {}", line);
                }

                let code: Vec<&str> = lines
                    .iter()
                    .skip_while(|l| !l.trim_start().starts_with("```"))
                    .skip(1)
                    .take_while(|l| !l.trim_start().starts_with("```"))
                    .map(|l| l.trim())
                    .filter(|l| !l.is_empty())
                    .collect();

                let code_str = code.join("\n");
                println!("Running code for task '{}':", task_name);
                println!("{}", code_str);
                if let Err(e) = run_shell_script(&code_str) {
                    eprintln!("Error running task '{}': {:?}", task_name, e);
                }
            }
        }
        None => {
            println!("Available tasks:");
            let readme = match fs::read_to_string("README.md") {
                Ok(content) => content,
                Err(e) => {
                    eprintln!("Error reading README.md file: {:?}", e);
                    return;
                }
            };

            let blocks = readme.split("### ");

            for block in blocks {
                let lines: Vec<&str> = block.lines().collect();
                if lines.is_empty() {
                    continue;
                }

                let task_name = lines[0].trim();

                println!(" - {}\t{}", task_name, lines[1].trim());
            }
        }
    }
}

fn run_shell_script(script: &str) -> std::io::Result<()> {
    let mut command = std::process::Command::new("sh");
    command.arg("-c").arg(script);
    command.output().map(|_| ())
}

この修正版のプログラムをコンパイルするとエラーが出ないはずです。

なにこの自信・・・w
で、テストすると・・・

$ cargo test
   Compiling xmd v0.1.0 (/home/roomtv/works/xmd)
error[E0432]: unresolved import `clap::ArgEnum`
 --> src/main.rs:2:12
  |
2 | use clap::{ArgEnum, Parser, Subcommand};
  |            ^^^^^^^ no `ArgEnum` in the root

error: cannot determine resolution for the derive macro `ArgEnum`
 --> src/main.rs:4:10
  |
4 | #[derive(ArgEnum, Copy, Clone, Debug)]
  |          ^^^^^^^
  |
  = note: import resolution is stuck, try simplifying macro imports

error: cannot find attribute `arg_enum` in this scope
 --> src/main.rs:6:7
  |
6 |     #[arg_enum(alias = "help")]
  |       ^^^^^^^^

error: cannot find derive macro `Parser` in this scope
  --> src/main.rs:10:10
   |
10 | #[derive(Parser, Debug)]
   |          ^^^^^^
   |
note: `Parser` is imported here, but it is only a trait, without a derive macro
  --> src/main.rs:2:21
   |
2  | use clap::{ArgEnum, Parser, Subcommand};
   |                     ^^^^^^

error: cannot find attribute `clap` in this scope
  --> src/main.rs:11:3
   |
11 | #[clap(name = "task-runner")]
   |   ^^^^
   |
   = note: `clap` is in scope, but it is a crate, not an attribute

error: cannot find attribute `clap` in this scope
  --> src/main.rs:13:7
   |
13 |     #[clap(subcommand)]
   |       ^^^^
   |
   = note: `clap` is in scope, but it is a crate, not an attribute

error: cannot find derive macro `Parser` in this scope
  --> src/main.rs:17:10
   |
17 | #[derive(Parser, Debug)]
   |          ^^^^^^
   |
note: `Parser` is imported here, but it is only a trait, without a derive macro
  --> src/main.rs:2:21
   |
2  | use clap::{ArgEnum, Parser, Subcommand};
   |                     ^^^^^^

error: cannot find attribute `clap` in this scope
  --> src/main.rs:19:7
   |
19 |     #[clap(about = "Run a task")]
   |       ^^^^
   |
   = note: `clap` is in scope, but it is a crate, not an attribute

error: cannot find attribute `clap` in this scope
  --> src/main.rs:21:11
   |
21 |         #[clap(possible_values(Task::VARIANTS))]
   |           ^^^^
   |
   = note: `clap` is in scope, but it is a crate, not an attribute

warning: unused import: `Subcommand`
 --> src/main.rs:2:29
  |
2 | use clap::{ArgEnum, Parser, Subcommand};
  |                             ^^^^^^^^^^
  |
  = note: `#[warn(unused_imports)]` on by default

error[E0599]: no function or associated item named `parse_from` found for struct `Opt` in the current scope
  --> src/main.rs:29:20
   |
12 | struct Opt {
   | ---------- function or associated item `parse_from` not found for this struct
...
29 |     let opt = Opt::parse_from(args);
   |                    ^^^^^^^^^^ function or associated item not found in `Opt`
   |
   = help: items from traits can only be used if the trait is implemented and in scope
   = note: the following trait defines an item `parse_from`, perhaps you need to implement it:
           candidate #1: `Parser`

error[E0599]: `Task` doesn't implement `std::fmt::Display`
  --> src/main.rs:50:38
   |
5  | enum Task {
   | ---------
   | |
   | method `to_string` not found for this enum
   | doesn't satisfy `Task: ToString`
   | doesn't satisfy `Task: std::fmt::Display`
...
50 |                 if task_name != task.to_string() {
   |                                      ^^^^^^^^^ `Task` cannot be formatted with the default formatter
   |
   = note: the following trait bounds were not satisfied:
           `Task: std::fmt::Display`
           which is required by `Task: ToString`
note: the following trait must be implemented
  --> /rustc/d5a82bbd26e1ad8b7401f6a718a9c57c96905483/library/core/src/fmt/mod.rs:762:1

Some errors have detailed explanations: E0432, E0599.
For more information about an error, try `rustc --explain E0432`.
warning: `xmd` (bin "xmd" test) generated 1 warning

とエラーが出ます。
まぁ、そりゃそうだろう。

トライ&エラーしすぎるとダメ

上記のエラーを打ち込んで、プログラムを修正させようとしたところ、最初の仕様を忘れてこの修正だけをするための修正例を出してきました。
ちょっと昔のオフショア開発を思い出しますね。。。バグを直すように指示したら、担当が変わって"そのバグだけ”対応してもらって何往復もしたこと。

つまり最初の指示に立ち返って文言を修正するべきでした。

あと -f で README.md 以外も指定させたいな、と。

以下が修正後のプロンプロです。

あなたは優秀なプログラマーです。
以下の仕様をもとにRust言語のテストコードを作成してください。
また、仕様を満たしあなたが出力したテストコードを通過するプログラムをRust言語で作成してください。

## 仕様
### 前提条件
- タスクランナーのコマンドラインツール

### 動作
- 同一ディレクトリ内の README.md という Markdown 形式のファイルを読み込む。
- "```" で囲まれた箇所をコードブロックとする。
- "###" で始まり次の "###"の間でコードブロックを含む範囲をタスク定義ブロックとする。 
- タスク定義ブロックの "### "以降の文字列をタスク名とする。 
- タスク定義ブロックの "### "の次の行からコードブロックまでの間の行はヘルプ文とする
- タスク名が渡されたらタスク定義ブロック内のコードブロックをシェルスクリプトとして実行する
- タスク名が渡されなければ、すべての"タスク名 タブ ヘルプ文"を出力する
- README.md というファイルがなければ "README.md がありません"と出力する
- `-f` オプションで README.md 以外のMarkdownファイルも指定できる

なお出力形式は以下とします。
テストコードは同値分割法(Equivalence Partitioning Testing)と境界値分析(Boundary value analysis)を用いてください。

## 出力形式
- 概要
- 短い実装コード

私たちがこのタスクで最高の結果を出すためにステップバイステップで考えてください。

それでは再度、流してみます。

以下は、仕様を満たすためのRustのテストコードです。

use std::env;
use std::fs::File;
use std::io::{BufRead, BufReader};

#[test]
fn test_task_runner() {
    // README.mdのパスを取得する
    let args: Vec<String> = env::args().collect();
    let md_file_path = if let Some(path) = args.get(1) {
        path
    } else {
        "README.md"
    };
    let file = match File::open(md_file_path) {
        Ok(file) => file,
        Err(_) => {
            println!("{} がありません", md_file_path);
            return;
        }
    };
    let reader = BufReader::new(file);
    let mut tasks: Vec<(String, String, String)> = Vec::new();
    let mut task_name = String::new();
    let mut task_help = String::new();
    let mut task_code = String::new();
    let mut in_task_def = false;
    for line in reader.lines() {
        let line = line.unwrap();
        if line.starts_with("### ") {
            if in_task_def {
                tasks.push((task_name, task_help, task_code));
                task_name = String::new();
                task_help = String::new();
                task_code = String::new();
            }
            task_name = line[4..].to_string();
            in_task_def = true;
        } else if in_task_def {
            if line.starts_with("```") {
                task_code = task_code.trim_start().to_string();
                in_task_def = false;
            } else {
                if task_help.is_empty() {
                    task_help = line.to_string();
                } else {
                    task_help = task_help + "\n" + &line;
                }
            }
        }
    }
    if in_task_def {
        tasks.push((task_name, task_help, task_code));
    }

    // タスク名に応じてシェルスクリプトを実行する
    let args: Vec<String> = env::args().collect();
    if args.len() > 2 && args[2] == "-f" {
        for (i, task) in tasks.iter().enumerate() {
            println!("タスク{}: {}\t{}", i+1, task.0, task.1);
        }
    } else {
        let task_name = if let Some(name) = args.get(1) {
            name
        } else {
            for task in tasks.iter() {
                println!("{} \t {}", task.0, task.1);
            }
            return;
        };
        let task = tasks.iter().find(|t| t.0 == *task_name);
        if let Some(task) = task {
            println!("{} を実行します", task.0);
            let output = std::process::Command::new("sh")
                .arg("-c")
                .arg(&task.2)
                .output()
                .expect("failed to execute process");
            println!("{}", String::from_utf8_lossy(&output.stdout));
            println!("{}", String::from_utf8_lossy(&output.stderr));
            assert!(output.status.success());
        } else {
            println!("{} というタスクはありません", task_name);
        }
    }
}

短い実装コードを作成してください。

以下は、仕様に基づいて実装されたRustの簡単な実装例です。

use std::env;
use std::fs::File;
use std::io::{BufRead, BufReader};

fn main() {
    let args: Vec<String> = env::args().collect();
    let filename = match args.get(2) {
        Some(file) => file,
        None => "README.md",
    };
    let file = match File::open(filename) {
        Ok(file) => file,
        Err(_) => {
            eprintln!("{} がありません", filename);
            return;
        }
    };
    let reader = BufReader::new(file);
    let mut tasks: Vec<(String, String, String)> = Vec::new();
    let mut current_task: Option<(String, String, String)> = None;
    for line in reader.lines() {
        let line = line.unwrap();
        if line.starts_with("###") {
            if let Some(task) = current_task {
                tasks.push(task);
            }
            let task_name = line[4..].trim().to_owned();
            current_task = Some((task_name, String::new(), String::new()));
        } else if let Some(task) = current_task.as_mut() {
            if line.starts_with("```") {
                let code_block = task.2.clone();
                task.1.push_str(&format!("{}\t{}\n", task.0, code_block));
                task.2.clear();
            } else {
                task.2.push_str(&format!("{}\n", line));
            }
        }
    }
    if let Some(task) = current_task {
        tasks.push(task);
    }
    if let Some(task_name) = args.get(1) {
        if let Some(task) = tasks.iter().find(|(name, _, _)| name == task_name) {
            let (_, _, code_block) = task;
            println!("{}", code_block.trim());
        } else {
            eprintln!("{} というタスクはありません", task_name);
            return;
        }
    } else {
        for (name, help, _) in tasks {
            println!("{}\t{}", name, help.trim());
        }
    }
}

テストケースは一番最初に生成したものをそのまま使っています。
main.rs は最後に生成させたものをコピペしただけです。

$ cargo test
   Compiling xmd v0.1.0 (/home/roomtv/works/xmd)
    Finished test [unoptimized + debuginfo] target(s) in 0.39s
     Running unittests src/main.rs (target/debug/deps/xmd-428818dfc198b539)

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

テスト、通ったね。。。

試しに以下のようなREADME.mdファイルを作りました。

# Executable Markdown

これは Markdown を実行可能にする革新的なツールです


## タスクリスト

### test

Rust test を実行します。

‘‘‘
 cargo test
‘‘‘

### echo

Echo "hello world!"

‘‘‘
 echo "hello world!"
‘‘‘

んで、cargo runしてみる

$ cargo run
    Finished dev [unoptimized + debuginfo] target(s) in 0.02s
     Running `target/debug/xmd`
test    test
Rust test を実行します。


test    cargo test
echo    echo
Echo "hello world!"


echo    echo "hello world!"

お~、違うけどいいセン行ってる!w

引き続きもうちょい指示を変えつつ試行錯誤してみたいと思います。

もう少しまともに動くようになった成果物は以下のリポジトリに挙げております。
https://github.com/asopitech/xmd

本日はこのくらいにしたいと思います。
ありがとうございました。

Discussion