use anyhow::{Context, Result, bail};
use chrono::{DateTime, NaiveDateTime, Utc};
use reedline::{HistoryItem, SqliteBackedHistory};
use std::collections::HashSet;
use std::fs::File;
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub struct ImportEntry {
pub command: String,
pub timestamp: Option<DateTime<Utc>>,
pub mode: Option<String>,
}
#[derive(Debug, Default)]
pub struct ImportResult {
pub r_imported: usize,
pub shell_imported: usize,
pub skipped: usize,
pub duplicates_skipped: usize,
pub warnings: Vec<String>,
}
impl ImportResult {
#[allow(dead_code)]
pub fn total_imported(&self) -> usize {
self.r_imported + self.shell_imported
}
}
pub fn default_radian_path() -> PathBuf {
dirs::home_dir()
.map(|h| h.join(".radian_history"))
.unwrap_or_else(|| PathBuf::from(".radian_history"))
}
pub fn default_r_history_path() -> PathBuf {
if let Ok(path) = std::env::var("R_HISTFILE") {
return PathBuf::from(path);
}
PathBuf::from(".Rhistory")
}
pub fn parse_radian_history(path: &Path) -> Result<Vec<ImportEntry>> {
let file = File::open(path)
.with_context(|| format!("Failed to open radian history: {}", path.display()))?;
let reader = BufReader::new(file);
let mut entries = Vec::new();
let mut current_timestamp: Option<DateTime<Utc>> = None;
let mut current_mode: Option<String> = None;
let mut current_lines: Vec<String> = Vec::new();
for line_result in reader.lines() {
let line = line_result.with_context(|| "Failed to read line from radian history")?;
if line.starts_with("# time: ") {
if !current_lines.is_empty() {
let command = current_lines.join("\n");
entries.push(ImportEntry {
command,
timestamp: current_timestamp,
mode: current_mode.take(),
});
current_lines.clear();
}
current_mode = None;
let time_str = line.trim_start_matches("# time: ").trim();
let time_str = time_str.trim_end_matches(" UTC");
current_timestamp = NaiveDateTime::parse_from_str(time_str, "%Y-%m-%d %H:%M:%S")
.ok()
.map(|naive| naive.and_utc());
} else if line.starts_with("# mode: ") {
current_mode = Some(line.trim_start_matches("# mode: ").trim().to_string());
} else if let Some(content) = line.strip_prefix('+') {
let content = content.strip_suffix('\r').unwrap_or(content);
current_lines.push(content.to_string());
} else if line.trim().is_empty() {
if !current_lines.is_empty() {
let command = current_lines.join("\n");
entries.push(ImportEntry {
command,
timestamp: current_timestamp,
mode: current_mode.take(),
});
current_lines.clear();
current_timestamp = None;
}
}
}
if !current_lines.is_empty() {
let command = current_lines.join("\n");
entries.push(ImportEntry {
command,
timestamp: current_timestamp,
mode: current_mode.take(),
});
}
Ok(entries)
}
pub fn parse_r_history(path: &Path) -> Result<Vec<ImportEntry>> {
let file = File::open(path)
.with_context(|| format!("Failed to open R history: {}", path.display()))?;
let reader = BufReader::new(file);
let mut entries = Vec::new();
for line_result in reader.lines() {
let line = line_result.with_context(|| "Failed to read line from R history")?;
let content = line.trim_end();
if !content.trim().is_empty() {
entries.push(ImportEntry {
command: content.to_string(),
timestamp: None,
mode: Some("r".to_string()),
});
}
}
Ok(entries)
}
pub fn parse_arf_history(path: &Path) -> Result<Vec<ImportEntry>> {
use reedline::History;
if !path.exists() {
bail!("arf history database not found: {}", path.display());
}
let is_shell = path
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n == "shell.db");
let mode = if is_shell {
Some("shell".to_string())
} else {
Some("r".to_string())
};
let source = SqliteBackedHistory::with_file(path.to_path_buf(), None, None)
.with_context(|| format!("Failed to open arf history database: {}", path.display()))?;
let query = reedline::SearchQuery::everything(reedline::SearchDirection::Backward, None);
let items = source
.search(query)
.with_context(|| "Failed to query arf history")?;
let entries: Vec<ImportEntry> = items
.into_iter()
.map(|item| ImportEntry {
command: item.command_line,
timestamp: item.start_timestamp,
mode: mode.clone(),
})
.collect();
Ok(entries)
}
pub struct ImportTargets {
pub r_history: SqliteBackedHistory,
pub shell_history: SqliteBackedHistory,
}
fn classify_mode(mode: Option<&str>) -> Option<bool> {
match mode {
Some("shell") => Some(true), Some("r") | Some("browse") => Some(false), None => Some(false), Some(_) => None, }
}
pub struct DedupSet {
command_timestamps: HashSet<(String, i64)>,
commands: HashSet<String>,
}
impl DedupSet {
pub fn from_history(history: &SqliteBackedHistory) -> Result<Self> {
use reedline::History;
let query = reedline::SearchQuery::everything(reedline::SearchDirection::Backward, None);
let items = history
.search(query)
.context("Failed to query existing history for dedup")?;
let mut command_timestamps = HashSet::new();
let mut commands = HashSet::new();
for item in items {
commands.insert(item.command_line.clone());
if let Some(ts) = item.start_timestamp {
command_timestamps.insert((item.command_line, ts.timestamp_millis()));
}
}
Ok(DedupSet {
command_timestamps,
commands,
})
}
pub fn from_db(path: &Path) -> Result<Self> {
use rusqlite::{Connection, OpenFlags};
let db = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY)
.with_context(|| format!("Failed to open history database: {}", path.display()))?;
let mut stmt = db
.prepare("SELECT command_line, start_timestamp FROM history")
.with_context(|| {
format!(
"Failed to query history table in '{}' (not an arf database?)",
path.display()
)
})?;
let mut command_timestamps = HashSet::new();
let mut commands = HashSet::new();
let rows = stmt
.query_map([], |row| {
let command: String = row.get(0)?;
let ts_millis: Option<i64> = row.get(1)?;
Ok((command, ts_millis))
})
.context("Failed to query history for dedup")?;
for row in rows {
let (command, ts_millis) = row.context("Failed to read history row")?;
commands.insert(command.clone());
if let Some(ms) = ts_millis {
command_timestamps.insert((command, ms));
}
}
Ok(DedupSet {
command_timestamps,
commands,
})
}
fn is_duplicate(&self, command: &str, timestamp: Option<&DateTime<Utc>>) -> bool {
if !self.commands.contains(command) {
return false;
}
if let Some(ts) = timestamp {
self.command_timestamps
.contains(&(command.to_string(), ts.timestamp_millis()))
} else {
true }
}
}
pub fn import_entries_dry_run(
entries: &[ImportEntry],
r_dedup: Option<&DedupSet>,
shell_dedup: Option<&DedupSet>,
) -> ImportResult {
let mut result = ImportResult::default();
for entry in entries {
if entry.command.trim().is_empty() {
result.skipped += 1;
continue;
}
let is_shell = match classify_mode(entry.mode.as_deref()) {
Some(is_shell) => is_shell,
None => {
let mode = entry.mode.as_deref().unwrap_or("?");
let cmd_preview: String = entry.command.chars().take(30).collect();
result.warnings.push(format!(
"Skipped unknown mode '{}': {}...",
mode, cmd_preview
));
result.skipped += 1;
continue;
}
};
let dedup_set = if is_shell { shell_dedup } else { r_dedup };
if let Some(dedup) = dedup_set
&& dedup.is_duplicate(&entry.command, entry.timestamp.as_ref())
{
result.duplicates_skipped += 1;
continue;
}
if is_shell {
result.shell_imported += 1;
} else {
result.r_imported += 1;
}
}
result
}
pub fn import_entries(
targets: &mut ImportTargets,
entries: Vec<ImportEntry>,
hostname_override: Option<&str>,
skip_duplicates: bool,
) -> Result<ImportResult> {
use reedline::History;
let (r_dedup, shell_dedup) = if skip_duplicates {
(
Some(DedupSet::from_history(&targets.r_history)?),
Some(DedupSet::from_history(&targets.shell_history)?),
)
} else {
(None, None)
};
let mut result = ImportResult::default();
for entry in entries {
if entry.command.trim().is_empty() {
result.skipped += 1;
continue;
}
let is_shell = match classify_mode(entry.mode.as_deref()) {
Some(is_shell) => is_shell,
None => {
let mode = entry.mode.as_deref().unwrap_or("?");
let cmd_preview: String = entry.command.chars().take(30).collect();
result.warnings.push(format!(
"Skipped unknown mode '{}': {}...",
mode, cmd_preview
));
result.skipped += 1;
continue;
}
};
if let Some(dedup_set) = if is_shell { &shell_dedup } else { &r_dedup }
&& dedup_set.is_duplicate(&entry.command, entry.timestamp.as_ref())
{
result.duplicates_skipped += 1;
continue;
}
let item = HistoryItem {
id: None, command_line: entry.command,
start_timestamp: entry.timestamp,
session_id: None,
hostname: hostname_override.map(|s| s.to_string()),
cwd: None,
duration: None,
exit_status: None,
more_info: None,
};
let save_result = if is_shell {
targets.shell_history.save(item)
} else {
targets.r_history.save(item)
};
match save_result {
Ok(_) => {
if is_shell {
result.shell_imported += 1;
} else {
result.r_imported += 1;
}
}
Err(e) => {
result
.warnings
.push(format!("Failed to import entry: {}", e));
result.skipped += 1;
}
}
}
Ok(result)
}
pub fn validate_table_name(name: &str) -> Result<()> {
if name.is_empty() {
bail!("Table name cannot be empty");
}
if !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
bail!(
"Invalid table name '{}': must contain only alphanumeric characters and underscores",
name
);
}
if name.chars().next().is_some_and(|c| c.is_ascii_digit()) {
bail!("Invalid table name '{}': cannot start with a digit", name);
}
if !name.chars().any(|c| c.is_ascii_alphanumeric()) {
bail!(
"Invalid table name '{}': must contain at least one alphanumeric character",
name
);
}
Ok(())
}
pub fn parse_unified_arf_history(
path: &Path,
r_table: &str,
shell_table: &str,
) -> Result<Vec<ImportEntry>> {
use rusqlite::{Connection, OpenFlags};
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 !path.exists() {
bail!("arf export file not found: {}", path.display());
}
let db = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY)
.with_context(|| format!("Failed to open arf export file: {}", path.display()))?;
let mut entries = Vec::new();
if table_exists(&db, r_table)? {
let r_entries = read_history_table(&db, r_table, "r")?;
entries.extend(r_entries);
}
if table_exists(&db, shell_table)? {
let shell_entries = read_history_table(&db, shell_table, "shell")?;
entries.extend(shell_entries);
}
Ok(entries)
}
fn table_exists(db: &rusqlite::Connection, table_name: &str) -> Result<bool> {
let count: i32 = db
.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?",
[table_name],
|row| row.get(0),
)
.context("Failed to check if table exists")?;
Ok(count > 0)
}
fn read_history_table(
db: &rusqlite::Connection,
table_name: &str,
mode: &str,
) -> Result<Vec<ImportEntry>> {
use chrono::TimeZone;
let query = format!(
"SELECT command_line, start_timestamp FROM \"{}\" ORDER BY id",
table_name
);
let mut stmt = db.prepare(&query).with_context(|| {
format!(
"Failed to query table '{}' (not a valid history table?)",
table_name
)
})?;
let rows = stmt
.query_map([], |row| {
let command: String = row.get(0)?;
let ts_millis: Option<i64> = row.get(1)?;
Ok((command, ts_millis))
})
.context("Failed to query history")?;
let mut entries = Vec::new();
for row in rows {
let (command, ts_millis) = row.context("Failed to read history row")?;
let timestamp = ts_millis.and_then(|ms| Utc.timestamp_millis_opt(ms).single());
entries.push(ImportEntry {
command,
timestamp,
mode: Some(mode.to_string()),
});
}
Ok(entries)
}
#[cfg(test)]
mod tests;