isitme 0.6.0

Is it me? Diagnose whether your network is the problem in a call (Zoom, Teams, Meet).
//! Rendering: progress bars, final table, colored verdict.
//!
//! Tables are hand-rolled to avoid the `tabled` proc-macro chain, which
//! transitively pulls in `proc-macro-error2` (archived upstream, emits a
//! future-compat warning). Our schema is fixed, so a tiny formatter
//! suffices.

use owo_colors::OwoColorize;

use crate::verdict::{PingStats, SpeedStats, Status, Verdict, VerdictLabel};

fn fmt_ms(opt: Option<f64>) -> String {
    match opt {
        Some(v) => format!("{:.1}", v),
        None => "-".to_string(),
    }
}

fn fmt_mbps(opt: Option<f64>) -> String {
    match opt {
        Some(v) => format!("{:.1}", v),
        None => "-".to_string(),
    }
}

/// Render a fixed-schema table with rounded Unicode box borders.
///
/// `headers` and every row must have the same length. Cells are
/// left-aligned and single-space-padded. No inter-row separators.
fn render_table(headers: &[&str], rows: &[Vec<String>]) -> String {
    let cols = headers.len();
    let widths: Vec<usize> = (0..cols)
        .map(|c| {
            let header_w = headers[c].chars().count();
            let row_w = rows.iter().map(|r| r[c].chars().count()).max().unwrap_or(0);
            header_w.max(row_w)
        })
        .collect();

    let pad = |c: &str, w: usize| -> String {
        let len = c.chars().count();
        let mut s = String::with_capacity(w + 2);
        s.push(' ');
        s.push_str(c);
        s.push_str(&" ".repeat(w.saturating_sub(len)));
        s.push(' ');
        s
    };

    let mut out = String::new();

    // Top border: ╭─...─┬─...─╮
    out.push('');
    out.push_str(
        &widths
            .iter()
            .map(|w| "".repeat(w + 2))
            .collect::<Vec<_>>()
            .join(""),
    );
    out.push('');
    out.push('\n');

    // Header row.
    out.push('');
    out.push_str(
        &headers
            .iter()
            .zip(widths.iter())
            .map(|(h, w)| pad(h, *w))
            .collect::<Vec<_>>()
            .join(""),
    );
    out.push('');
    out.push('\n');

    // Header separator: ├─...─┼─...─┤
    out.push('');
    out.push_str(
        &widths
            .iter()
            .map(|w| "".repeat(w + 2))
            .collect::<Vec<_>>()
            .join(""),
    );
    out.push('');
    out.push('\n');

    // Data rows.
    for row in rows {
        out.push('');
        out.push_str(
            &row.iter()
                .zip(widths.iter())
                .map(|(cell, w)| pad(cell, *w))
                .collect::<Vec<_>>()
                .join(""),
        );
        out.push('');
        out.push('\n');
    }

    // Bottom border: ╰─...─┴─...─╯
    out.push('');
    out.push_str(
        &widths
            .iter()
            .map(|w| "".repeat(w + 2))
            .collect::<Vec<_>>()
            .join(""),
    );
    out.push('');

    out
}

/// One compact diagnostic table: per-target latency (avg, jitter, loss)
/// with speed folded in as a final row. Min/max are dropped; avg is the
/// only latency number that matters for a quick gut check during a call.
pub fn diagnostic_table(pings: &[PingStats], speed: Option<&SpeedStats>) -> String {
    let headers = ["target", "latency", "jitter", "loss", "speed"];
    let mut rows: Vec<Vec<String>> = pings
        .iter()
        .map(|p| {
            vec![
                crate::targets::label_for(&p.target).to_string(),
                fmt_ms(p.avg_ms()),
                fmt_ms(p.jitter_ms()),
                format!("{:.0}%", p.loss_pct()),
                "-".to_string(),
            ]
        })
        .collect();
    if let Some(s) = speed {
        rows.push(vec![
            "Internet (CF)".to_string(),
            "-".to_string(),
            "-".to_string(),
            "-".to_string(),
            format!(
                "{} ↓ / {}",
                fmt_mbps(s.download_mbps),
                fmt_mbps(s.upload_mbps),
            ),
        ]);
    }
    render_table(&headers, &rows)
}

/// Big prominent banner: giant FIGlet verdict headline, the funny
/// one-liner reason below it. `worst` shades the Clear case green (all
/// OK) vs yellow (usable with warnings).
///
/// Uses the standard FIGlet font (via figlet-rs) for legible, proven
/// letterforms. Pure text generation: pipe-safe, no TUI event loop.
pub fn verdict_banner(verdict: &Verdict, worst: Status) -> String {
    use VerdictLabel::*;
    let (text, color): (&str, fn(&str) -> String) = match verdict.label {
        You => ("IT IS YOU", |s: &str| s.red().bold().to_string()),
        Vendor => ("IT IS THEM", |s: &str| s.yellow().bold().to_string()),
        Clear => match worst {
            Status::Ok => ("IT IS NOT YOU", |s: &str| s.green().bold().to_string()),
            _ => ("IT IS NOT YOU", |s: &str| s.yellow().bold().to_string()),
        },
    };
    let art = figlet_banner(text).unwrap_or_else(|| text.to_string());
    let colored: String = art
        .lines()
        .map(|line| format!("{}\n", color(line)))
        .collect();
    format!("{}\n  {}\n", colored, verdict.reason)
}

/// Render `text` with the big FIGlet font. The big font's taller
/// glyphs (7 rows) read clearly even to OCR/vision models, where the
/// standard font's thinner strokes can be ambiguous.
fn figlet_banner(text: &str) -> Option<String> {
    let font = figlet_rs::FIGlet::big().ok()?;
    let figure = font.convert(text)?;
    Some(figure.as_str())
}

#[cfg(test)]
mod tests {
    use super::*;
    use pretty_assertions::assert_eq;
    use crate::verdict::PingStats;

    fn stats(target: &str, samples: &[f64], sent: u32, recv: u32) -> PingStats {
        PingStats {
            target: target.to_string(),
            samples_ms: samples.to_vec(),
            packets_sent: sent,
            packets_received: recv,
        }
    }

    #[test]
    fn fmt_ms_handles_some_and_none() {
        assert_eq!(fmt_ms(Some(12.34)), "12.3");
        assert_eq!(fmt_ms(None), "-");
    }

    #[test]
    fn fmt_mbps_handles_some_and_none() {
        assert_eq!(fmt_mbps(Some(50.67)), "50.7");
        assert_eq!(fmt_mbps(None), "-");
    }

    #[test]
    fn render_table_empty_rows_has_header_only() {
        let t = render_table(&["a", "b"], &[]);
        assert!(t.contains(''));
        assert!(t.contains(''));
        assert!(t.contains("│ a │ b │"));
    }

    #[test]
    fn render_table_aligns_to_widest_cell() {
        let t = render_table(&["h", "name"], &[vec!["x".to_string(), "longvalue".to_string()]]);
        // Second column width = max("name", "longvalue") = "longvalue" (9).
        assert!(t.contains("│ longvalue │"));
        // Header padded to same width.
        assert!(t.contains("│ name      │"));
    }

    #[test]
    fn render_table_handles_unicode_width() {
        // Multibyte char counts as 1 char, consistent with our counting.
        let t = render_table(&["h"], &[vec!["é".to_string()]]);
        assert!(t.contains("│ é │"));
    }

    #[test]
    fn diagnostic_table_shows_label_and_loss() {
        let pings = vec![stats("8.8.8.8", &[10.0, 20.0], 2, 2)];
        let t = diagnostic_table(&pings, None);
        // Label resolves to the human form, not the raw host.
        assert!(t.contains("Google DNS (baseline)"));
        assert!(t.contains("0%"));
    }

    #[test]
    fn diagnostic_table_has_five_headers() {
        let pings = vec![stats("8.8.8.8", &[10.0, 20.0], 2, 2)];
        let t = diagnostic_table(&pings, None);
        for h in ["target", "latency", "jitter", "loss", "speed"] {
            assert!(t.contains(h), "missing header {}", h);
        }
    }

    #[test]
    fn diagnostic_table_appends_speed_row_when_given() {
        let pings = vec![stats("zoom.us", &[10.0, 20.0], 2, 2)];
        let s = SpeedStats {
            download_mbps: Some(50.0),
            upload_mbps: Some(10.0),
        };
        let t = diagnostic_table(&pings, Some(&s));
        assert!(t.contains("Internet (CF)"));
        assert!(t.contains("50.0"));
        assert!(t.contains("10.0"));
        assert!(t.contains(""));
        assert!(t.contains(""));
    }

    #[test]
    fn diagnostic_table_speed_dash_when_no_speed() {
        let pings = vec![stats("8.8.8.8", &[10.0, 20.0], 2, 2)];
        let t = diagnostic_table(&pings, None);
        // No speed row appended.
        assert!(!t.contains("Internet (CF)"));
    }

    #[test]
    fn verdict_banner_shows_headline_words_and_reason() {
        let v = Verdict {
            label: VerdictLabel::Clear,
            reason: "test reason here".to_string(),
        };
        let banner = verdict_banner(&v, Status::Ok);
        let stripped = strip_ansi(&banner);
        // Figlet art uses underscore/pipe chars; the reason follows.
        assert!(stripped.contains('_') || stripped.contains('|'));
        assert!(stripped.contains("test reason here"));
    }

    #[test]
    fn verdict_banner_you_when_label_you() {
        let v = Verdict {
            label: VerdictLabel::You,
            reason: "your network".to_string(),
        };
        let banner = verdict_banner(&v, Status::Bad);
        let stripped = strip_ansi(&banner);
        assert!(stripped.contains('_') || stripped.contains('|'));
        assert!(stripped.contains("your network"));
    }

    #[test]
    fn verdict_banner_vendor_when_label_vendor() {
        let v = Verdict {
            label: VerdictLabel::Vendor,
            reason: "zoom down".to_string(),
        };
        let banner = verdict_banner(&v, Status::Bad);
        let stripped = strip_ansi(&banner);
        assert!(stripped.contains('_') || stripped.contains('|'));
        assert!(stripped.contains("zoom down"));
    }

    fn strip_ansi(s: &str) -> String {
        let mut out = String::new();
        let mut in_esc = false;
        for c in s.chars() {
            if c == '\x1b' {
                in_esc = true;
            } else if in_esc && c == 'm' {
                in_esc = false;
            } else if !in_esc {
                out.push(c);
            }
        }
        out
    }
}