use anyhow::{Context, Result, bail};
use std::fs;
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
#[derive(Debug, Default)]
pub struct ExportResult {
pub r_exported: usize,
pub shell_exported: usize,
}
pub fn export_history(
r_db_path: &Path,
shell_db_path: &Path,
output_path: &Path,
r_table: &str,
shell_table: &str,
) -> Result<ExportResult> {
use super::import::validate_table_name;
validate_table_name(r_table)?;
validate_table_name(shell_table)?;
if r_table == shell_table {
bail!(
"R table name and shell table name must be different (both are '{}')",
r_table
);
}
if output_path.exists() {
bail!(
"Output file already exists: {}\nRemove it or specify a different path.",
output_path.display()
);
}
if let Some(parent) = output_path.parent()
&& !parent.as_os_str().is_empty()
&& !parent.exists()
{
bail!(
"Parent directory does not exist: {}\nCreate it first or specify a different path.",
parent.display()
);
}
let test_write_path = output_path.with_extension("arf-write-test");
match fs::File::create(&test_write_path) {
Ok(_) => {
let _ = fs::remove_file(&test_write_path);
}
Err(e) => {
bail!(
"Cannot write to output location: {}\n{}",
output_path.display(),
e
);
}
}
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let pid = std::process::id();
let temp_extension = format!("arf-export-tmp-{}-{}", timestamp, pid);
let temp_path = output_path.with_extension(temp_extension);
match fs::remove_file(&temp_path) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => {
return Err(e).with_context(|| {
format!("Failed to remove stale temp file: {}", temp_path.display())
});
}
}
let result = export_to_file(r_db_path, shell_db_path, &temp_path, r_table, shell_table);
match result {
Ok(export_result) => {
fs::rename(&temp_path, output_path).with_context(|| {
format!(
"Failed to rename temp file {} to {}",
temp_path.display(),
output_path.display()
)
})?;
Ok(export_result)
}
Err(e) => {
let _ = fs::remove_file(&temp_path);
Err(e)
}
}
}
fn export_to_file(
r_db_path: &Path,
shell_db_path: &Path,
output_path: &Path,
r_table: &str,
shell_table: &str,
) -> Result<ExportResult> {
use super::artifact::HistoryKind;
use rusqlite::{Connection, OpenFlags};
let mut output_db =
Connection::open(output_path).context("Failed to create output database")?;
super::artifact::write_export_metadata(&mut output_db)
.context("Failed to write history export metadata")?;
let mut result = ExportResult::default();
if r_db_path.exists() {
let r_db = Connection::open_with_flags(r_db_path, OpenFlags::SQLITE_OPEN_READ_ONLY)
.with_context(|| format!("Failed to open R history: {}", r_db_path.display()))?;
super::artifact::validate_history_artifact(&r_db, HistoryKind::R)
.with_context(|| format!("Invalid R history artifact: {}", r_db_path.display()))?;
result.r_exported = copy_history_table(&r_db, &mut output_db, r_table)?;
}
if shell_db_path.exists() {
let shell_db = Connection::open_with_flags(shell_db_path, OpenFlags::SQLITE_OPEN_READ_ONLY)
.with_context(|| {
format!("Failed to open shell history: {}", shell_db_path.display())
})?;
super::artifact::validate_history_artifact(&shell_db, HistoryKind::Shell).with_context(
|| {
format!(
"Invalid shell history artifact: {}",
shell_db_path.display()
)
},
)?;
result.shell_exported = copy_history_table(&shell_db, &mut output_db, shell_table)?;
}
Ok(result)
}
fn copy_history_table(
source: &rusqlite::Connection,
dest: &mut rusqlite::Connection,
dest_table: &str,
) -> Result<usize> {
let create_sql = format!(
r#"CREATE TABLE IF NOT EXISTS "{}" (
id INTEGER PRIMARY KEY AUTOINCREMENT,
command_line TEXT NOT NULL,
start_timestamp INTEGER,
session_id INTEGER,
hostname TEXT,
cwd TEXT,
duration_ms INTEGER,
exit_status INTEGER,
more_info TEXT
)"#,
dest_table
);
dest.execute(&create_sql, [])
.with_context(|| format!("Failed to create table '{}'", dest_table))?;
let index_sqls = [
format!(
r#"CREATE INDEX IF NOT EXISTS "idx_{}_time" ON "{}" (start_timestamp)"#,
dest_table, dest_table
),
format!(
r#"CREATE INDEX IF NOT EXISTS "idx_{}_cwd" ON "{}" (cwd)"#,
dest_table, dest_table
),
format!(
r#"CREATE INDEX IF NOT EXISTS "idx_{}_exit_status" ON "{}" (exit_status)"#,
dest_table, dest_table
),
format!(
r#"CREATE INDEX IF NOT EXISTS "idx_{}_cmd" ON "{}" (command_line)"#,
dest_table, dest_table
),
];
for sql in &index_sqls {
dest.execute(sql, []).context("Failed to create index")?;
}
let has_table: i32 = source
.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='history'",
[],
|row| row.get(0),
)
.context("Failed to check if source has history table")?;
if has_table == 0 {
return Ok(0);
}
let mut read_stmt = source
.prepare(
"SELECT command_line, start_timestamp, session_id, hostname, cwd, duration_ms, exit_status, more_info FROM history ORDER BY id",
)
.context("Failed to prepare read query")?;
let insert_sql = format!(
r#"INSERT INTO "{}" (command_line, start_timestamp, session_id, hostname, cwd, duration_ms, exit_status, more_info) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"#,
dest_table
);
let tx = dest.transaction().context("Failed to start transaction")?;
let mut count = 0;
{
let mut insert_stmt = tx
.prepare(&insert_sql)
.context("Failed to prepare insert")?;
let rows = read_stmt
.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, Option<i64>>(1)?,
row.get::<_, Option<i64>>(2)?,
row.get::<_, Option<String>>(3)?,
row.get::<_, Option<String>>(4)?,
row.get::<_, Option<i64>>(5)?,
row.get::<_, Option<i64>>(6)?,
row.get::<_, Option<String>>(7)?,
))
})
.context("Failed to query source history")?;
for row in rows {
let (cmd, ts, sess, host, cwd, dur, exit, info) =
row.context("Failed to read row from source")?;
insert_stmt
.execute(rusqlite::params![cmd, ts, sess, host, cwd, dur, exit, info])
.context("Failed to insert row")?;
count += 1;
}
}
tx.commit().context("Failed to commit transaction")?;
Ok(count)
}
#[cfg(test)]
mod tests {
use super::*;
use reedline::{History, HistoryItem, SqliteBackedHistory};
use tempfile::TempDir;
fn create_test_history(path: &Path, commands: &[&str]) {
let mut history = SqliteBackedHistory::with_file(path.to_path_buf(), None, None).unwrap();
for cmd in commands {
history
.save(HistoryItem {
id: None,
command_line: cmd.to_string(),
start_timestamp: None,
session_id: None,
hostname: None,
cwd: None,
duration: None,
exit_status: None,
more_info: None,
})
.unwrap();
}
}
fn write_artifact_metadata(
path: &Path,
artifact: &str,
format_version: &str,
history_kind: Option<&str>,
) {
let connection = rusqlite::Connection::open(path).unwrap();
connection
.execute_batch(
r#"CREATE TABLE arf_metadata (
key TEXT PRIMARY KEY NOT NULL,
value TEXT NOT NULL
)"#,
)
.unwrap();
let mut entries = vec![
("artifact", artifact),
("format_version", format_version),
("created_by_version", env!("CARGO_PKG_VERSION")),
];
if let Some(kind) = history_kind {
entries.push(("history_kind", kind));
}
for (key, value) in entries {
connection
.execute(
"INSERT INTO arf_metadata (key, value) VALUES (?1, ?2)",
[key, value],
)
.unwrap();
}
}
#[test]
fn test_export_history_basic() {
let temp_dir = TempDir::new().unwrap();
let r_path = temp_dir.path().join("r.db");
let shell_path = temp_dir.path().join("shell.db");
let output_path = temp_dir.path().join("export.db");
create_test_history(&r_path, &["library(dplyr)", "print(1)"]);
create_test_history(&shell_path, &["ls", "pwd"]);
let result = export_history(&r_path, &shell_path, &output_path, "r", "shell").unwrap();
assert_eq!(result.r_exported, 2);
assert_eq!(result.shell_exported, 2);
assert!(output_path.exists());
let db = rusqlite::Connection::open(&output_path).unwrap();
assert_eq!(
crate::history::artifact::read_artifact(&db).unwrap(),
crate::history::artifact::HistoryArtifact::Export
);
let metadata: std::collections::HashMap<String, String> = db
.prepare("SELECT key, value FROM arf_metadata")
.unwrap()
.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))
.unwrap()
.map(|row| row.unwrap())
.collect();
assert_eq!(metadata.len(), 3);
assert_eq!(
metadata.get("artifact").map(String::as_str),
Some("history-export")
);
assert_eq!(
metadata.get("format_version").map(String::as_str),
Some("1")
);
assert_eq!(
metadata.get("created_by_version").map(String::as_str),
Some(env!("CARGO_PKG_VERSION"))
);
let r_count: i32 = db
.query_row("SELECT COUNT(*) FROM r", [], |row| row.get(0))
.unwrap();
assert_eq!(r_count, 2);
let shell_count: i32 = db
.query_row("SELECT COUNT(*) FROM shell", [], |row| row.get(0))
.unwrap();
assert_eq!(shell_count, 2);
drop(db);
let unified =
super::super::import::parse_unified_arf_history(&output_path, "r", "shell").unwrap();
assert_eq!(unified.entries.len(), 4);
let error = crate::history::import::parse_arf_history(&output_path).unwrap_err();
assert!(error.to_string().contains("unified history export"));
}
#[test]
fn test_export_history_custom_table_names() {
let temp_dir = TempDir::new().unwrap();
let r_path = temp_dir.path().join("r.db");
let output_path = temp_dir.path().join("export.db");
create_test_history(&r_path, &["test"]);
let result = export_history(
&r_path,
&temp_dir.path().join("nonexistent.db"),
&output_path,
"my_r",
"my_shell",
)
.unwrap();
assert_eq!(result.r_exported, 1);
assert_eq!(result.shell_exported, 0);
let db = rusqlite::Connection::open(&output_path).unwrap();
let count: i32 = db
.query_row("SELECT COUNT(*) FROM my_r", [], |row| row.get(0))
.unwrap();
assert_eq!(count, 1);
}
#[test]
fn test_export_refuses_overwrite() {
let temp_dir = TempDir::new().unwrap();
let r_path = temp_dir.path().join("r.db");
let output_path = temp_dir.path().join("export.db");
create_test_history(&r_path, &["test"]);
export_history(
&r_path,
&temp_dir.path().join("none.db"),
&output_path,
"r",
"shell",
)
.unwrap();
let result = export_history(
&r_path,
&temp_dir.path().join("none.db"),
&output_path,
"r",
"shell",
);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("already exists"));
}
#[test]
fn test_export_no_temp_file_left_on_success() {
let temp_dir = TempDir::new().unwrap();
let r_path = temp_dir.path().join("r.db");
let output_path = temp_dir.path().join("export.db");
create_test_history(&r_path, &["test"]);
export_history(
&r_path,
&temp_dir.path().join("none.db"),
&output_path,
"r",
"shell",
)
.unwrap();
assert!(output_path.exists());
let entries: Vec<_> = std::fs::read_dir(temp_dir.path())
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| e.file_name().to_string_lossy().contains("arf-export-tmp"))
.collect();
assert!(entries.is_empty(), "Temp files should be cleaned up");
}
#[test]
fn test_export_rejects_same_table_names() {
let temp_dir = TempDir::new().unwrap();
let r_path = temp_dir.path().join("r.db");
let output_path = temp_dir.path().join("export.db");
create_test_history(&r_path, &["test"]);
let result = export_history(
&r_path,
&temp_dir.path().join("none.db"),
&output_path,
"history",
"history",
);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("must be different"));
}
#[test]
fn test_export_with_sqlite_reserved_words_as_table_names() {
let temp_dir = TempDir::new().unwrap();
let r_path = temp_dir.path().join("r.db");
let shell_path = temp_dir.path().join("shell.db");
let output_path = temp_dir.path().join("export.db");
create_test_history(&r_path, &["library(dplyr)"]);
create_test_history(&shell_path, &["ls"]);
let result = export_history(&r_path, &shell_path, &output_path, "select", "from").unwrap();
assert_eq!(result.r_exported, 1);
assert_eq!(result.shell_exported, 1);
let db = rusqlite::Connection::open(&output_path).unwrap();
let r_count: i32 = db
.query_row(r#"SELECT COUNT(*) FROM "select""#, [], |row| row.get(0))
.unwrap();
let shell_count: i32 = db
.query_row(r#"SELECT COUNT(*) FROM "from""#, [], |row| row.get(0))
.unwrap();
assert_eq!(r_count, 1);
assert_eq!(shell_count, 1);
}
#[test]
fn test_export_cleans_up_temp_file_on_failure() {
let temp_dir = TempDir::new().unwrap();
let invalid_db_path = temp_dir.path().join("invalid.db");
std::fs::write(&invalid_db_path, "not a valid sqlite database").unwrap();
let output_path = temp_dir.path().join("export.db");
let result = export_history(
&invalid_db_path,
&temp_dir.path().join("none.db"),
&output_path,
"r",
"shell",
);
assert!(result.is_err());
assert!(!output_path.exists());
let entries: Vec<_> = std::fs::read_dir(temp_dir.path())
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| e.file_name().to_string_lossy().contains("arf-export-tmp"))
.collect();
assert!(
entries.is_empty(),
"Temp files should be cleaned up on failure"
);
}
#[test]
fn test_export_cleans_up_temp_file_on_copy_failure() {
let temp_dir = TempDir::new().unwrap();
let r_path = temp_dir.path().join("r.db");
create_test_history(&r_path, &["test1", "test2"]);
let shell_path = temp_dir.path().join("shell.db");
std::fs::write(&shell_path, "not a valid sqlite database").unwrap();
let output_path = temp_dir.path().join("export.db");
let result = export_history(&r_path, &shell_path, &output_path, "r", "shell");
assert!(result.is_err());
assert!(!output_path.exists());
let entries: Vec<_> = std::fs::read_dir(temp_dir.path())
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| e.file_name().to_string_lossy().contains("arf-export-tmp"))
.collect();
assert!(
entries.is_empty(),
"Temp files should be cleaned up on copy failure"
);
}
#[test]
fn test_export_rejects_incompatible_r_artifacts_and_cleans_up_temp_files() {
for (artifact, format_version, kind, expected_error) in [
("history", "1", Some("shell"), "kind mismatch"),
("history-export", "1", None, "unified history export"),
(
"history",
"2",
Some("r"),
"unsupported arf artifact format version 2",
),
] {
let temp_dir = TempDir::new().unwrap();
let r_path = temp_dir.path().join("r.db");
write_artifact_metadata(&r_path, artifact, format_version, kind);
let output_path = temp_dir.path().join("export.db");
let error = export_history(
&r_path,
&temp_dir.path().join("missing-shell.db"),
&output_path,
"r",
"shell",
)
.unwrap_err();
assert!(format!("{error:#}").contains(expected_error));
assert!(!output_path.exists());
let temp_files: Vec<_> = std::fs::read_dir(temp_dir.path())
.unwrap()
.filter_map(|entry| entry.ok())
.filter(|entry| {
entry
.file_name()
.to_string_lossy()
.contains("arf-export-tmp")
})
.collect();
assert!(
temp_files.is_empty(),
"export temp file leaked for {artifact}"
);
}
}
#[test]
fn test_export_import_round_trip() {
use crate::history::HistoryStore;
use crate::history::import::{ImportTargets, import_entries, parse_unified_arf_history};
let temp_dir = TempDir::new().unwrap();
let r_path = temp_dir.path().join("r.db");
let shell_path = temp_dir.path().join("shell.db");
create_test_history(&r_path, &["library(dplyr)", "summary(iris)", "print(1)"]);
create_test_history(&shell_path, &["ls -la", "pwd", "git status"]);
let export_path = temp_dir.path().join("backup.db");
let export_result =
export_history(&r_path, &shell_path, &export_path, "r", "shell").unwrap();
assert_eq!(export_result.r_exported, 3);
assert_eq!(export_result.shell_exported, 3);
let entries = parse_unified_arf_history(&export_path, "r", "shell").unwrap();
assert_eq!(entries.len(), 6);
let new_r_path = temp_dir.path().join("new_r.db");
let new_shell_path = temp_dir.path().join("new_shell.db");
let mut targets = ImportTargets {
r_history: HistoryStore::open(
new_r_path,
crate::history::artifact::HistoryKind::R,
None,
None,
)
.unwrap(),
shell_history: HistoryStore::open(
new_shell_path,
crate::history::artifact::HistoryKind::Shell,
None,
None,
)
.unwrap(),
};
let import_result = import_entries(&mut targets, entries.entries, None, false).unwrap();
assert_eq!(import_result.r_imported, 3);
assert_eq!(import_result.shell_imported, 3);
let r_query = reedline::SearchQuery::everything(reedline::SearchDirection::Forward, None);
let r_items = targets.r_history.search(r_query).unwrap();
let r_commands: Vec<&str> = r_items.iter().map(|i| i.command_line.as_str()).collect();
assert!(r_commands.contains(&"library(dplyr)"));
assert!(r_commands.contains(&"summary(iris)"));
assert!(r_commands.contains(&"print(1)"));
let shell_query =
reedline::SearchQuery::everything(reedline::SearchDirection::Forward, None);
let shell_items = targets.shell_history.search(shell_query).unwrap();
let shell_commands: Vec<&str> = shell_items
.iter()
.map(|i| i.command_line.as_str())
.collect();
assert!(shell_commands.contains(&"ls -la"));
assert!(shell_commands.contains(&"pwd"));
assert!(shell_commands.contains(&"git status"));
}
}