Skip to main content

uls_db/
models.rs

1//! High-level domain models for ULS data.
2//!
3//! These models aggregate data from multiple record types into
4//! user-friendly structures for queries and display.
5
6use chrono::NaiveDate;
7use serde::{Deserialize, Serialize};
8
9/// A complete license with all related information.
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct License {
12    /// Unique system identifier.
13    pub unique_system_identifier: i64,
14    /// Call sign.
15    pub call_sign: String,
16    /// Licensee/entity name.
17    pub licensee_name: String,
18    /// First name (if individual).
19    pub first_name: Option<String>,
20    /// Middle initial.
21    pub middle_initial: Option<String>,
22    /// Last name (if individual).
23    pub last_name: Option<String>,
24    /// License status (A=Active, C=Cancelled, E=Expired, etc.).
25    pub status: char,
26    /// Radio service code (HA, HV, ZA, etc.).
27    pub radio_service: String,
28    /// Grant date.
29    pub grant_date: Option<NaiveDate>,
30    /// Expiration date.
31    pub expired_date: Option<NaiveDate>,
32    /// Cancellation date.
33    pub cancellation_date: Option<NaiveDate>,
34    /// FRN (FCC Registration Number).
35    pub frn: Option<String>,
36    /// Street address.
37    pub street_address: Option<String>,
38    /// City.
39    pub city: Option<String>,
40    /// State.
41    pub state: Option<String>,
42    /// ZIP code.
43    pub zip_code: Option<String>,
44    /// Operator class (for amateur).
45    pub operator_class: Option<char>,
46    /// Previous call sign.
47    pub previous_call_sign: Option<String>,
48}
49
50impl License {
51    /// Get the formatted name (entity name or "First Last").
52    pub fn display_name(&self) -> String {
53        if let (Some(first), Some(last)) = (&self.first_name, &self.last_name) {
54            if let Some(mi) = &self.middle_initial {
55                format!("{} {} {}", first, mi, last)
56            } else {
57                format!("{} {}", first, last)
58            }
59        } else {
60            self.licensee_name.clone()
61        }
62    }
63
64    /// Get the status description.
65    pub fn status_description(&self) -> &'static str {
66        match self.status {
67            'A' => "Active",
68            'C' => "Cancelled",
69            'E' => "Expired",
70            'L' => "Pending Legal Status",
71            'P' => "Parent Station Cancelled",
72            'T' => "Terminated",
73            'X' => "Term Pending",
74            _ => "Unknown",
75        }
76    }
77
78    /// Check if the license is active.
79    pub fn is_active(&self) -> bool {
80        self.status == 'A'
81    }
82
83    /// Get the operator class description (amateur only).
84    pub fn operator_class_description(&self) -> Option<&'static str> {
85        self.operator_class.map(|c| match c {
86            'T' => "Technician",
87            'G' => "General",
88            'A' => "Advanced",
89            'E' => "Amateur Extra",
90            'N' => "Novice",
91            'P' => "Technician Plus",
92            _ => "Unknown",
93        })
94    }
95
96    /// Get a field value by name for dynamic output.
97    pub fn get_field(&self, name: &str) -> Option<String> {
98        match name.to_lowercase().as_str() {
99            "call_sign" | "callsign" | "call" => Some(self.call_sign.clone()),
100            "name" | "licensee" | "entity_name" => Some(self.display_name()),
101            "first_name" | "first" => self.first_name.clone(),
102            "last_name" | "last" => self.last_name.clone(),
103            "middle_initial" | "mi" => self.middle_initial.clone(),
104            "status" | "license_status" => Some(self.status.to_string()),
105            "status_desc" | "status_description" => Some(self.status_description().to_string()),
106            "service" | "radio_service" => Some(self.radio_service.clone()),
107            "class" | "operator_class" => self.operator_class.map(|c| c.to_string()),
108            "class_desc" | "class_description" => {
109                self.operator_class_description().map(|s| s.to_string())
110            }
111            "city" => self.city.clone(),
112            "state" => self.state.clone(),
113            "zip" | "zip_code" => self.zip_code.clone(),
114            "location" => {
115                let city = self.city.as_deref().unwrap_or("");
116                let state = self.state.as_deref().unwrap_or("");
117                if city.is_empty() && state.is_empty() {
118                    None
119                } else {
120                    Some(format!("{}, {}", city, state))
121                }
122            }
123            "address" | "street_address" => self.street_address.clone(),
124            "frn" => self.frn.clone(),
125            "grant_date" | "granted" => self.grant_date.map(|d| d.to_string()),
126            "expired_date" | "expires" | "expiration" => self.expired_date.map(|d| d.to_string()),
127            "cancellation_date" | "cancelled" => self.cancellation_date.map(|d| d.to_string()),
128            "previous_call_sign" | "previous_call" => self.previous_call_sign.clone(),
129            "usi" | "unique_system_identifier" => Some(self.unique_system_identifier.to_string()),
130            _ => None,
131        }
132    }
133
134    /// Get list of available field names.
135    pub fn field_names() -> &'static [&'static str] {
136        &[
137            "call_sign",
138            "name",
139            "first_name",
140            "last_name",
141            "status",
142            "status_desc",
143            "service",
144            "class",
145            "class_desc",
146            "city",
147            "state",
148            "zip",
149            "location",
150            "address",
151            "frn",
152            "grant_date",
153            "expired_date",
154            "cancellation_date",
155            "previous_call_sign",
156            "usi",
157        ]
158    }
159}
160
161/// Amateur operator information.
162#[derive(Debug, Clone, Serialize, Deserialize)]
163pub struct Operator {
164    /// Unique system identifier.
165    pub unique_system_identifier: i64,
166    /// Call sign.
167    pub call_sign: String,
168    /// Operator class.
169    pub operator_class: char,
170    /// Group code.
171    pub group_code: Option<char>,
172    /// Region code.
173    pub region_code: Option<i32>,
174    /// Vanity call indicator.
175    pub vanity_call: bool,
176    /// Previous operator class.
177    pub previous_operator_class: Option<char>,
178}
179
180/// Database statistics.
181#[derive(Debug, Clone, Serialize, Deserialize)]
182pub struct LicenseStats {
183    /// Total number of licenses.
184    pub total_licenses: u64,
185    /// Number of active licenses.
186    pub active_licenses: u64,
187    /// Number of expired licenses.
188    pub expired_licenses: u64,
189    /// Number of cancelled licenses.
190    pub cancelled_licenses: u64,
191    /// Breakdown by radio service.
192    pub by_service: Vec<(String, u64)>,
193    /// Breakdown by operator class (amateur only).
194    pub by_operator_class: Vec<(String, u64)>,
195    /// Database last updated.
196    pub last_updated: Option<String>,
197    /// Database schema version.
198    pub schema_version: i32,
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204
205    #[test]
206    fn test_license_display_name() {
207        let mut license = License {
208            unique_system_identifier: 1,
209            call_sign: "W1AW".to_string(),
210            licensee_name: "ARRL".to_string(),
211            first_name: Some("Test".to_string()),
212            middle_initial: Some("X".to_string()),
213            last_name: Some("User".to_string()),
214            status: 'A',
215            radio_service: "HA".to_string(),
216            grant_date: None,
217            expired_date: None,
218            cancellation_date: None,
219            frn: None,
220            street_address: None,
221            city: None,
222            state: None,
223            zip_code: None,
224            operator_class: Some('E'),
225            previous_call_sign: None,
226        };
227
228        assert_eq!(license.display_name(), "Test X User");
229
230        license.first_name = None;
231        assert_eq!(license.display_name(), "ARRL");
232    }
233
234    #[test]
235    fn test_operator_class_description() {
236        let license = License {
237            unique_system_identifier: 1,
238            call_sign: "W1AW".to_string(),
239            licensee_name: "Test".to_string(),
240            first_name: None,
241            middle_initial: None,
242            last_name: None,
243            status: 'A',
244            radio_service: "HA".to_string(),
245            grant_date: None,
246            expired_date: None,
247            cancellation_date: None,
248            frn: None,
249            street_address: None,
250            city: None,
251            state: None,
252            zip_code: None,
253            operator_class: Some('E'),
254            previous_call_sign: None,
255        };
256
257        assert_eq!(license.operator_class_description(), Some("Amateur Extra"));
258        assert!(license.is_active());
259    }
260
261    #[test]
262    fn test_status_description_all_variants() {
263        let mut license = License {
264            unique_system_identifier: 1,
265            call_sign: "W1AW".to_string(),
266            licensee_name: "Test".to_string(),
267            first_name: None,
268            middle_initial: None,
269            last_name: None,
270            status: 'A',
271            radio_service: "HA".to_string(),
272            grant_date: None,
273            expired_date: None,
274            cancellation_date: None,
275            frn: None,
276            street_address: None,
277            city: None,
278            state: None,
279            zip_code: None,
280            operator_class: None,
281            previous_call_sign: None,
282        };
283
284        assert_eq!(license.status_description(), "Active");
285
286        license.status = 'C';
287        assert_eq!(license.status_description(), "Cancelled");
288
289        license.status = 'E';
290        assert_eq!(license.status_description(), "Expired");
291
292        license.status = 'L';
293        assert_eq!(license.status_description(), "Pending Legal Status");
294
295        license.status = 'P';
296        assert_eq!(license.status_description(), "Parent Station Cancelled");
297
298        license.status = 'T';
299        assert_eq!(license.status_description(), "Terminated");
300
301        license.status = 'X';
302        assert_eq!(license.status_description(), "Term Pending");
303
304        license.status = 'Z';
305        assert_eq!(license.status_description(), "Unknown");
306    }
307
308    #[test]
309    fn test_operator_class_all_variants() {
310        let mut license = License {
311            unique_system_identifier: 1,
312            call_sign: "W1AW".to_string(),
313            licensee_name: "Test".to_string(),
314            first_name: None,
315            middle_initial: None,
316            last_name: None,
317            status: 'A',
318            radio_service: "HA".to_string(),
319            grant_date: None,
320            expired_date: None,
321            cancellation_date: None,
322            frn: None,
323            street_address: None,
324            city: None,
325            state: None,
326            zip_code: None,
327            operator_class: Some('T'),
328            previous_call_sign: None,
329        };
330
331        assert_eq!(license.operator_class_description(), Some("Technician"));
332
333        license.operator_class = Some('G');
334        assert_eq!(license.operator_class_description(), Some("General"));
335
336        license.operator_class = Some('A');
337        assert_eq!(license.operator_class_description(), Some("Advanced"));
338
339        license.operator_class = Some('N');
340        assert_eq!(license.operator_class_description(), Some("Novice"));
341
342        license.operator_class = Some('P');
343        assert_eq!(
344            license.operator_class_description(),
345            Some("Technician Plus")
346        );
347
348        license.operator_class = Some('Z');
349        assert_eq!(license.operator_class_description(), Some("Unknown"));
350
351        license.operator_class = None;
352        assert_eq!(license.operator_class_description(), None);
353    }
354
355    #[test]
356    fn test_display_name_without_middle_initial() {
357        let license = License {
358            unique_system_identifier: 1,
359            call_sign: "W1AW".to_string(),
360            licensee_name: "ARRL".to_string(),
361            first_name: Some("John".to_string()),
362            middle_initial: None,
363            last_name: Some("Doe".to_string()),
364            status: 'A',
365            radio_service: "HA".to_string(),
366            grant_date: None,
367            expired_date: None,
368            cancellation_date: None,
369            frn: None,
370            street_address: None,
371            city: None,
372            state: None,
373            zip_code: None,
374            operator_class: None,
375            previous_call_sign: None,
376        };
377
378        assert_eq!(license.display_name(), "John Doe");
379    }
380
381    #[test]
382    fn test_get_field_basic() {
383        let license = License {
384            unique_system_identifier: 12345,
385            call_sign: "W1AW".to_string(),
386            licensee_name: "ARRL".to_string(),
387            first_name: Some("John".to_string()),
388            middle_initial: Some("Q".to_string()),
389            last_name: Some("Public".to_string()),
390            status: 'A',
391            radio_service: "HA".to_string(),
392            grant_date: Some(NaiveDate::from_ymd_opt(2020, 1, 15).unwrap()),
393            expired_date: Some(NaiveDate::from_ymd_opt(2030, 1, 15).unwrap()),
394            cancellation_date: None,
395            frn: Some("0012345678".to_string()),
396            street_address: Some("123 Main St".to_string()),
397            city: Some("Newington".to_string()),
398            state: Some("CT".to_string()),
399            zip_code: Some("06111".to_string()),
400            operator_class: Some('E'),
401            previous_call_sign: Some("N1XYZ".to_string()),
402        };
403
404        // Test all field name variants
405        assert_eq!(license.get_field("call_sign"), Some("W1AW".to_string()));
406        assert_eq!(license.get_field("callsign"), Some("W1AW".to_string()));
407        assert_eq!(license.get_field("call"), Some("W1AW".to_string()));
408
409        assert_eq!(license.get_field("name"), Some("John Q Public".to_string()));
410        assert_eq!(
411            license.get_field("licensee"),
412            Some("John Q Public".to_string())
413        );
414
415        assert_eq!(license.get_field("first_name"), Some("John".to_string()));
416        assert_eq!(license.get_field("first"), Some("John".to_string()));
417
418        assert_eq!(license.get_field("last_name"), Some("Public".to_string()));
419        assert_eq!(license.get_field("last"), Some("Public".to_string()));
420
421        assert_eq!(license.get_field("middle_initial"), Some("Q".to_string()));
422        assert_eq!(license.get_field("mi"), Some("Q".to_string()));
423
424        assert_eq!(license.get_field("status"), Some("A".to_string()));
425        assert_eq!(license.get_field("status_desc"), Some("Active".to_string()));
426
427        assert_eq!(license.get_field("service"), Some("HA".to_string()));
428        assert_eq!(license.get_field("radio_service"), Some("HA".to_string()));
429
430        assert_eq!(license.get_field("class"), Some("E".to_string()));
431        assert_eq!(
432            license.get_field("class_desc"),
433            Some("Amateur Extra".to_string())
434        );
435
436        assert_eq!(license.get_field("city"), Some("Newington".to_string()));
437        assert_eq!(license.get_field("state"), Some("CT".to_string()));
438        assert_eq!(license.get_field("zip"), Some("06111".to_string()));
439        assert_eq!(license.get_field("zip_code"), Some("06111".to_string()));
440
441        assert_eq!(
442            license.get_field("location"),
443            Some("Newington, CT".to_string())
444        );
445
446        assert_eq!(
447            license.get_field("address"),
448            Some("123 Main St".to_string())
449        );
450        assert_eq!(
451            license.get_field("street_address"),
452            Some("123 Main St".to_string())
453        );
454
455        assert_eq!(license.get_field("frn"), Some("0012345678".to_string()));
456
457        assert_eq!(
458            license.get_field("grant_date"),
459            Some("2020-01-15".to_string())
460        );
461        assert_eq!(license.get_field("granted"), Some("2020-01-15".to_string()));
462
463        assert_eq!(
464            license.get_field("expired_date"),
465            Some("2030-01-15".to_string())
466        );
467        assert_eq!(license.get_field("expires"), Some("2030-01-15".to_string()));
468
469        assert_eq!(license.get_field("cancellation_date"), None);
470        assert_eq!(license.get_field("cancelled"), None);
471
472        assert_eq!(
473            license.get_field("previous_call_sign"),
474            Some("N1XYZ".to_string())
475        );
476        assert_eq!(
477            license.get_field("previous_call"),
478            Some("N1XYZ".to_string())
479        );
480
481        assert_eq!(license.get_field("usi"), Some("12345".to_string()));
482        assert_eq!(
483            license.get_field("unique_system_identifier"),
484            Some("12345".to_string())
485        );
486
487        // Unknown field
488        assert_eq!(license.get_field("unknown_field"), None);
489    }
490
491    #[test]
492    fn test_get_field_location_empty() {
493        let license = License {
494            unique_system_identifier: 1,
495            call_sign: "W1AW".to_string(),
496            licensee_name: "Test".to_string(),
497            first_name: None,
498            middle_initial: None,
499            last_name: None,
500            status: 'A',
501            radio_service: "HA".to_string(),
502            grant_date: None,
503            expired_date: None,
504            cancellation_date: None,
505            frn: None,
506            street_address: None,
507            city: None,
508            state: None,
509            zip_code: None,
510            operator_class: None,
511            previous_call_sign: None,
512        };
513
514        // Empty city and state should return None for location
515        assert_eq!(license.get_field("location"), None);
516    }
517
518    #[test]
519    fn test_get_field_no_operator_class() {
520        let license = License {
521            unique_system_identifier: 1,
522            call_sign: "W1AW".to_string(),
523            licensee_name: "Test".to_string(),
524            first_name: None,
525            middle_initial: None,
526            last_name: None,
527            status: 'A',
528            radio_service: "ZA".to_string(), // GMRS has no class
529            grant_date: None,
530            expired_date: None,
531            cancellation_date: None,
532            frn: None,
533            street_address: None,
534            city: None,
535            state: None,
536            zip_code: None,
537            operator_class: None,
538            previous_call_sign: None,
539        };
540
541        assert_eq!(license.get_field("class"), None);
542        assert_eq!(license.get_field("class_desc"), None);
543    }
544
545    #[test]
546    fn test_field_names() {
547        let names = License::field_names();
548        assert!(names.contains(&"call_sign"));
549        assert!(names.contains(&"name"));
550        assert!(names.contains(&"status"));
551        assert!(names.contains(&"city"));
552        assert!(names.contains(&"state"));
553        assert!(names.contains(&"frn"));
554        assert!(names.contains(&"grant_date"));
555        assert!(names.len() >= 15);
556    }
557
558    #[test]
559    fn test_is_active() {
560        let mut license = License {
561            unique_system_identifier: 1,
562            call_sign: "W1AW".to_string(),
563            licensee_name: "Test".to_string(),
564            first_name: None,
565            middle_initial: None,
566            last_name: None,
567            status: 'A',
568            radio_service: "HA".to_string(),
569            grant_date: None,
570            expired_date: None,
571            cancellation_date: None,
572            frn: None,
573            street_address: None,
574            city: None,
575            state: None,
576            zip_code: None,
577            operator_class: None,
578            previous_call_sign: None,
579        };
580
581        assert!(license.is_active());
582
583        license.status = 'E';
584        assert!(!license.is_active());
585
586        license.status = 'C';
587        assert!(!license.is_active());
588    }
589
590    #[test]
591    fn test_get_field_cancellation_date_with_value() {
592        // Tests the cancellation_date path when a date IS present
593        // (the lambda inside .map() was never exercised before)
594        let license = License {
595            unique_system_identifier: 1,
596            call_sign: "W1AW".to_string(),
597            licensee_name: "Test".to_string(),
598            first_name: None,
599            middle_initial: None,
600            last_name: None,
601            status: 'C',
602            radio_service: "HA".to_string(),
603            grant_date: None,
604            expired_date: None,
605            cancellation_date: Some(NaiveDate::from_ymd_opt(2023, 6, 15).unwrap()),
606            frn: None,
607            street_address: None,
608            city: None,
609            state: None,
610            zip_code: None,
611            operator_class: None,
612            previous_call_sign: None,
613        };
614
615        assert_eq!(
616            license.get_field("cancellation_date"),
617            Some("2023-06-15".to_string())
618        );
619        assert_eq!(
620            license.get_field("cancelled"),
621            Some("2023-06-15".to_string())
622        );
623    }
624
625    #[test]
626    fn test_get_field_location_partial() {
627        // Tests location field with only city or only state present
628        let mut license = License {
629            unique_system_identifier: 1,
630            call_sign: "W1AW".to_string(),
631            licensee_name: "Test".to_string(),
632            first_name: None,
633            middle_initial: None,
634            last_name: None,
635            status: 'A',
636            radio_service: "HA".to_string(),
637            grant_date: None,
638            expired_date: None,
639            cancellation_date: None,
640            frn: None,
641            street_address: None,
642            city: Some("Boston".to_string()),
643            state: None,
644            zip_code: None,
645            operator_class: None,
646            previous_call_sign: None,
647        };
648
649        // Only city, no state - should still format
650        assert_eq!(license.get_field("location"), Some("Boston, ".to_string()));
651
652        // Only state, no city
653        license.city = None;
654        license.state = Some("MA".to_string());
655        assert_eq!(license.get_field("location"), Some(", MA".to_string()));
656    }
657
658    #[test]
659    fn test_get_field_expiration_alias() {
660        // Ensure the "expiration" alias works for expired_date
661        let license = License {
662            unique_system_identifier: 1,
663            call_sign: "W1AW".to_string(),
664            licensee_name: "Test".to_string(),
665            first_name: None,
666            middle_initial: None,
667            last_name: None,
668            status: 'A',
669            radio_service: "HA".to_string(),
670            grant_date: None,
671            expired_date: Some(NaiveDate::from_ymd_opt(2030, 12, 31).unwrap()),
672            cancellation_date: None,
673            frn: None,
674            street_address: None,
675            city: None,
676            state: None,
677            zip_code: None,
678            operator_class: None,
679            previous_call_sign: None,
680        };
681
682        assert_eq!(
683            license.get_field("expiration"),
684            Some("2030-12-31".to_string())
685        );
686    }
687
688    #[test]
689    fn test_get_field_entity_name_alias() {
690        // Ensure the "entity_name" alias works for display_name
691        let license = License {
692            unique_system_identifier: 1,
693            call_sign: "W1AW".to_string(),
694            licensee_name: "ARRL HQ Station".to_string(),
695            first_name: None,
696            middle_initial: None,
697            last_name: None,
698            status: 'A',
699            radio_service: "HA".to_string(),
700            grant_date: None,
701            expired_date: None,
702            cancellation_date: None,
703            frn: None,
704            street_address: None,
705            city: None,
706            state: None,
707            zip_code: None,
708            operator_class: None,
709            previous_call_sign: None,
710        };
711
712        // When no first/last name, should return licensee_name
713        assert_eq!(
714            license.get_field("entity_name"),
715            Some("ARRL HQ Station".to_string())
716        );
717    }
718
719    #[test]
720    fn test_get_field_license_status_alias() {
721        // Ensure the "license_status" alias works for status
722        let license = License {
723            unique_system_identifier: 1,
724            call_sign: "W1AW".to_string(),
725            licensee_name: "Test".to_string(),
726            first_name: None,
727            middle_initial: None,
728            last_name: None,
729            status: 'E',
730            radio_service: "HA".to_string(),
731            grant_date: None,
732            expired_date: None,
733            cancellation_date: None,
734            frn: None,
735            street_address: None,
736            city: None,
737            state: None,
738            zip_code: None,
739            operator_class: None,
740            previous_call_sign: None,
741        };
742
743        assert_eq!(license.get_field("license_status"), Some("E".to_string()));
744    }
745}