use std::path::{Path, PathBuf}; use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use postgres::{Config, config::SslMode}; use ratatui::{ DefaultTerminal, Frame, layout::{Constraint, Layout, Rect}, style::{Color, Style}, text::{Line, Text}, widgets::{Block, Clear, List, ListItem, ListState, Paragraph, Wrap}, }; use crate::{ Result, app::{App, Focus}, db::Database, error_text, }; pub fn run(terminal: &mut DefaultTerminal, initial: Option) -> Result<()> { let mut profiles = Profiles::open(&config_path()?)?; let mut browser = initial.map(App::new).transpose()?; let mut selection = ListState::default().with_selected((!profiles.entries.is_empty()).then_some(0)); let mut focus = Focus::Explorer; let mut active = None; let mut expanded = true; let mut error: Option = None; let mut form = Form::default(); let mut show_form = false; loop { let entries = explorer_entries( profiles.entries.len(), active, browser.as_ref().map(|app| app.relations.len()), expanded, ); terminal.draw(|frame| { let navigation = Paragraph::new(if focus == Focus::Query && !show_form { " Tab/Shift-Tab focus · Esc explorer · Enter newline · F5/Ctrl-Enter execute · Ctrl-U clear · Ctrl-C quit" } else { " Tab/Shift-Tab focus · Esc explorer · ↑↓/jk move · Enter open · s schema/rows · ←→/hl columns · n/p page · r refresh · c add database · q quit" }).wrap(Wrap { trim: true }).style(Style::default().fg(Color::DarkGray)); let help_height = navigation.line_count(frame.area().width) as u16; let [main, help] = Layout::vertical([Constraint::Min(1), Constraint::Length(help_height)]).areas(frame.area()); let [sidebar, content] = Layout::horizontal([Constraint::Length(32), Constraint::Min(1)]) .areas(main); let items = entries.iter().map(|entry| { ListItem::new(match *entry { ExplorerEntry::Database(index) => { let connected = browser.is_some() && index == active; let label = index.map_or_else(|| "Session database".into(), |index| profiles.entries[index].label()); format!("{} {label}", if connected && expanded { "▼" } else { "▶" }) } ExplorerEntry::Relation(index) => { let relation = &browser.as_ref().unwrap().relations[index]; format!(" ├ {} [{}]", relation.label, relation.kind) } }) }); let list = List::new(items) .block(Block::bordered().title(" Explorer · c add ").border_style( Style::default().fg(if focus == Focus::Explorer { Color::Cyan } else { Color::DarkGray }))) .highlight_style(Style::default().bg(Color::Blue).fg(Color::White)); frame.render_stateful_widget(list, sidebar, &mut selection); if let Some(browser) = &mut browser { browser.draw(frame, content, focus); } else { frame.render_widget(Paragraph::new("Select a database and press Enter to connect.\n\nc: add a database · Tab: focus · q: quit").block(Block::bordered().title(" dbdash · READ ONLY ")), content); } if let Some(error) = &error { frame.render_widget(Paragraph::new(error.as_str()).style(Style::default().fg(Color::Red)), Rect::new(content.x, content.bottom().saturating_sub(1), content.width, 1)); } frame.render_widget(navigation, help); if show_form { form.draw(frame); } })?; match event::read()? { Event::Key(key) if matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) => { if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) { return Ok(()); } if show_form { match key.code { KeyCode::Esc => { form.clear_password(); show_form = false; } KeyCode::Enter if form.focus == 0 => form.focus = 1, KeyCode::Enter => { let result = if form.reconnect { form.connect().and_then(App::new).map(|connected| { browser = Some(connected); active = selection.selected().and_then(|index| { match entries[index] { ExplorerEntry::Database(index) => index, _ => None, } }); selection.select(active); expanded = true; focus = Focus::Explorer; }) } else { profiles.save(&form).map(|()| { let updated = explorer_entries( profiles.entries.len(), active, browser.as_ref().map(|app| app.relations.len()), expanded, ); selection.select(updated.iter().position(|entry| { *entry == ExplorerEntry::Database(Some( profiles.entries.len() - 1, )) })); focus = Focus::Explorer; }) }; match result { Ok(()) => { form.clear_password(); show_form = false; error = None; } Err(error) => form.error = Some(error_text(error.as_ref())), } } _ => form.edit(key), } } else { match key.code { KeyCode::Esc => focus = Focus::Explorer, KeyCode::Tab | KeyCode::BackTab if browser.is_some() => { focus = match (focus, key.code == KeyCode::BackTab) { (Focus::Explorer, false) | (Focus::Rows, true) => Focus::Query, (Focus::Query, false) | (Focus::Explorer, true) => Focus::Rows, _ => Focus::Explorer, }; } _ if focus == Focus::Query => { if let Some(browser) = &mut browser { browser.handle_query_key(key); } } KeyCode::Char('q') => return Ok(()), KeyCode::Char('c') => { form = Form::default(); show_form = true; } KeyCode::Enter if focus == Focus::Explorer => { if let Some(entry) = selection.selected().and_then(|index| entries.get(index)) { let index = match *entry { ExplorerEntry::Relation(index) => { browser.as_mut().unwrap().open_relation(Some(index)); focus = Focus::Rows; continue; } ExplorerEntry::Database(index) if browser.is_some() && index == active => { expanded = !expanded; continue; } ExplorerEntry::Database(Some(index)) => index, ExplorerEntry::Database(None) => continue, }; form = profiles.entries[index].clone(); if form.provider == 0 { form.reconnect = true; form.focus = 5; show_form = true; } else { match form.connect().and_then(App::new) { Ok(connected) => { browser = Some(connected); active = Some(index); selection.select(Some(index)); expanded = true; focus = Focus::Explorer; error = None; } Err(failure) => error = Some(error_text(failure.as_ref())), } } } } KeyCode::Left | KeyCode::Char('h') if focus == Focus::Explorer => { if matches!( selection.selected().and_then(|index| entries.get(index)), Some(ExplorerEntry::Relation(_)) ) || selection.selected().and_then(|index| entries.get(index)) == Some(&ExplorerEntry::Database(active)) { expanded = false; selection.select( entries.iter().position(|entry| { *entry == ExplorerEntry::Database(active) }), ); } } KeyCode::Right | KeyCode::Char('l') if focus == Focus::Explorer => { if selection.selected().and_then(|index| entries.get(index)) == Some(&ExplorerEntry::Database(active)) { expanded = true; } } KeyCode::Char('r') if focus == Focus::Explorer => { if let Some(browser) = &mut browser { browser.handle_key(key); } } KeyCode::Down | KeyCode::Char('j') if focus == Focus::Explorer => { selection.select_next() } KeyCode::Up | KeyCode::Char('k') if focus == Focus::Explorer => { selection.select_previous() } KeyCode::Home | KeyCode::Char('g') if focus == Focus::Explorer => { selection.select_first() } KeyCode::End | KeyCode::Char('G') if focus == Focus::Explorer => { selection.select_last() } _ if focus == Focus::Rows => { if let Some(browser) = &mut browser { browser.handle_key(key); } } _ => {} } } } Event::Paste(text) if show_form => { form.error = None; if let Some(input) = form.input_mut() { input.paste(&text); } } Event::Paste(text) if focus == Focus::Query => { if let Some(browser) = &mut browser { browser.paste_query(&text); } } _ => {} } } } #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum ExplorerEntry { Database(Option), Relation(usize), } fn explorer_entries( count: usize, active: Option, relations: Option, expanded: bool, ) -> Vec { let mut entries = Vec::new(); let session = active.is_none() && relations.is_some(); for database in session .then_some(None) .into_iter() .chain((0..count).map(Some)) { entries.push(ExplorerEntry::Database(database)); if database == active && expanded { entries.extend((0..relations.unwrap_or(0)).map(ExplorerEntry::Relation)); } } entries } fn config_path() -> Result { let root = std::env::var_os("XDG_CONFIG_HOME") .filter(|path| Path::new(path).is_absolute()) .map(PathBuf::from) .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config"))) .ok_or("Set HOME or an absolute XDG_CONFIG_HOME to save database configurations.")?; Ok(root.join("dbdash/connections.sqlite3")) } struct Profiles { storage: rusqlite::Connection, entries: Vec
, } impl Profiles { fn open(path: &Path) -> Result { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } let storage = rusqlite::Connection::open(path)?; storage.execute_batch( "CREATE TABLE IF NOT EXISTS connections ( id INTEGER PRIMARY KEY, provider INTEGER NOT NULL CHECK(provider IN (0, 1)), host TEXT NOT NULL, port TEXT NOT NULL, database_name TEXT NOT NULL, username TEXT NOT NULL, tls INTEGER NOT NULL CHECK(tls BETWEEN 0 AND 2), path TEXT NOT NULL )", )?; let entries = storage.prepare("SELECT provider, host, port, database_name, username, tls, path FROM connections ORDER BY id")? .query_map([], |row| { let mut form = Form { provider: usize::from(row.get::<_, u8>(0)?), tls: usize::from(row.get::<_, u8>(5)?), path: Input::from(row.get::<_, String>(6)?.as_str()), ..Form::default() }; for index in 0..4 { form.postgres[index] = Input::from(row.get::<_, String>(index + 1)?.as_str()); } Ok(form) })? .collect::>>()?; Ok(Self { storage, entries }) } fn save(&mut self, form: &Form) -> Result<()> { let mut saved = form.clone(); saved.clear_password(); if form.provider == 0 { form.postgres_config()?; } else if form.path.value.is_empty() { return Err("Enter a SQLite database file path.".into()); } else { let path = shellexpand::path::full(Path::new(&form.path.value))?; let absolute = if path.is_absolute() { path.into_owned() } else { std::env::current_dir()?.join(path) }; saved.path = Input::from(absolute.to_str().ok_or("SQLite path must be UTF-8")?); } self.storage.execute("INSERT INTO connections (provider, host, port, database_name, username, tls, path) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", rusqlite::params![saved.provider as u8, saved.postgres[0].value, saved.postgres[1].value, saved.postgres[2].value, saved.postgres[3].value, saved.tls as u8, saved.path.value])?; self.entries.push(saved); Ok(()) } } #[derive(Clone)] struct Form { provider: usize, focus: usize, postgres: [Input; 5], tls: usize, path: Input, error: Option, reconnect: bool, } impl Default for Form { fn default() -> Self { Self { provider: 0, focus: 0, tls: 0, postgres: ["localhost", "5432", "postgres", "", ""].map(Input::from), path: Input::default(), error: None, reconnect: false, } } } impl Form { fn connect(&self) -> Result { if self.provider == 1 { if self.path.value.is_empty() { return Err("Enter an existing SQLite database file path.".into()); } return Database::open_sqlite(Path::new(&self.path.value)); } Database::connect_postgres(self.postgres_config()?) } fn postgres_config(&self) -> Result { let [host, port, database, user, password] = &self.postgres; if host.value.is_empty() || database.value.is_empty() || user.value.is_empty() { return Err("PostgreSQL requires a host, database, and user.".into()); } let port: u16 = port .value .parse() .ok() .filter(|port| *port > 0) .ok_or_else(|| { std::io::Error::new( std::io::ErrorKind::InvalidInput, "Port must be a number from 1 to 65535.", ) })?; let mut config = Config::new(); config .host(&host.value) .port(port) .dbname(&database.value) .user(&user.value) .ssl_mode([SslMode::Require, SslMode::Prefer, SslMode::Disable][self.tls]); if !password.value.is_empty() { config.password(&password.value); } Ok(config) } fn input_mut(&mut self) -> Option<&mut Input> { if self.reconnect { return Some(&mut self.postgres[4]); } if self.provider == 1 { (self.focus == 1).then_some(&mut self.path) } else { self.postgres .get_mut(self.focus.checked_sub(1)?) .filter(|_| self.focus < 5) } } fn clear_password(&mut self) { self.postgres[4] = Input::default(); } fn edit(&mut self, key: KeyEvent) { if self.reconnect { self.postgres[4].edit(key); self.error = None; return; } let count = if self.provider == 0 { 6 } else { 2 }; match key.code { KeyCode::Tab => self.focus = (self.focus + 1) % count, KeyCode::BackTab => self.focus = (self.focus + count - 1) % count, KeyCode::Up | KeyCode::Down | KeyCode::Left | KeyCode::Right if self.focus == 0 => { self.provider = 1 - self.provider } KeyCode::Left | KeyCode::Right if self.provider == 0 && self.focus == 5 => { self.tls = (self.tls + if key.code == KeyCode::Left { 2 } else { 1 }) % 3; } _ => { if let Some(input) = self.input_mut() { input.edit(key); } } } self.error = None; } fn label(&self) -> String { crate::db::display_text(&if self.provider == 1 { let path = Path::new(&self.path.value); format!( "{} · {}", path.file_name() .unwrap_or(path.as_os_str()) .to_string_lossy(), self.path.value ) } else { format!( "{} · {}@{}:{}", self.postgres[2].value, self.postgres[3].value, self.postgres[0].value, self.postgres[1].value ) }) } fn draw(&self, frame: &mut Frame) { let screen = frame.area(); let minimum_height = if self.provider == 0 { 20 } else { 16 }; if screen.width < 74 || screen.height < minimum_height { frame.render_widget(Clear, screen); frame.render_widget( Paragraph::new(format!( "Enlarge terminal to at least 74×{minimum_height}. Ctrl-C to quit." )), screen, ); return; } let area = Rect::new( screen.x + (screen.width - screen.width.saturating_sub(4).min(100)) / 2, screen.y + (screen.height - minimum_height) / 2, screen.width.saturating_sub(4).min(100), minimum_height, ); let window = Block::bordered().title(if self.reconnect { " Connect · password only " } else { " New database " }); let inner = window.inner(area); frame.render_widget(Clear, area); frame.render_widget(window, area); let [header, body, status, help] = Layout::vertical([ Constraint::Length(2), Constraint::Min(4), Constraint::Length(3), Constraint::Length(2), ]) .areas(inner); frame.render_widget( Paragraph::new(if self.reconnect { self.label() } else { "Configure a database · READ ONLY".into() }) .style(Style::default().fg(Color::Cyan)), header, ); let [providers, details] = Layout::horizontal([Constraint::Length(24), Constraint::Min(1)]).areas(body); frame.render_widget( List::new( [" PostgreSQL · server", " SQLite · local file"] .into_iter() .enumerate() .map(|(index, name)| { ListItem::new(name).style(if index == self.provider { Style::default().bg(Color::Blue).fg(Color::White) } else { Style::default() }) }), ) .block(Block::bordered().title(" Database type ").border_style( Style::default().fg(if self.focus == 0 { Color::Cyan } else { Color::DarkGray }), )), providers, ); if self.provider == 1 { let [path, description] = Layout::vertical([Constraint::Length(3), Constraint::Min(1)]).areas(details); self.path.draw( frame, path, " Database file path ", false, self.focus == 1, None, ); frame.render_widget(Paragraph::new("Save an existing SQLite file path.\nFiles are never created or modified.\n\nSelect the saved database to connect.").wrap(Wrap { trim: false }), description); } else { let rows = Layout::vertical([ Constraint::Length(3), Constraint::Length(3), Constraint::Length(3), Constraint::Min(0), ]) .split(details); let labels = [ " Host / socket directory ", " Port ", " Database ", " User ", " Password ", ]; let pairs: [[Rect; 2]; 3] = std::array::from_fn(|index| { Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]) .areas(rows[index]) }); for (index, input) in self.postgres .iter() .enumerate() .take(if self.reconnect { 5 } else { 4 }) { input.draw( frame, pairs[index / 2][index % 2], labels[index], index == 4, self.focus == index + 1 && (!self.reconnect || index == 4), None, ); } frame.render_widget( Paragraph::new(["Require TLS", "Prefer TLS", "Disable TLS"][self.tls]).block( Block::bordered().title(" TLS · ←/→ change ").border_style( Style::default().fg(if !self.reconnect && self.focus == 5 { Color::Cyan } else { Color::DarkGray }), ), ), pairs[2][usize::from(self.reconnect)], ); let tls_notice = match self.tls { 0 => "TLS required; certificates and hostnames are verified.", 1 => "TLS preferred; the server may fall back to unencrypted traffic.", _ => "TLS disabled: traffic is unencrypted. Use only on trusted networks.", }; frame.render_widget( Paragraph::new(vec![ Line::raw(tls_notice), Line::raw("Passwords are requested on connect, never saved."), ]) .wrap(Wrap { trim: false }), rows[3], ); } let message = self.error.as_deref().unwrap_or(if self.reconnect { "Enter a password if needed. The current database stays open until this succeeds." } else { "Enter saves this configuration without connecting." }); frame.render_widget( Paragraph::new(message) .wrap(Wrap { trim: false }) .style(Style::default().fg(if self.error.is_some() { Color::Red } else { Color::Cyan })), status, ); frame.render_widget(Paragraph::new(if self.reconnect { " Enter connect · Ctrl-U clear password · Esc cancel · Ctrl-C quit" } else { " Tab/Shift-Tab fields · ↑↓ provider · Enter save\n ←→ edit/TLS · Ctrl-U clear · Esc cancel · Ctrl-C quit" }).style(Style::default().fg(Color::DarkGray)), help); } } #[derive(Clone, Default)] pub(crate) struct Input { pub(crate) value: String, cursor: usize, } impl From<&str> for Input { fn from(value: &str) -> Self { Self { value: value.into(), cursor: value.len(), } } } impl Input { pub(crate) fn edit(&mut self, key: KeyEvent) { match key.code { KeyCode::Left => { self.cursor = self.value[..self.cursor] .char_indices() .next_back() .map_or(0, |(index, _)| index) } KeyCode::Right => { self.cursor += self.value[self.cursor..] .chars() .next() .map_or(0, char::len_utf8) } KeyCode::Up | KeyCode::Down => { let start = self.value[..self.cursor].rfind('\n').map_or(0, |i| i + 1); let column = self.value[start..self.cursor].chars().count(); let target = if key.code == KeyCode::Up { start .checked_sub(1) .map(|end| (self.value[..end].rfind('\n').map_or(0, |i| i + 1), end)) } else { self.value[self.cursor..].find('\n').map(|i| { let start = self.cursor + i + 1; let end = self.value[start..] .find('\n') .map_or(self.value.len(), |i| start + i); (start, end) }) }; if let Some((start, end)) = target { self.cursor = start + self.value[start..end] .chars() .take(column) .map(char::len_utf8) .sum::(); } } KeyCode::Home => { self.cursor = self.value[..self.cursor].rfind('\n').map_or(0, |i| i + 1); } KeyCode::End => { self.cursor += self.value[self.cursor..] .find('\n') .unwrap_or(self.value.len() - self.cursor); } KeyCode::Backspace if self.cursor > 0 => { let previous = self.value[..self.cursor] .char_indices() .next_back() .unwrap() .0; self.value.drain(previous..self.cursor); self.cursor = previous; } KeyCode::Delete if self.cursor < self.value.len() => { self.value.remove(self.cursor); } KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { self.value.clear(); self.cursor = 0; } KeyCode::Char(character) if !key .modifiers .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) && !character.is_control() => { self.value.insert(self.cursor, character); self.cursor += character.len_utf8(); } _ => {} } } fn paste(&mut self, value: &str) { let text: String = value .chars() .filter(|character| !character.is_control()) .collect(); self.value.insert_str(self.cursor, &text); self.cursor += text.len(); } pub(crate) fn paste_multiline(&mut self, value: &str) { let text: String = value .chars() .map(|character| if character == '\t' { ' ' } else { character }) .filter(|character| *character == '\n' || !character.is_control()) .collect(); self.value.insert_str(self.cursor, &text); self.cursor += text.len(); } pub(crate) fn draw( &self, frame: &mut Frame, area: Rect, label: &str, secret: bool, focused: bool, highlighted: Option>, ) { let text = if secret { Text::raw("•".repeat(self.value.chars().count())) } else { highlighted.unwrap_or_else(|| Text::raw(&self.value)) }; let prefix = &self.value[..self.cursor]; let row = u16::try_from(prefix.matches('\n').count()).unwrap_or(u16::MAX); let row_offset = row.saturating_sub(area.height.saturating_sub(3)); let before_cursor = if secret { self.value[..self.cursor].chars().count() } else { Line::raw(prefix.rsplit('\n').next().unwrap_or("")).width() }; let cursor = u16::try_from(before_cursor).unwrap_or(u16::MAX); let offset = cursor.saturating_sub(area.width.saturating_sub(3)); frame.render_widget( Paragraph::new(text).scroll((row_offset, offset)).block( Block::bordered() .title(label) .border_style(Style::default().fg(if focused { Color::Cyan } else { Color::DarkGray })), ), area, ); if focused && area.width > 2 && area.height > 2 { frame .set_cursor_position((area.x + 1 + cursor - offset, area.y + 1 + row - row_offset)); } } } #[cfg(test)] mod tests { use super::*; #[test] fn explorer_keeps_relations_under_the_connected_database() { use ExplorerEntry::{Database as Db, Relation as Rel}; assert_eq!( explorer_entries(3, Some(1), Some(2), true), vec![Db(Some(0)), Db(Some(1)), Rel(0), Rel(1), Db(Some(2))] ); assert_eq!( explorer_entries(3, Some(1), Some(2), false), vec![Db(Some(0)), Db(Some(1)), Db(Some(2))] ); assert_eq!( explorer_entries(1, None, Some(1), true), vec![Db(None), Rel(0), Db(Some(0))] ); assert_eq!( explorer_entries(1, Some(0), Some(0), true), vec![Db(Some(0))] ); assert_eq!(explorer_entries(1, None, None, true), vec![Db(Some(0))]); } #[test] fn editing_preserves_unicode_and_filters_terminal_controls() { let mut input = Input::default(); input.paste("aé🙂z\n\u{1b}"); input.edit(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)); input.edit(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); assert_eq!(input.value, "aéz"); input.edit(KeyEvent::new(KeyCode::Char('界'), KeyModifiers::NONE)); input.edit(KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE)); assert_eq!(input.value, "aé界"); input.edit(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE)); input.edit(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); input.paste("/tmp/"); assert_eq!(input.value, "/tmp/aé界"); input.edit(KeyEvent::new(KeyCode::End, KeyModifiers::NONE)); input.edit(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)); assert_eq!(input.cursor, input.value.len()); input.edit(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL)); assert!(input.value.is_empty()); assert_eq!(input.cursor, 0); } #[test] fn multiline_editing_preserves_lines_and_unicode() { let mut input = Input::default(); input.paste_multiline("aé界\nxy\nz\r\n\t\u{1b}"); assert_eq!(input.value, "aé界\nxy\nz\n "); for code in [KeyCode::Up, KeyCode::Up, KeyCode::Up] { input.edit(KeyEvent::new(code, KeyModifiers::NONE)); } assert_eq!(&input.value[..input.cursor], "a"); input.edit(KeyEvent::new(KeyCode::End, KeyModifiers::NONE)); input.edit(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); assert_eq!(&input.value[..input.cursor], "aé界\nxy"); input.edit(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE)); input.edit(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); assert_eq!(input.value, "aé界xy\nz\n "); assert_eq!(input.cursor, "aé界".len()); } #[test] fn password_never_appears_in_the_connection_screen() -> Result<()> { let mut form = Form { focus: 5, reconnect: true, ..Form::default() }; form.postgres[4] = Input::from("test-secret-é"); let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(100, 24))?; terminal.draw(|frame| form.draw(frame))?; let rendered: String = terminal .backend() .buffer() .content .iter() .map(|cell| cell.symbol()) .collect(); assert!(!rendered.contains("test-secret-é")); assert!(rendered.contains("•••••••••••••")); Ok(()) } #[test] fn configurations_survive_restart_without_passwords() -> Result<()> { let directory = tempfile::tempdir()?; let path = directory.path().join("connections.sqlite3"); let mut profiles = Profiles::open(&path)?; let mut postgres = Form { tls: 2, ..Form::default() }; postgres.postgres = [ "db.example", "5440", "warehouse", "reader", "do-not-store-this", ] .map(Input::from); profiles.save(&postgres)?; let sqlite = Form { provider: 1, path: Input::from("./quoted '界'.sqlite"), ..Form::default() }; profiles.save(&sqlite)?; postgres.postgres[1] = Input::from("0"); assert!(profiles.save(&postgres).is_err()); drop(profiles); let profiles = Profiles::open(&path)?; assert_eq!(profiles.entries.len(), 2); let restored = &profiles.entries[0]; assert_eq!(restored.provider, 0); for index in 0..4 { assert_eq!( restored.postgres[index].value, ["db.example", "5440", "warehouse", "reader"][index] ); } assert_eq!(restored.tls, 2); assert!(restored.postgres[4].value.is_empty()); assert_eq!(profiles.entries[1].provider, 1); assert_eq!( Path::new(&profiles.entries[1].path.value), std::env::current_dir()?.join("./quoted '界'.sqlite") ); assert!( !std::fs::read(path)? .windows(b"do-not-store-this".len()) .any(|bytes| bytes == b"do-not-store-this") ); Ok(()) } #[test] fn invalid_ports_are_rejected_before_connecting() { for port in ["0", "65536", "not-a-port"] { let mut form = Form::default(); form.postgres[1] = Input::from(port); form.postgres[3] = Input::from("inspector"); let error = form.connect().err().unwrap(); assert_eq!( error .downcast_ref::() .map(std::io::Error::kind), Some(std::io::ErrorKind::InvalidInput) ); } } }