mod cli;
mod completion;
mod config;
mod console_mode;
mod editor;
mod external;
mod fuzzy;
mod highlighter;
mod history;
mod ipc;
mod pager;
pub(crate) mod r_parser;
mod repl;
mod traps;
#[cfg(test)]
mod test_utils;
use anyhow::{Context, Result};
use clap::{CommandFactory, Parser};
use cli::{Cli, Commands, ConfigAction, HistoryAction, ImportSource, IpcAction, RArgsBuilder};
use config::{
Config, ConfigLoadError, ConfigStatus, RSource, RSourceMode, RSourceStatus, config_file_path,
ensure_directories, init_config, load_config, load_config_from_path, mask_home_path,
};
use ipc::session::SessionInfo;
use reedline::Reedline;
use repl::Repl;
use serde::Serialize;
use std::fs;
#[derive(Debug, Serialize)]
struct HeadlessInfo {
pid: u32,
socket_path: String,
r_version: Option<String>,
cwd: String,
started_at: String,
log_file: Option<String>,
history_session_id: Option<i64>,
warnings: Vec<String>,
}
impl HeadlessInfo {
fn from_session(session: &SessionInfo, warnings: Vec<String>) -> Self {
let r_version = session
.r_version
.as_deref()
.filter(|s| !s.trim().is_empty())
.map(|s| s.to_string());
Self {
pid: session.pid,
socket_path: session.socket_path.clone(),
r_version,
cwd: session.cwd.clone(),
started_at: session.started_at.clone(),
log_file: session.log_file.clone(),
history_session_id: session.history_session_id,
warnings,
}
}
}
#[cfg(windows)]
use std::path::PathBuf;
use std::process::ExitCode;
fn main() -> ExitCode {
match run() {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("Error: {:#}", e);
ExitCode::FAILURE
}
}
}
fn init_logger(log_file: Option<&std::path::Path>, redirect_stderr: bool) {
let mut builder = env_logger::Builder::from_default_env();
if let Some(path) = log_file {
let mut opts = std::fs::OpenOptions::new();
opts.create(true).append(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
opts.custom_flags(libc::O_NOFOLLOW);
}
match opts.open(path) {
Ok(file) => {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(0o600);
if let Err(e) = file.set_permissions(perms) {
eprintln!(
"Warning: could not set permissions on log file {}: {e}",
path.display()
);
}
}
if redirect_stderr {
redirect_stderr_to_file(&file);
}
builder.target(env_logger::Target::Pipe(Box::new(file)));
}
Err(e) => {
eprintln!("Warning: could not open log file {}: {e}", path.display());
eprintln!(" Falling back to stderr.");
}
}
}
builder.init();
}
#[cfg(unix)]
fn redirect_stderr_to_file(file: &std::fs::File) {
use std::os::unix::io::AsRawFd;
let fd = file.as_raw_fd();
let ret = unsafe { libc::dup2(fd, libc::STDERR_FILENO) };
if ret == -1 {
eprintln!(
"Warning: failed to redirect stderr to log file: {}",
std::io::Error::last_os_error()
);
}
}
#[cfg(windows)]
fn redirect_stderr_to_file(file: &std::fs::File) {
use std::os::windows::io::AsRawHandle;
let mut dup_handle: windows_sys::Win32::Foundation::HANDLE = std::ptr::null_mut();
let cur_proc = unsafe { windows_sys::Win32::System::Threading::GetCurrentProcess() };
let ok = unsafe {
windows_sys::Win32::Foundation::DuplicateHandle(
cur_proc,
file.as_raw_handle() as _,
cur_proc,
&mut dup_handle,
0,
0, windows_sys::Win32::Foundation::DUPLICATE_SAME_ACCESS,
)
};
if ok == 0 {
eprintln!(
"Warning: failed to duplicate handle for stderr redirect: {}",
std::io::Error::last_os_error()
);
return;
}
const O_WRONLY: libc::c_int = 0x0001;
let new_fd =
unsafe { libc::open_osfhandle(dup_handle as libc::intptr_t, O_WRONLY | libc::O_APPEND) };
if new_fd == -1 {
eprintln!("Warning: failed to convert handle for stderr redirect");
unsafe {
windows_sys::Win32::Foundation::CloseHandle(dup_handle);
}
return;
}
if unsafe { libc::dup2(new_fd, 2) } == -1 {
eprintln!(
"Warning: failed to redirect stderr to log file: {}",
std::io::Error::last_os_error()
);
}
unsafe {
libc::close(new_fd);
}
}
fn write_pid_file(path: &std::path::Path) -> Result<()> {
let pid = std::process::id().to_string();
#[cfg(unix)]
{
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let mut file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(path)
.with_context(|| format!("Failed to create PID file: {}", path.display()))?;
file.write_all(pid.as_bytes())
.with_context(|| format!("Failed to write PID file: {}", path.display()))?;
}
#[cfg(not(unix))]
{
std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(path)
.and_then(|mut f| {
use std::io::Write;
f.write_all(pid.as_bytes())
})
.with_context(|| format!("Failed to create PID file: {}", path.display()))?;
}
log::info!("PID file written: {}", path.display());
Ok(())
}
fn run() -> Result<()> {
let cli = Cli::parse();
if (cli.eval.is_some() || cli.file.is_some()) && cli.command.is_some() {
let flag = if cli.eval.is_some() {
"--eval"
} else {
"--file"
};
let subcommand = match &cli.command {
Some(Commands::Completions { .. }) => "completions",
Some(Commands::Config { .. }) => "config",
Some(Commands::History { .. }) => "history",
Some(Commands::Ipc { .. }) => "ipc",
Some(Commands::Headless { .. }) => "headless",
None => unreachable!(),
};
Cli::command()
.error(
clap::error::ErrorKind::ArgumentConflict,
format!("the argument '{flag}' cannot be used with subcommand '{subcommand}'"),
)
.exit();
}
let (log_file, is_headless) = match &cli.command {
Some(Commands::Headless { log_file, .. }) => (log_file.as_deref(), true),
_ => (None, false),
};
init_logger(log_file, is_headless);
traps::register_trap_handlers();
match &cli.command {
Some(Commands::Completions { shell }) => {
Cli::print_completions(*shell);
return Ok(());
}
Some(Commands::Config { action }) => {
return handle_config_command(action);
}
Some(Commands::History { action }) => {
return handle_history_command(action, cli.config.as_ref(), cli.history_dir.as_ref());
}
Some(Commands::Ipc { action }) => {
handle_ipc_command(action);
return Ok(());
}
Some(Commands::Headless {
config,
r_version,
r_home,
bind,
pid_file,
quiet,
json,
log_file,
history_dir,
no_history,
vanilla,
no_environ,
no_site_file,
no_init_file,
max_connections,
max_ppsize,
min_nsize,
min_vsize,
}) => {
let r_args_builder = RArgsBuilder {
vanilla: *vanilla,
no_environ: *no_environ,
no_site_file: *no_site_file,
no_init_file: *no_init_file,
save: false,
restore: false,
max_connections: *max_connections,
max_ppsize: *max_ppsize,
min_nsize: min_nsize.as_deref(),
min_vsize: min_vsize.as_deref(),
};
return run_headless(
config.as_ref(),
r_home.as_deref(),
r_version.as_deref(),
r_args_builder,
bind.as_deref(),
pid_file.as_deref(),
*quiet,
*json,
log_file.as_deref(),
history_dir.as_deref(),
*no_history,
);
}
None => {}
}
let script_mode = cli.eval.is_some() || cli.script_file().is_some();
if script_mode {
return run_script(&cli);
}
log::info!("Starting arf");
let _console_mode_guard = console_mode::ConsoleModeGuard::install();
ensure_directories()?;
let (mut config, config_path, config_status) = load_config_with_fallback(&cli);
log::debug!("Loaded config: {:?}", config);
if cli.reprex {
config.startup.mode.reprex = true;
}
if cli.auto_format {
if !external::formatter::is_formatter_available() {
anyhow::bail!(
"Cannot enable auto-format: Air CLI ('air' command) not found in PATH.\n\
Install Air CLI from https://github.com/posit-dev/air"
);
}
config.startup.mode.autoformat = true;
}
if cli.no_banner {
config.startup.show_banner = false;
}
if cli.no_auto_match {
config.editor.auto_match = false;
}
if cli.no_completion {
config.completion.enabled = false;
}
if cli.no_history {
config.history.disabled = true;
} else if let Some(history_dir) = &cli.history_dir {
config.history.dir = Some(history_dir.clone());
}
if config.startup.mode.autoformat
&& !cli.auto_format
&& !external::formatter::is_formatter_available()
{
eprintln!(
"Warning: Auto-format is enabled in config but Air CLI ('air' command) not found in PATH."
);
eprintln!(
" Auto-format has been disabled. Install Air CLI from https://github.com/posit-dev/air"
);
config.startup.mode.autoformat = false;
}
let r_source_status = setup_r(
&config.startup.r_source,
cli.r_home.as_deref(),
cli.r_version.as_deref(),
)?;
log::debug!("R source status: {:?}", r_source_status);
if let Err(e) = arf_libr::ensure_ld_library_path() {
log::warn!("Could not set LD_LIBRARY_PATH: {}", e);
}
let r_args = cli.r_args();
let r_args_refs: Vec<&str> = r_args.iter().map(|s| s.as_str()).collect();
log::debug!("R args: {:?}", r_args);
log::info!("Initializing R...");
#[allow(unused_variables)]
let r_initialized = unsafe {
match arf_libr::initialize_r_with_args(&r_args_refs) {
Ok(()) => {
log::info!("R initialized successfully");
true
}
Err(e) => {
eprintln!("Warning: Failed to initialize R: {}", e);
eprintln!("R evaluation will not be available.");
eprintln!("Make sure R is installed and R_HOME is set correctly.\n");
false
}
}
};
#[cfg(windows)]
if r_initialized {
source_r_profiles(&r_args);
}
let session_id = create_session_id(&config);
let session_id_raw = session_id.map(i64::from);
if !config.history.disabled {
let history_dir = config.history.dir.clone().or_else(config::history_dir);
if let Some(dir) = history_dir {
ipc::set_history_db_info(dir.join("r.db"), session_id);
}
}
if cli.with_ipc {
match ipc::start_server(None, None, session_id_raw) {
Ok(session) => {
log::info!("IPC server started on {}", session.socket_path);
}
Err(e) => {
eprintln!("Warning: Failed to start IPC server: {}", e);
}
}
}
let mut repl = Repl::new(
config,
config_path,
config_status,
r_source_status,
session_id,
)?;
let repl_result = repl.run();
ipc::stop_server();
repl_result
}
fn load_config_with_fallback(cli: &Cli) -> (Config, Option<std::path::PathBuf>, ConfigStatus) {
let (result, config_path) = if let Some(path) = &cli.config {
(load_config_from_path(path), Some(path.clone()))
} else {
let default_path = config_file_path();
(load_config(), default_path)
};
match result {
Ok(config) => (config, config_path, ConfigStatus::Ok),
Err(e) => {
let (raw_path, masked_path, source_msg, status) = match &e {
ConfigLoadError::Read { path, source } => (
path.display().to_string(),
mask_home_path(path),
source.to_string(),
ConfigStatus::ReadError,
),
ConfigLoadError::Parse { path, source } => (
path.display().to_string(),
mask_home_path(path),
source.to_string(),
ConfigStatus::ParseError,
),
};
eprintln!(
"Warning: Failed to load config from {}: {}",
masked_path, source_msg
);
eprintln!(
" Using default configuration. Run `arf config check` to see details."
);
log::warn!("Config load error for {}: {}", raw_path, source_msg);
(Config::default(), config_path, status)
}
}
}
fn load_config_or_warn(config_path: Option<&std::path::PathBuf>) -> Config {
let result = if let Some(path) = config_path {
load_config_from_path(path)
} else {
load_config()
};
match result {
Ok(config) => config,
Err(e) => {
let (path_display, source_msg) = match &e {
ConfigLoadError::Read { path, source } => {
(mask_home_path(path), source.to_string())
}
ConfigLoadError::Parse { path, source } => {
(mask_home_path(path), source.to_string())
}
};
eprintln!(
"Warning: Failed to load config from {}: {}",
path_display, source_msg
);
eprintln!(" Using default configuration.");
Config::default()
}
}
}
fn load_config_collecting_warnings(
config_path: Option<&std::path::PathBuf>,
warnings: &mut Vec<String>,
) -> Config {
let result = if let Some(path) = config_path {
load_config_from_path(path)
} else {
load_config()
};
match result {
Ok(config) => config,
Err(e) => {
let (path_display, source_msg) = match &e {
ConfigLoadError::Read { path, source } => {
(mask_home_path(path), source.to_string())
}
ConfigLoadError::Parse { path, source } => {
(mask_home_path(path), source.to_string())
}
};
warnings.push(format!(
"Failed to load config from {path_display}: {source_msg}. Using default configuration."
));
Config::default()
}
}
}
#[allow(clippy::too_many_arguments)]
fn run_headless(
config_path: Option<&std::path::PathBuf>,
r_home: Option<&std::path::Path>,
r_version: Option<&str>,
r_args_builder: RArgsBuilder<'_>,
bind: Option<&str>,
pid_file: Option<&std::path::Path>,
quiet: bool,
json: bool,
log_file: Option<&std::path::Path>,
cli_history_dir: Option<&std::path::Path>,
no_history: bool,
) -> Result<()> {
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
let quiet = quiet || json;
log::info!("Starting arf in headless mode");
let mut warnings: Vec<String> = Vec::new();
let mut config = if json {
load_config_collecting_warnings(config_path, &mut warnings)
} else {
load_config_or_warn(config_path)
};
setup_r(&config.startup.r_source, r_home, r_version)?;
if let Err(e) = arf_libr::ensure_ld_library_path() {
log::warn!("Could not set LD_LIBRARY_PATH: {}", e);
}
let r_args = r_args_builder.build();
let r_args_refs: Vec<&str> = r_args.iter().map(|s| s.as_str()).collect();
unsafe {
arf_libr::initialize_r_with_args(&r_args_refs).context("Failed to initialize R")?;
}
#[cfg(windows)]
source_r_profiles(&r_args);
configure_headless_r_options()?;
let shutdown = Arc::new(AtomicBool::new(false));
ipc::set_headless_shutdown(shutdown.clone());
if no_history {
config.history.disabled = true;
} else if let Some(history_dir) = cli_history_dir {
config.history.dir = Some(history_dir.to_path_buf());
}
let session_id = create_session_id(&config);
let mut session_id_raw = None;
if let Some(sid) = session_id {
let history_path = {
let dir = config.history.dir.clone().or_else(config::history_dir);
dir.map(|d| d.join("r.db"))
};
if let Some(path) = history_path {
match reedline::SqliteBackedHistory::with_file(
path.clone(),
Some(sid),
Some(chrono::Utc::now()),
) {
Ok(history) => {
ipc::set_headless_history(history);
ipc::set_history_db_info(path.clone(), Some(sid));
session_id_raw = Some(i64::from(sid));
log::info!("Headless history enabled: {}", path.display());
}
Err(e) => {
log::warn!("Failed to open history database {}: {}", path.display(), e);
}
}
}
}
let log_file_str = log_file.map(|p| {
std::path::absolute(p)
.unwrap_or_else(|_| p.to_path_buf())
.display()
.to_string()
});
let session = ipc::start_server(bind, log_file_str, session_id_raw)
.context("Failed to start IPC server")?;
if !quiet {
eprintln!("IPC server listening on: {}", session.socket_path);
}
if let Some(pid_path) = pid_file
&& let Err(e) = write_pid_file(pid_path)
{
ipc::stop_server();
return Err(e);
}
let shutdown_signal = shutdown.clone();
if let Err(e) = ctrlc::set_handler(move || {
shutdown_signal.store(true, Ordering::Release);
}) {
log::warn!("Could not set Ctrl+C handler: {}", e);
}
ipc::set_r_at_prompt(true);
if json {
let output = HeadlessInfo::from_session(&session, warnings);
let is_tty = std::io::IsTerminal::is_terminal(&std::io::stdout());
let json_str = if is_tty {
serde_json::to_string_pretty(&output)
} else {
serde_json::to_string(&output)
}
.context("Failed to serialize session info")?;
use std::io::Write;
let mut stdout = std::io::stdout().lock();
writeln!(stdout, "{json_str}").context("Failed to write session info to stdout")?;
stdout
.flush()
.context("Failed to flush session info to stdout")?;
} else if !quiet {
eprintln!("Headless mode ready. Press Ctrl+C to exit.");
}
while !shutdown.load(Ordering::Acquire) {
let had_work = ipc::headless_poll_and_process();
arf_libr::process_r_events();
if had_work {
std::thread::sleep(std::time::Duration::from_millis(1));
} else {
std::thread::sleep(std::time::Duration::from_millis(50));
}
}
if !quiet {
eprintln!("\nShutting down...");
}
ipc::stop_server();
if let Some(pid_path) = pid_file
&& let Err(e) = std::fs::remove_file(pid_path)
{
log::debug!("Could not remove PID file {}: {}", pid_path.display(), e);
}
Ok(())
}
fn configure_headless_r_options() -> Result<()> {
let code = r#"
local({
# Force text-based help output (no HTML browser)
options(help_type = "text")
# Custom pager: dump file contents to stdout instead of spawning less/more.
# Output goes through WriteConsoleEx callback, so evaluate_with_capture
# picks it up automatically.
.arf_headless_pager <- function(files, header = NULL, title = NULL,
delete.file = FALSE, ...) {
files <- as.character(files)
if (length(files) == 0L) return(invisible(NULL))
if (!is.null(title) && length(title) >= 1L && nzchar(title[[1L]])) {
cat(title[[1L]], "\n", sep = "")
}
for (i in seq_along(files)) {
path <- files[[i]]
if (!nzchar(path) || !file.exists(path)) next
if (!is.null(header) && length(header) >= i && nzchar(header[[i]])) {
cat(header[[i]], "\n", sep = "")
}
tryCatch({
lines <- readLines(path, warn = FALSE)
cat(lines, sep = "\n")
if (length(lines) > 0L) cat("\n")
}, error = function(e) NULL)
if (isTRUE(delete.file)) unlink(path, force = TRUE)
}
invisible(NULL)
}
options(pager = .arf_headless_pager)
options(help.pager = .arf_headless_pager)
# Suppress browseURL() — just print the URL
options(browser = function(url, ...) { cat(url, "\n"); invisible(0L) })
# Default graphics device: png with pdf fallback.
# Prevents X11/quartz from being opened in headless environments.
.arf_headless_device <- function(...) {
# Ignore ... to avoid unit mismatch: dev.new() passes width/height
# in inches, but png() interprets them as pixels by default.
# Use sensible defaults; Stage 2 can add proper argument handling.
path <- tempfile("arf-headless-plot-", fileext = ".png")
ok <- FALSE
tryCatch({
grDevices::png(filename = path)
ok <- TRUE
}, error = function(e) NULL)
if (!ok) {
path <- tempfile("arf-headless-plot-", fileext = ".pdf")
grDevices::pdf(file = path)
}
# Enable display list recording for potential future plot retrieval
try(grDevices::dev.control(displaylist = "enable"), silent = TRUE)
invisible(NULL)
}
options(device = .arf_headless_device)
})
"#;
arf_harp::eval_string(code)
.context("Failed to configure headless R options (pager, browser, graphics device)")?;
log::info!("Headless R options configured (pager, browser, graphics device)");
Ok(())
}
fn handle_config_command(action: &ConfigAction) -> Result<()> {
match action {
ConfigAction::Init { force } => {
let path = init_config(*force)?;
println!("Configuration file created at: {}", path.display());
Ok(())
}
ConfigAction::Check { config: path } => handle_config_check(path.as_deref()),
}
}
fn handle_config_check(path: Option<&std::path::Path>) -> Result<()> {
let config_path = if let Some(p) = path {
p.to_path_buf()
} else if let Some(p) = config_file_path() {
p
} else {
anyhow::bail!("Could not determine config file path");
};
if !config_path.exists() {
anyhow::bail!(
"Config file not found: {}\nRun `arf config init` to create a default configuration file.",
mask_home_path(&config_path)
);
}
println!("Checking config file: {}", mask_home_path(&config_path));
match load_config_from_path(&config_path) {
Ok(_) => {
println!("Config file is valid.");
Ok(())
}
Err(ConfigLoadError::Parse { source, .. }) => {
anyhow::bail!("Config file has errors:\n\n {}", source);
}
Err(ConfigLoadError::Read { source, .. }) => {
anyhow::bail!("Could not read config file: {}", source);
}
}
}
fn handle_history_command(
action: &HistoryAction,
config_path: Option<&std::path::PathBuf>,
cli_history_dir: Option<&std::path::PathBuf>,
) -> Result<()> {
match action {
HistoryAction::Schema => {
pager::history_schema::print_schema().context("Failed to display history schema")
}
HistoryAction::Import {
from,
file,
hostname,
dry_run,
import_duplicates,
unified,
r_table,
shell_table,
} => handle_history_import(
*from,
file.as_ref(),
hostname.as_deref(),
*dry_run,
!import_duplicates,
*unified,
r_table,
shell_table,
config_path,
cli_history_dir,
),
HistoryAction::Export {
file,
r_table,
shell_table,
} => handle_history_export(file, r_table, shell_table, config_path, cli_history_dir),
}
}
fn handle_ipc_command(action: &IpcAction) {
match action {
IpcAction::List => ipc::client::cmd_list(),
IpcAction::Eval {
code,
pid,
visible,
timeout,
} => ipc::client::cmd_eval(code.as_deref(), *pid, *visible, *timeout),
IpcAction::Send { code, pid } => ipc::client::cmd_send(code.as_deref(), *pid),
IpcAction::Shutdown { pid } => ipc::client::cmd_shutdown(*pid),
IpcAction::Session { pid } => ipc::client::cmd_session(*pid),
IpcAction::History {
limit,
all_sessions,
cwd,
grep,
since,
pid,
} => ipc::client::cmd_history(
*pid,
*limit,
*all_sessions,
cwd.as_deref(),
grep.as_deref(),
since.as_deref(),
),
}
}
#[allow(clippy::too_many_arguments)]
fn handle_history_import(
source: ImportSource,
file: Option<&std::path::PathBuf>,
hostname: Option<&str>,
dry_run: bool,
skip_duplicates: bool,
unified: bool,
r_table: &str,
shell_table: &str,
config_path: Option<&std::path::PathBuf>,
cli_history_dir: Option<&std::path::PathBuf>,
) -> Result<()> {
use history::import::{
DedupSet, default_r_history_path, default_radian_path, import_entries,
import_entries_dry_run, parse_arf_history, parse_r_history, parse_radian_history,
parse_unified_arf_history,
};
use reedline::SqliteBackedHistory;
let config = load_config_or_warn(config_path);
let history_dir = cli_history_dir
.cloned()
.or(config.history.dir.clone())
.or_else(config::history_dir);
let source_path = match (source, file) {
(_, Some(path)) => path.clone(),
(ImportSource::Radian, None) => default_radian_path(),
(ImportSource::R, None) => default_r_history_path(),
(ImportSource::Arf, None) => {
anyhow::bail!(
"The --file option is required when importing from arf format.\n\
Example: arf history import --from arf --file /path/to/backup/r.db"
);
}
};
if !source_path.exists() {
anyhow::bail!(
"Source history file not found: {}\nSpecify the path with --file",
source_path.display()
);
}
println!("Importing from: {}", source_path.display());
let entries = match source {
ImportSource::Radian => parse_radian_history(&source_path)?,
ImportSource::R => parse_r_history(&source_path)?,
ImportSource::Arf => {
let is_unified = unified || {
let filename = source_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("");
filename != "r.db" && filename != "shell.db"
};
if is_unified {
parse_unified_arf_history(&source_path, r_table, shell_table)?
} else {
parse_arf_history(&source_path)?
}
}
};
println!("Found {} entries to import", entries.len());
if dry_run {
let (r_dedup, shell_dedup) = if skip_duplicates {
if let Some(ref history_dir) = history_dir {
let r_path = history_dir.join("r.db");
let shell_path = history_dir.join("shell.db");
let r_dedup = if r_path.exists() {
Some(DedupSet::from_db(&r_path)?)
} else {
None
};
let shell_dedup = if shell_path.exists() {
Some(DedupSet::from_db(&shell_path)?)
} else {
None
};
(r_dedup, shell_dedup)
} else {
eprintln!(
"Warning: Could not determine history directory; \
duplicate detection skipped in dry-run."
);
(None, None)
}
} else {
(None, None)
};
let result = import_entries_dry_run(&entries, r_dedup.as_ref(), shell_dedup.as_ref());
println!("\n[Dry run] Would import:");
if let Some(h) = hostname {
println!(" Hostname: {}", h);
}
println!(" R commands: {}", result.r_imported);
println!(" Shell commands: {}", result.shell_imported);
println!(" Skipped: {}", result.skipped);
if result.duplicates_skipped > 0 {
println!(
" Duplicates: {} (use --import-duplicates to import anyway)",
result.duplicates_skipped
);
}
if !result.warnings.is_empty() {
println!("\nWarnings:");
for warning in result.warnings.iter().take(10) {
println!(" - {}", warning);
}
if result.warnings.len() > 10 {
println!(" ... and {} more warnings", result.warnings.len() - 10);
}
}
return Ok(());
}
let history_dir =
history_dir.ok_or_else(|| anyhow::anyhow!("Could not determine history directory"))?;
let r_path = history_dir.join("r.db");
let shell_path = history_dir.join("shell.db");
if matches!(source, ImportSource::Arf)
&& let Ok(source_canon) = fs::canonicalize(&source_path)
{
if fs::canonicalize(&r_path).is_ok_and(|r_canon| source_canon == r_canon) {
anyhow::bail!(
"Refusing to import from '{}' into itself (R history database). \
Please specify a different --file or history directory.",
source_path.display()
);
}
if fs::canonicalize(&shell_path).is_ok_and(|shell_canon| source_canon == shell_canon) {
anyhow::bail!(
"Refusing to import from '{}' into itself (shell history database). \
Please specify a different --file or history directory.",
source_path.display()
);
}
}
fs::create_dir_all(&history_dir).with_context(|| {
format!(
"Failed to create history directory: {}",
history_dir.display()
)
})?;
println!("Target databases:");
println!(" R: {}", r_path.display());
println!(" Shell: {}", shell_path.display());
let mut targets = history::import::ImportTargets {
r_history: SqliteBackedHistory::with_file(r_path, None, None)
.context("Failed to open R history database")?,
shell_history: SqliteBackedHistory::with_file(shell_path, None, None)
.context("Failed to open shell history database")?,
};
let result = import_entries(&mut targets, entries, hostname, skip_duplicates)?;
println!("\nImport complete:");
if let Some(h) = hostname {
println!(" Hostname: {}", h);
}
println!(" R commands: {}", result.r_imported);
println!(" Shell commands: {}", result.shell_imported);
println!(" Skipped: {}", result.skipped);
if result.duplicates_skipped > 0 {
println!(
" Duplicates: {} (use --import-duplicates to import anyway)",
result.duplicates_skipped
);
}
if !result.warnings.is_empty() {
println!("\nWarnings:");
for warning in result.warnings.iter().take(10) {
println!(" - {}", warning);
}
if result.warnings.len() > 10 {
println!(" ... and {} more warnings", result.warnings.len() - 10);
}
}
Ok(())
}
fn handle_history_export(
output_file: &std::path::Path,
r_table: &str,
shell_table: &str,
config_path: Option<&std::path::PathBuf>,
cli_history_dir: Option<&std::path::PathBuf>,
) -> Result<()> {
use history::export::export_history;
let config = load_config_or_warn(config_path);
let history_dir = cli_history_dir
.cloned()
.or(config.history.dir.clone())
.or_else(config::history_dir)
.ok_or_else(|| anyhow::anyhow!("Could not determine history directory"))?;
let r_path = history_dir.join("r.db");
let shell_path = history_dir.join("shell.db");
if !r_path.exists() && !shell_path.exists() {
anyhow::bail!(
"No history databases found in: {}\n\
Expected r.db and/or shell.db",
history_dir.display()
);
}
println!("Exporting history to: {}", output_file.display());
println!("Source databases:");
if r_path.exists() {
println!(" R: {} (table: {})", r_path.display(), r_table);
}
if shell_path.exists() {
println!(" Shell: {} (table: {})", shell_path.display(), shell_table);
}
let result = export_history(&r_path, &shell_path, output_file, r_table, shell_table)?;
println!("\nExport complete:");
println!(" R commands: {}", result.r_exported);
println!(" Shell commands: {}", result.shell_exported);
Ok(())
}
fn run_script(cli: &Cli) -> Result<()> {
let config = load_config_or_warn(cli.config.as_ref());
setup_r(
&config.startup.r_source,
cli.r_home.as_deref(),
cli.r_version.as_deref(),
)?;
if let Err(e) = arf_libr::ensure_ld_library_path() {
log::warn!("Could not set LD_LIBRARY_PATH: {}", e);
}
let r_args = cli.r_args();
let r_args_refs: Vec<&str> = r_args.iter().map(|s| s.as_str()).collect();
unsafe {
arf_libr::initialize_r_with_args(&r_args_refs).context("Failed to initialize R")?;
}
#[cfg(windows)]
source_r_profiles(&r_args);
let code = if let Some(eval_code) = &cli.eval {
eval_code.clone()
} else if let Some(script_path) = cli.script_file() {
if script_path == std::path::Path::new("-") {
use std::io::Read;
let mut buf = String::new();
std::io::stdin()
.read_to_string(&mut buf)
.context("Failed to read from stdin")?;
buf
} else {
fs::read_to_string(script_path)
.with_context(|| format!("Failed to read script file: {}", script_path.display()))?
}
} else {
return Ok(());
};
let reprex_enabled = cli.reprex || config.startup.mode.reprex;
if reprex_enabled {
match arf_harp::eval_string_reprex(&code, &config.mode.reprex.comment) {
Ok(_) => Ok(()),
Err(e) => {
eprintln!("{}", e);
Ok(())
}
}
} else {
match arf_harp::eval_string(&code) {
Ok(_) => Ok(()),
Err(e) => {
eprintln!("{}", e);
Ok(())
}
}
}
}
fn setup_r(
r_source: &RSource,
cli_r_home: Option<&std::path::Path>,
cli_version: Option<&str>,
) -> Result<RSourceStatus> {
if let Some(path) = cli_r_home {
if !path.exists() {
anyhow::bail!(
"R_HOME path does not exist: {}\n\
Check your --r-home argument.",
path.display()
);
}
let r_home = resolve_r_home_from_path(path)?;
log::info!("Using R from --r-home: {}", r_home.display());
unsafe { std::env::set_var("R_HOME", &r_home) };
return Ok(RSourceStatus::ExplicitPath { path: r_home });
}
if let Some(version) = cli_version {
return setup_r_via_rig(version);
}
match r_source {
RSource::Mode(RSourceMode::Auto) => {
if external::rig::rig_available() {
match external::rig::resolve_version("default") {
Ok(resolved) => {
log::info!("Using rig default R version: {}", resolved.version);
unsafe { std::env::set_var("R_HOME", &resolved.r_home) };
return Ok(RSourceStatus::Rig {
version: resolved.version,
});
}
Err(e) => {
log::debug!("Could not get rig default version: {}", e);
log::info!("Using R from PATH");
}
}
} else {
log::info!("Using R from PATH (rig not available)");
}
Ok(RSourceStatus::Path)
}
RSource::Mode(RSourceMode::Rig) => {
if !external::rig::rig_available() {
anyhow::bail!(
r#"r_source = "rig" but rig is not installed.
Install rig from https://github.com/r-lib/rig or use "auto"."#
);
}
match external::rig::resolve_version("default") {
Ok(resolved) => {
log::info!("Using rig default R version: {}", resolved.version);
unsafe { std::env::set_var("R_HOME", &resolved.r_home) };
Ok(RSourceStatus::Rig {
version: resolved.version,
})
}
Err(e) => {
anyhow::bail!("Failed to get rig default R version: {}", e);
}
}
}
RSource::Path { path } => {
if !path.exists() {
anyhow::bail!(
"R_HOME path does not exist: {}\n\
Check your r_source configuration.",
path.display()
);
}
log::info!("Using R from explicit path: {}", path.display());
unsafe { std::env::set_var("R_HOME", path) };
Ok(RSourceStatus::ExplicitPath { path: path.clone() })
}
}
}
fn resolve_r_home_from_path(path: &std::path::Path) -> Result<std::path::PathBuf> {
let r_binary = path.join("bin").join("R");
if r_binary.exists() {
let output = std::process::Command::new(&r_binary)
.arg("RHOME")
.output()
.with_context(|| format!("Failed to run {} RHOME", r_binary.display()))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("{} RHOME failed: {}", r_binary.display(), stderr);
}
let r_home = String::from_utf8_lossy(&output.stdout).trim().to_string();
if r_home.is_empty() {
anyhow::bail!("{} RHOME returned empty result", r_binary.display());
}
log::debug!(
"Resolved R_HOME from installation prefix: {} -> {}",
path.display(),
r_home
);
return Ok(std::path::PathBuf::from(r_home));
}
let renviron = path.join("etc").join("Renviron");
if !renviron.exists() {
log::warn!(
"Path {} does not look like R_HOME (missing etc/Renviron). \
Consider providing the installation prefix instead.",
path.display()
);
}
Ok(path.to_path_buf())
}
fn setup_r_via_rig(version_spec: &str) -> Result<RSourceStatus> {
if !external::rig::rig_available() {
anyhow::bail!(
"--with-r-version requires rig to be installed.\n\
Install rig from https://github.com/r-lib/rig"
);
}
match external::rig::resolve_version(version_spec) {
Ok(resolved) => {
log::info!(
"Using R version {} from {}",
resolved.version,
resolved.r_home
);
unsafe { std::env::set_var("R_HOME", &resolved.r_home) };
Ok(RSourceStatus::Rig {
version: resolved.version,
})
}
Err(e) => {
anyhow::bail!("Failed to resolve R version '{}': {}", version_spec, e);
}
}
}
#[cfg(windows)]
fn source_r_profiles(r_args: &[String]) {
arf_harp::override_platform_gui();
let r_home = match std::env::var("R_HOME") {
Ok(path) => PathBuf::from(path),
Err(_) => {
log::warn!("R_HOME not set, skipping R profile sourcing");
return;
}
};
if !arf_harp::should_ignore_site_r_profile(r_args) {
arf_harp::source_site_r_profile(&r_home);
} else {
log::trace!("Skipping site R profile (--no-site-file or --vanilla)");
}
if !arf_harp::should_ignore_user_r_profile(r_args) {
arf_harp::source_user_r_profile();
} else {
log::trace!("Skipping user R profile (--no-init-file or --vanilla)");
}
arf_harp::call_dot_first();
arf_harp::call_dot_first_sys();
}
fn create_session_id(config: &Config) -> Option<reedline::HistorySessionId> {
if config.history.disabled {
return None;
}
if config.history.dir.is_none() && config::history_dir().is_none() {
return None;
}
Reedline::create_history_session_id()
}
#[cfg(test)]
mod session_id_tests {
use super::*;
#[test]
fn test_create_session_id_when_history_enabled() {
let mut config = Config::default();
config.history.dir = Some(std::env::temp_dir());
assert!(!config.history.disabled);
let id = create_session_id(&config);
assert!(
id.is_some(),
"should generate session ID when history is enabled"
);
}
#[test]
fn test_create_session_id_when_history_disabled() {
let mut config = Config::default();
config.history.disabled = true;
let id = create_session_id(&config);
assert!(id.is_none(), "should be None when history is disabled");
}
#[test]
fn test_create_session_id_respects_default_history_dir() {
let config = Config::default();
assert!(!config.history.disabled);
assert!(config.history.dir.is_none());
let id = create_session_id(&config);
assert_eq!(id.is_some(), config::history_dir().is_some());
}
}