1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7fn normalized_token(value: &str) -> String {
8 value.trim().to_ascii_lowercase().replace(['_', ' '], "-")
9}
10
11fn non_empty_text(value: impl AsRef<str>) -> Result<String, TrophicTextError> {
12 let trimmed = value.as_ref().trim();
13
14 if trimmed.is_empty() {
15 Err(TrophicTextError::Empty)
16 } else {
17 Ok(trimmed.to_string())
18 }
19}
20
21#[derive(Clone, Copy, Debug, Eq, PartialEq)]
22pub enum TrophicTextError {
23 Empty,
24}
25
26impl fmt::Display for TrophicTextError {
27 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
28 match self {
29 Self::Empty => formatter.write_str("trophic text cannot be empty"),
30 }
31 }
32}
33
34impl Error for TrophicTextError {}
35
36#[derive(Clone, Copy, Debug, Eq, PartialEq)]
37pub enum TrophicValueError {
38 Negative,
39 NonFinite,
40}
41
42impl fmt::Display for TrophicValueError {
43 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
44 match self {
45 Self::Negative => formatter.write_str("trophic value cannot be negative"),
46 Self::NonFinite => formatter.write_str("trophic value must be finite"),
47 }
48 }
49}
50
51impl Error for TrophicValueError {}
52
53#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
54pub enum TrophicLevel {
55 PrimaryProducer,
56 PrimaryConsumer,
57 SecondaryConsumer,
58 TertiaryConsumer,
59 QuaternaryConsumer,
60 Decomposer,
61 Detritivore,
62 Omnivore,
63 Unknown,
64 Custom(String),
65}
66
67impl fmt::Display for TrophicLevel {
68 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
69 formatter.write_str(match self {
70 Self::PrimaryProducer => "primary-producer",
71 Self::PrimaryConsumer => "primary-consumer",
72 Self::SecondaryConsumer => "secondary-consumer",
73 Self::TertiaryConsumer => "tertiary-consumer",
74 Self::QuaternaryConsumer => "quaternary-consumer",
75 Self::Decomposer => "decomposer",
76 Self::Detritivore => "detritivore",
77 Self::Omnivore => "omnivore",
78 Self::Unknown => "unknown",
79 Self::Custom(value) => value.as_str(),
80 })
81 }
82}
83
84impl FromStr for TrophicLevel {
85 type Err = TrophicLevelParseError;
86
87 fn from_str(value: &str) -> Result<Self, Self::Err> {
88 let trimmed = value.trim();
89
90 if trimmed.is_empty() {
91 return Err(TrophicLevelParseError::Empty);
92 }
93
94 Ok(match normalized_token(trimmed).as_str() {
95 "primary-producer" => Self::PrimaryProducer,
96 "primary-consumer" => Self::PrimaryConsumer,
97 "secondary-consumer" => Self::SecondaryConsumer,
98 "tertiary-consumer" => Self::TertiaryConsumer,
99 "quaternary-consumer" => Self::QuaternaryConsumer,
100 "decomposer" => Self::Decomposer,
101 "detritivore" => Self::Detritivore,
102 "omnivore" => Self::Omnivore,
103 "unknown" => Self::Unknown,
104 _ => Self::Custom(trimmed.to_string()),
105 })
106 }
107}
108
109#[derive(Clone, Copy, Debug, Eq, PartialEq)]
110pub enum TrophicLevelParseError {
111 Empty,
112}
113
114impl fmt::Display for TrophicLevelParseError {
115 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
116 match self {
117 Self::Empty => formatter.write_str("trophic level cannot be empty"),
118 }
119 }
120}
121
122impl Error for TrophicLevelParseError {}
123
124#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
125pub struct TrophicRole(String);
126
127impl TrophicRole {
128 pub fn new(value: impl AsRef<str>) -> Result<Self, TrophicTextError> {
131 non_empty_text(value).map(Self)
132 }
133
134 #[must_use]
135 pub fn as_str(&self) -> &str {
136 &self.0
137 }
138}
139
140impl fmt::Display for TrophicRole {
141 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
142 formatter.write_str(self.as_str())
143 }
144}
145
146impl FromStr for TrophicRole {
147 type Err = TrophicTextError;
148
149 fn from_str(value: &str) -> Result<Self, Self::Err> {
150 Self::new(value)
151 }
152}
153
154#[derive(Clone, Copy, Debug, PartialEq, PartialOrd)]
155pub struct TrophicPosition(f64);
156
157impl TrophicPosition {
158 pub fn new(value: f64) -> Result<Self, TrophicValueError> {
162 if !value.is_finite() {
163 return Err(TrophicValueError::NonFinite);
164 }
165
166 if value < 0.0 {
167 return Err(TrophicValueError::Negative);
168 }
169
170 Ok(Self(value))
171 }
172
173 #[must_use]
174 pub const fn get(self) -> f64 {
175 self.0
176 }
177}
178
179impl fmt::Display for TrophicPosition {
180 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
181 self.0.fmt(formatter)
182 }
183}
184
185#[cfg(test)]
186mod tests {
187 use super::{TrophicLevel, TrophicPosition, TrophicRole, TrophicTextError, TrophicValueError};
188
189 #[test]
190 fn trophic_level_display_parse() {
191 assert_eq!(
192 "primary-consumer".parse::<TrophicLevel>(),
193 Ok(TrophicLevel::PrimaryConsumer)
194 );
195 assert_eq!(TrophicLevel::Decomposer.to_string(), "decomposer");
196 }
197
198 #[test]
199 fn custom_trophic_level() {
200 assert_eq!(
201 "suspension-feeder".parse::<TrophicLevel>(),
202 Ok(TrophicLevel::Custom("suspension-feeder".to_string()))
203 );
204 }
205
206 #[test]
207 fn valid_trophic_position() -> Result<(), TrophicValueError> {
208 let position = TrophicPosition::new(2.5)?;
209
210 assert!((position.get() - 2.5).abs() < f64::EPSILON);
211 Ok(())
212 }
213
214 #[test]
215 fn negative_trophic_position_rejected() {
216 assert_eq!(TrophicPosition::new(-1.0), Err(TrophicValueError::Negative));
217 }
218
219 #[test]
220 fn trophic_role_construction() -> Result<(), TrophicTextError> {
221 let role = TrophicRole::new("reef grazer")?;
222
223 assert_eq!(role.to_string(), "reef grazer");
224 Ok(())
225 }
226}