🤖

LinuxコマンドをRustで再現してみた!~AIによる再現!これこそ現代の力!~

に公開

はじめに

はい。。前回から2週間ほど経ってしまいました。。。
意気込みを語り、、【Linuxコマンド同等のものをRustで作る!】と宣言していました!
で、あれからすぐにGemini CLIに頼り、、 「あれ?これで良いんだっけ?」 となりまして。
その後もGeminiと話しているうちに、Git管理?ナニソレオイシイノ?
ワタシ、バックアップファイルヲサクセイシテタヨ。となったり、
GitHubへアップロード?なんか、めんどくさい。。という風にやる気が落ちたりしてました!
簡潔に言います!
自分が知らないことを今からやるのは嫌だ! って感じですね。。笑
エンジニアとしてどうなんだ?という意見は真正面から受け止めます!

ですが!AIに作ってもらったとしても、自分で作ろうとしたものではありますし!
今回はAIだけど、次こそは調べて作る!という風に思い始めたので更新することにしました!
前回の意気込みを語っているのはこちらになりますので、
もし見ていない方がいらっしゃったら併せて見てもらえると嬉しいです!
https://zenn.dev/maromaron88/articles/9aca8520cb79f0

では、AIに作ってもらったコーディング内容を紹介します!

Linuxコマンド再現

今回再現したコマンドは echo, cat, ls の3つになります!
オプション部分まで完全再現したわけではないですが、、
基本的な動作はする状態になってます!

GitHubアップロードしてないので、ここに直接貼り付けてます!
全文表示になっているので長くなってます。申し訳ないです。。

echo

・Config.toml

[package]
name = "rust_app_echo_command"
version = "0.1.0"
edition = "2024"

[dependencies]
clap = { version = "4.5.40", features = ["derive"] }
shellexpand = "3.1.1"

・main.rs
1.必要なクレートの導入とCLI引数の定義

use clap::Parser;
use std::borrow::Cow;

/// A Rust-based echo command with shell expansion and -n option.
#[derive(Parser)]
#[command(name = "echo", version, about, long_about = None)]
struct Cli {
    /// Do not output the trailing newline
    #[arg(short)]
    n: bool,

    /// The text to echo
    #[arg(name = "TEXT")]
    text: Vec<String>,
}

2.引数解析と文字列出力

fn main() {
    let cli = Cli::parse();
    let expanded_args = expand_shell_variables(&cli.text);
    let output = expanded_args.join(" ");
    print_output(&output, cli.n);
}

3.シェル関数展開

/// Expands shell variables in the given arguments.
fn expand_shell_variables(args: &[String]) -> Vec<String> {
    args.iter()
        .map(|arg| {
            shellexpand::full(arg)
                .unwrap_or_else(|e| {
                    eprintln!("shellexpand error: {}", e);
                    Cow::from(arg.as_str())
                })
                .into_owned()
        })
        .collect()
}

4.出力ロジック

/// Prints the final output string.
fn print_output(output: &str, no_newline: bool) {
    if no_newline {
        print!("{}", output);
    } else {
        println!("{}", output);
    }
}

cat

・Cargo.toml

[package]
name = "rust_app_cat_command"
version = "0.1.0"
edition = "2024"

[dependencies]
clap = { version = "4.5.40", features = ["derive"] }
colored = "3.0.0"

・main.rs
1.必要なクレートの導入とCLI引数の定義

use anyhow::{Context, Result};
use clap::Parser;
use colored::*;
use std::fs::File;
use std::io::{BufRead, BufReader};

/// A Rust-based cat command with color, -n, and -b options.
#[derive(Parser)]
#[command(name = "cat", version, about, long_about = None)]
struct Cli {
    /// Number all output lines
    #[arg(short, long, conflicts_with = "number_nonblank")]
    number: bool,

    /// Number only non-empty output lines
    #[arg(short = 'b', long = "number-nonblank")]
    number_nonblank: bool,

    /// Files to concatenate
    #[arg(name = "FILE")]
    files: Vec<String>,
}

2.引数処理と各ファイル内容展開

fn main() -> Result<()> {
    let cli = Cli::parse();
    let mut line_number = 1;

    for filename in &cli.files {
        process_file(filename, &cli, &mut line_number)?;
    }

    Ok(())
}

3.ファイル内容読み込みと標準出力内容準備

fn process_file(filename: &str, cli: &Cli, line_number: &mut usize) -> Result<()> {
    let file = File::open(filename).with_context(|| format!("Failed to open file: {}", filename))?;
    let reader = BufReader::new(file);

    for line in reader.lines() {
        let line_content = line.with_context(|| format!("Failed to read line from file: {}", filename))?;

        let show_number = cli.number || (cli.number_nonblank && !line_content.is_empty());

        if show_number {
            print!("{:>6}\t", line_number);
            *line_number += 1;
        }

        if line_content.trim().starts_with('#') {
            // Comment line: gray
            println!("{}", line_content.truecolor(128, 128, 128));
        } else {
            // Normal line: light blue
            println!("{}", line_content.bright_cyan());
        }
    }
    Ok(())
}

ls

・Cargo.toml

[package]
name = "rust_app_ls_command"
version = "0.1.0"
edition = "2021"

[dependencies]
clap = { version = "4.0", features = ["derive"] }
chrono = "0.4"
users = "0.11"

・main.rs
1.必要なクレートの導入とCLI引数の定義

use clap::Parser;
use std::fs;
use std::path::Path;
use std::os::unix::fs::{FileTypeExt, MetadataExt, PermissionsExt};
use users::{get_user_by_uid, get_group_by_gid};
use chrono::{DateTime, Local};

#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
struct Args {
    /// List files recursively
    #[arg(short = 'R', long)]
    recursive: bool,

    /// Show detailed information
    #[arg(short = 'l', long)]
    long: bool,

    /// List directories themselves, not their contents
    #[arg(short = 'd', long)]
    directory: bool,

    /// File or directory paths to list
    #[arg(default_value = ".")]
    paths: Vec<String>,
}

2.引数に応じたファイル/ディレクトリ情報出力

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

    for path_str in &args.paths {
        let path = Path::new(path_str);
        if args.directory {
            // -d オプション: パス自体の情報を表示
            list_path(path, &args, 0);
        } else if path.is_dir() {
            // パスがディレクトリの場合
            list_directory(path, &args, 0);
        } else {
            // パスがファイルの場合
            list_path(path, &args, 0);
        }
    }
}

3.サブディレクトリ探索処理(-R オプション対応)

fn list_directory(path: &Path, args: &Args, depth: usize) {
    if depth > 0 && !args.recursive {
        return;
    }

    match fs::read_dir(path) {
        Ok(entries) => {
            if args.recursive && depth > 0 {
                 println!("\n{}:", path.display());
            }
            for entry_result in entries {
                match entry_result {
                    Ok(entry) => {
                        let entry_path = entry.path();
                        list_path(&entry_path, args, depth);
                        if args.recursive && entry_path.is_dir() {
                            list_directory(&entry_path, args, depth + 1);
                        }
                    }
                    Err(e) => eprintln!("エラー: {}: '{}'", e, path.display()),
                }
            }
        }
        Err(e) => eprintln!("ディレクトリ '{}' の読み込みに失敗しました: {}", path.display(), e),
    }
}

4.詳細情報表示処理(-l オプション対応)

fn list_path(path: &Path, args: &Args, _depth: usize) {
    match fs::metadata(path) {
        Ok(metadata) => {
            if args.long {
                // -l オプション: 詳細表示
                print_long_format(path, &metadata);
            } else {
                // シンプルな表示
                println!("{}", path.file_name().unwrap_or_default().to_string_lossy());
            }
        }
        Err(e) => eprintln!("メタデータの取得に失敗しました: {}: '{}'", e, path.display()),
    }
}

5.詳細情報表示のためのフォーマット整理

fn print_long_format(path: &Path, metadata: &fs::Metadata) {
    // 1. File type and permissions
    let perms = metadata.permissions();
    let mode = perms.mode();
    print!("{}", get_file_type_char(metadata.file_type()));
    print_permissions(mode);

    // 2. Number of hard links
    print!("{:>4}", metadata.nlink());

    // 3. Owner name
    let owner = get_user_by_uid(metadata.uid()).map(|u| u.name().to_string_lossy().into_owned()).unwrap_or_else(|| metadata.uid().to_string());
    print!("{:>10}", owner);

    // 4. Group name
    let group = get_group_by_gid(metadata.gid()).map(|g| g.name().to_string_lossy().into_owned()).unwrap_or_else(|| metadata.gid().to_string());
    print!("{:>10}", group);

    // 5. File size
    print!("{:>8}", metadata.len());

    // 6. Last modification time
    let mtime: DateTime<Local> = DateTime::from(metadata.modified().unwrap());
    print!(" {}", mtime.format("%b %d %H:%M"));

    // 7. File name
    println!(" {}", path.file_name().unwrap_or_default().to_string_lossy());
}

6.ファイルタイプ表示内容準備(-l オプション指定時の所有者権限の左隣のやつ!)

fn get_file_type_char(file_type: fs::FileType) -> char {
    if file_type.is_dir() { 'd' }
    else if file_type.is_symlink() { 'l' }
    else if file_type.is_socket() { 's' }
    else if file_type.is_block_device() { 'b' }
    else if file_type.is_char_device() { 'c' }
    else if file_type.is_fifo() { 'p' }
    else { '-' }
}

7.ファイルパーミッション表示内容準備

fn print_permissions(mode: u32) {
    print!("{}", if (mode & 0o400) != 0 { 'r' } else { '-' });
    print!("{}", if (mode & 0o200) != 0 { 'w' } else { '-' });
    print!("{}", if (mode & 0o100) != 0 { 'x' } else { '-' });
    print!("{}", if (mode & 0o040) != 0 { 'r' } else { '-' });
    print!("{}", if (mode & 0o020) != 0 { 'w' } else { '-' });
    print!("{}", if (mode & 0o010) != 0 { 'x' } else { '-' });
    print!("{}", if (mode & 0o004) != 0 { 'r' } else { '-' });
    print!("{}", if (mode & 0o002) != 0 { 'w' } else { '-' });
    print!("{}", if (mode & 0o001) != 0 { 'x' } else { '-' });
}

Gemini CLI にコーディングを頼んだ感想!

今回もGemini CLIにコーディングを頼みました!
まず初めに【echo】コマンドを再現することから始めました!
「Rustでechoコマンド再現してみたいんだ!」とGemini CLIに伝えると、
「main.rsを〇〇の内容に変えます」と提示され、OKを選択すると表示してくれた内容の通りに
書き換えてくれました!
その後のビルドもコンパイルもお願いすると実行してくれて、
コンパイルエラーが発生した場合にはエラー箇所特定と修正案の提示をしてくれました!
修正と再コンパイルをお願いすると問題なくコンパイルが完了し、
実際に実行してみると【echo】コマンドのように引数で与えた文字列を表示できました!!

この動作に感動しました!
自分が実現したいことを一回伝えるだけで、プロトタイプを作成してくれて、、
そこから色々質問してみたり、要望を伝えることで細かく修正してくれました!
エラー対応も実施してもらえたので、困り果てることもありませんでした!

ですが、、、

AI-CLIツールの難しいところ。。

Ai-CLI と書きましたが、私はGemini CLIしか使ったことないので、
あくまでも「Gemini CLIを使ってみて難しいと思ったところ」という内容になります。
もし他のツールでは違うとか、ここが優れてるとかあるようでしたら
コメントで教えてもらえると嬉しいです!!

Gemini CLIは対話を通じてコードを生成してくれる一方で、
扱いにくいと感じる点がありました。。

それは、すぐにコード生成に走ってしまうところです!

おいおい。と、そういうツールだろう。という声が聞こえてきそうですが、
私にとってはこの点が扱いにくいと思った点です!
CLIツール初心者の意見として見てもらえればと思います。

走るの早くて追いつけない!!

↑でお話ししたように「lsコマンド再現したい!」と伝えると、すぐに生成してくれるのですが。。
「〇〇オプション対応させたり、色付けしてみたいと考えてる」
という風に伝えると、それらを実現するためのコードを生成してくれちゃいます!
こちらは話しかけているだけのつもりでも、Gemini CLIは話された内容を
生成する対象と認識して次の動作に走り始めるんですよね。
これは私がGemini(CLIじゃないやつ)を利用するときと同じように使っていることが
そもそもの問題の可能性もあるのですが。。。

私は対話というか会話というか、、AIと一緒に作り上げたかったんですよ!

少し工夫してみたけど。。。

話し合って進めていきたいと思ったので、こんな風に伝えてみました!

  1. Rust学習中で構造とか色々分かってないことが多い
  2. すぐにコード生成しないで、提案してほしい
  3. 〇〇の機能を付けたい、みたいな要望が来たときには、その機能に近しいパーツを渡してほしい

こんな感じで伝えてみたところ、Gemini CLIは「わかりました。」と言ってくれたんです!
なので、これで思い通りに使える!と思っていました。
ですが、、こちらの考えを先読みするAIはやっぱりコード生成に走るのでした。

GUIツールとCLIツールの違い

AIを活用したコーディングを進めていくにあたり、GUI/CLIの違いを理解することが
今後の開発において重要だと感じました!
そこで、GeminiにGUI(Gemini)とCLI(Gemini CLI)の違いについて聞いてみました!
以下、Geminiより。。

Gemini (Web版): 基本的にはブラウザのサンドボックス内で動作し、ローカルのファイルシステムには直接アクセスできません。生成されたコードをコピー&ペーストする必要があるなど、手動での作業が発生します。

Gemini CLI: あなたが体験したように、PC上のファイル(main.rs など)を直接読み込んだり、変更したり、新しいファイルを生成したりできます。これは、AIがローカル環境と連携して具体的な作業を実行できるという大きな強みになります。CLIなので、シェルスクリプトなどと組み合わせて、一連の作業を自動化することも非常に得意です。

どちらが良いか?
どちらが優れているというわけではなく、目的とユーザーのスキルセットによって適材適所です。

GUI: 初めてAIを使う、アイデア出しや簡単な質問応答、視覚的なフィードバックが欲しい、プログラミング知識が少ない場合。

CLI: プログラミングやシステム管理の経験がある、開発作業にAIを組み込みたい、繰り返し作業を自動化したい、ローカルファイルと連携させたい場合。

Geminiの回答を確認したことにより、自分に合ったツールがどちらなのか判明しました!!

自分に合っているのはGUI?CLI?

Geminiに慣れていた私はGemini CLIの動作に驚きました!
伝えたことをすぐに実ファイルに対して生成してくれる便利さを感じ、
会話ではなく生成に重きを置いた動作に困惑していました。

先ほどお伝えしたGeminiからのGUI / CLI比較で私は納得しました!
そして今の自分に合っているのがどちらなのか考えた結果、
Geminiが私に合っている という結論に至りました!

理由としては、、

  1. 自分の理解力はまだ低いため、すぐに生成されてもOKとして言えない
  2. まずは自分のRustコーディング力を上げるのが先だと感じた
  3. そもそも、自分がおしゃべりな方なので会話しながら利用する方が楽しい!
    になりました!
    もう少し地力をつけてからCLIを活用していこうと思ってます!

次に作りたいもの!

echo, cat, ls という標準出力系コマンドをGemini CLIに作ってもらいました!
次は自力で作成進めて困ったらGeminiにサポートしてもらう形で進めます!

そして、次に作成してみたいと思うコマンドは、、、
grep, cp, rm です!

ファイル操作系のコマンドをなるべく自力で作成してみます!
また作成出来たら記事として残したいと思ってます!

おわりに

今回はGemini CLIによるコード生成とGUI/CLIツールの違いについてお話ししました!
最近いろいろなCLIツールがリリースされたり、バージョンアップされてますよね!
それらを実際に試して比較してみたとか、〇〇と××を活用して自動△△を~~~
みたいな記事をよく見ますが、、、私の理解は追いつかないです!笑

今回私はGUIツール利用が今の自分に合っていると認識しました!
実現したいものが明確な人や早くコーディングしてデバッグしたい人、などは
CLIツール利用が向いているんだなと思いました!
ゆくゆくはCLIツールも活用してみたいなと思いながら、
Linuxコマンド再現を頑張りたいと思います!!

ではまた!

Discussion