diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d846bd..a2896d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ ## [unreleased] +### 🚀 Features + +- Add SQLite-backed interactive history with safe connection metadata (#32); store it in the platform state/data directory + ### 📚 Documentation - Remove use_copy option from CedarDB example diff --git a/Cargo.lock b/Cargo.lock index 89afc3f..eebaff3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -177,7 +177,7 @@ dependencies = [ "chrono", "chrono-tz", "half", - "hashbrown", + "hashbrown 0.17.1", "libc", "num-complex", "num-integer", @@ -424,8 +424,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", + "js-sys", "num-traits", "serde", + "wasm-bindgen", "windows-link 0.2.1", ] @@ -606,12 +608,16 @@ dependencies = [ "arrow-buffer", "arrow-cast", "arrow-schema", + "chrono", "clap", "comfy-table", + "dirs", "libc", "nu-ansi-term", "parquet", "reedline", + "serde", + "serde_json", "syntect", "tempfile", "terminal-colorsaurus", @@ -647,6 +653,27 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys", +] + [[package]] name = "document-features" version = "0.2.12" @@ -672,6 +699,18 @@ dependencies = [ "windows-sys", ] +[[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" @@ -711,6 +750,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "futures-core" version = "0.3.34" @@ -781,11 +826,32 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash", +] + [[package]] name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashlink" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a596f1b20ed2cc5ecac41a164aaebc7258057060f06c0cf7a2ba3991ee7990fb" +dependencies = [ + "hashbrown 0.17.1", +] [[package]] name = "heck" @@ -824,7 +890,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.17.1", ] [[package]] @@ -929,6 +995,26 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +[[package]] +name = "libredox" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" +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 = "linked-hash-map" version = "0.5.6" @@ -1077,6 +1163,12 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "parking_lot" version = "0.12.5" @@ -1117,7 +1209,7 @@ dependencies = [ "bytes", "chrono", "half", - "hashbrown", + "hashbrown 0.17.1", "num-bigint", "num-integer", "num-traits", @@ -1229,6 +1321,16 @@ dependencies = [ "bitflags", ] +[[package]] +name = "redox_users" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" +dependencies = [ + "libredox", + "thiserror", +] + [[package]] name = "reedline" version = "0.52.0" @@ -1238,7 +1340,9 @@ dependencies = [ "chrono", "crossterm", "nu-ansi-term", + "rusqlite", "serde", + "serde_json", "strip-ansi-escapes", "strum", "unicode-segmentation", @@ -1274,6 +1378,31 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "rsqlite-vfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" +dependencies = [ + "hashbrown 0.16.1", + "thiserror", +] + +[[package]] +name = "rusqlite" +version = "0.40.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", + "sqlite-wasm-rs", +] + [[package]] name = "rustc_version" version = "0.4.1" @@ -1460,6 +1589,18 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "199905e6153d6405f9728fe44daace35f8f837bbf830bb6e85fbd5828709a886" +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", +] + [[package]] name = "strip-ansi-escapes" version = "0.2.1" @@ -1704,6 +1845,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" diff --git a/Cargo.toml b/Cargo.toml index 39e3268..8e7da8c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,8 +20,12 @@ arrow-schema = "59.3.0" clap = { version = "4.6.7", features = ["derive"] } comfy-table = "8.0.1" nu-ansi-term = "0.50.3" +chrono = "0.4.41" +dirs = "6.0.0" +reedline = { version = "0.52.0", features = ["sqlite"] } +serde = { version = "1.0.219", features = ["derive"] } +serde_json = "1.0.140" parquet = { version = "59.3.0", default-features = false, features = ["arrow", "snap"] } -reedline = "0.52.0" syntect = "5.3.0" tempfile = "3.27.0" terminal-colorsaurus = "1.0.3" diff --git a/src/cli.rs b/src/cli.rs index ac7c6d9..bfb5ea5 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -177,6 +177,17 @@ pub fn parse_args() -> AppConfig { } } +pub(crate) fn uri_driver_scheme(uri: &str) -> Option<&str> { + let (scheme, _) = uri.split_once(':')?; + let mut chars = scheme.chars(); + if !chars.next().is_some_and(|c| c.is_ascii_alphabetic()) + || !chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')) + { + return None; + } + Some(scheme) +} + /// Parse all `key=value` values supplied for the argument `id`, exiting with an /// error message if any value is malformed. fn collect_options(matches: &clap::ArgMatches, id: &str) -> Vec<(String, String)> { @@ -200,10 +211,7 @@ fn parse_output_path(s: &str) -> Result { } fn uri_has_driver_scheme(uri: &str) -> bool { - let Some(idx) = uri.find(':') else { - return false; - }; - idx != 0 + uri_driver_scheme(uri).is_some() } fn parse_option(option: &str) -> Result<(String, String), String> { @@ -300,6 +308,10 @@ mod tests { assert!(!uri_has_driver_scheme("plain_path")); // Empty scheme (leading colon). assert!(!uri_has_driver_scheme(":memory:")); + // A scheme must start with an ASCII letter and contain only RFC 3986 + // scheme characters after it. + assert!(!uri_has_driver_scheme("not a scheme://x")); + assert!(!uri_has_driver_scheme("duck/db://x")); } #[test] @@ -309,6 +321,13 @@ mod tests { assert!(uri_has_driver_scheme("PROFILE://my_database")); } + #[test] + fn test_uri_driver_scheme_preserves_original_case() { + assert_eq!(uri_driver_scheme("DuckDB://x"), Some("DuckDB")); + assert_eq!(uri_driver_scheme("sqlite::memory:"), Some("sqlite")); + assert_eq!(uri_driver_scheme(":memory:"), None); + } + #[test] fn test_connection_source_profile_with_path() { let source = ConnectionSource::Profile { diff --git a/src/history.rs b/src/history.rs new file mode 100644 index 0000000..884fb94 --- /dev/null +++ b/src/history.rs @@ -0,0 +1,630 @@ +// Copyright 2026 Columnar Technologies Inc. +// SPDX-License-Identifier: Apache-2.0 + +//! Persistent interactive history for databow. +//! +//! The history database uses reedline's schema directly. The adapter is the +//! boundary between reedline's type-erased `History` trait and the typed +//! SQLite APIs, allowing connection identity to survive a save/load cycle. + +use crate::cli::{self, ConnectionSource}; +use chrono::Utc; +use reedline::{ + History, HistoryItem, HistoryItemExtraInfo, HistoryItemId, HistorySessionId, Result, + SearchQuery, SqliteBackedHistory, +}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use serde_json::{Map, Value}; +use std::path::PathBuf; + +const CONNECTION_KEY: &str = "connection"; + +/// Safe identity metadata for the connection associated with a history row. +/// +/// This is deliberately separate from [`ConnectionSource`], which contains +/// credentials, URI values, and arbitrary driver options needed to connect. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum ConnectionIdentity { + Profile { profile: String }, + Direct { driver: Option }, +} + +impl ConnectionIdentity { + /// Build safe metadata before the connection source is consumed. + pub fn from_source(source: &ConnectionSource) -> Self { + match source { + ConnectionSource::Profile { profile, .. } => Self::Profile { + profile: profile.clone(), + }, + ConnectionSource::Direct { + driver_name, uri, .. + } => Self::Direct { + driver: driver_name.clone().or_else(|| { + uri.as_deref() + .and_then(cli::uri_driver_scheme) + .map(|scheme| scheme.to_ascii_lowercase()) + }), + }, + } + } +} + +/// Lossless JSON object wrapper for databow history metadata. +/// +/// Known fields are exposed through typed accessors while fields introduced by +/// newer versions are retained across updates. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct HistoryExtraInfo { + fields: Map, +} + +impl HistoryExtraInfo { + /// Return the connection identity when the stored value has the known shape. + /// + /// Keep this accessor available for future history consumers even though + /// the initial REPL only writes the identity. + #[allow(dead_code)] + pub fn connection(&self) -> Option { + self.fields + .get(CONNECTION_KEY) + .and_then(|value| serde_json::from_value(value.clone()).ok()) + } + + pub fn set_connection(&mut self, identity: ConnectionIdentity) { + self.fields.insert( + CONNECTION_KEY.to_string(), + serde_json::to_value(identity).expect("connection identity is serializable"), + ); + } +} + +impl Serialize for HistoryExtraInfo { + fn serialize(&self, serializer: S) -> std::result::Result + where + S: Serializer, + { + self.fields.serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for HistoryExtraInfo { + fn deserialize(deserializer: D) -> std::result::Result + where + D: Deserializer<'de>, + { + match Value::deserialize(deserializer)? { + Value::Object(fields) => Ok(Self { fields }), + value => Err(serde::de::Error::custom(format!( + "history metadata must be a JSON object, got {value}" + ))), + } + } +} + +impl HistoryItemExtraInfo for HistoryExtraInfo {} + +/// A small adapter that adds connection identity to reedline history saves. +pub struct HistoryAdapter { + backend: SqliteBackedHistory, + identity: ConnectionIdentity, + session: Option, +} + +impl HistoryAdapter { + fn new( + backend: SqliteBackedHistory, + identity: ConnectionIdentity, + session: Option, + ) -> Self { + Self { + backend, + identity, + session, + } + } + + pub fn persistent( + path: PathBuf, + identity: ConnectionIdentity, + session: Option, + ) -> Result { + Self::persistent_at(path, identity, session, Utc::now()) + } + + fn persistent_at( + path: PathBuf, + identity: ConnectionIdentity, + session: Option, + session_timestamp: chrono::DateTime, + ) -> Result { + Ok(Self::new( + SqliteBackedHistory::with_file(path, session, Some(session_timestamp))?, + identity, + session, + )) + } + + pub fn in_memory( + identity: ConnectionIdentity, + session: Option, + ) -> Result { + Ok(Self::new( + SqliteBackedHistory::in_memory()?, + identity, + session, + )) + } + + /// Access the typed backend for tests and future metadata operations. + #[cfg(test)] + pub fn load_with_extra(&self, id: HistoryItemId) -> Result> { + self.backend.load_with_extra(id) + } + + fn typed_item(&self, item: HistoryItem) -> HistoryItem { + let existing: HistoryExtraInfo = item + .id + .and_then(|id| self.backend.load_with_extra(id).ok()) + .and_then(|loaded| loaded.more_info) + .unwrap_or_default(); + let mut metadata = existing; + metadata.set_connection(self.identity.clone()); + HistoryItem { + id: item.id, + start_timestamp: item.start_timestamp.or_else(|| Some(Utc::now())), + command_line: item.command_line, + session_id: item.session_id.or(self.session), + hostname: item.hostname, + cwd: item.cwd, + duration: item.duration, + exit_status: item.exit_status, + more_info: Some(metadata), + } + } +} + +fn erase_extra(item: HistoryItem) -> HistoryItem { + HistoryItem { + id: item.id, + start_timestamp: item.start_timestamp, + command_line: item.command_line, + session_id: item.session_id, + hostname: item.hostname, + cwd: item.cwd, + duration: item.duration, + exit_status: item.exit_status, + more_info: None, + } +} + +impl History for HistoryAdapter { + fn save(&mut self, item: HistoryItem) -> Result { + let original = item.clone(); + match self.backend.save_with_extra(self.typed_item(item)) { + Ok(saved) => Ok(erase_extra(saved)), + Err(error) => { + // Reedline currently expects save to succeed and panics on an + // error. Keep the REPL usable when persistent history fails. + eprintln!("Warning: failed to save databow history: {error:?}"); + Ok(original) + } + } + } + + fn load(&self, id: HistoryItemId) -> Result { + self.backend.load(id) + } + + fn count(&self, query: SearchQuery) -> Result { + self.backend.count(query) + } + + fn search(&self, query: SearchQuery) -> Result> { + self.backend.search(query) + } + + fn update( + &mut self, + id: HistoryItemId, + updater: &dyn Fn(HistoryItem) -> HistoryItem, + ) -> Result<()> { + // Reedline preserves typed more_info on this type-erased path. + self.backend.update(id, updater) + } + + fn clear(&mut self) -> Result<()> { + self.backend.clear() + } + + fn delete(&mut self, id: HistoryItemId) -> Result<()> { + self.backend.delete(id) + } + + fn sync(&mut self) -> std::io::Result<()> { + self.backend.sync() + } + + fn session(&self) -> Option { + self.session + } +} + +/// Resolve the default OS-appropriate history database path. +/// +/// Linux uses `XDG_STATE_HOME`, defaulting to `~/.local/state`. Other platforms +/// use the local data directory, which is LocalAppData on Windows and +/// Application Support on macOS. The database lives under `databow/` within +/// that directory. +pub fn default_history_path() -> Option { + history_base_dir().map(|path| path.join("databow").join("history.sqlite3")) +} + +fn history_base_dir() -> Option { + history_base_dir_from(dirs::state_dir(), dirs::data_local_dir()) +} + +fn history_base_dir_from( + _state_dir: Option, + _data_local_dir: Option, +) -> Option { + #[cfg(target_os = "linux")] + { + _state_dir + } + + #[cfg(not(target_os = "linux"))] + { + _data_local_dir + } +} + +/// Open persistent history, falling back to an in-memory SQLite backend. +pub fn initialize_history( + identity: ConnectionIdentity, + session: Option, +) -> Box { + initialize_history_at(default_history_path(), identity, session) +} + +fn initialize_history_at( + path: Option, + identity: ConnectionIdentity, + session: Option, +) -> Box { + if let Some(path) = path { + match HistoryAdapter::persistent(path.clone(), identity.clone(), session) { + Ok(history) => return Box::new(history), + Err(error) => eprintln!( + "Warning: failed to initialize persistent databow history at {}: {error:?}; using in-memory history", + path.display() + ), + } + } else { + eprintln!( + "Warning: could not resolve a databow history directory; using in-memory history" + ); + } + + match HistoryAdapter::in_memory(identity, session) { + Ok(history) => Box::new(history), + Err(error) => { + eprintln!("Warning: failed to initialize in-memory databow history: {error:?}"); + // A backend failure is extraordinarily unlikely, but the REPL can + // still operate with reedline's default in-memory implementation. + Box::new(reedline::FileBackedHistory::default()) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::TimeZone; + use reedline::{History, SearchDirection, SearchFilter, SearchQuery}; + use tempfile::tempdir; + + fn item(command_line: &str) -> HistoryItem { + HistoryItem::from_command_line(command_line) + } + + fn profile_identity() -> ConnectionIdentity { + ConnectionIdentity::Profile { + profile: "production".to_string(), + } + } + + #[test] + fn identity_excludes_credentials_options_and_uri_target() { + let source = ConnectionSource::Direct { + driver_name: None, + uri: Some("postgresql://user:password@example/db?token=secret".to_string()), + username: Some("user".to_string()), + password: Some("password".to_string()), + options: vec![("token".to_string(), "secret".to_string())], + }; + let identity = ConnectionIdentity::from_source(&source); + assert_eq!( + identity, + ConnectionIdentity::Direct { + driver: Some("postgresql".to_string()) + } + ); + let json = serde_json::to_string(&identity).unwrap(); + assert!(!json.contains("password")); + assert!(!json.contains("secret")); + assert!(!json.contains("example")); + } + + #[test] + fn direct_identity_is_persisted_without_sensitive_fields() { + let source = ConnectionSource::Direct { + driver_name: None, + uri: Some("postgresql://user:password@example/db?token=secret".to_string()), + username: Some("user".to_string()), + password: Some("password".to_string()), + options: vec![("token".to_string(), "secret".to_string())], + }; + let identity = ConnectionIdentity::from_source(&source); + let dir = tempdir().unwrap(); + let mut history = + HistoryAdapter::persistent(dir.path().join("history.sqlite3"), identity, None).unwrap(); + let id = history.save(item("select 1")).unwrap().id.unwrap(); + let metadata = history.load_with_extra(id).unwrap().more_info.unwrap(); + let json = serde_json::to_value(metadata).unwrap(); + + assert_eq!( + json, + serde_json::json!({ + "connection": {"kind": "direct", "driver": "postgresql"} + }) + ); + let serialized = json.to_string(); + assert!(!serialized.contains("password")); + assert!(!serialized.contains("secret")); + assert!(!serialized.contains("example")); + } + + #[test] + fn profile_identity_stores_only_user_profile() { + let source = ConnectionSource::Profile { + profile: "production".to_string(), + uri: Some("postgresql://user:password@host/db".to_string()), + username: Some("user".to_string()), + password: Some("password".to_string()), + options: vec![("secret".to_string(), "value".to_string())], + }; + assert_eq!(ConnectionIdentity::from_source(&source), profile_identity()); + } + + #[test] + fn unknown_metadata_survives_connection_update() { + let mut metadata: HistoryExtraInfo = + serde_json::from_str(r#"{"connection":"future-format","future":{"value":1}}"#).unwrap(); + assert!(metadata.connection().is_none()); + metadata.set_connection(profile_identity()); + let value: Value = serde_json::to_value(metadata).unwrap(); + assert_eq!(value["future"]["value"], 1); + assert_eq!(value["connection"]["kind"], "profile"); + } + + #[test] + fn sqlite_history_round_trips_typed_connection_metadata() { + let dir = tempdir().unwrap(); + let path = dir.path().join("history.sqlite3"); + let session = reedline::Reedline::create_history_session_id(); + let mut history = + HistoryAdapter::persistent(path.clone(), profile_identity(), session).unwrap(); + let saved = history.save(item("select 1")).unwrap(); + let id = saved.id.unwrap(); + let loaded = history.load_with_extra(id).unwrap(); + assert_eq!(loaded.command_line, "select 1"); + assert_eq!(loaded.session_id, session); + assert!(loaded.start_timestamp.is_some()); + assert_eq!( + loaded.more_info.unwrap().connection(), + Some(profile_identity()) + ); + drop(history); + + let reopened = HistoryAdapter::persistent(path, profile_identity(), session).unwrap(); + let loaded = reopened.load_with_extra(id).unwrap(); + assert_eq!( + loaded.more_info.unwrap().connection(), + Some(profile_identity()) + ); + } + + #[test] + fn timestamp_is_filled_without_overwriting_explicit_value() { + let mut history = HistoryAdapter::in_memory(profile_identity(), None).unwrap(); + + let saved = history.save(item("select 1")).unwrap(); + assert!(saved.start_timestamp.is_some()); + assert!( + history + .load_with_extra(saved.id.unwrap()) + .unwrap() + .start_timestamp + .is_some() + ); + + let explicit = Utc.timestamp_millis_opt(123_456).single().unwrap(); + let mut explicit_item = item("select 2"); + explicit_item.start_timestamp = Some(explicit); + let explicit_saved = history.save(explicit_item).unwrap(); + assert_eq!(explicit_saved.start_timestamp, Some(explicit)); + assert_eq!( + history + .load_with_extra(explicit_saved.id.unwrap()) + .unwrap() + .start_timestamp, + Some(explicit) + ); + } + + #[test] + fn timestamped_rows_are_visible_to_a_later_session_cursor() { + let dir = tempdir().unwrap(); + let path = dir.path().join("history.sqlite3"); + let session_a = reedline::Reedline::create_history_session_id(); + let mut history_a = + HistoryAdapter::persistent_at(path.clone(), profile_identity(), session_a, Utc::now()) + .unwrap(); + let saved = history_a.save(item("select from session_a")).unwrap(); + let saved_timestamp = saved.start_timestamp.expect("adapter fills timestamp"); + drop(history_a); + + let session_b = reedline::Reedline::create_history_session_id(); + let session_b_timestamp = saved_timestamp + chrono::Duration::milliseconds(1); + let history_b = + HistoryAdapter::persistent_at(path, profile_identity(), session_b, session_b_timestamp) + .unwrap(); + // This is the initial backward query issued by reedline's + // HistoryCursor::back for HistoryNavigationQuery::Normal. HistoryCursor + // is not re-exported, so keep the regression test on + // the exact public History query it constructs. + let results = history_b + .search(SearchQuery { + start_id: None, + end_id: None, + start_time: None, + end_time: None, + direction: SearchDirection::Backward, + limit: Some(1), + filter: SearchFilter::anything(session_b), + }) + .unwrap(); + assert_eq!( + results.first().map(|item| item.command_line.as_str()), + Some("select from session_a") + ); + } + + #[test] + fn multiple_saves_share_one_session() { + let session = reedline::Reedline::create_history_session_id(); + let mut history = HistoryAdapter::in_memory(profile_identity(), session).unwrap(); + let first = history.save(item("select 1")).unwrap(); + let second = history.save(item("select 2")).unwrap(); + assert_eq!(first.session_id, session); + assert_eq!(second.session_id, session); + assert_eq!(history.session(), session); + let query = SearchQuery::everything(SearchDirection::Forward, session); + assert_eq!(history.count(query).unwrap(), 2); + } + + #[test] + fn persistent_open_failure_falls_back_to_memory() { + let dir = tempdir().unwrap(); + let path = dir.path().join("existing-directory"); + std::fs::create_dir(&path).unwrap(); + let session = reedline::Reedline::create_history_session_id(); + let mut history = initialize_history_at(Some(path), profile_identity(), session); + let saved = history.save(item("select 1")).unwrap(); + assert!(saved.id.is_some()); + assert_eq!(saved.session_id, session); + assert_eq!(history.session(), session); + assert_eq!(history.count_all().unwrap(), 1); + } + + #[test] + fn default_history_path_has_expected_application_shape() { + let Some(path) = default_history_path() else { + return; + }; + assert_eq!( + path.file_name(), + Some(std::ffi::OsStr::new("history.sqlite3")) + ); + assert_eq!( + path.parent().and_then(std::path::Path::file_name), + Some(std::ffi::OsStr::new("databow")) + ); + } + + #[test] + fn history_base_dir_uses_the_platform_history_location() { + let state_dir = PathBuf::from("state"); + let data_local_dir = PathBuf::from("local-data"); + + #[cfg(target_os = "linux")] + { + assert_eq!( + history_base_dir_from(Some(state_dir.clone()), Some(data_local_dir.clone())), + Some(state_dir) + ); + assert_eq!(history_base_dir_from(None, Some(data_local_dir)), None); + } + + #[cfg(not(target_os = "linux"))] + { + assert_eq!( + history_base_dir_from(Some(state_dir), Some(data_local_dir.clone())), + Some(data_local_dir) + ); + assert_eq!( + history_base_dir_from(Some(PathBuf::from("state")), None), + None + ); + } + } + + #[test] + fn type_erased_update_preserves_more_info() { + let dir = tempdir().unwrap(); + let mut history = HistoryAdapter::persistent( + dir.path().join("history.sqlite3"), + profile_identity(), + None, + ) + .unwrap(); + let id = history.save(item("select 1")).unwrap().id.unwrap(); + history + .update(id, &|mut item| { + item.command_line = "select 2".to_string(); + item + }) + .unwrap(); + let loaded = history.load_with_extra(id).unwrap(); + assert_eq!(loaded.command_line, "select 2"); + assert_eq!( + loaded.more_info.unwrap().connection(), + Some(profile_identity()) + ); + } + + #[test] + fn saving_an_existing_item_preserves_unknown_metadata() { + let dir = tempdir().unwrap(); + let mut history = HistoryAdapter::persistent( + dir.path().join("history.sqlite3"), + profile_identity(), + None, + ) + .unwrap(); + let id = history.save(item("select 1")).unwrap().id.unwrap(); + history + .backend + .update_with_extra::(id, &|mut item| { + item.more_info.as_mut().unwrap().fields.insert( + "future_field".to_string(), + Value::String("preserve me".to_string()), + ); + item + }) + .unwrap(); + + let mut updated = history.load(id).unwrap(); + updated.command_line = "select 2".to_string(); + history.save(updated).unwrap(); + + let loaded = history.load_with_extra(id).unwrap(); + let metadata = loaded.more_info.unwrap(); + assert_eq!( + metadata.fields.get("future_field"), + Some(&Value::String("preserve me".to_string())) + ); + assert_eq!(metadata.connection(), Some(profile_identity())); + } +} diff --git a/src/main.rs b/src/main.rs index bacdc33..9406d6a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,6 +5,7 @@ mod cli; mod commands; mod database; mod highlighter; +mod history; mod output; mod pager; mod repl; @@ -26,6 +27,7 @@ fn main() { let statement_options = database::build_statement_options(statement_options); if matches!(query_source, QuerySource::Interactive) { + let connection_identity = history::ConnectionIdentity::from_source(&connection); let connection = database::initialize_connection(connection).unwrap_or_else(|e| { eprintln!("{e}"); exit(1); @@ -33,6 +35,7 @@ fn main() { repl::run_repl( connection, table_mode, + connection_identity, table::TruncationOptions::default(), &statement_options, ); diff --git a/src/repl.rs b/src/repl.rs index 9ae95bd..a3f2e1c 100644 --- a/src/repl.rs +++ b/src/repl.rs @@ -4,6 +4,7 @@ use crate::commands::{self, Outcome, ReplState}; use crate::database; use crate::highlighter::SyntectHighlighter; +use crate::history::{self, ConnectionIdentity}; use crate::pager::PagerSettings; use crate::table::{RenderOptions, TableMode, TruncationOptions}; use adbc_core::Connection; @@ -74,15 +75,22 @@ fn format_banner(version: &str, vendor: &database::VendorInfo) -> String { pub fn run_repl( mut connection: impl Connection, table_mode: TableMode, + connection_identity: ConnectionIdentity, truncation: TruncationOptions, statement_options: &[(OptionStatement, OptionValue)], ) { let vendor = database::get_vendor_info(&connection); println!("{}", format_banner(env!("CARGO_PKG_VERSION"), &vendor)); + let history_session = Reedline::create_history_session_id(); let mut line_editor = Reedline::create() .with_highlighter(Box::new(SyntectHighlighter::new())) - .with_validator(Box::new(SqlValidator)); + .with_validator(Box::new(SqlValidator)) + .with_history_session_id(history_session) + .with_history(history::initialize_history( + connection_identity, + history_session, + )); let prompt = SqlPrompt; let mut ctrl_c_count: u8 = 0; let mut state = ReplState {