mod app;
mod clipboard;
#[cfg(feature = "disasm")]
mod disasm;
mod export;
mod formats;
mod hexedit;
mod mru;
mod overlay;
mod prefs;
mod rkyv_inspect;
mod sqlite;
mod store;
mod theme;
use anyhow::{anyhow, Result};
use clap::Parser;
use crossterm::event::{DisableMouseCapture, EnableMouseCapture};
use crossterm::execute;
use crossterm::terminal::{
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
};
use ratatui::backend::CrosstermBackend;
use ratatui::{DefaultTerminal, Terminal};
use std::io;
use std::path::PathBuf;
use store::Store;
#[derive(Parser)]
#[command(
name = "zdbview",
version,
about = "Terminal inspector and CRUD editor for rkyv archives and SQLite databases"
)]
struct Cli {
file: Option<PathBuf>,
#[arg(long, conflicts_with = "rkyv")]
sqlite: bool,
#[arg(long)]
rkyv: bool,
#[arg(long, value_name = "FORMAT")]
export: Option<String>,
#[arg(long, value_name = "NAME")]
theme: Option<String>,
#[arg(long)]
list_themes: bool,
}
fn main() -> Result<()> {
let cli = Cli::parse();
if cli.list_themes {
list_themes();
return Ok(());
}
if let Some(fmt) = &cli.export {
return run_export(&cli, fmt);
}
let theme = cli.theme.as_deref().map(parse_theme).transpose()?;
let mut terminal = setup_terminal()?;
let res = run(&cli, &mut terminal, theme);
restore_terminal(&mut terminal);
res
}
fn setup_terminal() -> Result<DefaultTerminal> {
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let terminal = Terminal::new(CrosstermBackend::new(stdout))?;
Ok(terminal)
}
fn restore_terminal(terminal: &mut DefaultTerminal) {
disable_raw_mode().ok();
execute!(
terminal.backend_mut(),
DisableMouseCapture,
LeaveAlternateScreen
)
.ok();
terminal.show_cursor().ok();
}
fn list_themes() {
use theme::{Theme, ThemeName};
const RST: &str = "\x1b[0m";
const B_CYAN: &str = "\x1b[1;36m";
const B_GREEN: &str = "\x1b[1;32m";
const B_MAGENTA: &str = "\x1b[1;35m";
const B_YELLOW: &str = "\x1b[1;33m";
println!("\n{B_CYAN} ── COLOR SCHEMES ({}) ────────────────────────{RST}\n", ThemeName::ALL.len());
for &name in ThemeName::ALL {
let swatch: String = Theme::swatch(name)
.iter()
.map(|(color, _)| match color {
ratatui::style::Color::Indexed(n) => format!("\x1b[48;5;{n}m {RST}"),
_ => " ".to_string(),
})
.collect();
println!(
" {B_GREEN}{token:<14}{RST} {B_MAGENTA}{display:<14}{RST} {swatch}",
token = name.token(),
display = name.display(),
);
}
println!("\n {B_YELLOW}Use:{RST} zdbview FILE {B_GREEN}--theme neon_sprawl{RST}");
println!(" {B_YELLOW}In TUI:{RST} press {B_GREEN}t{RST} for the chooser, {B_GREEN}e{RST} for the palette editor\n");
}
fn parse_theme(token: &str) -> Result<theme::ThemeName> {
theme::ThemeName::from_token(token).ok_or_else(|| {
anyhow!(
"unknown theme '{}' — run --list-themes for the {} valid names",
token,
theme::ThemeName::ALL.len()
)
})
}
fn run_export(cli: &Cli, fmt: &str) -> Result<()> {
let fmt = fmt.to_lowercase();
if fmt != "json" && fmt != "csv" {
return Err(anyhow!("--export expects 'json' or 'csv', got '{}'", fmt));
}
let file = cli
.file
.clone()
.ok_or_else(|| anyhow!("--export requires a file argument"))?;
let kind = store::detect(&file, cli.sqlite, cli.rkyv)?;
let (store, _) = store::Store::open(&file, kind)?;
print!("{}", export_store(&store, &fmt)?);
Ok(())
}
fn export_store(store: &Store, fmt: &str) -> Result<String> {
match store {
Store::Sqlite(s) => {
if fmt == "csv" {
let table = s
.tables
.first()
.cloned()
.ok_or_else(|| anyhow!("no tables to export"))?;
let total = s.count(&table)?;
let v = s.rows(&table, total.max(1), 0, None)?;
Ok(export::rows_to_csv(&v.columns, &v.rows))
} else {
let mut out = String::from("{");
for (i, t) in s.tables.iter().enumerate() {
if i > 0 {
out.push(',');
}
let total = s.count(t)?;
let v = s.rows(t, total.max(1), 0, None)?;
out.push_str(&export::json_escape(t));
out.push(':');
out.push_str(&export::rows_to_json(&v.columns, &v.rows));
}
out.push('}');
out.push('\n');
Ok(out)
}
}
Store::Rkyv(r) => {
let d = formats::try_decode(&r.bytes)
.ok_or_else(|| anyhow!("unrecognized rkyv archive — nothing to export"))?;
let recs: Vec<export::RecordExport> = d
.records
.iter()
.map(|rec| export::RecordExport {
key: &rec.key,
fields: &rec.fields,
value: &rec.value,
})
.collect();
let mut out = export::records_to_json(&recs);
out.push('\n');
Ok(out)
}
}
}
fn run(
cli: &Cli,
terminal: &mut DefaultTerminal,
theme: Option<theme::ThemeName>,
) -> Result<()> {
let file = match &cli.file {
Some(f) => f.clone(),
None => {
let recent: Vec<mru::Entry> = mru::load()
.into_iter()
.filter(|e| e.path.exists())
.collect();
match app::pick_mru(terminal, &recent, theme)? {
Some(p) => p,
None => return Ok(()), }
}
};
let kind = store::detect(&file, cli.sqlite, cli.rkyv)?;
let (store, actual) = store::Store::open(&file, kind)?;
mru::record(&file, actual);
app::App::new(store, theme).run(terminal)
}