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 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 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}