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        output.push_str(&b.finish());
156        output.push('\n');
157    }
158
159    // DNS details
160    if let Some(ref dns) = results.dns_details {
161        let mut b = ReportBuilder::new(label_width, data_width, chars);
162        b = b.full_top_border().span_row("  DNS SERVERS").divider();
163
164        for server in &dns.servers {
165            b = b.row("Server", &server.address);
166        }
167
168        if let Some(ref test) = dns.resolution_test {
169            b = b.divider();
170            b = b.row("Test Domain", &test.domain);
171            b = b.row("Resolved", if test.resolved { "Yes" } else { "No" });
172            b = b.row("Time", &format!("{:.1}ms", test.resolution_time_ms));
173            for ip in &test.resolved_ips {
174                b = b.row("Resolved IP", ip);
175            }
176        }
177        output.push_str(&b.finish());
178        output.push('\n');
179    }
180
181    // Public IP details
182    if let Some(ref pip) = results.public_ip_details {
183        let mut b = ReportBuilder::new(label_width, data_width, chars);
184        b = b
185            .full_top_border()
186            .span_row("  PUBLIC IP & GEOLOCATION")
187            .divider();
188        b = b.row("Public IP", &pip.ip);
189        b = b.row("Lookup Time", &format!("{:.0}ms", pip.lookup_time_ms));
190        b = b.row("Behind NAT", if pip.behind_nat { "Yes" } else { "No" });
191
192        if let Some(ref city) = pip.city {
193            b = b.row("City", city);
194        }
195        if let Some(ref region) = pip.region {
196            b = b.row("Region", region);
197        }
198        if let Some(ref country) = pip.country {
199            b = b.row("Country", country);
200        }
201        if let Some(ref isp) = pip.isp {
202            b = b.row("ISP", isp);
203        }
204        if let Some(ref org) = pip.org {
205            b = b.row("Organization", org);
206        }
207        output.push_str(&b.finish());
208        output.push('\n');
209    }
210
211    // Latency details
212    if let Some(ref latencies) = results.latency_details {
213        let mut b = ReportBuilder::new(label_width, data_width, chars);
214        b = b.full_top_border().span_row("  LATENCY TESTS").divider();
215
216        for (i, lat) in latencies.iter().enumerate() {
217            if i > 0 {
218                b = b.divider();
219            }
220            b = b.row("Host", &format!("{} ({})", lat.host, lat.label));
221            b = b.row("Reachable", if lat.reachable { "Yes" } else { "No" });
222            if let Some(min) = lat.min_ms {
223                b = b.row("Min", &format!("{:.1}ms", min));
224            }
225            if let Some(avg) = lat.avg_ms {
226                b = b.row("Avg", &format!("{:.1}ms", avg));
227            }
228            if let Some(max) = lat.max_ms {
229                b = b.row("Max", &format!("{:.1}ms", max));
230            }
231            if let Some(jitter) = lat.jitter_ms {
232                b = b.row("Jitter", &format!("{:.1}ms", jitter));
233            }
234            b = b.row("Packet Loss", &format!("{:.0}%", lat.packet_loss));
235        }
236        output.push_str(&b.finish());
237        output.push('\n');
238    }
239
240    // Speed test details
241    if let Some(ref speed) = results.speed_details {
242        let mut b = ReportBuilder::new(label_width, data_width, chars);
243        b = b.full_top_border().span_row("  SPEED TEST").divider();
244
245        if let Some(ping) = speed.ping_ms {
246            b = b.row("Ping", &format!("{:.1} ms", ping));
247        }
248        if let Some(jitter) = speed.jitter_ms {
249            b = b.row("Jitter", &format!("{:.1} ms", jitter));
250        }
251        b = b.row(
252            "Download",
253            &format!(
254                "{} (avg)",
255                crate::speedtest::format_mbps(speed.download_mbps)
256            ),
257        );
258        b = b.row(
259            "Upload",
260            &format!("{} (avg)", crate::speedtest::format_mbps(speed.upload_mbps)),
261        );
262        if let Some(loss) = speed.packet_loss_pct {
263            b = b.row("Packet Loss", &format!("{:.0}%", loss));
264        }
265        b = b.row("Duration", &format!("{:.1}s", speed.duration_s));
266
267        // Per-provider breakdown
268        for provider in &speed.providers {
269            if provider.error.is_some() {
270                continue;
271            }
272            b = b.section_header(&provider.provider);
273            b = b.row("Server", &provider.server);
274            if let Some(ref location) = provider.location {
275                b = b.row("Location", location);
276            }
277            if let Some(dl) = provider.download_mbps {
278                b = b.row("Download", &crate::speedtest::format_mbps(dl));
279            }
280            if let Some(ul) = provider.upload_mbps {
281                b = b.row("Upload", &crate::speedtest::format_mbps(ul));
282            }
283            b = b.row("DL Data", &format_bytes(provider.download_bytes));
284            b = b.row("UL Data", &format_bytes(provider.upload_bytes));
285        }
286
287        output.push_str(&b.finish());
288        output.push('\n');
289    }
290
291    // Port details
292    if let Some(ref ports) = results.port_details {
293        let mut b = ReportBuilder::new(label_width, data_width, chars);
294        b = b
295            .full_top_border()
296            .span_row("  PORT CONNECTIVITY")
297            .divider();
298
299        for port in ports {
300            let status = if port.open { "Open" } else { "Blocked" };
301            let lat = port
302                .latency_ms
303                .map(|l| format!(" ({:.0}ms)", l))
304                .unwrap_or_default();
305            b = b.row(
306                &format!("{} ({})", port.service, port.port),
307                &format!("{}{}", status, lat),
308            );
309        }
310        output.push_str(&b.finish());
311        output.push('\n');
312    }
313
314    // == TECHNICIAN DEEP DIAGNOSTICS ==
315    if let Some(ref tech) = results.technician {
316        output.push_str(&render_technician_details(tech, config));
317    }
318
319    // Timestamp footer
320    output.push_str(&format!("  Report generated: {}\n", results.timestamp));
321    output.push('\n');
322
323    output
324}
325
326fn render_technician_details(tech: &TechnicianResults, config: &Config) -> String {
327    let label_width = 16;
328    let data_width = 40;
329    let chars = config.box_chars();
330    let mut output = String::new();
331
332    // ARP Table
333    if let Some(ref arp) = tech.arp_table {
334        if !arp.is_empty() {
335            let mut b = ReportBuilder::new(label_width, data_width, chars);
336            b = b.full_top_border().span_row("  ARP TABLE").divider();
337            for entry in arp.iter().take(30) {
338                b = b.row(&entry.ip, &format!("{} ({})", entry.mac, entry.entry_type));
339            }
340            if arp.len() > 30 {
341                b = b.row("", &format!("... and {} more", arp.len() - 30));
342            }
343            output.push_str(&b.finish());
344            output.push('\n');
345        }
346    }
347
348    // Routing Table
349    if let Some(ref routes) = tech.routing_table {
350        if !routes.is_empty() {
351            let mut b = ReportBuilder::new(label_width, data_width, chars);
352            b = b.full_top_border().span_row("  ROUTING TABLE").divider();
353            for route in routes.iter().take(20) {
354                let gw = if route.gateway.is_empty() {
355                    "direct".to_string()
356                } else {
357                    route.gateway.clone()
358                };
359                let metric = route
360                    .metric
361                    .map(|m| format!(" m:{}", m))
362                    .unwrap_or_default();
363                b = b.row(
364                    &route.destination,
365                    &format!("via {} dev {}{}", gw, route.interface, metric),
366                );
367            }
368            if routes.len() > 20 {
369                b = b.row("", &format!("... and {} more", routes.len() - 20));
370            }
371            output.push_str(&b.finish());
372            output.push('\n');
373        }
374    }
375
376    // Active Connections
377    if let Some(ref conns) = tech.active_connections {
378        if !conns.is_empty() {
379            let established: Vec<_> = conns
380                .iter()
381                .filter(|c| c.state == "ESTABLISHED")
382                .take(20)
383                .collect();
384            if !established.is_empty() {
385                let mut b = ReportBuilder::new(label_width, data_width, chars);
386                b = b
387                    .full_top_border()
388                    .span_row("  ACTIVE CONNECTIONS (ESTABLISHED)")
389                    .divider();
390                for conn in &established {
391                    let proc_info = conn.process_name.as_deref().unwrap_or("?");
392                    b = b.row(
393                        &conn.local_addr,
394                        &format!("{} [{}]", conn.remote_addr, proc_info),
395                    );
396                }
397                let total_established = conns.iter().filter(|c| c.state == "ESTABLISHED").count();
398                if total_established > 20 {
399                    b = b.row("", &format!("... and {} more", total_established - 20));
400                }
401                output.push_str(&b.finish());
402                output.push('\n');
403            }
404        }
405    }
406
407    // Listening Ports
408    if let Some(ref ports) = tech.listening_ports {
409        if !ports.is_empty() {
410            let mut b = ReportBuilder::new(label_width, data_width, chars);
411            b = b.full_top_border().span_row("  LISTENING PORTS").divider();
412            for port in ports.iter().take(20) {
413                let proc_info = port.process_name.as_deref().unwrap_or("?");
414                b = b.row(
415                    &format!("{} :{}", port.protocol, port.port),
416                    &format!("{} [{}]", port.address, proc_info),
417                );
418            }
419            if ports.len() > 20 {
420                b = b.row("", &format!("... and {} more", ports.len() - 20));
421            }
422            output.push_str(&b.finish());
423            output.push('\n');
424        }
425    }
426
427    // DHCP Info
428    if let Some(ref dhcp) = tech.dhcp_info {
429        if !dhcp.is_empty() {
430            let mut b = ReportBuilder::new(label_width, data_width, chars);
431            b = b.full_top_border().span_row("  DHCP LEASES").divider();
432            for (i, lease) in dhcp.iter().enumerate() {
433                if i > 0 {
434                    b = b.divider();
435                }
436                b = b.row("Interface", &lease.interface);
437                b = b.row(
438                    "DHCP Enabled",
439                    if lease.dhcp_enabled { "Yes" } else { "No" },
440                );
441                if let Some(ref server) = lease.dhcp_server {
442                    b = b.row("DHCP Server", server);
443                }
444                if let Some(ref ip) = lease.ip_address {
445                    b = b.row("IP Address", ip);
446                }
447                if let Some(ref obtained) = lease.lease_obtained {
448                    b = b.row("Obtained", obtained);
449                }
450                if let Some(ref expires) = lease.lease_expires {
451                    b = b.row("Expires", expires);
452                }
453            }
454            output.push_str(&b.finish());
455            output.push('\n');
456        }
457    }
458
459    // Protocol Statistics
460    if let Some(ref stats) = tech.protocol_stats {
461        let mut b = ReportBuilder::new(label_width, data_width, chars);
462        b = b
463            .full_top_border()
464            .span_row("  PROTOCOL STATISTICS")
465            .divider();
466
467        b = b.row("TCP Active Opens", &stats.tcp.active_opens.to_string());
468        b = b.row("TCP Passive Opens", &stats.tcp.passive_opens.to_string());
469        b = b.row("TCP Current", &stats.tcp.current_connections.to_string());
470        b = b.row("TCP Failed", &stats.tcp.failed_connections.to_string());
471        b = b.row("TCP Resets", &stats.tcp.reset_connections.to_string());
472        b = b.row(
473            "TCP Retransmits",
474            &stats.tcp.segments_retransmitted.to_string(),
475        );
476        b = b.row("TCP Segments In", &stats.tcp.segments_received.to_string());
477        b = b.row("TCP Segments Out", &stats.tcp.segments_sent.to_string());
478        b = b.divider();
479        b = b.row("UDP In", &stats.udp.datagrams_received.to_string());
480        b = b.row("UDP Out", &stats.udp.datagrams_sent.to_string());
481        b = b.row("UDP Errors", &stats.udp.receive_errors.to_string());
482        b = b.divider();
483        b = b.row("ICMP In", &stats.icmp.messages_received.to_string());
484        b = b.row("ICMP Out", &stats.icmp.messages_sent.to_string());
485        b = b.row("ICMP Errors In", &stats.icmp.errors_received.to_string());
486
487        output.push_str(&b.finish());
488        output.push('\n');
489    }
490
491    // Adapter HW Stats
492    if let Some(ref hw) = tech.adapter_hw_stats {
493        if !hw.is_empty() {
494            let mut b = ReportBuilder::new(label_width, data_width, chars);
495            b = b
496                .full_top_border()
497                .span_row("  ADAPTER HARDWARE STATS")
498                .divider();
499            for (i, stat) in hw.iter().enumerate() {
500                if i > 0 {
501                    b = b.divider();
502                }
503                b = b.row("Interface", &stat.name);
504                b = b.row("RX Bytes", &format_bytes(stat.rx_bytes));
505                b = b.row("TX Bytes", &format_bytes(stat.tx_bytes));
506                b = b.row("RX Packets", &stat.rx_packets.to_string());
507                b = b.row("TX Packets", &stat.tx_packets.to_string());
508                b = b.row("RX Errors", &stat.rx_errors.to_string());
509                b = b.row("TX Errors", &stat.tx_errors.to_string());
510                if let Some(ref speed) = stat.link_speed {
511                    b = b.row("Link Speed", speed);
512                }
513                if let Some(ref dup) = stat.duplex {
514                    b = b.row("Duplex", dup);
515                }
516            }
517            output.push_str(&b.finish());
518            output.push('\n');
519        }
520    }
521
522    // Proxy Config
523    if let Some(ref proxy) = tech.proxy_config {
524        let mut b = ReportBuilder::new(label_width, data_width, chars);
525        b = b
526            .full_top_border()
527            .span_row("  PROXY CONFIGURATION")
528            .divider();
529        b = b.row(
530            "Proxy Enabled",
531            if proxy.proxy_enabled { "Yes" } else { "No" },
532        );
533        if let Some(ref http) = proxy.http_proxy {
534            b = b.row("HTTP Proxy", http);
535        }
536        if let Some(ref https) = proxy.https_proxy {
537            b = b.row("HTTPS Proxy", https);
538        }
539        if let Some(ref socks) = proxy.socks_proxy {
540            b = b.row("SOCKS Proxy", socks);
541        }
542        if let Some(ref pac) = proxy.pac_url {
543            b = b.row("PAC URL", pac);
544        }
545        if let Some(ref no) = proxy.no_proxy {
546            b = b.row("No Proxy", no);
547        }
548        output.push_str(&b.finish());
549        output.push('\n');
550    }
551
552    // VPN Detection
553    if let Some(ref vpns) = tech.vpn_info {
554        let mut b = ReportBuilder::new(label_width, data_width, chars);
555        b = b.full_top_border().span_row("  VPN DETECTION").divider();
556        for (i, vpn) in vpns.iter().enumerate() {
557            if i > 0 {
558                b = b.divider();
559            }
560            b = b.row("VPN Adapter", &vpn.name);
561            b = b.row("Type", &vpn.adapter_type);
562            b = b.row("Status", &vpn.status);
563            if let Some(ref vendor) = vpn.vendor {
564                b = b.row("Vendor", vendor);
565            }
566            if vpn.is_enterprise {
567                b = b.row("Policy", "Enterprise/Managed");
568            }
569            if let Some(ref iface) = vpn.interface_name {
570                b = b.row("Interface", iface);
571            }
572            if let Some(ref ip) = vpn.ip_address {
573                b = b.row("IP Address", ip);
574            }
575        }
576        output.push_str(&b.finish());
577        output.push('\n');
578    }
579
580    // Firewall Summary
581    if let Some(ref fw) = tech.firewall_info {
582        let mut b = ReportBuilder::new(label_width, data_width, chars);
583        b = b.full_top_border().span_row("  FIREWALL STATUS").divider();
584        b = b.row("Status", &fw.summary);
585        for profile in &fw.profiles {
586            b = b.row(
587                &profile.name,
588                if profile.enabled {
589                    "Enabled"
590                } else {
591                    "Disabled"
592                },
593            );
594        }
595        output.push_str(&b.finish());
596        output.push('\n');
597    }
598
599    // DNS Cache
600    if let Some(ref cache) = tech.dns_cache {
601        if !cache.is_empty() {
602            let mut b = ReportBuilder::new(label_width, data_width, chars);
603            b = b.full_top_border().span_row("  DNS CACHE").divider();
604            for entry in cache.iter().take(20) {
605                b = b.row(
606                    &entry.name,
607                    &format!("{}: {}", entry.record_type, entry.data),
608                );
609            }
610            if cache.len() > 20 {
611                b = b.row("", &format!("... and {} more entries", cache.len() - 20));
612            }
613            output.push_str(&b.finish());
614            output.push('\n');
615        }
616    }
617
618    // IPv6 Status
619    if let Some(ref ipv6) = tech.ipv6_info {
620        let mut b = ReportBuilder::new(label_width, data_width, chars);
621        b = b.full_top_border().span_row("  IPv6 STATUS").divider();
622        b = b.row("Available", if ipv6.available { "Yes" } else { "No" });
623        let conn_str = match ipv6.connectivity {
624            crate::diagnostics::ipv6::Ipv6Connectivity::Full => "Full",
625            crate::diagnostics::ipv6::Ipv6Connectivity::LinkLocal => "Link-local only",
626            crate::diagnostics::ipv6::Ipv6Connectivity::None => "None",
627        };
628        b = b.row("Connectivity", conn_str);
629        b = b.row("Dual Stack", if ipv6.dual_stack { "Yes" } else { "No" });
630
631        for addr in ipv6.addresses.iter().take(10) {
632            b = b.row(
633                &addr.scope,
634                &format!("{} ({})", addr.address, addr.interface),
635            );
636        }
637        output.push_str(&b.finish());
638        output.push('\n');
639    }
640
641    // MTU Info
642    if let Some(ref mtus) = tech.mtu_info {
643        if !mtus.is_empty() {
644            let mut b = ReportBuilder::new(label_width, data_width, chars);
645            b = b
646                .full_top_border()
647                .span_row("  MTU PER INTERFACE")
648                .divider();
649            for mtu in mtus {
650                b = b.row(&mtu.interface, &mtu.mtu.to_string());
651            }
652            output.push_str(&b.finish());
653            output.push('\n');
654        }
655    }
656
657    // Connection States
658    if let Some(ref states) = tech.connection_states {
659        let mut b = ReportBuilder::new(label_width, data_width, chars);
660        b = b
661            .full_top_border()
662            .span_row("  TCP CONNECTION STATES")
663            .divider();
664        b = b.row("ESTABLISHED", &states.established.to_string());
665        b = b.row("TIME_WAIT", &states.time_wait.to_string());
666        b = b.row("CLOSE_WAIT", &states.close_wait.to_string());
667        b = b.row("FIN_WAIT_1", &states.fin_wait_1.to_string());
668        b = b.row("FIN_WAIT_2", &states.fin_wait_2.to_string());
669        b = b.row("SYN_SENT", &states.syn_sent.to_string());
670        b = b.row("SYN_RECEIVED", &states.syn_received.to_string());
671        b = b.row("CLOSING", &states.closing.to_string());
672        b = b.row("LAST_ACK", &states.last_ack.to_string());
673        b = b.row("LISTEN", &states.listen.to_string());
674        output.push_str(&b.finish());
675        output.push('\n');
676    }
677
678    // Bufferbloat
679    if let Some(ref bb) = tech.bufferbloat {
680        let mut b = ReportBuilder::new(label_width, data_width, chars);
681        b = b.full_top_border().span_row("  BUFFERBLOAT TEST").divider();
682        b = b.row("Grade", &bb.grade);
683        b = b.row(
684            "Unloaded Latency",
685            &format!("{:.1}ms", bb.unloaded_latency_ms),
686        );
687        if let Some(loaded) = bb.loaded_latency_ms {
688            b = b.row("Loaded Latency", &format!("{:.1}ms", loaded));
689        }
690        if let Some(bloat) = bb.bloat_ms {
691            b = b.row("Bloat", &format!("+{:.1}ms", bloat));
692        }
693        b = b.row("Assessment", &bb.description);
694        output.push_str(&b.finish());
695        output.push('\n');
696    }
697
698    // Reverse DNS
699    if let Some(ref rdns) = tech.reverse_dns {
700        if !rdns.is_empty() {
701            let mut b = ReportBuilder::new(label_width, data_width, chars);
702            b = b.full_top_border().span_row("  REVERSE DNS").divider();
703            for entry in rdns {
704                let hostname = entry.hostname.as_deref().unwrap_or("(no PTR)");
705                b = b.row(&format!("{} ({})", entry.label, entry.ip), hostname);
706            }
707            output.push_str(&b.finish());
708            output.push('\n');
709        }
710    }
711
712    // TLS Inspection
713    if let Some(ref tls) = tech.tls_inspection {
714        let mut b = ReportBuilder::new(label_width, data_width, chars);
715        b = b
716            .full_top_border()
717            .span_row("  TLS INSPECTION CHECK")
718            .divider();
719        b = b.row("Intercepted", if tls.detected { "DETECTED" } else { "No" });
720        b = b.row("Assessment", &tls.description);
721        output.push_str(&b.finish());
722        output.push('\n');
723    }
724
725    // Traffic Counters
726    if let Some(ref traffic) = tech.traffic_counters {
727        if !traffic.is_empty() {
728            let mut b = ReportBuilder::new(label_width, data_width, chars);
729            b = b
730                .full_top_border()
731                .span_row("  TRAFFIC COUNTERS (since boot)")
732                .divider();
733            for counter in traffic {
734                b = b.row(
735                    &counter.interface,
736                    &format!("RX {} / TX {}", counter.rx_formatted, counter.tx_formatted),
737                );
738            }
739            output.push_str(&b.finish());
740            output.push('\n');
741        }
742    }
743
744    output
745}
746
747fn render_summary_row(
748    builder: ReportBuilder,
749    result: &crate::diagnostics::DiagnosticResult,
750    config: &Config,
751) -> ReportBuilder {
752    let icon = config.status_chars(&result.status);
753    let colored_icon = colorize_status(icon, &result.status, config);
754    let label = format!("{} {}", colored_icon, result.category);
755
756    let lines: Vec<&str> = result.summary.split('\n').collect();
757    let mut b = builder.row(&label, lines[0]);
758    for line in lines.iter().skip(1) {
759        b = b.row("", line);
760    }
761    b
762}
763
764fn count_issues(results: &DiagnosticResults) -> (usize, usize) {
765    let statuses = [
766        &results.adapters.status,
767        &results.interfaces.status,
768        &results.gateway.status,
769        &results.dns.status,
770        &results.public_ip.status,
771        &results.latency.status,
772        &results.speed.status,
773        &results.ports.status,
774    ];
775
776    let fails = statuses
777        .iter()
778        .filter(|s| ***s == DiagnosticStatus::Fail)
779        .count();
780    let warns = statuses
781        .iter()
782        .filter(|s| ***s == DiagnosticStatus::Warn)
783        .count();
784    (fails, warns)
785}
786
787fn format_overall(fails: usize, warns: usize, config: &Config) -> String {
788    if fails > 0 && warns > 0 {
789        let text = format!(
790            "{} failure{}, {} warning{}",
791            fails,
792            if fails > 1 { "s" } else { "" },
793            warns,
794            if warns > 1 { "s" } else { "" }
795        );
796        colorize_status(&text, &DiagnosticStatus::Fail, config)
797    } else if fails > 0 {
798        let text = format!(
799            "{} failure{} detected",
800            fails,
801            if fails > 1 { "s" } else { "" }
802        );
803        colorize_status(&text, &DiagnosticStatus::Fail, config)
804    } else if warns > 0 {
805        let text = format!(
806            "{} warning{} detected",
807            warns,
808            if warns > 1 { "s" } else { "" }
809        );
810        colorize_status(&text, &DiagnosticStatus::Warn, config)
811    } else {
812        colorize_status("All diagnostics passed", &DiagnosticStatus::Ok, config)
813    }
814}
815
816fn format_link_speed(mbps: u64) -> String {
817    if mbps >= 1000 {
818        format!("{:.1} Gbps", mbps as f64 / 1000.0)
819    } else {
820        format!("{} Mbps", mbps)
821    }
822}
823
824fn format_bytes(bytes: u64) -> String {
825    const KB: u64 = 1024;
826    const MB: u64 = 1024 * KB;
827    const GB: u64 = 1024 * MB;
828
829    if bytes >= GB {
830        format!("{:.2} GB", bytes as f64 / GB as f64)
831    } else if bytes >= MB {
832        format!("{:.1} MB", bytes as f64 / MB as f64)
833    } else if bytes >= KB {
834        format!("{:.1} KB", bytes as f64 / KB as f64)
835    } else {
836        format!("{} B", bytes)
837    }
838}