Skip to main content

use_community/
lib.rs

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    /// # Errors
41    /// Returns `CommunityTextError::Empty` when `value` is blank.
42    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    /// # Errors
73    /// Returns `CommunityTextError::Empty` when any provided label is blank.
74    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}