rialoman 0.2.0

Rialo native toolchain manager
Documentation
//! Spec parsing utilities for converting user input into `(channel, version)` release identifiers.
// Copyright (c) Subzero Labs, Inc.
// SPDX-License-Identifier: Apache-2.0

use std::{fmt, str::FromStr};

use anyhow::{anyhow, Result};
use once_cell::sync::Lazy;
use regex::Regex;

#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Channel {
    Stable,
    Nightly,
    Commit,
}

impl Channel {
    pub const KNOWN: &'static [Channel] = &[Channel::Stable, Channel::Nightly, Channel::Commit];

    pub fn as_str(&self) -> &'static str {
        match self {
            Channel::Stable => "stable",
            Channel::Nightly => "nightly",
            Channel::Commit => "commit",
        }
    }
}

impl fmt::Display for Channel {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(self.as_str())
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum VersionSpec {
    Latest,
    Explicit(String),
}

impl VersionSpec {
    pub fn is_latest(&self) -> bool {
        matches!(self, VersionSpec::Latest)
    }

    pub fn as_ref(&self) -> Option<&str> {
        match self {
            VersionSpec::Latest => None,
            VersionSpec::Explicit(v) => Some(v.as_str()),
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InstallSpec {
    pub channel: Channel,
    pub version: VersionSpec,
}

impl InstallSpec {
    pub fn latest(channel: Channel) -> Self {
        Self {
            channel,
            version: VersionSpec::Latest,
        }
    }

    pub fn to_id(&self, resolved_version: &str) -> ReleaseId {
        ReleaseId {
            channel: self.channel,
            version: resolved_version.to_owned(),
        }
    }
}

impl fmt::Display for InstallSpec {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match &self.version {
            VersionSpec::Latest => write!(f, "{}@latest", self.channel),
            VersionSpec::Explicit(ver) => write!(f, "{}@{}", self.channel, ver),
        }
    }
}

impl FromStr for InstallSpec {
    type Err = anyhow::Error;

    fn from_str(input: &str) -> Result<Self> {
        parse_spec(input)
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub struct ReleaseId {
    pub channel: Channel,
    pub version: String,
}

impl ReleaseId {
    pub fn new(channel: Channel, version: impl Into<String>) -> Self {
        Self {
            channel,
            version: version.into(),
        }
    }
}

impl fmt::Display for ReleaseId {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}@{}", self.channel, self.version)
    }
}

fn parse_spec(raw: &str) -> Result<InstallSpec> {
    let trimmed = raw.trim();

    if trimmed.is_empty() {
        return Err(anyhow!("spec cannot be empty"));
    }

    // bare commit (hex) convenience
    if looks_like_commit(trimmed) {
        let sanitized = sanitize_commit_hash(trimmed)?;
        return Ok(InstallSpec {
            channel: Channel::Commit,
            version: VersionSpec::Explicit(sanitized),
        });
    }

    let (channel_str, version_str_opt) = match trimmed.split_once('@') {
        Some((chan, ver)) => (chan.to_lowercase(), Some(ver.trim())),
        None => (trimmed.to_lowercase(), None),
    };

    let channel = match channel_str.as_str() {
        "stable" => Channel::Stable,
        "nightly" => Channel::Nightly,
        "commit" => Channel::Commit,
        other => {
            return Err(anyhow!(
                "unknown channel '{other}'. Supported channels: stable, nightly, commit"
            ))
        }
    };

    let version = match version_str_opt {
        None | Some("" | "latest") => VersionSpec::Latest,
        Some(ver) => {
            // Sanitize commit hashes to exactly 8 characters
            let sanitized = if channel == Channel::Commit {
                sanitize_commit_hash(ver)?
            } else {
                ver.to_owned()
            };
            VersionSpec::Explicit(sanitized)
        }
    };

    Ok(InstallSpec { channel, version })
}

fn looks_like_commit(input: &str) -> bool {
    static HEX_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"^[0-9a-f]{7,40}$").unwrap());
    HEX_RE.is_match(input)
}

/// The required length for commit hashes used in toolchain paths.
const COMMIT_HASH_LENGTH: usize = 8;

/// Sanitizes a commit hash to the required length.
/// Returns an error if the hash is too short, or truncates if too long.
fn sanitize_commit_hash(hash: &str) -> Result<String> {
    if hash.len() < COMMIT_HASH_LENGTH {
        return Err(anyhow!(
            "commit hash '{}' is too short (minimum {} characters required)",
            hash,
            COMMIT_HASH_LENGTH
        ));
    }
    Ok(hash[..COMMIT_HASH_LENGTH].to_owned())
}

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

    #[test]
    fn parse_simple_channel() {
        let spec: InstallSpec = "stable".parse().unwrap();
        assert_eq!(spec.channel, Channel::Stable);
        assert!(spec.version.is_latest());
    }

    #[test]
    fn parse_channel_with_version() {
        let spec: InstallSpec = "nightly@2025-11-19".parse().unwrap();
        assert_eq!(spec.channel, Channel::Nightly);
        assert_eq!(spec.version.as_ref(), Some("2025-11-19"));
    }

    #[test]
    fn parse_commit_shortcut() {
        let spec: InstallSpec = "9dd86f69".parse().unwrap();
        assert_eq!(spec.channel, Channel::Commit);
        assert_eq!(spec.version.as_ref(), Some("9dd86f69"));
    }

    #[test]
    fn parse_commit_truncates_long_hash() {
        // Full 40-character SHA should be truncated to 8
        let spec: InstallSpec = "9dd86f69abcdef1234567890abcdef1234567890".parse().unwrap();
        assert_eq!(spec.channel, Channel::Commit);
        assert_eq!(spec.version.as_ref(), Some("9dd86f69"));
    }

    #[test]
    fn parse_commit_with_explicit_channel_truncates() {
        // commit@<long-hash> should also truncate
        let spec: InstallSpec = "commit@abcdef1234567890".parse().unwrap();
        assert_eq!(spec.channel, Channel::Commit);
        assert_eq!(spec.version.as_ref(), Some("abcdef12"));
    }

    #[test]
    fn parse_commit_too_short_fails() {
        // 7-character hash is too short (minimum is 8)
        let err = "abcdef1".parse::<InstallSpec>().unwrap_err();
        assert!(err.to_string().contains("too short"));
    }

    #[test]
    fn invalid_channel() {
        let err = "preview".parse::<InstallSpec>().unwrap_err();
        assert!(err.to_string().contains("unknown channel"));
    }
}