Skip to main content

rch_common/ui/errors/
network.rs

1//! NetworkErrorDisplay - Specialized display for network and SSH errors.
2//!
3//! This module provides rich error display for network connectivity issues,
4//! including SSH failures, DNS errors, timeouts, and authentication problems.
5//!
6//! # Features
7//!
8//! - Parses `std::io::Error` and `ssh2` errors for detail extraction
9//! - Displays network path visualization (local → daemon → worker)
10//! - Shows relevant environment variables (SSH_AUTH_SOCK, etc.)
11//! - Suggests diagnostic commands (ping, nc, ssh -vvv)
12//! - Supports JSON serialization for structured output
13//!
14//! # Example
15//!
16//! ```ignore
17//! use rch_common::ui::errors::NetworkErrorDisplay;
18//! use rch_common::ui::OutputContext;
19//!
20//! let display = NetworkErrorDisplay::ssh_connection_failed("build1.internal")
21//!     .port(22)
22//!     .timeout_secs(30)
23//!     .with_io_error(&io_err)
24//!     .network_path("local", "daemon", "worker");
25//!
26//! display.render(OutputContext::detect());
27//! ```
28
29use serde::{Deserialize, Serialize};
30use std::io;
31use std::net::IpAddr;
32use std::time::Duration;
33
34use crate::errors::catalog::ErrorCode;
35#[cfg(all(feature = "rich-ui", unix))]
36use crate::ui::RchTheme;
37use crate::ui::{ErrorPanel, Icons, OutputContext};
38
39#[cfg(all(feature = "rich-ui", unix))]
40use rich_rust::r#box::HEAVY;
41#[cfg(all(feature = "rich-ui", unix))]
42use rich_rust::prelude::*;
43
44/// Network connection details for error display.
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct ConnectionDetails {
47    /// Hostname or address attempted
48    pub host: String,
49    /// Port number (default 22 for SSH)
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub port: Option<u16>,
52    /// Resolved IP address if available
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub resolved_ip: Option<IpAddr>,
55    /// SSH username if applicable
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub username: Option<String>,
58    /// SSH key path if applicable
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub key_path: Option<String>,
61}
62
63/// Network path segment for visualizing connection flow.
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct NetworkPathSegment {
66    /// Segment name (e.g., "local", "daemon", "worker")
67    pub name: String,
68    /// Segment status
69    pub status: PathSegmentStatus,
70    /// Optional details about this segment
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub details: Option<String>,
73}
74
75/// Status of a network path segment.
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
77#[serde(rename_all = "lowercase")]
78pub enum PathSegmentStatus {
79    /// Segment is working
80    Ok,
81    /// Segment failed
82    Failed,
83    /// Segment status unknown
84    Unknown,
85}
86
87/// Environment variable relevant to network errors.
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct EnvVarInfo {
90    /// Variable name
91    pub name: String,
92    /// Variable value (redacted if sensitive)
93    pub value: Option<String>,
94    /// Whether value is set
95    pub is_set: bool,
96}
97
98/// Diagnostic command suggestion.
99#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct DiagnosticCommand {
101    /// Brief description of what the command checks
102    pub description: String,
103    /// The command to run
104    pub command: String,
105}
106
107/// NetworkErrorDisplay - Rich error display for network/SSH errors.
108///
109/// Builds on [`ErrorPanel`] with network-specific context:
110/// - Connection details (host, port, IP, credentials)
111/// - Network path visualization
112/// - Environment variable display
113/// - Diagnostic command suggestions
114#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct NetworkErrorDisplay {
116    /// The underlying error code
117    pub error_code: ErrorCode,
118
119    /// Connection details
120    pub connection: ConnectionDetails,
121
122    /// Network path segments for visualization
123    #[serde(default, skip_serializing_if = "Vec::is_empty")]
124    pub network_path: Vec<NetworkPathSegment>,
125
126    /// Relevant environment variables
127    #[serde(default, skip_serializing_if = "Vec::is_empty")]
128    pub env_vars: Vec<EnvVarInfo>,
129
130    /// Diagnostic command suggestions
131    #[serde(default, skip_serializing_if = "Vec::is_empty")]
132    pub diagnostics: Vec<DiagnosticCommand>,
133
134    /// Connection timeout if applicable
135    #[serde(skip_serializing_if = "Option::is_none")]
136    pub timeout: Option<Duration>,
137
138    /// Retry information
139    #[serde(skip_serializing_if = "Option::is_none")]
140    pub retry_count: Option<u32>,
141
142    /// Low-level error message from OS/library
143    #[serde(skip_serializing_if = "Option::is_none")]
144    pub raw_error: Option<String>,
145
146    /// Error chain (caused by)
147    #[serde(default, skip_serializing_if = "Vec::is_empty")]
148    pub caused_by: Vec<String>,
149
150    /// Custom message override
151    #[serde(skip_serializing_if = "Option::is_none")]
152    pub custom_message: Option<String>,
153}
154
155impl NetworkErrorDisplay {
156    // ========================================================================
157    // CONSTRUCTORS FOR SPECIFIC ERROR TYPES
158    // ========================================================================
159
160    /// Create display for SSH connection failure (E100).
161    #[must_use]
162    pub fn ssh_connection_failed(host: impl Into<String>) -> Self {
163        let host = host.into();
164        Self::new(ErrorCode::SshConnectionFailed, host.clone())
165            .add_diagnostic("Test basic connectivity", format!("ping -c 3 {host}"))
166            .add_diagnostic("Check SSH port", format!("nc -zv {host} 22"))
167            .add_diagnostic("Verbose SSH connection", format!("ssh -vvv {host}"))
168    }
169
170    /// Create display for SSH authentication failure (E101).
171    #[must_use]
172    pub fn ssh_auth_failed(host: impl Into<String>) -> Self {
173        let host = host.into();
174        Self::new(ErrorCode::SshAuthFailed, host.clone())
175            .auto_add_ssh_env_vars()
176            .add_diagnostic("List loaded SSH keys", "ssh-add -l".to_string())
177            .add_diagnostic(
178                "Check authorized_keys",
179                format!("ssh {host} 'cat ~/.ssh/authorized_keys'"),
180            )
181            .add_diagnostic("Verbose SSH auth", format!("ssh -vvv {host}"))
182    }
183
184    /// Create display for SSH key error (E102).
185    #[must_use]
186    pub fn ssh_key_error(host: impl Into<String>) -> Self {
187        let host = host.into();
188        Self::new(ErrorCode::SshKeyError, host)
189            .auto_add_ssh_env_vars()
190            .add_diagnostic("List SSH keys", "ls -la ~/.ssh/".to_string())
191            .add_diagnostic(
192                "Check key format",
193                "ssh-keygen -y -f ~/.ssh/id_ed25519".to_string(),
194            )
195            .add_diagnostic("List loaded keys", "ssh-add -l".to_string())
196    }
197
198    /// Create display for SSH host key verification failure (E103).
199    #[must_use]
200    pub fn ssh_host_key_error(host: impl Into<String>) -> Self {
201        let host = host.into();
202        Self::new(ErrorCode::SshHostKeyError, host.clone())
203            .add_diagnostic("View known_hosts entry", format!("ssh-keygen -F {host}"))
204            .add_diagnostic("Remove old host key", format!("ssh-keygen -R {host}"))
205            .add_diagnostic("Show host fingerprint", format!("ssh-keyscan {host}"))
206    }
207
208    /// Create display for SSH timeout (E104).
209    #[must_use]
210    pub fn ssh_timeout(host: impl Into<String>) -> Self {
211        let host = host.into();
212        Self::new(ErrorCode::SshTimeout, host.clone())
213            .add_diagnostic("Test connectivity", format!("ping -c 3 {host}"))
214            .add_diagnostic("Check route", format!("traceroute {host}"))
215            .add_diagnostic(
216                "Test SSH with timeout",
217                format!("timeout 10 ssh -v {host} exit"),
218            )
219    }
220
221    /// Create display for SSH session dropped (E105).
222    #[must_use]
223    pub fn ssh_session_dropped(host: impl Into<String>) -> Self {
224        let host = host.into();
225        Self::new(ErrorCode::SshSessionDropped, host.clone())
226            .add_diagnostic("Check host uptime", format!("ssh {host} uptime"))
227            .add_diagnostic(
228                "Test connection stability",
229                format!("ssh {host} 'sleep 5 && echo ok'"),
230            )
231    }
232
233    /// Create display for DNS resolution failure (E106).
234    #[must_use]
235    pub fn dns_error(host: impl Into<String>) -> Self {
236        let host = host.into();
237        Self::new(ErrorCode::NetworkDnsError, host.clone())
238            .add_diagnostic("DNS lookup", format!("nslookup {host}"))
239            .add_diagnostic("Alternative DNS lookup", format!("dig {host}"))
240            .add_diagnostic("Check /etc/hosts", format!("grep {host} /etc/hosts"))
241    }
242
243    /// Create display for network unreachable (E107).
244    #[must_use]
245    pub fn network_unreachable(host: impl Into<String>) -> Self {
246        let host = host.into();
247        Self::new(ErrorCode::NetworkUnreachable, host.clone())
248            .add_diagnostic("Check network interface", "ip addr show".to_string())
249            .add_diagnostic("Check routing table", "ip route show".to_string())
250            .add_diagnostic("Trace route", format!("traceroute {host}"))
251    }
252
253    /// Create display for connection refused (E108).
254    #[must_use]
255    pub fn connection_refused(host: impl Into<String>) -> Self {
256        let host = host.into();
257        Self::new(ErrorCode::NetworkConnectionRefused, host.clone())
258            .add_diagnostic(
259                "Check if SSH is running",
260                format!("ssh {host} 'systemctl status sshd'"),
261            )
262            .add_diagnostic("Check port", format!("nc -zv {host} 22"))
263            .add_diagnostic(
264                "Check firewall (remote)",
265                format!("ssh {host} 'iptables -L -n'"),
266            )
267    }
268
269    /// Create display for TCP timeout (E109).
270    #[must_use]
271    pub fn tcp_timeout(host: impl Into<String>) -> Self {
272        let host = host.into();
273        Self::new(ErrorCode::NetworkTimeout, host.clone())
274            .add_diagnostic("Check latency", format!("ping -c 5 {host}"))
275            .add_diagnostic("Check route", format!("traceroute {host}"))
276            .add_diagnostic(
277                "Test port with timeout",
278                format!("timeout 5 nc -zv {host} 22"),
279            )
280    }
281
282    // ========================================================================
283    // CORE CONSTRUCTOR
284    // ========================================================================
285
286    /// Create a new NetworkErrorDisplay with error code and host.
287    #[must_use]
288    fn new(error_code: ErrorCode, host: impl Into<String>) -> Self {
289        Self {
290            error_code,
291            connection: ConnectionDetails {
292                host: host.into(),
293                port: None,
294                resolved_ip: None,
295                username: None,
296                key_path: None,
297            },
298            network_path: Vec::new(),
299            env_vars: Vec::new(),
300            diagnostics: Vec::new(),
301            timeout: None,
302            retry_count: None,
303            raw_error: None,
304            caused_by: Vec::new(),
305            custom_message: None,
306        }
307    }
308
309    // ========================================================================
310    // BUILDER METHODS - CONNECTION DETAILS
311    // ========================================================================
312
313    /// Set the port number.
314    #[must_use]
315    pub fn port(mut self, port: u16) -> Self {
316        self.connection.port = Some(port);
317        self
318    }
319
320    /// Set the resolved IP address.
321    #[must_use]
322    pub fn resolved_ip(mut self, ip: IpAddr) -> Self {
323        self.connection.resolved_ip = Some(ip);
324        self
325    }
326
327    /// Set the SSH username.
328    #[must_use]
329    pub fn username(mut self, username: impl Into<String>) -> Self {
330        self.connection.username = Some(username.into());
331        self
332    }
333
334    /// Set the SSH key path.
335    #[must_use]
336    pub fn key_path(mut self, path: impl Into<String>) -> Self {
337        self.connection.key_path = Some(path.into());
338        self
339    }
340
341    // ========================================================================
342    // BUILDER METHODS - TIMEOUT AND RETRY
343    // ========================================================================
344
345    /// Set the timeout duration.
346    #[must_use]
347    pub fn timeout(mut self, timeout: Duration) -> Self {
348        self.timeout = Some(timeout);
349        self
350    }
351
352    /// Set the timeout in seconds (convenience method).
353    #[must_use]
354    pub fn timeout_secs(self, secs: u64) -> Self {
355        self.timeout(Duration::from_secs(secs))
356    }
357
358    /// Set the retry count.
359    #[must_use]
360    pub fn retries(mut self, count: u32) -> Self {
361        self.retry_count = Some(count);
362        self
363    }
364
365    // ========================================================================
366    // BUILDER METHODS - NETWORK PATH
367    // ========================================================================
368
369    /// Add a network path segment.
370    #[must_use]
371    pub fn path_segment(
372        mut self,
373        name: impl Into<String>,
374        status: PathSegmentStatus,
375        details: Option<String>,
376    ) -> Self {
377        self.network_path.push(NetworkPathSegment {
378            name: name.into(),
379            status,
380            details,
381        });
382        self
383    }
384
385    /// Set the full network path with simple status.
386    ///
387    /// Creates a path like: local → daemon → worker
388    /// The failed segment is marked based on which name matches `failed_at`.
389    #[must_use]
390    pub fn network_path_simple(
391        self,
392        local: &str,
393        daemon: &str,
394        worker: &str,
395        failed_at: Option<&str>,
396    ) -> Self {
397        let status_for = |name: &str| {
398            if Some(name) == failed_at {
399                PathSegmentStatus::Failed
400            } else if failed_at.is_some() {
401                // If something failed, segments before it are OK, after are unknown
402                PathSegmentStatus::Unknown
403            } else {
404                PathSegmentStatus::Ok
405            }
406        };
407
408        self.path_segment(local, status_for(local), None)
409            .path_segment(daemon, status_for(daemon), None)
410            .path_segment(worker, status_for(worker), None)
411    }
412
413    // ========================================================================
414    // BUILDER METHODS - ENVIRONMENT VARIABLES
415    // ========================================================================
416
417    /// Add an environment variable with its current value.
418    #[must_use]
419    pub fn env_var(mut self, name: impl Into<String>, value: Option<String>) -> Self {
420        let name = name.into();
421        self.env_vars.push(EnvVarInfo {
422            name,
423            is_set: value.is_some(),
424            value,
425        });
426        self
427    }
428
429    /// Add an environment variable, reading from the current environment.
430    #[must_use]
431    pub fn env_var_from_env(self, name: &str) -> Self {
432        let value = std::env::var(name).ok();
433        self.env_var(name, value)
434    }
435
436    /// Automatically add common SSH-related environment variables.
437    #[must_use]
438    pub fn auto_add_ssh_env_vars(self) -> Self {
439        self.env_var_from_env("SSH_AUTH_SOCK")
440            .env_var_from_env("SSH_AGENT_PID")
441            .env_var_from_env("HOME")
442    }
443
444    // ========================================================================
445    // BUILDER METHODS - DIAGNOSTICS
446    // ========================================================================
447
448    /// Add a diagnostic command suggestion.
449    #[must_use]
450    pub fn add_diagnostic(
451        mut self,
452        description: impl Into<String>,
453        command: impl Into<String>,
454    ) -> Self {
455        self.diagnostics.push(DiagnosticCommand {
456            description: description.into(),
457            command: command.into(),
458        });
459        self
460    }
461
462    // ========================================================================
463    // BUILDER METHODS - ERROR PARSING
464    // ========================================================================
465
466    /// Extract details from a std::io::Error.
467    #[must_use]
468    pub fn with_io_error(mut self, err: &io::Error) -> Self {
469        self.raw_error = Some(err.to_string());
470
471        // Extract OS error code if available
472        if let Some(os_err) = err.raw_os_error() {
473            self.caused_by.push(format!("OS error {}: {}", os_err, err));
474        }
475
476        // Detect specific error kinds
477        match err.kind() {
478            io::ErrorKind::ConnectionRefused => {
479                self.caused_by
480                    .push("Connection actively refused by remote host".to_string());
481            }
482            io::ErrorKind::ConnectionReset => {
483                self.caused_by
484                    .push("Connection was reset by remote host".to_string());
485            }
486            io::ErrorKind::ConnectionAborted => {
487                self.caused_by.push("Connection was aborted".to_string());
488            }
489            io::ErrorKind::NotConnected => {
490                self.caused_by.push("Socket is not connected".to_string());
491            }
492            io::ErrorKind::TimedOut => {
493                self.caused_by.push("Operation timed out".to_string());
494            }
495            io::ErrorKind::HostUnreachable => {
496                self.caused_by.push("Host is unreachable".to_string());
497            }
498            io::ErrorKind::NetworkUnreachable => {
499                self.caused_by.push("Network is unreachable".to_string());
500            }
501            _ => {}
502        }
503
504        self
505    }
506
507    /// Set a custom error message.
508    #[must_use]
509    pub fn message(mut self, message: impl Into<String>) -> Self {
510        self.custom_message = Some(message.into());
511        self
512    }
513
514    /// Add a caused-by entry.
515    #[must_use]
516    pub fn caused_by(mut self, cause: impl Into<String>) -> Self {
517        self.caused_by.push(cause.into());
518        self
519    }
520
521    // ========================================================================
522    // CONVERSION TO ErrorPanel
523    // ========================================================================
524
525    /// Convert to an ErrorPanel for rendering.
526    #[must_use]
527    pub fn to_error_panel(&self) -> ErrorPanel {
528        let entry = self.error_code.entry();
529
530        let mut panel = ErrorPanel::error(&entry.code, &entry.message);
531
532        // Set custom message if provided
533        if let Some(ref msg) = self.custom_message {
534            panel = panel.message(msg.clone());
535        } else if let Some(ref raw) = self.raw_error {
536            panel = panel.message(raw.clone());
537        }
538
539        // Add connection details as context
540        panel = panel.context("Host", &self.connection.host);
541        if let Some(port) = self.connection.port {
542            panel = panel.context("Port", port.to_string());
543        }
544        if let Some(ref ip) = self.connection.resolved_ip {
545            panel = panel.context("Resolved IP", ip.to_string());
546        }
547        if let Some(ref user) = self.connection.username {
548            panel = panel.context("Username", user.clone());
549        }
550        if let Some(ref key) = self.connection.key_path {
551            panel = panel.context("SSH Key", key.clone());
552        }
553
554        // Add timeout and retry info
555        if let Some(ref timeout) = self.timeout {
556            panel = panel.context("Timeout", format!("{:.1}s", timeout.as_secs_f64()));
557        }
558        if let Some(retries) = self.retry_count {
559            panel = panel.context("Retries", retries.to_string());
560        }
561
562        // Add caused-by chain
563        for cause in &self.caused_by {
564            panel = panel.caused_by(cause.clone(), None);
565        }
566
567        // Add remediation from catalog
568        for step in entry.remediation {
569            panel = panel.suggestion(step);
570        }
571
572        panel
573    }
574
575    // ========================================================================
576    // RENDERING
577    // ========================================================================
578
579    /// Render the error to stderr.
580    ///
581    /// Uses rich output if supported, otherwise plain text.
582    pub fn render(&self, ctx: OutputContext) {
583        if ctx.is_machine() {
584            // Machine mode - caller should use to_json()
585            return;
586        }
587
588        #[cfg(all(feature = "rich-ui", unix))]
589        if ctx.supports_rich() {
590            self.render_rich(ctx);
591            return;
592        }
593
594        self.render_plain(ctx);
595    }
596
597    /// Render using rich_rust Panel with network-specific formatting.
598    #[cfg(all(feature = "rich-ui", unix))]
599    fn render_rich(&self, ctx: OutputContext) {
600        let content = self.build_rich_content(ctx);
601        let entry = self.error_code.entry();
602        let icon = Icons::cross(ctx);
603        let title_text = format!("{icon} {}: {}", entry.code, entry.message);
604
605        let border_color = Color::parse(RchTheme::ERROR).unwrap_or_else(|_| Color::default());
606        let border_style = Style::new().bold().color(border_color);
607
608        let panel = Panel::from_text(&content)
609            .title(title_text.as_str())
610            .border_style(border_style)
611            .box_style(&HEAVY);
612
613        let console = Console::builder().force_terminal(true).build();
614        console.print_renderable(&panel);
615    }
616
617    /// Build rich content string.
618    #[cfg(all(feature = "rich-ui", unix))]
619    fn build_rich_content(&self, ctx: OutputContext) -> String {
620        let mut lines = Vec::new();
621
622        // Custom or raw error message
623        if let Some(ref msg) = self.custom_message {
624            lines.push(msg.clone());
625        } else if let Some(ref raw) = self.raw_error {
626            lines.push(raw.clone());
627        }
628
629        // Connection details
630        lines.push(String::new());
631        lines.push(format!("[{}]Connection:[/]", RchTheme::DIM));
632        lines.push(format!("  Host: {}", self.connection.host));
633        if let Some(port) = self.connection.port {
634            lines.push(format!("  Port: {port}"));
635        }
636        if let Some(ref ip) = self.connection.resolved_ip {
637            lines.push(format!("  Resolved IP: {ip}"));
638        }
639        if let Some(ref user) = self.connection.username {
640            lines.push(format!("  Username: {user}"));
641        }
642        if let Some(ref key) = self.connection.key_path {
643            lines.push(format!("  SSH Key: {key}"));
644        }
645        if let Some(ref timeout) = self.timeout {
646            lines.push(format!("  Timeout: {:.1}s", timeout.as_secs_f64()));
647        }
648        if let Some(retries) = self.retry_count {
649            lines.push(format!("  Retries: {retries}"));
650        }
651
652        // Network path visualization
653        if !self.network_path.is_empty() {
654            lines.push(String::new());
655            lines.push(format!("[{}]Network Path:[/]", RchTheme::DIM));
656            lines.push(format!("  {}", self.format_network_path(ctx)));
657        }
658
659        // Environment variables
660        if !self.env_vars.is_empty() {
661            lines.push(String::new());
662            lines.push(format!("[{}]Environment:[/]", RchTheme::DIM));
663            for var in &self.env_vars {
664                let value = if var.is_set {
665                    var.value.as_deref().unwrap_or("(set)")
666                } else {
667                    "(not set)"
668                };
669                lines.push(format!("  {}: {}", var.name, value));
670            }
671        }
672
673        // Error chain
674        if !self.caused_by.is_empty() {
675            lines.push(String::new());
676            lines.push(format!("[{}]Caused by:[/]", RchTheme::DIM));
677            for cause in &self.caused_by {
678                lines.push(format!("  {cause}"));
679            }
680        }
681
682        // Diagnostic commands
683        if !self.diagnostics.is_empty() {
684            lines.push(String::new());
685            lines.push(format!("[{}]Diagnostic Commands:[/]", RchTheme::SECONDARY));
686            for (i, diag) in self.diagnostics.iter().enumerate() {
687                lines.push(format!(
688                    "  [{}]{}.[/] {} [{}]{}[/]",
689                    RchTheme::SECONDARY,
690                    i + 1,
691                    diag.description,
692                    RchTheme::DIM,
693                    diag.command
694                ));
695            }
696        }
697
698        // Remediation from catalog
699        let entry = self.error_code.entry();
700        if !entry.remediation.is_empty() {
701            lines.push(String::new());
702            lines.push(format!("[{}]Suggestions:[/]", RchTheme::SECONDARY));
703            for (i, step) in entry.remediation.iter().enumerate() {
704                lines.push(format!("  [{}]{}.[/] {step}", RchTheme::SECONDARY, i + 1));
705            }
706        }
707
708        lines.join("\n")
709    }
710
711    /// Render plain text to stderr.
712    fn render_plain(&self, ctx: OutputContext) {
713        let entry = self.error_code.entry();
714        let icon = Icons::cross(ctx);
715
716        // Header line
717        eprintln!("{icon} [ERROR] {}: {}", entry.code, entry.message);
718
719        // Custom or raw error message
720        if let Some(ref msg) = self.custom_message {
721            eprintln!();
722            eprintln!("{msg}");
723        } else if let Some(ref raw) = self.raw_error {
724            eprintln!();
725            eprintln!("{raw}");
726        }
727
728        // Connection details
729        eprintln!();
730        eprintln!("Connection:");
731        eprintln!("  Host: {}", self.connection.host);
732        if let Some(port) = self.connection.port {
733            eprintln!("  Port: {port}");
734        }
735        if let Some(ref ip) = self.connection.resolved_ip {
736            eprintln!("  Resolved IP: {ip}");
737        }
738        if let Some(ref user) = self.connection.username {
739            eprintln!("  Username: {user}");
740        }
741        if let Some(ref key) = self.connection.key_path {
742            eprintln!("  SSH Key: {key}");
743        }
744        if let Some(ref timeout) = self.timeout {
745            eprintln!("  Timeout: {:.1}s", timeout.as_secs_f64());
746        }
747        if let Some(retries) = self.retry_count {
748            eprintln!("  Retries: {retries}");
749        }
750
751        // Network path visualization
752        if !self.network_path.is_empty() {
753            eprintln!();
754            eprintln!("Network Path:");
755            eprintln!("  {}", self.format_network_path(ctx));
756        }
757
758        // Environment variables
759        if !self.env_vars.is_empty() {
760            eprintln!();
761            eprintln!("Environment:");
762            for var in &self.env_vars {
763                let value = if var.is_set {
764                    var.value.as_deref().unwrap_or("(set)")
765                } else {
766                    "(not set)"
767                };
768                eprintln!("  {}: {}", var.name, value);
769            }
770        }
771
772        // Error chain
773        if !self.caused_by.is_empty() {
774            eprintln!();
775            eprintln!("Caused by:");
776            for cause in &self.caused_by {
777                eprintln!("  {cause}");
778            }
779        }
780
781        // Diagnostic commands
782        if !self.diagnostics.is_empty() {
783            eprintln!();
784            eprintln!("Diagnostic Commands:");
785            for (i, diag) in self.diagnostics.iter().enumerate() {
786                eprintln!("  {}. {}: {}", i + 1, diag.description, diag.command);
787            }
788        }
789
790        // Remediation from catalog
791        if !entry.remediation.is_empty() {
792            eprintln!();
793            eprintln!("Suggestions:");
794            for (i, step) in entry.remediation.iter().enumerate() {
795                eprintln!("  {}. {step}", i + 1);
796            }
797        }
798    }
799
800    /// Format the network path for display.
801    fn format_network_path(&self, ctx: OutputContext) -> String {
802        let arrow = if ctx.supports_unicode() {
803            " → "
804        } else {
805            " -> "
806        };
807        let check = Icons::check(ctx);
808        let cross = Icons::cross(ctx);
809        let question = "?";
810
811        self.network_path
812            .iter()
813            .map(|seg| {
814                let icon = match seg.status {
815                    PathSegmentStatus::Ok => check,
816                    PathSegmentStatus::Failed => cross,
817                    PathSegmentStatus::Unknown => question,
818                };
819                format!("{icon}{}", seg.name)
820            })
821            .collect::<Vec<_>>()
822            .join(arrow)
823    }
824
825    // ========================================================================
826    // JSON SERIALIZATION
827    // ========================================================================
828
829    /// Serialize to JSON string.
830    pub fn to_json(&self) -> serde_json::Result<String> {
831        serde_json::to_string_pretty(self)
832    }
833
834    /// Serialize to compact JSON string.
835    pub fn to_json_compact(&self) -> serde_json::Result<String> {
836        serde_json::to_string(self)
837    }
838}
839
840impl std::fmt::Display for NetworkErrorDisplay {
841    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
842        let entry = self.error_code.entry();
843        write!(f, "[ERROR] {}: {}", entry.code, entry.message)?;
844        if let Some(ref msg) = self.custom_message {
845            write!(f, " - {msg}")?;
846        }
847        Ok(())
848    }
849}
850
851impl std::error::Error for NetworkErrorDisplay {}
852
853#[cfg(test)]
854mod tests {
855    use super::*;
856
857    #[test]
858    fn test_ssh_connection_failed_creation() {
859        let display = NetworkErrorDisplay::ssh_connection_failed("build1.internal");
860        assert_eq!(display.error_code, ErrorCode::SshConnectionFailed);
861        assert_eq!(display.connection.host, "build1.internal");
862        assert!(!display.diagnostics.is_empty());
863    }
864
865    #[test]
866    fn test_ssh_auth_failed_creation() {
867        let display = NetworkErrorDisplay::ssh_auth_failed("build1.internal");
868        assert_eq!(display.error_code, ErrorCode::SshAuthFailed);
869        assert!(!display.env_vars.is_empty()); // auto-added SSH env vars
870    }
871
872    #[test]
873    fn test_builder_chain() {
874        let display = NetworkErrorDisplay::ssh_connection_failed("example.com")
875            .port(2222)
876            .username("deploy")
877            .timeout_secs(30)
878            .retries(3);
879
880        assert_eq!(display.connection.port, Some(2222));
881        assert_eq!(display.connection.username, Some("deploy".to_string()));
882        assert_eq!(display.timeout, Some(Duration::from_secs(30)));
883        assert_eq!(display.retry_count, Some(3));
884    }
885
886    #[test]
887    fn test_resolved_ip() {
888        use std::net::Ipv4Addr;
889        let ip = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 100));
890        let display = NetworkErrorDisplay::ssh_connection_failed("build1").resolved_ip(ip);
891
892        assert_eq!(display.connection.resolved_ip, Some(ip));
893    }
894
895    #[test]
896    fn test_network_path() {
897        let display = NetworkErrorDisplay::ssh_connection_failed("build1").network_path_simple(
898            "local",
899            "daemon",
900            "worker",
901            Some("worker"),
902        );
903
904        assert_eq!(display.network_path.len(), 3);
905        assert_eq!(display.network_path[2].status, PathSegmentStatus::Failed);
906    }
907
908    #[test]
909    fn test_with_io_error() {
910        let io_err = io::Error::new(io::ErrorKind::ConnectionRefused, "Connection refused");
911        let display = NetworkErrorDisplay::ssh_connection_failed("build1").with_io_error(&io_err);
912
913        assert!(display.raw_error.is_some());
914        assert!(!display.caused_by.is_empty());
915    }
916
917    #[test]
918    fn test_env_var() {
919        let display = NetworkErrorDisplay::ssh_auth_failed("build1")
920            .env_var("TEST_VAR", Some("test_value".to_string()));
921
922        let test_var = display.env_vars.iter().find(|v| v.name == "TEST_VAR");
923        assert!(test_var.is_some());
924        assert_eq!(test_var.unwrap().value, Some("test_value".to_string()));
925    }
926
927    #[test]
928    fn test_diagnostic_commands() {
929        let display = NetworkErrorDisplay::ssh_connection_failed("build1")
930            .add_diagnostic("Custom check", "custom-cmd");
931
932        assert!(
933            display
934                .diagnostics
935                .iter()
936                .any(|d| d.command == "custom-cmd")
937        );
938    }
939
940    #[test]
941    fn test_to_error_panel() {
942        let display = NetworkErrorDisplay::ssh_connection_failed("build1")
943            .port(22)
944            .message("Custom message");
945
946        let panel = display.to_error_panel();
947        assert_eq!(panel.code, "RCH-E100");
948        assert!(panel.message.is_some());
949    }
950
951    #[test]
952    fn test_json_serialization() {
953        let display = NetworkErrorDisplay::ssh_connection_failed("build1")
954            .port(22)
955            .username("deploy");
956
957        let json = display.to_json().expect("JSON serialization failed");
958        assert!(json.contains("build1"));
959        assert!(json.contains("22"));
960        assert!(json.contains("deploy"));
961    }
962
963    #[test]
964    fn test_json_compact_serialization() {
965        let display = NetworkErrorDisplay::ssh_connection_failed("build1");
966        let json = display
967            .to_json_compact()
968            .expect("JSON serialization failed");
969        assert!(!json.contains('\n'));
970    }
971
972    #[test]
973    fn test_display_implementation() {
974        let display = NetworkErrorDisplay::ssh_connection_failed("build1").message("Test message");
975
976        let output = format!("{display}");
977        assert!(output.contains("RCH-E100"));
978        assert!(output.contains("Test message"));
979    }
980
981    #[test]
982    fn test_render_plain_no_panic() {
983        let display = NetworkErrorDisplay::ssh_connection_failed("build1")
984            .port(22)
985            .username("deploy")
986            .network_path_simple("local", "daemon", "worker", Some("worker"))
987            .env_var("SSH_AUTH_SOCK", Some("/tmp/ssh-agent.sock".to_string()))
988            .message("Connection failed");
989
990        // Should not panic
991        display.render(OutputContext::Plain);
992    }
993
994    #[test]
995    fn test_render_machine_silent() {
996        let display = NetworkErrorDisplay::ssh_connection_failed("build1");
997        // Should not output anything in machine mode
998        display.render(OutputContext::Machine);
999    }
1000
1001    #[test]
1002    fn test_all_error_constructors() {
1003        // Verify all constructors work and set correct error codes
1004        assert_eq!(
1005            NetworkErrorDisplay::ssh_connection_failed("h").error_code,
1006            ErrorCode::SshConnectionFailed
1007        );
1008        assert_eq!(
1009            NetworkErrorDisplay::ssh_auth_failed("h").error_code,
1010            ErrorCode::SshAuthFailed
1011        );
1012        assert_eq!(
1013            NetworkErrorDisplay::ssh_key_error("h").error_code,
1014            ErrorCode::SshKeyError
1015        );
1016        assert_eq!(
1017            NetworkErrorDisplay::ssh_host_key_error("h").error_code,
1018            ErrorCode::SshHostKeyError
1019        );
1020        assert_eq!(
1021            NetworkErrorDisplay::ssh_timeout("h").error_code,
1022            ErrorCode::SshTimeout
1023        );
1024        assert_eq!(
1025            NetworkErrorDisplay::ssh_session_dropped("h").error_code,
1026            ErrorCode::SshSessionDropped
1027        );
1028        assert_eq!(
1029            NetworkErrorDisplay::dns_error("h").error_code,
1030            ErrorCode::NetworkDnsError
1031        );
1032        assert_eq!(
1033            NetworkErrorDisplay::network_unreachable("h").error_code,
1034            ErrorCode::NetworkUnreachable
1035        );
1036        assert_eq!(
1037            NetworkErrorDisplay::connection_refused("h").error_code,
1038            ErrorCode::NetworkConnectionRefused
1039        );
1040        assert_eq!(
1041            NetworkErrorDisplay::tcp_timeout("h").error_code,
1042            ErrorCode::NetworkTimeout
1043        );
1044    }
1045
1046    #[test]
1047    fn test_format_network_path() {
1048        let display = NetworkErrorDisplay::ssh_connection_failed("build1")
1049            .path_segment("local", PathSegmentStatus::Ok, None)
1050            .path_segment("daemon", PathSegmentStatus::Ok, None)
1051            .path_segment("worker", PathSegmentStatus::Failed, None);
1052
1053        let path = display.format_network_path(OutputContext::Plain);
1054        assert!(path.contains("local"));
1055        assert!(path.contains("daemon"));
1056        assert!(path.contains("worker"));
1057    }
1058
1059    #[test]
1060    fn test_path_segment_status() {
1061        let display = NetworkErrorDisplay::ssh_connection_failed("build1").path_segment(
1062            "seg1",
1063            PathSegmentStatus::Ok,
1064            Some("details".to_string()),
1065        );
1066
1067        assert_eq!(display.network_path.len(), 1);
1068        assert_eq!(display.network_path[0].status, PathSegmentStatus::Ok);
1069        assert_eq!(display.network_path[0].details, Some("details".to_string()));
1070    }
1071
1072    #[test]
1073    fn test_key_path() {
1074        let display = NetworkErrorDisplay::ssh_connection_failed("build1")
1075            .key_path("/home/user/.ssh/id_ed25519");
1076
1077        assert_eq!(
1078            display.connection.key_path,
1079            Some("/home/user/.ssh/id_ed25519".to_string())
1080        );
1081    }
1082
1083    #[test]
1084    fn test_caused_by_chain() {
1085        let display = NetworkErrorDisplay::ssh_connection_failed("build1")
1086            .caused_by("First cause")
1087            .caused_by("Second cause");
1088
1089        assert_eq!(display.caused_by.len(), 2);
1090        assert_eq!(display.caused_by[0], "First cause");
1091        assert_eq!(display.caused_by[1], "Second cause");
1092    }
1093
1094    #[test]
1095    fn test_error_trait() {
1096        let display: Box<dyn std::error::Error> =
1097            Box::new(NetworkErrorDisplay::ssh_connection_failed("build1"));
1098        let _ = format!("{display}");
1099    }
1100}