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