mod format;
mod shell_alias;
#[cfg(windows)]
mod win_path;
use clap::{Parser, Subcommand};
use format::{
format_check_line, format_dry_run_result, format_which_result, version_line,
which_result_to_json,
};
use runex_core::config::{default_config_path, load_config};
use runex_core::doctor;
use runex_core::expand;
use runex_core::init as runex_init;
use runex_core::model::{Config, ExpandResult};
use runex_core::sanitize::sanitize_for_display;
use runex_core::shell::Shell;
use shell_alias::add_shell_alias_conflicts;
use std::io::{self, IsTerminal, Write};
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::sync::{
atomic::{AtomicBool, Ordering},
Arc,
};
use std::thread;
use std::time::Duration;
pub(crate) const ANSI_RESET: &str = "\x1b[0m";
pub(crate) const ANSI_GREEN: &str = "\x1b[32m";
pub(crate) const ANSI_RED: &str = "\x1b[31m";
pub(crate) const ANSI_YELLOW: &str = "\x1b[33m";
pub(crate) const GIT_COMMIT: Option<&str> = option_env!("RUNEX_GIT_COMMIT");
pub(crate) const CHECK_TAG_WIDTH: usize = 8;
const MAX_BIN_LEN: usize = 255;
struct Spinner {
done: Arc<AtomicBool>,
handle: Option<thread::JoinHandle<()>>,
}
impl Spinner {
fn start(message: &'static str) -> Self {
if !io::stderr().is_terminal() {
return Self {
done: Arc::new(AtomicBool::new(true)),
handle: None,
};
}
let done = Arc::new(AtomicBool::new(false));
let thread_done = Arc::clone(&done);
let handle = thread::spawn(move || {
let frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
let mut i = 0usize;
while !thread_done.load(Ordering::Relaxed) {
eprint!("\r{} {}", frames[i % frames.len()], message);
let _ = io::stderr().flush();
i += 1;
thread::sleep(Duration::from_millis(100));
}
eprint!("\r\x1b[2K");
let _ = io::stderr().flush();
});
Self {
done,
handle: Some(handle),
}
}
fn stop(mut self) {
self.done.store(true, Ordering::Relaxed);
if let Some(handle) = self.handle.take() {
let _ = handle.join();
}
}
}
#[derive(Parser)]
#[command(name = "runex", about = "Rune-to-cast expansion engine")]
struct Cli {
#[arg(long, global = true)]
json: bool,
#[arg(long, global = true, value_name = "PATH")]
config: Option<PathBuf>,
#[arg(long, global = true, value_name = "DIR")]
path_prepend: Option<PathBuf>,
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
Expand {
#[arg(long)]
token: String,
#[arg(long)]
dry_run: bool,
#[arg(long, value_name = "SHELL")]
shell: Option<String>,
},
List {
#[arg(long, value_name = "SHELL")]
shell: Option<String>,
},
Doctor {
#[arg(long)]
no_shell_aliases: bool,
#[arg(long)]
verbose: bool,
#[arg(long)]
strict: bool,
},
Version,
Export {
shell: String,
#[arg(long, default_value = "runex")]
bin: String,
},
Which {
token: String,
#[arg(long)]
why: bool,
#[arg(long, value_name = "SHELL")]
shell: Option<String>,
},
#[command(hide = true)]
Precache {
#[arg(long, value_name = "SHELL")]
shell: String,
#[arg(long)]
list_commands: bool,
#[arg(long, value_name = "RESOLVED")]
resolved: Option<String>,
},
Timings {
key: Option<String>,
#[arg(long, value_name = "SHELL")]
shell: Option<String>,
},
Add {
key: String,
expand: String,
#[arg(long, value_name = "CMD", num_args = 1..)]
when: Option<Vec<String>>,
},
Remove {
key: String,
},
Init {
shell: Option<String>,
#[arg(long, short = 'y')]
yes: bool,
},
#[command(hide = true)]
Hook {
#[arg(long, value_name = "SHELL")]
shell: String,
#[arg(long)]
line: String,
#[arg(long)]
cursor: usize,
#[arg(long)]
paste_pending: bool,
},
}
fn resolve_config(
config_override: Option<&Path>,
) -> Result<(PathBuf, Config), Box<dyn std::error::Error>> {
if let Some(path) = config_override {
let config = load_config(path).map_err(|e| {
format!("failed to load config {}: {e}", sanitize_for_display(&path.display().to_string()))
})?;
return Ok((path.to_path_buf(), config));
}
let path = default_config_path()?;
let config = load_config(&path).map_err(|e| {
format!("failed to load config {}: {e}", sanitize_for_display(&path.display().to_string()))
})?;
Ok((path, config))
}
fn resolve_config_opt(config_override: Option<&Path>) -> (PathBuf, Option<Config>, Option<String>) {
if let Some(path) = config_override {
let result = load_config(path);
let err = result.as_ref().err().map(|e| e.to_string());
return (path.to_path_buf(), result.ok(), err);
}
let path = default_config_path().unwrap_or_default();
let result = load_config(&path);
let err = result.as_ref().err().map(|e| e.to_string());
(path, result.ok(), err)
}
fn compute_precache_fingerprint(config_path: &Path, shell: &str) -> String {
let path_env = std::env::var("PATH").unwrap_or_default();
let mtime = runex_core::precache::config_mtime(config_path);
runex_core::precache::compute_fingerprint(&path_env, mtime, shell)
}
fn make_command_exists<'a>(
path_prepend: Option<&'a Path>,
precache_fingerprint: Option<&str>,
) -> impl Fn(&str) -> bool + 'a {
use runex_core::precache;
let hint = precache_fingerprint.and_then(precache::load_cache);
let cache = std::cell::RefCell::new(std::collections::HashMap::<String, bool>::new());
#[cfg(windows)]
let effective_path = win_path::effective_search_path();
move |cmd: &str| -> bool {
if cmd.contains('/') || cmd.contains('\\') || cmd.contains(':') {
return false;
}
if let Some(&cached) = cache.borrow().get(cmd) {
return cached;
}
if let Some(ref h) = hint {
if let Some(&cached) = h.commands.get(cmd) {
if cached {
cache.borrow_mut().insert(cmd.to_owned(), true);
return true;
}
}
}
let live_check = |c: &str| -> bool {
#[cfg(windows)]
{
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
which::which_in(c, Some(&effective_path.combined), &cwd).is_ok()
}
#[cfg(not(windows))]
{
which::which(c).is_ok()
}
};
let exists = if let Some(dir) = path_prepend {
if dir.join(cmd).is_file() {
true
} else {
#[cfg(windows)]
{
if dir.join(format!("{cmd}.exe")).is_file() {
true
} else {
live_check(cmd)
}
}
#[cfg(not(windows))]
{
live_check(cmd)
}
}
} else {
live_check(cmd)
};
cache.borrow_mut().insert(cmd.to_owned(), exists);
exists
}
}
const MAX_RC_FILE_BYTES: usize = 1024 * 1024;
fn read_rc_content(path: &Path) -> String {
use std::io::Read;
#[cfg(unix)]
let mut file = {
use std::os::unix::fs::OpenOptionsExt;
match std::fs::OpenOptions::new()
.read(true)
.custom_flags(libc::O_NONBLOCK)
.open(path)
{
Ok(f) => f,
Err(_) => return String::new(),
}
};
#[cfg(not(unix))]
let mut file = match std::fs::File::open(path) {
Ok(f) => f,
Err(_) => return String::new(),
};
let meta = match file.metadata() {
Ok(m) => m,
Err(_) => return String::new(),
};
if !meta.is_file() {
return String::new();
}
if meta.len() > MAX_RC_FILE_BYTES as u64 {
return String::new();
}
let mut content = String::new();
file.read_to_string(&mut content).unwrap_or_default();
content
}
fn detect_shell() -> Option<Shell> {
if let Ok(sh) = std::env::var("SHELL") {
let base = Path::new(&sh)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("");
if let Ok(s) = base.parse::<Shell>() {
return Some(s);
}
}
if std::env::var("PSModulePath").is_ok() {
return Some(Shell::Pwsh);
}
None
}
fn resolve_shell(shell_flag: Option<&str>) -> Result<Option<Shell>, Box<dyn std::error::Error>> {
if let Some(s) = shell_flag {
let sh = s.parse::<Shell>().map_err(|e: runex_core::shell::ShellParseError| {
Box::<dyn std::error::Error>::from(e.to_string())
})?;
return Ok(Some(sh));
}
Ok(detect_shell())
}
const MAX_CONFIRM_BYTES: usize = 1_024;
fn prompt_confirm_from(reader: &mut impl io::BufRead) -> bool {
use io::{BufRead as _, Read as _};
let mut input = String::new();
let mut limited = reader.by_ref().take(MAX_CONFIRM_BYTES as u64 + 1);
match limited.read_line(&mut input) {
Err(_) => return false,
Ok(0) => return false,
Ok(_) => {}
}
if input.len() > MAX_CONFIRM_BYTES {
return false;
}
matches!(input.trim().to_ascii_lowercase().as_str(), "y" | "yes")
}
fn prompt_confirm(msg: &str) -> bool {
eprint!("{msg} [y/N] ");
let _ = io::stderr().flush();
let stdin = io::stdin();
let mut reader = io::BufReader::new(stdin.lock());
prompt_confirm_from(&mut reader)
}
const MAX_TOKEN_BYTES: usize = 1_024;
type CmdResult = Result<(), Box<dyn std::error::Error>>;
fn handle_version(json: bool) -> CmdResult {
if json {
#[derive(serde::Serialize)]
struct VersionJson<'a> {
version: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
commit: Option<&'a str>,
}
let v = VersionJson {
version: env!("CARGO_PKG_VERSION"),
commit: GIT_COMMIT.filter(|s| !s.is_empty()),
};
println!("{}", serde_json::to_string_pretty(&v)?);
} else {
println!("{}", version_line());
}
Ok(())
}
fn handle_list(config: &Config, shell: Option<Shell>, json: bool) -> CmdResult {
if json {
println!("{}", serde_json::to_string_pretty(&config.abbr)?);
} else {
for (key, exp) in expand::list(config, shell) {
println!("{}\t{}", sanitize_for_display(key), sanitize_for_display(&exp));
}
}
Ok(())
}
fn handle_which(
token: String,
config: &Config,
shell: Shell,
command_exists: &dyn Fn(&str) -> bool,
json: bool,
why: bool,
) -> CmdResult {
if token.len() > MAX_TOKEN_BYTES {
eprintln!(
"error: token is too long ({} bytes); maximum is {MAX_TOKEN_BYTES}",
token.len()
);
std::process::exit(1);
}
let result = expand::which_abbr(config, &token, shell, command_exists);
if json {
println!("{}", serde_json::to_string_pretty(&which_result_to_json(&result))?);
} else {
println!("{}", format_which_result(&result, why));
}
Ok(())
}
fn handle_expand(
token: String,
config: &Config,
shell: Shell,
command_exists: &dyn Fn(&str) -> bool,
json: bool,
dry_run: bool,
) -> CmdResult {
if token.len() > MAX_TOKEN_BYTES {
eprintln!(
"error: --token is too long ({} bytes); maximum is {MAX_TOKEN_BYTES}",
token.len()
);
std::process::exit(1);
}
if dry_run {
let result = expand::which_abbr(config, &token, shell, command_exists);
if json {
println!("{}", serde_json::to_string_pretty(&which_result_to_json(&result))?);
} else {
print!("{}", format_dry_run_result(&token, &result));
}
} else {
let result = expand::expand(config, &token, shell, command_exists);
if json {
let v = match &result {
ExpandResult::Expanded { text: s, .. } => serde_json::json!({
"result": "expanded",
"token": token,
"expansion": s,
}),
ExpandResult::PassThrough(s) => serde_json::json!({
"result": "pass_through",
"token": s,
}),
};
println!("{}", serde_json::to_string_pretty(&v)?);
} else {
match result {
ExpandResult::Expanded { text, cursor_offset } => {
if let Some(offset) = cursor_offset {
print!("{text}\x1f{offset}");
} else {
print!("{text}");
}
}
ExpandResult::PassThrough(s) => print!("{s}"),
}
}
}
Ok(())
}
fn validate_bin(bin: &str) {
if bin.trim().is_empty() {
eprintln!("error: --bin must not be empty or whitespace-only");
std::process::exit(1);
}
if bin.len() > MAX_BIN_LEN {
eprintln!("error: --bin is too long ({} bytes); maximum is {MAX_BIN_LEN}", bin.len());
std::process::exit(1);
}
if bin.chars().any(|c| c.is_ascii_control() || c == '\u{0085}' || c == '\u{2028}' || c == '\u{2029}') {
eprintln!("error: --bin contains an invalid control character");
std::process::exit(1);
}
if bin.chars().any(|c| !c.is_ascii() || !c.is_ascii_graphic()) {
eprintln!("error: --bin must contain only printable ASCII characters");
std::process::exit(1);
}
}
fn handle_export(
shell: String,
bin: String,
config_flag: Option<&Path>,
) -> CmdResult {
validate_bin(&bin);
let s: Shell = shell.parse().map_err(|e: runex_core::shell::ShellParseError| {
Box::<dyn std::error::Error>::from(e.to_string())
})?;
let config = if config_flag.is_some() {
let (_path, cfg) = resolve_config(config_flag)?;
Some(cfg)
} else {
let (_path, cfg, _err) = resolve_config_opt(None);
cfg
};
let effective_bin = if matches!(s, Shell::Clink) && bin == "runex" {
std::env::current_exe()
.ok()
.and_then(|p| p.to_str().map(|s| s.to_string()))
.unwrap_or(bin)
} else {
bin
};
print!("{}", runex_core::shell::export_script(s, &effective_bin, config.as_ref()));
Ok(())
}
fn handle_precache(
shell: String,
list_commands: bool,
resolved: Option<String>,
config_flag: Option<&Path>,
path_prepend: Option<&Path>,
) -> CmdResult {
use runex_core::precache;
let s: Shell = shell.parse().map_err(|e: runex_core::shell::ShellParseError| {
Box::<dyn std::error::Error>::from(e.to_string())
})?;
let shell_name = format!("{s:?}").to_lowercase();
let (config_path, config) = resolve_config(config_flag)?;
if list_commands {
if !config.precache.path_only {
let cmds = precache::collect_unique_commands(&config);
print!("{}", cmds.join(","));
}
return Ok(());
}
let fp = compute_precache_fingerprint(&config_path, &shell_name);
if let Some(resolved_str) = resolved {
let cache = precache::build_cache_from_resolved(&config, &fp, &resolved_str);
let json = precache::cache_to_json(&cache);
println!("{}", precache::export_statement(&shell_name, &json));
return Ok(());
}
let command_exists = make_command_exists(path_prepend, None);
let cache = precache::build_cache(&config, &fp, &command_exists);
let json = precache::cache_to_json(&cache);
println!("{}", precache::export_statement(&shell_name, &json));
Ok(())
}
fn handle_timings(
key: Option<String>,
shell_str: Option<String>,
config_flag: Option<&Path>,
path_prepend: Option<&Path>,
json: bool,
) -> CmdResult {
use runex_core::timings::{PhaseTimer, Timings};
let mut timings = Timings::new();
let t = PhaseTimer::start();
let (config_path, config) = resolve_config(config_flag)?;
timings.record_phase("config_load", t.elapsed());
let t = PhaseTimer::start();
let shell = resolve_shell(shell_str.as_deref())?.unwrap_or(Shell::Bash);
timings.record_phase("shell_resolve", t.elapsed());
let fp = compute_precache_fingerprint(&config_path, &format!("{shell:?}").to_lowercase());
let command_exists = make_command_exists(path_prepend, Some(&fp));
match key {
Some(k) => {
if k.len() > MAX_TOKEN_BYTES {
eprintln!(
"error: key is too long ({} bytes); maximum is {MAX_TOKEN_BYTES}",
k.len()
);
std::process::exit(1);
}
expand::expand_timed(&config, &k, shell, &command_exists, &mut timings);
}
None => {
let keys: Vec<String> = config.abbr.iter().map(|a| a.key.clone()).collect();
let unique_keys: Vec<String> = {
let mut seen = std::collections::HashSet::new();
keys.into_iter().filter(|k| seen.insert(k.clone())).collect()
};
for key in &unique_keys {
expand::expand_timed(&config, key, shell, &command_exists, &mut timings);
}
}
}
if json {
println!("{}", serde_json::to_string_pretty(&format::format_timings_json(&timings))?);
} else {
print!("{}", format::format_timings_table(&timings));
}
Ok(())
}
fn build_doctor_env_info(config: Option<&Config>) -> doctor::DoctorEnvInfo {
let mut info = doctor::DoctorEnvInfo::default();
#[cfg(windows)]
{
let p = win_path::effective_search_path();
info.effective_search_path = Some(doctor::EffectiveSearchPathSummary {
from_process: p.from_process,
from_user_registry: p.from_user_registry,
from_system_registry: p.from_system_registry,
});
}
let clink_bin = std::env::current_exe()
.ok()
.and_then(|p| p.to_str().map(|s| s.to_string()))
.unwrap_or_else(|| "runex".to_string());
info.clink_export_for_drift_check = Some(runex_core::shell::export_script(
Shell::Clink,
&clink_bin,
config,
));
info.check_rcfile_markers = doctor::RcfileMarkerSelection::all();
info
}
fn handle_doctor(
config_flag: Option<&Path>,
path_prepend: Option<&Path>,
no_shell_aliases: bool,
verbose: bool,
strict: bool,
json: bool,
) -> CmdResult {
let (config_path, config, parse_error) = resolve_config_opt(config_flag);
let command_exists = make_command_exists(path_prepend, None);
let spinner = Spinner::start("Checking environment...");
let env_info = build_doctor_env_info(config.as_ref());
let mut result = doctor::diagnose(
&config_path,
config.as_ref(),
parse_error.as_deref(),
&env_info,
&command_exists,
);
if !no_shell_aliases {
add_shell_alias_conflicts(&mut result, config.as_ref());
}
let source = runex_core::config::read_config_source(&config_path).ok();
if let Some(src) = source.as_deref() {
result.checks.extend(doctor::check_rejected_rules(src));
}
if strict {
if let Some(src) = source.as_deref() {
result.checks.extend(doctor::check_unknown_fields(src));
result.checks.extend(doctor::check_precache_deprecation(src));
}
if let Some(cfg) = config.as_ref() {
result.checks.extend(doctor::check_unreachable_duplicates(cfg));
}
}
spinner.stop();
if json {
println!("{}", serde_json::to_string_pretty(&result.checks)?);
} else {
for check in &result.checks {
println!("{}", format_check_line(check, verbose));
}
}
if !result.is_healthy() {
std::process::exit(1);
}
Ok(())
}
fn handle_init(config_path: PathBuf, shell_override: Option<&str>, yes: bool) -> CmdResult {
let msg = format!("Create config at {}?", sanitize_for_display(&config_path.display().to_string()));
if yes || prompt_confirm(&msg) {
if let Some(parent) = config_path.parent() {
std::fs::create_dir_all(parent)?;
}
match std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&config_path)
{
Ok(mut f) => {
f.write_all(runex_init::default_config_content().as_bytes())?;
println!("Created: {}", sanitize_for_display(&config_path.display().to_string()));
}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
println!("Config already exists: {}", sanitize_for_display(&config_path.display().to_string()));
}
Err(e) => return Err(e.into()),
}
} else {
println!("Skipped config creation.");
}
let shell = if let Some(s) = shell_override {
s.parse::<Shell>().map_err(|e: runex_core::shell::ShellParseError| {
Box::<dyn std::error::Error>::from(e.to_string())
})?
} else {
detect_shell().unwrap_or_else(|| {
eprintln!(
"Could not detect shell. Defaulting to bash. \
Use `runex init <shell>` (e.g. `runex init pwsh`) to target a specific shell."
);
Shell::Bash
})
};
let rc_path_for_next_steps = match shell {
Shell::Clink => {
install_clink_lua(yes, &config_path)?;
None
}
_ => install_rcfile_integration(shell, yes)?,
};
println!();
println!("{}", runex_init::next_steps_message(shell, rc_path_for_next_steps.as_deref()));
Ok(())
}
fn install_rcfile_integration(shell: Shell, yes: bool) -> Result<Option<PathBuf>, Box<dyn std::error::Error>> {
let Some(rc_path) = runex_init::rc_file_for(shell) else {
println!(
"Shell integration for {:?} must be added manually. \
Run `runex export {:?}` for the script.",
shell, shell
);
return Ok(None);
};
let existing = read_rc_content(&rc_path);
if existing.contains(runex_init::RUNEX_INIT_MARKER) {
println!(
"Shell integration already present in {}",
sanitize_for_display(&rc_path.display().to_string())
);
return Ok(Some(rc_path));
}
let msg = format!(
"Append shell integration to {}?",
sanitize_for_display(&rc_path.display().to_string())
);
if !(yes || prompt_confirm(&msg)) {
println!("Skipped shell integration.");
return Ok(Some(rc_path));
}
let line = runex_init::integration_line(shell, "runex");
let block = format!("\n{}\n{}\n", runex_init::RUNEX_INIT_MARKER, line);
if let Some(parent) = rc_path.parent() {
std::fs::create_dir_all(parent)?;
}
let mut open_opts = std::fs::OpenOptions::new();
open_opts.create(true).append(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
open_opts.custom_flags(libc::O_NOFOLLOW);
}
let mut file = open_opts.open(&rc_path)?;
file.write_all(block.as_bytes())?;
println!("Appended integration to {}", sanitize_for_display(&rc_path.display().to_string()));
Ok(Some(rc_path))
}
fn install_clink_lua(yes: bool, config_path: &Path) -> CmdResult {
use runex_core::integration_check::{check_clink_lua_freshness, IntegrationCheck};
let bin = std::env::current_exe()
.ok()
.and_then(|p| p.to_str().map(|s| s.to_string()))
.unwrap_or_else(|| "runex".to_string());
let (_path, config, _err) = resolve_config_opt(Some(config_path));
let new_content = runex_core::shell::export_script(Shell::Clink, &bin, config.as_ref());
let install_path = runex_init::default_clink_lua_install_path();
let probe = check_clink_lua_freshness(
&new_content,
&runex_core::integration_check::default_clink_lua_paths(),
);
match probe {
IntegrationCheck::Ok { detail, .. } => {
println!("clink integration already up-to-date ({detail}).");
return Ok(());
}
IntegrationCheck::Outdated { path, .. } => {
let msg = format!(
"clink lua at {} is out of date. Overwrite with the current export?",
sanitize_for_display(&path.display().to_string())
);
if !(yes || prompt_confirm(&msg)) {
println!("Skipped clink integration update.");
return Ok(());
}
}
IntegrationCheck::Skipped { .. } | IntegrationCheck::Missing { .. } => {
let msg = format!(
"Write clink integration to {}?",
sanitize_for_display(&install_path.display().to_string())
);
if !(yes || prompt_confirm(&msg)) {
println!("Skipped clink integration.");
return Ok(());
}
}
}
if let Some(parent) = install_path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&install_path, new_content.as_bytes())?;
println!(
"Wrote clink integration to {}",
sanitize_for_display(&install_path.display().to_string())
);
Ok(())
}
fn handle_hook(
shell_str: &str,
line: &str,
cursor: usize,
paste_pending: bool,
config_override: Option<&Path>,
path_prepend: Option<&Path>,
) -> CmdResult {
let shell = Shell::from_str(shell_str)
.map_err(|e| format!("{}", e))?;
if paste_pending {
let action = runex_core::hook::HookAction::InsertSpace {
line: {
let mut s = String::with_capacity(line.len() + 1);
let cursor = cursor.min(line.len());
s.push_str(&line[..cursor]);
s.push(' ');
s.push_str(&line[cursor..]);
s
},
cursor: cursor.min(line.len()) + 1,
};
println!("{}", runex_core::hook::render_action(shell, &action));
return Ok(());
}
let (config_path, config_opt, _err) = resolve_config_opt(config_override);
let Some(config) = config_opt else {
let cursor_safe = cursor.min(line.len());
let mut s = String::with_capacity(line.len() + 1);
s.push_str(&line[..cursor_safe]);
s.push(' ');
s.push_str(&line[cursor_safe..]);
let action = runex_core::hook::HookAction::InsertSpace { line: s, cursor: cursor_safe + 1 };
println!("{}", runex_core::hook::render_action(shell, &action));
return Ok(());
};
let fp = compute_precache_fingerprint(&config_path, &format!("{shell:?}").to_lowercase());
let command_exists = make_command_exists(path_prepend, Some(&fp));
let action = runex_core::hook::hook(&config, shell, line, cursor, command_exists);
println!("{}", runex_core::hook::render_action(shell, &action));
Ok(())
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let cli = Cli::parse();
match cli.command {
Commands::Version => handle_version(cli.json)?,
Commands::List { shell: shell_str } => {
let (_config_path, config) = resolve_config(cli.config.as_deref())?;
let shell = resolve_shell(shell_str.as_deref())?;
handle_list(&config, shell, cli.json)?;
}
Commands::Which { token, why, shell: shell_str } => {
let (config_path, config) = resolve_config(cli.config.as_deref())?;
let shell = resolve_shell(shell_str.as_deref())?.unwrap_or(Shell::Bash);
let fp = compute_precache_fingerprint(&config_path, &format!("{shell:?}").to_lowercase());
let command_exists = make_command_exists(cli.path_prepend.as_deref(), Some(&fp));
handle_which(token, &config, shell, &command_exists, cli.json, why)?;
}
Commands::Expand { token, dry_run, shell: shell_str } => {
let (config_path, config) = resolve_config(cli.config.as_deref())?;
let shell = resolve_shell(shell_str.as_deref())?.unwrap_or(Shell::Bash);
let fp = compute_precache_fingerprint(&config_path, &format!("{shell:?}").to_lowercase());
let command_exists = make_command_exists(cli.path_prepend.as_deref(), Some(&fp));
handle_expand(token, &config, shell, &command_exists, cli.json, dry_run)?;
}
Commands::Export { shell, bin } => {
handle_export(shell, bin, cli.config.as_deref())?;
}
Commands::Doctor { no_shell_aliases, verbose, strict } => {
handle_doctor(
cli.config.as_deref(),
cli.path_prepend.as_deref(),
no_shell_aliases,
verbose,
strict,
cli.json,
)?;
}
Commands::Precache { shell, list_commands, resolved } => {
handle_precache(shell, list_commands, resolved, cli.config.as_deref(), cli.path_prepend.as_deref())?;
}
Commands::Timings { key, shell: shell_str } => {
handle_timings(
key,
shell_str,
cli.config.as_deref(),
cli.path_prepend.as_deref(),
cli.json,
)?;
}
Commands::Init { shell, yes } => {
let config_path = if let Some(p) = cli.config.as_deref() {
p.to_path_buf()
} else {
default_config_path()?
};
handle_init(config_path, shell.as_deref(), yes)?;
}
Commands::Hook { shell, line, cursor, paste_pending } => {
handle_hook(
&shell,
&line,
cursor,
paste_pending,
cli.config.as_deref(),
cli.path_prepend.as_deref(),
)?;
}
Commands::Add { key, expand, when } => {
let config_path = if let Some(p) = cli.config.as_deref() {
p.to_path_buf()
} else {
default_config_path()?
};
runex_core::config::append_abbr_to_file(
&config_path,
&key,
&expand,
when.as_deref(),
)?;
println!("Added: {} -> {}", sanitize_for_display(&key), sanitize_for_display(&expand));
}
Commands::Remove { key } => {
let config_path = if let Some(p) = cli.config.as_deref() {
p.to_path_buf()
} else {
default_config_path()?
};
let removed = runex_core::config::remove_abbr_from_file(&config_path, &key)?;
if removed > 0 {
println!("Removed {} rule(s) for '{}'", removed, sanitize_for_display(&key));
} else {
println!("No rule found for '{}'", sanitize_for_display(&key));
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
mod command_exists {
use super::*;
#[test]
fn make_command_exists_no_prepend_uses_which() {
let exists = make_command_exists(None, None);
assert!(exists("cargo"));
assert!(!exists("__runex_fake_cmd_that_does_not_exist__"));
}
#[test]
fn make_command_exists_prepend_finds_file() {
let dir = tempfile::tempdir().unwrap();
let fake_bin = dir.path().join("myfaketool");
std::fs::write(&fake_bin, b"").unwrap();
let exists = make_command_exists(Some(dir.path()), None);
assert!(exists("myfaketool"));
assert!(!exists("__runex_other_fake__"));
}
#[test]
#[cfg(windows)]
fn make_command_exists_finds_user_path_binary_when_process_path_is_minimal() {
let user_path = read_user_path_for_test();
if !user_path
.split(';')
.any(|p| std::path::Path::new(&p.replace("%UserProfile%", &std::env::var("USERPROFILE").unwrap_or_default()))
.join("cargo.exe").is_file())
{
eprintln!("skipping: cargo.exe not found via registry User PATH");
return;
}
let original = std::env::var_os("PATH");
unsafe { std::env::set_var("PATH", r"C:\Windows\System32;C:\Windows"); }
let exists = make_command_exists(None, None);
let found = exists("cargo");
unsafe {
match original {
Some(v) => std::env::set_var("PATH", v),
None => std::env::remove_var("PATH"),
}
}
assert!(
found,
"make_command_exists must consult HKCU Environment Path on Windows so commands installed under the User PATH (e.g. ~/.cargo/bin) are discoverable even when the process PATH lacks them"
);
}
#[cfg(windows)]
fn read_user_path_for_test() -> String {
use winreg::enums::HKEY_CURRENT_USER;
use winreg::RegKey;
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
let env = match hkcu.open_subkey("Environment") {
Ok(k) => k,
Err(_) => return String::new(),
};
env.get_value("Path").unwrap_or_default()
}
}
mod init_cli {
use super::*;
use clap::Parser;
#[test]
fn init_without_args_parses() {
let cli = Cli::try_parse_from(["runex", "init"]).expect("init parses without args");
match cli.command {
Commands::Init { shell, yes } => {
assert!(shell.is_none(), "no positional → shell must be None");
assert!(!yes, "no -y → yes must be false");
}
_ => panic!("expected Init"),
}
}
#[test]
fn init_with_shell_positional_parses() {
let cli = Cli::try_parse_from(["runex", "init", "bash"]).expect("init bash parses");
match cli.command {
Commands::Init { shell, .. } => {
assert_eq!(shell.as_deref(), Some("bash"));
}
_ => panic!("expected Init"),
}
}
#[test]
fn init_with_shell_and_yes_parses() {
let cli = Cli::try_parse_from(["runex", "init", "-y", "clink"])
.expect("init -y clink parses");
match cli.command {
Commands::Init { shell, yes } => {
assert_eq!(shell.as_deref(), Some("clink"));
assert!(yes);
}
_ => panic!("expected Init"),
}
}
}
mod rc_file_size_limit {
use super::*;
#[test]
fn read_rc_content_returns_content_for_normal_file() {
let mut f = tempfile::NamedTempFile::new().unwrap();
use std::io::Write;
f.write_all(b"# runex-init\n").unwrap();
let content = read_rc_content(f.path());
assert!(content.contains("# runex-init"), "normal rc file must be readable");
}
#[test]
fn read_rc_content_returns_empty_for_oversized_file() {
let mut f = tempfile::NamedTempFile::new().unwrap();
use std::io::Write;
f.write_all(&vec![b'x'; MAX_RC_FILE_BYTES + 1]).unwrap();
let content = read_rc_content(f.path());
assert!(
content.is_empty(),
"read_rc_content must return empty string for oversized rc file"
);
}
#[test]
fn read_rc_content_returns_empty_for_missing_file() {
let content = read_rc_content(std::path::Path::new("/nonexistent/runex_test.rc"));
assert!(content.is_empty(), "missing rc file must return empty string");
}
#[test]
fn read_rc_content_accepts_file_at_exact_size_limit() {
let mut f = tempfile::NamedTempFile::new().unwrap();
use std::io::Write;
f.write_all(&vec![b'x'; MAX_RC_FILE_BYTES]).unwrap();
let content = read_rc_content(f.path());
assert_eq!(
content.len(),
MAX_RC_FILE_BYTES,
"read_rc_content must accept a file exactly at MAX_RC_FILE_BYTES"
);
}
}
mod prompt_confirm_limit {
use super::*;
#[test]
fn prompt_confirm_from_accepts_yes() {
use std::io::BufReader;
let input = b"y\n";
let mut reader = BufReader::new(&input[..]);
assert!(
prompt_confirm_from(&mut reader),
"prompt_confirm_from must return true for 'y\\n'"
);
}
#[test]
fn prompt_confirm_from_accepts_yes_long_form() {
use std::io::BufReader;
let input = b"yes\n";
let mut reader = BufReader::new(&input[..]);
assert!(
prompt_confirm_from(&mut reader),
"prompt_confirm_from must return true for 'yes\\n'"
);
}
#[test]
fn prompt_confirm_from_rejects_no() {
use std::io::BufReader;
let input = b"n\n";
let mut reader = BufReader::new(&input[..]);
assert!(
!prompt_confirm_from(&mut reader),
"prompt_confirm_from must return false for 'n\\n'"
);
}
#[test]
fn prompt_confirm_from_rejects_oversized_input() {
use std::io::BufReader;
let huge = vec![b'y'; MAX_CONFIRM_BYTES + 1];
let mut reader = BufReader::new(huge.as_slice());
assert!(
!prompt_confirm_from(&mut reader),
"prompt_confirm_from must return false for input exceeding MAX_CONFIRM_BYTES"
);
}
#[test]
fn prompt_confirm_from_rejects_empty_input() {
use std::io::BufReader;
let input = b"";
let mut reader = BufReader::new(&input[..]);
assert!(
!prompt_confirm_from(&mut reader),
"prompt_confirm_from must return false for empty input (EOF)"
);
}
}
#[cfg(unix)]
mod rc_file_non_regular {
use super::*;
#[test]
fn read_rc_content_rejects_named_pipe() {
use std::ffi::CString;
let dir = tempfile::tempdir().unwrap();
let pipe = dir.path().join("fake_rc.sh");
let path_c = CString::new(pipe.to_str().unwrap()).unwrap();
unsafe { libc::mkfifo(path_c.as_ptr(), 0o600) };
let content = read_rc_content(&pipe);
assert_eq!(
content, "",
"read_rc_content must return empty string for a named pipe (FIFO), not block"
);
}
#[test]
#[cfg(unix)]
fn read_rc_content_rejects_dev_zero() {
let path = std::path::Path::new("/dev/zero");
let content = read_rc_content(path);
assert_eq!(
content, "",
"read_rc_content must return empty string for /dev/zero (device file)"
);
}
}
}