Skip to main content

use_scale/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7pub mod prelude {
8    pub use crate::{
9        ChromaticScale, DiatonicScale, PentatonicScale, ScaleDegree, ScaleError, ScaleKind,
10        ScaleName, ScalePattern, ScaleStepPattern, ScaleToneCount,
11    };
12}
13#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
14pub struct ScaleName(String);
15
16impl ScaleName {
17    pub fn new(value: impl AsRef<str>) -> Result<Self, ScaleError> {
18        non_empty_text(value).map(Self)
19    }
20
21    pub fn as_str(&self) -> &str {
22        &self.0
23    }
24
25    pub fn value(&self) -> &str {
26        self.as_str()
27    }
28
29    pub fn into_string(self) -> String {
30        self.0
31    }
32}
33
34impl AsRef<str> for ScaleName {
35    fn as_ref(&self) -> &str {
36        self.as_str()
37    }
38}
39
40impl fmt::Display for ScaleName {
41    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
42        formatter.write_str(self.as_str())
43    }
44}
45
46impl FromStr for ScaleName {
47    type Err = ScaleError;
48
49    fn from_str(value: &str) -> Result<Self, Self::Err> {
50        Self::new(value)
51    }
52}
53
54impl TryFrom<&str> for ScaleName {
55    type Error = ScaleError;
56
57    fn try_from(value: &str) -> Result<Self, Self::Error> {
58        Self::new(value)
59    }
60}
61#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
62pub struct ScaleDegree(u8);
63
64impl ScaleDegree {
65    pub fn new(value: u8) -> Result<Self, ScaleError> {
66        if !(1..=64).contains(&value) {
67            return Err(ScaleError::OutOfRange);
68        }
69
70        Ok(Self(value))
71    }
72
73    pub const fn value(self) -> u8 {
74        self.0
75    }
76}
77
78impl fmt::Display for ScaleDegree {
79    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
80        self.0.fmt(formatter)
81    }
82}
83
84impl FromStr for ScaleDegree {
85    type Err = ScaleError;
86
87    fn from_str(value: &str) -> Result<Self, Self::Err> {
88        let parsed = value
89            .trim()
90            .parse::<u8>()
91            .map_err(|_| ScaleError::InvalidFormat)?;
92        Self::new(parsed)
93    }
94}
95
96impl TryFrom<u8> for ScaleDegree {
97    type Error = ScaleError;
98
99    fn try_from(value: u8) -> Result<Self, Self::Error> {
100        Self::new(value)
101    }
102}
103#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
104pub struct ScaleToneCount(u8);
105
106impl ScaleToneCount {
107    pub fn new(value: u8) -> Result<Self, ScaleError> {
108        if !(1..=64).contains(&value) {
109            return Err(ScaleError::OutOfRange);
110        }
111
112        Ok(Self(value))
113    }
114
115    pub const fn value(self) -> u8 {
116        self.0
117    }
118}
119
120impl fmt::Display for ScaleToneCount {
121    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
122        self.0.fmt(formatter)
123    }
124}
125
126impl FromStr for ScaleToneCount {
127    type Err = ScaleError;
128
129    fn from_str(value: &str) -> Result<Self, Self::Err> {
130        let parsed = value
131            .trim()
132            .parse::<u8>()
133            .map_err(|_| ScaleError::InvalidFormat)?;
134        Self::new(parsed)
135    }
136}
137
138impl TryFrom<u8> for ScaleToneCount {
139    type Error = ScaleError;
140
141    fn try_from(value: u8) -> Result<Self, Self::Error> {
142        Self::new(value)
143    }
144}
145#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
146pub enum ScaleKind {
147    Major,
148    NaturalMinor,
149    HarmonicMinor,
150    MelodicMinor,
151    Chromatic,
152    MajorPentatonic,
153    MinorPentatonic,
154    Blues,
155    WholeTone,
156    Diminished,
157    Custom,
158}
159
160impl ScaleKind {
161    pub const ALL: &'static [Self] = &[
162        Self::Major,
163        Self::NaturalMinor,
164        Self::HarmonicMinor,
165        Self::MelodicMinor,
166        Self::Chromatic,
167        Self::MajorPentatonic,
168        Self::MinorPentatonic,
169        Self::Blues,
170        Self::WholeTone,
171        Self::Diminished,
172        Self::Custom,
173    ];
174
175    pub const fn as_str(self) -> &'static str {
176        match self {
177            Self::Major => "major",
178            Self::NaturalMinor => "natural-minor",
179            Self::HarmonicMinor => "harmonic-minor",
180            Self::MelodicMinor => "melodic-minor",
181            Self::Chromatic => "chromatic",
182            Self::MajorPentatonic => "major-pentatonic",
183            Self::MinorPentatonic => "minor-pentatonic",
184            Self::Blues => "blues",
185            Self::WholeTone => "whole-tone",
186            Self::Diminished => "diminished",
187            Self::Custom => "custom",
188        }
189    }
190}
191
192impl fmt::Display for ScaleKind {
193    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
194        formatter.write_str(self.as_str())
195    }
196}
197
198impl FromStr for ScaleKind {
199    type Err = ScaleError;
200
201    fn from_str(value: &str) -> Result<Self, Self::Err> {
202        match normalized_label(value)?.as_str() {
203            "major" => Ok(Self::Major),
204            "natural-minor" => Ok(Self::NaturalMinor),
205            "harmonic-minor" => Ok(Self::HarmonicMinor),
206            "melodic-minor" => Ok(Self::MelodicMinor),
207            "chromatic" => Ok(Self::Chromatic),
208            "major-pentatonic" => Ok(Self::MajorPentatonic),
209            "minor-pentatonic" => Ok(Self::MinorPentatonic),
210            "blues" => Ok(Self::Blues),
211            "whole-tone" => Ok(Self::WholeTone),
212            "diminished" => Ok(Self::Diminished),
213            "custom" => Ok(Self::Custom),
214            _ => Err(ScaleError::UnknownLabel),
215        }
216    }
217}
218#[derive(Clone, Debug, Eq, PartialEq)]
219pub struct ScalePattern {
220    steps: Vec<u8>,
221}
222
223impl ScalePattern {
224    pub fn new(steps: impl Into<Vec<u8>>) -> Result<Self, ScaleError> {
225        let steps = steps.into();
226        if steps.is_empty() {
227            return Err(ScaleError::Empty);
228        }
229        Ok(Self { steps })
230    }
231
232    pub fn steps(&self) -> &[u8] {
233        &self.steps
234    }
235
236    pub fn tone_count(&self) -> ScaleToneCount {
237        ScaleToneCount(u8::try_from(self.steps.len()).unwrap_or(u8::MAX))
238    }
239
240    pub fn is_heptatonic(&self) -> bool {
241        self.steps.len() == 7
242    }
243    pub fn is_pentatonic(&self) -> bool {
244        self.steps.len() == 5
245    }
246    pub fn is_chromatic(&self) -> bool {
247        self.steps.len() == 12
248    }
249}
250
251pub type ScaleStepPattern = ScalePattern;
252pub type DiatonicScale = ScalePattern;
253pub type PentatonicScale = ScalePattern;
254pub type ChromaticScale = ScalePattern;
255#[derive(Clone, Copy, Debug, Eq, PartialEq)]
256pub enum ScaleError {
257    Empty,
258    InvalidFormat,
259    OutOfRange,
260    NonFinite,
261    NonPositive,
262    UnknownLabel,
263}
264
265impl fmt::Display for ScaleError {
266    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
267        match self {
268            Self::Empty => formatter.write_str("scale metadata text cannot be empty"),
269            Self::InvalidFormat => formatter.write_str("scale metadata has an invalid format"),
270            Self::OutOfRange => formatter.write_str("scale metadata value is out of range"),
271            Self::NonFinite => formatter.write_str("scale metadata value must be finite"),
272            Self::NonPositive => formatter.write_str("scale metadata value must be positive"),
273            Self::UnknownLabel => formatter.write_str("unknown scale metadata label"),
274        }
275    }
276}
277
278impl Error for ScaleError {}
279
280#[allow(dead_code)]
281fn non_empty_text(value: impl AsRef<str>) -> Result<String, ScaleError> {
282    let trimmed = value.as_ref().trim();
283    if trimmed.is_empty() {
284        Err(ScaleError::Empty)
285    } else {
286        Ok(trimmed.to_string())
287    }
288}
289
290fn normalized_label(value: &str) -> Result<String, ScaleError> {
291    let trimmed = value.trim();
292    if trimmed.is_empty() {
293        Err(ScaleError::Empty)
294    } else {
295        Ok(trimmed.to_ascii_lowercase().replace(['_', ' '], "-"))
296    }
297}
298#[cfg(test)]
299#[allow(
300    unused_imports,
301    clippy::unnecessary_wraps,
302    clippy::assertions_on_constants
303)]
304mod tests {
305    use super::{
306        ChromaticScale, DiatonicScale, PentatonicScale, ScaleDegree, ScaleError, ScaleKind,
307        ScaleName, ScalePattern, ScaleStepPattern, ScaleToneCount,
308    };
309    use core::{fmt, str::FromStr};
310
311    fn assert_enum_family<T>(variants: &[T]) -> Result<(), ScaleError>
312    where
313        T: Copy + Eq + fmt::Debug + fmt::Display + FromStr<Err = ScaleError>,
314    {
315        for variant in variants {
316            let label = variant.to_string();
317            assert_eq!(label.parse::<T>()?, *variant);
318            assert_eq!(label.replace('-', "_").parse::<T>()?, *variant);
319            assert_eq!(label.replace('-', " ").parse::<T>()?, *variant);
320        }
321        Ok(())
322    }
323
324    #[test]
325    fn validates_text_newtypes() -> Result<(), ScaleError> {
326        let value = ScaleName::new(" example-value ")?;
327        assert_eq!(value.as_str(), "example-value");
328        assert_eq!(value.value(), "example-value");
329        assert_eq!(value.to_string(), "example-value");
330        assert_eq!(
331            <ScaleName as TryFrom<&str>>::try_from("example-value")?,
332            value
333        );
334        Ok(())
335    }
336
337    #[test]
338    fn validates_numeric_newtypes() -> Result<(), ScaleError> {
339        let value = ScaleDegree::new(1)?;
340        assert_eq!(value.value(), 1);
341        assert_eq!("1".parse::<ScaleDegree>()?, value);
342        assert_eq!(ScaleDegree::new(65), Err(ScaleError::OutOfRange));
343        let value = ScaleToneCount::new(1)?;
344        assert_eq!(value.value(), 1);
345        assert_eq!("1".parse::<ScaleToneCount>()?, value);
346        assert_eq!(ScaleToneCount::new(65), Err(ScaleError::OutOfRange));
347        Ok(())
348    }
349
350    #[test]
351    fn displays_and_parses_enums() -> Result<(), ScaleError> {
352        assert_enum_family(ScaleKind::ALL)?;
353        Ok(())
354    }
355
356    #[test]
357    fn classifies_scale_patterns() -> Result<(), ScaleError> {
358        let major = ScalePattern::new([2, 2, 1, 2, 2, 2, 1])?;
359        let pentatonic = ScalePattern::new([2, 2, 3, 2, 3])?;
360        let chromatic = ScalePattern::new([1; 12])?;
361        assert!(major.is_heptatonic());
362        assert!(pentatonic.is_pentatonic());
363        assert!(chromatic.is_chromatic());
364        assert_eq!(major.tone_count().value(), 7);
365        Ok(())
366    }
367}