Skip to main content

uls_query/
output.rs

1//! Output formatting for license data.
2
3use uls_db::models::License;
4
5/// Supported output formats.
6#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
7pub enum OutputFormat {
8    /// Human-readable table format.
9    #[default]
10    Table,
11    /// JSON format.
12    Json,
13    /// JSON with pretty printing.
14    JsonPretty,
15    /// CSV format.
16    Csv,
17    /// YAML format.
18    Yaml,
19    /// Single-line compact format.
20    Compact,
21}
22
23impl std::str::FromStr for OutputFormat {
24    type Err = ();
25
26    fn from_str(s: &str) -> Result<Self, Self::Err> {
27        match s.to_lowercase().as_str() {
28            "table" => Ok(OutputFormat::Table),
29            "json" => Ok(OutputFormat::Json),
30            "json-pretty" | "jsonpretty" => Ok(OutputFormat::JsonPretty),
31            "csv" => Ok(OutputFormat::Csv),
32            "yaml" | "yml" => Ok(OutputFormat::Yaml),
33            "compact" | "oneline" => Ok(OutputFormat::Compact),
34            _ => Err(()),
35        }
36    }
37}
38
39/// Trait for formatting output.
40pub trait FormatOutput {
41    /// Format as the given output format.
42    fn format(&self, format: OutputFormat) -> String;
43}
44
45impl FormatOutput for License {
46    fn format(&self, format: OutputFormat) -> String {
47        match format {
48            OutputFormat::Table => format_license_table(self),
49            OutputFormat::Json => serde_json::to_string(self).unwrap_or_default(),
50            OutputFormat::JsonPretty => serde_json::to_string_pretty(self).unwrap_or_default(),
51            OutputFormat::Csv => format_license_csv(self),
52            OutputFormat::Yaml => format_license_yaml(self),
53            OutputFormat::Compact => format_license_compact(self),
54        }
55    }
56}
57
58impl FormatOutput for Vec<License> {
59    fn format(&self, format: OutputFormat) -> String {
60        match format {
61            OutputFormat::Table => format_licenses_table(self),
62            OutputFormat::Json => serde_json::to_string(self).unwrap_or_default(),
63            OutputFormat::JsonPretty => serde_json::to_string_pretty(self).unwrap_or_default(),
64            OutputFormat::Csv => format_licenses_csv(self),
65            OutputFormat::Yaml => format_licenses_yaml(self),
66            OutputFormat::Compact => self
67                .iter()
68                .map(format_license_compact)
69                .collect::<Vec<_>>()
70                .join("\n"),
71        }
72    }
73}
74
75/// Format a single license as a table.
76fn format_license_table(license: &License) -> String {
77    let mut output = String::new();
78    output.push_str(&format!("Call Sign:      {}\n", license.call_sign));
79    output.push_str(&format!("Name:           {}\n", license.display_name()));
80    output.push_str(&format!(
81        "Status:         {} ({})\n",
82        license.status,
83        license.status_description()
84    ));
85    output.push_str(&format!("Service:        {}\n", license.radio_service));
86
87    if let Some(class) = license.operator_class_description() {
88        output.push_str(&format!("Operator Class: {}\n", class));
89    }
90
91    match (&license.street_address, &license.po_box) {
92        (Some(addr), Some(po_box)) => {
93            output.push_str(&format!("Address:        {}\n", addr));
94            output.push_str(&format!("                PO Box {}\n", po_box));
95        }
96        (Some(addr), None) => {
97            output.push_str(&format!("Address:        {}\n", addr));
98        }
99        (None, Some(po_box)) => {
100            output.push_str(&format!("Address:        PO Box {}\n", po_box));
101        }
102        (None, None) => {}
103    }
104
105    let location = format_location(license);
106    if !location.is_empty() {
107        output.push_str(&format!("Location:       {}\n", location));
108    }
109
110    if let Some(ref frn) = license.frn {
111        output.push_str(&format!("FRN:            {}\n", frn));
112    }
113
114    if let Some(date) = license.grant_date {
115        output.push_str(&format!("Granted:        {}\n", date));
116    }
117
118    if let Some(date) = license.expired_date {
119        output.push_str(&format!("Expires:        {}\n", date));
120    }
121
122    output
123}
124
125/// Format multiple licenses as a table.
126fn format_licenses_table(licenses: &[License]) -> String {
127    if licenses.is_empty() {
128        return "No results found.\n".to_string();
129    }
130
131    let mut output = String::new();
132    output.push_str(&format!(
133        "{:<10} {:<30} {:<6} {:<5} {:<20}\n",
134        "CALL", "NAME", "STATUS", "CLASS", "LOCATION"
135    ));
136    output.push_str(&format!(
137        "{:-<10} {:-<30} {:-<6} {:-<5} {:-<20}\n",
138        "", "", "", "", ""
139    ));
140
141    for license in licenses {
142        let class = license
143            .operator_class
144            .map(|c| c.to_string())
145            .unwrap_or_else(|| "-".to_string());
146        let location = format!(
147            "{}, {}",
148            license.city.as_deref().unwrap_or("-"),
149            license.state.as_deref().unwrap_or("-")
150        );
151
152        output.push_str(&format!(
153            "{:<10} {:<30} {:<6} {:<5} {:<20}\n",
154            license.call_sign,
155            truncate(&license.display_name(), 30),
156            license.status,
157            class,
158            truncate(&location, 20)
159        ));
160    }
161
162    output.push_str(&format!("\n{} result(s)\n", licenses.len()));
163    output
164}
165
166/// Format a license as compact one-liner.
167fn format_license_compact(license: &License) -> String {
168    let class = license
169        .operator_class
170        .map(|c| format!(" ({})", c))
171        .unwrap_or_default();
172    format!(
173        "{}{} - {} [{}]",
174        license.call_sign,
175        class,
176        license.display_name(),
177        license.status_description()
178    )
179}
180
181/// Format a license as CSV row.
182fn format_license_csv(license: &License) -> String {
183    format!(
184        "{},{},{},{},{},{},{},{},{}",
185        csv_escape(&license.call_sign),
186        csv_escape(&license.display_name()),
187        license.status,
188        &license.radio_service,
189        license
190            .operator_class
191            .map(|c| c.to_string())
192            .unwrap_or_default(),
193        csv_escape(license.city.as_deref().unwrap_or("")),
194        csv_escape(license.state.as_deref().unwrap_or("")),
195        license
196            .grant_date
197            .map(|d| d.to_string())
198            .unwrap_or_default(),
199        license
200            .expired_date
201            .map(|d| d.to_string())
202            .unwrap_or_default()
203    )
204}
205
206/// Format multiple licenses as CSV.
207fn format_licenses_csv(licenses: &[License]) -> String {
208    let mut output =
209        String::from("call_sign,name,status,service,class,city,state,grant_date,expiration_date\n");
210    for license in licenses {
211        output.push_str(&format_license_csv(license));
212        output.push('\n');
213    }
214    output
215}
216
217/// Format a license as YAML.
218fn format_license_yaml(license: &License) -> String {
219    // Simple YAML-like format
220    let mut output = String::new();
221    output.push_str(&format!("call_sign: {}\n", license.call_sign));
222    output.push_str(&format!("name: {}\n", license.display_name()));
223    output.push_str(&format!("status: {}\n", license.status));
224    output.push_str(&format!("service: {}\n", license.radio_service));
225    if let Some(class) = license.operator_class {
226        output.push_str(&format!("operator_class: {}\n", class));
227    }
228    if let Some(ref city) = license.city {
229        output.push_str(&format!("city: {}\n", city));
230    }
231    if let Some(ref state) = license.state {
232        output.push_str(&format!("state: {}\n", state));
233    }
234    output
235}
236
237/// Format multiple licenses as YAML.
238fn format_licenses_yaml(licenses: &[License]) -> String {
239    let mut output = String::from("licenses:\n");
240    for license in licenses {
241        output.push_str("  - ");
242        let yaml = format_license_yaml(license);
243        let lines: Vec<&str> = yaml.lines().collect();
244        for (i, line) in lines.iter().enumerate() {
245            if i == 0 {
246                output.push_str(line);
247                output.push('\n');
248            } else {
249                output.push_str("    ");
250                output.push_str(line);
251                output.push('\n');
252            }
253        }
254    }
255    output
256}
257
258/// Format location string.
259fn format_location(license: &License) -> String {
260    let parts: Vec<&str> = [
261        license.city.as_deref(),
262        license.state.as_deref(),
263        license.zip_code.as_deref(),
264    ]
265    .into_iter()
266    .flatten()
267    .collect();
268
269    if parts.is_empty() {
270        String::new()
271    } else if parts.len() >= 2 {
272        format!(
273            "{}, {} {}",
274            parts[0],
275            parts.get(1).unwrap_or(&""),
276            parts.get(2).unwrap_or(&"")
277        )
278        .trim()
279        .to_string()
280    } else {
281        parts[0].to_string()
282    }
283}
284
285/// Truncate a string to max length.
286fn truncate(s: &str, max_len: usize) -> String {
287    if s.len() <= max_len {
288        s.to_string()
289    } else {
290        format!("{}...", &s[..max_len.saturating_sub(3)])
291    }
292}
293
294/// Escape a value for CSV.
295fn csv_escape(s: &str) -> String {
296    if s.contains(',') || s.contains('"') || s.contains('\n') {
297        format!("\"{}\"", s.replace('"', "\"\""))
298    } else {
299        s.to_string()
300    }
301}
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306
307    fn test_license() -> License {
308        License {
309            unique_system_identifier: 123,
310            call_sign: "W1TEST".to_string(),
311            licensee_name: "Test User".to_string(),
312            first_name: Some("Test".to_string()),
313            middle_initial: None,
314            last_name: Some("User".to_string()),
315            status: 'A',
316            radio_service: "HA".to_string(),
317            grant_date: None,
318            expired_date: None,
319            cancellation_date: None,
320            frn: Some("0001234567".to_string()),
321            street_address: Some("123 Main St".to_string()),
322            city: Some("NEWINGTON".to_string()),
323            state: Some("CT".to_string()),
324            zip_code: Some("06111".to_string()),
325            po_box: None,
326            operator_class: Some('E'),
327            previous_call_sign: None,
328        }
329    }
330
331    #[test]
332    fn test_table_format() {
333        let license = test_license();
334        let output = license.format(OutputFormat::Table);
335        assert!(output.contains("W1TEST"));
336        assert!(output.contains("Test User"));
337        assert!(output.contains("NEWINGTON"));
338    }
339
340    #[test]
341    fn test_compact_format() {
342        let license = test_license();
343        let output = license.format(OutputFormat::Compact);
344        assert!(output.contains("W1TEST"));
345        assert!(output.contains("(E)"));
346    }
347
348    #[test]
349    fn test_csv_format() {
350        let license = test_license();
351        let output = license.format(OutputFormat::Csv);
352        assert!(output.contains("W1TEST"));
353        assert!(output.contains("NEWINGTON"));
354    }
355
356    #[test]
357    fn test_csv_escape() {
358        assert_eq!(csv_escape("simple"), "simple");
359        assert_eq!(csv_escape("with,comma"), "\"with,comma\"");
360        assert_eq!(csv_escape("with\"quote"), "\"with\"\"quote\"");
361    }
362
363    #[test]
364    fn test_json_format() {
365        let license = test_license();
366        let output = license.format(OutputFormat::Json);
367        assert!(output.contains("W1TEST"));
368        assert!(output.contains("unique_system_identifier"));
369    }
370
371    #[test]
372    fn test_json_pretty_format() {
373        let license = test_license();
374        let output = license.format(OutputFormat::JsonPretty);
375        assert!(output.contains("W1TEST"));
376        assert!(output.contains("\n")); // Pretty format has newlines
377    }
378
379    #[test]
380    fn test_yaml_format() {
381        let license = test_license();
382        let output = license.format(OutputFormat::Yaml);
383        assert!(output.contains("call_sign: W1TEST"));
384        assert!(output.contains("status: A"));
385    }
386
387    #[test]
388    fn test_vec_table_format() {
389        let licenses = vec![test_license()];
390        let output = licenses.format(OutputFormat::Table);
391        assert!(output.contains("W1TEST"));
392        assert!(output.contains("CALL")); // Header
393        assert!(output.contains("1 result"));
394    }
395
396    #[test]
397    fn test_vec_empty_table() {
398        let licenses: Vec<License> = vec![];
399        let output = licenses.format(OutputFormat::Table);
400        assert!(output.contains("No results found"));
401    }
402
403    #[test]
404    fn test_vec_csv_format() {
405        let licenses = vec![test_license()];
406        let output = licenses.format(OutputFormat::Csv);
407        assert!(output.contains("call_sign,name")); // Header
408        assert!(output.contains("W1TEST"));
409    }
410
411    #[test]
412    fn test_vec_yaml_format() {
413        let licenses = vec![test_license()];
414        let output = licenses.format(OutputFormat::Yaml);
415        assert!(output.contains("licenses:"));
416        assert!(output.contains("call_sign: W1TEST"));
417    }
418
419    #[test]
420    fn test_vec_compact_format() {
421        let licenses = vec![test_license()];
422        let output = licenses.format(OutputFormat::Compact);
423        assert!(output.contains("W1TEST"));
424    }
425
426    #[test]
427    fn test_output_format_from_str() {
428        assert_eq!("table".parse::<OutputFormat>(), Ok(OutputFormat::Table));
429        assert_eq!("json".parse::<OutputFormat>(), Ok(OutputFormat::Json));
430        assert_eq!(
431            "json-pretty".parse::<OutputFormat>(),
432            Ok(OutputFormat::JsonPretty)
433        );
434        assert_eq!("csv".parse::<OutputFormat>(), Ok(OutputFormat::Csv));
435        assert_eq!("yaml".parse::<OutputFormat>(), Ok(OutputFormat::Yaml));
436        assert_eq!("compact".parse::<OutputFormat>(), Ok(OutputFormat::Compact));
437        assert!("unknown".parse::<OutputFormat>().is_err());
438    }
439
440    #[test]
441    fn test_truncate() {
442        assert_eq!(truncate("short", 10), "short");
443        assert_eq!(truncate("this is a very long string", 10), "this is...");
444    }
445
446    #[test]
447    fn test_csv_escape_newline() {
448        assert_eq!(csv_escape("with\nnewline"), "\"with\nnewline\"");
449    }
450
451    // ==========================================================================
452    // JSON output validation tests
453    // ==========================================================================
454
455    #[test]
456    fn test_json_format_is_valid_json() {
457        let license = test_license();
458        let output = license.format(OutputFormat::Json);
459        let parsed: serde_json::Result<serde_json::Value> = serde_json::from_str(&output);
460        assert!(
461            parsed.is_ok(),
462            "JSON output should be valid JSON: {}",
463            output
464        );
465    }
466
467    #[test]
468    fn test_json_pretty_format_is_valid_json() {
469        let license = test_license();
470        let output = license.format(OutputFormat::JsonPretty);
471        let parsed: serde_json::Result<serde_json::Value> = serde_json::from_str(&output);
472        assert!(
473            parsed.is_ok(),
474            "JSON-pretty output should be valid JSON: {}",
475            output
476        );
477    }
478
479    #[test]
480    fn test_vec_json_format_is_valid_json_array() {
481        let licenses = vec![test_license(), test_license()];
482        let output = licenses.format(OutputFormat::Json);
483        let parsed: serde_json::Result<Vec<serde_json::Value>> = serde_json::from_str(&output);
484        assert!(
485            parsed.is_ok(),
486            "Vec JSON output should be valid JSON array: {}",
487            output
488        );
489        assert_eq!(parsed.unwrap().len(), 2);
490    }
491
492    #[test]
493    fn test_vec_json_pretty_format_is_valid_json_array() {
494        let licenses = vec![test_license()];
495        let output = licenses.format(OutputFormat::JsonPretty);
496        let parsed: serde_json::Result<Vec<serde_json::Value>> = serde_json::from_str(&output);
497        assert!(
498            parsed.is_ok(),
499            "Vec JSON-pretty output should be valid JSON array: {}",
500            output
501        );
502    }
503
504    #[test]
505    fn test_empty_vec_json_format_is_valid_json() {
506        let licenses: Vec<License> = vec![];
507        let output = licenses.format(OutputFormat::Json);
508        let parsed: serde_json::Result<Vec<serde_json::Value>> = serde_json::from_str(&output);
509        assert!(parsed.is_ok(), "Empty vec JSON should be valid: {}", output);
510        assert_eq!(parsed.unwrap().len(), 0);
511    }
512
513    #[test]
514    fn test_format_location_empty() {
515        let mut license = test_license();
516        license.city = None;
517        license.state = None;
518        license.zip_code = None;
519        let location = format_location(&license);
520        assert!(location.is_empty());
521    }
522
523    #[test]
524    fn test_format_location_single_part() {
525        let mut license = test_license();
526        license.city = Some("ONLY_CITY".to_string());
527        license.state = None;
528        license.zip_code = None;
529        let location = format_location(&license);
530        assert_eq!(location, "ONLY_CITY");
531    }
532
533    #[test]
534    fn test_yaml_format_minimal_fields() {
535        // License with only required fields, no optional ones
536        let license = License {
537            unique_system_identifier: 999,
538            call_sign: "W0MIN".to_string(),
539            licensee_name: "Minimal".to_string(),
540            first_name: None,
541            middle_initial: None,
542            last_name: None,
543            status: 'A',
544            radio_service: "HA".to_string(),
545            grant_date: None,
546            expired_date: None,
547            cancellation_date: None,
548            frn: None,
549            street_address: None,
550            city: None,
551            state: None,
552            zip_code: None,
553            po_box: None,
554            operator_class: None,
555            previous_call_sign: None,
556        };
557        let output = license.format(OutputFormat::Yaml);
558        // Should have call_sign, name, status, service
559        assert!(output.contains("call_sign: W0MIN"));
560        assert!(output.contains("status: A"));
561        // Should NOT have operator_class, city, state since they're None
562        assert!(!output.contains("operator_class:"));
563        assert!(!output.contains("city:"));
564        assert!(!output.contains("state:"));
565    }
566
567    #[test]
568    fn test_compact_format_no_operator_class() {
569        let mut license = test_license();
570        license.operator_class = None;
571        let output = license.format(OutputFormat::Compact);
572        // Should not contain class in parentheses
573        assert!(!output.contains("("));
574        assert!(output.contains("W1TEST"));
575    }
576
577    #[test]
578    fn test_table_format_minimal() {
579        let license = License {
580            unique_system_identifier: 999,
581            call_sign: "W0MIN".to_string(),
582            licensee_name: "Minimal".to_string(),
583            first_name: None,
584            middle_initial: None,
585            last_name: None,
586            status: 'A',
587            radio_service: "HA".to_string(),
588            grant_date: None,
589            expired_date: None,
590            cancellation_date: None,
591            frn: None,
592            street_address: None,
593            city: None,
594            state: None,
595            zip_code: None,
596            po_box: None,
597            operator_class: None,
598            previous_call_sign: None,
599        };
600        let output = license.format(OutputFormat::Table);
601        assert!(output.contains("W0MIN"));
602        // Should not have Location line since city/state/zip are all None
603    }
604
605    #[test]
606    fn test_table_format_po_box_fallback() {
607        let mut license = test_license();
608        license.street_address = None;
609        license.po_box = Some("608".to_string());
610        let output = license.format(OutputFormat::Table);
611        assert!(output.contains("Address:        PO Box 608"));
612        assert!(!output.contains("123 Main St"));
613    }
614
615    #[test]
616    fn test_table_format_both_address_and_po_box() {
617        let mut license = test_license();
618        license.street_address = Some("2865 Center Road".to_string());
619        license.po_box = Some("1367".to_string());
620        let output = license.format(OutputFormat::Table);
621        assert!(output.contains("Address:        2865 Center Road\n"));
622        assert!(output.contains("                PO Box 1367\n"));
623    }
624
625    #[test]
626    fn test_table_format_no_address_or_po_box() {
627        let mut license = test_license();
628        license.street_address = None;
629        license.po_box = None;
630        let output = license.format(OutputFormat::Table);
631        assert!(!output.contains("Address:"));
632    }
633
634    #[test]
635    fn test_output_format_aliases() {
636        // Test alternative format names
637        assert_eq!("yml".parse::<OutputFormat>(), Ok(OutputFormat::Yaml));
638        assert_eq!(
639            "jsonpretty".parse::<OutputFormat>(),
640            Ok(OutputFormat::JsonPretty)
641        );
642        assert_eq!("oneline".parse::<OutputFormat>(), Ok(OutputFormat::Compact));
643    }
644}