use-scale 0.0.1

Scale pattern metadata primitives for RustUse.
Documentation
#![forbid(unsafe_code)]
#![doc = include_str!("../README.md")]

use core::{fmt, str::FromStr};
use std::error::Error;

pub mod prelude {
    pub use crate::{
        ChromaticScale, DiatonicScale, PentatonicScale, ScaleDegree, ScaleError, ScaleKind,
        ScaleName, ScalePattern, ScaleStepPattern, ScaleToneCount,
    };
}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct ScaleName(String);

impl ScaleName {
    pub fn new(value: impl AsRef<str>) -> Result<Self, ScaleError> {
        non_empty_text(value).map(Self)
    }

    pub fn as_str(&self) -> &str {
        &self.0
    }

    pub fn value(&self) -> &str {
        self.as_str()
    }

    pub fn into_string(self) -> String {
        self.0
    }
}

impl AsRef<str> for ScaleName {
    fn as_ref(&self) -> &str {
        self.as_str()
    }
}

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

impl FromStr for ScaleName {
    type Err = ScaleError;

    fn from_str(value: &str) -> Result<Self, Self::Err> {
        Self::new(value)
    }
}

impl TryFrom<&str> for ScaleName {
    type Error = ScaleError;

    fn try_from(value: &str) -> Result<Self, Self::Error> {
        Self::new(value)
    }
}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct ScaleDegree(u8);

impl ScaleDegree {
    pub fn new(value: u8) -> Result<Self, ScaleError> {
        if !(1..=64).contains(&value) {
            return Err(ScaleError::OutOfRange);
        }

        Ok(Self(value))
    }

    pub const fn value(self) -> u8 {
        self.0
    }
}

impl fmt::Display for ScaleDegree {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        self.0.fmt(formatter)
    }
}

impl FromStr for ScaleDegree {
    type Err = ScaleError;

    fn from_str(value: &str) -> Result<Self, Self::Err> {
        let parsed = value
            .trim()
            .parse::<u8>()
            .map_err(|_| ScaleError::InvalidFormat)?;
        Self::new(parsed)
    }
}

impl TryFrom<u8> for ScaleDegree {
    type Error = ScaleError;

    fn try_from(value: u8) -> Result<Self, Self::Error> {
        Self::new(value)
    }
}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct ScaleToneCount(u8);

impl ScaleToneCount {
    pub fn new(value: u8) -> Result<Self, ScaleError> {
        if !(1..=64).contains(&value) {
            return Err(ScaleError::OutOfRange);
        }

        Ok(Self(value))
    }

    pub const fn value(self) -> u8 {
        self.0
    }
}

impl fmt::Display for ScaleToneCount {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        self.0.fmt(formatter)
    }
}

impl FromStr for ScaleToneCount {
    type Err = ScaleError;

    fn from_str(value: &str) -> Result<Self, Self::Err> {
        let parsed = value
            .trim()
            .parse::<u8>()
            .map_err(|_| ScaleError::InvalidFormat)?;
        Self::new(parsed)
    }
}

impl TryFrom<u8> for ScaleToneCount {
    type Error = ScaleError;

    fn try_from(value: u8) -> Result<Self, Self::Error> {
        Self::new(value)
    }
}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum ScaleKind {
    Major,
    NaturalMinor,
    HarmonicMinor,
    MelodicMinor,
    Chromatic,
    MajorPentatonic,
    MinorPentatonic,
    Blues,
    WholeTone,
    Diminished,
    Custom,
}

impl ScaleKind {
    pub const ALL: &'static [Self] = &[
        Self::Major,
        Self::NaturalMinor,
        Self::HarmonicMinor,
        Self::MelodicMinor,
        Self::Chromatic,
        Self::MajorPentatonic,
        Self::MinorPentatonic,
        Self::Blues,
        Self::WholeTone,
        Self::Diminished,
        Self::Custom,
    ];

    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Major => "major",
            Self::NaturalMinor => "natural-minor",
            Self::HarmonicMinor => "harmonic-minor",
            Self::MelodicMinor => "melodic-minor",
            Self::Chromatic => "chromatic",
            Self::MajorPentatonic => "major-pentatonic",
            Self::MinorPentatonic => "minor-pentatonic",
            Self::Blues => "blues",
            Self::WholeTone => "whole-tone",
            Self::Diminished => "diminished",
            Self::Custom => "custom",
        }
    }
}

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

impl FromStr for ScaleKind {
    type Err = ScaleError;

    fn from_str(value: &str) -> Result<Self, Self::Err> {
        match normalized_label(value)?.as_str() {
            "major" => Ok(Self::Major),
            "natural-minor" => Ok(Self::NaturalMinor),
            "harmonic-minor" => Ok(Self::HarmonicMinor),
            "melodic-minor" => Ok(Self::MelodicMinor),
            "chromatic" => Ok(Self::Chromatic),
            "major-pentatonic" => Ok(Self::MajorPentatonic),
            "minor-pentatonic" => Ok(Self::MinorPentatonic),
            "blues" => Ok(Self::Blues),
            "whole-tone" => Ok(Self::WholeTone),
            "diminished" => Ok(Self::Diminished),
            "custom" => Ok(Self::Custom),
            _ => Err(ScaleError::UnknownLabel),
        }
    }
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ScalePattern {
    steps: Vec<u8>,
}

impl ScalePattern {
    pub fn new(steps: impl Into<Vec<u8>>) -> Result<Self, ScaleError> {
        let steps = steps.into();
        if steps.is_empty() {
            return Err(ScaleError::Empty);
        }
        Ok(Self { steps })
    }

    pub fn steps(&self) -> &[u8] {
        &self.steps
    }

    pub fn tone_count(&self) -> ScaleToneCount {
        ScaleToneCount(u8::try_from(self.steps.len()).unwrap_or(u8::MAX))
    }

    pub fn is_heptatonic(&self) -> bool {
        self.steps.len() == 7
    }
    pub fn is_pentatonic(&self) -> bool {
        self.steps.len() == 5
    }
    pub fn is_chromatic(&self) -> bool {
        self.steps.len() == 12
    }
}

pub type ScaleStepPattern = ScalePattern;
pub type DiatonicScale = ScalePattern;
pub type PentatonicScale = ScalePattern;
pub type ChromaticScale = ScalePattern;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ScaleError {
    Empty,
    InvalidFormat,
    OutOfRange,
    NonFinite,
    NonPositive,
    UnknownLabel,
}

impl fmt::Display for ScaleError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Empty => formatter.write_str("scale metadata text cannot be empty"),
            Self::InvalidFormat => formatter.write_str("scale metadata has an invalid format"),
            Self::OutOfRange => formatter.write_str("scale metadata value is out of range"),
            Self::NonFinite => formatter.write_str("scale metadata value must be finite"),
            Self::NonPositive => formatter.write_str("scale metadata value must be positive"),
            Self::UnknownLabel => formatter.write_str("unknown scale metadata label"),
        }
    }
}

impl Error for ScaleError {}

#[allow(dead_code)]
fn non_empty_text(value: impl AsRef<str>) -> Result<String, ScaleError> {
    let trimmed = value.as_ref().trim();
    if trimmed.is_empty() {
        Err(ScaleError::Empty)
    } else {
        Ok(trimmed.to_string())
    }
}

fn normalized_label(value: &str) -> Result<String, ScaleError> {
    let trimmed = value.trim();
    if trimmed.is_empty() {
        Err(ScaleError::Empty)
    } else {
        Ok(trimmed.to_ascii_lowercase().replace(['_', ' '], "-"))
    }
}
#[cfg(test)]
#[allow(
    unused_imports,
    clippy::unnecessary_wraps,
    clippy::assertions_on_constants
)]
mod tests {
    use super::{
        ChromaticScale, DiatonicScale, PentatonicScale, ScaleDegree, ScaleError, ScaleKind,
        ScaleName, ScalePattern, ScaleStepPattern, ScaleToneCount,
    };
    use core::{fmt, str::FromStr};

    fn assert_enum_family<T>(variants: &[T]) -> Result<(), ScaleError>
    where
        T: Copy + Eq + fmt::Debug + fmt::Display + FromStr<Err = ScaleError>,
    {
        for variant in variants {
            let label = variant.to_string();
            assert_eq!(label.parse::<T>()?, *variant);
            assert_eq!(label.replace('-', "_").parse::<T>()?, *variant);
            assert_eq!(label.replace('-', " ").parse::<T>()?, *variant);
        }
        Ok(())
    }

    #[test]
    fn validates_text_newtypes() -> Result<(), ScaleError> {
        let value = ScaleName::new(" example-value ")?;
        assert_eq!(value.as_str(), "example-value");
        assert_eq!(value.value(), "example-value");
        assert_eq!(value.to_string(), "example-value");
        assert_eq!(
            <ScaleName as TryFrom<&str>>::try_from("example-value")?,
            value
        );
        Ok(())
    }

    #[test]
    fn validates_numeric_newtypes() -> Result<(), ScaleError> {
        let value = ScaleDegree::new(1)?;
        assert_eq!(value.value(), 1);
        assert_eq!("1".parse::<ScaleDegree>()?, value);
        assert_eq!(ScaleDegree::new(65), Err(ScaleError::OutOfRange));
        let value = ScaleToneCount::new(1)?;
        assert_eq!(value.value(), 1);
        assert_eq!("1".parse::<ScaleToneCount>()?, value);
        assert_eq!(ScaleToneCount::new(65), Err(ScaleError::OutOfRange));
        Ok(())
    }

    #[test]
    fn displays_and_parses_enums() -> Result<(), ScaleError> {
        assert_enum_family(ScaleKind::ALL)?;
        Ok(())
    }

    #[test]
    fn classifies_scale_patterns() -> Result<(), ScaleError> {
        let major = ScalePattern::new([2, 2, 1, 2, 2, 2, 1])?;
        let pentatonic = ScalePattern::new([2, 2, 3, 2, 3])?;
        let chromatic = ScalePattern::new([1; 12])?;
        assert!(major.is_heptatonic());
        assert!(pentatonic.is_pentatonic());
        assert!(chromatic.is_chromatic());
        assert_eq!(major.tone_count().value(), 7);
        Ok(())
    }
}