🛺

『ダウンロードしたファイルを現在のフォルダーに移動させるbatファイル』をRustで書き直す

2025/03/04に公開

以前このような記事を出しました。
https://zenn.dev/yuhi_ili/articles/62c87d5558b0bc
これをRustに書き換えます。

ただ、batをRustに書き直すのは面倒くさいので、batファイルをCahtGPTに投げて出力してもらいました。
難読コードみたいな感じでしたがしっかりと解読してくれました。人工知能ってすごい。

人工知能が出力したコード

use std::env;
use std::fs;
use std::path::PathBuf;
use std::process;

fn main() {
    let args: Vec<String> = env::args().collect();
    let downloads_path = dirs::download_dir().expect("Failed to get Downloads directory");
    
    if args.len() > 1 {
        let input = &args[1];
        
        if input == "dir" {
            list_files(&downloads_path);
            return;
        }
        
        match input.parse::<i32>() {
            Ok(num) => move_files(downloads_path, num),
            Err(_) => {
                eprintln!("Invalid input: Please enter a number or 'dir'");
                process::exit(1);
            }
        }
    } else {
        move_files(downloads_path, 1);
    }
}

fn list_files(downloads_path: &PathBuf) {
    let mut files: Vec<PathBuf> = match fs::read_dir(downloads_path) {
        Ok(entries) => entries.filter_map(Result::ok).map(|e| e.path()).collect(),
        Err(_) => {
            eprintln!("Failed to read directory");
            process::exit(1);
        }
    };
    
    files.sort_by_key(|path| path.metadata().unwrap().modified().unwrap());
    files.reverse(); // 最新のファイルが先に来るようにする
    
    for file in files {
        println!("{}", file.display());
    }
}

fn move_files(downloads_path: PathBuf, count: i32) {
    let mut files: Vec<PathBuf> = match fs::read_dir(&downloads_path) {
        Ok(entries) => entries.filter_map(Result::ok).map(|e| e.path()).collect(),
        Err(_) => {
            eprintln!("Failed to read directory");
            process::exit(1);
        }
    };
    
    files.sort_by_key(|path| path.metadata().unwrap().modified().unwrap());
    if count >= 0 {
        files.reverse(); // count >= 0 の場合、新しい順に並べる
    }
    
    let move_count = if count == 0 { files.len() } else { count.abs() as usize };
    let current_dir = env::current_dir().expect("Failed to get current directory");
    
    for file in files.into_iter().take(move_count) {
        let destination = current_dir.join(file.file_name().unwrap());
        if let Err(e) = fs::rename(&file, &destination) {
            eprintln!("Failed to move {:?} to {:?}: {}", file, destination, e);
        }
    }
}

このままでも使えますが使いやすくするため少しだけ修正しましょう。

修正後のコード

コードの変更などをいろいろ加えて最終的にはこのようになりました。

use std::env::{args,current_dir};
use std::fs::{read_dir,rename};
use std::io::Error;
use std::process::ExitCode;
use std::path::PathBuf;
use dirs::download_dir;
use crossterm::event::{self,Event};

fn main() -> ExitCode{
    let args: Vec<String> = args().collect();
    let downloads_path = download_dir().expect("Failed to get Downloads Directory");
    
    if args.len() > 1 {
        let input = &args[1];
        
        if input == "dir" {
            list_files(downloads_path);
            return ExitCode::SUCCESS;
        }
        
        match input.parse::<i32>() {
            Ok(num) => move_files(downloads_path, num),
            Err(_) => {
                eprintln!("Invalid input: Please enter a number or 'dir'");
                return ExitCode::FAILURE;
            }
        }
    } else {
        move_files(downloads_path, 1);
    }
    return ExitCode::SUCCESS;
}

fn sort_metadata_modified(directory_path:PathBuf, reverse_modified:bool) -> Vec<PathBuf>{
    let mut files: Vec<PathBuf> = match read_dir(&directory_path) {
        Ok(entries) => entries.filter_map(Result::ok).map(|e| e.path()).collect(),
        Err(_) => {
            eprintln!("Failed to read directory : {}",directory_path.display());
            return vec![];
        }
    };
    files.sort_by_key(|path| path.metadata().ok().and_then(|path| path.modified().ok()));
    if reverse_modified { files.reverse() } // 最新のファイルに並び替える
    return files;
}

fn list_files(directory_path:PathBuf) {
    let files = sort_metadata_modified(directory_path,true);
    for (index,file) in files.iter().enumerate() {
        println!("[{:>width$}] {}",index + 1,
            file.file_name().unwrap().to_string_lossy(),
            width = files.len().to_string().len());
    }
    pause().expect("Failed to input Key");
}

fn move_files(downloads_path: PathBuf, count: i32) {
    let files = sort_metadata_modified(downloads_path,if 0 <= count {true} else {false});
    let move_count = if count == 0 { files.len() } else { count.abs() as usize };
    let current_dir = current_dir().expect("Failed to get Current Directory");
    
    for file in files.into_iter().take(move_count) {
        let destination = current_dir.join(file.file_name().unwrap());
        if let Err(e) = rename(&file, &destination) {
            eprintln!("Failed to move {:?} to {:?}: {}", file, destination, e);
        }
    }

    println!("{}個移動しました。",move_count);
}

fn pause() -> Result<(), Error> {
    println!("続行するには何かキーを押してください...");
    loop {
        if event::poll(std::time::Duration::from_millis(500))? {
            if let Event::Key(_) = event::read()? {
                break;
            }
        }
    }
    Ok(())
}

コード解説

一応解説していきましょうか。

関数はmainsort_metadata_modifiedlist_filesmove_filespauseの五つです。リストにしましょう。

  • sort_metadata_modified:ダウンロードディレクトリのファイルを更新日順に並び替えてVec<PathBuf>で返す関数です。
  • list_files:ダウンロードディレクトリのファイルを表示する関数です。
  • move_files:ダウンロードディレクトリからファイルを指定数移動させる関数です。
  • pauseはキーが入力されるまで一時停止する関数です。

ライブラリの読み込み

use std::env::{args,current_dir};
use std::fs::{read_dir,rename};
use std::io::Error;
use std::process::ExitCode;
use std::path::PathBuf;
use dirs::download_dir;

ライブラリの読み込みを行います。
Cargo.tomlの[dependencies]に書くのはdirs = "*"だけで問題ありません。
ここにuse crossterm::event::{self,Event};がありませんが、これはuse std::io::stdin;のどちらを使うかということなので、pause関数の欄を読んでください。

main関数

    let args: Vec<String> = args().collect();
    let downloads_path = download_dir().expect("Failed to get Downloads Directory");

まず、引数をargsに格納し、次にdirs::download_dir()でダウンロードディレクトリのパスをdownloads_pathに代入します。

    if args.len() > 1 {
        # コードを入力
    } else {
        move_files(downloads_path, 1);
    }
    return ExitCode::SUCCESS;

引数が一つでも入っていればifにあるコードを実行し、引数がなければelseにあるmove_files(downloads_path, 1)を実行します。
処理が終わったらExitCode::SUCCESS;を返してやります。ExitCodeを使わないならなくてもいいんですが。

ifの中身

        let input = &args[1];

引数の値を渡したargsの一番目の値をinputに渡します(ゼロ番目の値はプログラム名が入っています)。

        if input == "dir" {
            list_files(downloads_path);
            return ExitCode::SUCCESS;
        }

引数に入っているのがdirであればlist_files(downloads_path)を実行します。

        match input.parse::<i32>() {
            Ok(num) => move_files(downloads_path, num),
            Err(_) => {
                eprintln!("Invalid input: Please enter a number or 'dir'");
                return ExitCode::FAILURE;
            }
        }

matchinputがInt型であるかを判定し、Int型であるならmove_files(downloads_path, num)を実行します。
Int型ではないなら、エラー文を出して強制的に終了させます。eprintln!で標準エラーとして出力できますね。
match文はErr(_) => {}で指定した例外以外すべての例外をキャッチしてくれるので楽ですね。
ちなみにChatGPTが出したprocess::exit(1)ですが、デストラクタが発生しません。なので、代わりにExitCodeを使っています。小さいプログラムなのと九割九分エラーが起きないのでprocess::exit(1)でいいんですが。
https://qiita.com/Yuki_Oshima/items/cc6c663a39b797b6bf24

Note that because this function never returns, and that it terminates the process, no destructors on the current stack or any other thread’s stack will be run. If a clean shutdown is needed it is recommended to only call this function at a known point where there are no more destructors left to run; or, preferably, simply return a type implementing Termination (such as ExitCode or Result) from the main function and avoid this function altogether:[1]

sort_metadata_modified関数

指定したフォルダ内にあるファイルを更新日順にVec<PathBuf>として返す関数です。

fn sort_metadata_modified(directory_path: PathBuf, reverse_modified:bool) -> Vec<PathBuf>

PathBufのdirectory_pathとboolのreverse_modifiedを引数にして、Vec<PathBuf>を返します。
デフォルト引数を使いたかったのですが、Rustだとこの機能無いんですよね・・・。

    let mut files: Vec<PathBuf> = match read_dir(&directory_path) {
        Ok(entries) => entries.filter_map(Result::ok).map(|e| e.path()).collect(),
        Err(_) => {
            eprintln!("Failed to read directory : {}",directory_path.display());
            return vec![];
        }
    };

let mut : Vec<PathBuf> = match read_dir(&downloads_path)でダウンロードフォルダが存在するかを確認して読み込みを行います。
OKである場合は、Ok(entries) => entries.filter_map(Result::ok).map(|e| e.path()).collect(), を走らせます。
entriseという配列を作り(厳密にはstd::fs::ReadDirとなり、into_iter()でイテレータとしてファイルのパス名を逐次取得できる).filter_map(Result::ok)で読み取りに失敗したファイルを除外します。
その後、.map(|e| e.path()).collect()PathBufの配列としてfilesに格納します。
Err(フォルダーが存在しない)なら、"Failed to read directory : {}",directory_path.display()を標準エラーとして出力し空のVecを返します。
ちなみにChatGPTに説明させたら分かりやすい内容が出ました。説明全部任せて良さそうな気がしてきた。[2]

  • fs::read_dir(downloads_path)downloads_path 内のファイル/フォルダを取得。
  • .filter_map(Result::ok) → 読み取りに失敗したものを除外。
  • .map(|e| e.path()).collect()PathBuf のリストとして files に格納。
    files.sort_by_key(|path| path.metadata().ok().and_then(|m| m.modified().ok()));

面倒くさいのでChatGPTでリスト出力しました。

  • .sort_by_key(...)files更新日時(modified())の昇順(古いものが先) でソートする。
  • path.metadata().ok()metadata() 取得に失敗した場合は None を返す。
  • .and_then(|m| m.modified().ok())modified() 取得に失敗した場合も None を返す。
  • 失敗した場合(None)はデフォルトの並び順のままになる。

まず、sort_by_key()は括弧の中にある比較関数元にして入れ替える関数です。そのため、ファイルのメタデータにある更新日を扱い判定用にする必要があります。
なので、.metadata().ok()でファイルのメタデータを抽出しmodified().ok()で最終更新日を取得できるようにします(合っているはず)。
ちなみにand_thenmapと似ています(実際mapに変えても動く)が、値の変換ではなくくOption<T>を返したいのでこちらを使うのがよいです。
実際、mapを使うとOption<Option<SystemTime>> となりsort_by_key()に渡して使うのは面倒くさいです。なので、and_thenOption<SystemTime>にするのが効率がいいです。
ちなみにこんなことしなくてもこれで動きます。

    files.sort_by_key(|path| path.metadata().unwrap().modified().unwrap());

ただ、こちらの場合、unwrap()を使っているためエラーが起きるとNoneを返さずクラッシュします。
どちらがいいのかはご自由に。

    if reverse_modified { files.reverse(); } // 最新のファイルに並び替える
    return files;

filesは先程のソートができていれば古い順に並んでいるので、.reverse()で反転させてやると新しい順に並び替えられます。
そのため、reverse_modifiedでTrueかFalseかを判定しています。
ちなみにfiles.reverse()はセミコロンがなくても動きます。

    if reverse_modified { files.reverse() }

Rustが持つif文の特徴ですね。

    return files;

filesを戻り値として返すコードです。Rustの規格上こちらでもできます

    files

C言語系統をやっているとreturnがないと違和感がありますね。

これでファイルの並びを更新順に並び替えるコードが完成しました。

list_files関数

ダウンロードフォルダにあるファイルを読み込んでリストで出力するlist_files関数です。

fn list_files(directory_path:PathBuf)

PathBufのdirectory_pathを引数としています。
フォルダ内のファイルをリストにして出力するだけなので値は返さなくていいです。

    let files = sort_metadata_modified(directory_path,true);

sort_metadata_modified関数を呼び出してfilesに配列を入れます。

    for (index,file) in files.iter().enumerate() {
        println!("[{}]{}",index + 1, file.file_name().unwrap().to_string_lossy());
    }

filesを.iter()してイテレータにします。これでfilesにあるオブジェクトを一つずつ取り出すことができます。また、.enumerate()で順番に数字を出してくれます。
println!()の中にあるfile.file_name().unwrap().to_string_lossy()ですが、file_name()はPathBufにあるファイル名を取り出します。そのとき、Option<&OnStr>になるのでto_string_lossy()でStringに変換する変換する必要があるわけです。
to_str()でもいいですが、UTF-8じゃなかったらエラーが起きてパニックします。滅多に起きないとは思うが・・・。

また、println!()の中をこのようにすることで数値をfilesに格納されている桁数分右寄せできます。

    for (index,file) in files.iter().enumerate() {
        println!("[{:>width$}] {}",index + 1,
            file.file_name().unwrap().to_string_lossy(),
            width = files.len().to_string().len());
    }

files.len().to_string().len();で数値を文字数にして長さを調べるので、これで桁数が分かります。
そのため[{}]{:>widht$}にしてfile.file_name().unwrap().to_string_lossy()の後ろにwidht = files.len().to_string().len());を入れます。

pause();

最後にキーボードからの入力を待つ関数です。pause関数を読んでください。

move_file関数

ダウンロードフォルダから指定数ファイルを移動する関数です。

fn move_files(downloads_path: PathBuf, count: i32)

PathBufのdownloads_pathとInt32型のcountを引数にしています。
こちらも指定したフォルダ内のファイルをカレントフォルダに移動させるだけなので戻り値はありません。

    let files = sort_metadata_modified(downloads_path,if 0 <= count {true} else {false});

もともとlist_files関数とmove_file関数に同じコードがありましたが、sort_metadata_modified関数に分離しました。こっちのほうがいいですね。
if 0 <= count {true} else {false}でcountの正負により古い順か新しい順かを変えられます。

    let move_count = if count == 0 { files.len() } else { count.abs() as usize };

countが0の場合、filesに格納されている数値を代入します。でなければcountを正の数値に変えてmove_countに代入します。
dm 0はdm.batのバグもとい仕様です。
そもそもバッチファイルは数字を入力するところを文字で入力すると、フォルダにあるファイルが無くなるまで処理を続けます。0でも同じ現象が発生します。
それをdm 0として正常な動作としていたのでこのコードができました。

    let current_dir = current_dir().expect("Failed to get Current Directory");

std::envcurrent_dir()でカレントフォルダを読み込めます。

    for file in files.into_iter().take(move_count) {
        let destination = current_dir.join(file.file_name().unwrap());
        if let Err(e) = rename(&file, &destination) {
            eprintln!("Failed to move {:?} to {:?}: {}", file, destination, e);
        }
    }

このコードでファイルを指定した分移動していきます。
ひとつひとつ見ていきましょう。

for file in files.into_iter().take(move_count)

filesを.into_iter()でイテレータにします。これで回るごとに順番にfileで扱えるようになりましたが、このままではすべてのファイルを移動させることになります。そのため、.take(move_count)で回る回数を決めることができます。

        let destination = current_dir.join(file.file_name().unwrap());

カレントフォルダ+ファイル名の文字列もといPathBufを代入します。
これを入れないでrename(&file,&file.file_name().unwrap())なんてやると、dm.exeがあるフォルダに移動するかダウンロードフォルダから移動しません。

        if let Err(e) = rename(&file, &destination) {
            eprintln!("Failed to move {:?} to {:?}: {}", file, destination, e);
        }

さきほどのcurrent_dir.join(file.file_name().unwrap())ですが、直接入れても問題ありません。

        if let Err(e) = rename(&file, &current_dir.join(file.file_name().unwrap())) {
            eprintln!("Failed to move {:?}: {}", file, e);
        }

・・・少しだけ読みにくくなりますが。

    println!("{}個移動しました。",move_count);

移動させた数を教えるだけです。
これでmove_file関数が完成しました。

pause関数

Enterが入力されるまで処理を止める関数です。

use std::io::stdin;
fn pause() {
    println!("続行するにEnterを押してください...");
    let _ = stdin().read_line(&mut String::new());
}

もともとdm.batはターミナル上からではなく、エクスプローラーのアドレスバーから起動するのが想定される動作です。そのため、ターミナルをすぐに閉じないようにpause & exit \bを入れていました。
それをRustで実現するとこのようになります。
let _ = stdin().read_line(&mut String::new());で入力待ちができるというだけです。let _ = はなくてもいいですがワーニングが出ます。ResultのErr関連だったはずです。
ただ、これだと他のキーを押しても入力待ちは解除できません。バッチファイルみたいにいずれかのキーを入力して解除できないでしょうか?

ということでこちらを使います。
Cargo.tomlの[dependencies]にcrossterm = "*"を入れる必要があります。

use std::io::Error;
use crossterm::event::{self,Event};
fn pause() -> Result<(), Error> {
    println!("続行するには何かキーを押してください...");
    loop {
        if event::poll(std::time::Duration::from_millis(1000))? {
            if let Event::Key(_) = event::read()? {
                break;
            }
        }
    }
    Ok(())
}

crosstermの機能を使って入力を待つようにします。
std::time::Duration::from_millis(1000)は1000msおきに入力があったかを確認し、入力があったら待たずにif文の中にいくようです。ChatGPTで初めて知りました。CPUの稼働を抑えられそうでいいですね。
ターミナルからの起動だと入力しなくていいみたいです。そうなると「続行するには何かキーを押してください...」を表示しないようにしたいですが、分からないので無視します。

おわり

これでdm.batからdm.exeへの変換が完了しました。
やはりコンパイルできる言語は楽しいですね。Rustのcargo checkによるもなかなか強力ですし。
この記事を作るにあたってChatGPTが相当役に立ってくれました。分からない文があれば聞くだけで動作を教えてくれるのですから、さっと調べたいときには重宝します。
しかしながらRustの文体は慣れないというよりかエラーハンドリングが厳格ですね・・・。そこがやりやすい点でもありますが、入力が面倒くさい。
流石にNeovim環境にもneocodeium入れようかな。

脚注
  1. https://doc.rust-lang.org/std/process/fn.exit.html ↩︎

  2. Copilotも分かりやすい説明を出してくれました。 ↩︎

Discussion