jarq 0.5.1

An interactive jq-like JSON query tool with a TUI
Documentation
use std::io::{self, IsTerminal, Read, Write};
use std::path::PathBuf;
use std::time::Duration;
use std::{env, fs, panic, process};

use anyhow::{Context, Result};
use crossterm::{
    ExecutableCommand,
    event::{self, Event},
    terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use ratatui::prelude::*;

const POLL_TIMEOUT: Duration = Duration::from_millis(50);

mod app;
mod error;
mod filter;
mod loader;
mod text_buffer;
mod ui;
mod worker;

use app::{App, AppAction};
use loader::Loader;

fn main() {
    if let Err(e) = run() {
        eprintln!("Error: {e:#}");
        process::exit(1);
    }
}

fn run() -> Result<()> {
    let args: Vec<String> = env::args().skip(1).collect();

    // Handle --version / -V
    if args.iter().any(|a| a == "--version" || a == "-V") {
        println!("jarq {}", env!("CARGO_PKG_VERSION"));
        return Ok(());
    }

    let stdin_is_pipe = !io::stdin().is_terminal();

    match args.len() {
        0 => {
            // No args: interactive mode
            if stdin_is_pipe {
                run_interactive_async(Loader::spawn_stdin())
            } else {
                eprintln!("Usage: jarq [FILTER] [FILE]");
                eprintln!("       cat file.json | jarq [FILTER]");
                eprintln!();
                eprintln!("If FILTER is provided, runs non-interactively.");
                eprintln!("Otherwise, opens an interactive TUI.");
                Ok(())
            }
        }
        1 => {
            if stdin_is_pipe {
                // 1 arg + piped stdin: arg is filter, non-interactive
                let json_input = read_stdin()?;
                let values = parse_json(&json_input)?;
                run_noninteractive(&args[0], &values)
            } else {
                // 1 arg + no stdin: arg is file, interactive
                run_interactive_async(Loader::spawn_file(PathBuf::from(&args[0])))
            }
        }
        _ => {
            // 2+ args: first is filter, second is file, non-interactive
            let json_input = fs::read_to_string(&args[1])
                .with_context(|| format!("Failed to read file: {}", &args[1]))?;
            let values = parse_json(&json_input)?;
            run_noninteractive(&args[0], &values)
        }
    }
}

fn run_noninteractive(filter_text: &str, values: &[serde_json::Value]) -> Result<()> {
    let results =
        filter::evaluate_all(filter_text, values).map_err(|e| anyhow::anyhow!("{}", e))?;

    let stdout = io::stdout();
    let mut handle = stdout.lock();

    for value in results {
        writeln!(handle, "{}", serde_json::to_string_pretty(&value)?)?;
    }

    Ok(())
}

fn run_interactive_async(loader: Loader) -> Result<()> {
    let mut app = App::new_loading(loader);
    run_tui_loop(&mut app)
}

#[allow(dead_code)]
fn run_interactive(values: Vec<serde_json::Value>) -> Result<()> {
    let mut app = App::new(values);
    run_tui_loop(&mut app)
}

fn run_tui_loop(app: &mut App) -> Result<()> {
    let mut stdout = io::stdout();
    enable_raw_mode().context("Failed to enable raw mode")?;
    stdout
        .execute(EnterAlternateScreen)
        .context("Failed to enter alternate screen")?;

    let original_hook = panic::take_hook();
    panic::set_hook(Box::new(move |panic_info| {
        let _ = disable_raw_mode();
        let _ = io::stdout().execute(LeaveAlternateScreen);
        original_hook(panic_info);
    }));

    let mut terminal =
        Terminal::new(CrosstermBackend::new(stdout)).context("Failed to create terminal")?;

    loop {
        terminal.draw(|frame| ui::render(frame, app))?;

        if event::poll(POLL_TIMEOUT).context("Failed to poll event")? {
            match event::read().context("Failed to read event")? {
                Event::Key(key) => match app.handle_key(key) {
                    AppAction::Quit => break,
                    AppAction::Continue => {}
                },
                Event::Resize(_, _) => {}
                _ => {}
            }
        }

        app.tick();
    }

    disable_raw_mode().context("Failed to disable raw mode")?;
    io::stdout()
        .execute(LeaveAlternateScreen)
        .context("Failed to leave alternate screen")?;

    Ok(())
}

fn parse_json(input: &str) -> Result<Vec<serde_json::Value>> {
    // First, try parsing as a single JSON value
    if let Ok(value) = serde_json::from_str::<serde_json::Value>(input) {
        return Ok(vec![value]);
    }

    // Fall back to JSONL: parse each non-empty line
    let mut values = Vec::new();
    for (line_num, line) in input.lines().enumerate() {
        let trimmed = line.trim();
        if trimmed.is_empty() {
            continue;
        }
        let value = serde_json::from_str(trimmed)
            .with_context(|| format!("Invalid JSON on line {}", line_num + 1))?;
        values.push(value);
    }

    if values.is_empty() {
        anyhow::bail!("No valid JSON found in input");
    }

    Ok(values)
}

fn read_stdin() -> Result<String> {
    let mut input = String::new();
    io::stdin()
        .read_to_string(&mut input)
        .context("Failed to read from stdin")?;
    if input.is_empty() {
        anyhow::bail!("Empty input is not valid JSON");
    }
    Ok(input)
}