Skip to main content

nd_300/render/
tech_mode.rs

1use crate::config::Config;
2use crate::diagnostics::{DiagnosticResults, DiagnosticStatus, TechnicianResults};
3use crate::render::color::{colorize_status, dim};
4use crate::render::table::ReportBuilder;
5
6pub fn render(results: &DiagnosticResults, config: &Config) -> String {
7    let label_width = 16;
8    let data_width = 40;
9    let chars = config.box_chars();
10
11    let mut output = String::new();
12
13    // == SUMMARY SECTION (same as user mode) ==
14    let mut builder = ReportBuilder::new(label_width, data_width, chars)
15        .header(config.title(), config.subtitle());
16
17    builder = builder.span_row("  DIAGNOSTIC SUMMARY").divider();
18
19    builder = render_summary_row(builder, &results.adapters, config);
20    builder = render_summary_row(builder, &results.interfaces, config);
21    builder = render_summary_row(builder, &results.gateway, config);
22    builder = render_summary_row(builder, &results.dns, config);
23    builder = render_summary_row(builder, &results.public_ip, config);
24    builder = render_summary_row(builder, &results.latency, config);
25    builder = render_summary_row(builder, &results.speed, config);
26    builder = render_summary_row(builder, &results.ports, config);
27
28    let (fail_count, warn_count) = count_issues(results);
29    let overall = format_overall(fail_count, warn_count, config);
30    builder = builder.divider();
31    builder = builder.span_row(&format!("  OVERALL: {}", overall));
32
33    if fail_count > 0 {
34        builder = builder.span_row(&dim("  Run 'nd300 -f' to attempt automatic fixes", config));
35    }
36
37    output.push_str(&builder.finish());
38    output.push('\n');
39
40    // == DETAILED TECHNICAL SECTIONS ==
41
42    // Interface details
43    if let Some(ref ifaces) = results.interface_details {
44        let mut b = ReportBuilder::new(label_width, data_width, chars);
45        b = b
46            .full_top_border()
47            .span_row("  NETWORK INTERFACES")
48            .divider();
49
50        for (i, iface) in ifaces.iter().enumerate() {
51            if i > 0 {
52                b = b.divider();
53            }
54            b = b.row("Name", &iface.name);
55            b = b.row("Type", &iface.interface_type);
56            b = b.row("MAC", &iface.mac);
57            b = b.row("Status", if iface.is_up { "Up" } else { "Down" });
58            for ip in &iface.ip_addresses {
59                b = b.row("IP Address", ip);
60            }
61        }
62        output.push_str(&b.finish());
63        output.push('\n');
64    }
65
66    // Adapter details
67    if let Some(ref adapters) = results.adapter_details {
68        if !adapters.is_empty() {
69            let mut b = ReportBuilder::new(label_width, data_width, chars);
70            b = b.full_top_border().span_row("  NETWORK ADAPTERS").divider();
71
72            for (i, adapter) in adapters.iter().enumerate() {
73                if i > 0 {
74                    b = b.divider();
75                }
76                // Use description (hardware chip name) as primary label if available
77                let adapter_label = adapter.description.as_deref().unwrap_or(&adapter.name);
78                b = b.row("Adapter", adapter_label);
79
80                // Show display type with physical medium detail
81                let type_detail = if let Some(ref pm) = adapter.physical_medium {
82                    format!("{} ({})", adapter.adapter_type, pm)
83                } else {
84                    adapter.adapter_type.clone()
85                };
86                b = b.row("Type", &type_detail);
87                b = b.row("Status", &adapter.status);
88
89                if let Some(ref mac) = adapter.mac_address {
90                    b = b.row("MAC", mac);
91                }
92
93                // Link speed (show TX/RX if different, single value if same)
94                match (adapter.link_speed_mbps, adapter.rx_link_speed_mbps) {
95                    (Some(tx), Some(rx)) if tx == rx => {
96                        b = b.row("Link Speed", &format_link_speed(tx));
97                    }
98                    (Some(tx), Some(rx)) => {
99                        b = b.row(
100                            "Link Speed",
101                            &format!(
102                                "TX: {} / RX: {}",
103                                format_link_speed(tx),
104                                format_link_speed(rx)
105                            ),
106                        );
107                    }
108                    (Some(tx), None) => {
109                        b = b.row("Link Speed", &format_link_speed(tx));
110                    }
111                    _ => {}
112                }
113
114                if let Some(ref gws) = adapter.gateways {
115                    for gw in gws {
116                        b = b.row("Gateway", gw);
117                    }
118                }
119                if let Some(ref dns) = adapter.dns_servers {
120                    for server in dns {
121                        b = b.row("DNS", server);
122                    }
123                }
124                if let Some(mtu) = adapter.mtu {
125                    b = b.row("MTU", &mtu.to_string());
126                }
127                if let Some(metric) = adapter.ipv4_metric {
128                    b = b.row("IPv4 Metric", &metric.to_string());
129                }
130
131                if let Some(ref drv) = adapter.driver_name {
132                    b = b.row("Driver", drv);
133                }
134                if let Some(ref ver) = adapter.driver_version {
135                    b = b.row("Driver Version", ver);
136                }
137                if let Some(ref date) = adapter.driver_date {
138                    b = b.row("Driver Date", date);
139                }
140            }
141            output.push_str(&b.finish());
142            output.push('\n');
143        }
144    }
145
146    // Gateway details
147    if let Some(ref gw) = results.gateway_details {
148        let mut b = ReportBuilder::new(label_width, data_width, chars);
149        b = b.full_top_border().span_row("  GATEWAY").divider();
150        b = b.row("IP Address", &gw.ip);
151        b = b.row("Reachable", if gw.reachable { "Yes" } else { "No" });
152        if let Some(lat) = gw.latency_ms {
153            b = b.row("Latency", &format!("{:.1}ms", lat));
154        }
155        if gw.packets_sent > 0 {
156            b = b.row(
157                "Probes",
158                &format!("{}/{} replies", gw.packets_received, gw.packets_sent),
159            );
160        }
161        output.push_str(&b.finish());
162        output.push('\n');
163    }
164
165    // DNS details
166    if let Some(ref dns) = results.dns_details {
167        let mut b = ReportBuilder::new(label_width, data_width, chars);
168        b = b.full_top_border().span_row("  DNS SERVERS").divider();
169
170        for server in &dns.servers {
171            b = b.row("Server", &server.address);
172        }
173
174        if dns.resolution_tests.is_empty() {
175            // Pre-v3.4.0 shape: a single probe.
176            if let Some(ref test) = dns.resolution_test {
177                b = b.divider();
178                b = b.row("Test Domain", &test.domain);
179                b = b.row("Resolved", if test.resolved { "Yes" } else { "No" });
180                b = b.row("Time", &format!("{:.1}ms", test.resolution_time_ms));
181                for ip in &test.resolved_ips {
182                    b = b.row("Resolved IP", ip);
183                }
184            }
185        } else {
186            for test in &dns.resolution_tests {
187                b = b.divider();
188                b = b.row("Test Domain", &test.domain);
189                b = b.row("Resolved", if test.resolved { "Yes" } else { "No" });
190                b = b.row("Time", &format!("{:.1}ms", test.resolution_time_ms));
191                for ip in &test.resolved_ips {
192                    b = b.row("Resolved IP", ip);
193                }
194            }
195        }
196        output.push_str(&b.finish());
197        output.push('\n');
198    }
199
200    // Public IP details
201    if let Some(ref pip) = results.public_ip_details {
202        let mut b = ReportBuilder::new(label_width, data_width, chars);
203        b = b
204            .full_top_border()
205            .span_row("  PUBLIC IP & GEOLOCATION")
206            .divider();
207        b = b.row("Public IP", &pip.ip);
208        b = b.row("Lookup Time", &format!("{:.0}ms", pip.lookup_time_ms));
209        b = b.row("Behind NAT", if pip.behind_nat { "Yes" } else { "No" });
210
211        if let Some(ref city) = pip.city {
212            b = b.row("City", city);
213        }
214        if let Some(ref region) = pip.region {
215            b = b.row("Region", region);
216        }
217        if let Some(ref country) = pip.country {
218            b = b.row("Country", country);
219        }
220        if let Some(ref isp) = pip.isp {
221            b = b.row("ISP", isp);
222        }
223        if let Some(ref org) = pip.org {
224            b = b.row("Organization", org);
225        }
226        output.push_str(&b.finish());
227        output.push('\n');
228    }
229
230    // Latency details
231    if let Some(ref latencies) = results.latency_details {
232        let mut b = ReportBuilder::new(label_width, data_width, chars);
233        b = b.full_top_border().span_row("  LATENCY TESTS").divider();
234
235        for (i, lat) in latencies.iter().enumerate() {
236            if i > 0 {
237                b = b.divider();
238            }
239            b = b.row("Host", &format!("{} ({})", lat.host, lat.label));
240            b = b.row("Reachable", if lat.reachable { "Yes" } else { "No" });
241            if let Some(min) = lat.min_ms {
242                b = b.row("Min", &format!("{:.1}ms", min));
243            }
244            if let Some(avg) = lat.avg_ms {
245                b = b.row("Avg", &format!("{:.1}ms", avg));
246            }
247            if let Some(max) = lat.max_ms {
248                b = b.row("Max", &format!("{:.1}ms", max));
249            }
250            if let Some(jitter) = lat.jitter_ms {
251                b = b.row("Jitter", &format!("{:.1}ms", jitter));
252            }
253            b = b.row("Packet Loss", &format!("{:.0}%", lat.packet_loss));
254        }
255        output.push_str(&b.finish());
256        output.push('\n');
257    }
258
259    // Speed test details
260    if let Some(ref speed) = results.speed_details {
261        let mut b = ReportBuilder::new(label_width, data_width, chars);
262        b = b.full_top_border().span_row("  SPEED TEST").divider();
263
264        if let Some(ping) = speed.ping_ms {
265            b = b.row("Ping", &format!("{:.1} ms", ping));
266        }
267        if let Some(jitter) = speed.jitter_ms {
268            b = b.row("Jitter", &format!("{:.1} ms", jitter));
269        }
270        let dl_margin = speed
271            .confidence_intervals
272            .as_ref()
273            .and_then(|ci| ci.download.as_ref())
274            .map(|ci| format!(" ±{}", crate::speedtest::format_mbps(ci.margin)))
275            .unwrap_or_default();
276        let ul_margin = speed
277            .confidence_intervals
278            .as_ref()
279            .and_then(|ci| ci.upload.as_ref())
280            .map(|ci| format!(" ±{}", crate::speedtest::format_mbps(ci.margin)))
281            .unwrap_or_default();
282        b = b.row(
283            "Download",
284            &format!(
285                "{}{} (avg)",
286                crate::speedtest::format_mbps(speed.download_mbps),
287                dl_margin
288            ),
289        );
290        b = b.row(
291            "Upload",
292            &format!(
293                "{}{} (avg)",
294                crate::speedtest::format_mbps(speed.upload_mbps),
295                ul_margin
296            ),
297        );
298        if let Some(loss) = speed.packet_loss_pct {
299            b = b.row("Packet Loss", &format!("{:.0}%", loss));
300        }
301        b = b.row("Duration", &format!("{:.1}s", speed.duration_s));
302        if !speed.merge_exclusions.is_empty() {
303            let list = speed
304                .merge_exclusions
305                .iter()
306                .map(|e| {
307                    format!(
308                        "{} {} ({} sample{})",
309                        e.provider,
310                        if e.direction == "download" {
311                            "DL"
312                        } else {
313                            "UL"
314                        },
315                        e.samples,
316                        if e.samples == 1 { "" } else { "s" },
317                    )
318                })
319                .collect::<Vec<_>>()
320                .join(", ");
321            b = b.row("Excluded", &list);
322        }
323
324        // Per-provider breakdown
325        for provider in &speed.providers {
326            if provider.error.is_some() {
327                continue;
328            }
329            b = b.section_header(&provider.provider);
330            b = b.row("Server", &provider.server);
331            if let Some(ref location) = provider.location {
332                b = b.row("Location", location);
333            }
334            if let Some(dl) = provider.download_mbps {
335                b = b.row("Download", &crate::speedtest::format_mbps(dl));
336            }
337            if let Some(ul) = provider.upload_mbps {
338                b = b.row("Upload", &crate::speedtest::format_mbps(ul));
339            }
340            b = b.row("DL Data", &format_bytes(provider.download_bytes));
341            b = b.row("UL Data", &format_bytes(provider.upload_bytes));
342        }
343
344        output.push_str(&b.finish());
345        output.push('\n');
346    }
347
348    // Port details
349    if let Some(ref ports) = results.port_details {
350        let mut b = ReportBuilder::new(label_width, data_width, chars);
351        b = b
352            .full_top_border()
353            .span_row("  PORT CONNECTIVITY")
354            .divider();
355
356        for port in ports {
357            let status = match port.outcome {
358                crate::diagnostics::ports::PortOutcome::Open => "Open",
359                crate::diagnostics::ports::PortOutcome::Blocked => "Blocked",
360                crate::diagnostics::ports::PortOutcome::Unresolved => "Untested (DNS)",
361            };
362            let lat = port
363                .latency_ms
364                .map(|l| format!(" ({:.0}ms)", l))
365                .unwrap_or_default();
366            b = b.row(
367                &format!("{} ({})", port.service, port.port),
368                &format!("{}{}", status, lat),
369            );
370        }
371        output.push_str(&b.finish());
372        output.push('\n');
373    }
374
375    // == TECHNICIAN DEEP DIAGNOSTICS ==
376    if let Some(ref tech) = results.technician {
377        output.push_str(&render_technician_details(tech, config));
378    }
379
380    // Timestamp footer
381    output.push_str(&format!("  Report generated: {}\n", results.timestamp));
382    output.push('\n');
383
384    output
385}
386
387fn render_technician_details(tech: &TechnicianResults, config: &Config) -> String {
388    let label_width = 16;
389    let data_width = 40;
390    let chars = config.box_chars();
391    let mut output = String::new();
392
393    // ARP Table
394    if let Some(ref arp) = tech.arp_table {
395        if !arp.is_empty() {
396            let mut b = ReportBuilder::new(label_width, data_width, chars);
397            b = b.full_top_border().span_row("  ARP TABLE").divider();
398            for entry in arp.iter().take(30) {
399                b = b.row(&entry.ip, &format!("{} ({})", entry.mac, entry.entry_type));
400            }
401            if arp.len() > 30 {
402                b = b.row("", &format!("... and {} more", arp.len() - 30));
403            }
404            if let Some(ref health) = tech.arp_health {
405                b = b.divider();
406                b = b.row(
407                    "Gateway in ARP",
408                    if health.gateway_in_table { "Yes" } else { "No" },
409                );
410                if let Some(ref mac) = health.gateway_mac {
411                    b = b.row("Gateway MAC", mac);
412                }
413                for dup in &health.duplicate_ip_macs {
414                    b = b.row(&format!("Duplicate {}", dup.ip), &dup.macs.join(", "));
415                }
416                b = b.row("Assessment", &health.assessment);
417            }
418            output.push_str(&b.finish());
419            output.push('\n');
420        }
421    }
422
423    // Routing Table
424    if let Some(ref routes) = tech.routing_table {
425        if !routes.is_empty() {
426            let mut b = ReportBuilder::new(label_width, data_width, chars);
427            b = b.full_top_border().span_row("  ROUTING TABLE").divider();
428            for route in routes.iter().take(20) {
429                let gw = if route.gateway.is_empty() {
430                    "direct".to_string()
431                } else {
432                    route.gateway.clone()
433                };
434                let metric = route
435                    .metric
436                    .map(|m| format!(" m:{}", m))
437                    .unwrap_or_default();
438                b = b.row(
439                    &route.destination,
440                    &format!("via {} dev {}{}", gw, route.interface, metric),
441                );
442            }
443            if routes.len() > 20 {
444                b = b.row("", &format!("... and {} more", routes.len() - 20));
445            }
446            output.push_str(&b.finish());
447            output.push('\n');
448        }
449    }
450
451    // Active Connections
452    if let Some(ref conns) = tech.active_connections {
453        if !conns.is_empty() {
454            let established: Vec<_> = conns
455                .iter()
456                .filter(|c| c.state == "ESTABLISHED")
457                .take(20)
458                .collect();
459            if !established.is_empty() {
460                let mut b = ReportBuilder::new(label_width, data_width, chars);
461                b = b
462                    .full_top_border()
463                    .span_row("  ACTIVE CONNECTIONS (ESTABLISHED)")
464                    .divider();
465                for conn in &established {
466                    let proc_info = conn.process_name.as_deref().unwrap_or("?");
467                    b = b.row(
468                        &conn.local_addr,
469                        &format!("{} [{}]", conn.remote_addr, proc_info),
470                    );
471                }
472                let total_established = conns.iter().filter(|c| c.state == "ESTABLISHED").count();
473                if total_established > 20 {
474                    b = b.row("", &format!("... and {} more", total_established - 20));
475                }
476                output.push_str(&b.finish());
477                output.push('\n');
478            }
479        }
480    }
481
482    // Listening Ports
483    if let Some(ref ports) = tech.listening_ports {
484        if !ports.is_empty() {
485            let mut b = ReportBuilder::new(label_width, data_width, chars);
486            b = b.full_top_border().span_row("  LISTENING PORTS").divider();
487            for port in ports.iter().take(20) {
488                let proc_info = port.process_name.as_deref().unwrap_or("?");
489                b = b.row(
490                    &format!("{} :{}", port.protocol, port.port),
491                    &format!("{} [{}]", port.address, proc_info),
492                );
493            }
494            if ports.len() > 20 {
495                b = b.row("", &format!("... and {} more", ports.len() - 20));
496            }
497            output.push_str(&b.finish());
498            output.push('\n');
499        }
500    }
501
502    // DHCP Info
503    if let Some(ref dhcp) = tech.dhcp_info {
504        if !dhcp.is_empty() {
505            let mut b = ReportBuilder::new(label_width, data_width, chars);
506            b = b.full_top_border().span_row("  DHCP LEASES").divider();
507            for (i, lease) in dhcp.iter().enumerate() {
508                if i > 0 {
509                    b = b.divider();
510                }
511                b = b.row("Interface", &lease.interface);
512                b = b.row(
513                    "DHCP Enabled",
514                    if lease.dhcp_enabled { "Yes" } else { "No" },
515                );
516                if let Some(ref server) = lease.dhcp_server {
517                    b = b.row("DHCP Server", server);
518                }
519                if let Some(ref ip) = lease.ip_address {
520                    b = b.row("IP Address", ip);
521                }
522                if let Some(ref obtained) = lease.lease_obtained {
523                    b = b.row("Obtained", obtained);
524                }
525                if let Some(ref expires) = lease.lease_expires {
526                    b = b.row("Expires", expires);
527                }
528            }
529            output.push_str(&b.finish());
530            output.push('\n');
531        }
532    }
533
534    // Protocol Statistics
535    if let Some(ref stats) = tech.protocol_stats {
536        let mut b = ReportBuilder::new(label_width, data_width, chars);
537        b = b
538            .full_top_border()
539            .span_row("  PROTOCOL STATISTICS")
540            .divider();
541
542        b = b.row("TCP Active Opens", &stats.tcp.active_opens.to_string());
543        b = b.row("TCP Passive Opens", &stats.tcp.passive_opens.to_string());
544        b = b.row("TCP Current", &stats.tcp.current_connections.to_string());
545        b = b.row("TCP Failed", &stats.tcp.failed_connections.to_string());
546        b = b.row("TCP Resets", &stats.tcp.reset_connections.to_string());
547        b = b.row(
548            "TCP Retransmits",
549            &stats.tcp.segments_retransmitted.to_string(),
550        );
551        b = b.row("TCP Segments In", &stats.tcp.segments_received.to_string());
552        b = b.row("TCP Segments Out", &stats.tcp.segments_sent.to_string());
553        b = b.divider();
554        b = b.row("UDP In", &stats.udp.datagrams_received.to_string());
555        b = b.row("UDP Out", &stats.udp.datagrams_sent.to_string());
556        b = b.row("UDP Errors", &stats.udp.receive_errors.to_string());
557        b = b.divider();
558        b = b.row("ICMP In", &stats.icmp.messages_received.to_string());
559        b = b.row("ICMP Out", &stats.icmp.messages_sent.to_string());
560        b = b.row("ICMP Errors In", &stats.icmp.errors_received.to_string());
561
562        output.push_str(&b.finish());
563        output.push('\n');
564    }
565
566    // Adapter HW Stats
567    if let Some(ref hw) = tech.adapter_hw_stats {
568        if !hw.is_empty() {
569            let mut b = ReportBuilder::new(label_width, data_width, chars);
570            b = b
571                .full_top_border()
572                .span_row("  ADAPTER HARDWARE STATS")
573                .divider();
574            for (i, stat) in hw.iter().enumerate() {
575                if i > 0 {
576                    b = b.divider();
577                }
578                b = b.row("Interface", &stat.name);
579                b = b.row("RX Bytes", &format_bytes(stat.rx_bytes));
580                b = b.row("TX Bytes", &format_bytes(stat.tx_bytes));
581                b = b.row("RX Packets", &stat.rx_packets.to_string());
582                b = b.row("TX Packets", &stat.tx_packets.to_string());
583                b = b.row("RX Errors", &stat.rx_errors.to_string());
584                b = b.row("TX Errors", &stat.tx_errors.to_string());
585                if let Some(ref speed) = stat.link_speed {
586                    b = b.row("Link Speed", speed);
587                }
588                if let Some(ref dup) = stat.duplex {
589                    b = b.row("Duplex", dup);
590                }
591            }
592            output.push_str(&b.finish());
593            output.push('\n');
594        }
595    }
596
597    // Proxy Config
598    if let Some(ref proxy) = tech.proxy_config {
599        let mut b = ReportBuilder::new(label_width, data_width, chars);
600        b = b
601            .full_top_border()
602            .span_row("  PROXY CONFIGURATION")
603            .divider();
604        b = b.row(
605            "Proxy Enabled",
606            if proxy.proxy_enabled { "Yes" } else { "No" },
607        );
608        if let Some(ref http) = proxy.http_proxy {
609            b = b.row("HTTP Proxy", http);
610        }
611        if let Some(ref https) = proxy.https_proxy {
612            b = b.row("HTTPS Proxy", https);
613        }
614        if let Some(ref socks) = proxy.socks_proxy {
615            b = b.row("SOCKS Proxy", socks);
616        }
617        if let Some(ref pac) = proxy.pac_url {
618            b = b.row("PAC URL", pac);
619        }
620        if let Some(reachable) = proxy.pac_reachable {
621            let size = proxy
622                .pac_size_bytes
623                .map(|s| format!(" ({} bytes)", s))
624                .unwrap_or_default();
625            b = b.row(
626                "PAC Reachable",
627                &if reachable {
628                    format!("Yes{}", size)
629                } else {
630                    "NO — configured PAC script is unreachable".to_string()
631                },
632            );
633        }
634        if let Some(wpad) = proxy.wpad_dns_detected {
635            b = b.row(
636                "WPAD DNS",
637                if wpad {
638                    "Detected — proxy auto-discovery is live on this network"
639                } else {
640                    "Not detected"
641                },
642            );
643        }
644        if let Some(ref no) = proxy.no_proxy {
645            b = b.row("No Proxy", no);
646        }
647        output.push_str(&b.finish());
648        output.push('\n');
649    }
650
651    // VPN Detection
652    if let Some(ref vpns) = tech.vpn_info {
653        let mut b = ReportBuilder::new(label_width, data_width, chars);
654        b = b.full_top_border().span_row("  VPN DETECTION").divider();
655        for (i, vpn) in vpns.iter().enumerate() {
656            if i > 0 {
657                b = b.divider();
658            }
659            b = b.row("VPN Adapter", &vpn.name);
660            b = b.row("Type", &vpn.adapter_type);
661            b = b.row("Status", &vpn.status);
662            if let Some(ref vendor) = vpn.vendor {
663                b = b.row("Vendor", vendor);
664            }
665            if vpn.is_enterprise {
666                b = b.row("Policy", "Enterprise/Managed");
667            }
668            if let Some(ref iface) = vpn.interface_name {
669                b = b.row("Interface", iface);
670            }
671            if let Some(ref ip) = vpn.ip_address {
672                b = b.row("IP Address", ip);
673            }
674        }
675        output.push_str(&b.finish());
676        output.push('\n');
677    }
678
679    // Firewall Summary
680    if let Some(ref fw) = tech.firewall_info {
681        let mut b = ReportBuilder::new(label_width, data_width, chars);
682        b = b.full_top_border().span_row("  FIREWALL STATUS").divider();
683        b = b.row("Status", &fw.summary);
684        for profile in &fw.profiles {
685            b = b.row(
686                &profile.name,
687                if profile.enabled {
688                    "Enabled"
689                } else {
690                    "Disabled"
691                },
692            );
693        }
694        output.push_str(&b.finish());
695        output.push('\n');
696    }
697
698    // DNS Cache
699    if let Some(ref cache) = tech.dns_cache {
700        if !cache.is_empty() {
701            let mut b = ReportBuilder::new(label_width, data_width, chars);
702            b = b.full_top_border().span_row("  DNS CACHE").divider();
703            for entry in cache.iter().take(20) {
704                b = b.row(
705                    &entry.name,
706                    &format!("{}: {}", entry.record_type, entry.data),
707                );
708            }
709            if cache.len() > 20 {
710                b = b.row("", &format!("... and {} more entries", cache.len() - 20));
711            }
712            output.push_str(&b.finish());
713            output.push('\n');
714        }
715    }
716
717    // IPv6 Status
718    if let Some(ref ipv6) = tech.ipv6_info {
719        let mut b = ReportBuilder::new(label_width, data_width, chars);
720        b = b.full_top_border().span_row("  IPv6 STATUS").divider();
721        b = b.row("Available", if ipv6.available { "Yes" } else { "No" });
722        let conn_str = match ipv6.connectivity {
723            crate::diagnostics::ipv6::Ipv6Connectivity::Full => "Full",
724            crate::diagnostics::ipv6::Ipv6Connectivity::LinkLocal => "Link-local only",
725            crate::diagnostics::ipv6::Ipv6Connectivity::None => "None",
726        };
727        b = b.row("Connectivity", conn_str);
728        b = b.row("Dual Stack", if ipv6.dual_stack { "Yes" } else { "No" });
729        if let Some(http_ok) = ipv6.v6_http_ok {
730            b = b.row("HTTPS over v6", if http_ok { "Working" } else { "FAILED" });
731        }
732        if let (Some(v4), Some(v6)) = (ipv6.v4_connect_ms, ipv6.v6_connect_ms) {
733            b = b.row("v4 vs v6 connect", &format!("{:.0}ms vs {:.0}ms", v4, v6));
734        }
735        if let Some(penalty) = ipv6.v6_penalty_ms {
736            if penalty > 0.0 {
737                b = b.row("v6 penalty", &format!("+{:.0}ms", penalty));
738            }
739        }
740
741        for addr in ipv6.addresses.iter().take(10) {
742            b = b.row(
743                &addr.scope,
744                &format!("{} ({})", addr.address, addr.interface),
745            );
746        }
747        output.push_str(&b.finish());
748        output.push('\n');
749    }
750
751    // MTU Info
752    if let Some(ref mtus) = tech.mtu_info {
753        if !mtus.is_empty() {
754            let mut b = ReportBuilder::new(label_width, data_width, chars);
755            b = b
756                .full_top_border()
757                .span_row("  MTU PER INTERFACE")
758                .divider();
759            for mtu in mtus {
760                b = b.row(&mtu.interface, &mtu.mtu.to_string());
761            }
762            if let Some(ref pm) = tech.path_mtu {
763                b = b.divider();
764                b = b.row(
765                    &format!("Path MTU ({})", pm.target),
766                    &if pm.path_mtu > 0 {
767                        format!("{} ({} probes)", pm.path_mtu, pm.probes)
768                    } else {
769                        "unmeasurable".to_string()
770                    },
771                );
772                b = b.row("Assessment", &pm.assessment);
773            }
774            output.push_str(&b.finish());
775            output.push('\n');
776        }
777    }
778
779    // Connection States
780    if let Some(ref states) = tech.connection_states {
781        let mut b = ReportBuilder::new(label_width, data_width, chars);
782        b = b
783            .full_top_border()
784            .span_row("  TCP CONNECTION STATES")
785            .divider();
786        b = b.row("ESTABLISHED", &states.established.to_string());
787        b = b.row("TIME_WAIT", &states.time_wait.to_string());
788        b = b.row("CLOSE_WAIT", &states.close_wait.to_string());
789        b = b.row("FIN_WAIT_1", &states.fin_wait_1.to_string());
790        b = b.row("FIN_WAIT_2", &states.fin_wait_2.to_string());
791        b = b.row("SYN_SENT", &states.syn_sent.to_string());
792        b = b.row("SYN_RECEIVED", &states.syn_received.to_string());
793        b = b.row("CLOSING", &states.closing.to_string());
794        b = b.row("LAST_ACK", &states.last_ack.to_string());
795        b = b.row("LISTEN", &states.listen.to_string());
796        output.push_str(&b.finish());
797        output.push('\n');
798    }
799
800    // Bufferbloat
801    if let Some(ref bb) = tech.bufferbloat {
802        let mut b = ReportBuilder::new(label_width, data_width, chars);
803        b = b.full_top_border().span_row("  BUFFERBLOAT TEST").divider();
804        b = b.row("Grade", &bb.grade);
805        b = b.row(
806            "Idle Latency",
807            &format!(
808                "{:.1}ms ({} probes)",
809                bb.unloaded_latency_ms, bb.samples_idle
810            ),
811        );
812        if let Some(loaded) = bb.loaded_latency_ms {
813            let grade = bb
814                .download_grade
815                .as_deref()
816                .map(|g| format!(" — grade {}", g))
817                .unwrap_or_default();
818            b = b.row(
819                "Loaded (down)",
820                &format!(
821                    "{:.1}ms{}{}",
822                    loaded,
823                    bb.download_bloat_ms
824                        .map(|d| format!(" (+{:.1}ms)", d))
825                        .unwrap_or_default(),
826                    grade
827                ),
828            );
829        }
830        if let Some(ul_loaded) = bb.upload_loaded_latency_ms {
831            let grade = bb
832                .upload_grade
833                .as_deref()
834                .map(|g| format!(" — grade {}", g))
835                .unwrap_or_default();
836            b = b.row(
837                "Loaded (up)",
838                &format!(
839                    "{:.1}ms{}{}",
840                    ul_loaded,
841                    bb.upload_bloat_ms
842                        .map(|d| format!(" (+{:.1}ms)", d))
843                        .unwrap_or_default(),
844                    grade
845                ),
846            );
847        }
848        b = b.row("Assessment", &bb.description);
849        output.push_str(&b.finish());
850        output.push('\n');
851    }
852
853    // Reverse DNS
854    if let Some(ref rdns) = tech.reverse_dns {
855        if !rdns.is_empty() {
856            let mut b = ReportBuilder::new(label_width, data_width, chars);
857            b = b.full_top_border().span_row("  REVERSE DNS").divider();
858            for entry in rdns {
859                let hostname = entry.hostname.as_deref().unwrap_or("(no PTR)");
860                b = b.row(&format!("{} ({})", entry.label, entry.ip), hostname);
861            }
862            output.push_str(&b.finish());
863            output.push('\n');
864        }
865    }
866
867    // TLS Inspection
868    if let Some(ref tls) = tech.tls_inspection {
869        let mut b = ReportBuilder::new(label_width, data_width, chars);
870        b = b
871            .full_top_border()
872            .span_row("  TLS INSPECTION CHECK")
873            .divider();
874        b = b.row("Intercepted", if tls.detected { "DETECTED" } else { "No" });
875        b = b.row("Assessment", &tls.description);
876        output.push_str(&b.finish());
877        output.push('\n');
878    }
879
880    // Traffic Counters
881    if let Some(ref traffic) = tech.traffic_counters {
882        if !traffic.is_empty() {
883            let mut b = ReportBuilder::new(label_width, data_width, chars);
884            b = b
885                .full_top_border()
886                .span_row("  TRAFFIC COUNTERS (since boot)")
887                .divider();
888            for counter in traffic {
889                b = b.row(
890                    &counter.interface,
891                    &format!("RX {} / TX {}", counter.rx_formatted, counter.tx_formatted),
892                );
893            }
894            output.push_str(&b.finish());
895            output.push('\n');
896        }
897    }
898
899    // Route Path (traceroute)
900    if let Some(ref path) = tech.route_path {
901        let mut b = ReportBuilder::new(label_width, data_width, chars);
902        b = b
903            .full_top_border()
904            .span_row("  ROUTE PATH (TRACEROUTE)")
905            .divider();
906        for hop in path.hops.iter().take(16) {
907            let value = if hop.timed_out {
908                "*".to_string()
909            } else {
910                format!(
911                    "{}{}",
912                    hop.ip.as_deref().unwrap_or("?"),
913                    hop.avg_ms
914                        .map(|ms| format!("  {:.1}ms", ms))
915                        .unwrap_or_default()
916                )
917            };
918            b = b.row(&format!("hop {}", hop.number), &value);
919        }
920        b = b.divider();
921        b = b.row("Reached", if path.reached { "Yes" } else { "No" });
922        if let Some(first) = path.first_hop_ms {
923            b = b.row("First Hop", &format!("{:.1}ms (your router)", first));
924        }
925        if let Some(boundary) = path.isp_boundary_hop {
926            b = b.row("ISP Boundary", &format!("hop {}", boundary));
927        }
928        if let Some(ref jump) = path.largest_jump {
929            b = b.row(
930                "Largest Jump",
931                &format!(
932                    "+{:.1}ms (hop {}→{}, {})",
933                    jump.delta_ms, jump.from_hop, jump.to_hop, jump.segment
934                ),
935            );
936        }
937        b = b.row("Assessment", &path.assessment);
938        output.push_str(&b.finish());
939        output.push('\n');
940    }
941
942    // Sustained Packet Loss
943    if let Some(ref losses) = tech.packet_loss {
944        let mut b = ReportBuilder::new(label_width, data_width, chars);
945        b = b
946            .full_top_border()
947            .span_row("  SUSTAINED PACKET LOSS")
948            .divider();
949        for (i, loss) in losses.iter().enumerate() {
950            if i > 0 {
951                b = b.divider();
952            }
953            b = b.row("Target", &format!("{} ({})", loss.host, loss.label));
954            b = b.row(
955                "Loss",
956                &format!(
957                    "{:.1}% ({}/{} received)",
958                    loss.loss_pct, loss.received, loss.sent
959                ),
960            );
961            if let (Some(min), Some(avg), Some(max)) = (loss.min_ms, loss.avg_ms, loss.max_ms) {
962                b = b.row(
963                    "Latency",
964                    &format!("min {:.1} / avg {:.1} / max {:.1}ms", min, avg, max),
965                );
966            }
967            if let Some(p95) = loss.p95_ms {
968                b = b.row("P95", &format!("{:.1}ms", p95));
969            }
970            if let Some(jitter) = loss.jitter_ms {
971                b = b.row("Jitter", &format!("{:.1}ms", jitter));
972            }
973            b = b.row("Assessment", &loss.assessment);
974        }
975        output.push_str(&b.finish());
976        output.push('\n');
977    }
978
979    // NAT Analysis
980    if let Some(ref nat) = tech.nat_analysis {
981        let mut b = ReportBuilder::new(label_width, data_width, chars);
982        b = b.full_top_border().span_row("  NAT ANALYSIS").divider();
983        if let Some(ref gw) = nat.gateway_ip {
984            b = b.row(
985                "Gateway",
986                &format!(
987                    "{} ({})",
988                    gw,
989                    if nat.gateway_is_private {
990                        "private"
991                    } else {
992                        "public"
993                    }
994                ),
995            );
996        }
997        if let Some(ref hop2) = nat.hop2_ip {
998            b = b.row(
999                "Second Hop",
1000                &format!(
1001                    "{} ({})",
1002                    hop2,
1003                    if nat.hop2_is_private {
1004                        "private"
1005                    } else {
1006                        "public"
1007                    }
1008                ),
1009            );
1010        }
1011        if let Some(ref pip) = nat.public_ip {
1012            b = b.row("Public IP", pip);
1013        }
1014        b = b.row("NAT Layers", &format!("~{}", nat.nat_layers_estimate));
1015        b = b.row("CGNAT", if nat.cgnat_detected { "DETECTED" } else { "No" });
1016        b = b.row(
1017            "Double NAT",
1018            if nat.double_nat_suspected {
1019                "Suspected"
1020            } else {
1021                "No"
1022            },
1023        );
1024        b = b.row("Assessment", &nat.assessment);
1025        output.push_str(&b.finish());
1026        output.push('\n');
1027    }
1028
1029    // Wi-Fi Link Quality
1030    if let Some(ref links) = tech.wifi {
1031        let mut b = ReportBuilder::new(label_width, data_width, chars);
1032        b = b
1033            .full_top_border()
1034            .span_row("  WI-FI LINK QUALITY")
1035            .divider();
1036        for (i, link) in links.iter().enumerate() {
1037            if i > 0 {
1038                b = b.divider();
1039            }
1040            b = b.row("Interface", &link.interface);
1041            if let Some(ref ssid) = link.ssid {
1042                b = b.row("SSID", ssid);
1043            }
1044            if let Some(ref bssid) = link.bssid {
1045                b = b.row("BSSID", bssid);
1046            }
1047            match (link.signal_pct, link.rssi_dbm) {
1048                (Some(pct), _) => b = b.row("Signal", &format!("{}%", pct)),
1049                (None, Some(rssi)) => b = b.row("Signal", &format!("{} dBm", rssi)),
1050                _ => {}
1051            }
1052            if let (Some(ch), Some(ref band)) = (link.channel, link.band.as_ref()) {
1053                b = b.row("Channel", &format!("{} ({})", ch, band));
1054            } else if let Some(ref band) = link.band {
1055                b = b.row("Band", band);
1056            }
1057            if let Some(ref phy) = link.phy_mode {
1058                b = b.row("PHY Mode", phy);
1059            }
1060            if let Some(ref sec) = link.security {
1061                b = b.row("Security", sec);
1062            }
1063            match (link.rx_rate_mbps, link.tx_rate_mbps) {
1064                (Some(rx), Some(tx)) => {
1065                    b = b.row("Link Rate", &format!("RX {:.0} / TX {:.0} Mbps", rx, tx))
1066                }
1067                (Some(rx), None) => b = b.row("Link Rate", &format!("RX {:.0} Mbps", rx)),
1068                (None, Some(tx)) => b = b.row("Link Rate", &format!("TX {:.0} Mbps", tx)),
1069                _ => {}
1070            }
1071            b = b.row("Assessment", &link.assessment);
1072        }
1073        output.push_str(&b.finish());
1074        output.push('\n');
1075    }
1076
1077    // DNS Resolver Benchmark
1078    if let Some(ref bench) = tech.dns_benchmark {
1079        let mut b = ReportBuilder::new(label_width, data_width, chars);
1080        b = b
1081            .full_top_border()
1082            .span_row("  DNS RESOLVER BENCHMARK")
1083            .divider();
1084        for r in &bench.resolvers {
1085            let timing = r
1086                .avg_ms
1087                .map(|ms| format!("{:.0}ms avg", ms))
1088                .unwrap_or_else(|| "no replies".to_string());
1089            b = b.row(
1090                &r.name,
1091                &format!("{} ({}/{} ok)", timing, r.queries_ok, r.queries_total),
1092            );
1093        }
1094        b = b.divider();
1095        if let Some(ref fastest) = bench.fastest {
1096            b = b.row("Fastest", fastest);
1097        }
1098        if let Some(delta) = bench.system_vs_fastest_ms {
1099            b = b.row("System vs Best", &format!("{:+.0}ms", delta));
1100        }
1101        if let Some(hijack) = bench.hijack_detected {
1102            b = b.row("NXDOMAIN Hijack", if hijack { "DETECTED" } else { "No" });
1103        }
1104        if let Some(dnssec) = bench.dnssec_validating {
1105            b = b.row(
1106                "DNSSEC",
1107                if dnssec {
1108                    "Validating"
1109                } else {
1110                    "Not validating"
1111                },
1112            );
1113        }
1114        b = b.row("Assessment", &bench.assessment);
1115        output.push_str(&b.finish());
1116        output.push('\n');
1117    }
1118
1119    // Captive Portal
1120    if let Some(ref portal) = tech.captive_portal {
1121        let mut b = ReportBuilder::new(label_width, data_width, chars);
1122        b = b.full_top_border().span_row("  CAPTIVE PORTAL").divider();
1123        b = b.row(
1124            "Portal",
1125            if portal.portal_detected {
1126                "DETECTED"
1127            } else {
1128                "Not detected"
1129            },
1130        );
1131        if let Some(status) = portal.http_status {
1132            b = b.row("Probe Status", &format!("HTTP {}", status));
1133        }
1134        if let Some(ref loc) = portal.redirect_location {
1135            b = b.row("Redirects To", loc);
1136        }
1137        b = b.row("Assessment", &portal.assessment);
1138        output.push_str(&b.finish());
1139        output.push('\n');
1140    }
1141
1142    // Clock Sync (NTP)
1143    if let Some(ref clock) = tech.clock_sync {
1144        let mut b = ReportBuilder::new(label_width, data_width, chars);
1145        b = b.full_top_border().span_row("  CLOCK SYNC (NTP)").divider();
1146        if let Some(offset) = clock.offset_ms {
1147            b = b.row("Clock Offset", &format!("{:+.0}ms", offset));
1148        }
1149        b = b.row(
1150            "Source",
1151            &format!(
1152                "{} ({} server{} responded)",
1153                clock.source,
1154                clock.servers_responded,
1155                if clock.servers_responded == 1 {
1156                    ""
1157                } else {
1158                    "s"
1159                }
1160            ),
1161        );
1162        if clock.ntp_blocked {
1163            b = b.row("NTP (UDP/123)", "Blocked or filtered");
1164        }
1165        b = b.row("Assessment", &clock.assessment);
1166        output.push_str(&b.finish());
1167        output.push('\n');
1168    }
1169
1170    output
1171}
1172
1173fn render_summary_row(
1174    builder: ReportBuilder,
1175    result: &crate::diagnostics::DiagnosticResult,
1176    config: &Config,
1177) -> ReportBuilder {
1178    let icon = config.status_chars(&result.status);
1179    let colored_icon = colorize_status(icon, &result.status, config);
1180    let label = format!("{} {}", colored_icon, result.category);
1181
1182    let lines: Vec<&str> = result.summary.split('\n').collect();
1183    let mut b = builder.row(&label, lines[0]);
1184    for line in lines.iter().skip(1) {
1185        b = b.row("", line);
1186    }
1187    b
1188}
1189
1190fn count_issues(results: &DiagnosticResults) -> (usize, usize) {
1191    let statuses = [
1192        &results.adapters.status,
1193        &results.interfaces.status,
1194        &results.gateway.status,
1195        &results.dns.status,
1196        &results.public_ip.status,
1197        &results.latency.status,
1198        &results.speed.status,
1199        &results.ports.status,
1200    ];
1201
1202    let fails = statuses
1203        .iter()
1204        .filter(|s| ***s == DiagnosticStatus::Fail)
1205        .count();
1206    let warns = statuses
1207        .iter()
1208        .filter(|s| ***s == DiagnosticStatus::Warn)
1209        .count();
1210    (fails, warns)
1211}
1212
1213fn format_overall(fails: usize, warns: usize, config: &Config) -> String {
1214    if fails > 0 && warns > 0 {
1215        let text = format!(
1216            "{} failure{}, {} warning{}",
1217            fails,
1218            if fails > 1 { "s" } else { "" },
1219            warns,
1220            if warns > 1 { "s" } else { "" }
1221        );
1222        colorize_status(&text, &DiagnosticStatus::Fail, config)
1223    } else if fails > 0 {
1224        let text = format!(
1225            "{} failure{} detected",
1226            fails,
1227            if fails > 1 { "s" } else { "" }
1228        );
1229        colorize_status(&text, &DiagnosticStatus::Fail, config)
1230    } else if warns > 0 {
1231        let text = format!(
1232            "{} warning{} detected",
1233            warns,
1234            if warns > 1 { "s" } else { "" }
1235        );
1236        colorize_status(&text, &DiagnosticStatus::Warn, config)
1237    } else {
1238        colorize_status("All diagnostics passed", &DiagnosticStatus::Ok, config)
1239    }
1240}
1241
1242fn format_link_speed(mbps: u64) -> String {
1243    if mbps >= 1000 {
1244        format!("{:.1} Gbps", mbps as f64 / 1000.0)
1245    } else {
1246        format!("{} Mbps", mbps)
1247    }
1248}
1249
1250fn format_bytes(bytes: u64) -> String {
1251    const KB: u64 = 1024;
1252    const MB: u64 = 1024 * KB;
1253    const GB: u64 = 1024 * MB;
1254
1255    if bytes >= GB {
1256        format!("{:.2} GB", bytes as f64 / GB as f64)
1257    } else if bytes >= MB {
1258        format!("{:.1} MB", bytes as f64 / MB as f64)
1259    } else if bytes >= KB {
1260        format!("{:.1} KB", bytes as f64 / KB as f64)
1261    } else {
1262        format!("{} B", bytes)
1263    }
1264}