1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct ConnectionDetails {
47 pub host: String,
49 #[serde(skip_serializing_if = "Option::is_none")]
51 pub port: Option<u16>,
52 #[serde(skip_serializing_if = "Option::is_none")]
54 pub resolved_ip: Option<IpAddr>,
55 #[serde(skip_serializing_if = "Option::is_none")]
57 pub username: Option<String>,
58 #[serde(skip_serializing_if = "Option::is_none")]
60 pub key_path: Option<String>,
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct NetworkPathSegment {
66 pub name: String,
68 pub status: PathSegmentStatus,
70 #[serde(skip_serializing_if = "Option::is_none")]
72 pub details: Option<String>,
73}
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
77#[serde(rename_all = "lowercase")]
78pub enum PathSegmentStatus {
79 Ok,
81 Failed,
83 Unknown,
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct EnvVarInfo {
90 pub name: String,
92 pub value: Option<String>,
94 pub is_set: bool,
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct DiagnosticCommand {
101 pub description: String,
103 pub command: String,
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct NetworkErrorDisplay {
116 pub error_code: ErrorCode,
118
119 pub connection: ConnectionDetails,
121
122 #[serde(default, skip_serializing_if = "Vec::is_empty")]
124 pub network_path: Vec<NetworkPathSegment>,
125
126 #[serde(default, skip_serializing_if = "Vec::is_empty")]
128 pub env_vars: Vec<EnvVarInfo>,
129
130 #[serde(default, skip_serializing_if = "Vec::is_empty")]
132 pub diagnostics: Vec<DiagnosticCommand>,
133
134 #[serde(skip_serializing_if = "Option::is_none")]
136 pub timeout: Option<Duration>,
137
138 #[serde(skip_serializing_if = "Option::is_none")]
140 pub retry_count: Option<u32>,
141
142 #[serde(skip_serializing_if = "Option::is_none")]
144 pub raw_error: Option<String>,
145
146 #[serde(default, skip_serializing_if = "Vec::is_empty")]
148 pub caused_by: Vec<String>,
149
150 #[serde(skip_serializing_if = "Option::is_none")]
152 pub custom_message: Option<String>,
153}
154
155impl NetworkErrorDisplay {
156 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[must_use]
315 pub fn port(mut self, port: u16) -> Self {
316 self.connection.port = Some(port);
317 self
318 }
319
320 #[must_use]
322 pub fn resolved_ip(mut self, ip: IpAddr) -> Self {
323 self.connection.resolved_ip = Some(ip);
324 self
325 }
326
327 #[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 #[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 #[must_use]
347 pub fn timeout(mut self, timeout: Duration) -> Self {
348 self.timeout = Some(timeout);
349 self
350 }
351
352 #[must_use]
354 pub fn timeout_secs(self, secs: u64) -> Self {
355 self.timeout(Duration::from_secs(secs))
356 }
357
358 #[must_use]
360 pub fn retries(mut self, count: u32) -> Self {
361 self.retry_count = Some(count);
362 self
363 }
364
365 #[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 #[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 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 #[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 #[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 #[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 #[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 #[must_use]
468 pub fn with_io_error(mut self, err: &io::Error) -> Self {
469 self.raw_error = Some(err.to_string());
470
471 if let Some(os_err) = err.raw_os_error() {
473 self.caused_by.push(format!("OS error {}: {}", os_err, err));
474 }
475
476 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 #[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 #[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 #[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 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 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 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 for cause in &self.caused_by {
564 panel = panel.caused_by(cause.clone(), None);
565 }
566
567 for step in entry.remediation {
569 panel = panel.suggestion(step);
570 }
571
572 panel
573 }
574
575 pub fn render(&self, ctx: OutputContext) {
583 if ctx.is_machine() {
584 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 #[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 #[cfg(all(feature = "rich-ui", unix))]
619 fn build_rich_content(&self, ctx: OutputContext) -> String {
620 let mut lines = Vec::new();
621
622 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 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 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 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 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 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 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 fn render_plain(&self, ctx: OutputContext) {
713 let entry = self.error_code.entry();
714 let icon = Icons::cross(ctx);
715
716 eprintln!("{icon} [ERROR] {}: {}", entry.code, entry.message);
718
719 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 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 if !self.network_path.is_empty() {
753 eprintln!();
754 eprintln!("Network Path:");
755 eprintln!(" {}", self.format_network_path(ctx));
756 }
757
758 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 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 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 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 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 pub fn to_json(&self) -> serde_json::Result<String> {
831 serde_json::to_string_pretty(self)
832 }
833
834 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()); }
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 display.render(OutputContext::Plain);
992 }
993
994 #[test]
995 fn test_render_machine_silent() {
996 let display = NetworkErrorDisplay::ssh_connection_failed("build1");
997 display.render(OutputContext::Machine);
999 }
1000
1001 #[test]
1002 fn test_all_error_constructors() {
1003 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}