Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down
139 changes: 132 additions & 7 deletions src/history.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<HistoryEntry> {
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<Vec<HistoryEntry>, 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<HistoryEntry> = Vec::new();
while let sqlite::State::Row = statement.next()? {
let ts_ns: i64 = statement.read::<i64, _>(0)?;
let command: String = statement.read::<String, _>(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<HistoryEntry> {
let hist_path = match custom_path {
Some(p) if !p.is_empty() => p.to_string(),
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
7 changes: 7 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// Load history from an atuin SQLite database at PATH instead of bash history
#[arg(long = "atuin-db", value_name = "PATH")]
atuin_db: Option<String>,
/// Show animations
#[arg(long = "show-animations", default_missing_value = "true", num_args = 0..=1)]
show_animations: Option<bool>,
Expand Down Expand Up @@ -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;
Expand Down
5 changes: 5 additions & 0 deletions src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// 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<String>,
/// Whether the interactive tutorial is active.
pub run_tutorial: bool,
/// Current tutorial step.
Expand Down Expand Up @@ -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,
Expand Down