Skip to main content

made_core/value_objects/
specialty.rs

1//! [`Specialty`] value object.
2//!
3//! Use-case-agnostic replacement for the SWE-specific `role` field in
4//! the original service. A specialty is a free-form label chosen by
5//! the operator (e.g. `"triage"`, `"investigator"`, `"reviewer"`).
6//! MADE never enumerates specialties itself.
7
8use std::fmt;
9
10use serde::{Deserialize, Serialize};
11
12use crate::error::DomainError;
13
14const MAX_SPECIALTY_LEN: usize = 128;
15
16/// A specialty label identifying a kind of agent expertise.
17#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
18#[serde(transparent)]
19pub struct Specialty(String);
20
21impl Specialty {
22    /// Construct a specialty after validating it.
23    ///
24    /// Accepts any non-empty, non-whitespace-only label up to
25    /// `MAX_SPECIALTY_LEN` chars, free of control characters. The
26    /// label is trimmed.
27    pub fn new(raw: impl Into<String>) -> Result<Self, DomainError> {
28        let trimmed = raw.into().trim().to_owned();
29        if trimmed.is_empty() {
30            return Err(DomainError::EmptyField { field: "specialty" });
31        }
32        if trimmed.len() > MAX_SPECIALTY_LEN {
33            return Err(DomainError::FieldTooLong {
34                field: "specialty",
35                actual: trimmed.len(),
36                max: MAX_SPECIALTY_LEN,
37            });
38        }
39        if trimmed.chars().any(char::is_control) {
40            return Err(DomainError::InvalidCharacters { field: "specialty" });
41        }
42        Ok(Self(trimmed))
43    }
44
45    #[must_use]
46    pub fn as_str(&self) -> &str {
47        &self.0
48    }
49
50    #[must_use]
51    pub fn into_inner(self) -> String {
52        self.0
53    }
54}
55
56impl fmt::Display for Specialty {
57    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
58        f.write_str(&self.0)
59    }
60}
61
62impl TryFrom<&str> for Specialty {
63    type Error = DomainError;
64    fn try_from(value: &str) -> Result<Self, Self::Error> {
65        Self::new(value)
66    }
67}
68
69impl TryFrom<String> for Specialty {
70    type Error = DomainError;
71    fn try_from(value: String) -> Result<Self, Self::Error> {
72        Self::new(value)
73    }
74}
75
76#[cfg(test)]
77mod tests {
78    use super::*;
79
80    #[test]
81    fn arbitrary_label_is_accepted() {
82        let s = Specialty::new("triage").unwrap();
83        assert_eq!(s.as_str(), "triage");
84    }
85
86    #[test]
87    fn label_is_trimmed() {
88        assert_eq!(Specialty::new("  planner  ").unwrap().as_str(), "planner");
89    }
90
91    #[test]
92    fn empty_is_rejected() {
93        let err = Specialty::new("   ").unwrap_err();
94        assert!(matches!(
95            err,
96            DomainError::EmptyField { field: "specialty" }
97        ));
98    }
99
100    #[test]
101    fn control_characters_are_rejected() {
102        assert!(matches!(
103            Specialty::new("bad\nrole").unwrap_err(),
104            DomainError::InvalidCharacters { field: "specialty" }
105        ));
106    }
107
108    #[test]
109    fn overlong_is_rejected() {
110        let err = Specialty::new("a".repeat(MAX_SPECIALTY_LEN + 1)).unwrap_err();
111        assert!(matches!(err, DomainError::FieldTooLong { .. }));
112    }
113
114    #[test]
115    fn display_is_inner() {
116        assert_eq!(Specialty::new("x").unwrap().to_string(), "x");
117    }
118
119    #[test]
120    fn no_enum_of_known_specialties_exists() {
121        // Regression test: MADE must accept arbitrary
122        // operator-defined specialties, not restrict to a fixed set.
123        for label in [
124            "triage",
125            "investigator",
126            "reviewer",
127            "quality-check",
128            "anomaly-scout",
129            "clinical-intake",
130            "supply-sourcing",
131        ] {
132            Specialty::new(label).unwrap_or_else(|e| panic!("{label}: {e}"));
133        }
134    }
135
136    #[test]
137    fn serde_is_transparent() {
138        let s = Specialty::new("x").unwrap();
139        assert_eq!(serde_json::to_string(&s).unwrap(), "\"x\"");
140    }
141}