Skip to main content

netspeed_cli/
bin_errors.rs

1//! Binary-level error handling for netspeed-cli.
2//!
3//! This module contains the error presentation logic that is specific to
4//! the binary entry point. Library users should use the [`Error`] type
5//! directly without this presentation layer.
6//!
7//! ## Architecture
8//!
9//! - Exit codes following sysexits.h conventions
10//! - Machine-readable error output for JSON/JSONL formats
11//! - User-friendly error messages with suggestions
12
13use crate::cli::OutputFormatType;
14use crate::error::Error;
15use crate::terminal::no_color;
16use owo_colors::OwoColorize;
17use serde::Serialize;
18
19/// Exit codes following sysexits.h conventions.
20pub 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
46/// Determine machine-readable error format from CLI args.
47pub 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
56/// Check if the error is the "--list was shown" sentinel.
57pub fn is_list_sentinel(e: &Error) -> bool {
58    matches!(e, Error::Context { msg, .. } if msg == "__list_displayed__")
59}
60
61/// Check if the error is network-related.
62pub 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
75/// Check if the error is a configuration/validation error.
76pub fn is_config_error(e: &Error) -> bool {
77    matches!(e, Error::ServerNotFound(_) | Error::Context { .. })
78}
79
80/// Print a user-friendly error message.
81pub 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
97/// Output machine-readable error.
98pub 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
103/// Render error to machine-readable string.
104pub 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
138/// Map error to machine-readable code and category.
139pub 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
155/// Print contextual suggestions based on error type.
156fn 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
166/// Get suggestion text for error type.
167pub 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
187/// Select exit code based on error type.
188pub 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        // IoError is not network, config, or list sentinel → INTERNAL_ERROR
599        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}