mod app;
mod clipboard;
#[cfg(feature = "disasm")]
mod disasm;
mod export;
mod formats;
mod frames;
mod hexedit;
mod import;
mod monitor;
mod mru;
mod overlay;
mod prefs;
mod query;
mod recover;
mod rkyv_inspect;
mod scan;
mod sqledit;
mod sqlite;
mod store;
mod theme;
mod wal;
mod watch;
use anyhow::{anyhow, Context, 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;
const HELP_TEMPLATE: &str = "
{before-help}
{about}
\x1b[33m USAGE:\x1b[0m {usage}
{all-args}
{after-help}";
const BOX_W: usize = 54;
fn banner() -> String {
const ART: &str = concat!(
"\x1b[36m ███████╗██████╗ ██████╗ ██╗ ██╗██╗███████╗██╗ ██╗\x1b[0m\n",
"\x1b[36m ╚══███╔╝██╔══██╗██╔══██╗██║ ██║██║██╔════╝██║ ██║\x1b[0m\n",
"\x1b[35m ███╔╝ ██║ ██║██████╔╝██║ ██║██║█████╗ ██║ █╗ ██║\x1b[0m\n",
"\x1b[35m ███╔╝ ██║ ██║██╔══██╗╚██╗ ██╔╝██║██╔══╝ ██║███╗██║\x1b[0m\n",
"\x1b[31m ███████╗██████╔╝██████╔╝ ╚████╔╝ ██║███████╗╚███╔███╔╝\x1b[0m\n",
"\x1b[31m ╚══════╝╚═════╝ ╚═════╝ ╚═══╝ ╚═╝╚══════╝ ╚══╝╚══╝\x1b[0m\n",
);
let status = format!(
" rkyv + sqlite // magic detection // v{}",
env!("CARGO_PKG_VERSION")
);
let rule = "─".repeat(BOX_W);
format!(
"{ART}\x1b[36m ┌{rule}┐\x1b[0m\n\
\x1b[36m │\x1b[0m{status:<BOX_W$}\x1b[36m│\x1b[0m\n\
\x1b[36m └{rule}┘\x1b[0m\n\
\x1b[35m >> BOTH HALVES OF THE CACHE // ONE BINARY <<\x1b[0m"
)
}
const AFTER_HELP: &str = concat!(
"\x1b[36m ── KEYS ───────────────────────────────────────────────\x1b[0m\n",
"\x1b[32m //\x1b[0m j/k ←/→ move Tab focus Enter detail / search (n/N)\n",
"\x1b[32m //\x1b[0m e a d : edit/add/delete/SQL s sort S schema (SQLite)\n",
"\x1b[32m //\x1b[0m D database report A column stats Y row as INSERT (SQLite)\n",
"\x1b[32m //\x1b[0m 0 1 2 3 views a e r d record CRUD e hex editor (rkyv)\n",
"\x1b[32m //\x1b[0m v value render y copy (OSC 52) x export to file\n",
"\x1b[32m //\x1b[0m c scheme C palette o file list h/? help q quit\n",
"\n",
"\x1b[36m ── EXAMPLES ───────────────────────────────────────────\x1b[0m\n",
"\x1b[32m //\x1b[0m zdbview \x1b[90mrecent files + saved scan\x1b[0m\n",
"\x1b[32m //\x1b[0m zdbview ~/.zshrs/scripts.rkyv \x1b[90mrecords, or structural\x1b[0m\n",
"\x1b[32m //\x1b[0m zdbview data.db --export json \x1b[90mdump every table\x1b[0m\n",
"\x1b[32m //\x1b[0m zdbview data.db --export sql \x1b[90mreplayable .dump\x1b[0m\n",
"\x1b[32m //\x1b[0m zdbview d.db --import r.csv --table t \x1b[90mload a CSV\x1b[0m\n",
"\x1b[32m //\x1b[0m zdbview --rescan \x1b[90mwalk again now\x1b[0m\n",
"\x1b[32m //\x1b[0m zdbview --list-themes \x1b[90mpreview the schemes\x1b[0m\n",
"\n",
"\x1b[36m ── FILES ──────────────────────────────────────────────\x1b[0m\n",
"\x1b[32m //\x1b[0m $XDG_CACHE_HOME/zdbview/recent \x1b[90mrecent-files list\x1b[0m\n",
"\x1b[32m //\x1b[0m $XDG_CACHE_HOME/zdbview/scan \x1b[90msaved scan results\x1b[0m\n",
"\x1b[32m //\x1b[0m $XDG_CONFIG_HOME/zdbview/prefs \x1b[90mscheme + palette\x1b[0m\n",
"\n",
"\x1b[36m ── SYSTEM ─────────────────────────────────────────────\x1b[0m\n",
"\x1b[35m v",
env!("CARGO_PKG_VERSION"),
" \x1b[0m// \x1b[33mratatui + crossterm // rkyv 0.7 // bundled sqlite\x1b[0m\n",
"\x1b[35m The magic decides the backend, never the file name.\x1b[0m\n",
"\x1b[33m >>> OPEN THE SHARD. EDIT THE BYTES. WRITE IT BACK. <<<\x1b[0m\n",
"\x1b[36m ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░\x1b[0m"
);
const H_POSITIONAL: &str = "\x1b[36m ── FILE\x1b[0m";
const H_BACKEND: &str = "\x1b[36m ── BACKEND\x1b[0m";
const H_OUTPUT: &str = "\x1b[36m ── OUTPUT\x1b[0m";
const H_DISCOVERY: &str = "\x1b[36m ── DISCOVERY\x1b[0m";
const H_APPEARANCE: &str = "\x1b[36m ── APPEARANCE\x1b[0m";
const H_GENERAL: &str = "\x1b[36m ── GENERAL\x1b[0m";
#[derive(Parser)]
#[command(
name = "zdbview",
version,
about = "\x1b[0m A terminal inspector and CRUD editor for rkyv archives and SQLite databases.",
help_template = HELP_TEMPLATE,
before_help = banner(),
after_help = AFTER_HELP,
disable_help_flag = true,
disable_version_flag = true
)]
struct Cli {
#[arg(
value_name = "FILE",
help_heading = H_POSITIONAL,
help = "\x1b[32m//\x1b[0m File to open; omit for the picker (recent + scan)"
)]
file: Option<PathBuf>,
#[arg(
long,
conflicts_with = "rkyv",
help_heading = H_BACKEND,
help = "\x1b[32m//\x1b[0m Force the SQLite backend, skipping detection"
)]
sqlite: bool,
#[arg(
long,
help_heading = H_BACKEND,
help = "\x1b[32m//\x1b[0m Force the rkyv/binary backend, skipping detection"
)]
rkyv: bool,
#[arg(
long,
value_name = "FORMAT",
help_heading = H_OUTPUT,
help = "\x1b[32m//\x1b[0m Dump to stdout and exit: json csv tsv markdown insert line sql"
)]
export: Option<String>,
#[arg(
long,
value_name = "TABLE",
help_heading = H_OUTPUT,
help = "\x1b[32m//\x1b[0m Restrict --export to one table"
)]
table: Option<String>,
#[arg(
long,
value_name = "FILE",
help_heading = H_OUTPUT,
help = "\x1b[32m//\x1b[0m Copy the database with VACUUM INTO and exit"
)]
backup: Option<PathBuf>,
#[arg(
long,
value_name = "FILE",
help_heading = H_OUTPUT,
help = "\x1b[32m//\x1b[0m Load a CSV (or TSV) into --table and exit"
)]
import: Option<PathBuf>,
#[arg(
long,
help_heading = H_OUTPUT,
help = "\x1b[32m//\x1b[0m Salvage rows page by page to stdout as SQL, and exit"
)]
recover: bool,
#[arg(
long,
value_name = "NAME",
help_heading = H_APPEARANCE,
help = "\x1b[32m//\x1b[0m Colour scheme for this run (see --list-themes)"
)]
theme: Option<String>,
#[arg(
long,
help_heading = H_APPEARANCE,
help = "\x1b[32m//\x1b[0m Preview every scheme with its palette and exit"
)]
list_themes: bool,
#[arg(
long,
value_name = "DIR",
help_heading = H_DISCOVERY,
help = "\x1b[32m//\x1b[0m Scan DIR instead of the default roots (repeatable)"
)]
scan: Vec<PathBuf>,
#[arg(
long,
conflicts_with = "scan",
help_heading = H_DISCOVERY,
help = "\x1b[32m//\x1b[0m Skip the scan; list only recent files"
)]
no_scan: bool,
#[arg(
long,
conflicts_with = "no_scan",
help_heading = H_DISCOVERY,
help = "\x1b[32m//\x1b[0m Walk again now, ignoring the saved scan"
)]
rescan: bool,
#[arg(
short,
long,
action = clap::ArgAction::Help,
help_heading = H_GENERAL,
help = "\x1b[32m//\x1b[0m Print this help"
)]
help: Option<bool>,
#[arg(
short = 'V',
long,
action = clap::ArgAction::Version,
help_heading = H_GENERAL,
help = "\x1b[32m//\x1b[0m Print the version"
)]
version: Option<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);
}
if let Some(dest) = &cli.backup {
return run_backup(&cli, dest);
}
if let Some(src) = &cli.import {
return run_import(&cli, src);
}
if cli.recover {
return run_recover(&cli);
}
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();
const FORMATS: &[&str] = &["json", "csv", "tsv", "markdown", "insert", "line", "sql"];
if !FORMATS.contains(&fmt.as_str()) {
return Err(anyhow!(
"--export expects one of {}, got '{}'",
FORMATS.join(" "),
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, cli.table.as_deref())?);
Ok(())
}
fn run_recover(cli: &Cli) -> Result<()> {
let file = cli
.file
.clone()
.ok_or_else(|| anyhow!("--recover requires a file argument"))?;
let found = recover::recover(&file)?;
let (tables, rows, orphans) = (found.tables.len(), found.rows.len(), found.orphans());
print!("{}", recover::to_sql(&found));
eprintln!(
"recovered {} row{} across {} table{}{}",
rows,
if rows == 1 { "" } else { "s" },
tables,
if tables == 1 { "" } else { "s" },
if orphans > 0 {
format!(", {orphans} in lost_and_found")
} else {
String::new()
}
);
Ok(())
}
fn run_import(cli: &Cli, src: &std::path::Path) -> Result<()> {
let file = cli
.file
.clone()
.ok_or_else(|| anyhow!("--import requires a file argument (the database)"))?;
let table = cli
.table
.clone()
.ok_or_else(|| anyhow!("--import requires --table to say where the rows go"))?;
let kind = store::detect(&file, cli.sqlite, cli.rkyv)?;
if kind != store::Kind::Sqlite {
return Err(anyhow!("--import only applies to a SQLite database"));
}
let text =
std::fs::read_to_string(src).with_context(|| format!("cannot read {}", src.display()))?;
let sep = match src.extension().and_then(|e| e.to_str()) {
Some("tsv") | Some("tab") => '\t',
_ => ',',
};
let csv = import::parse(&text, sep)?;
let (store, _) = store::Store::open(&file, kind)?;
let n = match &store {
Store::Sqlite(s) => s.import_rows(&table, &csv.header, &csv.rows)?,
_ => return Err(anyhow!("not a SQLite database")),
};
println!("imported {} rows into {}", n, table);
Ok(())
}
fn run_backup(cli: &Cli, dest: &std::path::Path) -> Result<()> {
let file = cli
.file
.clone()
.ok_or_else(|| anyhow!("--backup requires a file argument"))?;
let kind = store::detect(&file, cli.sqlite, cli.rkyv)?;
if kind != store::Kind::Sqlite {
return Err(anyhow!("--backup only applies to a SQLite database"));
}
if dest.exists() {
return Err(anyhow!("{} already exists", dest.display()));
}
let (store, _) = store::Store::open(&file, kind)?;
match &store {
Store::Sqlite(s) => s.backup_to(dest)?,
_ => return Err(anyhow!("not a SQLite database")),
}
let size = std::fs::metadata(dest).map(|m| m.len()).unwrap_or(0);
println!("wrote {} ({} bytes)", dest.display(), size);
Ok(())
}
fn export_store(store: &Store, fmt: &str, only: Option<&str>) -> Result<String> {
match store {
Store::Sqlite(s) => {
if fmt == "sql" {
return s.dump(only);
}
let tables: Vec<String> = match only {
Some(t) => {
if !s.tables.iter().any(|x| x == t) {
return Err(anyhow!("no such table: {t}"));
}
vec![t.to_string()]
}
None if fmt == "json" => s.tables.clone(),
None => s
.tables
.first()
.cloned()
.map(|t| vec![t])
.ok_or_else(|| anyhow!("no tables to export"))?,
};
let page = |t: &str| -> Result<crate::sqlite::RowsView> {
let total = s.count(t)?;
s.rows(&crate::sqlite::PageQuery::all(t, total.max(1)))
};
match fmt {
"csv" => {
let v = page(&tables[0])?;
Ok(export::rows_to_csv(&v.columns, &v.rows))
}
"tsv" => {
let v = page(&tables[0])?;
Ok(export::rows_to_tsv(&v.columns, &v.rows))
}
"markdown" => {
let v = page(&tables[0])?;
Ok(export::rows_to_markdown(&v.columns, &v.rows))
}
"line" => {
let v = page(&tables[0])?;
Ok(export::rows_to_lines(&v.columns, &v.rows))
}
"insert" => {
let mut out = String::new();
for t in &tables {
let columns = s.columns(t)?;
let literals = s.literal_rows(t)?;
out.push_str(&export::rows_to_inserts_exact(t, &columns, &literals));
}
Ok(out)
}
_ => {
let mut out = String::from("{");
for (i, t) in tables.iter().enumerate() {
if i > 0 {
out.push(',');
}
let v = page(t)?;
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)
}
}
}
trait Session {
fn pick(&mut self, scheme: theme::Theme) -> Result<Option<app::Picked>>;
fn open(
&mut self,
scheme: theme::Theme,
file: PathBuf,
) -> Result<(app::Outcome, theme::Theme, Option<PathBuf>)>;
}
struct TerminalSession<'a> {
cli: &'a Cli,
terminal: &'a mut DefaultTerminal,
}
impl Session for TerminalSession<'_> {
fn pick(&mut self, scheme: theme::Theme) -> Result<Option<app::Picked>> {
pick(self.cli, self.terminal, scheme)
}
fn open(
&mut self,
scheme: theme::Theme,
file: PathBuf,
) -> Result<(app::Outcome, theme::Theme, Option<PathBuf>)> {
open_and_run(self.cli, self.terminal, scheme, file)
}
}
fn run(cli: &Cli, terminal: &mut DefaultTerminal, theme: Option<theme::ThemeName>) -> Result<()> {
let scheme = app::resolve_theme(theme);
let first = cli.file.clone();
let mut session = TerminalSession { cli, terminal };
drive(&mut session, first, scheme)
}
fn drive(
session: &mut dyn Session,
first: Option<PathBuf>,
mut scheme: theme::Theme,
) -> Result<()> {
let mut pending: Option<PathBuf> = None;
if let Some(file) = first {
let (outcome, used, next) = session.open(scheme, file)?;
if outcome == app::Outcome::Quit {
return Ok(());
}
scheme = used;
pending = next;
}
loop {
let file = match pending.take() {
Some(f) => f,
None => match session.pick(scheme)? {
Some(picked) => {
scheme = picked.theme;
picked.path
}
None => return Ok(()), },
};
let (outcome, used, next) = session.open(scheme, file)?;
if outcome == app::Outcome::Quit {
return Ok(());
}
scheme = used;
pending = next;
}
}
fn pick(
cli: &Cli,
terminal: &mut DefaultTerminal,
scheme: theme::Theme,
) -> Result<Option<app::Picked>> {
let recent: Vec<mru::Entry> = mru::load()
.into_iter()
.filter(|e| e.path.exists())
.collect();
let custom_roots = !cli.scan.is_empty();
let roots: Vec<scan::Root> = if custom_roots {
cli.scan
.iter()
.map(|p| scan::Root::new(p.clone(), scan::DEEP))
.collect()
} else {
scan::default_roots()
};
let saved = if cli.no_scan || cli.rescan || custom_roots {
None
} else {
scan::load_cache().filter(|c| c.fresh())
};
let (cached, cache_age, walk) = match saved {
Some(c) => {
let age = c.age();
(c.hits, Some(age), false)
}
None => (Vec::new(), None, !cli.no_scan),
};
app::pick_mru(
terminal,
app::Picker {
recent: &recent,
theme: scheme,
cached,
cache_age,
scan: walk.then(|| scan::spawn(roots.clone())),
roots,
persist: !custom_roots,
},
)
}
fn open_and_run(
cli: &Cli,
terminal: &mut DefaultTerminal,
scheme: theme::Theme,
file: PathBuf,
) -> Result<(app::Outcome, theme::Theme, Option<PathBuf>)> {
let kind = store::detect(&file, cli.sqlite, cli.rkyv)?;
let (store, actual) = store::Store::open(&file, kind)?;
mru::record(&file, actual);
let mut app = app::App::with_theme(store, scheme);
let outcome = app.run(terminal)?;
Ok((outcome, app.theme(), app.open_next()))
}
#[cfg(test)]
mod tests {
use super::{
drive, export_store, run_backup, run_export, run_import, run_recover, store, Cli, Session,
};
use crate::app::{Outcome, Picked};
use crate::theme::{Theme, ThemeName};
use clap::Parser;
use std::path::Path;
use std::path::PathBuf;
#[derive(Default)]
struct Fake {
picks: Vec<Option<Picked>>,
opens: Vec<(Outcome, Theme, Option<PathBuf>)>,
opened: Vec<(PathBuf, ThemeName)>,
picked_with: Vec<ThemeName>,
}
impl Session for Fake {
fn pick(&mut self, scheme: Theme) -> anyhow::Result<Option<Picked>> {
self.picked_with.push(scheme.name);
Ok(self.picks.remove(0))
}
fn open(
&mut self,
scheme: Theme,
file: PathBuf,
) -> anyhow::Result<(Outcome, Theme, Option<PathBuf>)> {
self.opened.push((file, scheme.name));
Ok(self.opens.remove(0))
}
}
fn theme(name: ThemeName) -> Theme {
Theme::from_name(name)
}
fn fixture(name: &str) -> PathBuf {
static N: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
let seq = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let path =
std::env::temp_dir().join(format!("zdbview_cli_{}_{seq}_{name}", std::process::id()));
let _ = std::fs::remove_file(&path);
let conn = rusqlite::Connection::open(&path).unwrap();
conn.execute_batch("CREATE TABLE t (a TEXT, raw BLOB, n REAL)")
.unwrap();
conn.execute(
"INSERT INTO t VALUES (?1, ?2, ?3)",
rusqlite::params!["it's here", vec![0x00u8, 0xff], 1.5f64],
)
.unwrap();
drop(conn);
path
}
fn cli(args: &[&str]) -> Cli {
let mut all = vec!["zdbview"];
all.extend_from_slice(args);
Cli::parse_from(all)
}
#[test]
fn export_formats_render_what_they_claim() {
let path = fixture("export.db");
let kind = store::detect(&path, false, false).unwrap();
let (st, _) = store::Store::open(&path, kind).unwrap();
let csv = export_store(&st, "csv", None).unwrap();
assert!(csv.starts_with("a,raw,n\n"), "{csv}");
assert!(
csv.contains("<blob 2 bytes>"),
"the grid describes a blob: {csv}"
);
let tsv = export_store(&st, "tsv", None).unwrap();
assert!(tsv.contains('\t') && !tsv.contains(",<blob"), "{tsv}");
let md = export_store(&st, "markdown", None).unwrap();
assert!(md.lines().nth(1).unwrap().starts_with("|--"), "{md}");
let line = export_store(&st, "line", None).unwrap();
assert!(line.contains(" a = it's here"), "{line}");
let ins = export_store(&st, "insert", None).unwrap();
assert!(ins.contains("x'00ff'"), "insert must carry the blob: {ins}");
assert!(ins.contains("'it''s here'"), "{ins}");
assert!(!ins.contains("<blob"), "no descriptions in SQL: {ins}");
let dump = export_store(&st, "sql", None).unwrap();
assert!(
dump.contains("CREATE TABLE t") && dump.contains("x'00ff'"),
"{dump}"
);
let json = export_store(&st, "json", None).unwrap();
assert!(json.starts_with("{\"t\":[{"), "{json}");
assert!(export_store(&st, "csv", Some("t")).is_ok());
assert!(export_store(&st, "csv", Some("nope"))
.unwrap_err()
.to_string()
.contains("no such table"));
let _ = std::fs::remove_file(path);
}
#[test]
fn the_cli_entry_points_report_what_they_need() {
let db = fixture("entry.db");
let db_arg = db.to_str().unwrap();
let text =
std::env::temp_dir().join(format!("zdbview_cli_text_{}.txt", std::process::id()));
std::fs::write(&text, "not a database, just words\n".repeat(6)).unwrap();
let text_arg = text.to_str().unwrap();
let err = run_export(&cli(&[db_arg, "--export", "yaml"]), "yaml")
.unwrap_err()
.to_string();
assert!(err.contains("--export expects one of"), "{err}");
let err = run_export(&cli(&["--export", "csv"]), "csv")
.unwrap_err()
.to_string();
assert!(err.contains("requires a file"), "{err}");
let err = run_backup(&cli(&["--backup", "/tmp/x.db"]), Path::new("/tmp/x.db"))
.unwrap_err()
.to_string();
assert!(err.contains("requires a file"), "{err}");
let dest = std::env::temp_dir().join(format!("zdbview_cli_bk_{}.db", std::process::id()));
let _ = std::fs::remove_file(&dest);
run_backup(&cli(&[db_arg, "--backup", dest.to_str().unwrap()]), &dest).unwrap();
assert!(dest.exists(), "the copy is written");
let err = run_backup(&cli(&[db_arg, "--backup", dest.to_str().unwrap()]), &dest)
.unwrap_err()
.to_string();
assert!(err.contains("already exists"), "{err}");
let csv = std::env::temp_dir().join(format!("zdbview_cli_in_{}.csv", std::process::id()));
std::fs::write(&csv, "a\nfrom the file\n").unwrap();
let csv_arg = csv.to_str().unwrap();
let err = run_import(&cli(&[db_arg, "--import", csv_arg]), &csv)
.unwrap_err()
.to_string();
assert!(err.contains("--table"), "{err}");
let err = run_import(
&cli(&[text_arg, "--import", csv_arg, "--table", "t", "--sqlite"]),
&csv,
)
.unwrap_err()
.to_string();
assert!(!err.is_empty(), "a text file is not importable into: {err}");
run_import(&cli(&[db_arg, "--import", csv_arg, "--table", "t"]), &csv).unwrap();
let conn = rusqlite::Connection::open(&db).unwrap();
let n: i64 = conn
.query_row(
"SELECT count(*) FROM t WHERE a = 'from the file'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(n, 1, "the row landed");
drop(conn);
let err = run_recover(&cli(&["--recover"])).unwrap_err().to_string();
assert!(err.contains("requires a file"), "{err}");
let err = run_recover(&cli(&[text_arg, "--recover", "--sqlite"]))
.unwrap_err()
.to_string();
assert!(err.contains("not a SQLite database"), "{err}");
run_recover(&cli(&[db_arg, "--recover"])).unwrap();
for p in [db, text, dest, csv] {
let _ = std::fs::remove_file(p);
}
}
#[test]
fn an_explicit_file_that_quits_never_reaches_the_picker() {
let mut f = Fake {
opens: vec![(Outcome::Quit, theme(ThemeName::NeonSprawl), None)],
..Default::default()
};
drive(
&mut f,
Some(PathBuf::from("/tmp/a.db")),
theme(ThemeName::NeonSprawl),
)
.unwrap();
assert_eq!(f.opened.len(), 1);
assert!(f.picked_with.is_empty(), "the picker must not open");
}
#[test]
fn reopen_carries_the_scheme_into_the_picker_and_the_next_file() {
let mut f = Fake {
opens: vec![
(Outcome::Reopen, theme(ThemeName::AcidRain), None),
(Outcome::Quit, theme(ThemeName::AcidRain), None),
],
picks: vec![Some(Picked {
path: PathBuf::from("/tmp/b.rkyv"),
theme: theme(ThemeName::SynthWave),
})],
..Default::default()
};
drive(
&mut f,
Some(PathBuf::from("/tmp/a.db")),
theme(ThemeName::NeonSprawl),
)
.unwrap();
assert_eq!(
f.picked_with,
vec![ThemeName::AcidRain],
"the file's scheme"
);
assert_eq!(
f.opened,
vec![
(PathBuf::from("/tmp/a.db"), ThemeName::NeonSprawl),
(PathBuf::from("/tmp/b.rkyv"), ThemeName::SynthWave),
]
);
}
#[test]
fn a_file_from_the_monitor_skips_the_picker_once() {
let mut f = Fake {
opens: vec![
(
Outcome::Reopen,
theme(ThemeName::NeonSprawl),
Some(PathBuf::from("/tmp/watched.db")),
),
(Outcome::Reopen, theme(ThemeName::NeonSprawl), None),
(Outcome::Quit, theme(ThemeName::NeonSprawl), None),
],
picks: vec![Some(Picked {
path: PathBuf::from("/tmp/from_picker.db"),
theme: theme(ThemeName::NeonSprawl),
})],
..Default::default()
};
drive(
&mut f,
Some(PathBuf::from("/tmp/a.db")),
theme(ThemeName::NeonSprawl),
)
.unwrap();
let files: Vec<&str> = f.opened.iter().map(|(p, _)| p.to_str().unwrap()).collect();
assert_eq!(
files,
["/tmp/a.db", "/tmp/watched.db", "/tmp/from_picker.db"],
"the handed-over file comes before the picker is asked"
);
assert_eq!(f.picked_with.len(), 1, "asked once, after the handover");
}
#[test]
fn quitting_the_picker_ends_a_session_with_no_file() {
let mut f = Fake {
picks: vec![None],
..Default::default()
};
drive(&mut f, None, theme(ThemeName::NeonSprawl)).unwrap();
assert!(f.opened.is_empty(), "nothing was opened");
assert_eq!(f.picked_with.len(), 1);
}
}