From 1f48b020dfdf248d5f81023949cb14bd36c38561 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Apr 2026 18:02:35 +0000 Subject: [PATCH] Add --atuin-db flag to load history from an atuin SQLite database Agent-Logs-Url: https://github.com/HalFrgrd/flyline/sessions/643bd0f1-6799-4c62-8dd7-5450769122c6 Co-authored-by: HalFrgrd <4559349+HalFrgrd@users.noreply.github.com> --- Cargo.lock | 35 ++++++++++++ Cargo.toml | 4 ++ src/history.rs | 139 +++++++++++++++++++++++++++++++++++++++++++++--- src/lib.rs | 7 +++ src/settings.rs | 5 ++ 5 files changed, 183 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 796c2324..71fe9866 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -863,6 +863,7 @@ dependencies = [ "serde_json", "shlex", "skim", + "sqlite", "timeago", "unicode-segmentation", "unicode-width", @@ -1724,6 +1725,12 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + [[package]] name = "portable-atomic" version = "1.13.0" @@ -2354,6 +2361,34 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "sqlite" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f66e9c01a11936154f3910dbba732c01f8b591543bc4d6672bddee79fd9c4783" +dependencies = [ + "sqlite3-sys", +] + +[[package]] +name = "sqlite3-src" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5b6d3c860886b0a33e69e421796a5f4a27f23597a182c2450f6d7ace5103120" +dependencies = [ + "cc", + "pkg-config", +] + +[[package]] +name = "sqlite3-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7781d97adc13a1d5081127a9ee29afad8427f3757bd984daf814d8265267039" +dependencies = [ + "sqlite3-src", +] + [[package]] name = "static_assertions" version = "1.1.0" diff --git a/Cargo.toml b/Cargo.toml index d01a8bca..102d1166 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,10 @@ rand = "0.10.0" parse-style = { version = "0.4.0" } easing-function = "0.1.1" ctor = "0.10.0" +# Lightweight SQLite wrapper used to read history from an atuin database +# (see `--atuin-db`). Uses the `bundled` feature so libsqlite3 is compiled +# from source and we don't depend on a system library. +sqlite = { version = "0.37", features = ["bundled"] } # tokio is not used directly by flyline; it remains a transitive dependency pulled # in by `skim` (via `interprocess` and `tokio-util`). diff --git a/src/history.rs b/src/history.rs index 7e8af6a0..58d3dc46 100644 --- a/src/history.rs +++ b/src/history.rs @@ -204,6 +204,69 @@ impl HistoryManager { res } + /// Read history entries from an atuin SQLite database. + /// + /// The atuin schema stores each command in a `history` table with + /// nanosecond `timestamp` and a nullable `deleted_at` column for soft + /// deletes. The database is opened read-only. Returns an empty vector + /// on any failure (missing file, corrupt schema, etc.) — failures are + /// logged at warn/error level. + fn parse_atuin_history(db_path: &str) -> Vec { + log::debug!("Reading atuin history from: {}", db_path); + + if !std::path::Path::new(db_path).exists() { + eprintln!("flyline: atuin database not found: {}", db_path); + log::warn!("atuin database not found: {}", db_path); + return Vec::new(); + } + + let res = time_it!( + "parse atuin history", + Self::parse_atuin_history_inner(db_path) + ); + + match res { + Ok(entries) => { + log::debug!("Parsed atuin history ({} entries)", entries.len()); + entries + } + Err(e) => { + eprintln!("flyline: failed to read atuin database {}: {}", db_path, e); + log::error!("Failed to read atuin database {}: {}", db_path, e); + Vec::new() + } + } + } + + fn parse_atuin_history_inner(db_path: &str) -> Result, sqlite::Error> { + // Open the database read-only so we never modify the user's atuin DB. + let connection = sqlite::Connection::open_with_flags( + db_path, + sqlite::OpenFlags::new().with_read_only(), + )?; + + // Atuin stores timestamps as nanoseconds since the Unix epoch and + // marks soft-deleted entries by setting `deleted_at`. + let query = "SELECT timestamp, command FROM history \ + WHERE deleted_at IS NULL \ + ORDER BY timestamp ASC"; + let mut statement = connection.prepare(query)?; + + let mut entries: Vec = Vec::new(); + while let sqlite::State::Row = statement.next()? { + let ts_ns: i64 = statement.read::(0)?; + let command: String = statement.read::(1)?; + let timestamp = if ts_ns > 0 { + Some((ts_ns as u64) / 1_000_000_000) + } else { + None + }; + // Index is reassigned by `push_deduped_entry` / `normalize_entries`. + entries.push(HistoryEntry::new(timestamp, 0, command)); + } + Ok(entries) + } + fn parse_zsh_history(custom_path: Option<&str>) -> Vec { let hist_path = match custom_path { Some(p) if !p.is_empty() => p.to_string(), @@ -248,13 +311,22 @@ impl HistoryManager { } pub fn new(settings: &Settings) -> HistoryManager { - // Bash will load the history into memory, so we can read it from there - // Bash parses it after bashrc is loaded. - let bash_entries = Self::parse_bash_history_from_memory(); - Self::log_recent_entries(&bash_entries, "bash"); - - // Alternative is to do it ourselves - // let bash_entries = Self::parse_bash_history_from_file(); + // When --atuin-db is set, load history from the atuin SQLite database + // instead of parsing it from bash. + let bash_entries = if let Some(ref atuin_path) = settings.atuin_db_path { + let atuin_entries = Self::parse_atuin_history(atuin_path); + Self::log_recent_entries(&atuin_entries, "atuin"); + atuin_entries + } else { + // Bash will load the history into memory, so we can read it from there + // Bash parses it after bashrc is loaded. + let bash_entries = Self::parse_bash_history_from_memory(); + Self::log_recent_entries(&bash_entries, "bash"); + bash_entries + + // Alternative is to do it ourselves + // let bash_entries = Self::parse_bash_history_from_file(); + }; let entries = if let Some(ref zsh_path) = settings.zsh_history_path { // As a Zsh user migrating to Bash, I want to have my Zsh history available too @@ -777,6 +849,59 @@ cd /home/user2 check(Some(1625078460), 5, "cd /home/user2"); } + #[test] + fn test_parse_atuin_history() { + // Build an in-memory atuin-shaped database, populate a few rows, persist + // it to a temp file, and then read it back via parse_atuin_history. + let mut tmp = std::env::temp_dir(); + tmp.push(format!("flyline_atuin_test_{}.db", std::process::id())); + let _ = std::fs::remove_file(&tmp); + + { + let conn = sqlite::Connection::open(&tmp).unwrap(); + conn.execute( + "CREATE TABLE history ( + id TEXT PRIMARY KEY, + timestamp INTEGER NOT NULL, + duration INTEGER NOT NULL, + exit INTEGER NOT NULL, + command TEXT NOT NULL, + cwd TEXT NOT NULL, + session TEXT NOT NULL, + hostname TEXT NOT NULL, + deleted_at INTEGER + );", + ) + .unwrap(); + // Insert out of order to verify ORDER BY timestamp ASC. + conn.execute( + "INSERT INTO history VALUES \ + ('a', 1625078460000000000, 0, 0, 'echo hi', '/', 's', 'h', NULL), \ + ('b', 1625078400000000000, 0, 0, 'ls -al', '/', 's', 'h', NULL), \ + ('c', 1625078500000000000, 0, 0, 'gone', '/', 's', 'h', 1625078600000000000), \ + ('d', 1625078520000000000, 0, 0, 'cd /tmp', '/', 's', 'h', NULL);", + ) + .unwrap(); + } + + let entries = HistoryManager::parse_atuin_history(tmp.to_str().unwrap()); + let _ = std::fs::remove_file(&tmp); + + assert_eq!(entries.len(), 3); + assert_eq!(entries[0].command, "ls -al"); + assert_eq!(entries[0].timestamp, Some(1625078400)); + assert_eq!(entries[1].command, "echo hi"); + assert_eq!(entries[1].timestamp, Some(1625078460)); + assert_eq!(entries[2].command, "cd /tmp"); + assert_eq!(entries[2].timestamp, Some(1625078520)); + } + + #[test] + fn test_parse_atuin_history_missing_file() { + let entries = HistoryManager::parse_atuin_history("/nonexistent/path/to/atuin/history.db"); + assert!(entries.is_empty()); + } + #[test] fn test_parse_zsh_history() { // Test simple format (no timestamps) diff --git a/src/lib.rs b/src/lib.rs index a31df8f6..f52337bd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -105,6 +105,9 @@ struct FlylineArgs { /// Load Zsh history in addition to Bash history. Optionally specify a PATH to the Zsh history file #[arg(long = "load-zsh-history", value_name = "PATH", default_missing_value = "", num_args = 0..=1)] load_zsh_history: Option, + /// Load history from an atuin SQLite database at PATH instead of bash history + #[arg(long = "atuin-db", value_name = "PATH")] + atuin_db: Option, /// Show animations #[arg(long = "show-animations", default_missing_value = "true", num_args = 0..=1)] show_animations: Option, @@ -818,6 +821,10 @@ impl Flyline { self.settings.zsh_history_path = Some(path); } + if let Some(path) = parsed.atuin_db { + self.settings.atuin_db_path = Some(path); + } + if let Some(enabled) = parsed.show_animations { log::info!("Animations disabled: {}", enabled); self.settings.show_animations = enabled; diff --git a/src/settings.rs b/src/settings.rs index 501b90f3..0041a356 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -139,6 +139,10 @@ pub struct Settings { /// When `Some`, Zsh history is loaded in addition to Bash history; an empty string or no /// value means use the default path (`$HOME/.zsh_history`). pub zsh_history_path: Option, + /// Optional path to an atuin SQLite database (typically + /// `$HOME/.local/share/atuin/history.db`). When set, history is loaded + /// from the atuin database instead of from bash history. + pub atuin_db_path: Option, /// Whether the interactive tutorial is active. pub run_tutorial: bool, /// Current tutorial step. @@ -194,6 +198,7 @@ impl Default for Settings { fn default() -> Self { Self { zsh_history_path: None, + atuin_db_path: None, run_tutorial: false, tutorial_step: TutorialStep::default(), show_animations: true,