acorn-tui 0.3.4

ACORN terminal user interface (TUI)
//! Interactive browser and confirmer for shared host diagnostic records.

use crate::app::App;
use crate::event::{to_internal_event, Event};
use crate::export;
use crate::Screen;
use acorn::analyzer::{
    fix::{CommandRunner, DiscoveryReference, HostProvider, IntoFixes, Plan, Policy, Provider, Verifier},
    host::discovery::{FindingStatus, Record, Records},
};
use core::time::Duration;
use crossterm::event;
use jiff::Timestamp;
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, Paragraph};
use ratatui::Frame;
use std::path::Path;
struct HostVerifier;
impl Verifier for HostVerifier {
    fn verify(&self, references: &[DiscoveryReference]) -> bool {
        let current = Records::diagnose_host();
        references.iter().all(|reference| {
            current.iter().any(|record| match record {
                | Record::Finding {
                    category,
                    status: FindingStatus::Healthy,
                    subject,
                    ..
                } => category == reference.category.as_ref() && subject == &reference.subject,
                | _ => false,
            })
        })
    }
}
fn apply_plan(app: &mut App) {
    let outcome = app.doctor.plan.as_ref().map_or_else(
        || Err("No trusted plan is available".to_string()),
        |plan| {
            let root = std::env::current_dir().unwrap_or_else(|_| Path::new(".").to_path_buf());
            plan.execute(&root, &CommandRunner::new(Duration::from_secs(120)), HostVerifier)
                .map(|outcomes| format!("Applied {} trusted operation(s)", outcomes.len()))
                .map_err(|why| why.to_string())
        },
    );
    app.refresh_doctor();
    app.doctor.export_message = Some(outcome.unwrap_or_else(|why| format!("Fix failed: {why}")));
}
fn centered_rect(percent_x: u16, height: u16, area: Rect) -> Rect {
    let vertical = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Length(area.height.saturating_sub(height) / 2),
            Constraint::Length(height.min(area.height)),
            Constraint::Min(0),
        ])
        .split(area);
    Layout::default()
        .direction(Direction::Horizontal)
        .constraints([
            Constraint::Percentage((100_u16.saturating_sub(percent_x)) / 2),
            Constraint::Percentage(percent_x),
            Constraint::Min(0),
        ])
        .split(vertical[1])[1]
}
fn decline_plan(app: &mut App) {
    app.doctor.confirming = false;
    app.doctor.plan = None;
    app.doctor.export_message = Some("Fix plan declined".to_string());
}
fn export(app: &mut App) {
    if let Some(data) = &app.doctor.data {
        let timestamp = Timestamp::now().strftime("%Y%m%d_%H%M%S");
        let filename = format!("acorn_doctor_report_{timestamp}.json");
        app.doctor.export_message = Some(match export::export_doctor_report(data, Some(Path::new(&filename))) {
            | Ok(path) => format!("Report saved to {path}"),
            | Err(why) => format!("Export failed: {why}"),
        });
    }
}
pub fn handle_event(app: &mut App, event: event::Event) {
    let Some(event) = (match event {
        | event::Event::Key(key) => to_internal_event(key),
        | _ => None,
    }) else {
        return;
    };
    if app.doctor.confirming {
        match event {
            | Event::Char('y' | 'Y') => apply_plan(app),
            | Event::Char('n' | 'N') | Event::Back => decline_plan(app),
            | Event::Quit => app.should_quit = true,
            | _ => {}
        }
        return;
    }
    match event {
        | Event::Quit => app.should_quit = true,
        | Event::Back => app.navigate_to(Screen::Dashboard),
        | Event::Up => app.doctor.selected_record = app.doctor.selected_record.saturating_sub(1),
        | Event::Down => {
            let maximum = app.doctor.data.as_ref().map_or(0, |records| records.len().saturating_sub(1));
            app.doctor.selected_record = app.doctor.selected_record.saturating_add(1).min(maximum);
        }
        | Event::Refresh => app.refresh_doctor(),
        | Event::Export => export(app),
        | Event::Char(' ') if app.options.doctor_fix => toggle(app),
        | Event::Char('f' | 'F') if app.options.doctor_fix => preview(app),
        | _ => {}
    }
}
fn preview(app: &mut App) {
    let references = app.doctor.data.as_ref().map_or_else(Vec::new, |records| {
        records
            .fix_references()
            .into_iter()
            .enumerate()
            .filter(|(index, _)| app.doctor.selected.contains(index))
            .map(|(_, reference)| reference)
            .collect()
    });
    let report = HostProvider.provide(&references);
    let fixes = report.fixes().into_iter().cloned().collect::<Vec<_>>();
    match Plan::from_fixes(&fixes, Policy::confirmed(app.options.offline)) {
        | Ok(plan) if !plan.preview().is_empty() => {
            app.doctor.plan = Some(plan);
            app.doctor.confirming = true;
        }
        | Ok(_) => app.doctor.export_message = Some("No trusted fixes are available for the selection".to_string()),
        | Err(why) => app.doctor.export_message = Some(format!("Cannot plan fixes: {why}")),
    }
}
pub fn render(frame: &mut Frame, app: &mut App) {
    app.load_doctor_data();
    let area = frame.area();
    let chunks = Layout::default()
        .direction(Direction::Vertical)
        .constraints([Constraint::Min(1), Constraint::Length(3)])
        .split(area);
    let main = Layout::default()
        .direction(Direction::Horizontal)
        .constraints([Constraint::Percentage(48), Constraint::Percentage(52)])
        .split(chunks[0]);
    render_findings(frame, main[0], app);
    render_details(frame, main[1], app);
    render_status(frame, chunks[1], app);
    if app.doctor.confirming {
        render_confirmation(frame, centered_rect(70, 10, area), app);
    }
}
fn render_confirmation(frame: &mut Frame, area: Rect, app: &App) {
    let lines = app
        .doctor
        .plan
        .as_ref()
        .map(|plan| {
            plan.preview()
                .iter()
                .map(|operation| Line::from(format!(" • {operation}")))
                .chain([Line::from(""), Line::from(" Apply these fixed operations? [Y] yes / [N or Esc] no")])
                .collect::<Vec<_>>()
        })
        .unwrap_or_else(|| vec![Line::from(" No trusted operations selected")]);
    frame.render_widget(Clear, area);
    frame.render_widget(
        Paragraph::new(lines)
            .block(
                Block::default()
                    .borders(Borders::ALL)
                    .title(" Confirm fixes ")
                    .border_style(Style::default().fg(app.theme.warning)),
            )
            .alignment(Alignment::Left),
        area,
    );
}
fn render_details(frame: &mut Frame, area: Rect, app: &App) {
    let mut lines = app
        .doctor
        .data
        .as_ref()
        .and_then(|records| records.iter().nth(app.doctor.selected_record))
        .map_or_else(
            || vec![Line::from(" No finding selected")],
            |record| match record {
                | Record::Finding {
                    category,
                    details,
                    expected,
                    observed,
                    status,
                    subject,
                } => core::iter::once(Line::from(format!(" Subject:  {subject}")))
                    .chain([
                        Line::from(format!(" Category: {category}")),
                        Line::from(format!(" Status:   {status}")),
                        Line::from(format!(" Observed: {observed}")),
                        Line::from(format!(" Expected: {expected}")),
                    ])
                    .chain(details.iter().map(|(key, value)| Line::from(format!(" {key}: {value}"))))
                    .collect(),
                | _ => vec![Line::from(" Unsupported diagnostic record")],
            },
        );
    if let Some(message) = &app.doctor.export_message {
        lines.extend([Line::from(""), Line::from(format!(" {message}"))]);
    }
    frame.render_widget(
        Paragraph::new(lines).block(
            Block::default()
                .borders(Borders::ALL)
                .title(" Details ")
                .title_alignment(Alignment::Center)
                .border_style(Style::default().fg(app.theme.accent)),
        ),
        area,
    );
}
fn render_findings(frame: &mut Frame, area: Rect, app: &App) {
    let lines = app.doctor.data.as_ref().map_or_else(
        || vec![Line::from(" Loading diagnostics...")],
        |records| {
            records
                .iter()
                .enumerate()
                .filter_map(|(index, record)| match record {
                    | Record::Finding {
                        category, status, subject, ..
                    } => {
                        let cursor = if index == app.doctor.selected_record { ">" } else { " " };
                        let selected = if app.doctor.selected.contains(&index) { "x" } else { " " };
                        let status = match status {
                            | FindingStatus::Healthy => "✓",
                            | FindingStatus::Attention => "!",
                            | FindingStatus::Unavailable => "?",
                        };
                        let style = if index == app.doctor.selected_record {
                            Style::default()
                                .fg(app.theme.selection_fg)
                                .bg(app.theme.selection_bg)
                                .add_modifier(Modifier::BOLD)
                        } else {
                            Style::default().fg(app.theme.text)
                        };
                        Some(Line::from(Span::styled(
                            format!(" {cursor} [{selected}] {status} {category}: {subject}"),
                            style,
                        )))
                    }
                    | _ => None,
                })
                .collect()
        },
    );
    frame.render_widget(
        Paragraph::new(lines).block(
            Block::default()
                .borders(Borders::ALL)
                .title(" Host findings ")
                .title_alignment(Alignment::Center)
                .border_style(Style::default().fg(app.theme.accent)),
        ),
        area,
    );
}
fn render_status(frame: &mut Frame, area: Rect, app: &App) {
    let mut controls = vec![
        Span::styled(" [↑/↓] Navigate  ", Style::default().fg(app.theme.text_muted)),
        Span::styled("[R] Refresh  ", Style::default().fg(app.theme.text_muted)),
        Span::styled("[E] Export  ", Style::default().fg(app.theme.text_muted)),
    ];
    if app.options.doctor_fix {
        controls.extend([
            Span::styled("[Space] Toggle  ", Style::default().fg(app.theme.text_muted)),
            Span::styled("[F] Preview  ", Style::default().fg(app.theme.text_muted)),
        ]);
    }
    controls.push(Span::styled("[Esc] Back  [Q] Quit", Style::default().fg(app.theme.text_muted)));
    frame.render_widget(
        Paragraph::new(Line::from(controls))
            .block(Block::default().borders(Borders::TOP).border_style(Style::default().fg(app.theme.border)))
            .alignment(Alignment::Center),
        area,
    );
}
fn toggle(app: &mut App) {
    let index = app.doctor.selected_record;
    if !app.doctor.selected.insert(index) {
        app.doctor.selected.remove(&index);
    }
}
#[cfg(test)]
mod tests {
    use super::*;
    use crate::TuiOptions;

    #[test]
    fn decline_clears_confirmation_without_applying() {
        let mut app = App::with_options(
            Screen::Doctor,
            TuiOptions {
                doctor_fix: true,
                ..TuiOptions::default()
            },
        );
        app.doctor.confirming = true;
        decline_plan(&mut app);
        assert!(!app.doctor.confirming);
        assert!(app.doctor.plan.is_none());
    }
    #[test]
    fn selection_toggles_current_finding() {
        let mut app = App::with_options(
            Screen::Doctor,
            TuiOptions {
                doctor_fix: true,
                ..TuiOptions::default()
            },
        );
        toggle(&mut app);
        assert!(app.doctor.selected.contains(&0));
        toggle(&mut app);
        assert!(app.doctor.selected.is_empty());
    }
}