Skip to main content

use_signal_score/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7/// Common signal score primitives.
8pub mod prelude {
9    pub use crate::{
10        SignalDirection, SignalDirectionParseError, SignalError, SignalName, SignalScore,
11        SignalStrength, SignalStrengthParseError,
12    };
13}
14
15/// A non-empty signal name.
16#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
17pub struct SignalName(String);
18
19impl SignalName {
20    /// Creates a signal name from non-empty text.
21    ///
22    /// # Errors
23    ///
24    /// Returns [`SignalError::EmptyName`] when the trimmed value is empty.
25    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    /// Returns the signal name.
35    #[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/// Descriptive signal direction vocabulary.
62#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
63pub enum SignalDirection {
64    /// Long direction vocabulary.
65    Long,
66    /// Short direction vocabulary.
67    Short,
68    /// Neutral direction vocabulary.
69    Neutral,
70    /// Unknown direction vocabulary.
71    Unknown,
72    /// Caller-defined direction vocabulary.
73    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/// Errors returned while parsing signal directions.
108#[derive(Clone, Copy, Debug, Eq, PartialEq)]
109pub enum SignalDirectionParseError {
110    /// The input was empty after trimming whitespace.
111    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/// Descriptive signal strength vocabulary.
125#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
126pub enum SignalStrength {
127    /// Weak strength vocabulary.
128    Weak,
129    /// Moderate strength vocabulary.
130    Moderate,
131    /// Strong strength vocabulary.
132    Strong,
133    /// Unknown strength vocabulary.
134    Unknown,
135    /// Caller-defined strength vocabulary.
136    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/// Errors returned while parsing signal strengths.
171#[derive(Clone, Copy, Debug, Eq, PartialEq)]
172pub enum SignalStrengthParseError {
173    /// The input was empty after trimming whitespace.
174    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/// A finite quantitative signal score with descriptive semantics.
188#[derive(Clone, Debug, PartialEq)]
189pub struct SignalScore {
190    name: SignalName,
191    score: f64,
192    direction: SignalDirection,
193    strength: SignalStrength,
194}
195
196impl SignalScore {
197    /// Creates a signal score with unknown direction and strength.
198    ///
199    /// # Errors
200    ///
201    /// Returns [`SignalError::NonFiniteScore`] when `score` is not finite.
202    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    /// Sets descriptive direction vocabulary.
216    #[must_use]
217    pub fn with_direction(mut self, direction: SignalDirection) -> Self {
218        self.direction = direction;
219        self
220    }
221
222    /// Sets descriptive strength vocabulary.
223    #[must_use]
224    pub fn with_strength(mut self, strength: SignalStrength) -> Self {
225        self.strength = strength;
226        self
227    }
228
229    /// Returns the signal name.
230    #[must_use]
231    pub const fn name(&self) -> &SignalName {
232        &self.name
233    }
234
235    /// Returns the numeric score.
236    #[must_use]
237    pub const fn score(&self) -> f64 {
238        self.score
239    }
240
241    /// Returns the descriptive direction.
242    #[must_use]
243    pub const fn direction(&self) -> &SignalDirection {
244        &self.direction
245    }
246
247    /// Returns the descriptive strength.
248    #[must_use]
249    pub const fn strength(&self) -> &SignalStrength {
250        &self.strength
251    }
252}
253
254/// Errors returned by signal score helpers.
255#[derive(Clone, Copy, Debug, Eq, PartialEq)]
256pub enum SignalError {
257    /// Names must be non-empty after trimming whitespace.
258    EmptyName,
259    /// Scores must be finite.
260    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}