sofie_values/
person_name.rs1use std::fmt;
2
3use sofie_core::{FieldError, Result};
4
5use crate::refusal::{refusal, refusal_of};
6
7const MAXIMUM_LENGTH: usize = 200;
8
9#[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 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
70fn 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}