h5inspect 1.5.0

A terminal based HDF5 file inspector
use crate::app::App;
use clap;
use color_eyre::Result;
use ratatui;
use serde_json;
use std::error::Error;
use std::io::{stdout, Write};
use std::path::PathBuf;
use tui_logger;

mod analysis;
mod app;
mod events;
mod h5_utils;
mod hist_plot;
mod num_utils;
mod tree;
mod ui;

fn main() -> Result<(), Box<dyn Error>> {
    // crate::h5_utils::generate_dummy_file()?;
    let matches = clap::Command::new("h5inspect")
        .author("Hal Frigaard")
        .about("Simple TUI to inspect h5 files")
        .color(clap::ColorChoice::Auto)
        .styles(
            clap::builder::Styles::styled()
                .header(
                    clap::builder::styling::AnsiColor::Yellow.on_default()
                        | clap::builder::styling::Effects::BOLD,
                )
                .usage(
                    clap::builder::styling::AnsiColor::Yellow.on_default()
                        | clap::builder::styling::Effects::BOLD,
                )
                .literal(
                    clap::builder::styling::AnsiColor::Green.on_default()
                        | clap::builder::styling::Effects::BOLD,
                )
                .placeholder(clap::builder::styling::AnsiColor::White.on_default())
                .error(
                    clap::builder::styling::AnsiColor::Red.on_default()
                        | clap::builder::styling::Effects::BOLD,
                )
                .valid(
                    clap::builder::styling::AnsiColor::Green.on_default()
                        | clap::builder::styling::Effects::BOLD,
                )
                .invalid(
                    clap::builder::styling::AnsiColor::Red.on_default()
                        | clap::builder::styling::Effects::BOLD,
                ),
        )
        .arg(
            clap::Arg::new("h5file")
                .value_name("FILE")
                .help("Name of hdf5 file to inspect")
                .value_hint(clap::ValueHint::FilePath)
                .required_unless_present("generate-dummy-file"),
        )
        .arg(
            clap::Arg::new("analyze-dataset")
                .long("analyze-dataset")
                .value_name("DATASET")
                .help("Run analysis on a specific dataset and output JSON result")
                .required(false),
        )
        .arg(
            clap::Arg::new("logs")
                .long("logs")
                .value_name("FILE")
                .help("File path to print logs to")
                .value_hint(clap::ValueHint::FilePath)
                .required(false),
        )
        .arg(
            clap::Arg::new("generate-dummy-file")
                .long("generate-dummy-file")
                .help("Generate a dummy hdf5 file for testing purposes")
                .action(clap::ArgAction::SetTrue),
        )
        .version(env!("CARGO_PKG_VERSION"))
        .get_matches();

    // Handle generate-dummy-file flag first (doesn't require h5file)
    if matches.get_flag("generate-dummy-file") {
        h5_utils::generate_dummy_file()?;
        return Ok(());
    }

    // For all other operations, h5file is required
    let h5_file_name: &String = matches
        .get_one("h5file")
        .expect("clap should have enforced presence of h5file argument");
    let h5_file_path = std::path::PathBuf::from(h5_file_name);

    initialize_logger(matches.get_one::<String>("logs"))?;

    if let Some(dataset_path) = matches.get_one::<String>("analyze-dataset") {
        // Check if we're in analysis mode
        // Run analysis and output JSON
        analyze_dataset(&h5_file_path, dataset_path)
    } else {
        log::info!("Starting app");

        let runtime = build_runtime();

        color_eyre::install()?;
        loop {
            let app = App::new(h5_file_path.clone());
            let terminal = ratatui::init();
            crossterm::execute!(std::io::stdout(), crossterm::event::EnableMouseCapture)?;
            let res = runtime.block_on(app.run(terminal));
            crossterm::execute!(std::io::stdout(), crossterm::event::DisableMouseCapture)?;
            ratatui::restore();

            match res {
                Ok(app::AppFinishingState::ShouldRunCommand(post_cmd, ds_path)) => match post_cmd {
                    Some(post_cmd) => {
                        println!(
                            "H5INSPECT_POST running: {} {} {}",
                            post_cmd, h5_file_name, ds_path
                        );
                        let mut child = std::process::Command::new(post_cmd)
                            .arg(h5_file_name)
                            .arg(ds_path)
                            .stdin(std::process::Stdio::inherit())
                            .stdout(std::process::Stdio::inherit())
                            .stderr(std::process::Stdio::inherit())
                            .spawn()?;
                        let status = child.wait()?;
                        if !status.success() {
                            eprintln!("H5INSPECT_POST script exited with status: {}", status);
                        }
                    }
                    None => {
                        println!("H5INSPECT_POST not set. See https://github.com/HalFrgrd/h5inspect/blob/master/h5inspect_post/README.md");
                        for i in 1..=5 {
                            print!("Continuing in {} seconds...", 6 - i);
                            stdout().flush().ok();
                            std::thread::sleep(std::time::Duration::from_secs(1));
                            print!("\r");
                        }
                    }
                },
                Ok(_) => return Ok(()),
                Err(e) => return Err(e),
            }
        }
    }
}

fn initialize_logger(log_file_path: Option<&String>) -> Result<(), Box<dyn Error>> {
    // Initialize tui_logger as the main logger
    tui_logger::init_logger(log::LevelFilter::Trace)?;
    tui_logger::set_default_level(log::LevelFilter::Trace);
    tui_logger::set_level_for_target("plotters_ratatui_backend::widget", log::LevelFilter::Off);
    tui_logger::set_level_for_target("mio::poll", log::LevelFilter::Off);

    // Set up file logging if --logs argument is provided
    if let Some(log_file_path) = log_file_path {
        // Set up tui_logger to also output to file via custom dispatch
        // We'll use the tui_logger's built-in move_events functionality
        tui_logger::set_log_file(tui_logger::TuiLoggerFile::new(log_file_path));
    }
    Ok(())
}

fn analyze_dataset(h5_file_path: &PathBuf, dataset_path: &str) -> Result<(), Box<dyn Error>> {
    // Run analysis and output JSON
    match analysis::hdf5_dataset_analysis_from_path(&h5_file_path, dataset_path) {
        Ok(result) => {
            let json_output = serde_json::to_string(&result)?;
            println!("{}", json_output);
            return Ok(());
        }
        Err(e) => {
            let error_result = analysis::AnalysisResult::Failed(e.to_string());
            let json_output = serde_json::to_string(&error_result)?;
            println!("{}", json_output);
            std::process::exit(1);
        }
    }
}

fn build_runtime() -> tokio::runtime::Runtime {
    tokio::runtime::Builder::new_multi_thread()
        .worker_threads(2)
        .max_blocking_threads(512)
        .enable_all()
        .build()
        .unwrap()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn run_app_startup() -> Result<(), Box<dyn Error>> {
        h5_utils::generate_dummy_file()?;
        let h5_file_path = std::path::PathBuf::from("dummy.h5");
        run_app(h5_file_path)
    }

    #[test]
    #[should_panic(expected = "File path doesn't exist")]
    fn run_app_on_non_existent_file() {
        let h5_file_path = std::path::PathBuf::from("non_existent.h5");
        run_app(h5_file_path).unwrap();
    }

    #[test]
    #[should_panic(expected = "Couldn't open file")]
    fn run_app_on_non_h5_file() {
        let h5_file_path = std::path::PathBuf::from("src/main.rs");
        run_app(h5_file_path).unwrap();
    }

    #[test]
    fn run_app_on_split_file() -> Result<(), Box<dyn Error>> {
        h5_utils::generate_dummy_split_file()?;
        let h5_file_path = std::path::PathBuf::from("dummy_split.h5");
        run_app(h5_file_path)
    }

    fn run_app(h5_file_path: std::path::PathBuf) -> Result<(), Box<dyn Error>> {
        let app = App::new(h5_file_path);

        let backend = ratatui::backend::TestBackend::new(200, 120);
        let terminal = ratatui::Terminal::new(backend).unwrap();

        let runtime = build_runtime();
        let res = runtime.block_on(async {
            tokio::select! {
                res = app.run(terminal) => {
                    res.map(|_| ())
                }
                _ = tokio::time::sleep(std::time::Duration::from_secs(2)) => {
                    println!("Timer expired before app returned, nice.");
                    Ok(())
                }
            }
        });

        res
    }
}