1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::{collections::BTreeSet, 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, CommunityTextError> {
12 let trimmed = value.as_ref().trim();
13
14 if trimmed.is_empty() {
15 Err(CommunityTextError::Empty)
16 } else {
17 Ok(trimmed.to_string())
18 }
19}
20
21#[derive(Clone, Copy, Debug, Eq, PartialEq)]
22pub enum CommunityTextError {
23 Empty,
24}
25
26impl fmt::Display for CommunityTextError {
27 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
28 match self {
29 Self::Empty => formatter.write_str("community text cannot be empty"),
30 }
31 }
32}
33
34impl Error for CommunityTextError {}
35
36#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
37pub struct CommunityName(String);
38
39impl CommunityName {
40 pub fn new(value: impl AsRef<str>) -> Result<Self, CommunityTextError> {
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 CommunityName {
53 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
54 formatter.write_str(self.as_str())
55 }
56}
57
58impl FromStr for CommunityName {
59 type Err = CommunityTextError;
60
61 fn from_str(value: &str) -> Result<Self, Self::Err> {
62 Self::new(value)
63 }
64}
65
66#[derive(Clone, Debug, Eq, PartialEq)]
67pub struct CommunityComposition {
68 labels: BTreeSet<String>,
69}
70
71impl CommunityComposition {
72 pub fn new<I, S>(labels: I) -> Result<Self, CommunityTextError>
75 where
76 I: IntoIterator<Item = S>,
77 S: AsRef<str>,
78 {
79 let mut values = BTreeSet::new();
80
81 for label in labels {
82 values.insert(non_empty_text(label)?);
83 }
84
85 Ok(Self { labels: values })
86 }
87
88 #[must_use]
89 pub fn len(&self) -> usize {
90 self.labels.len()
91 }
92
93 #[must_use]
94 pub fn is_empty(&self) -> bool {
95 self.labels.is_empty()
96 }
97
98 pub fn iter(&self) -> impl Iterator<Item = &String> {
99 self.labels.iter()
100 }
101}
102
103#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
104pub enum CommunityKind {
105 PlantCommunity,
106 AnimalCommunity,
107 MicrobialCommunity,
108 AquaticCommunity,
109 TerrestrialCommunity,
110 Mixed,
111 Unknown,
112 Custom(String),
113}
114
115impl fmt::Display for CommunityKind {
116 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
117 formatter.write_str(match self {
118 Self::PlantCommunity => "plant-community",
119 Self::AnimalCommunity => "animal-community",
120 Self::MicrobialCommunity => "microbial-community",
121 Self::AquaticCommunity => "aquatic-community",
122 Self::TerrestrialCommunity => "terrestrial-community",
123 Self::Mixed => "mixed",
124 Self::Unknown => "unknown",
125 Self::Custom(value) => value.as_str(),
126 })
127 }
128}
129
130impl FromStr for CommunityKind {
131 type Err = CommunityKindParseError;
132
133 fn from_str(value: &str) -> Result<Self, Self::Err> {
134 let trimmed = value.trim();
135
136 if trimmed.is_empty() {
137 return Err(CommunityKindParseError::Empty);
138 }
139
140 Ok(match normalized_token(trimmed).as_str() {
141 "plant-community" => Self::PlantCommunity,
142 "animal-community" => Self::AnimalCommunity,
143 "microbial-community" => Self::MicrobialCommunity,
144 "aquatic-community" => Self::AquaticCommunity,
145 "terrestrial-community" => Self::TerrestrialCommunity,
146 "mixed" => Self::Mixed,
147 "unknown" => Self::Unknown,
148 _ => Self::Custom(trimmed.to_string()),
149 })
150 }
151}
152
153#[derive(Clone, Copy, Debug, Eq, PartialEq)]
154pub enum CommunityKindParseError {
155 Empty,
156}
157
158impl fmt::Display for CommunityKindParseError {
159 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
160 match self {
161 Self::Empty => formatter.write_str("community kind cannot be empty"),
162 }
163 }
164}
165
166impl Error for CommunityKindParseError {}
167
168#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
169pub enum CommunityRole {
170 Dominant,
171 Keystone,
172 Foundation,
173 Rare,
174 Invasive,
175 Native,
176 Unknown,
177 Custom(String),
178}
179
180impl fmt::Display for CommunityRole {
181 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
182 formatter.write_str(match self {
183 Self::Dominant => "dominant",
184 Self::Keystone => "keystone",
185 Self::Foundation => "foundation",
186 Self::Rare => "rare",
187 Self::Invasive => "invasive",
188 Self::Native => "native",
189 Self::Unknown => "unknown",
190 Self::Custom(value) => value.as_str(),
191 })
192 }
193}
194
195impl FromStr for CommunityRole {
196 type Err = CommunityRoleParseError;
197
198 fn from_str(value: &str) -> Result<Self, Self::Err> {
199 let trimmed = value.trim();
200
201 if trimmed.is_empty() {
202 return Err(CommunityRoleParseError::Empty);
203 }
204
205 Ok(match normalized_token(trimmed).as_str() {
206 "dominant" => Self::Dominant,
207 "keystone" => Self::Keystone,
208 "foundation" => Self::Foundation,
209 "rare" => Self::Rare,
210 "invasive" => Self::Invasive,
211 "native" => Self::Native,
212 "unknown" => Self::Unknown,
213 _ => Self::Custom(trimmed.to_string()),
214 })
215 }
216}
217
218#[derive(Clone, Copy, Debug, Eq, PartialEq)]
219pub enum CommunityRoleParseError {
220 Empty,
221}
222
223impl fmt::Display for CommunityRoleParseError {
224 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
225 match self {
226 Self::Empty => formatter.write_str("community role cannot be empty"),
227 }
228 }
229}
230
231impl Error for CommunityRoleParseError {}
232
233#[cfg(test)]
234mod tests {
235 use super::{
236 CommunityComposition, CommunityKind, CommunityName, CommunityRole, CommunityTextError,
237 };
238
239 #[test]
240 fn valid_community_name() -> Result<(), CommunityTextError> {
241 let name = CommunityName::new("prairie pollinators")?;
242
243 assert_eq!(name.as_str(), "prairie pollinators");
244 Ok(())
245 }
246
247 #[test]
248 fn empty_community_name_rejected() {
249 assert_eq!(CommunityName::new(" "), Err(CommunityTextError::Empty));
250 }
251
252 #[test]
253 fn community_kind_display_parse() {
254 assert_eq!(
255 "aquatic-community".parse::<CommunityKind>(),
256 Ok(CommunityKind::AquaticCommunity)
257 );
258 assert_eq!(CommunityKind::Mixed.to_string(), "mixed");
259 }
260
261 #[test]
262 fn community_role_display_parse() {
263 assert_eq!(
264 "keystone".parse::<CommunityRole>(),
265 Ok(CommunityRole::Keystone)
266 );
267 assert_eq!(CommunityRole::Native.to_string(), "native");
268 }
269
270 #[test]
271 fn deterministic_composition_ordering() -> Result<(), CommunityTextError> {
272 let composition = CommunityComposition::new(["zebra", "antelope", "buffalo"])?;
273 let ordered = composition.iter().map(String::as_str).collect::<Vec<_>>();
274
275 assert_eq!(ordered, vec!["antelope", "buffalo", "zebra"]);
276 Ok(())
277 }
278}