1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7pub mod prelude {
9 pub use crate::{
10 SignalDirection, SignalDirectionParseError, SignalError, SignalName, SignalScore,
11 SignalStrength, SignalStrengthParseError,
12 };
13}
14
15#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
17pub struct SignalName(String);
18
19impl SignalName {
20 pub fn new(value: impl AsRef<str>) -> Result<Self, SignalError> {
26 let trimmed = value.as_ref().trim();
27 if trimmed.is_empty() {
28 Err(SignalError::EmptyName)
29 } else {
30 Ok(Self(trimmed.to_string()))
31 }
32 }
33
34 #[must_use]
36 pub fn as_str(&self) -> &str {
37 &self.0
38 }
39}
40
41impl AsRef<str> for SignalName {
42 fn as_ref(&self) -> &str {
43 self.as_str()
44 }
45}
46
47impl fmt::Display for SignalName {
48 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
49 formatter.write_str(self.as_str())
50 }
51}
52
53impl FromStr for SignalName {
54 type Err = SignalError;
55
56 fn from_str(value: &str) -> Result<Self, Self::Err> {
57 Self::new(value)
58 }
59}
60
61#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
63pub enum SignalDirection {
64 Long,
66 Short,
68 Neutral,
70 Unknown,
72 Custom(String),
74}
75
76impl fmt::Display for SignalDirection {
77 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
78 formatter.write_str(match self {
79 Self::Long => "long",
80 Self::Short => "short",
81 Self::Neutral => "neutral",
82 Self::Unknown => "unknown",
83 Self::Custom(value) => value.as_str(),
84 })
85 }
86}
87
88impl FromStr for SignalDirection {
89 type Err = SignalDirectionParseError;
90
91 fn from_str(value: &str) -> Result<Self, Self::Err> {
92 let trimmed = value.trim();
93 if trimmed.is_empty() {
94 return Err(SignalDirectionParseError::Empty);
95 }
96
97 match normalized_token(trimmed).as_str() {
98 "long" => Ok(Self::Long),
99 "short" => Ok(Self::Short),
100 "neutral" => Ok(Self::Neutral),
101 "unknown" => Ok(Self::Unknown),
102 _ => Ok(Self::Custom(trimmed.to_string())),
103 }
104 }
105}
106
107#[derive(Clone, Copy, Debug, Eq, PartialEq)]
109pub enum SignalDirectionParseError {
110 Empty,
112}
113
114impl fmt::Display for SignalDirectionParseError {
115 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
116 match self {
117 Self::Empty => formatter.write_str("signal direction cannot be empty"),
118 }
119 }
120}
121
122impl Error for SignalDirectionParseError {}
123
124#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
126pub enum SignalStrength {
127 Weak,
129 Moderate,
131 Strong,
133 Unknown,
135 Custom(String),
137}
138
139impl fmt::Display for SignalStrength {
140 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
141 formatter.write_str(match self {
142 Self::Weak => "weak",
143 Self::Moderate => "moderate",
144 Self::Strong => "strong",
145 Self::Unknown => "unknown",
146 Self::Custom(value) => value.as_str(),
147 })
148 }
149}
150
151impl FromStr for SignalStrength {
152 type Err = SignalStrengthParseError;
153
154 fn from_str(value: &str) -> Result<Self, Self::Err> {
155 let trimmed = value.trim();
156 if trimmed.is_empty() {
157 return Err(SignalStrengthParseError::Empty);
158 }
159
160 match normalized_token(trimmed).as_str() {
161 "weak" => Ok(Self::Weak),
162 "moderate" => Ok(Self::Moderate),
163 "strong" => Ok(Self::Strong),
164 "unknown" => Ok(Self::Unknown),
165 _ => Ok(Self::Custom(trimmed.to_string())),
166 }
167 }
168}
169
170#[derive(Clone, Copy, Debug, Eq, PartialEq)]
172pub enum SignalStrengthParseError {
173 Empty,
175}
176
177impl fmt::Display for SignalStrengthParseError {
178 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
179 match self {
180 Self::Empty => formatter.write_str("signal strength cannot be empty"),
181 }
182 }
183}
184
185impl Error for SignalStrengthParseError {}
186
187#[derive(Clone, Debug, PartialEq)]
189pub struct SignalScore {
190 name: SignalName,
191 score: f64,
192 direction: SignalDirection,
193 strength: SignalStrength,
194}
195
196impl SignalScore {
197 pub fn new(name: SignalName, score: f64) -> Result<Self, SignalError> {
203 if !score.is_finite() {
204 return Err(SignalError::NonFiniteScore);
205 }
206
207 Ok(Self {
208 name,
209 score,
210 direction: SignalDirection::Unknown,
211 strength: SignalStrength::Unknown,
212 })
213 }
214
215 #[must_use]
217 pub fn with_direction(mut self, direction: SignalDirection) -> Self {
218 self.direction = direction;
219 self
220 }
221
222 #[must_use]
224 pub fn with_strength(mut self, strength: SignalStrength) -> Self {
225 self.strength = strength;
226 self
227 }
228
229 #[must_use]
231 pub const fn name(&self) -> &SignalName {
232 &self.name
233 }
234
235 #[must_use]
237 pub const fn score(&self) -> f64 {
238 self.score
239 }
240
241 #[must_use]
243 pub const fn direction(&self) -> &SignalDirection {
244 &self.direction
245 }
246
247 #[must_use]
249 pub const fn strength(&self) -> &SignalStrength {
250 &self.strength
251 }
252}
253
254#[derive(Clone, Copy, Debug, Eq, PartialEq)]
256pub enum SignalError {
257 EmptyName,
259 NonFiniteScore,
261}
262
263impl fmt::Display for SignalError {
264 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
265 match self {
266 Self::EmptyName => formatter.write_str("signal name cannot be empty"),
267 Self::NonFiniteScore => formatter.write_str("signal score must be finite"),
268 }
269 }
270}
271
272impl Error for SignalError {}
273
274fn normalized_token(value: &str) -> String {
275 value
276 .trim()
277 .chars()
278 .map(|character| match character {
279 '_' | ' ' => '-',
280 other => other.to_ascii_lowercase(),
281 })
282 .collect()
283}
284
285#[cfg(test)]
286mod tests {
287 use super::{SignalDirection, SignalError, SignalName, SignalScore, SignalStrength};
288
289 #[test]
290 fn accepts_valid_signal_name() {
291 let name = SignalName::new("quality-score").expect("name should be valid");
292
293 assert_eq!(name.as_str(), "quality-score");
294 }
295
296 #[test]
297 fn rejects_empty_signal_name() {
298 assert_eq!(SignalName::new(" "), Err(SignalError::EmptyName));
299 }
300
301 #[test]
302 fn constructs_score() {
303 let score = SignalScore::new(
304 SignalName::new("momentum").expect("name should be valid"),
305 1.2,
306 )
307 .expect("score should be valid");
308
309 assert!((score.score() - 1.2).abs() < f64::EPSILON);
310 }
311
312 #[test]
313 fn displays_and_parses_direction() {
314 let direction: SignalDirection = "long".parse().expect("direction should parse");
315
316 assert_eq!(direction, SignalDirection::Long);
317 assert_eq!(direction.to_string(), "long");
318 }
319
320 #[test]
321 fn displays_and_parses_strength() {
322 let strength: SignalStrength = "moderate".parse().expect("strength should parse");
323
324 assert_eq!(strength, SignalStrength::Moderate);
325 assert_eq!(strength.to_string(), "moderate");
326 }
327
328 #[test]
329 fn supports_custom_values() {
330 let direction: SignalDirection = "pair".parse().expect("direction should parse");
331 let strength: SignalStrength = "screen-only".parse().expect("strength should parse");
332
333 assert_eq!(direction, SignalDirection::Custom("pair".to_string()));
334 assert_eq!(strength, SignalStrength::Custom("screen-only".to_string()));
335 }
336}