jsonl-tui 0.1.1

Terminal explorer for JSONL files: search, filter, sort, group and export from your keyboard or mouse.
//! CLI parsing, terminal setup/teardown, and the main event loop.

mod app;
mod config;
mod data;
mod event;
mod filter;
mod ui;

use std::io;
use std::path::PathBuf;
use std::time::Duration;

use anyhow::{bail, Context, Result};
use clap::Parser;
use crossterm::{
    event::{DisableMouseCapture, EnableMouseCapture},
    execute,
    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{backend::CrosstermBackend, Terminal};

use app::{group_digits, App};

/// Interactive TUI explorer for JSONL files.
#[derive(Parser, Debug)]
#[command(name = "jsonl-tui", version, about)]
struct Cli {
    /// JSONL file to explore.
    file: PathBuf,

    /// Cap the number of records loaded (for very large files).
    #[arg(long)]
    max_lines: Option<usize>,

    /// Load a named view profile at startup (instead of shape auto-match).
    #[arg(long)]
    profile: Option<String>,

    /// Filter expression, e.g. "status=error score>3".
    #[arg(long)]
    filter: Option<String>,

    /// Search text (case-insensitive substring, or "re:pattern" for regex).
    #[arg(long)]
    search: Option<String>,

    /// Group by field; "field=value" also selects that facet.
    #[arg(long)]
    group: Option<String>,

    /// Write the filtered set to this JSONL file and exit (no TUI).
    #[arg(long)]
    export: Option<PathBuf>,

    /// Disable mouse support (click to focus/select/sort, wheel to scroll).
    #[arg(long)]
    no_mouse: bool,
}

fn main() -> Result<()> {
    let cli = Cli::parse();

    let dataset = data::load_jsonl(&cli.file, cli.max_lines)?;
    let load_summary = format!(
        "Loaded {} records from {}{}",
        group_digits(dataset.records.len()),
        cli.file.display(),
        if dataset.parse_errors > 0 {
            format!(" ({} malformed lines skipped)", group_digits(dataset.parse_errors))
        } else {
            String::new()
        }
    );

    let mut app = App::new(dataset, cli.file.clone());

    // Profile: explicit --profile wins; otherwise auto-apply a saved config
    // matching this file's shape (hash of its discovered field paths).
    if let Some(name) = &cli.profile {
        let profile = config::load_profile(name)
            .with_context(|| format!("failed to load profile '{name}'"))?;
        app.apply_profile(&profile);
        app.status = Some(format!("Applied profile '{name}'"));
    } else if let Ok(profile) = config::load_shape_profile(&app.shape_hash) {
        app.apply_profile(&profile);
        app.status = Some("Applied saved view for this file shape".to_string());
    }

    // CLI flags override whatever the profile set.
    if let Some(f) = &cli.filter {
        app.filter_input = f.clone();
    }
    if let Some(s) = &cli.search {
        app.search_input = s.clone();
    }
    let cli_facet = if let Some(g) = &cli.group {
        let (field, facet) = match g.split_once('=') {
            Some((f, v)) => (f.to_string(), Some(v.to_string())),
            None => (g.clone(), None),
        };
        if !app.dataset.schema.contains_key(&field) {
            bail!("unknown group field '{field}' (not present in this file)");
        }
        app.group_field = Some(field);
        app.group_facet = facet.clone();
        app.sync_group_input();
        facet
    } else {
        None
    };
    app.recompute();

    if let Some(err) = &app.filter_error {
        bail!("invalid --filter expression: {err}");
    }
    if let Some(err) = &app.search_error {
        bail!("invalid --search expression: {err}");
    }
    if let Some(facet) = &cli_facet {
        if app.group_facet.is_none() {
            bail!("group value '{facet}' matches no records (after filters)");
        }
    }

    // Headless export mode: apply flags, write, print summary, exit.
    if let Some(out) = &cli.export {
        let n = app.export_to(out)?;
        println!("Wrote {} records to {}", group_digits(n), out.display());
        return Ok(());
    }

    if app.status.is_none() {
        app.status = Some(load_summary);
    }
    run_tui(&mut app, !cli.no_mouse)
}

fn run_tui(app: &mut App, mouse: bool) -> Result<()> {
    // Restore the terminal even if we panic mid-draw.
    let default_hook = std::panic::take_hook();
    std::panic::set_hook(Box::new(move |info| {
        let _ = disable_raw_mode();
        let _ = execute!(io::stdout(), DisableMouseCapture, LeaveAlternateScreen);
        default_hook(info);
    }));

    enable_raw_mode().context("failed to enable raw mode")?;
    let mut stdout = io::stdout();
    execute!(stdout, EnterAlternateScreen).context("failed to enter alternate screen")?;
    if mouse {
        execute!(stdout, EnableMouseCapture).context("failed to enable mouse capture")?;
    }
    let backend = CrosstermBackend::new(stdout);
    let mut terminal = Terminal::new(backend).context("failed to create terminal")?;

    let result = event_loop(&mut terminal, app);

    disable_raw_mode().ok();
    execute!(terminal.backend_mut(), DisableMouseCapture, LeaveAlternateScreen).ok();
    terminal.show_cursor().ok();
    let _ = std::panic::take_hook(); // drop our hook

    result
}

fn event_loop(
    terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
    app: &mut App,
) -> Result<()> {
    loop {
        terminal.draw(|f| ui::draw(f, app))?;
        if crossterm::event::poll(Duration::from_millis(200))? {
            match crossterm::event::read()? {
                crossterm::event::Event::Key(key) => event::handle_key(app, key),
                crossterm::event::Event::Mouse(me) => event::handle_mouse(app, me),
                crossterm::event::Event::Resize(_, _) => {}
                _ => {}
            }
        }
        if app.should_quit {
            return Ok(());
        }
    }
}