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