ジョークコマンド "dont" を作った
🔕

ジョークコマンド "dont" を作った

2022/03/26に公開

dontコマンドを作ったので紹介します。Rustが入っていれば、以下のようにしてインストールできます。

cargo install dont

(ビルド済みのバイナリや各種ディストリビューション向けのパッケージは現時点では存在しません。貢献を歓迎します!)

これは何?

名前の通り、指定した処理を実行しないコマンドです。たとえば、

dont mkdir foo

はfooを作成しません。

dontコマンドは必ずしも「何もしない」とは限りません。特定のパターンに対しては、ユーザーの意図を汲んで特別な処理が行われます。いくつかの例をreadmeに書いてあります。全てのパターンを知りたい人はソースコードを参照してください。

なぜこのタイミングで公開したのか

エイプリルフールのネタにしようかなと思ったのですが、他のジョークの話題と食い合うのもな…… と思ったので早めに公開することにしました。

実装上の工夫

基本的には出落ちで終わってしまう話ですが、実装上の工夫がいくつかあるので紹介していきます。

-- の処理

本コマンドはコマンドラインパーサーclapを使っており、オプション引数を取ることができるようになっています。現状では --help--version などのclapのデフォルトコマンドしか実装されていません。

この場合、万が一 - で始まる文字列をサブコマンドの一部として指定したい場合に困ります。このような場合の対策として、 -- というトークンによって「残りは全て位置引数である」という慣習が使われています。

# 以下の2つは同じ意味
dont ls
dont -- ls
# 以下の2つは別の意味になる
dont --help  # ヘルプを表示
dont -- --help # "--help" はdontに対する指定の一部として解釈される

clapはこのような -- の解釈をデフォルトで行ってくれますが、あとのほうで登場した -- も食べてしまうという問題があります。

dont -- ls  # 引数は ["ls"]
dont rm -- -r # 引数は ["rm", "--", "-r"] になってほしい
# ↑デフォルトでは ["rm", "-r"] になってしまう

dont のように、位置引数をコマンドとして再解釈したい場合、 -- がこのように食べられてしまうとユーザーの意図に反する可能性が高くなってしまいます。そこで #[clap(allow_hyphen_values = true)] を指定することで、最初の位置引数以降は全て位置引数として解釈させています。

exec

dont は一定条件下ではコマンドを呼び出します。このとき、制御を dont に戻す必要がないため、unix系の環境では execve(2) で自身を置換したほうがお行儀が良いです。一般的に、これには以下のような理由があります。

  • 余計なプロセスを保持する必要がなくなる。
  • unix系の環境ではpid=1が特別扱いされる。pid=1で起動されたとき、サブコマンドにpidが引き継がれたほうが都合が良い場合がある。
    • 特にコンテナが普及したことで所謂initプログラム (sysv init, upstart, systemdなど) 以外のpid=1コマンドがよく使われるようになったため、一見システムプログラムではないプログラムでもしばしばこのような性質が重要になる。

dont コマンドを真面目に使う人はいないと考えられますが、せっかくなのでお行儀の良い実装にするために dont でもfork-execを使わずexecでコマンドを起動するようにしています。しかし、あらゆるOSでexecが使えるわけではないので、以下のようにOSで場合分けをしています。

cfg_if! {
    if #[cfg(unix)] {
        // unix系環境の場合はexecで起動し、dontコマンドに制御を戻さない
        use std::os::unix::process::CommandExt;
        result = Err(command.exec());
    } else {
        // unix系以外の環境の場合はサブプロセスとして起動する
        result = command.spawn();
    }
}

テスト

dont はジョークコマンドとはいえ、それなりには複雑な処理を含んでいます。今後の機能追加の余地もあるため、テストを書いておきたいところです。

dont のうち以下の部分はある程度複雑性があるため、テスト対象に含めています。

  • コマンドライン引数のパース
  • ユーザーの指定コマンドの内容や周辺環境に基づいて、次の処理を決定する部分

一方、以下の部分はテストに含んでいません。

  • 実際にサブコマンドを実行する部分
    • 理由: exec でプログラムを終了してしまうので、ユニットテストで実行するのが難しい。また、環境によって実際に入っているコマンドは異なるが、そこまで面倒を見たくない。この部分は頻繁に変更する箇所ではないため、割に合わない。

このようにテスト対象を切り分けるために、以下のenumを定義しています。

// dontコマンドの中核処理はこの値を返す
#[derive(Debug, Clone, PartialEq, Eq)]
enum Conclusion {
    Exit(i32),
    Exec(Vec<OsString>),
}

これは、最後に実行するべき処理をあらわしています。実ビルドでは以下の処理に対応しています。

  • Conclusion::Exitstd::process::exit
  • Conclusion::Execstd::os::unix::process::CommandExt::exec

いずれも成功時には処理が復帰しないため、どちらか片方を1回だけ実行できます。そのため戻り値で指定するようになっています。

一方、繰り返し行える外部処理をテスト可能にするために、モック可能な以下のtraitを定義しています。

// 外界に問い合わせるときはこの値を使う。
// 現在はコマンドの存在判定処理だけが定義されている。
trait Controller {
    fn has_command(&self, name: &str) -> bool;
}

実ビルドではwhich crateに処理を移譲していて、dontコマンド内では特別な処理はしていません。そのためこの部分はテストできなくてもよさそうです。

dontコマンドの中核処理である execute 関数はこのインスタンスを受け取るようになっています。今回は静的ディスパッチを採用しているため、オーバーヘッドもありません。

// ctl が外界へのインターフェースになっている
fn execute<C: Controller>(ctl: &C, args: &Args) -> Conclusion { /* ... */ }

さて、このように外部環境に依存する処理をインターフェース化することでテスト時に別の実装に差し替えることができるようになりますが、差し替え方法として大まかに以下の2つの方法が考えられます。

  • 実環境の振舞いを模倣し、動的に応答を返す実装 (fake implementation) に差し替える。
  • テスト項目ごとに必要なパターンにだけ応答できる実装 (mock) に差し替える。

mockは以下のようなケースで弱みがあります。

  • 入力がテストケースごとの関心に無関係な要因で変わりやすい場合
    • たとえばHTTPリクエストへの応答をmockするとき、リクエストには多くの非決定性や実装依存性があります。 (ヘッダの順番、タイムスタンプ、JSONのキーの順序など)
    • これらの揺らぎをmatcherで吸収できない場合、テストの関心に関係のない変更でもテストを修正する必要が出る可能性があります。
  • 出力にテストケースごとの関心に無関係な情報が多く含まれている場合
    • ユーザーを一覧するリクエストへの応答をmockするとき、リストされるべきユーザーIDはテストケース固有の関心として与えたいが、ユーザーデータは別の箇所から合成するなどの場合、普通にmockを書くと本来の関心と異なる情報がテストケースに残りがちです。

今回差し替えたい has_command は入出力ともにシンプルで、必要最低限しか呼ばれず、呼ばれたときに期待する挙動はテストケースごとに異なることが大半です。mockのほうが適切だと考えるのでmockを提供することにします。

Rustでmockを提供するためのライブラリはいくつか存在しますが、mockallは作者のこだわりが感じられ、評判も良いので今回はこれを使うことにします。

mockallではattribute macroである #[automock] をtraitに付与することでモック実装を生成できます。実ビルドでモックする必要はないため、mockallはdev-dependenciesに入れておき cfg_attr でテスト時だけモック実装を生成しています。

#[cfg_attr(test, mockall::automock)] // mock実装を生成する
trait Controller {
    fn has_command(&self, name: &str) -> bool;
}

モック実装はtrait名に Mock を足した名前のstruct (この場合は MockController) として生成されます。何も応答しないモックは以下のように生成できます。

// 何も応答しない (常にpanicする)
let ctl = MockController::new();

これを渡すことで、 has_command が呼ばれないことを確認できます。

has_command が呼ばれることをテストするには、あらかじめ expect_* を呼んでおきます。

// has_command("sl") に対してtrueを返すよう設定
let mut ctl = MockController::new();
ctl.expect_has_command().with(mockall::predicate::eq("sl")).returning(|_| true);

さいごに

dont コマンドは皆さんからの貢献を歓迎しています。Rustで何か書いてみたい人、ぜひ面白いパターンを追加するPRを作ってみてほしいです。

また、もし気に入ってもらえたら qnighy/dont にstarをつけてもらえると励みになります。

Discussion