mod app;
mod cmd;
mod domain;
mod format;
mod infra;
mod shell_alias;
mod util;
#[cfg(windows)]
mod win_path;
use clap::{Parser, Subcommand};
use crate::app::config::{default_config_path, load_config};
use crate::domain::model::Config;
use crate::domain::sanitize::sanitize_for_display;
use crate::domain::shell::Shell;
use std::io::{self, IsTerminal, Write};
use std::path::{Path, PathBuf};
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;
pub(crate) const MAX_BIN_LEN: usize = 255;
pub(crate) struct Spinner {
done: Arc<AtomicBool>,
handle: Option<thread::JoinHandle<()>>,
}
impl Spinner {
pub(crate) 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),
}
}
pub(crate) 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)]
PasteClipboard,
#[command(hide = true)]
Hook {
#[arg(long, value_name = "SHELL")]
shell: String,
#[arg(long)]
line: String,
#[arg(long)]
cursor: usize,
#[arg(long)]
paste_pending: bool,
},
}
pub(crate) 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))
}
pub(crate) 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)
}
pub(crate) fn compute_precache_fingerprint(config_path: &Path, shell: &str) -> String {
let path_env = std::env::var("PATH").unwrap_or_default();
let mtime = crate::app::precache::config_mtime(config_path);
crate::app::precache::compute_fingerprint(&path_env, mtime, shell)
}
pub(crate) struct AppContext {
#[allow(dead_code)]
pub(crate) config_path: PathBuf,
pub(crate) config: Config,
pub(crate) shell: Option<Shell>,
#[allow(dead_code)]
pub(crate) fingerprint: String,
pub(crate) command_exists: Box<dyn Fn(&str) -> bool>,
}
impl AppContext {
pub(crate) fn build(
config_flag: Option<&Path>,
shell_flag: Option<&str>,
path_prepend: Option<&Path>,
precache_enabled: bool,
) -> Result<Self, Box<dyn std::error::Error>> {
let (config_path, config) = resolve_config(config_flag)?;
Ok(Self::assemble(config_path, config, shell_flag, path_prepend, precache_enabled)?)
}
pub(crate) fn build_optional(
config_flag: Option<&Path>,
shell_flag: Option<&str>,
path_prepend: Option<&Path>,
precache_enabled: bool,
) -> OptionalContext {
let (config_path, config_opt, parse_error) = resolve_config_opt(config_flag);
let shell = resolve_shell(shell_flag).ok().flatten();
let resolved_shell = shell.unwrap_or(Shell::Bash);
let fingerprint = compute_precache_fingerprint(
&config_path,
&format!("{resolved_shell:?}").to_lowercase(),
);
let path_prepend_owned = path_prepend.map(|p| p.to_path_buf());
let command_exists: Box<dyn Fn(&str) -> bool> = if precache_enabled {
Box::new(make_command_exists_owned(path_prepend_owned, Some(fingerprint.clone())))
} else {
Box::new(make_command_exists_owned(path_prepend_owned, None))
};
OptionalContext {
config_path,
config: config_opt,
parse_error,
shell,
fingerprint,
command_exists,
}
}
fn assemble(
config_path: PathBuf,
config: Config,
shell_flag: Option<&str>,
path_prepend: Option<&Path>,
precache_enabled: bool,
) -> Result<Self, Box<dyn std::error::Error>> {
let shell = resolve_shell(shell_flag)?;
let resolved_shell = shell.unwrap_or(Shell::Bash);
let fingerprint = compute_precache_fingerprint(
&config_path,
&format!("{resolved_shell:?}").to_lowercase(),
);
let path_prepend_owned = path_prepend.map(|p| p.to_path_buf());
let command_exists: Box<dyn Fn(&str) -> bool> = if precache_enabled {
Box::new(make_command_exists_owned(path_prepend_owned, Some(fingerprint.clone())))
} else {
Box::new(make_command_exists_owned(path_prepend_owned, None))
};
Ok(Self {
config_path,
config,
shell,
fingerprint,
command_exists,
})
}
}
pub(crate) struct OptionalContext {
#[allow(dead_code)]
pub(crate) config_path: PathBuf,
pub(crate) config: Option<Config>,
#[allow(dead_code)]
pub(crate) parse_error: Option<String>,
#[allow(dead_code)]
pub(crate) shell: Option<Shell>,
#[allow(dead_code)]
pub(crate) fingerprint: String,
pub(crate) command_exists: Box<dyn Fn(&str) -> bool>,
}
use util::path::make_command_exists_owned;
use util::shell::resolve_shell;
#[cfg(test)]
use util::prompt::{prompt_confirm_from, MAX_CONFIRM_BYTES, MAX_RC_FILE_BYTES};
pub(crate) const MAX_TOKEN_BYTES: usize = 1_024;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum CmdOutcome {
Ok,
ExitCode(i32),
}
pub(crate) type CmdResult = Result<CmdOutcome, Box<dyn std::error::Error>>;
pub(crate) const MAX_HOOK_LINE_BYTES: usize = 16 * 1024;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let cli = Cli::parse();
let outcome: CmdOutcome = match cli.command {
Commands::Version => cmd::version::handle(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())?;
cmd::list::handle(&config, shell, cli.json)?
}
Commands::Which { token, why, shell: shell_str } => {
let ctx = AppContext::build(
cli.config.as_deref(),
shell_str.as_deref(),
cli.path_prepend.as_deref(),
true,
)?;
cmd::which::handle(
token,
&ctx.config,
ctx.shell.unwrap_or(Shell::Bash),
&*ctx.command_exists,
cli.json,
why,
)?
}
Commands::Expand { token, dry_run, shell: shell_str } => {
let ctx = AppContext::build(
cli.config.as_deref(),
shell_str.as_deref(),
cli.path_prepend.as_deref(),
true,
)?;
cmd::expand::handle(
token,
&ctx.config,
ctx.shell.unwrap_or(Shell::Bash),
&*ctx.command_exists,
cli.json,
dry_run,
)?
}
Commands::Export { shell, bin } => cmd::export::handle(shell, bin, cli.config.as_deref())?,
Commands::Doctor { no_shell_aliases, verbose, strict } => cmd::doctor::handle(
cli.config.as_deref(),
cli.path_prepend.as_deref(),
no_shell_aliases,
verbose,
strict,
cli.json,
)?,
Commands::Precache { shell, list_commands, resolved } => cmd::precache::handle(
shell,
list_commands,
resolved,
cli.config.as_deref(),
cli.path_prepend.as_deref(),
)?,
Commands::Timings { key, shell: shell_str } => cmd::timings::handle(
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()?
};
cmd::init::handle(
config_path,
shell.as_deref(),
yes,
&infra::env::SystemHomeDir,
)?
}
Commands::PasteClipboard => cmd::paste_clipboard::handle()?,
Commands::Hook { shell, line, cursor, paste_pending } => cmd::hook::handle(
&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()?
};
cmd::add_remove::handle_add(&config_path, &key, &expand, when.as_deref())?
}
Commands::Remove { key } => {
let config_path = if let Some(p) = cli.config.as_deref() {
p.to_path_buf()
} else {
default_config_path()?
};
cmd::add_remove::handle_remove(&config_path, &key)?
}
};
match outcome {
CmdOutcome::Ok => Ok(()),
CmdOutcome::ExitCode(code) => std::process::exit(code),
}
}
#[cfg(test)]
mod tests {
use super::*;
mod command_exists {
#[test]
fn make_command_exists_no_prepend_uses_which() {
let exists = crate::util::path::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 = crate::util::path::make_command_exists(Some(dir.path()), None);
assert!(exists("myfaketool"));
assert!(!exists("__runex_other_fake__"));
}
#[test]
#[cfg(windows)]
#[serial_test::serial(env_path)]
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 = crate::util::path::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 = crate::util::prompt::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 = crate::util::prompt::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 = crate::util::prompt::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 = crate::util::prompt::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 = crate::util::prompt::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 = crate::util::prompt::read_rc_content(path);
assert_eq!(
content, "",
"read_rc_content must return empty string for /dev/zero (device file)"
);
}
}
mod app_context {
use super::*;
use std::io::Write;
fn write_minimal_config(toml: &str) -> tempfile::NamedTempFile {
let mut f = tempfile::NamedTempFile::new().unwrap();
f.write_all(toml.as_bytes()).unwrap();
f.flush().unwrap();
f
}
#[test]
#[cfg_attr(windows, serial_test::serial(env_path))]
fn build_returns_same_fingerprint_for_identical_args() {
let cfg = write_minimal_config("version = 1\n");
let a = AppContext::build(Some(cfg.path()), Some("bash"), None, true)
.expect("build must succeed for a valid config");
let b = AppContext::build(Some(cfg.path()), Some("bash"), None, true)
.expect("build must succeed twice");
assert_eq!(
a.fingerprint, b.fingerprint,
"two builds with the same inputs must produce the same fingerprint, \
otherwise on-disk precache hits would alternate between calls"
);
}
#[test]
fn build_optional_returns_none_config_when_path_missing() {
let nonexistent = std::path::Path::new("/nonexistent/runex/config.toml");
let ctx = AppContext::build_optional(Some(nonexistent), Some("bash"), None, true);
assert!(
ctx.config.is_none(),
"build_optional must return None config (not an Err) when the file is missing — \
hook depends on this so it can fall back to InsertSpace"
);
}
#[test]
fn build_fails_when_config_path_missing() {
let nonexistent = std::path::Path::new("/nonexistent/runex/config.toml");
let result = AppContext::build(Some(nonexistent), Some("bash"), None, true);
assert!(
result.is_err(),
"build (non-graceful) must Err on missing config — \
which/expand depend on this to surface the error"
);
}
}
mod handler_outcomes {
use super::*;
use crate::domain::model::Config;
fn over_long_token() -> String {
"a".repeat(MAX_TOKEN_BYTES + 1)
}
fn never_exists(_: &str) -> bool {
false
}
#[test]
fn handle_which_over_long_token_returns_exit_code_1() {
let cfg = Config {
version: 1,
keybind: Default::default(),
precache: Default::default(),
abbr: Vec::new(),
};
let outcome = cmd::which::handle(
over_long_token(),
&cfg,
Shell::Bash,
&never_exists,
false,
false,
)
.expect("cmd::which::handle must return Ok, not Err, for an over-long token");
assert_eq!(outcome, CmdOutcome::ExitCode(1));
}
#[test]
fn handle_expand_over_long_token_returns_exit_code_1() {
let cfg = Config {
version: 1,
keybind: Default::default(),
precache: Default::default(),
abbr: Vec::new(),
};
let outcome = cmd::expand::handle(
over_long_token(),
&cfg,
Shell::Bash,
&never_exists,
false,
false,
)
.expect("cmd::expand::handle must return Ok, not Err, for an over-long token");
assert_eq!(outcome, CmdOutcome::ExitCode(1));
}
#[test]
fn validate_bin_rejects_empty() {
assert!(cmd::export::validate_bin("").is_err());
assert!(cmd::export::validate_bin(" ").is_err());
}
#[test]
fn validate_bin_rejects_oversize() {
let huge = "a".repeat(MAX_BIN_LEN + 1);
assert!(cmd::export::validate_bin(&huge).is_err());
}
#[test]
fn validate_bin_rejects_control_characters() {
assert!(cmd::export::validate_bin("ru\nnex").is_err()); assert!(cmd::export::validate_bin("ru\x07nex").is_err()); }
#[test]
fn validate_bin_rejects_non_ascii() {
assert!(cmd::export::validate_bin("rünex").is_err());
}
#[test]
fn validate_bin_accepts_normal_name() {
assert!(cmd::export::validate_bin("runex").is_ok());
assert!(cmd::export::validate_bin("/usr/local/bin/runex").is_ok());
}
}
}