1use uls_db::models::License;
4
5#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
7pub enum OutputFormat {
8 #[default]
10 Table,
11 Json,
13 JsonPretty,
15 Csv,
17 Yaml,
19 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
39pub trait FormatOutput {
41 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
75fn 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
125fn 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
166fn 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
181fn 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
206fn 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
217fn format_license_yaml(license: &License) -> String {
219 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
237fn 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
258fn 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
285fn 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
294fn 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")); }
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")); 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")); 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 #[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 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 assert!(output.contains("call_sign: W0MIN"));
560 assert!(output.contains("status: A"));
561 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 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 }
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 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}