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, FoodWebTextError> {
12 let trimmed = value.as_ref().trim();
13
14 if trimmed.is_empty() {
15 Err(FoodWebTextError::Empty)
16 } else {
17 Ok(trimmed.to_string())
18 }
19}
20
21#[derive(Clone, Copy, Debug, Eq, PartialEq)]
22pub enum FoodWebTextError {
23 Empty,
24}
25
26impl fmt::Display for FoodWebTextError {
27 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
28 match self {
29 Self::Empty => formatter.write_str("food web text cannot be empty"),
30 }
31 }
32}
33
34impl Error for FoodWebTextError {}
35
36#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
37pub struct FoodWebName(String);
38
39impl FoodWebName {
40 pub fn new(value: impl AsRef<str>) -> Result<Self, FoodWebTextError> {
43 non_empty_text(value).map(Self)
44 }
45
46 #[must_use]
47 pub fn as_str(&self) -> &str {
48 &self.0
49 }
50}
51
52impl fmt::Display for FoodWebName {
53 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
54 formatter.write_str(self.as_str())
55 }
56}
57
58impl FromStr for FoodWebName {
59 type Err = FoodWebTextError;
60
61 fn from_str(value: &str) -> Result<Self, Self::Err> {
62 Self::new(value)
63 }
64}
65
66#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
67pub enum FeedingRelation {
68 Consumes,
69 PreysOn,
70 Grazes,
71 Parasitizes,
72 Scavenges,
73 Decomposes,
74 Unknown,
75 Custom(String),
76}
77
78impl fmt::Display for FeedingRelation {
79 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
80 formatter.write_str(match self {
81 Self::Consumes => "consumes",
82 Self::PreysOn => "preys-on",
83 Self::Grazes => "grazes",
84 Self::Parasitizes => "parasitizes",
85 Self::Scavenges => "scavenges",
86 Self::Decomposes => "decomposes",
87 Self::Unknown => "unknown",
88 Self::Custom(value) => value.as_str(),
89 })
90 }
91}
92
93impl FromStr for FeedingRelation {
94 type Err = FeedingRelationParseError;
95
96 fn from_str(value: &str) -> Result<Self, Self::Err> {
97 let trimmed = value.trim();
98
99 if trimmed.is_empty() {
100 return Err(FeedingRelationParseError::Empty);
101 }
102
103 Ok(match normalized_token(trimmed).as_str() {
104 "consumes" => Self::Consumes,
105 "preys-on" => Self::PreysOn,
106 "grazes" => Self::Grazes,
107 "parasitizes" => Self::Parasitizes,
108 "scavenges" => Self::Scavenges,
109 "decomposes" => Self::Decomposes,
110 "unknown" => Self::Unknown,
111 _ => Self::Custom(trimmed.to_string()),
112 })
113 }
114}
115
116#[derive(Clone, Copy, Debug, Eq, PartialEq)]
117pub enum FeedingRelationParseError {
118 Empty,
119}
120
121impl fmt::Display for FeedingRelationParseError {
122 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
123 match self {
124 Self::Empty => formatter.write_str("feeding relation cannot be empty"),
125 }
126 }
127}
128
129impl Error for FeedingRelationParseError {}
130
131#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
132pub enum EnergyFlowDirection {
133 ProducerToConsumer,
134 PreyToPredator,
135 DetritusToDecomposer,
136 Unknown,
137 Custom(String),
138}
139
140impl fmt::Display for EnergyFlowDirection {
141 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
142 formatter.write_str(match self {
143 Self::ProducerToConsumer => "producer-to-consumer",
144 Self::PreyToPredator => "prey-to-predator",
145 Self::DetritusToDecomposer => "detritus-to-decomposer",
146 Self::Unknown => "unknown",
147 Self::Custom(value) => value.as_str(),
148 })
149 }
150}
151
152impl FromStr for EnergyFlowDirection {
153 type Err = EnergyFlowDirectionParseError;
154
155 fn from_str(value: &str) -> Result<Self, Self::Err> {
156 let trimmed = value.trim();
157
158 if trimmed.is_empty() {
159 return Err(EnergyFlowDirectionParseError::Empty);
160 }
161
162 Ok(match normalized_token(trimmed).as_str() {
163 "producer-to-consumer" => Self::ProducerToConsumer,
164 "prey-to-predator" => Self::PreyToPredator,
165 "detritus-to-decomposer" => Self::DetritusToDecomposer,
166 "unknown" => Self::Unknown,
167 _ => Self::Custom(trimmed.to_string()),
168 })
169 }
170}
171
172#[derive(Clone, Copy, Debug, Eq, PartialEq)]
173pub enum EnergyFlowDirectionParseError {
174 Empty,
175}
176
177impl fmt::Display for EnergyFlowDirectionParseError {
178 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
179 match self {
180 Self::Empty => formatter.write_str("energy flow direction cannot be empty"),
181 }
182 }
183}
184
185impl Error for EnergyFlowDirectionParseError {}
186
187#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
188pub struct FoodWebLink {
189 source: String,
190 target: String,
191 relation: FeedingRelation,
192}
193
194impl FoodWebLink {
195 pub fn new(
198 source: impl AsRef<str>,
199 target: impl AsRef<str>,
200 relation: FeedingRelation,
201 ) -> Result<Self, FoodWebTextError> {
202 Ok(Self {
203 source: non_empty_text(source)?,
204 target: non_empty_text(target)?,
205 relation,
206 })
207 }
208
209 #[must_use]
210 pub fn source(&self) -> &str {
211 &self.source
212 }
213
214 #[must_use]
215 pub fn target(&self) -> &str {
216 &self.target
217 }
218
219 #[must_use]
220 pub const fn relation(&self) -> &FeedingRelation {
221 &self.relation
222 }
223}
224
225impl fmt::Display for FoodWebLink {
226 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
227 write!(
228 formatter,
229 "{} -[{}]-> {}",
230 self.source, self.relation, self.target
231 )
232 }
233}
234
235#[cfg(test)]
236mod tests {
237 use super::{EnergyFlowDirection, FeedingRelation, FoodWebLink, FoodWebName, FoodWebTextError};
238
239 #[test]
240 fn valid_food_web_name() -> Result<(), FoodWebTextError> {
241 let name = FoodWebName::new("shelf web")?;
242
243 assert_eq!(name.as_str(), "shelf web");
244 Ok(())
245 }
246
247 #[test]
248 fn empty_food_web_name_rejected() {
249 assert_eq!(FoodWebName::new(""), Err(FoodWebTextError::Empty));
250 }
251
252 #[test]
253 fn feeding_relation_display_parse() {
254 assert_eq!(
255 "consumes".parse::<FeedingRelation>(),
256 Ok(FeedingRelation::Consumes)
257 );
258 assert_eq!(FeedingRelation::PreysOn.to_string(), "preys-on");
259 }
260
261 #[test]
262 fn energy_flow_direction_display_parse() {
263 assert_eq!(
264 "prey-to-predator".parse::<EnergyFlowDirection>(),
265 Ok(EnergyFlowDirection::PreyToPredator)
266 );
267 assert_eq!(
268 EnergyFlowDirection::DetritusToDecomposer.to_string(),
269 "detritus-to-decomposer"
270 );
271 }
272
273 #[test]
274 fn food_web_link_construction() -> Result<(), FoodWebTextError> {
275 let link = FoodWebLink::new("zooplankton", "anchovy", FeedingRelation::Consumes)?;
276
277 assert_eq!(link.source(), "zooplankton");
278 assert_eq!(link.target(), "anchovy");
279 assert_eq!(link.to_string(), "zooplankton -[consumes]-> anchovy");
280 Ok(())
281 }
282}