1use crate::cli::OutputFormatType;
14use crate::error::Error;
15use crate::terminal::no_color;
16use owo_colors::OwoColorize;
17use serde::Serialize;
18
19pub mod exit_codes {
21 pub const SUCCESS: i32 = 0;
22 #[allow(dead_code)]
23 pub const GENERAL_ERROR: i32 = 1;
24 pub const USAGE_ERROR: i32 = 64;
25 pub const CONFIG_ERROR: i32 = 65;
26 pub const NETWORK_ERROR: i32 = 69;
27 pub const INTERNAL_ERROR: i32 = 70;
28}
29
30#[derive(Serialize)]
31pub struct MachineErrorOutput<'a> {
32 status: &'static str,
33 exit_code: i32,
34 timestamp: String,
35 error: MachineErrorBody<'a>,
36}
37
38#[derive(Serialize)]
39pub struct MachineErrorBody<'a> {
40 code: &'static str,
41 category: &'static str,
42 message: String,
43 suggestion: &'a str,
44}
45
46pub fn machine_error_format(args: &crate::cli::Args) -> Option<OutputFormatType> {
48 match args.format {
49 Some(OutputFormatType::Json | OutputFormatType::Jsonl) => args.format,
50 #[allow(deprecated)]
51 _ if args.json.unwrap_or(false) => Some(OutputFormatType::Json),
52 _ => None,
53 }
54}
55
56pub fn is_list_sentinel(e: &Error) -> bool {
58 matches!(e, Error::Context { msg, .. } if msg == "__list_displayed__")
59}
60
61pub fn is_network_error(e: &Error) -> bool {
63 matches!(
64 e,
65 Error::NetworkError(_)
66 | Error::ServerListFetch(_)
67 | Error::DownloadTest(_)
68 | Error::DownloadFailure(_)
69 | Error::UploadTest(_)
70 | Error::UploadFailure(_)
71 | Error::IpDiscovery(_)
72 )
73}
74
75pub fn is_config_error(e: &Error) -> bool {
77 matches!(e, Error::ServerNotFound(_) | Error::Context { .. })
78}
79
80pub fn print_error(e: &Error, exit_code: i32, machine_format: Option<OutputFormatType>) {
82 if let Some(format) = machine_format {
83 print_machine_error(e, exit_code, format);
84 return;
85 }
86
87 let nc = no_color();
88 if nc {
89 eprintln!("\nError: {e}");
90 print_suggestion(e);
91 } else {
92 eprintln!("\n{}", format!("Error: {e}").red().bold());
93 print_suggestion(e);
94 }
95}
96
97pub fn print_machine_error(e: &Error, exit_code: i32, format: OutputFormatType) {
99 let output = render_machine_error(e, exit_code, format);
100 println!("{output}");
101}
102
103pub fn render_machine_error(e: &Error, exit_code: i32, format: OutputFormatType) -> String {
105 let (code, category) = machine_error_identity(e);
106 let payload = MachineErrorOutput {
107 status: "error",
108 exit_code,
109 timestamp: chrono::Utc::now().to_rfc3339(),
110 error: MachineErrorBody {
111 code,
112 category,
113 message: e.to_string(),
114 suggestion: suggestion_for_error(e),
115 },
116 };
117
118 match format {
119 OutputFormatType::Jsonl => {
120 serde_json::to_string(&payload).expect("machine error JSONL serialization failed")
121 }
122 OutputFormatType::Json => {
123 let is_tty = {
124 use std::io::IsTerminal;
125 std::io::stdout().is_terminal()
126 };
127 if is_tty {
128 serde_json::to_string_pretty(&payload)
129 } else {
130 serde_json::to_string(&payload)
131 }
132 .expect("machine error JSON serialization failed")
133 }
134 _ => unreachable!("machine-readable error output is only supported for JSON/JSONL"),
135 }
136}
137
138pub fn machine_error_identity(e: &Error) -> (&'static str, &'static str) {
140 match e {
141 Error::NetworkError(_) => ("network_error", "network"),
142 Error::ServerListFetch(_) => ("server_list_fetch_failed", "network"),
143 Error::DownloadTest(_) | Error::DownloadFailure(_) => ("download_failed", "network"),
144 Error::UploadTest(_) | Error::UploadFailure(_) => ("upload_failed", "network"),
145 Error::IpDiscovery(_) => ("ip_discovery_failed", "network"),
146 Error::ParseJson(_) => ("json_parse_failed", "parse"),
147 Error::ParseXml(_) | Error::DeserializeXml(_) => ("xml_parse_failed", "parse"),
148 Error::Csv(_) => ("csv_output_failed", "output"),
149 Error::ServerNotFound(_) => ("server_not_found", "config"),
150 Error::IoError(_) => ("io_error", "io"),
151 Error::Context { .. } => ("context_error", "internal"),
152 }
153}
154
155fn print_suggestion(e: &Error) {
157 let nc = no_color();
158 let suggestion = suggestion_for_error(e);
159 if nc {
160 eprintln!("{suggestion}");
161 } else {
162 eprintln!("{}", suggestion.bright_black());
163 }
164}
165
166pub fn suggestion_for_error(e: &Error) -> &'static str {
168 match e {
169 Error::NetworkError(_) | Error::ServerListFetch(_) | Error::IpDiscovery(_) => {
170 "Tip: Check your network connection and try again.\n You can also use --list to verify server access."
171 }
172 Error::DownloadTest(_) | Error::DownloadFailure(_) => {
173 "Tip: Download may be blocked by a firewall or proxy.\n Try with --single for a simpler test."
174 }
175 Error::UploadTest(_) | Error::UploadFailure(_) => {
176 "Tip: Upload may be blocked by a firewall or proxy.\n Try with --no-upload to skip upload testing."
177 }
178 Error::ServerNotFound(_) => "Tip: Use --list to see available servers.",
179 Error::IoError(_) => "Tip: Check file permissions and disk space.",
180 Error::ParseJson(_) | Error::ParseXml(_) | Error::DeserializeXml(_) => {
181 "Tip: The server response was malformed. Try again later."
182 }
183 _ => "For more information, run: netspeed-cli --help",
184 }
185}
186
187pub fn select_exit_code(e: &Error) -> i32 {
189 if is_list_sentinel(e) {
190 exit_codes::SUCCESS
191 } else if is_network_error(e) {
192 exit_codes::NETWORK_ERROR
193 } else if is_config_error(e) {
194 exit_codes::CONFIG_ERROR
195 } else {
196 exit_codes::INTERNAL_ERROR
197 }
198}
199
200#[cfg(test)]
201mod tests {
202 use super::*;
203 use crate::cli::OutputFormatType;
204 use clap::Parser;
205
206 #[test]
207 fn test_exit_codes_values() {
208 assert_eq!(exit_codes::SUCCESS, 0);
209 assert_eq!(exit_codes::USAGE_ERROR, 64);
210 assert_eq!(exit_codes::CONFIG_ERROR, 65);
211 assert_eq!(exit_codes::NETWORK_ERROR, 69);
212 assert_eq!(exit_codes::INTERNAL_ERROR, 70);
213 }
214
215 #[test]
216 fn test_machine_error_format_json() {
217 let args = crate::cli::Args::try_parse_from(["netspeed-cli", "--format", "json"]).unwrap();
218 assert!(matches!(
219 machine_error_format(&args),
220 Some(OutputFormatType::Json)
221 ));
222 }
223
224 #[test]
225 fn test_machine_error_format_jsonl() {
226 let args = crate::cli::Args::try_parse_from(["netspeed-cli", "--format", "jsonl"]).unwrap();
227 assert!(matches!(
228 machine_error_format(&args),
229 Some(OutputFormatType::Jsonl)
230 ));
231 }
232
233 #[test]
234 fn test_machine_error_format_legacy_json_flag() {
235 let args = crate::cli::Args::try_parse_from(["netspeed-cli", "--json"]).unwrap();
236 assert!(matches!(
237 machine_error_format(&args),
238 Some(OutputFormatType::Json)
239 ));
240 }
241
242 #[test]
243 fn test_machine_error_format_ignores_human_formats() {
244 let args =
245 crate::cli::Args::try_parse_from(["netspeed-cli", "--format", "compact"]).unwrap();
246 assert!(machine_error_format(&args).is_none());
247 }
248
249 #[test]
250 fn test_machine_error_format_ignores_dashboard() {
251 let args =
252 crate::cli::Args::try_parse_from(["netspeed-cli", "--format", "dashboard"]).unwrap();
253 assert!(machine_error_format(&args).is_none());
254 }
255
256 #[test]
257 fn test_machine_error_format_ignores_detailed() {
258 let args =
259 crate::cli::Args::try_parse_from(["netspeed-cli", "--format", "detailed"]).unwrap();
260 assert!(machine_error_format(&args).is_none());
261 }
262
263 #[test]
264 fn test_machine_error_format_ignores_simple() {
265 let args =
266 crate::cli::Args::try_parse_from(["netspeed-cli", "--format", "simple"]).unwrap();
267 assert!(machine_error_format(&args).is_none());
268 }
269
270 #[test]
271 fn test_machine_error_format_none_by_default() {
272 let args = crate::cli::Args::try_parse_from(["netspeed-cli"]).unwrap();
273 assert!(machine_error_format(&args).is_none());
274 }
275
276 #[test]
277 fn test_is_list_sentinel_true() {
278 let sentinel = Error::Context {
279 msg: "__list_displayed__".into(),
280 source: None,
281 };
282 assert!(is_list_sentinel(&sentinel));
283 }
284
285 #[test]
286 fn test_is_list_sentinel_false_different_message() {
287 let other = Error::Context {
288 msg: "other error".into(),
289 source: None,
290 };
291 assert!(!is_list_sentinel(&other));
292 }
293
294 #[test]
295 fn test_is_list_sentinel_false_different_error_type() {
296 assert!(!is_list_sentinel(&Error::DownloadFailure("test".into())));
297 assert!(!is_list_sentinel(&Error::IoError(std::io::Error::new(
298 std::io::ErrorKind::NotFound,
299 "not found"
300 ))));
301 }
302
303 #[test]
304 fn test_is_network_error_download_failure() {
305 assert!(is_network_error(&Error::DownloadFailure("test".into())));
306 }
307
308 #[test]
309 fn test_is_network_error_upload_failure() {
310 assert!(is_network_error(&Error::UploadFailure("test".into())));
311 }
312
313 #[test]
314 fn test_is_network_error_false_context() {
315 assert!(!is_network_error(&Error::Context {
316 msg: "config error".into(),
317 source: None,
318 }));
319 }
320
321 #[test]
322 fn test_is_network_error_false_server_not_found() {
323 assert!(!is_network_error(&Error::ServerNotFound("missing".into())));
324 }
325
326 #[test]
327 fn test_is_config_error_server_not_found() {
328 assert!(is_config_error(&Error::ServerNotFound("missing".into())));
329 }
330
331 #[test]
332 fn test_is_config_error_context() {
333 let err = Error::Context {
334 msg: "config error".into(),
335 source: None,
336 };
337 assert!(is_config_error(&err));
338 }
339
340 #[test]
341 fn test_is_config_error_false_network() {
342 assert!(!is_config_error(&Error::DownloadFailure("test".into())));
343 assert!(!is_config_error(&Error::UploadFailure("test".into())));
344 }
345
346 #[test]
347 fn test_machine_error_identity_download_failure() {
348 let err = Error::DownloadFailure("zero bytes".into());
349 let (code, category) = machine_error_identity(&err);
350 assert_eq!(code, "download_failed");
351 assert_eq!(category, "network");
352 }
353
354 #[test]
355 fn test_machine_error_identity_upload_failure() {
356 let err = Error::UploadFailure("timeout".into());
357 let (code, category) = machine_error_identity(&err);
358 assert_eq!(code, "upload_failed");
359 assert_eq!(category, "network");
360 }
361
362 #[test]
363 fn test_machine_error_identity_server_not_found() {
364 let (code, category) =
365 machine_error_identity(&Error::ServerNotFound("missing".to_string()));
366 assert_eq!(code, "server_not_found");
367 assert_eq!(category, "config");
368 }
369
370 #[test]
371 fn test_machine_error_identity_parse_json() {
372 let (code, category) = machine_error_identity(&Error::ParseJson(
373 serde_json::from_str::<serde_json::Value>("invalid").unwrap_err(),
374 ));
375 assert_eq!(code, "json_parse_failed");
376 assert_eq!(category, "parse");
377 }
378
379 #[test]
380 fn test_machine_error_identity_parse_xml() {
381 let io_err = std::io::Error::new(std::io::ErrorKind::InvalidData, "invalid xml");
382 let xml_err = quick_xml::Error::Io(io_err.into());
383 let err = Error::ParseXml(xml_err);
384 let (code, category) = machine_error_identity(&err);
385 assert_eq!(code, "xml_parse_failed");
386 assert_eq!(category, "parse");
387 }
388
389 #[test]
390 fn test_machine_error_identity_deserialize_xml() {
391 let invalid_xml = "<unclosed>";
392 let result: Result<serde_json::Value, _> = quick_xml::de::from_str(invalid_xml);
393 assert!(result.is_err());
394 let err = Error::DeserializeXml(result.unwrap_err());
395 let (code, category) = machine_error_identity(&err);
396 assert_eq!(code, "xml_parse_failed");
397 assert_eq!(category, "parse");
398 }
399
400 #[test]
401 fn test_machine_error_identity_csv() {
402 let io_err = std::io::Error::new(std::io::ErrorKind::InvalidData, "csv error");
403 let csv_err = csv::Error::from(io_err);
404 let err = Error::Csv(csv_err);
405 let (code, category) = machine_error_identity(&err);
406 assert_eq!(code, "csv_output_failed");
407 assert_eq!(category, "output");
408 }
409
410 #[test]
411 fn test_machine_error_identity_io_error() {
412 let (code, category) = machine_error_identity(&Error::IoError(std::io::Error::new(
413 std::io::ErrorKind::NotFound,
414 "not found",
415 )));
416 assert_eq!(code, "io_error");
417 assert_eq!(category, "io");
418 }
419
420 #[test]
421 fn test_machine_error_identity_context() {
422 let (code, category) = machine_error_identity(&Error::Context {
423 msg: "test".into(),
424 source: None,
425 });
426 assert_eq!(code, "context_error");
427 assert_eq!(category, "internal");
428 }
429
430 #[test]
431 fn test_machine_error_identity_context_with_source() {
432 let source_err = Error::IoError(std::io::Error::new(
433 std::io::ErrorKind::TimedOut,
434 "timed out",
435 ));
436 let (code, category) = machine_error_identity(&Error::Context {
437 msg: "nested error".into(),
438 source: Some(Box::new(source_err)),
439 });
440 assert_eq!(code, "context_error");
441 assert_eq!(category, "internal");
442 }
443
444 #[test]
445 fn test_render_machine_error_jsonl() {
446 let output = render_machine_error(
447 &Error::DownloadFailure("all streams failed".to_string()),
448 exit_codes::NETWORK_ERROR,
449 OutputFormatType::Jsonl,
450 );
451 let payload: serde_json::Value = serde_json::from_str(&output).unwrap();
452 assert_eq!(payload["status"], "error");
453 assert_eq!(payload["exit_code"], exit_codes::NETWORK_ERROR);
454 assert_eq!(payload["error"]["code"], "download_failed");
455 assert_eq!(payload["error"]["category"], "network");
456 assert!(payload["error"]["message"].is_string());
457 assert!(payload["error"]["suggestion"].is_string());
458 assert!(!output.contains('\n'));
459 }
460
461 #[test]
462 fn test_render_machine_error_json() {
463 let output = render_machine_error(
464 &Error::DownloadFailure("test error".to_string()),
465 exit_codes::NETWORK_ERROR,
466 OutputFormatType::Json,
467 );
468 let payload: serde_json::Value = serde_json::from_str(&output).unwrap();
469 assert_eq!(payload["status"], "error");
470 assert_eq!(payload["error"]["code"], "download_failed");
471 assert!(payload["error"]["message"].is_string());
472 assert!(payload["error"]["suggestion"].is_string());
473 }
474
475 #[test]
476 fn test_render_machine_error_timestamp_format() {
477 let output = render_machine_error(
478 &Error::ServerNotFound("missing".to_string()),
479 exit_codes::CONFIG_ERROR,
480 OutputFormatType::Json,
481 );
482 let payload: serde_json::Value = serde_json::from_str(&output).unwrap();
483 let timestamp = payload["timestamp"].as_str().unwrap();
484 assert!(timestamp.contains("T") || timestamp.contains(" "));
485 assert!(timestamp.ends_with('Z') || timestamp.ends_with("+00:00"));
486 }
487
488 #[test]
489 fn test_render_machine_error_all_fields_present() {
490 let output = render_machine_error(
491 &Error::UploadFailure("connection reset".to_string()),
492 exit_codes::NETWORK_ERROR,
493 OutputFormatType::Json,
494 );
495 let payload: serde_json::Value = serde_json::from_str(&output).unwrap();
496
497 assert!(payload.get("status").is_some());
498 assert!(payload.get("exit_code").is_some());
499 assert!(payload.get("timestamp").is_some());
500 assert!(payload.get("error").is_some());
501
502 let error = &payload["error"];
503 assert!(error.get("code").is_some());
504 assert!(error.get("category").is_some());
505 assert!(error.get("message").is_some());
506 assert!(error.get("suggestion").is_some());
507 }
508
509 #[test]
510 fn test_suggestion_for_error_download_failure() {
511 let err = Error::DownloadFailure("connection timed out".into());
512 let suggestion = suggestion_for_error(&err);
513 assert!(suggestion.contains("firewall") || suggestion.contains("--single"));
514 }
515
516 #[test]
517 fn test_suggestion_for_error_upload_failure() {
518 let err = Error::UploadFailure("timeout".into());
519 let suggestion = suggestion_for_error(&err);
520 assert!(suggestion.contains("firewall") || suggestion.contains("--no-upload"));
521 }
522
523 #[test]
524 fn test_suggestion_for_error_server_not_found() {
525 let suggestion = suggestion_for_error(&Error::ServerNotFound("missing".into()));
526 assert!(suggestion.contains("--list"));
527 }
528
529 #[test]
530 fn test_suggestion_for_error_io_error() {
531 let suggestion = suggestion_for_error(&Error::IoError(std::io::Error::new(
532 std::io::ErrorKind::NotFound,
533 "not found",
534 )));
535 assert!(suggestion.contains("permissions") || suggestion.contains("disk"));
536 }
537
538 #[test]
539 fn test_suggestion_for_error_parse_json() {
540 let suggestion = suggestion_for_error(&Error::ParseJson(
541 serde_json::from_str::<serde_json::Value>("invalid").unwrap_err(),
542 ));
543 assert!(suggestion.contains("malformed") || suggestion.contains("Try again"));
544 }
545
546 #[test]
547 fn test_suggestion_for_error_parse_xml() {
548 let io_err = std::io::Error::new(std::io::ErrorKind::InvalidData, "invalid xml");
549 let xml_err = quick_xml::Error::Io(io_err.into());
550 let err = Error::ParseXml(xml_err);
551 let suggestion = suggestion_for_error(&err);
552 assert!(suggestion.contains("malformed") || suggestion.contains("Try again"));
553 }
554
555 #[test]
556 fn test_suggestion_for_error_deserialize_xml() {
557 let invalid_xml = "<unclosed>";
558 let result: Result<serde_json::Value, _> = quick_xml::de::from_str(invalid_xml);
559 assert!(result.is_err());
560 let err = Error::DeserializeXml(result.unwrap_err());
561 let suggestion = suggestion_for_error(&err);
562 assert!(suggestion.contains("malformed") || suggestion.contains("Try again"));
563 }
564
565 #[test]
566 fn test_suggestion_for_error_default() {
567 let err = Error::Context {
568 msg: "unknown".into(),
569 source: None,
570 };
571 let suggestion = suggestion_for_error(&err);
572 assert!(suggestion.contains("--help"));
573 }
574
575 #[test]
576 fn test_select_exit_code_network_error() {
577 let err = Error::DownloadFailure("test".into());
578 assert_eq!(select_exit_code(&err), exit_codes::NETWORK_ERROR);
579 }
580
581 #[test]
582 fn test_select_exit_code_config_error() {
583 let err = Error::ServerNotFound("missing".into());
584 assert_eq!(select_exit_code(&err), exit_codes::CONFIG_ERROR);
585 }
586
587 #[test]
588 fn test_select_exit_code_list_sentinel() {
589 let err = Error::Context {
590 msg: "__list_displayed__".into(),
591 source: None,
592 };
593 assert_eq!(select_exit_code(&err), exit_codes::SUCCESS);
594 }
595
596 #[test]
597 fn test_select_exit_code_io_error() {
598 let err = Error::IoError(std::io::Error::other("internal error"));
600 assert_eq!(select_exit_code(&err), exit_codes::INTERNAL_ERROR);
601 }
602
603 #[test]
604 fn test_machine_error_output_serialization() {
605 let output = MachineErrorOutput {
606 status: "error",
607 exit_code: 69,
608 timestamp: "2024-01-01T00:00:00Z".to_string(),
609 error: MachineErrorBody {
610 code: "test_code",
611 category: "test_category",
612 message: "Test error message".to_string(),
613 suggestion: "Test suggestion",
614 },
615 };
616
617 let json = serde_json::to_string(&output).unwrap();
618 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
619
620 assert_eq!(parsed["status"], "error");
621 assert_eq!(parsed["exit_code"], 69);
622 assert_eq!(parsed["error"]["code"], "test_code");
623 assert_eq!(parsed["error"]["category"], "test_category");
624 assert_eq!(parsed["error"]["message"], "Test error message");
625 assert_eq!(parsed["error"]["suggestion"], "Test suggestion");
626 }
627
628 #[test]
629 fn test_machine_error_body_serialization() {
630 let body = MachineErrorBody {
631 code: "network_error",
632 category: "network",
633 message: "Connection failed".to_string(),
634 suggestion: "Check connection",
635 };
636
637 let json = serde_json::to_string(&body).unwrap();
638 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
639
640 assert_eq!(parsed["code"], "network_error");
641 assert_eq!(parsed["category"], "network");
642 assert_eq!(parsed["message"], "Connection failed");
643 assert_eq!(parsed["suggestion"], "Check connection");
644 }
645}