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 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
115fn 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
156fn 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
171fn 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
196fn 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
207fn format_license_yaml(license: &License) -> String {
209 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
227fn 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
248fn 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
275fn 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
284fn 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")); }
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")); 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")); 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 #[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 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 assert!(output.contains("call_sign: W0MIN"));
548 assert!(output.contains("status: A"));
549 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 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 }
591
592 #[test]
593 fn test_output_format_aliases() {
594 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}