shohei 0.1.0

Next-generation DNS diagnostic CLI: visualize DNSSEC chain-of-trust, DoH/DoT, and iterative resolution paths in the terminal
Documentation
use ratatui::{
    Frame,
    layout::{Constraint, Direction, Layout},
    style::{Color, Modifier, Style},
    text::{Line, Span, Text},
    widgets::{Block, Borders, Paragraph, Wrap},
};

use crate::display::table::format_record_data;
use crate::resolver::TrustState;
use crate::resolver::iterative::StepResponseType;

use super::app::{App, View};

fn trust_color(state: &TrustState) -> Color {
    match state {
        TrustState::Secure => Color::Green,
        TrustState::Insecure => Color::Yellow,
        TrustState::Bogus => Color::Red,
        TrustState::Indeterminate => Color::DarkGray,
    }
}

fn trust_label(state: &TrustState) -> &'static str {
    match state {
        TrustState::Secure => "✓ SECURE",
        TrustState::Insecure => "⚠ INSECURE",
        TrustState::Bogus => "✗ BOGUS",
        TrustState::Indeterminate => "? INDETERMINATE",
    }
}

pub fn draw(frame: &mut Frame, app: &App) {
    let chunks = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Length(1),
            Constraint::Min(0),
            Constraint::Length(1),
        ])
        .split(frame.area());

    let title = Paragraph::new(Line::from(vec![
        Span::styled(
            " shohei ",
            Style::default()
                .fg(Color::Cyan)
                .add_modifier(Modifier::BOLD),
        ),
        Span::raw(""),
        Span::styled(
            app.domain.clone(),
            Style::default()
                .fg(Color::White)
                .add_modifier(Modifier::BOLD),
        ),
    ]));
    frame.render_widget(title, chunks[0]);

    let block_title = match app.view {
        View::Records => " Records ",
        View::Dnssec => " DNSSEC Chain of Trust ",
        View::Trace => " Iterative Resolution Trace ",
    };
    let content = match app.view {
        View::Records => render_records(app),
        View::Dnssec => render_dnssec(app),
        View::Trace => render_trace(app),
    };
    let block = Block::default()
        .borders(Borders::ALL)
        .title(Span::styled(
            block_title,
            Style::default().fg(Color::Cyan),
        ));
    let paragraph = Paragraph::new(content)
        .block(block)
        .wrap(Wrap { trim: false })
        .scroll((app.scroll, 0));
    frame.render_widget(paragraph, chunks[1]);

    let active = Style::default()
        .fg(Color::White)
        .add_modifier(Modifier::BOLD);
    let dim = Style::default().fg(Color::DarkGray);
    let status = Paragraph::new(Line::from(vec![
        Span::raw(" "),
        Span::styled(
            "[r] Records",
            if app.view == View::Records { active } else { dim },
        ),
        Span::raw("  "),
        Span::styled(
            "[d] DNSSEC",
            if app.view == View::Dnssec { active } else { dim },
        ),
        Span::raw("  "),
        Span::styled(
            "[t] Trace",
            if app.view == View::Trace { active } else { dim },
        ),
        Span::styled("  [↑↓/jk] Scroll  [q] Quit", dim),
    ]));
    frame.render_widget(status, chunks[2]);
}

fn render_records(app: &App) -> Text<'static> {
    let result = &app.records;
    let mut lines: Vec<Line<'static>> = Vec::new();

    lines.push(Line::from(vec![
        Span::styled("Query: ", Style::default().fg(Color::DarkGray)),
        Span::styled(
            result.query.name.clone(),
            Style::default().fg(Color::White),
        ),
        Span::styled(
            format!(" ({} {})", result.query.record_type, result.query.class),
            Style::default().fg(Color::DarkGray),
        ),
    ]));
    lines.push(Line::default());

    if result.answers.is_empty() {
        lines.push(Line::from(Span::styled(
            "No records found.",
            Style::default().fg(Color::DarkGray),
        )));
        return Text::from(lines);
    }

    lines.push(Line::from(Span::styled(
        format!("{:<38} {:>6}  {:<8} DATA", "NAME", "TTL", "TYPE"),
        Style::default()
            .fg(Color::Cyan)
            .add_modifier(Modifier::BOLD),
    )));
    lines.push(Line::from(Span::styled(
        "".repeat(72),
        Style::default().fg(Color::DarkGray),
    )));

    for record in &result.answers {
        let data_str = format_record_data(&record.data);
        let tc = trust_color(&record.trust);
        let tl = trust_label(&record.trust);
        lines.push(Line::from(vec![
            Span::styled(
                format!(
                    "{:<38} {:>6}  {:<8} ",
                    record.name, record.ttl, record.record_type
                ),
                Style::default().fg(Color::White),
            ),
            Span::styled(data_str, Style::default().fg(Color::White)),
            Span::raw("  "),
            Span::styled(tl, Style::default().fg(tc)),
        ]));
    }

    lines.push(Line::default());
    lines.push(Line::from(Span::styled(
        format!(
            "Resolved in {}ms via {}",
            result.duration_ms, result.server_addr
        ),
        Style::default().fg(Color::DarkGray),
    )));

    Text::from(lines)
}

fn render_dnssec(app: &App) -> Text<'static> {
    let chain = &app.dnssec;
    let mut lines: Vec<Line<'static>> = Vec::new();

    let oc = trust_color(&chain.overall);
    let ol = trust_label(&chain.overall);
    lines.push(Line::from(vec![
        Span::styled("Domain: ", Style::default().fg(Color::DarkGray)),
        Span::styled(
            chain.domain.clone(),
            Style::default()
                .fg(Color::White)
                .add_modifier(Modifier::BOLD),
        ),
        Span::raw("  "),
        Span::styled(ol, Style::default().fg(oc).add_modifier(Modifier::BOLD)),
    ]));
    lines.push(Line::default());

    for (i, step) in chain.steps.iter().enumerate() {
        let indent = if i == 0 {
            String::new()
        } else {
            "  ".to_string()
        };
        let connector = if i == 0 { "" } else { "└─ " };
        let sc = trust_color(&step.status);
        let sl = trust_label(&step.status);
        lines.push(Line::from(vec![
            Span::raw(format!("{indent}{connector}")),
            Span::styled(sl, Style::default().fg(sc)),
            Span::raw(" "),
            Span::styled(
                format!("[{}]", step.step_type),
                Style::default().fg(Color::DarkGray),
            ),
            Span::raw(" "),
            Span::styled(
                step.label.clone(),
                Style::default()
                    .fg(Color::White)
                    .add_modifier(Modifier::BOLD),
            ),
            Span::styled(
                format!("{}", step.detail),
                Style::default().fg(Color::DarkGray),
            ),
        ]));
    }

    Text::from(lines)
}

fn render_trace(app: &App) -> Text<'static> {
    let trace = &app.trace;
    let mut lines: Vec<Line<'static>> = Vec::new();

    lines.push(Line::from(vec![
        Span::styled("Trace: ", Style::default().fg(Color::DarkGray)),
        Span::styled(
            trace.record_type.clone(),
            Style::default().fg(Color::Cyan),
        ),
        Span::raw(" "),
        Span::styled(
            trace.target.clone(),
            Style::default()
                .fg(Color::White)
                .add_modifier(Modifier::BOLD),
        ),
    ]));
    lines.push(Line::default());

    for (i, step) in trace.steps.iter().enumerate() {
        let (status_text, status_color) = match &step.response_type {
            StepResponseType::Answer => ("✓ ANSWER", Color::Green),
            StepResponseType::Referral => ("→ REFERRAL", Color::Cyan),
            StepResponseType::Nxdomain => ("✗ NXDOMAIN", Color::Red),
            StepResponseType::Error(_) => ("✗ ERROR", Color::Red),
        };
        let indent = "  ".repeat(i);

        lines.push(Line::from(vec![
            Span::raw(indent.clone()),
            Span::styled(
                format!("[{}] ", status_text),
                Style::default().fg(status_color),
            ),
            Span::styled(step.server_name.clone(), Style::default().fg(Color::White)),
            Span::styled(
                format!(" @ {}", step.server_addr),
                Style::default().fg(Color::DarkGray),
            ),
            Span::styled(
                format!(" ({}ms)", step.duration_ms),
                Style::default().fg(Color::DarkGray),
            ),
        ]));

        if let Some(refs) = &step.referral_to {
            lines.push(Line::from(vec![
                Span::raw(format!("{indent}    ")),
                Span::styled("→ Referred to: ", Style::default().fg(Color::DarkGray)),
                Span::styled(refs.join(", "), Style::default().fg(Color::Yellow)),
            ]));
        }
    }

    if let Some(msg) = &trace.truncated {
        lines.push(Line::default());
        lines.push(Line::from(Span::styled(
            format!("{msg}"),
            Style::default().fg(Color::Yellow),
        )));
    }

    Text::from(lines)
}