netscli 0.3.1

Interactive TUI and CLI for network discovery, scanning, and diagnostics
use super::common::{fit_cell, tree_child_line, tree_prefix};
use super::Formatter;
use netscli_core::scan::{PortResult, PortStatus};
use ratatui::style::{Color, Style};
use ratatui::text::{Line, Span};
use std::net::IpAddr;

impl Formatter {
    pub fn format_scan_results(results: &[PortResult]) -> Vec<Line<'static>> {
        let mut lines = Vec::new();
        lines.push(Line::from(vec![
            Span::styled("Ports", Style::default().fg(Color::Cyan)),
            Span::styled(" (scan results)", Style::default().fg(Color::DarkGray)),
        ]));

        if results.is_empty() {
            lines.push(Line::from(Span::styled(
                "No ports scanned.",
                Style::default().fg(Color::DarkGray),
            )));
            return lines;
        }

        for (idx, port) in results.iter().enumerate() {
            let (state_color, state) = match port.status {
                PortStatus::Open => (Color::Green, "open"),
                PortStatus::Closed => (Color::Red, "closed"),
                PortStatus::Filtered => (Color::Yellow, "filtered"),
                PortStatus::Error => (Color::Red, "error"),
            };
            let is_last = idx + 1 == results.len();
            let mut spans: Vec<Span<'static>> = vec![
                Span::styled(tree_prefix(is_last), Style::default().fg(Color::DarkGray)),
                Span::styled(port.port.to_string(), Style::default().fg(Color::White)),
                Span::styled(" ", Style::default().fg(Color::DarkGray)),
                Span::styled(state, Style::default().fg(state_color)),
            ];
            if let Some(latency_ms) = port.latency_ms {
                spans.push(Span::styled("  ", Style::default().fg(Color::DarkGray)));
                spans.push(Span::styled(
                    format!("{latency_ms}ms"),
                    Style::default().fg(Color::Gray),
                ));
            } else if matches!(port.status, PortStatus::Filtered) {
                spans.push(Span::styled("  ", Style::default().fg(Color::DarkGray)));
                spans.push(Span::styled("timeout", Style::default().fg(Color::Yellow)));
            }
            if let Some(service) = &port.service {
                spans.push(Span::styled("  ", Style::default().fg(Color::DarkGray)));
                spans.push(Span::styled(
                    service.to_string(),
                    Style::default().fg(Color::Gray),
                ));
            }
            if let Some(banner) = &port.banner {
                spans.push(Span::styled("  ", Style::default().fg(Color::DarkGray)));
                spans.push(Span::styled(
                    fit_cell(banner, 42),
                    Style::default().fg(Color::DarkGray),
                ));
            }
            lines.push(Line::from(spans));
        }
        lines
    }

    pub fn format_scan_target_card(
        ip: IpAddr,
        hostname: Option<String>,
        mac: Option<String>,
        vendor: Option<String>,
        has_extra_child: bool,
    ) -> Vec<Line<'static>> {
        let hostname = hostname.unwrap_or_else(|| "-".to_string());
        let mac = mac.unwrap_or_else(|| "-".to_string());
        let vendor = vendor.unwrap_or_else(|| "-".to_string());

        let lines = vec![
            Line::from(vec![
                Span::styled("host ", Style::default().fg(Color::DarkGray)),
                Span::styled(ip.to_string(), Style::default().fg(Color::Green)),
            ]),
            tree_child_line("mac", &mac, false, Color::Yellow),
            tree_child_line("vendor", &vendor, false, Color::Blue),
            tree_child_line("name", &hostname, !has_extra_child, Color::Gray),
        ];
        lines
    }

    pub fn format_sweep_summary(
        subnet: &str,
        ports: &[u16],
        total_hosts: usize,
        hosts_with_open_ports: usize,
    ) -> Vec<Line<'static>> {
        let ports = ports
            .iter()
            .map(|p| p.to_string())
            .collect::<Vec<_>>()
            .join(", ");

        vec![
            Line::from(vec![
                Span::styled("Swept subnet ", Style::default().fg(Color::Green)),
                Span::styled(subnet.to_string(), Style::default().fg(Color::Cyan)),
            ]),
            tree_child_line("ports", &ports, false, Color::Gray),
            tree_child_line(
                "hosts",
                &format!("{total_hosts} • open {hosts_with_open_ports}"),
                true,
                Color::Gray,
            ),
            Line::default(),
        ]
    }

    pub fn format_open_ports_inline(open_ports: &[PortResult]) -> Line<'static> {
        if open_ports.is_empty() {
            return tree_child_line("open ports", "none", true, Color::DarkGray);
        }

        let mut spans: Vec<Span<'static>> = vec![
            Span::styled(tree_prefix(true), Style::default().fg(Color::DarkGray)),
            Span::styled("open ports ", Style::default().fg(Color::DarkGray)),
        ];
        for (i, p) in open_ports.iter().enumerate() {
            if i > 0 {
                spans.push(Span::raw(", "));
            }
            spans.push(Span::styled(
                p.port.to_string(),
                Style::default().fg(Color::White),
            ));
            if let Some(service) = &p.service {
                spans.push(Span::styled(
                    format!(" ({service})"),
                    Style::default().fg(Color::DarkGray),
                ));
            }
        }
        Line::from(spans)
    }
}