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"));
}
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) => {
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)
}
const COMMIT_HASH_LENGTH: usize = 8;
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() {
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() {
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() {
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"));
}
}