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 {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
#[test]
fn test_parse_radian_history_basic() {
let mut file = NamedTempFile::new().unwrap();
writeln!(file, "# time: 2024-01-15 10:30:00 UTC").unwrap();
writeln!(file, "# mode: r").unwrap();
writeln!(file, "+library(dplyr)").unwrap();
writeln!(file).unwrap();
writeln!(file, "# time: 2024-01-15 10:31:00 UTC").unwrap();
writeln!(file, "# mode: shell").unwrap();
writeln!(file, "+ls -la").unwrap();
let entries = parse_radian_history(file.path()).unwrap();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].command, "library(dplyr)");
assert_eq!(entries[0].mode, Some("r".to_string()));
assert!(entries[0].timestamp.is_some());
assert_eq!(entries[1].command, "ls -la");
assert_eq!(entries[1].mode, Some("shell".to_string()));
}
#[test]
fn test_parse_radian_history_multiline() {
let mut file = NamedTempFile::new().unwrap();
writeln!(file, "# time: 2024-01-15 10:30:00 UTC").unwrap();
writeln!(file, "# mode: r").unwrap();
writeln!(file, "+iris %>%").unwrap();
writeln!(file, "+ filter(Species == \"setosa\") %>%").unwrap();
writeln!(file, "+ head()").unwrap();
let entries = parse_radian_history(file.path()).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(
entries[0].command,
"iris %>%\n filter(Species == \"setosa\") %>%\n head()"
);
}
#[test]
fn test_parse_radian_history_empty_file() {
let file = NamedTempFile::new().unwrap();
let entries = parse_radian_history(file.path()).unwrap();
assert!(entries.is_empty());
}
#[test]
fn test_parse_r_history_basic() {
let mut file = NamedTempFile::new().unwrap();
writeln!(file, "library(dplyr)").unwrap();
writeln!(file, "print(\"hello\")").unwrap();
writeln!(file).unwrap(); writeln!(file, "summary(iris)").unwrap();
let entries = parse_r_history(file.path()).unwrap();
assert_eq!(entries.len(), 3);
assert_eq!(entries[0].command, "library(dplyr)");
assert_eq!(entries[1].command, "print(\"hello\")");
assert_eq!(entries[2].command, "summary(iris)");
for entry in &entries {
assert_eq!(entry.mode, Some("r".to_string()));
assert!(entry.timestamp.is_none());
}
}
#[test]
fn test_parse_r_history_empty_file() {
let file = NamedTempFile::new().unwrap();
let entries = parse_r_history(file.path()).unwrap();
assert!(entries.is_empty());
}
#[test]
fn test_parse_r_history_preserves_leading_whitespace() {
let mut file = NamedTempFile::new().unwrap();
writeln!(file, "if (TRUE) {{").unwrap();
writeln!(file, r#" print("indented")"#).unwrap();
writeln!(file, "}}").unwrap();
let entries = parse_r_history(file.path()).unwrap();
assert_eq!(entries.len(), 3);
assert_eq!(entries[1].command, r#" print("indented")"#);
}
#[test]
fn test_default_paths() {
let radian_path = default_radian_path();
assert!(radian_path.to_string_lossy().contains("radian_history"));
let r_path = default_r_history_path();
assert!(
r_path.to_string_lossy().contains("Rhistory") || std::env::var("R_HISTFILE").is_ok()
);
}
#[test]
fn test_import_entry_struct() {
let entry = ImportEntry {
command: "test".to_string(),
timestamp: Some(Utc::now()),
mode: Some("r".to_string()),
};
assert_eq!(entry.command, "test");
assert!(entry.timestamp.is_some());
assert_eq!(entry.mode, Some("r".to_string()));
}
#[test]
fn test_parse_radian_history_timestamp_parsing() {
let mut file = NamedTempFile::new().unwrap();
writeln!(file, "# time: 2024-06-15 14:30:45 UTC").unwrap();
writeln!(file, "# mode: r").unwrap();
writeln!(file, "+test()").unwrap();
let entries = parse_radian_history(file.path()).unwrap();
assert_eq!(entries.len(), 1);
let ts = entries[0].timestamp.unwrap();
assert_eq!(
ts.format("%Y-%m-%d %H:%M:%S").to_string(),
"2024-06-15 14:30:45"
);
}
#[test]
fn test_parse_radian_history_browse_mode() {
let mut file = NamedTempFile::new().unwrap();
writeln!(file, "# time: 2024-01-15 10:30:00 UTC").unwrap();
writeln!(file, "# mode: browse").unwrap();
writeln!(file, "+n").unwrap();
let entries = parse_radian_history(file.path()).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].mode, Some("browse".to_string()));
}
fn create_test_targets(temp_dir: &tempfile::TempDir) -> ImportTargets {
let r_path = temp_dir.path().join("r.db");
let shell_path = temp_dir.path().join("shell.db");
ImportTargets {
r_history: SqliteBackedHistory::with_file(r_path, None, None).unwrap(),
shell_history: SqliteBackedHistory::with_file(shell_path, None, None).unwrap(),
}
}
#[test]
fn test_import_entries_to_sqlite() {
use reedline::History;
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let mut targets = create_test_targets(&temp_dir);
let entries = vec![
ImportEntry {
command: "library(ggplot2)".to_string(),
timestamp: Some(Utc::now()),
mode: Some("r".to_string()),
},
ImportEntry {
command: "print('hello')".to_string(),
timestamp: None,
mode: Some("r".to_string()),
},
];
let result = import_entries(&mut targets, entries, None, false).unwrap();
assert_eq!(result.r_imported, 2);
assert_eq!(result.shell_imported, 0);
assert_eq!(result.skipped, 0);
assert!(result.warnings.is_empty());
let query = reedline::SearchQuery::everything(reedline::SearchDirection::Backward, None);
let items = targets.r_history.search(query).unwrap();
assert_eq!(items.len(), 2);
let commands: Vec<&str> = items.iter().map(|i| i.command_line.as_str()).collect();
assert!(commands.contains(&"library(ggplot2)"));
assert!(commands.contains(&"print('hello')"));
}
#[test]
fn test_import_entries_routes_shell_mode() {
use reedline::History;
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let mut targets = create_test_targets(&temp_dir);
let entries = vec![
ImportEntry {
command: "library(dplyr)".to_string(),
timestamp: None,
mode: Some("r".to_string()),
},
ImportEntry {
command: "ls -la".to_string(),
timestamp: None,
mode: Some("shell".to_string()),
},
ImportEntry {
command: "pwd".to_string(),
timestamp: None,
mode: Some("shell".to_string()),
},
];
let result = import_entries(&mut targets, entries, None, false).unwrap();
assert_eq!(result.r_imported, 1);
assert_eq!(result.shell_imported, 2);
assert_eq!(result.skipped, 0);
let r_query = reedline::SearchQuery::everything(reedline::SearchDirection::Backward, None);
let r_items = targets.r_history.search(r_query).unwrap();
assert_eq!(r_items.len(), 1);
assert_eq!(r_items[0].command_line, "library(dplyr)");
let shell_query =
reedline::SearchQuery::everything(reedline::SearchDirection::Backward, None);
let shell_items = targets.shell_history.search(shell_query).unwrap();
assert_eq!(shell_items.len(), 2);
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"));
}
#[test]
fn test_import_entries_dry_run() {
let entries = vec![
ImportEntry {
command: "test_r".to_string(),
timestamp: None,
mode: Some("r".to_string()),
},
ImportEntry {
command: "test_shell".to_string(),
timestamp: None,
mode: Some("shell".to_string()),
},
ImportEntry {
command: "unknown_mode".to_string(),
timestamp: None,
mode: Some("python".to_string()), },
ImportEntry {
command: " ".to_string(), timestamp: None,
mode: Some("r".to_string()),
},
];
let result = import_entries_dry_run(&entries, None, None);
assert_eq!(result.r_imported, 1);
assert_eq!(result.shell_imported, 1);
assert_eq!(result.skipped, 2); assert_eq!(result.warnings.len(), 1); assert!(result.warnings[0].contains("python"));
}
#[test]
fn test_import_entries_skips_empty() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let mut targets = create_test_targets(&temp_dir);
let entries = vec![
ImportEntry {
command: "valid".to_string(),
timestamp: None,
mode: None,
},
ImportEntry {
command: " ".to_string(), timestamp: None,
mode: None,
},
ImportEntry {
command: "".to_string(), timestamp: None,
mode: None,
},
];
let result = import_entries(&mut targets, entries, None, false).unwrap();
assert_eq!(result.r_imported, 1); assert_eq!(result.shell_imported, 0);
assert_eq!(result.skipped, 2);
}
#[test]
fn test_import_entries_skips_unknown_modes() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let mut targets = create_test_targets(&temp_dir);
let entries = vec![
ImportEntry {
command: "valid_r".to_string(),
timestamp: None,
mode: Some("r".to_string()),
},
ImportEntry {
command: "valid_shell".to_string(),
timestamp: None,
mode: Some("shell".to_string()),
},
ImportEntry {
command: "unknown_mode_cmd".to_string(),
timestamp: None,
mode: Some("python".to_string()), },
ImportEntry {
command: "another_unknown".to_string(),
timestamp: None,
mode: Some("jupyter".to_string()), },
];
let result = import_entries(&mut targets, entries, None, false).unwrap();
assert_eq!(result.r_imported, 1);
assert_eq!(result.shell_imported, 1);
assert_eq!(result.skipped, 2);
assert_eq!(result.warnings.len(), 2);
assert!(result.warnings[0].contains("python"));
assert!(result.warnings[1].contains("jupyter"));
}
#[test]
fn test_import_entries_handles_browse_mode() {
use reedline::History;
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let mut targets = create_test_targets(&temp_dir);
let entries = vec![
ImportEntry {
command: "n".to_string(), timestamp: None,
mode: Some("browse".to_string()),
},
ImportEntry {
command: "c".to_string(), timestamp: None,
mode: Some("browse".to_string()),
},
];
let result = import_entries(&mut targets, entries, None, false).unwrap();
assert_eq!(result.r_imported, 2);
assert_eq!(result.shell_imported, 0);
assert_eq!(result.skipped, 0);
let query = reedline::SearchQuery::everything(reedline::SearchDirection::Backward, None);
let items = targets.r_history.search(query).unwrap();
assert_eq!(items.len(), 2);
}
#[test]
fn test_parse_arf_history_not_found() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let missing_path = temp_dir.path().join("nonexistent.db");
let result = parse_arf_history(&missing_path);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("not found"));
}
#[test]
fn test_parse_arf_history_infers_mode_from_filename() {
use reedline::History;
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let r_path = temp_dir.path().join("r.db");
let mut r_db = SqliteBackedHistory::with_file(r_path.clone(), None, None).unwrap();
r_db.save(HistoryItem {
id: None,
command_line: "summary(iris)".to_string(),
start_timestamp: None,
session_id: None,
hostname: None,
cwd: None,
duration: None,
exit_status: None,
more_info: None,
})
.unwrap();
drop(r_db);
let shell_path = temp_dir.path().join("shell.db");
let mut shell_db = SqliteBackedHistory::with_file(shell_path.clone(), None, None).unwrap();
shell_db
.save(HistoryItem {
id: None,
command_line: "ls -la".to_string(),
start_timestamp: None,
session_id: None,
hostname: None,
cwd: None,
duration: None,
exit_status: None,
more_info: None,
})
.unwrap();
drop(shell_db);
let r_entries = parse_arf_history(&r_path).unwrap();
assert_eq!(r_entries.len(), 1);
assert_eq!(r_entries[0].mode, Some("r".to_string()));
let shell_entries = parse_arf_history(&shell_path).unwrap();
assert_eq!(shell_entries.len(), 1);
assert_eq!(shell_entries[0].mode, Some("shell".to_string()));
}
#[test]
fn test_arf_shell_to_shell_import() {
use reedline::History;
use tempfile::TempDir;
let source_dir = TempDir::new().unwrap();
let target_dir = TempDir::new().unwrap();
let source_path = source_dir.path().join("old_shell.db");
let mut source_db =
SqliteBackedHistory::with_file(source_path.clone(), None, None).unwrap();
source_db
.save(HistoryItem {
id: None,
command_line: "git status".to_string(),
start_timestamp: Some(Utc::now()),
session_id: None,
hostname: None,
cwd: None,
duration: None,
exit_status: None,
more_info: None,
})
.unwrap();
drop(source_db);
let entries = parse_arf_history(&source_path).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].mode, Some("r".to_string()));
let shell_source_path = source_dir.path().join("shell.db");
std::fs::copy(&source_path, &shell_source_path).unwrap();
let shell_entries = parse_arf_history(&shell_source_path).unwrap();
assert_eq!(shell_entries.len(), 1);
assert_eq!(shell_entries[0].mode, Some("shell".to_string()));
let mut targets = create_test_targets(&target_dir);
let result = import_entries(&mut targets, shell_entries, None, false).unwrap();
assert_eq!(result.r_imported, 0);
assert_eq!(result.shell_imported, 1);
let query = reedline::SearchQuery::everything(reedline::SearchDirection::Backward, None);
let items = targets.shell_history.search(query).unwrap();
assert_eq!(items.len(), 1);
assert_eq!(items[0].command_line, "git status");
}
#[test]
fn test_end_to_end_radian_import_with_shell() {
use reedline::History;
use tempfile::TempDir;
let mut source_file = NamedTempFile::new().unwrap();
writeln!(source_file, "# time: 2024-03-15 09:00:00 UTC").unwrap();
writeln!(source_file, "# mode: r").unwrap();
writeln!(source_file, "+summary(mtcars)").unwrap();
writeln!(source_file).unwrap();
writeln!(source_file, "# time: 2024-03-15 09:01:00 UTC").unwrap();
writeln!(source_file, "# mode: shell").unwrap();
writeln!(source_file, "+git status").unwrap();
writeln!(source_file).unwrap();
writeln!(source_file, "# time: 2024-03-15 09:02:00 UTC").unwrap();
writeln!(source_file, "# mode: r").unwrap();
writeln!(source_file, "+plot(mtcars$mpg, mtcars$hp)").unwrap();
let entries = parse_radian_history(source_file.path()).unwrap();
assert_eq!(entries.len(), 3);
let temp_dir = TempDir::new().unwrap();
let mut targets = create_test_targets(&temp_dir);
let result = import_entries(&mut targets, entries, None, false).unwrap();
assert_eq!(result.r_imported, 2);
assert_eq!(result.shell_imported, 1);
let r_query = reedline::SearchQuery::everything(reedline::SearchDirection::Backward, None);
let r_items = targets.r_history.search(r_query).unwrap();
assert_eq!(r_items.len(), 2);
assert!(r_items.iter().all(|i| i.start_timestamp.is_some()));
let shell_query =
reedline::SearchQuery::everything(reedline::SearchDirection::Backward, None);
let shell_items = targets.shell_history.search(shell_query).unwrap();
assert_eq!(shell_items.len(), 1);
assert_eq!(shell_items[0].command_line, "git status");
assert!(shell_items[0].start_timestamp.is_some());
}
#[test]
fn test_import_entries_with_hostname_override() {
use reedline::History;
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let mut targets = create_test_targets(&temp_dir);
let entries = vec![
ImportEntry {
command: "library(dplyr)".to_string(),
timestamp: None,
mode: Some("r".to_string()),
},
ImportEntry {
command: "ls -la".to_string(),
timestamp: None,
mode: Some("shell".to_string()),
},
];
let result = import_entries(&mut targets, entries, Some("radian-import"), false).unwrap();
assert_eq!(result.r_imported, 1);
assert_eq!(result.shell_imported, 1);
let r_query = reedline::SearchQuery::everything(reedline::SearchDirection::Backward, None);
let r_items = targets.r_history.search(r_query).unwrap();
assert_eq!(r_items.len(), 1);
assert_eq!(r_items[0].hostname, Some("radian-import".to_string()));
let shell_query =
reedline::SearchQuery::everything(reedline::SearchDirection::Backward, None);
let shell_items = targets.shell_history.search(shell_query).unwrap();
assert_eq!(shell_items.len(), 1);
assert_eq!(shell_items[0].hostname, Some("radian-import".to_string()));
}
#[test]
fn test_parse_radian_history_mode_not_carried_over() {
let mut file = NamedTempFile::new().unwrap();
writeln!(file, "# time: 2024-01-15 10:30:00 UTC").unwrap();
writeln!(file, "# mode: shell").unwrap();
writeln!(file, "+ls -la").unwrap();
writeln!(file).unwrap();
writeln!(file, "# time: 2024-01-15 10:31:00 UTC").unwrap();
writeln!(file, "+library(dplyr)").unwrap();
let entries = parse_radian_history(file.path()).unwrap();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].command, "ls -la");
assert_eq!(entries[0].mode, Some("shell".to_string()));
assert_eq!(entries[1].command, "library(dplyr)");
assert_eq!(entries[1].mode, None);
}
#[test]
fn test_parse_radian_history_consecutive_timestamps_without_commands() {
let mut file = NamedTempFile::new().unwrap();
writeln!(file, "# time: 2024-01-15 10:30:00 UTC").unwrap();
writeln!(file, "# mode: r").unwrap();
writeln!(file).unwrap();
writeln!(file, "# time: 2024-01-15 10:31:00 UTC").unwrap();
writeln!(file, "# mode: shell").unwrap();
writeln!(file, "+git status").unwrap();
let entries = parse_radian_history(file.path()).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].command, "git status");
assert_eq!(entries[0].mode, Some("shell".to_string()));
}
#[test]
fn test_parse_radian_history_file_not_found() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let missing_path = temp_dir.path().join("nonexistent_radian_history");
let result = parse_radian_history(&missing_path);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("Failed to open radian history"));
}
#[test]
fn test_parse_r_history_file_not_found() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let missing_path = temp_dir.path().join("nonexistent_Rhistory");
let result = parse_r_history(&missing_path);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("Failed to open R history"));
}
#[test]
fn test_parse_radian_history_mode_reset_between_entries() {
let mut file = NamedTempFile::new().unwrap();
writeln!(file, "# time: 2024-01-15 10:30:00 UTC").unwrap();
writeln!(file, "# mode: shell").unwrap();
writeln!(file, "+pwd").unwrap();
writeln!(file).unwrap(); writeln!(file, "# time: 2024-01-15 10:31:00 UTC").unwrap();
writeln!(file, "+summary(iris)").unwrap();
writeln!(file).unwrap();
writeln!(file, "# time: 2024-01-15 10:32:00 UTC").unwrap();
writeln!(file, "# mode: browse").unwrap();
writeln!(file, "+n").unwrap();
let entries = parse_radian_history(file.path()).unwrap();
assert_eq!(entries.len(), 3);
assert_eq!(entries[0].mode, Some("shell".to_string()));
assert_eq!(entries[1].mode, None); assert_eq!(entries[2].mode, Some("browse".to_string()));
}
#[test]
fn test_parse_r_history_whitespace_only_lines_skipped() {
let mut file = NamedTempFile::new().unwrap();
writeln!(file, "library(dplyr)").unwrap();
writeln!(file, " ").unwrap(); writeln!(file, "\t").unwrap(); writeln!(file, "print(1)").unwrap();
let entries = parse_r_history(file.path()).unwrap();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].command, "library(dplyr)");
assert_eq!(entries[1].command, "print(1)");
}
#[test]
fn test_import_entries_none_mode_goes_to_r_database() {
use reedline::History;
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let mut targets = create_test_targets(&temp_dir);
let entries = vec![ImportEntry {
command: "summary(mtcars)".to_string(),
timestamp: None,
mode: None, }];
let result = import_entries(&mut targets, entries, None, false).unwrap();
assert_eq!(result.r_imported, 1);
assert_eq!(result.shell_imported, 0);
let query = reedline::SearchQuery::everything(reedline::SearchDirection::Backward, None);
let items = targets.r_history.search(query).unwrap();
assert_eq!(items.len(), 1);
assert_eq!(items[0].command_line, "summary(mtcars)");
}
#[test]
fn test_parse_radian_history_crlf_line_endings() {
let mut file = NamedTempFile::new().unwrap();
file.write_all(b"# time: 2024-01-15 10:30:00 UTC\r\n")
.unwrap();
file.write_all(b"# mode: r\r\n").unwrap();
file.write_all(b"+print(1)\r\n").unwrap();
let entries = parse_radian_history(file.path()).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].command, "print(1)");
assert!(!entries[0].command.ends_with('\r'));
}
#[test]
fn test_import_skips_duplicates_with_timestamp() {
use reedline::History;
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let mut targets = create_test_targets(&temp_dir);
let ts = DateTime::parse_from_rfc3339("2024-06-15T14:30:45Z")
.unwrap()
.with_timezone(&Utc);
let entries = vec![ImportEntry {
command: "library(dplyr)".to_string(),
timestamp: Some(ts),
mode: Some("r".to_string()),
}];
let result = import_entries(&mut targets, entries, None, true).unwrap();
assert_eq!(result.r_imported, 1);
assert_eq!(result.duplicates_skipped, 0);
let entries = vec![ImportEntry {
command: "library(dplyr)".to_string(),
timestamp: Some(ts),
mode: Some("r".to_string()),
}];
let result = import_entries(&mut targets, entries, None, true).unwrap();
assert_eq!(result.r_imported, 0);
assert_eq!(result.duplicates_skipped, 1);
let query = reedline::SearchQuery::everything(reedline::SearchDirection::Backward, None);
let items = targets.r_history.search(query).unwrap();
assert_eq!(items.len(), 1);
}
#[test]
fn test_import_skips_duplicates_without_timestamp() {
use reedline::History;
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let mut targets = create_test_targets(&temp_dir);
let entries = vec![ImportEntry {
command: "summary(iris)".to_string(),
timestamp: None,
mode: Some("r".to_string()),
}];
let result = import_entries(&mut targets, entries, None, true).unwrap();
assert_eq!(result.r_imported, 1);
assert_eq!(result.duplicates_skipped, 0);
let entries = vec![ImportEntry {
command: "summary(iris)".to_string(),
timestamp: None,
mode: Some("r".to_string()),
}];
let result = import_entries(&mut targets, entries, None, true).unwrap();
assert_eq!(result.r_imported, 0);
assert_eq!(result.duplicates_skipped, 1);
let query = reedline::SearchQuery::everything(reedline::SearchDirection::Backward, None);
let items = targets.r_history.search(query).unwrap();
assert_eq!(items.len(), 1);
}
#[test]
fn test_import_allows_same_command_different_timestamp() {
use reedline::History;
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let mut targets = create_test_targets(&temp_dir);
let ts1 = DateTime::parse_from_rfc3339("2024-06-15T14:30:45Z")
.unwrap()
.with_timezone(&Utc);
let ts2 = DateTime::parse_from_rfc3339("2024-06-15T15:00:00Z")
.unwrap()
.with_timezone(&Utc);
let entries = vec![ImportEntry {
command: "library(dplyr)".to_string(),
timestamp: Some(ts1),
mode: Some("r".to_string()),
}];
let result = import_entries(&mut targets, entries, None, true).unwrap();
assert_eq!(result.r_imported, 1);
let entries = vec![ImportEntry {
command: "library(dplyr)".to_string(),
timestamp: Some(ts2),
mode: Some("r".to_string()),
}];
let result = import_entries(&mut targets, entries, None, true).unwrap();
assert_eq!(result.r_imported, 1);
assert_eq!(result.duplicates_skipped, 0);
let query = reedline::SearchQuery::everything(reedline::SearchDirection::Backward, None);
let items = targets.r_history.search(query).unwrap();
assert_eq!(items.len(), 2);
}
#[test]
fn test_import_duplicates_flag_disables_dedup() {
use reedline::History;
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let mut targets = create_test_targets(&temp_dir);
let ts = DateTime::parse_from_rfc3339("2024-06-15T14:30:45Z")
.unwrap()
.with_timezone(&Utc);
let entries = vec![ImportEntry {
command: "library(dplyr)".to_string(),
timestamp: Some(ts),
mode: Some("r".to_string()),
}];
let result = import_entries(&mut targets, entries, None, false).unwrap();
assert_eq!(result.r_imported, 1);
let entries = vec![ImportEntry {
command: "library(dplyr)".to_string(),
timestamp: Some(ts),
mode: Some("r".to_string()),
}];
let result = import_entries(&mut targets, entries, None, false).unwrap();
assert_eq!(result.r_imported, 1);
assert_eq!(result.duplicates_skipped, 0);
let query = reedline::SearchQuery::everything(reedline::SearchDirection::Backward, None);
let items = targets.r_history.search(query).unwrap();
assert_eq!(items.len(), 2);
}
#[test]
fn test_import_dedup_works_per_database() {
use reedline::History;
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let mut targets = create_test_targets(&temp_dir);
let entries = vec![ImportEntry {
command: "ls -la".to_string(),
timestamp: None,
mode: Some("r".to_string()),
}];
let result = import_entries(&mut targets, entries, None, true).unwrap();
assert_eq!(result.r_imported, 1);
let entries = vec![ImportEntry {
command: "ls -la".to_string(),
timestamp: None,
mode: Some("shell".to_string()),
}];
let result = import_entries(&mut targets, entries, None, true).unwrap();
assert_eq!(result.shell_imported, 1);
assert_eq!(result.duplicates_skipped, 0);
let r_query = reedline::SearchQuery::everything(reedline::SearchDirection::Backward, None);
let r_items = targets.r_history.search(r_query).unwrap();
assert_eq!(r_items.len(), 1);
let shell_query =
reedline::SearchQuery::everything(reedline::SearchDirection::Backward, None);
let shell_items = targets.shell_history.search(shell_query).unwrap();
assert_eq!(shell_items.len(), 1);
}
#[test]
fn test_import_dry_run_with_dedup() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let mut targets = create_test_targets(&temp_dir);
let ts = DateTime::parse_from_rfc3339("2024-06-15T14:30:45Z")
.unwrap()
.with_timezone(&Utc);
let entries = vec![ImportEntry {
command: "library(dplyr)".to_string(),
timestamp: Some(ts),
mode: Some("r".to_string()),
}];
import_entries(&mut targets, entries, None, false).unwrap();
let r_dedup = DedupSet::from_history(&targets.r_history).unwrap();
let shell_dedup = DedupSet::from_history(&targets.shell_history).unwrap();
let entries = vec![
ImportEntry {
command: "library(dplyr)".to_string(), timestamp: Some(ts),
mode: Some("r".to_string()),
},
ImportEntry {
command: "print(1)".to_string(), timestamp: None,
mode: Some("r".to_string()),
},
];
let result = import_entries_dry_run(&entries, Some(&r_dedup), Some(&shell_dedup));
assert_eq!(result.r_imported, 1);
assert_eq!(result.duplicates_skipped, 1);
}
#[test]
fn test_import_mixed_dedup_new_and_existing() {
use reedline::History;
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let mut targets = create_test_targets(&temp_dir);
let ts = DateTime::parse_from_rfc3339("2024-06-15T14:30:45Z")
.unwrap()
.with_timezone(&Utc);
let entries = vec![ImportEntry {
command: "library(dplyr)".to_string(),
timestamp: Some(ts),
mode: Some("r".to_string()),
}];
import_entries(&mut targets, entries, None, false).unwrap();
let entries = vec![
ImportEntry {
command: "library(dplyr)".to_string(), timestamp: Some(ts),
mode: Some("r".to_string()),
},
ImportEntry {
command: "print(1)".to_string(), timestamp: None,
mode: Some("r".to_string()),
},
ImportEntry {
command: "git status".to_string(), timestamp: None,
mode: Some("shell".to_string()),
},
];
let result = import_entries(&mut targets, entries, None, true).unwrap();
assert_eq!(result.r_imported, 1);
assert_eq!(result.shell_imported, 1);
assert_eq!(result.duplicates_skipped, 1);
let r_query = reedline::SearchQuery::everything(reedline::SearchDirection::Backward, None);
let r_items = targets.r_history.search(r_query).unwrap();
assert_eq!(r_items.len(), 2);
let shell_query =
reedline::SearchQuery::everything(reedline::SearchDirection::Backward, None);
let shell_items = targets.shell_history.search(shell_query).unwrap();
assert_eq!(shell_items.len(), 1);
}
#[test]
fn test_import_dry_run_with_partial_dedup() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let mut targets = create_test_targets(&temp_dir);
let entries = vec![ImportEntry {
command: "library(dplyr)".to_string(),
timestamp: None,
mode: Some("r".to_string()),
}];
import_entries(&mut targets, entries, None, false).unwrap();
let r_dedup = DedupSet::from_history(&targets.r_history).unwrap();
let entries = vec![
ImportEntry {
command: "library(dplyr)".to_string(), timestamp: None,
mode: Some("r".to_string()),
},
ImportEntry {
command: "print(1)".to_string(), timestamp: None,
mode: Some("r".to_string()),
},
ImportEntry {
command: "ls -la".to_string(), timestamp: None,
mode: Some("shell".to_string()),
},
];
let result = import_entries_dry_run(&entries, Some(&r_dedup), None);
assert_eq!(result.r_imported, 1); assert_eq!(result.shell_imported, 1); assert_eq!(result.duplicates_skipped, 1); }
#[test]
fn test_import_skips_notimestamp_when_timestamped_exists() {
use reedline::History;
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let mut targets = create_test_targets(&temp_dir);
let ts = DateTime::parse_from_rfc3339("2024-06-15T14:30:45Z")
.unwrap()
.with_timezone(&Utc);
let entries = vec![ImportEntry {
command: "library(dplyr)".to_string(),
timestamp: Some(ts),
mode: Some("r".to_string()),
}];
let result = import_entries(&mut targets, entries, None, true).unwrap();
assert_eq!(result.r_imported, 1);
let entries = vec![ImportEntry {
command: "library(dplyr)".to_string(),
timestamp: None,
mode: Some("r".to_string()),
}];
let result = import_entries(&mut targets, entries, None, true).unwrap();
assert_eq!(result.r_imported, 0);
assert_eq!(result.duplicates_skipped, 1);
let query = reedline::SearchQuery::everything(reedline::SearchDirection::Backward, None);
let items = targets.r_history.search(query).unwrap();
assert_eq!(items.len(), 1);
assert!(items[0].start_timestamp.is_some());
}
#[test]
fn test_from_db_matches_from_history() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let mut targets = create_test_targets(&temp_dir);
let ts1 = Utc::now();
let ts2 = ts1 + chrono::Duration::seconds(60);
let entries = vec![
ImportEntry {
command: "library(dplyr)".to_string(),
timestamp: Some(ts1),
mode: Some("r".to_string()),
},
ImportEntry {
command: "print(1)".to_string(),
timestamp: Some(ts2),
mode: Some("r".to_string()),
},
ImportEntry {
command: "summary(iris)".to_string(),
timestamp: None, mode: Some("r".to_string()),
},
];
import_entries(&mut targets, entries, None, false).unwrap();
let r_path = temp_dir.path().join("r.db");
let from_history = DedupSet::from_history(&targets.r_history).unwrap();
let from_db = DedupSet::from_db(&r_path).unwrap();
assert_eq!(from_history.commands, from_db.commands);
assert_eq!(from_history.command_timestamps, from_db.command_timestamps);
assert!(from_history.is_duplicate("library(dplyr)", Some(&ts1)));
assert!(from_db.is_duplicate("library(dplyr)", Some(&ts1)));
assert!(!from_history.is_duplicate("new_cmd", None));
assert!(!from_db.is_duplicate("new_cmd", None));
assert!(from_history.is_duplicate("summary(iris)", None));
assert!(from_db.is_duplicate("summary(iris)", None));
}
#[test]
fn test_parse_unified_arf_history_basic() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let unified_path = temp_dir.path().join("export.db");
let db = rusqlite::Connection::open(&unified_path).unwrap();
db.execute(
"CREATE TABLE r (id INTEGER PRIMARY KEY, command_line TEXT NOT NULL, start_timestamp INTEGER)",
[],
)
.unwrap();
db.execute(
"CREATE TABLE shell (id INTEGER PRIMARY KEY, command_line TEXT NOT NULL, start_timestamp INTEGER)",
[],
)
.unwrap();
db.execute("INSERT INTO r (command_line, start_timestamp) VALUES ('library(dplyr)', 1705315800000)", []).unwrap();
db.execute(
"INSERT INTO r (command_line, start_timestamp) VALUES ('print(1)', NULL)",
[],
)
.unwrap();
db.execute(
"INSERT INTO shell (command_line, start_timestamp) VALUES ('ls -la', 1705315860000)",
[],
)
.unwrap();
drop(db);
let entries = parse_unified_arf_history(&unified_path, "r", "shell").unwrap();
assert_eq!(entries.len(), 3);
let r_entries: Vec<_> = entries
.iter()
.filter(|e| e.mode.as_deref() == Some("r"))
.collect();
assert_eq!(r_entries.len(), 2);
assert_eq!(r_entries[0].command, "library(dplyr)");
assert!(r_entries[0].timestamp.is_some());
assert_eq!(r_entries[1].command, "print(1)");
assert!(r_entries[1].timestamp.is_none());
let shell_entries: Vec<_> = entries
.iter()
.filter(|e| e.mode.as_deref() == Some("shell"))
.collect();
assert_eq!(shell_entries.len(), 1);
assert_eq!(shell_entries[0].command, "ls -la");
}
#[test]
fn test_parse_unified_arf_history_custom_table_names() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let unified_path = temp_dir.path().join("custom.db");
let db = rusqlite::Connection::open(&unified_path).unwrap();
db.execute(
"CREATE TABLE my_r_history (id INTEGER PRIMARY KEY, command_line TEXT NOT NULL, start_timestamp INTEGER)",
[],
)
.unwrap();
db.execute(
"INSERT INTO my_r_history (command_line) VALUES ('test_cmd')",
[],
)
.unwrap();
drop(db);
let entries =
parse_unified_arf_history(&unified_path, "my_r_history", "my_shell_history").unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].command, "test_cmd");
assert_eq!(entries[0].mode, Some("r".to_string()));
}
#[test]
fn test_parse_unified_arf_history_missing_tables() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let unified_path = temp_dir.path().join("empty.db");
let db = rusqlite::Connection::open(&unified_path).unwrap();
drop(db);
let entries = parse_unified_arf_history(&unified_path, "r", "shell").unwrap();
assert!(entries.is_empty());
}
#[test]
fn test_validate_table_name_valid() {
assert!(validate_table_name("r").is_ok());
assert!(validate_table_name("shell").is_ok());
assert!(validate_table_name("my_r_history").is_ok());
assert!(validate_table_name("R_History_2024").is_ok());
assert!(validate_table_name("_private").is_ok());
}
#[test]
fn test_validate_table_name_invalid() {
assert!(validate_table_name("").is_err());
assert!(validate_table_name("r; DROP TABLE history;--").is_err());
assert!(validate_table_name("r' OR '1'='1").is_err());
assert!(validate_table_name("table-name").is_err());
assert!(validate_table_name("table.name").is_err());
assert!(validate_table_name("123table").is_err());
assert!(validate_table_name("table name").is_err());
assert!(validate_table_name("table\nname").is_err());
assert!(validate_table_name("_").is_err());
assert!(validate_table_name("___").is_err());
assert!(validate_table_name("_____").is_err());
}
#[test]
fn test_parse_unified_works_regardless_of_filename() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let unified_path = temp_dir.path().join("r.db");
let db = rusqlite::Connection::open(&unified_path).unwrap();
db.execute(
"CREATE TABLE r (id INTEGER PRIMARY KEY, command_line TEXT NOT NULL, start_timestamp INTEGER)",
[],
)
.unwrap();
db.execute(
"CREATE TABLE shell (id INTEGER PRIMARY KEY, command_line TEXT NOT NULL, start_timestamp INTEGER)",
[],
)
.unwrap();
db.execute("INSERT INTO r (command_line) VALUES ('r_cmd')", [])
.unwrap();
db.execute("INSERT INTO shell (command_line) VALUES ('shell_cmd')", [])
.unwrap();
drop(db);
let entries = parse_unified_arf_history(&unified_path, "r", "shell").unwrap();
assert_eq!(entries.len(), 2);
let r_entries: Vec<_> = entries
.iter()
.filter(|e| e.mode.as_deref() == Some("r"))
.collect();
let shell_entries: Vec<_> = entries
.iter()
.filter(|e| e.mode.as_deref() == Some("shell"))
.collect();
assert_eq!(r_entries.len(), 1);
assert_eq!(r_entries[0].command, "r_cmd");
assert_eq!(shell_entries.len(), 1);
assert_eq!(shell_entries[0].command, "shell_cmd");
}
#[test]
fn test_parse_unified_rejects_same_table_names() {
let temp_dir = tempfile::TempDir::new().unwrap();
let unified_path = temp_dir.path().join("backup.db");
let db = rusqlite::Connection::open(&unified_path).unwrap();
db.execute(
"CREATE TABLE history (id INTEGER PRIMARY KEY, command_line TEXT)",
[],
)
.unwrap();
drop(db);
let result = parse_unified_arf_history(&unified_path, "history", "history");
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("must be different"));
}
#[test]
fn test_parse_unified_with_sqlite_reserved_words_as_table_names() {
let temp_dir = tempfile::TempDir::new().unwrap();
let unified_path = temp_dir.path().join("export.db");
let db = rusqlite::Connection::open(&unified_path).unwrap();
db.execute(
r#"CREATE TABLE "select" (id INTEGER PRIMARY KEY, command_line TEXT NOT NULL, start_timestamp INTEGER)"#,
[],
)
.unwrap();
db.execute(
r#"CREATE TABLE "from" (id INTEGER PRIMARY KEY, command_line TEXT NOT NULL, start_timestamp INTEGER)"#,
[],
)
.unwrap();
db.execute(
r#"INSERT INTO "select" (command_line) VALUES ('r_cmd')"#,
[],
)
.unwrap();
db.execute(
r#"INSERT INTO "from" (command_line) VALUES ('shell_cmd')"#,
[],
)
.unwrap();
drop(db);
let entries = parse_unified_arf_history(&unified_path, "select", "from").unwrap();
assert_eq!(entries.len(), 2);
let r_entries: Vec<_> = entries
.iter()
.filter(|e| e.mode.as_deref() == Some("r"))
.collect();
let shell_entries: Vec<_> = entries
.iter()
.filter(|e| e.mode.as_deref() == Some("shell"))
.collect();
assert_eq!(r_entries.len(), 1);
assert_eq!(r_entries[0].command, "r_cmd");
assert_eq!(shell_entries.len(), 1);
assert_eq!(shell_entries[0].command, "shell_cmd");
}
}