Skip to main content

sofie_values/
person_name.rs

1use std::fmt;
2
3use sofie_core::{FieldError, Result};
4
5use crate::refusal::{refusal, refusal_of};
6
7const MAXIMUM_LENGTH: usize = 200;
8
9/// One field: names do not split into given and family the same way everywhere.
10#[derive(Clone, PartialEq, Eq, Hash)]
11pub struct PersonName {
12    name: String,
13}
14
15impl PersonName {
16    pub fn parse(text: &str) -> Result<Self> {
17        let text = text.trim();
18        // A char is at most 4 bytes; refused before any copy.
19        if text.len() > MAXIMUM_LENGTH * 4 {
20            return Err(refusal(
21                "name",
22                "validation.person_name.too_long",
23                "must have at most 200 characters",
24            ));
25        }
26        let mut name = String::with_capacity(text.len());
27        for word in text.split_whitespace() {
28            if !name.is_empty() {
29                name.push(' ');
30            }
31            name.push_str(word);
32        }
33        Self::from_collapsed(name)
34    }
35
36    fn from_stored(text: String) -> Result<Self> {
37        if is_collapsed(&text) {
38            return Self::from_collapsed(text);
39        }
40        Self::parse(&text)
41    }
42
43    fn from_collapsed(name: String) -> Result<Self> {
44        if name.is_empty() {
45            return Err(refusal_of(FieldError::required("name")));
46        }
47        if name.chars().count() > MAXIMUM_LENGTH {
48            return Err(refusal(
49                "name",
50                "validation.person_name.too_long",
51                "must have at most 200 characters",
52            ));
53        }
54        if name.chars().any(sofie_core::misleads_display) {
55            return Err(refusal(
56                "name",
57                "validation.person_name.invisible",
58                "must not contain control or invisible formatting characters",
59            ));
60        }
61        Ok(Self { name })
62    }
63
64    #[must_use]
65    pub fn as_str(&self) -> &str {
66        &self.name
67    }
68}
69
70/// Words joined by single spaces, as `parse` leaves them.
71fn is_collapsed(text: &str) -> bool {
72    let mut after_space = true;
73    for character in text.chars() {
74        if !character.is_whitespace() {
75            after_space = false;
76        } else if character != ' ' || after_space {
77            return false;
78        } else {
79            after_space = true;
80        }
81    }
82    !after_space || text.is_empty()
83}
84
85impl fmt::Debug for PersonName {
86    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
87        let first = self.name.chars().next().unwrap_or_default();
88        write!(formatter, "PersonName({first}***)")
89    }
90}
91
92sofie_core::text_value!(PersonName, name, PersonName::from_stored);
93
94crate::present::present_text!(
95    PersonName,
96    crate::Input::typed(&[("type", "text"), ("autocomplete", "name")]),
97    Text
98);
99
100#[cfg(test)]
101mod tests {
102    use sofie_core::SqlValue;
103
104    use super::*;
105    use crate::testing::{assert_refused, round_trip};
106
107    #[test]
108    fn keeps_a_name_as_written_with_whitespace_collapsed() {
109        for (text, name) in [
110            ("Ada Lovelace", "Ada Lovelace"),
111            ("  Gabriel  García\tMárquez ", "Gabriel García Márquez"),
112            ("毛泽东", "毛泽东"),
113            ("O'Brien-Smith", "O'Brien-Smith"),
114            ("می\u{200C}خواهم", "می\u{200C}خواهم"),
115        ] {
116            assert_eq!(PersonName::parse(text).unwrap().as_str(), name);
117        }
118        assert!(PersonName::parse(&"é".repeat(200)).is_ok());
119    }
120
121    #[test]
122    fn refuses_empty_overlong_control_and_invisible_formatting_characters() {
123        assert_refused(
124            PersonName::parse,
125            &[
126                "",
127                "   ",
128                "Ada\u{0}Lovelace",
129                "Ada\u{7f}",
130                "Ada\u{202E}ecalevoL",
131                "Ada\u{200B}",
132                &"é".repeat(201),
133            ],
134        );
135    }
136
137    #[test]
138    fn stored_text_is_checked_and_collapsed_like_input() {
139        use sofie_core::value::Value;
140        let stored = |text: &str| PersonName::decode_stored(text.to_owned()).map(|name| name.name);
141
142        assert_eq!(stored("Ada Lovelace").ok().as_deref(), Some("Ada Lovelace"));
143        assert_eq!(
144            stored(" Ada  Lovelace\t").ok().as_deref(),
145            Some("Ada Lovelace")
146        );
147        assert!(stored("").is_err());
148        assert!(stored("Ada\u{202E}").is_err());
149    }
150
151    #[test]
152    fn masks_itself_in_debug() {
153        let name = PersonName::parse("Ada Lovelace").unwrap();
154
155        assert_eq!(format!("{name:?}"), "PersonName(A***)");
156    }
157
158    #[test]
159    fn round_trips_through_a_text_column_and_json() {
160        let name = PersonName::parse("Ada Lovelace").unwrap();
161        assert_eq!(
162            round_trip(&name),
163            vec![SqlValue::Text("Ada Lovelace".into())]
164        );
165        let json = serde_json::to_value(&name).unwrap();
166        assert_eq!(serde_json::from_value::<PersonName>(json).unwrap(), name);
167    }
168}