scim_server/resource/value_objects/
phone_number.rs

1//! PhoneNumber value object for SCIM user phone number components.
2//!
3//! This module provides a type-safe wrapper around SCIM phoneNumbers attributes with built-in validation.
4//! PhoneNumber attributes represent phone numbers as defined in RFC 7643 Section 4.1.2.
5
6use crate::error::{ValidationError, ValidationResult};
7use serde::{Deserialize, Serialize};
8use std::fmt;
9
10/// A validated SCIM phone number attribute.
11///
12/// PhoneNumber represents a phone number as defined in RFC 7643.
13/// It enforces validation rules at construction time, ensuring that only valid phone number
14/// attributes can exist in the system.
15///
16/// ## Validation Rules
17///
18/// - Phone number value cannot be empty
19/// - Phone number should follow RFC 3966 format when possible (tel:+1-201-555-0123)
20/// - Type must be one of canonical values: "work", "home", "mobile", "fax", "pager", "other" when provided
21/// - Display name is optional and used for human-readable representation
22/// - Primary can only be true for one phone number in a collection
23///
24/// ## Examples
25///
26/// ```rust
27/// use scim_server::resource::value_objects::PhoneNumber;
28///
29/// fn main() -> Result<(), Box<dyn std::error::Error>> {
30///     // Create with full phone number components
31///     let phone = PhoneNumber::new(
32///         "+1-201-555-0123".to_string(),
33///         Some("Work Phone".to_string()),
34///         Some("work".to_string()),
35///         Some(true)
36///     )?;
37///
38///     // Create with minimal components
39///     let simple_phone = PhoneNumber::new_simple(
40///         "+1-555-123-4567".to_string(),
41///         "mobile".to_string()
42///     )?;
43///
44///     Ok(())
45/// }
46/// ```
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48pub struct PhoneNumber {
49    pub value: String,
50    pub display: Option<String>,
51    #[serde(rename = "type")]
52    pub phone_type: Option<String>,
53    pub primary: Option<bool>,
54}
55
56impl PhoneNumber {
57    /// Create a new PhoneNumber with all components.
58    ///
59    /// This is the primary constructor that enforces all validation rules.
60    /// Use this method when creating PhoneNumber instances from untrusted input.
61    ///
62    /// # Arguments
63    ///
64    /// * `value` - The phone number value (preferably in RFC 3966 format)
65    /// * `display` - Optional human-readable display name
66    /// * `phone_type` - The type of phone number ("work", "home", "mobile", etc.)
67    /// * `primary` - Whether this is the primary phone number
68    ///
69    /// # Returns
70    ///
71    /// * `Ok(PhoneNumber)` - If the phone number is valid
72    /// * `Err(ValidationError)` - If any field violates validation rules
73    pub fn new(
74        value: String,
75        display: Option<String>,
76        phone_type: Option<String>,
77        primary: Option<bool>,
78    ) -> ValidationResult<Self> {
79        // Validate the phone number value
80        Self::validate_phone_value(&value)?;
81
82        // Validate display if provided
83        if let Some(ref d) = display {
84            Self::validate_display(d)?;
85        }
86
87        // Validate phone type if provided
88        if let Some(ref pt) = phone_type {
89            Self::validate_phone_type(pt)?;
90        }
91
92        Ok(Self {
93            value,
94            display,
95            phone_type,
96            primary,
97        })
98    }
99
100    /// Create a simple PhoneNumber with just value and type.
101    ///
102    /// Convenience constructor for creating basic phone number structures.
103    ///
104    /// # Arguments
105    ///
106    /// * `value` - The phone number value
107    /// * `phone_type` - The type of phone number
108    ///
109    /// # Returns
110    ///
111    /// * `Ok(PhoneNumber)` - If the phone number is valid
112    /// * `Err(ValidationError)` - If any component violates validation rules
113    pub fn new_simple(value: String, phone_type: String) -> ValidationResult<Self> {
114        Self::new(value, None, Some(phone_type), None)
115    }
116
117    /// Create a work PhoneNumber.
118    ///
119    /// Convenience constructor for work phone numbers.
120    ///
121    /// # Arguments
122    ///
123    /// * `value` - The phone number value
124    ///
125    /// # Returns
126    ///
127    /// * `Ok(PhoneNumber)` - If the phone number is valid
128    /// * `Err(ValidationError)` - If the phone number violates validation rules
129    pub fn new_work(value: String) -> ValidationResult<Self> {
130        Self::new(value, None, Some("work".to_string()), None)
131    }
132
133    /// Create a mobile PhoneNumber.
134    ///
135    /// Convenience constructor for mobile phone numbers.
136    ///
137    /// # Arguments
138    ///
139    /// * `value` - The phone number value
140    ///
141    /// # Returns
142    ///
143    /// * `Ok(PhoneNumber)` - If the phone number is valid
144    /// * `Err(ValidationError)` - If the phone number violates validation rules
145    pub fn new_mobile(value: String) -> ValidationResult<Self> {
146        Self::new(value, None, Some("mobile".to_string()), None)
147    }
148
149    /// Create a PhoneNumber instance without validation for internal use.
150
151    /// Get the phone number value.
152    pub fn value(&self) -> &str {
153        &self.value
154    }
155
156    /// Get the display name.
157    pub fn display(&self) -> Option<&str> {
158        self.display.as_deref()
159    }
160
161    /// Get the phone type.
162    pub fn phone_type(&self) -> Option<&str> {
163        self.phone_type.as_deref()
164    }
165
166    /// Get whether this is the primary phone number.
167    pub fn is_primary(&self) -> bool {
168        self.primary.unwrap_or(false)
169    }
170
171    /// Get a display-friendly representation of the phone number.
172    ///
173    /// Returns the display name if available, otherwise the phone number value.
174    pub fn display_value(&self) -> &str {
175        self.display.as_deref().unwrap_or(&self.value)
176    }
177
178    /// Check if this phone number uses RFC 3966 format.
179    pub fn is_rfc3966_format(&self) -> bool {
180        self.value.starts_with("tel:")
181    }
182
183    /// Convert to RFC 3966 format if possible.
184    ///
185    /// Attempts to convert the phone number to RFC 3966 format.
186    /// This is a simple conversion that handles common cases.
187    pub fn to_rfc3966(&self) -> String {
188        if self.is_rfc3966_format() {
189            self.value.clone()
190        } else {
191            // Simple conversion - in practice you might want more sophisticated parsing
192            let cleaned = self
193                .value
194                .chars()
195                .filter(|c| c.is_ascii_digit() || *c == '+' || *c == '-')
196                .collect::<String>();
197
198            if cleaned.starts_with('+') {
199                format!("tel:{}", cleaned)
200            } else {
201                format!("tel:+{}", cleaned)
202            }
203        }
204    }
205
206    /// Validate the phone number value.
207    fn validate_phone_value(value: &str) -> ValidationResult<()> {
208        if value.trim().is_empty() {
209            return Err(ValidationError::custom(
210                "value: Phone number value cannot be empty",
211            ));
212        }
213
214        // Check for reasonable length
215        if value.len() > 50 {
216            return Err(ValidationError::custom(
217                "value: Phone number exceeds maximum length of 50 characters",
218            ));
219        }
220
221        // If it claims to be RFC 3966 format, do basic validation first
222        if value.starts_with("tel:") {
223            let phone_part = &value[4..];
224            if phone_part.is_empty() {
225                return Err(ValidationError::custom(
226                    "value: RFC 3966 format phone number cannot be empty after 'tel:' prefix",
227                ));
228            }
229        }
230
231        // Basic format validation - should contain digits and possibly formatting characters
232        if !value.chars().any(|c| c.is_ascii_digit()) {
233            return Err(ValidationError::custom(
234                "value: Phone number must contain at least one digit",
235            ));
236        }
237
238        // Check for obviously invalid characters
239        let has_invalid_chars = value.chars().any(|c| {
240            !c.is_ascii_digit()
241                && c != '+'
242                && c != '-'
243                && c != '('
244                && c != ')'
245                && c != ' '
246                && c != '.'
247                && c != ':'
248                && !c.is_ascii_alphabetic() // for tel: prefix
249        });
250
251        if has_invalid_chars {
252            return Err(ValidationError::custom(
253                "value: Phone number contains invalid characters",
254            ));
255        }
256
257        Ok(())
258    }
259
260    /// Validate the display name.
261    fn validate_display(display: &str) -> ValidationResult<()> {
262        if display.trim().is_empty() {
263            return Err(ValidationError::custom(
264                "display: Display name cannot be empty or contain only whitespace",
265            ));
266        }
267
268        // Check for reasonable length
269        if display.len() > 256 {
270            return Err(ValidationError::custom(
271                "display: Display name exceeds maximum length of 256 characters",
272            ));
273        }
274
275        Ok(())
276    }
277
278    /// Validate phone type against canonical values.
279    fn validate_phone_type(phone_type: &str) -> ValidationResult<()> {
280        if phone_type.trim().is_empty() {
281            return Err(ValidationError::custom("type: Phone type cannot be empty"));
282        }
283
284        // SCIM canonical values for phone type
285        let valid_types = ["work", "home", "mobile", "fax", "pager", "other"];
286        if !valid_types.contains(&phone_type) {
287            return Err(ValidationError::custom(format!(
288                "type: '{}' is not a valid phone type. Valid types are: {:?}",
289                phone_type, valid_types
290            )));
291        }
292
293        Ok(())
294    }
295}
296
297impl fmt::Display for PhoneNumber {
298    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
299        if let Some(phone_type) = &self.phone_type {
300            write!(f, "{} ({})", self.display_value(), phone_type)
301        } else {
302            write!(f, "{}", self.display_value())
303        }
304    }
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310
311    #[test]
312    fn test_valid_phone_number_full() {
313        let phone = PhoneNumber::new(
314            "+1-201-555-0123".to_string(),
315            Some("Work Phone".to_string()),
316            Some("work".to_string()),
317            Some(true),
318        );
319
320        assert!(phone.is_ok());
321        let phone = phone.unwrap();
322        assert_eq!(phone.value(), "+1-201-555-0123");
323        assert_eq!(phone.display(), Some("Work Phone"));
324        assert_eq!(phone.phone_type(), Some("work"));
325        assert!(phone.is_primary());
326    }
327
328    #[test]
329    fn test_valid_phone_number_simple() {
330        let phone = PhoneNumber::new_simple("555-123-4567".to_string(), "mobile".to_string());
331
332        assert!(phone.is_ok());
333        let phone = phone.unwrap();
334        assert_eq!(phone.value(), "555-123-4567");
335        assert_eq!(phone.phone_type(), Some("mobile"));
336        assert!(!phone.is_primary());
337    }
338
339    #[test]
340    fn test_valid_phone_number_work() {
341        let phone = PhoneNumber::new_work("+1-555-123-4567".to_string());
342
343        assert!(phone.is_ok());
344        let phone = phone.unwrap();
345        assert_eq!(phone.phone_type(), Some("work"));
346        assert_eq!(phone.value(), "+1-555-123-4567");
347    }
348
349    #[test]
350    fn test_valid_phone_number_mobile() {
351        let phone = PhoneNumber::new_mobile("(555) 123-4567".to_string());
352
353        assert!(phone.is_ok());
354        let phone = phone.unwrap();
355        assert_eq!(phone.phone_type(), Some("mobile"));
356        assert_eq!(phone.value(), "(555) 123-4567");
357    }
358
359    #[test]
360    fn test_empty_phone_value() {
361        let result = PhoneNumber::new("".to_string(), None, Some("work".to_string()), None);
362        assert!(result.is_err());
363        assert!(
364            result
365                .unwrap_err()
366                .to_string()
367                .contains("Phone number value cannot be empty")
368        );
369    }
370
371    #[test]
372    fn test_invalid_phone_type() {
373        let result = PhoneNumber::new(
374            "555-123-4567".to_string(),
375            None,
376            Some("business".to_string()), // Should be work, home, mobile, fax, pager, or other
377            None,
378        );
379        assert!(result.is_err());
380        assert!(
381            result
382                .unwrap_err()
383                .to_string()
384                .contains("not a valid phone type")
385        );
386    }
387
388    #[test]
389    fn test_too_long_phone_value() {
390        let long_phone = "1".repeat(60);
391        let result = PhoneNumber::new_work(long_phone);
392        assert!(result.is_err());
393        assert!(
394            result
395                .unwrap_err()
396                .to_string()
397                .contains("exceeds maximum length")
398        );
399    }
400
401    #[test]
402    fn test_phone_without_digits() {
403        let result = PhoneNumber::new_work("abc-def-ghij".to_string());
404        assert!(result.is_err());
405        assert!(
406            result
407                .unwrap_err()
408                .to_string()
409                .contains("must contain at least one digit")
410        );
411    }
412
413    #[test]
414    fn test_phone_with_invalid_characters() {
415        let result = PhoneNumber::new_work("555-123-4567#".to_string());
416        assert!(result.is_err());
417        assert!(
418            result
419                .unwrap_err()
420                .to_string()
421                .contains("contains invalid characters")
422        );
423    }
424
425    #[test]
426    fn test_empty_display() {
427        let result = PhoneNumber::new(
428            "555-123-4567".to_string(),
429            Some("".to_string()),
430            Some("work".to_string()),
431            None,
432        );
433        assert!(result.is_err());
434        assert!(
435            result
436                .unwrap_err()
437                .to_string()
438                .contains("Display name cannot be empty")
439        );
440    }
441
442    #[test]
443    fn test_rfc3966_format() {
444        let phone = PhoneNumber::new_work("tel:+1-201-555-0123".to_string()).unwrap();
445        assert!(phone.is_rfc3966_format());
446
447        let phone2 = PhoneNumber::new_work("555-123-4567".to_string()).unwrap();
448        assert!(!phone2.is_rfc3966_format());
449    }
450
451    #[test]
452    fn test_to_rfc3966() {
453        let phone = PhoneNumber::new_work("555-123-4567".to_string()).unwrap();
454        assert_eq!(phone.to_rfc3966(), "tel:+555-123-4567");
455
456        let phone2 = PhoneNumber::new_work("+1-555-123-4567".to_string()).unwrap();
457        assert_eq!(phone2.to_rfc3966(), "tel:+1-555-123-4567");
458
459        let phone3 = PhoneNumber::new_work("tel:+1-555-123-4567".to_string()).unwrap();
460        assert_eq!(phone3.to_rfc3966(), "tel:+1-555-123-4567");
461    }
462
463    #[test]
464    fn test_display_value() {
465        let phone = PhoneNumber::new(
466            "555-123-4567".to_string(),
467            Some("My Work Phone".to_string()),
468            Some("work".to_string()),
469            None,
470        )
471        .unwrap();
472        assert_eq!(phone.display_value(), "My Work Phone");
473
474        let phone2 = PhoneNumber::new_work("555-123-4567".to_string()).unwrap();
475        assert_eq!(phone2.display_value(), "555-123-4567");
476    }
477
478    #[test]
479    fn test_display() {
480        let phone = PhoneNumber::new(
481            "555-123-4567".to_string(),
482            Some("My Work Phone".to_string()),
483            Some("work".to_string()),
484            None,
485        )
486        .unwrap();
487        assert_eq!(format!("{}", phone), "My Work Phone (work)");
488
489        let phone2 = PhoneNumber::new(
490            "555-123-4567".to_string(),
491            None,
492            Some("mobile".to_string()),
493            None,
494        )
495        .unwrap();
496        assert_eq!(format!("{}", phone2), "555-123-4567 (mobile)");
497
498        let phone3 = PhoneNumber::new("555-123-4567".to_string(), None, None, None).unwrap();
499        assert_eq!(format!("{}", phone3), "555-123-4567");
500    }
501
502    #[test]
503    fn test_serialization() {
504        let phone = PhoneNumber::new(
505            "+1-201-555-0123".to_string(),
506            Some("Work Phone".to_string()),
507            Some("work".to_string()),
508            Some(true),
509        )
510        .unwrap();
511
512        let json = serde_json::to_string(&phone).unwrap();
513        assert!(json.contains("\"value\":\"+1-201-555-0123\""));
514        assert!(json.contains("\"display\":\"Work Phone\""));
515        assert!(json.contains("\"type\":\"work\""));
516        assert!(json.contains("\"primary\":true"));
517    }
518
519    #[test]
520    fn test_deserialization() {
521        let json = r#"{
522            "value": "+1-201-555-0123",
523            "display": "Work Phone",
524            "type": "work",
525            "primary": true
526        }"#;
527
528        let phone: PhoneNumber = serde_json::from_str(json).unwrap();
529        assert_eq!(phone.value(), "+1-201-555-0123");
530        assert_eq!(phone.display(), Some("Work Phone"));
531        assert_eq!(phone.phone_type(), Some("work"));
532        assert!(phone.is_primary());
533    }
534
535    #[test]
536    fn test_equality() {
537        let phone1 = PhoneNumber::new_work("555-123-4567".to_string()).unwrap();
538        let phone2 = PhoneNumber::new_work("555-123-4567".to_string()).unwrap();
539        let phone3 = PhoneNumber::new_mobile("555-123-4567".to_string()).unwrap();
540
541        assert_eq!(phone1, phone2);
542        assert_ne!(phone1, phone3);
543    }
544
545    #[test]
546    fn test_clone() {
547        let original = PhoneNumber::new(
548            "+1-555-123-4567".to_string(),
549            Some("Work Phone".to_string()),
550            Some("work".to_string()),
551            Some(true),
552        )
553        .unwrap();
554
555        let cloned = original.clone();
556        assert_eq!(original, cloned);
557        assert_eq!(cloned.value(), "+1-555-123-4567");
558        assert_eq!(cloned.phone_type(), Some("work"));
559    }
560
561    #[test]
562    fn test_valid_phone_types() {
563        for phone_type in ["work", "home", "mobile", "fax", "pager", "other"] {
564            let phone = PhoneNumber::new(
565                "555-123-4567".to_string(),
566                None,
567                Some(phone_type.to_string()),
568                None,
569            );
570            assert!(phone.is_ok(), "Phone type '{}' should be valid", phone_type);
571        }
572    }
573
574    #[test]
575    fn test_various_phone_formats() {
576        let formats = [
577            "555-123-4567",
578            "(555) 123-4567",
579            "+1-555-123-4567",
580            "tel:+1-555-123-4567",
581            "555.123.4567",
582            "1 555 123 4567",
583        ];
584
585        for format in &formats {
586            let phone = PhoneNumber::new_work(format.to_string());
587            assert!(phone.is_ok(), "Phone format '{}' should be valid", format);
588        }
589    }
590
591    #[test]
592    fn test_invalid_rfc3966_format() {
593        let result = PhoneNumber::new_work("tel:".to_string());
594        assert!(result.is_err());
595        let error_msg = result.unwrap_err().to_string();
596        assert!(error_msg.contains("cannot be empty after 'tel:' prefix"));
597
598        // Test valid RFC 3966 format
599        let result2 = PhoneNumber::new(
600            "tel:+1-555-123-4567".to_string(),
601            None,
602            Some("work".to_string()),
603            None,
604        );
605        assert!(result2.is_ok());
606    }
607}