Skip to main content

use_star/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7fn normalized_key(value: &str) -> String {
8    value
9        .trim()
10        .chars()
11        .map(|character| match character {
12            '_' | ' ' => '-',
13            other => other.to_ascii_lowercase(),
14        })
15        .collect()
16}
17
18fn non_empty_text(value: impl AsRef<str>, error: StarError) -> Result<String, StarError> {
19    let trimmed = value.as_ref().trim();
20
21    if trimmed.is_empty() {
22        Err(error)
23    } else {
24        Ok(trimmed.to_string())
25    }
26}
27
28#[derive(Clone, Copy, Debug, Eq, PartialEq)]
29pub enum StarError {
30    EmptyName,
31    NonFiniteMass,
32    NegativeMass,
33}
34
35impl fmt::Display for StarError {
36    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
37        match self {
38            Self::EmptyName => formatter.write_str("star name cannot be empty"),
39            Self::NonFiniteMass => formatter.write_str("stellar mass must be finite"),
40            Self::NegativeMass => formatter.write_str("stellar mass cannot be negative"),
41        }
42    }
43}
44
45impl Error for StarError {}
46
47#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
48pub struct StarName(String);
49
50impl StarName {
51    /// Creates a star name from non-empty text.
52    ///
53    /// # Errors
54    ///
55    /// Returns [`StarError::EmptyName`] when the trimmed input is empty.
56    pub fn new(value: impl AsRef<str>) -> Result<Self, StarError> {
57        non_empty_text(value, StarError::EmptyName).map(Self)
58    }
59
60    #[must_use]
61    pub fn as_str(&self) -> &str {
62        &self.0
63    }
64
65    #[must_use]
66    pub fn into_string(self) -> String {
67        self.0
68    }
69}
70
71impl AsRef<str> for StarName {
72    fn as_ref(&self) -> &str {
73        self.as_str()
74    }
75}
76
77impl fmt::Display for StarName {
78    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
79        formatter.write_str(self.as_str())
80    }
81}
82
83impl FromStr for StarName {
84    type Err = StarError;
85
86    fn from_str(value: &str) -> Result<Self, Self::Err> {
87        Self::new(value)
88    }
89}
90
91#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
92pub enum StarKind {
93    MainSequence,
94    RedGiant,
95    WhiteDwarf,
96    NeutronStar,
97    Protostar,
98    Supergiant,
99    BrownDwarf,
100    Variable,
101    Binary,
102    Unknown,
103    Custom(String),
104}
105
106impl fmt::Display for StarKind {
107    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
108        match self {
109            Self::MainSequence => formatter.write_str("main-sequence"),
110            Self::RedGiant => formatter.write_str("red-giant"),
111            Self::WhiteDwarf => formatter.write_str("white-dwarf"),
112            Self::NeutronStar => formatter.write_str("neutron-star"),
113            Self::Protostar => formatter.write_str("protostar"),
114            Self::Supergiant => formatter.write_str("supergiant"),
115            Self::BrownDwarf => formatter.write_str("brown-dwarf"),
116            Self::Variable => formatter.write_str("variable"),
117            Self::Binary => formatter.write_str("binary"),
118            Self::Unknown => formatter.write_str("unknown"),
119            Self::Custom(value) => formatter.write_str(value),
120        }
121    }
122}
123
124#[derive(Clone, Copy, Debug, Eq, PartialEq)]
125pub enum StarKindParseError {
126    Empty,
127}
128
129impl fmt::Display for StarKindParseError {
130    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
131        match self {
132            Self::Empty => formatter.write_str("star kind cannot be empty"),
133        }
134    }
135}
136
137impl Error for StarKindParseError {}
138
139impl FromStr for StarKind {
140    type Err = StarKindParseError;
141
142    fn from_str(value: &str) -> Result<Self, Self::Err> {
143        let trimmed = value.trim();
144
145        if trimmed.is_empty() {
146            return Err(StarKindParseError::Empty);
147        }
148
149        match normalized_key(trimmed).as_str() {
150            "main-sequence" | "mainsequence" => Ok(Self::MainSequence),
151            "red-giant" | "redgiant" => Ok(Self::RedGiant),
152            "white-dwarf" | "whitedwarf" => Ok(Self::WhiteDwarf),
153            "neutron-star" | "neutronstar" => Ok(Self::NeutronStar),
154            "protostar" => Ok(Self::Protostar),
155            "supergiant" => Ok(Self::Supergiant),
156            "brown-dwarf" | "browndwarf" => Ok(Self::BrownDwarf),
157            "variable" => Ok(Self::Variable),
158            "binary" => Ok(Self::Binary),
159            "unknown" => Ok(Self::Unknown),
160            _ => Ok(Self::Custom(trimmed.to_string())),
161        }
162    }
163}
164
165#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
166pub enum SpectralClass {
167    O,
168    B,
169    A,
170    F,
171    G,
172    K,
173    M,
174    L,
175    T,
176    Y,
177    Unknown,
178    Custom(String),
179}
180
181impl fmt::Display for SpectralClass {
182    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
183        match self {
184            Self::O => formatter.write_str("o"),
185            Self::B => formatter.write_str("b"),
186            Self::A => formatter.write_str("a"),
187            Self::F => formatter.write_str("f"),
188            Self::G => formatter.write_str("g"),
189            Self::K => formatter.write_str("k"),
190            Self::M => formatter.write_str("m"),
191            Self::L => formatter.write_str("l"),
192            Self::T => formatter.write_str("t"),
193            Self::Y => formatter.write_str("y"),
194            Self::Unknown => formatter.write_str("unknown"),
195            Self::Custom(value) => formatter.write_str(value),
196        }
197    }
198}
199
200#[derive(Clone, Copy, Debug, Eq, PartialEq)]
201pub enum SpectralClassParseError {
202    Empty,
203}
204
205impl fmt::Display for SpectralClassParseError {
206    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
207        match self {
208            Self::Empty => formatter.write_str("spectral class cannot be empty"),
209        }
210    }
211}
212
213impl Error for SpectralClassParseError {}
214
215impl FromStr for SpectralClass {
216    type Err = SpectralClassParseError;
217
218    fn from_str(value: &str) -> Result<Self, Self::Err> {
219        let trimmed = value.trim();
220
221        if trimmed.is_empty() {
222            return Err(SpectralClassParseError::Empty);
223        }
224
225        match trimmed.to_ascii_uppercase().as_str() {
226            "O" => Ok(Self::O),
227            "B" => Ok(Self::B),
228            "A" => Ok(Self::A),
229            "F" => Ok(Self::F),
230            "G" => Ok(Self::G),
231            "K" => Ok(Self::K),
232            "M" => Ok(Self::M),
233            "L" => Ok(Self::L),
234            "T" => Ok(Self::T),
235            "Y" => Ok(Self::Y),
236            "UNKNOWN" => Ok(Self::Unknown),
237            _ => Ok(Self::Custom(trimmed.to_string())),
238        }
239    }
240}
241
242#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
243pub enum LuminosityClass {
244    Ia,
245    Ib,
246    II,
247    III,
248    IV,
249    V,
250    VI,
251    Unknown,
252    Custom(String),
253}
254
255impl fmt::Display for LuminosityClass {
256    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
257        match self {
258            Self::Ia => formatter.write_str("ia"),
259            Self::Ib => formatter.write_str("ib"),
260            Self::II => formatter.write_str("ii"),
261            Self::III => formatter.write_str("iii"),
262            Self::IV => formatter.write_str("iv"),
263            Self::V => formatter.write_str("v"),
264            Self::VI => formatter.write_str("vi"),
265            Self::Unknown => formatter.write_str("unknown"),
266            Self::Custom(value) => formatter.write_str(value),
267        }
268    }
269}
270
271#[derive(Clone, Copy, Debug, Eq, PartialEq)]
272pub enum LuminosityClassParseError {
273    Empty,
274}
275
276impl fmt::Display for LuminosityClassParseError {
277    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
278        match self {
279            Self::Empty => formatter.write_str("luminosity class cannot be empty"),
280        }
281    }
282}
283
284impl Error for LuminosityClassParseError {}
285
286impl FromStr for LuminosityClass {
287    type Err = LuminosityClassParseError;
288
289    fn from_str(value: &str) -> Result<Self, Self::Err> {
290        let trimmed = value.trim();
291
292        if trimmed.is_empty() {
293            return Err(LuminosityClassParseError::Empty);
294        }
295
296        match trimmed.to_ascii_lowercase().as_str() {
297            "ia" => Ok(Self::Ia),
298            "ib" => Ok(Self::Ib),
299            "ii" => Ok(Self::II),
300            "iii" => Ok(Self::III),
301            "iv" => Ok(Self::IV),
302            "v" => Ok(Self::V),
303            "vi" => Ok(Self::VI),
304            "unknown" => Ok(Self::Unknown),
305            _ => Ok(Self::Custom(trimmed.to_string())),
306        }
307    }
308}
309
310#[derive(Clone, Copy, Debug, PartialEq, PartialOrd)]
311pub struct StellarMass(f64);
312
313impl StellarMass {
314    /// Creates a stellar mass from a finite, non-negative solar-mass value.
315    ///
316    /// # Errors
317    ///
318    /// Returns [`StarError::NonFiniteMass`] when `value` is not finite, or
319    /// [`StarError::NegativeMass`] when `value` is negative.
320    pub const fn new(value: f64) -> Result<Self, StarError> {
321        if !value.is_finite() {
322            return Err(StarError::NonFiniteMass);
323        }
324
325        if value < 0.0 {
326            return Err(StarError::NegativeMass);
327        }
328
329        Ok(Self(value))
330    }
331
332    #[must_use]
333    pub const fn solar_masses(self) -> f64 {
334        self.0
335    }
336}
337
338impl fmt::Display for StellarMass {
339    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
340        self.solar_masses().fmt(formatter)
341    }
342}
343
344#[cfg(test)]
345mod tests {
346    use super::{LuminosityClass, SpectralClass, StarError, StarKind, StarName, StellarMass};
347
348    #[test]
349    fn valid_star_name() {
350        let name = StarName::new("Sirius A").unwrap();
351
352        assert_eq!(name.as_str(), "Sirius A");
353    }
354
355    #[test]
356    fn empty_star_name_rejected() {
357        assert_eq!(StarName::new("   "), Err(StarError::EmptyName));
358    }
359
360    #[test]
361    fn star_kind_display_and_parse() {
362        assert_eq!(StarKind::MainSequence.to_string(), "main-sequence");
363        assert_eq!("red giant".parse::<StarKind>().unwrap(), StarKind::RedGiant);
364    }
365
366    #[test]
367    fn spectral_class_display_and_parse() {
368        assert_eq!(SpectralClass::G.to_string(), "g");
369        assert_eq!("k".parse::<SpectralClass>().unwrap(), SpectralClass::K);
370    }
371
372    #[test]
373    fn luminosity_class_display_and_parse() {
374        assert_eq!(LuminosityClass::V.to_string(), "v");
375        assert_eq!(
376            "iii".parse::<LuminosityClass>().unwrap(),
377            LuminosityClass::III
378        );
379    }
380
381    #[test]
382    fn valid_stellar_mass() {
383        let mass = StellarMass::new(1.0).unwrap();
384
385        assert!((mass.solar_masses() - 1.0).abs() < f64::EPSILON);
386    }
387
388    #[test]
389    fn negative_stellar_mass_rejected() {
390        assert_eq!(StellarMass::new(-0.1), Err(StarError::NegativeMass));
391    }
392}