diff --git a/Cargo.lock b/Cargo.lock index fb8d64d..efc7e3b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -263,6 +263,8 @@ dependencies = [ "postgres", "postgres-native-tls", "ratatui", + "rusqlite", + "tempfile", ] [[package]] @@ -342,6 +344,18 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "2.5.0" @@ -560,6 +574,17 @@ dependencies = [ "libc", ] +[[package]] +name = "libsqlite3-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "line-clipping" version = "0.3.8" @@ -847,7 +872,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33ad20e0aa0b24f5a394eab4f78c781d248982b22b25cecc7e3aa46a681605bd" dependencies = [ "bytes", - "fallible-iterator", + "fallible-iterator 0.2.0", "futures-util", "log", "tokio", @@ -875,7 +900,7 @@ dependencies = [ "base64", "byteorder", "bytes", - "fallible-iterator", + "fallible-iterator 0.2.0", "hmac", "md-5", "memchr", @@ -891,7 +916,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "851ca9db4932932d69f3ea811b1abe63087a0f740a47692619dd40d4899b68be" dependencies = [ "bytes", - "fallible-iterator", + "fallible-iterator 0.2.0", "postgres-protocol", ] @@ -1017,6 +1042,19 @@ dependencies = [ "bitflags", ] +[[package]] +name = "rusqlite" +version = "0.40.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3" +dependencies = [ + "bitflags", + "fallible-iterator 0.3.0", + "fallible-streaming-iterator", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "rustc_version" version = "0.4.1" @@ -1360,7 +1398,7 @@ dependencies = [ "async-trait", "byteorder", "bytes", - "fallible-iterator", + "fallible-iterator 0.2.0", "futures-channel", "futures-util", "log", diff --git a/Cargo.toml b/Cargo.toml index 392db14..0168156 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,7 @@ name = "dbdash" version = "0.1.0" edition = "2024" -description = "Read-only PostgreSQL inspection in your terminal" +description = "Read-only PostgreSQL and SQLite inspection in your terminal" [dependencies] ratatui = { version = "0.30.2", default-features = false, features = ["crossterm"] } @@ -10,3 +10,7 @@ crossterm = "0.29" postgres = "0.19" postgres-native-tls = "0.5" native-tls = "0.2" +rusqlite = { version = "0.40", default-features = false, features = ["bundled"] } + +[dev-dependencies] +tempfile = "3" diff --git a/src/app.rs b/src/app.rs index 6e8bdc1..f2027c9 100644 --- a/src/app.rs +++ b/src/app.rs @@ -3,6 +3,7 @@ use ratatui::{ DefaultTerminal, Frame, layout::{Constraint, Layout}, style::{Color, Modifier, Style}, + text::Line, widgets::{Block, Cell, List, ListItem, ListState, Paragraph, Row, Table, TableState}, }; @@ -152,7 +153,7 @@ impl App { fn load_page(&mut self, index: usize) { if let Some(active) = self.active { - // ponytail: synchronous reads block input; server timeout is 5s, use a worker if responsiveness matters. + // ponytail: synchronous reads block input; use a worker if responsiveness matters. match self.db.page(&self.relations[active], &self.columns, index) { Ok(page) => { self.rows_state = @@ -169,10 +170,10 @@ impl App { fn refresh(&mut self) { match self.db.relations() { Ok(relations) => { - let oid = self.active.map(|index| self.relations[index].oid); + let id = self.active.map(|index| self.relations[index].id); let selected = relations .iter() - .position(|relation| Some(relation.oid) == oid) + .position(|relation| Some(relation.id) == id) .or_else(|| (!relations.is_empty()).then_some(0)); self.relations = relations; self.selection = ListState::default().with_selected(selected); @@ -199,8 +200,12 @@ impl App { ]) .areas(area); frame.render_widget( - Paragraph::new(" dbdash │ PostgreSQL │ READ ONLY") - .style(Style::default().fg(Color::Cyan)), + Paragraph::new(Line::from_iter([ + " dbdash │ ", + self.db.name(), + " │ READ ONLY", + ])) + .style(Style::default().fg(Color::Cyan)), header, ); let [sidebar, content] = diff --git a/src/db.rs b/src/db.rs index 838514e..c77afd4 100644 --- a/src/db.rs +++ b/src/db.rs @@ -1,4 +1,4 @@ -use std::time::Duration; +use std::{path::Path, time::Duration}; use native_tls::TlsConnector; use postgres::{Client, Config}; @@ -6,14 +6,25 @@ use postgres_native_tls::MakeTlsConnector; use crate::Result; +mod sqlite; + pub const PAGE_SIZE: usize = 100; pub struct Database { - client: Client, + backend: Backend, +} + +#[allow( + clippy::large_enum_variant, + reason = "ponytail: one connection per app; box only if storing backends in bulk" +)] +enum Backend { + Postgres(Client), + Sqlite(rusqlite::Connection), } pub struct Relation { - pub oid: u32, + pub id: i64, pub schema: String, pub name: String, pub label: String, @@ -47,11 +58,30 @@ impl Database { client.batch_execute( "SET default_transaction_read_only = on; SET statement_timeout = '5s';", )?; - Ok(Self { client }) + Ok(Self { + backend: Backend::Postgres(client), + }) + } + + pub fn open_sqlite(path: &Path) -> Result { + Ok(Self { + backend: Backend::Sqlite(sqlite::open(path)?), + }) + } + + pub fn name(&self) -> &'static str { + match self.backend { + Backend::Postgres(_) => "PostgreSQL", + Backend::Sqlite(_) => "SQLite", + } } pub fn relations(&mut self) -> Result> { - let rows = self.client.query( + let client = match &mut self.backend { + Backend::Postgres(client) => client, + Backend::Sqlite(connection) => return sqlite::relations(connection), + }; + let rows = client.query( "SELECT c.oid, n.nspname, c.relname, c.relkind::text FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace @@ -70,7 +100,7 @@ impl Database { let name: String = row.get(2); let kind: &str = row.get(3); Relation { - oid: row.get(0), + id: i64::from(row.get::<_, u32>(0)), label: display_text(&format!("{schema}.{name}")), schema, name, @@ -89,7 +119,12 @@ impl Database { } pub fn columns(&mut self, relation: &Relation) -> Result> { - let rows = self.client.query( + let client = match &mut self.backend { + Backend::Postgres(client) => client, + Backend::Sqlite(connection) => return sqlite::columns(connection, relation), + }; + let oid = u32::try_from(relation.id)?; + let rows = client.query( "SELECT a.attname, pg_catalog.format_type(a.atttypid, a.atttypmod), NOT a.attnotnull, EXISTS (SELECT FROM pg_catalog.pg_index i @@ -101,7 +136,7 @@ impl Database { ON d.adrelid = a.attrelid AND d.adnum = a.attnum WHERE a.attrelid = $1 AND a.attnum > 0 AND NOT a.attisdropped ORDER BY a.attnum", - &[&relation.oid], + &[&oid], )?; Ok(rows .into_iter() @@ -120,6 +155,12 @@ impl Database { } pub fn page(&mut self, relation: &Relation, columns: &[Column], page: usize) -> Result { + let client = match &mut self.backend { + Backend::Postgres(client) => client, + Backend::Sqlite(connection) => { + return sqlite::page(connection, relation, columns, page); + } + }; let projection = columns .iter() .map(|column| format!("t.{}::text", quote_identifier(&column.name))) @@ -145,12 +186,8 @@ impl Database { quote_identifier(&relation.schema), quote_identifier(&relation.name), ); - let offset = i64::try_from(page)? - .checked_mul(PAGE_SIZE as i64) - .ok_or("page offset overflow")?; - let result = self - .client - .query(&sql, &[&((PAGE_SIZE + 1) as i64), &offset])?; + let offset = page_offset(page)?; + let result = client.query(&sql, &[&((PAGE_SIZE + 1) as i64), &offset])?; let has_next = result.len() > PAGE_SIZE; let rows = result .into_iter() @@ -165,6 +202,12 @@ impl Database { } } +fn page_offset(page: usize) -> Result { + i64::try_from(page)? + .checked_mul(PAGE_SIZE as i64) + .ok_or_else(|| "page offset overflow".into()) +} + fn quote_identifier(value: &str) -> String { format!("\"{}\"", value.replace('"', "\"\"")) } @@ -273,8 +316,10 @@ mod tests { let page = db.page(empty, &columns, 0)?; assert!(page.rows.is_empty()); assert!(!page.has_next); - let error = db - .client + let Backend::Postgres(client) = &mut db.backend else { + unreachable!() + }; + let error = client .batch_execute(&format!("DELETE FROM {schema}.empty_table")) .unwrap_err(); assert_eq!( diff --git a/src/db/sqlite.rs b/src/db/sqlite.rs new file mode 100644 index 0000000..8b94ac0 --- /dev/null +++ b/src/db/sqlite.rs @@ -0,0 +1,304 @@ +use std::{fmt::Write, path::Path, time::Duration}; + +use rusqlite::{Connection, OpenFlags, params, types::ValueRef}; + +use super::{Column, PAGE_SIZE, Page, Relation, display_text, page_offset, quote_identifier}; +use crate::Result; + +pub(super) fn open(path: &Path) -> Result { + // An absolute existing path also prevents SQLite's special :memory: filename from creating a database. + let path = path.canonicalize()?; + let connection = Connection::open_with_flags( + path, + OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX, + )?; + connection.busy_timeout(Duration::from_secs(5))?; + connection.execute_batch("PRAGMA query_only = ON; PRAGMA trusted_schema = OFF;")?; + Ok(connection) +} + +pub(super) fn relations(connection: &Connection) -> Result> { + let mut statement = connection.prepare( + "SELECT s.rowid, s.name, s.type, t.wr + FROM main.sqlite_schema s + JOIN pragma_table_list t ON t.schema = 'main' AND t.name = s.name + WHERE s.type IN ('table', 'view') AND s.name NOT GLOB 'sqlite_*' + AND t.type <> 'shadow' + ORDER BY s.name", + )?; + let relations = statement + .query_map([], |row| { + let name: String = row.get(1)?; + let kind: String = row.get(2)?; + Ok(Relation { + id: row.get(0)?, + schema: "main".into(), + label: display_text(&format!("main.{name}")), + name, + physical_order: kind == "table" && !row.get::<_, bool>(3)?, + kind, + }) + })? + .collect::>>()?; + Ok(relations) +} + +pub(super) fn columns(connection: &Connection, relation: &Relation) -> Result> { + let mut statement = connection.prepare( + "SELECT name, type, \"notnull\", dflt_value, pk, + pk > 0 AND upper(type) = 'INTEGER' AND NOT EXISTS ( + SELECT 1 FROM pragma_index_list(?1, 'main') WHERE origin = 'pk' + ) AS rowid_alias + FROM pragma_table_xinfo(?1, 'main') + WHERE hidden <> 1 ORDER BY cid", + )?; + let columns = statement + .query_map([&relation.name], |row| { + let name: String = row.get(0)?; + Ok(Column { + label: display_text(&name), + name, + data_type: display_text(&row.get::<_, String>(1)?), + nullable: !row.get::<_, bool>(2)? && !row.get::<_, bool>(5)?, + default: row + .get::<_, Option>(3)? + .as_deref() + .map(display_text) + .unwrap_or_default(), + primary_key: row.get::<_, i64>(4)? > 0, + }) + })? + .collect::>>()?; + Ok(columns) +} + +pub(super) fn page( + connection: &Connection, + relation: &Relation, + columns: &[Column], + page: usize, +) -> Result { + let projection = columns + .iter() + .map(|column| format!("t.{}", quote_identifier(&column.name))) + .collect::>() + .join(", "); + let mut order = columns + .iter() + .filter(|column| column.primary_key) + .map(|column| format!("t.{}", quote_identifier(&column.name))) + .collect::>() + .join(", "); + if order.is_empty() + && relation.physical_order + && let Some(rowid) = ["_rowid_", "rowid", "oid"].into_iter().find(|name| { + !columns + .iter() + .any(|column| column.name.eq_ignore_ascii_case(name)) + }) + { + order = format!("t.{}", quote_identifier(rowid)); + } + if !order.is_empty() { + order.insert_str(0, " ORDER BY "); + } + let sql = format!( + "SELECT {projection} FROM {}.{} AS t{order} LIMIT ?1 OFFSET ?2", + quote_identifier(&relation.schema), + quote_identifier(&relation.name) + ); + let mut statement = connection.prepare(&sql)?; + let mut query = statement.query(params![(PAGE_SIZE + 1) as i64, page_offset(page)?])?; + let mut rows = Vec::with_capacity(PAGE_SIZE); + while let Some(row) = query.next()? { + if rows.len() == PAGE_SIZE { + return Ok(Page { + rows, + has_next: true, + }); + } + let values = (0..columns.len()) + .map(|index| { + Ok(match row.get_ref(index)? { + ValueRef::Null => None, + ValueRef::Integer(value) => Some(value.to_string()), + ValueRef::Real(value) => Some(value.to_string()), + ValueRef::Text(value) => Some(match std::str::from_utf8(value) { + Ok(text) => display_text(text), + Err(_) => value.escape_ascii().to_string(), + }), + ValueRef::Blob(value) => Some(blob_text(value)), + }) + }) + .collect::>>()?; + rows.push(values); + } + Ok(Page { + rows, + has_next: false, + }) +} + +fn blob_text(bytes: &[u8]) -> String { + let mut output = String::with_capacity(bytes.len() * 2 + 3); + output.push_str("X'"); + for byte in bytes { + write!(output, "{byte:02X}").expect("writing to a String cannot fail"); + } + output.push('\''); + output +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::{Backend, Database}; + + #[test] + fn inspect_sqlite_read_only() -> Result<()> { + let directory = tempfile::tempdir()?; + let path = directory.path().join("inspection.sqlite"); + let writer = Connection::open(&path)?; + writer.execute_batch( + "CREATE TABLE \"odd\"\"table\" ( + id INTEGER PRIMARY KEY, \"odd\"\"column\" TEXT, + amount REAL NOT NULL DEFAULT 1.25, bytes BLOB, mixed, + generated INTEGER GENERATED ALWAYS AS (id + 1) STORED); + WITH RECURSIVE numbers(n) AS ( + SELECT 1 UNION ALL SELECT n + 1 FROM numbers WHERE n < 101 + ) INSERT INTO \"odd\"\"table\" (id, \"odd\"\"column\", amount, bytes, mixed) + SELECT n, CASE WHEN n = 1 THEN NULL WHEN n = 2 THEN char(27) || '[2J' ELSE 'NULL' END, + 1.25, X'00FF', CASE n WHEN 1 THEN 7 WHEN 2 THEN 2.5 + WHEN 3 THEN X'0080' WHEN 4 THEN CAST(X'FF' AS TEXT) ELSE 'text' END + FROM numbers; + CREATE VIEW sample_view AS SELECT id FROM \"odd\"\"table\"; + CREATE TABLE empty_table (id INTEGER); + CREATE TABLE keyed (a TEXT, b INTEGER, PRIMARY KEY (a, b)) WITHOUT ROWID; + INSERT INTO keyed VALUES ('b', 2), ('a', 1); + CREATE TABLE nullable_pk (id INTEGER PRIMARY KEY DESC); + CREATE TABLE keyless (_rowid_ TEXT, rowid TEXT, value INTEGER); + WITH RECURSIVE numbers(n) AS ( + SELECT 1 UNION ALL SELECT n + 1 FROM numbers WHERE n < 100 + ) INSERT INTO keyless SELECT CAST(n AS TEXT), CAST(n AS TEXT), n FROM numbers ORDER BY n DESC;" + )?; + let mut db = Database::open_sqlite(&path)?; + let relations = db.relations()?; + assert!( + !relations + .iter() + .any(|relation| relation.name.starts_with("sqlite_")) + ); + let table = relations + .iter() + .find(|relation| relation.name == "odd\"table") + .unwrap(); + let columns = db.columns(table)?; + assert!(!columns[0].nullable); + assert!(columns[0].primary_key); + assert_eq!(columns[1].name, "odd\"column"); + assert!(columns[1].nullable); + assert_eq!(columns[2].data_type, "REAL"); + assert!(!columns[2].nullable); + assert_eq!(columns[2].default, "1.25"); + assert_eq!(columns[5].name, "generated"); + let first = db.page(table, &columns, 0)?; + assert!(first.has_next); + assert_eq!(first.rows.len(), 100); + assert_eq!( + first.rows[0], + vec![ + Some("1".into()), + None, + Some("1.25".into()), + Some("X'00FF'".into()), + Some("7".into()), + Some("2".into()) + ] + ); + assert_eq!(first.rows[1][1].as_deref(), Some("\\u{1b}[2J")); + assert_eq!(first.rows[2][1].as_deref(), Some("NULL")); + assert_eq!(first.rows[1][4].as_deref(), Some("2.5")); + assert_eq!(first.rows[2][4].as_deref(), Some("X'0080'")); + assert_eq!(first.rows[3][4].as_deref(), Some("\\xff")); + let second = db.page(table, &columns, 1)?; + assert!(!second.has_next); + assert_eq!( + second.rows, + vec![vec![ + Some("101".into()), + Some("NULL".into()), + Some("1.25".into()), + Some("X'00FF'".into()), + Some("text".into()), + Some("102".into()) + ]] + ); + let view = relations + .iter() + .find(|relation| relation.name == "sample_view") + .unwrap(); + assert_eq!(view.kind, "view"); + let view_columns = db.columns(view)?; + assert_eq!( + db.page(view, &view_columns, 1)?.rows[0][0].as_deref(), + Some("101") + ); + let empty = relations + .iter() + .find(|relation| relation.name == "empty_table") + .unwrap(); + let empty_columns = db.columns(empty)?; + let empty_page = db.page(empty, &empty_columns, 0)?; + assert!(empty_page.rows.is_empty()); + assert!(!empty_page.has_next); + let keyed = relations + .iter() + .find(|relation| relation.name == "keyed") + .unwrap(); + let keyed_columns = db.columns(keyed)?; + assert!(!keyed_columns[0].nullable); + assert_eq!( + db.page(keyed, &keyed_columns, 0)?.rows, + vec![ + vec![Some("a".into()), Some("1".into())], + vec![Some("b".into()), Some("2".into())] + ] + ); + let nullable_pk = relations + .iter() + .find(|relation| relation.name == "nullable_pk") + .unwrap(); + assert!(db.columns(nullable_pk)?[0].nullable); + let keyless = relations + .iter() + .find(|relation| relation.name == "keyless") + .unwrap(); + let keyless_columns = db.columns(keyless)?; + let page = db.page(keyless, &keyless_columns, 0)?; + assert!(!page.has_next); + assert_eq!(page.rows.len(), 100); + assert_eq!(page.rows[0][2].as_deref(), Some("100")); + assert_eq!(page.rows[99][2].as_deref(), Some("1")); + let Backend::Sqlite(connection) = &db.backend else { + unreachable!() + }; + assert!(connection.is_readonly("main")?); + let error = connection + .execute("DELETE FROM empty_table", []) + .unwrap_err(); + assert!( + matches!(error, rusqlite::Error::SqliteFailure(error, _) if error.code == rusqlite::ErrorCode::ReadOnly) + ); + let missing = directory.path().join("missing.sqlite"); + assert!(Database::open_sqlite(&missing).is_err()); + assert!(!missing.exists()); + let path = directory.path().join("invalid.sqlite"); + std::fs::write(&path, b"not a SQLite database")?; + assert!( + Database::open_sqlite(&path) + .and_then(|mut db| db.relations()) + .is_err() + ); + Ok(()) + } +} diff --git a/src/main.rs b/src/main.rs index 9e79bf2..83299b7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,14 +1,15 @@ mod app; mod db; -use std::{error::Error, io::IsTerminal, process::ExitCode}; +use std::{error::Error, io::IsTerminal, path::Path, process::ExitCode}; type Result> = std::result::Result; -const HELP: &str = r#"dbdash — read-only PostgreSQL inspection +const HELP: &str = r#"dbdash — read-only PostgreSQL and SQLite inspection Usage: dbdash [POSTGRES_CONNECTION_STRING] + dbdash --sqlite PATH dbdash --help With no argument, the connection is read from DATABASE_URL. @@ -16,6 +17,10 @@ Prefer the environment variable to keep passwords out of process arguments: export DATABASE_URL='postgresql://user@localhost:5432/database' cargo run +Open an existing SQLite database file (read-only; missing files are not created): + cargo run -- --sqlite ./database.sqlite +SQLite paths can be relative or absolute; quote paths containing spaces. + PostgreSQL URLs and libpq-style key=value strings are accepted. Use sslmode=require for mandatory TLS; certificates and hostnames are verified. Use sslmode=disable only for trusted local connections. @@ -31,9 +36,11 @@ Navigation: r Refresh relations and the active relation q / Ctrl-C Quit -Reads are enforced read-only by PostgreSQL and have a 5-second query timeout. -The interface waits for reads to complete. NULL cells are dimmed. -Pages use primary-key order, or physical order for keyless tables. +PostgreSQL reads are enforced read-only and have a 5-second query timeout. +SQLite files are opened read-only, with a 5-second lock wait but no query timeout. +The interface waits for reads to complete. NULL cells are dimmed; SQLite BLOBs +are shown as X'hex'. Only SQLite's main database is inspected. +Pages use primary-key order, or physical row order for keyless tables when available. Keyless views have no guaranteed order; concurrent changes can shift pages. "#; @@ -49,24 +56,38 @@ fn main() -> ExitCode { fn run() -> Result<()> { let mut args = std::env::args_os().skip(1); - let connection = match args.next() { - Some(argument) if argument == "--help" || argument == "-h" => { - print!("{HELP}"); - return Ok(()); - } - Some(argument) => argument - .into_string() - .map_err(|_| "connection string must be UTF-8")?, - None => std::env::var("DATABASE_URL") - .map_err(|_| "set DATABASE_URL or pass a connection string; see dbdash --help")?, + let argument = args.next(); + if argument + .as_ref() + .is_some_and(|arg| arg == "--help" || arg == "-h") + { + print!("{HELP}"); + return Ok(()); + } + let sqlite = argument.as_ref().is_some_and(|arg| arg == "--sqlite"); + let connection = if sqlite { + args.next() + .ok_or("--sqlite requires a database file path; see dbdash --help")? + } else { + argument.or_else(|| std::env::var_os("DATABASE_URL")) + .ok_or("set DATABASE_URL, pass a PostgreSQL connection string, or use --sqlite PATH; see dbdash --help")? }; if args.next().is_some() { - return Err("expected one connection string; see dbdash --help".into()); + return Err("unexpected extra arguments; see dbdash --help".into()); } if !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() { return Err("an interactive terminal is required; see dbdash --help".into()); } - let mut app = app::App::new(db::Database::connect(&connection)?)?; + let database = if sqlite { + db::Database::open_sqlite(Path::new(&connection))? + } else { + db::Database::connect( + connection + .to_str() + .ok_or("connection string must be UTF-8")?, + )? + }; + let mut app = app::App::new(database)?; let result = (|| { let mut terminal = ratatui::try_init()?; app.run(&mut terminal)