lean-tui 1.1.1

Standalone TUI infoview for Lean 4 theorem prover
use std::{io, ops::RangeInclusive, time::Duration};

use futures::future::join_all;
use tokio::{
    io::{AsyncBufReadExt, AsyncWriteExt, BufReader},
    net::{tcp::OwnedWriteHalf, TcpStream},
    select,
    sync::mpsc::{unbounded_channel, UnboundedSender},
    time::{sleep, timeout},
};

use crate::{
    message::AppMessage,
    protocol::{ClientToServerCommand, ServerToClientMessage},
};

const RECONNECT_DELAY: Duration = Duration::from_secs(2);
const SCAN_INTERVAL: Duration = Duration::from_secs(2);

#[derive(Debug, Clone)]
pub struct WorkerInfo {
    pub port: u16,
    pub file_uri: Option<String>,
    pub server_mode: Option<String>,
    pub editor_name: Option<String>,
}

impl WorkerInfo {
    pub fn filename(&self) -> Option<&str> {
        self.file_uri
            .as_ref()
            .and_then(|uri| uri.rsplit('/').next())
    }

    pub fn display_label(&self) -> String {
        self.file_uri.as_ref().map_or_else(
            || format!("Worker on port {}", self.port),
            |uri| {
                let filename = self.filename().unwrap_or(uri);
                format!("{filename} (port {})", self.port)
            },
        )
    }
}

async fn probe_port(port: u16) -> Option<WorkerInfo> {
    let addr = format!("127.0.0.1:{port}");

    let Ok(Ok(stream)) = timeout(Duration::from_millis(100), TcpStream::connect(&addr)).await
    else {
        return None;
    };

    let (reader, _writer) = stream.into_split();
    let mut reader = BufReader::new(reader).lines();

    let Ok(Ok(Some(line))) = timeout(Duration::from_millis(200), reader.next_line()).await else {
        return None;
    };

    match serde_json::from_str(&line).ok()? {
        ServerToClientMessage::Connected {
            file_uri,
            server_mode,
            editor_name,
            ..
        } => Some(WorkerInfo {
            port,
            file_uri,
            server_mode: server_mode.map(|m| format!("{m:?}")),
            editor_name,
        }),
        _ => None,
    }
}

pub async fn discover_workers(port_min: u16, port_max: u16) -> Vec<WorkerInfo> {
    tracing::info!("Scanning ports {port_min}–{port_max} for workers");

    let handles: Vec<_> = (port_min..=port_max)
        .map(|port| tokio::spawn(probe_port(port)))
        .collect();

    let mut workers: Vec<_> = join_all(handles)
        .await
        .into_iter()
        .filter_map(|result| result.ok().flatten())
        .inspect(|info| tracing::info!("Found worker: {:?}", info))
        .collect();

    workers.sort_by_key(|w| w.port);
    workers
}

pub async fn scan_workers_periodically(
    port_range: RangeInclusive<u16>,
    output: UnboundedSender<AppMessage>,
) {
    loop {
        let workers = discover_workers(*port_range.start(), *port_range.end()).await;
        if output
            .send(AppMessage::WorkersDiscovered { workers })
            .is_err()
        {
            return;
        }
        sleep(SCAN_INTERVAL).await;
    }
}

fn parse_worker_line(port: u16, line: &str) -> AppMessage {
    match serde_json::from_str::<ServerToClientMessage>(line) {
        Ok(message) => {
            tracing::debug!("Received message: {:?}", message.message_type());
            AppMessage::WorkerSent { port, message }
        }
        Err(error) => {
            let raw_preview: String = line.chars().take(500).collect();
            tracing::warn!("Failed to parse message: {error}");
            AppMessage::WorkerSentInvalid {
                port,
                error: format!("Protocol error: {error}\n\nRaw message preview:\n{raw_preview}"),
            }
        }
    }
}

async fn write_command(
    writer: &mut OwnedWriteHalf,
    command: &ClientToServerCommand,
) -> io::Result<()> {
    let json = serde_json::to_string(command)?;
    writer.write_all(format!("{json}\n").as_bytes()).await
}

async fn exchange_with_worker(port: u16, stream: TcpStream, output: &UnboundedSender<AppMessage>) {
    let (reader, mut writer) = stream.into_split();
    let (commands, mut pending_commands) = unbounded_channel();
    let _ = commands.send(ClientToServerCommand::Ready);
    if output
        .send(AppMessage::WorkerConnected { port, commands })
        .is_err()
    {
        return;
    }

    let mut lines = BufReader::new(reader).lines();
    loop {
        select! {
            line = lines.next_line() => match line {
                Ok(Some(line)) => {
                    if output.send(parse_worker_line(port, &line)).is_err() {
                        return;
                    }
                }
                Ok(None) | Err(_) => break,
            },
            Some(command) = pending_commands.recv() => {
                if let Err(error) = write_command(&mut writer, &command).await {
                    tracing::warn!("Write error on port {port}: {error}");
                    break;
                }
            }
        }
    }
    tracing::info!("Worker on port {port} disconnected");
    let _ = output.send(AppMessage::WorkerDisconnected { port });
}

pub async fn stay_connected_to_worker(port: u16, output: UnboundedSender<AppMessage>) {
    loop {
        match TcpStream::connect(("127.0.0.1", port)).await {
            Ok(stream) => {
                tracing::info!("Connected to worker on port {port}");
                exchange_with_worker(port, stream, &output).await;
            }
            Err(error) => tracing::trace!("Failed to connect to port {port}: {error}"),
        }
        if output.is_closed() {
            return;
        }
        sleep(RECONNECT_DELAY).await;
    }
}