use std::collections::HashMap;
use std::fs::File;
use std::io::{BufRead, BufReader};
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
use rusqlite::{params, Connection};
const BASH_HISTORY: &str = "~/.bash_history";
const ZSH_HISTORY: &str = "~/.zsh_history";
const NUSHELL_HISTORY: &str = "~/.config/nushell/history.db";
pub struct CliHistorySource;
impl CliHistorySource {
pub fn import(conn: &Connection, dry_run: bool) -> Result<usize, String> {
let mut imported = 0;
if let Some(count) = Self::import_bash_history(conn, dry_run)? {
imported += count;
}
if let Some(count) = Self::import_zsh_history(conn, dry_run)? {
imported += count;
}
if let Some(count) = Self::import_nushell_history(conn, dry_run)? {
imported += count;
}
Ok(imported)
}
fn import_bash_history(conn: &Connection, dry_run: bool) -> Result<Option<usize>, String> {
let path = Self::expand_path(BASH_HISTORY);
if !path.exists() {
return Ok(None);
}
let file = File::open(&path).map_err(|e| format!("Failed to open bash history: {}", e))?;
let reader = BufReader::new(file);
let mut dedup: HashMap<String, i64> = HashMap::new();
for line in reader.lines().map_while(Result::ok) {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let hash = Self::content_hash(trimmed);
if let Ok(true) = check_duplicate(conn, &hash) {
continue;
}
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
dedup.insert(hash, ts);
}
if dedup.is_empty() {
return Ok(None);
}
let mut batch: Vec<(String, String, String)> = Vec::new();
for (hash, ts) in dedup {
let content = Self::get_content_by_hash(&path, ts).unwrap_or_else(|_| String::new());
if !content.is_empty() {
batch.push((hash, content, "cli-history".to_string()));
}
}
if dry_run {
println!("Would import {} bash history entries", batch.len());
return Ok(Some(batch.len()));
}
let imported_count = batch.len();
let tx = conn.unchecked_transaction().map_err(|e| e.to_string())?;
for (hash, content, source) in batch {
let id = uuid::Uuid::new_v4().to_string();
let now = Self::unix_now_secs();
tx.execute(
"INSERT INTO events (id, timestamp, source, content, meta, ingested_at, content_hash)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params![
id,
now,
source,
content,
Option::<String>::None,
now,
hash
],
).map_err(|e| e.to_string())?;
}
tx.commit().map_err(|e| e.to_string())?;
Ok(Some(imported_count))
}
fn import_zsh_history(conn: &Connection, dry_run: bool) -> Result<Option<usize>, String> {
let path = Self::expand_path(ZSH_HISTORY);
if !path.exists() {
return Ok(None);
}
let file = File::open(&path).map_err(|e| format!("Failed to open zsh history: {}", e))?;
let reader = BufReader::new(file);
let mut dedup: HashMap<String, i64> = HashMap::new();
for line in reader.lines().map_while(Result::ok) {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let hash = Self::content_hash(trimmed);
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
dedup.insert(hash, ts);
}
if dedup.is_empty() {
return Ok(None);
}
let mut batch: Vec<(String, String, String)> = Vec::new();
for (hash, ts) in dedup {
let content = Self::get_content_by_hash(&path, ts).unwrap_or_else(|_| String::new());
if !content.is_empty() {
batch.push((hash, content, "cli-history".to_string()));
}
}
if dry_run {
println!("Would import {} zsh history entries", batch.len());
return Ok(Some(batch.len()));
}
let imported_count = batch.len();
let tx = conn.unchecked_transaction().map_err(|e| e.to_string())?;
for (hash, content, source) in batch {
let id = uuid::Uuid::new_v4().to_string();
let now = Self::unix_now_secs();
tx.execute(
"INSERT INTO events (id, timestamp, source, content, meta, ingested_at, content_hash)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params![
id,
now,
source,
content,
Option::<String>::None,
now,
hash
],
).map_err(|e| e.to_string())?;
}
tx.commit().map_err(|e| e.to_string())?;
Ok(Some(imported_count))
}
fn import_nushell_history(conn: &Connection, dry_run: bool) -> Result<Option<usize>, String> {
let path = Self::expand_path(NUSHELL_HISTORY);
if !path.exists() {
return Ok(None);
}
let nushell_conn = Connection::open(&path)
.map_err(|e| format!("Failed to open nushell history: {}", e))?;
let mut dedup: HashMap<String, i64> = HashMap::new();
let mut stmt = nushell_conn
.prepare("SELECT value, created_at FROM history")
.map_err(|e| e.to_string())?;
let rows = stmt
.query_map([], |row| {
let value: String = row.get(0)?;
let created_at: i64 = row.get(1)?;
Ok((value, created_at))
})
.map_err(|e| e.to_string())?;
for row in rows {
let (value, created_at) = row.map_err(|e| e.to_string())?;
let trimmed = value.trim();
if trimmed.is_empty() {
continue;
}
let hash = Self::content_hash(trimmed);
dedup.insert(hash, created_at);
}
if dedup.is_empty() {
return Ok(None);
}
let mut batch: Vec<(String, String, String)> = Vec::new();
for (hash, ts) in dedup {
let content = Self::get_content_by_hash(&path, ts).unwrap_or_else(|_| String::new());
if !content.is_empty() {
batch.push((hash, content, "cli-history".to_string()));
}
}
if dry_run {
println!("Would import {} nushell history entries", batch.len());
return Ok(Some(batch.len()));
}
let imported_count = batch.len();
let tx = conn.unchecked_transaction().map_err(|e| e.to_string())?;
for (hash, content, source) in batch {
let id = uuid::Uuid::new_v4().to_string();
let now = Self::unix_now_secs();
tx.execute(
"INSERT INTO events (id, timestamp, source, content, meta, ingested_at, content_hash)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params![
id,
now,
source,
content,
Option::<String>::None,
now,
hash
],
).map_err(|e| e.to_string())?;
}
tx.commit().map_err(|e| e.to_string())?;
Ok(Some(imported_count))
}
fn get_content_by_hash(_path: &PathBuf, _ts: i64) -> Result<String, String> {
Ok(String::new())
}
fn content_hash(content: &str) -> String {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(content.as_bytes());
format!("{:x}", hasher.finalize())
}
fn expand_path(path: &str) -> PathBuf {
if path.starts_with('~') {
if let Ok(home) = std::env::var("HOME") {
return PathBuf::from(home).join(path.strip_prefix('~').unwrap());
}
}
PathBuf::from(path)
}
fn unix_now_secs() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs() as i64
}
}
fn check_duplicate(conn: &Connection, content_hash: &str) -> Result<bool, String> {
let exists: bool = conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM events WHERE content_hash = ?1)",
[content_hash],
|row| row.get(0),
)
.map_err(|e| e.to_string())?;
Ok(exists)
}