Rustでheadコマンドを実装する②(-n, -cオプションを実装する)

Rust

前回の記事でheadコマンドのデフォルト仕様を実装しました。 実装したデフォルト仕様は「与えられたファイルの先頭3行(*)を出力する」というものでした。
* 本来の仕様では出力行数は10行ですがテストコードが書きづらいので3行とします

ただ、デフォルト仕様のままだと、出力する行数を変更できません。また、出力するのも行数のみで文字列を出力することができません。

したがって、今回は出力モードを行数と文字数で変更できるようにし、出力する行数もしくは文字数もパラメータで与えられるように変更を加えていきます。ちなみに、この仕様は元のheadコマンドだと-n,-cオプションに相当します。

Rustでheadコマンドを実装する①(デフォルト仕様まで)
・Rustでheadコマンドを実装する②(-n, -cオプションを実装する) ← 今回はココ!

構築環境

コマンドの実装にあたり使用した環境は以下の通りです。
前回と同じです。

・Ubuntu 20.04 (WSL2)
・rustc 1.66.0
・cargo 1.66.0

$ cat /etc/lsb-release
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=20.04
DISTRIB_CODENAME=focal
DISTRIB_DESCRIPTION="Ubuntu 20.04.4 LTS"
$ 
$ rustc --version
rustc 1.66.0 (69f9c33d7 2022-12-12)
$ 
$ cargo --version
cargo 1.66.0 (d65d197ad 2022-11-15)

実装方針

いろいろ試行錯誤しながら実装方針については以下の3方針としました。
・引数解析には外部クレートとしてgetoptsを使用する
・オプションモードとパラメータをConfigに保有する
・lineモードとcharモードはそれぞれ関数を作成して実装する

それぞれ以下で説明します。

getoptsを使用する

与えられた引数については、外部クレートであるgetoptsを用いて解析します。

「自作する」「他の外部クレート」を採用するなどいろいろ選択肢はありますが、
・自分で作るのはめんどくさそう
・必要最小限の機能をシンプルに使用できそう
・ある程度ドキュメントが転がっていそう
という理由でgetoptsを採用することにしました。

使用例は以下の通りです。
opts.optoptもしくはopts.optflagを使用して対象となるオプションを設定し、match parse(&args)でヒットしたオプションと引数に対して処理をしていきます。

extern crate getopts;
use getopts::Options;
use std::env;

fn do_work(inp: &str, out: Option<String>) {
    println!("{}", inp);
    match out {
        Some(x) => println!("{}", x),
        None => println!("No Output"),
    }
}

fn print_usage(program: &str, opts: Options) {
    let brief = format!("Usage: {} FILE [options]", program);
    print!("{}", opts.usage(&brief));
}

fn main() {
    let args: Vec<String> = env::args().collect();
    let program = args[0].clone();

    let mut opts = Options::new();
    opts.optopt("o", "", "set output file name", "NAME");
    opts.optflag("h", "help", "print this help menu");
    let matches = match opts.parse(&args[1..]) {
        Ok(m) => { m }
        Err(f) => { panic!(f.to_string()) }
    };
    if matches.opt_present("h") {
        print_usage(&program, opts);
        return;
    }
    let output = matches.opt_str("o");
    let input = if !matches.free.is_empty() {
        matches.free[0].clone()
    } else {
        print_usage(&program, opts);
        return;
    };
    do_work(&input, output);
}

オプションモードとパラメータをConfigに保有する

前回の記事で説明したソースコード(lib.rs)では、コマンドの設定情報を保有するConfig型にファイル名を保有していました。

今回は-nオプション、-cオプションを設定し、またそれぞれの設定値(行数もしくは文字数)を保有する必要があるため、こちらの情報をConfig型に保有してきます。

pub struct Config {
    filename: String,
    // オプションモードを保有する
    output_mode: String,
    // 設定値を保有する
    limit_num: usize,
}

lineモードとcharモードはそれぞれ関数を作成して実装する

前回の記事で説明したソースコード(lib.rs)では入力されたファイルの抽出にfn extract(buff: &mut BufReader<File>, limit_num: usize)を呼び出していました。

今回はこれの文字列解析バージョンを追加し、fn run(config: Config)を呼び出す際にConfigの値に応じて分岐させる仕組みとします。

-n,-cオプションを実装する

では実装したプログラムのプロジェクト構成とソースコードを見ていきましょう。

プロジェクト構成

プロジェクト構成は以下の通りです。前回からほとんど変更ありません。

$ tree -I target
.
├── Cargo.lock
├── Cargo.toml
├── LICENCE
├── README.md
├── src
│   ├── lib.rs // headコマンドのロジックを記載する
│   └── main.rs  // headコマンドの設定やrun関数の呼び出し、エラー処理を行う
└── tests
    └── testdata
        └── spec.md  // テストデータ

3 directories, 7 files

ソースコード

続いてソースコードを見ていきましょう。
内容については適宜コメントで補足しています。

以下にmain.rsを示します。
こちらは前回から変更ありません。

extern crate minihead;

use std::env;
use std::process;

// コマンドの設定情報を保有する構造体
use minihead::Config;

fn main() {
  // (1) 実行時に与えられた引数をargs: Vec<String>に格納する
    let args: Vec<String> = env::args().collect();

    // (2) argsを引数としてconfig: Configインスタンスを作成する
    //     呼び出し時にエラーを検知した場合は、エラーメッセージを出力して処理を終了する
    let config = Config::new(&args).unwrap_or_else(|err| {
        eprintln!("Problem parsing arguments: {}", err);
        process::exit(1);
    });

    // (3) configを引数としてrun関数を呼び出す
    //     呼び出し時にエラーを検知した場合は、エラーメッセージを出力して処理を終了する
    if let Err(e) = minihead::run(config) {
        eprintln!("Application error: {}", e);
        process::exit(1);
    }
}

以下にlib.rsを示します。
長くなるのでロジック部分とテストコード部分で分割して記載しています。

use getopts::Options;
use std::error::Error;
use std::fs::File;
use std::io::{prelude::*, BufReader};

// 全体を通して使用する定数をconstとして定義
const LINE_OPTION: &'static str = "n";
const LINE_MODE: &'static str = "line";
const CHAR_OPTION: &'static str = "c";
const CHAR_MODE: &'static str = "char";
const DEAFULT_LINES_NUMBER: usize = 10;

// headコマンドの設定情報を保有する構造体
pub struct Config {
    filename: String,
    output_mode: String,
    limit_num: usize,
}

fn print_usage(program: &str, opts: Options) {
    let brief = format!("Usage: {} [options] FILE", program);
    println!("{}", opts.usage(&brief));
}

// Configのメソッド
impl Config {
    // Configのコンストラクタ
    // 引数をConfigのフィールド(filename)に格納する
    pub fn new(args: &[String]) -> Result<Config, &'static str> {
        let program = &args[0];
        let mut opts = Options::new();
        
        // オプションを設定
        opts.optopt(LINE_OPTION, "lines", "output first NUM lines", "NUM");
        opts.optopt(CHAR_OPTION, "bytes", "output first NUM chars", "NUM");

        // 未定義のオプションを指定した場合にエラーメッセージを出力する
        let matches = match opts.parse(&args[1..]) {
            Ok(m) => m,
            Err(msg) => {
                println!("Error: {}", msg.to_string());
                print_usage(program, opts);
                return Err("error caused in parsing auguments");
            }
        };

        // デフォルトのモードと出力数を設定する
        let mut limit_num = DEAFULT_LINES_NUMBER;
        let mut output_mode = LINE_MODE.to_string();

        // -n(--lines)と一致した場合
        if matches.opt_present(LINE_OPTION) {
            if let Some(text) = matches.opt_str(LINE_OPTION) {
                match text.parse::<usize>() {
                    Ok(number) => {
                        limit_num = number;
                    }
                    Err(msg) => {
                        println!("Error: {}", msg.to_string());
                        print_usage(program, opts);
                        return Err("invalid number of lines");
                    }
                }
            }
        // -c(--bytes)と一致した場合
        } else if matches.opt_present(CHAR_OPTION) {
            if let Some(text) = matches.opt_str(CHAR_OPTION) {
                match text.parse::<usize>() {
                    Ok(number) => {
                        output_mode = CHAR_MODE.to_string();
                        limit_num = number;
                    }
                    Err(msg) => {
                        println!("Error: {}", msg.to_string());
                        print_usage(program, opts);
                        return Err("invalid number of chars");
                    }
                }
            }
        }

        // オプションの設定されていない引数をファイル名として設定する
        if matches.free.is_empty() {
            print_usage(program, opts);
            return Err("set filename in arguments");
        } else {
            let filename = String::from(&matches.free[0]);
            Ok(Config {
                filename,
                output_mode,
                limit_num,
            })
        }
    }
}

// 先頭からlimit_num行だけ文字列を抽出する
fn extract_line(buff: &mut BufReader<File>, limit_num: usize) -> String {
    let mut result = String::new();

    for _ in 0..limit_num {
        buff.read_line(&mut result).expect("failed to read file");
    }

    result
}

// 先頭からlimit_num文字だけ文字列を抽出する
fn extract_char(buff: &mut BufReader<File>, limit_num: usize) -> String {
    let mut tmp_strs = String::new();
    let mut result = String::new();
    let mut char_cnt = 0;

    while char_cnt < limit_num {
        let line_bytes = buff.read_line(&mut tmp_strs).expect("failed to read file");
        char_cnt += line_bytes;

        if char_cnt >= limit_num {
            result = (&tmp_strs[0..limit_num]).to_string();
            break;
        }
    }

    result
}

// headコマンドのドメインロジック
pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let f = File::open(config.filename)?;
    let mut buff = BufReader::new(f);

    // 設定されたout_putmodeに応じて処理を分岐する
    let extracted_str = match config.output_mode.as_str() {
        LINE_MODE => extract_line(&mut buff, config.limit_num),
        CHAR_MODE => extract_char(&mut buff, config.limit_num),
        _ => return Err("no match output mode".into()),
    };

    print!("{}", extracted_str);

    Ok(())
}

lib.rsのテストコード部分を以下に示します。
本来は上記のロジック部分と合わせて1ファイルにまとまっています。


#[cfg(test)]
mod test {
    use super::*;

    #[test]
    fn config_normal_test1() {
        let command_input = "minihead test1.txt -n 10";
        let args: Vec<String> = command_input
            .split_whitespace()
            .map(|s| s.to_string())
            .collect();

        let config = Config::new(&args).unwrap();
        assert_eq!("test1.txt", config.filename);
        assert_eq!("line", config.output_mode);
        assert_eq!(10, config.limit_num);
    }

    #[test]
    fn config_normal_test2() {
        let command_input = "minihead test2.txt -c 5";
        let args: Vec<String> = command_input
            .split_whitespace()
            .map(|s| s.to_string())
            .collect();

        let config = Config::new(&args).unwrap();
        assert_eq!("test2.txt", config.filename);
        assert_eq!("char", config.output_mode);
        assert_eq!(5, config.limit_num);
    }

    #[test]
    fn config_normal_test3() {
        let command_input = "minihead test3.txt -n 10 -c 5";
        let args: Vec<String> = command_input
            .split_whitespace()
            .map(|s| s.to_string())
            .collect();

        let config = Config::new(&args).unwrap();
        assert_eq!("test3.txt", config.filename);
        assert_eq!("line", config.output_mode);
        assert_eq!(10, config.limit_num);
    }

    #[test]
    fn extract_line_normal_test1() {
        let testdata_path = "tests/testdata/spec.md";
        let f = File::open(testdata_path).unwrap();
        let mut buff = BufReader::new(f);
        let ok_contents = "\
# head
## NAME
       head - output the first part of files

## SYNOPSIS
       head [OPTION]... [FILE]...

## DESCRIPTION
       Print the first 10 lines of each FILE to standard output.  With more than one FILE, precede each with a header giving the file name.

";
        assert_eq!(ok_contents, extract_line(&mut buff, 10));
    }

    #[test]
    fn extract_char_normal_test1() {
        let testdata_path = "tests/testdata/spec.md";
        let f = File::open(testdata_path).unwrap();
        let mut buff = BufReader::new(f);
        let ok_contents = "\
# head
## NAME
       head - output the first part";
        assert_eq!(ok_contents, extract_char(&mut buff, 50));
    }

    #[test]
    fn extract_char_normal_test2() {
        let testdata_path = "tests/testdata/spec.md";
        let f = File::open(testdata_path).unwrap();
        let mut buff = BufReader::new(f);
        let ok_contents = "\
# head
";
        assert_eq!(ok_contents, extract_char(&mut buff, 7));
    }
}

実装したプログラムのポイント

実装したプログラムのポイントは以下の通りです。

  • impl Configにおいて、何度も使用する定数はconst値として定義することでソースコードの見通しをよくしています。
  • impl Configにおいて、引数解析時にエラーを出力した場合はエラーメッセージと共に使用法(usage)を出力し、修正が楽になるように工夫しています。
  • fn runにおいて、out_putmodeに応じて呼び出す関数を振り分けることでモードの追加があったとしても柔軟に機能追加ができる構成としています。

所感

今回はminiheadコマンドに-n,-cオプションを実装しました。

何となくRustが書けるようになってきた気がしますが、まだまだ所有権のあたりでコンパイルエラーを出すことが多く、精進が必要です。

ちなみに今回作成したプロジェクトは以下のリポジトリに格納しています。
よかったらそちらも見てください。

GitHub - tm-hack/minihead
Contribute to tm-hack/minihead development by creating an account on GitHub.

参考文献

入出力プロジェクト:コマンドラインプログラムを構築する - The Rust Programming Language 日本語版
getopts - Rust
API documentation for the Rust `getopts` crate.

コメント

タイトルとURLをコピーしました