[2023年3月版]Chat GPTに'xc'っぽいツールをRustで作成させてみた
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
はい完了です。
プロンプト開始 (エンカウンター!)
続いて、以下のページを参考に
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/ で clap
と regex
を引っ張ってきました。
...
[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
引き続きもうちょい指示を変えつつ試行錯誤してみたいと思います。
もう少しまともに動くようになった成果物は以下のリポジトリに挙げております。
本日はこのくらいにしたいと思います。
ありがとうございました。
Discussion