⚒️

Rust でバージョン文字列を構造体に変換する

2022/12/11に公開

Rust Advent Calendar 2022 の 11 日目の記事です。


はじめに

最近、いくつか Dart パッケージを開発するようになり、Dart バージョンの切り替えが面倒になってきました。

良さそうな Dart のバージョン管理ツールがないか探してみましたが見つからず、せっかくなので自分で作ることにしました。

Dart で書いても良かったのですが、できる限りパフォーマンスを良くしたかったので、Rust で書くことにしました。

この記事では、ユーザーが入力したバージョン文字列を構造体に変換するところに絞って紹介します。

バージョン文字列の形式

詳しくは ↓ に記載されています。

https://dart.dev/get-dart#release-channels

Stable

x.y.z という形式。

x は major version、y は minor version、z は patch version です。

Beta / Dev

x.y.z-a.b.<beta|dev> という形式。

a は prerelease version、b は prerelease patch version です。

環境

cargo --version
# cargo 1.65.0 (4bc8f24d3 2022-10-20)

rustc --version
# rustc 1.65.0 (897e37553 2022-11-02)

rustdoc --version
# rustdoc 1.65.0 (897e37553 2022-11-02)

rustup --version
# rustup 1.25.1 (bb60b1e89 2022-07-12)

構造体を定義する

enum Channel {
    Dev,
    Beta,
    Stable,
}

struct Version {
    channel: Channel,
    major: i32,
    minor: i32,
    patch: i32,
    pre: Option<i32>,
    pre_patch: Option<i32>,
}

変換処理のドキュメント

文字列への変換

https://doc.rust-lang.org/rust-by-example/conversion/string.html

構造体への変換

https://doc.rust-lang.org/rust-by-example/conversion/try_from_try_into.html

変換処理の実装

Channel

Enum の文字列との変換処理はデフォルトでありそうなものの、現状は無いみたいでした。

strum というクレートを使えば、もう少しスマートに書けそうですが、いったんは公式ドキュメントに記載されている方法で書いてみます。

文字列への変換

impl Display for Channel {
    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
        let channel = match self {
            Channel::Dev => "dev",
            Channel::Beta => "beta",
            Channel::Stable => "stable",
        };
        write!(f, "{}", channel)
    }
}

構造体への変換

impl TryFrom<&str> for Channel {
    type Error = String;

    fn try_from(value: &str) -> Result<Self, Self::Error> {
        let channel = match value {
            "dev" => Channel::Dev,
            "beta" => Channel::Beta,
            "stable" => Channel::Stable,
            &_ => return Err(format!("Unknown channel: `{}`", value)),
        };
        Ok(channel)
    }
}

Version

文字列への変換

impl Display for Version {
    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
        let version = format!(
            "{major}.{minor}.{patch}",
            major = self.major,
            minor = self.minor,
            patch = self.patch,
        );

        if let Channel::Stable = self.channel {
            return write!(f, "{}", version);
        }

        if self.pre.is_none() || self.pre_patch.is_none() {
            return write!(f, "{}", version);
        }

        write!(
            f,
            "{version}-{pre}.{pre_patch}.{channel}",
            version = version,
            pre = self.pre.unwrap(),
            pre_patch = self.pre_patch.unwrap(),
            channel = self.channel,
        )
    }
}

構造体への変換

さまざまな方法が考えられますが、正規表現で抽出する方法にしてみました。

使用したクレートは regex です。

Option<Match<'t>> から i32Option<i32> への変換が少し面倒でしたので、to_int()to_int_or_null() という拡張メソッドを定義しました。

trait ToInt {
    fn to_int(self) -> i32;
    fn to_int_or_null(self) -> Option<i32>;
}

impl<'t> ToInt for Option<Match<'t>> {
    fn to_int(self) -> i32 {
        self.unwrap().as_str().parse::<i32>().unwrap()
    }

    fn to_int_or_null(self) -> Option<i32> {
        self.and_then(|m| m.as_str().parse::<i32>().ok())
    }
}

文字列からデータを抽出する方法としては、名前付きキャプチャーを利用しました。

https://docs.rs/regex/latest/regex/#example-replacement-with-named-capture-groups

impl TryFrom<&str> for Version {
    type Error = String;

    fn try_from(value: &str) -> Result<Self, Self::Error> {
        let reg = Regex::new(
            r"^(?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(-(?P<pre>\d+)\.(?P<pre_patch>\d+)\.(?P<channel>beta|dev))?$",
        ).unwrap();

        let caps = match reg.captures(value) {
            Some(caps) => caps,
            None => return Err(format!("No match: `{}`", value)),
        };

        let major = caps.name("major").to_int();
        let minor = caps.name("minor").to_int();
        let patch = caps.name("patch").to_int();
        let pre = caps.name("pre").to_int_or_null();
        let pre_patch = caps.name("pre_patch").to_int_or_null();
        let channel = caps
            .name("channel")
            .map_or(Channel::Stable, |m| Channel::try_from(m.as_str()).unwrap());

        Ok(Version {
            channel,
            major,
            minor,
            patch,
            pre,
            pre_patch,
        })
    }
}

完成したコード

コード
use regex::{Match, Regex};
use std::fmt::{Display, Formatter};

trait ToInt {
    fn to_int(self) -> i32;
    fn to_int_or_null(self) -> Option<i32>;
}

impl<'t> ToInt for Option<Match<'t>> {
    fn to_int(self) -> i32 {
        self.unwrap().as_str().parse::<i32>().unwrap()
    }

    fn to_int_or_null(self) -> Option<i32> {
        self.and_then(|m| m.as_str().parse::<i32>().ok())
    }
}

enum Channel {
    Dev,
    Beta,
    Stable,
}

struct Version {
    channel: Channel,
    major: i32,
    minor: i32,
    patch: i32,
    pre: Option<i32>,
    pre_patch: Option<i32>,
}

impl Display for Channel {
    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
        let channel = match self {
            Channel::Dev => "dev",
            Channel::Beta => "beta",
            Channel::Stable => "stable",
        };
        write!(f, "{}", channel)
    }
}

impl TryFrom<&str> for Channel {
    type Error = String;

    fn try_from(value: &str) -> Result<Self, Self::Error> {
        let channel = match value {
            "dev" => Channel::Dev,
            "beta" => Channel::Beta,
            "stable" => Channel::Stable,
            &_ => return Err(format!("Unknown channel: `{}`", value)),
        };
        Ok(channel)
    }
}

impl Display for Version {
    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
        let version = format!(
            "{major}.{minor}.{patch}",
            major = self.major,
            minor = self.minor,
            patch = self.patch,
        );

        if let Channel::Stable = self.channel {
            return write!(f, "{}", version);
        }

        if self.pre.is_none() || self.pre_patch.is_none() {
            return write!(f, "{}", version);
        }

        write!(
            f,
            "{version}-{pre}.{pre_patch}.{channel}",
            version = version,
            pre = self.pre.unwrap(),
            pre_patch = self.pre_patch.unwrap(),
            channel = self.channel,
        )
    }
}

impl TryFrom<&str> for Version {
    type Error = String;

    fn try_from(value: &str) -> Result<Self, Self::Error> {
        let reg = Regex::new(
            r"^(?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(-(?P<pre>\d+)\.(?P<pre_patch>\d+)\.(?P<channel>beta|dev))?$",
        ).unwrap();

        let caps = match reg.captures(value) {
            Some(caps) => caps,
            None => return Err(format!("No match: `{}`", value)),
        };

        let major = caps.name("major").to_int();
        let minor = caps.name("minor").to_int();
        let patch = caps.name("patch").to_int();
        let pre = caps.name("pre").to_int_or_null();
        let pre_patch = caps.name("pre_patch").to_int_or_null();
        let channel = caps
            .name("channel")
            .map_or(Channel::Stable, |m| Channel::try_from(m.as_str()).unwrap());

        Ok(Version {
            channel,
            major,
            minor,
            patch,
            pre,
            pre_patch,
        })
    }
}

fn main() {
    let stable_version_text = "2.18.5";
    let beta_version_text = "2.19.0-444.1.beta";
    let dev_version_text = "3.0.0-0.0.dev";

    let stable_version = Version::try_from(stable_version_text).unwrap();
    let beta_version = Version::try_from(beta_version_text).unwrap();
    let dev_version = Version::try_from(dev_version_text).unwrap();

    println!("{}", stable_version); // 2.18.5
    println!("{}", beta_version); // 2.19.0-444.1.beta
    println!("{}", dev_version); // 3.0.0-0.0.dev
}

おわりに

はじめて Rust でちゃんとしたツール開発をしていく中で、Enum の文字列変換機能や実装ミスのエラー処理など、若干の使いづらさは感じました。

しかし、強力なパターンマッチングやメタプログラミング・Result や Option などの文化などによって、個人的には心地よく開発が続けられそうです。

GitHubで編集を提案

Discussion