Skip to main content

use_celestial_body/
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(
19    value: impl AsRef<str>,
20    error: CelestialBodyTextError,
21) -> Result<String, CelestialBodyTextError> {
22    let trimmed = value.as_ref().trim();
23
24    if trimmed.is_empty() {
25        Err(error)
26    } else {
27        Ok(trimmed.to_string())
28    }
29}
30
31#[derive(Clone, Copy, Debug, Eq, PartialEq)]
32pub enum CelestialBodyTextError {
33    EmptyName,
34    EmptyId,
35}
36
37impl fmt::Display for CelestialBodyTextError {
38    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
39        match self {
40            Self::EmptyName => formatter.write_str("celestial body name cannot be empty"),
41            Self::EmptyId => formatter.write_str("celestial body identifier cannot be empty"),
42        }
43    }
44}
45
46impl Error for CelestialBodyTextError {}
47
48#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
49pub struct CelestialBodyName(String);
50
51impl CelestialBodyName {
52    /// Creates a celestial body name from non-empty text.
53    ///
54    /// # Errors
55    ///
56    /// Returns [`CelestialBodyTextError::EmptyName`] when the trimmed input is empty.
57    pub fn new(value: impl AsRef<str>) -> Result<Self, CelestialBodyTextError> {
58        non_empty_text(value, CelestialBodyTextError::EmptyName).map(Self)
59    }
60
61    #[must_use]
62    pub fn as_str(&self) -> &str {
63        &self.0
64    }
65
66    #[must_use]
67    pub fn into_string(self) -> String {
68        self.0
69    }
70}
71
72impl AsRef<str> for CelestialBodyName {
73    fn as_ref(&self) -> &str {
74        self.as_str()
75    }
76}
77
78impl fmt::Display for CelestialBodyName {
79    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
80        formatter.write_str(self.as_str())
81    }
82}
83
84impl FromStr for CelestialBodyName {
85    type Err = CelestialBodyTextError;
86
87    fn from_str(value: &str) -> Result<Self, Self::Err> {
88        Self::new(value)
89    }
90}
91
92#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
93pub struct CelestialBodyId(String);
94
95impl CelestialBodyId {
96    /// Creates a celestial body identifier from non-empty text.
97    ///
98    /// # Errors
99    ///
100    /// Returns [`CelestialBodyTextError::EmptyId`] when the trimmed input is empty.
101    pub fn new(value: impl AsRef<str>) -> Result<Self, CelestialBodyTextError> {
102        non_empty_text(value, CelestialBodyTextError::EmptyId).map(Self)
103    }
104
105    #[must_use]
106    pub fn as_str(&self) -> &str {
107        &self.0
108    }
109
110    #[must_use]
111    pub fn into_string(self) -> String {
112        self.0
113    }
114}
115
116impl AsRef<str> for CelestialBodyId {
117    fn as_ref(&self) -> &str {
118        self.as_str()
119    }
120}
121
122impl fmt::Display for CelestialBodyId {
123    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
124        formatter.write_str(self.as_str())
125    }
126}
127
128impl FromStr for CelestialBodyId {
129    type Err = CelestialBodyTextError;
130
131    fn from_str(value: &str) -> Result<Self, Self::Err> {
132        Self::new(value)
133    }
134}
135
136#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
137pub enum CelestialBodyKind {
138    Star,
139    Planet,
140    DwarfPlanet,
141    Moon,
142    Asteroid,
143    Comet,
144    Meteoroid,
145    Nebula,
146    Galaxy,
147    BlackHole,
148    StarCluster,
149    Unknown,
150    Custom(String),
151}
152
153impl fmt::Display for CelestialBodyKind {
154    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
155        match self {
156            Self::Star => formatter.write_str("star"),
157            Self::Planet => formatter.write_str("planet"),
158            Self::DwarfPlanet => formatter.write_str("dwarf-planet"),
159            Self::Moon => formatter.write_str("moon"),
160            Self::Asteroid => formatter.write_str("asteroid"),
161            Self::Comet => formatter.write_str("comet"),
162            Self::Meteoroid => formatter.write_str("meteoroid"),
163            Self::Nebula => formatter.write_str("nebula"),
164            Self::Galaxy => formatter.write_str("galaxy"),
165            Self::BlackHole => formatter.write_str("black-hole"),
166            Self::StarCluster => formatter.write_str("star-cluster"),
167            Self::Unknown => formatter.write_str("unknown"),
168            Self::Custom(value) => formatter.write_str(value),
169        }
170    }
171}
172
173#[derive(Clone, Copy, Debug, Eq, PartialEq)]
174pub enum CelestialBodyKindParseError {
175    Empty,
176}
177
178impl fmt::Display for CelestialBodyKindParseError {
179    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
180        match self {
181            Self::Empty => formatter.write_str("celestial body kind cannot be empty"),
182        }
183    }
184}
185
186impl Error for CelestialBodyKindParseError {}
187
188impl FromStr for CelestialBodyKind {
189    type Err = CelestialBodyKindParseError;
190
191    fn from_str(value: &str) -> Result<Self, Self::Err> {
192        let trimmed = value.trim();
193
194        if trimmed.is_empty() {
195            return Err(CelestialBodyKindParseError::Empty);
196        }
197
198        match normalized_key(trimmed).as_str() {
199            "star" => Ok(Self::Star),
200            "planet" => Ok(Self::Planet),
201            "dwarf-planet" | "dwarfplanet" => Ok(Self::DwarfPlanet),
202            "moon" => Ok(Self::Moon),
203            "asteroid" => Ok(Self::Asteroid),
204            "comet" => Ok(Self::Comet),
205            "meteoroid" => Ok(Self::Meteoroid),
206            "nebula" => Ok(Self::Nebula),
207            "galaxy" => Ok(Self::Galaxy),
208            "black-hole" | "blackhole" => Ok(Self::BlackHole),
209            "star-cluster" | "starcluster" => Ok(Self::StarCluster),
210            "unknown" => Ok(Self::Unknown),
211            _ => Ok(Self::Custom(trimmed.to_string())),
212        }
213    }
214}
215
216#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
217pub enum CelestialBodyStatus {
218    Confirmed,
219    Candidate,
220    Provisional,
221    Retired,
222    Unknown,
223    Custom(String),
224}
225
226impl fmt::Display for CelestialBodyStatus {
227    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
228        match self {
229            Self::Confirmed => formatter.write_str("confirmed"),
230            Self::Candidate => formatter.write_str("candidate"),
231            Self::Provisional => formatter.write_str("provisional"),
232            Self::Retired => formatter.write_str("retired"),
233            Self::Unknown => formatter.write_str("unknown"),
234            Self::Custom(value) => formatter.write_str(value),
235        }
236    }
237}
238
239#[derive(Clone, Copy, Debug, Eq, PartialEq)]
240pub enum CelestialBodyStatusParseError {
241    Empty,
242}
243
244impl fmt::Display for CelestialBodyStatusParseError {
245    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
246        match self {
247            Self::Empty => formatter.write_str("celestial body status cannot be empty"),
248        }
249    }
250}
251
252impl Error for CelestialBodyStatusParseError {}
253
254impl FromStr for CelestialBodyStatus {
255    type Err = CelestialBodyStatusParseError;
256
257    fn from_str(value: &str) -> Result<Self, Self::Err> {
258        let trimmed = value.trim();
259
260        if trimmed.is_empty() {
261            return Err(CelestialBodyStatusParseError::Empty);
262        }
263
264        match normalized_key(trimmed).as_str() {
265            "confirmed" => Ok(Self::Confirmed),
266            "candidate" => Ok(Self::Candidate),
267            "provisional" => Ok(Self::Provisional),
268            "retired" => Ok(Self::Retired),
269            "unknown" => Ok(Self::Unknown),
270            _ => Ok(Self::Custom(trimmed.to_string())),
271        }
272    }
273}
274
275#[cfg(test)]
276mod tests {
277    use super::{
278        CelestialBodyId, CelestialBodyKind, CelestialBodyName, CelestialBodyStatus,
279        CelestialBodyTextError,
280    };
281
282    #[test]
283    fn valid_celestial_body_name() {
284        let name = CelestialBodyName::new("Alpha Centauri A").unwrap();
285
286        assert_eq!(name.as_str(), "Alpha Centauri A");
287        assert_eq!(name.to_string(), "Alpha Centauri A");
288    }
289
290    #[test]
291    fn empty_celestial_body_name_rejected() {
292        assert_eq!(
293            CelestialBodyName::new("   "),
294            Err(CelestialBodyTextError::EmptyName)
295        );
296    }
297
298    #[test]
299    fn body_kind_display_and_parse() {
300        assert_eq!(CelestialBodyKind::BlackHole.to_string(), "black-hole");
301        assert_eq!(
302            "dwarf planet".parse::<CelestialBodyKind>().unwrap(),
303            CelestialBodyKind::DwarfPlanet
304        );
305    }
306
307    #[test]
308    fn custom_body_kind() {
309        assert_eq!(
310            "proto-planetary disk".parse::<CelestialBodyKind>().unwrap(),
311            CelestialBodyKind::Custom("proto-planetary disk".to_string())
312        );
313    }
314
315    #[test]
316    fn body_status_display_and_parse() {
317        assert_eq!(CelestialBodyStatus::Confirmed.to_string(), "confirmed");
318        assert_eq!(
319            "provisional".parse::<CelestialBodyStatus>().unwrap(),
320            CelestialBodyStatus::Provisional
321        );
322    }
323
324    #[test]
325    fn body_id_construction() {
326        let identifier = CelestialBodyId::new("HIP 71683").unwrap();
327
328        assert_eq!(identifier.as_str(), "HIP 71683");
329    }
330}