use super::error::{ArtefactError, Result};
use serde::Serialize;
use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
#[serde(transparent)]
pub struct ToolchainChannel(String);
fn is_valid_channel_char(c: char) -> bool {
c.is_ascii_alphanumeric() || c == '-' || c == '.' || c == '_'
}
impl ToolchainChannel {
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
#[must_use]
pub fn into_inner(self) -> String {
self.0
}
}
impl TryFrom<&str> for ToolchainChannel {
type Error = ArtefactError;
fn try_from(value: &str) -> Result<Self> {
if value.is_empty() {
return Err(ArtefactError::InvalidToolchainChannel {
reason: "channel must not be empty".to_owned(),
});
}
if let Some(bad) = value.chars().find(|c| !is_valid_channel_char(*c)) {
return Err(ArtefactError::InvalidToolchainChannel {
reason: format!("invalid character '{bad}' in channel \"{value}\""),
});
}
Ok(Self(value.to_owned()))
}
}
impl TryFrom<String> for ToolchainChannel {
type Error = ArtefactError;
fn try_from(value: String) -> Result<Self> {
let _ = Self::try_from(value.as_str())?;
Ok(Self(value))
}
}
impl AsRef<str> for ToolchainChannel {
fn as_ref(&self) -> &str {
&self.0
}
}
impl<'de> serde::Deserialize<'de> for ToolchainChannel {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: serde::de::Deserializer<'de>,
{
let s = <String as serde::Deserialize>::deserialize(deserializer)?;
Self::try_from(s).map_err(serde::de::Error::custom)
}
}
impl fmt::Display for ToolchainChannel {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
#[cfg(test)]
mod tests {
use super::*;
use rstest::rstest;
#[rstest]
#[case::nightly_with_date("nightly-2026-05-28")]
#[case::stable("stable")]
#[case::version_with_dots("1.75.0")]
#[case::host_qualified("nightly-2026-05-28-x86_64-unknown-linux-gnu")]
fn accepts_valid_channel(#[case] input: &str) {
let ch = ToolchainChannel::try_from(input).expect("expected valid channel");
assert_eq!(ch.as_str(), input);
}
#[rstest]
#[case::empty("", "empty")]
#[case::whitespace("nightly 2025", "whitespace")]
#[case::slashes("nightly/latest", "slashes")]
fn rejects_invalid_channel(#[case] input: &str, #[case] label: &str) {
let err =
ToolchainChannel::try_from(input).expect_err("expected rejection of invalid channel");
assert!(
matches!(err, ArtefactError::InvalidToolchainChannel { .. }),
"expected InvalidToolchainChannel for {label}, got {err:?}"
);
}
#[test]
fn display_shows_inner_value() {
let ch = ToolchainChannel::try_from("nightly-2026-05-28").expect("known good");
assert_eq!(format!("{ch}"), "nightly-2026-05-28");
}
#[test]
fn from_owned_string_accepts_valid() {
let ch = ToolchainChannel::try_from(String::from("nightly-2026-05-28"));
assert!(ch.is_ok());
}
#[test]
fn serde_round_trip() {
let ch = ToolchainChannel::try_from("nightly-2026-05-28").expect("valid");
let json = serde_json::to_string(&ch).expect("serialize");
let back: ToolchainChannel = serde_json::from_str(&json).expect("deserialize");
assert_eq!(ch, back);
}
#[test]
fn deserialize_rejects_invalid() {
let json = r#""""#; let result: std::result::Result<ToolchainChannel, _> = serde_json::from_str(json);
assert!(result.is_err());
}
}