Skip to main content

sofie_values/
person_name.rs

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