use crate::domain::shell::{bash_quote_string, lua_quote_string, pwsh_quote_string, Shell};
pub(crate) fn default_config_content() -> &'static str {
r#"version = 1
[keybind.trigger]
default = "space"
# Sample abbreviation. After restarting your shell, type `gst<Space>`
# and it will expand to `git status `.
[[abbr]]
key = "gst"
expand = "git status"
# Add your own below. For more recipes (per-shell commands, fallback
# chains, cursor placeholders, etc.) see:
# https://github.com/ShortArrow/runex/blob/main/docs/recipes.md
"#
}
fn sanitize_nu_cache_path_literal(s: &str) -> String {
s.chars()
.filter(|&c| {
!matches!(c, '\'' | '\n' | '\r' | '\t')
&& !(c as u32 <= 0x1F)
&& c as u32 != 0x7F
&& !crate::domain::sanitize::is_deceptive_unicode(c)
})
.collect()
}
pub(crate) fn integration_line(shell: Shell, cache_path: &str) -> String {
match shell {
Shell::Bash => format!(
"[ -r {p} ] && . {p}",
p = bash_quote_string(cache_path)
),
Shell::Zsh => format!(
"[ -r {p} ] && . {p}",
p = bash_quote_string(cache_path)
),
Shell::Pwsh => format!(
"if (Test-Path {p}) {{ . {p} }}",
p = pwsh_quote_string(cache_path)
),
Shell::Nu => {
let safe = sanitize_nu_cache_path_literal(cache_path);
format!("if ('{safe}' | path exists) {{ source '{safe}' }}")
}
Shell::Clink => format!(
"-- runex clink integration is auto-loaded from {}",
lua_quote_string(cache_path)
),
}
}
pub(crate) fn clink_lua_install_path_with_resolver(
env: &dyn crate::infra::env::HomeDirResolver,
) -> std::path::PathBuf {
if let Some(p) = env.env_var("RUNEX_CLINK_LUA_PATH") {
return std::path::PathBuf::from(p);
}
if let Some(local) = env.env_var("LOCALAPPDATA") {
return std::path::PathBuf::from(local).join("clink").join("runex.lua");
}
if let Some(home) = env.home_dir() {
return home.join(".local").join("share").join("clink").join("runex.lua");
}
std::path::PathBuf::from("runex.lua")
}
pub(crate) fn next_steps_message(shell: Shell, rc_path: Option<&std::path::Path>) -> String {
let reload = match shell {
Shell::Bash | Shell::Zsh => match rc_path {
Some(p) => format!("Reload your shell: `source {}` (or `exec $SHELL`)", p.display()),
None => "Reload your shell: `exec $SHELL`".to_string(),
},
Shell::Pwsh => match rc_path {
Some(p) => format!("Reload your profile: `. $PROFILE` (resolves to {})", p.display()),
None => "Reload your profile: `. $PROFILE`".to_string(),
},
Shell::Nu => "Reload nushell: open a new shell (or run `exec nu`)".to_string(),
Shell::Clink => "Open a new cmd window — clink loads the lua at startup.".to_string(),
};
format!(
"Next steps:\n 1. {reload}\n 2. Try `gst<Space>` — it should expand to `git status `.\n 3. Add your own abbreviations: see https://github.com/ShortArrow/runex/blob/main/docs/recipes.md\n 4. Verify any time with: `runex doctor`"
)
}
#[cfg(test)]
mod tests {
use super::*;
mod integration_line {
use super::*;
#[test]
fn default_config_content_has_version() {
assert!(default_config_content().contains("version = 1"));
}
#[test]
fn default_config_content_includes_default_trigger() {
let s = default_config_content();
assert!(s.contains("[keybind.trigger]"), "missing [keybind.trigger]: {s}");
assert!(s.contains("default = \"space\""), "missing default trigger: {s}");
}
#[test]
fn default_config_content_includes_sample_abbr_gst() {
let s = default_config_content();
assert!(s.contains("key = \"gst\""), "missing gst sample: {s}");
assert!(s.contains("expand = \"git status\""), "missing gst expand: {s}");
}
#[test]
fn next_steps_for_bash_mentions_source_command() {
let msg = next_steps_message(Shell::Bash, Some(std::path::Path::new("/home/u/.bashrc")));
assert!(msg.contains("source /home/u/.bashrc") || msg.contains("exec"),
"bash next_steps must explain how to reload: {msg}");
assert!(msg.contains("runex doctor"), "must suggest doctor: {msg}");
assert!(msg.contains("recipes"), "must point at recipes: {msg}");
}
#[test]
fn next_steps_for_clink_mentions_new_cmd_window() {
let msg = next_steps_message(Shell::Clink, None);
assert!(msg.to_lowercase().contains("cmd"),
"clink next_steps must mention opening a new cmd window: {msg}");
assert!(msg.contains("runex doctor"), "must suggest doctor: {msg}");
}
#[test]
fn next_steps_for_pwsh_mentions_dot_profile() {
let msg = next_steps_message(
Shell::Pwsh,
Some(std::path::Path::new("/u/Microsoft.PowerShell_profile.ps1")),
);
assert!(msg.contains("$PROFILE") || msg.contains(". /"),
"pwsh next_steps must explain reload: {msg}");
}
#[test]
fn clink_install_path_honors_env_override() {
use crate::infra::env::EnvHomeDir;
use std::collections::HashMap;
let owned: HashMap<String, String> = HashMap::from([(
"RUNEX_CLINK_LUA_PATH".to_string(),
"/tmp/runex_test_clink.lua".to_string(),
)]);
let env = EnvHomeDir::new(move |n| owned.get(n).cloned());
let p = clink_lua_install_path_with_resolver(&env);
assert_eq!(p, std::path::PathBuf::from("/tmp/runex_test_clink.lua"));
}
#[test]
fn clink_install_path_uses_localappdata_when_set() {
use crate::infra::env::EnvHomeDir;
use std::collections::HashMap;
let owned: HashMap<String, String> = HashMap::from([(
"LOCALAPPDATA".to_string(),
"/tmp/local_appdata_test".to_string(),
)]);
let env = EnvHomeDir::new(move |n| owned.get(n).cloned());
let p = clink_lua_install_path_with_resolver(&env);
assert_eq!(
p,
std::path::PathBuf::from("/tmp/local_appdata_test/clink/runex.lua")
);
}
#[test]
fn clink_install_path_falls_back_to_home() {
use crate::infra::env::EnvHomeDir;
use std::collections::HashMap;
let owned: HashMap<String, String> =
HashMap::from([("HOME".to_string(), "/home/user".to_string())]);
let env = EnvHomeDir::new(move |n| owned.get(n).cloned());
let p = clink_lua_install_path_with_resolver(&env);
assert_eq!(
p,
std::path::PathBuf::from("/home/user/.local/share/clink/runex.lua")
);
}
#[test]
fn clink_install_path_treats_empty_env_as_unset() {
use crate::infra::env::EnvHomeDir;
use std::collections::HashMap;
let owned: HashMap<String, String> = HashMap::from([
("RUNEX_CLINK_LUA_PATH".to_string(), String::new()),
("LOCALAPPDATA".to_string(), String::new()),
("HOME".to_string(), "/home/u".to_string()),
]);
let env = EnvHomeDir::new(move |n| owned.get(n).cloned());
let p = clink_lua_install_path_with_resolver(&env);
assert!(p.starts_with("/home/u"), "expected home fallback, got {p:?}");
}
#[test]
fn integration_line_bash_sources_cache_path() {
let line = integration_line(Shell::Bash, "/home/u/.cache/runex/integration.bash");
assert_eq!(
line,
r"[ -r '/home/u/.cache/runex/integration.bash' ] && . '/home/u/.cache/runex/integration.bash'"
);
}
#[test]
fn integration_line_zsh_sources_cache_path() {
let line = integration_line(Shell::Zsh, "/home/u/.cache/runex/integration.zsh");
assert!(
line.starts_with("[ -r ") && line.contains("integration.zsh"),
"zsh line must guard with `[ -r ...]` and source the cache: {line}"
);
}
#[test]
fn integration_line_pwsh_uses_test_path() {
let line = integration_line(
Shell::Pwsh,
r"C:\Users\u\.cache\runex\integration.ps1",
);
assert!(line.contains("Test-Path"), "pwsh line must guard with Test-Path: {line}");
assert!(line.contains("integration.ps1"), "pwsh line must reference cache file: {line}");
}
#[test]
fn integration_line_nu_uses_path_exists_guard() {
let line = integration_line(
Shell::Nu,
"/home/u/.cache/runex/integration.nu",
);
assert!(
line.contains("path exists") && line.contains("source"),
"nu line must check path exists and source the cache: {line}"
);
}
#[test]
fn integration_line_nu_does_not_use_external_command_prefix() {
let line = integration_line(
Shell::Nu,
"/home/u/.cache/runex/integration.nu",
);
assert!(
!line.contains("^\""),
"nu line must NOT prefix the path with ^ (external command marker): {line}",
);
}
#[test]
fn integration_line_nu_uses_single_quoted_literal() {
let line = integration_line(
Shell::Nu,
"/home/u/.cache/runex/integration.nu",
);
let expected_quoted = "'/home/u/.cache/runex/integration.nu'";
assert!(
line.contains(expected_quoted),
"nu line must contain single-quoted literal {expected_quoted}: {line}"
);
}
#[test]
fn integration_line_bash_escapes_single_quote_in_path() {
let line = integration_line(Shell::Bash, "/odd/path'with-quote/integration.bash");
assert!(
!line.contains("/odd/path'with-quote/"),
"raw single quote leaked into bash line: {line}"
);
assert!(
line.contains(r"path'\''with-quote"),
"expected bash-escaped form: {line}"
);
}
#[test]
fn integration_line_zsh_escapes_single_quote_in_path() {
let line = integration_line(Shell::Zsh, "/odd/path'with-quote/integration.zsh");
assert!(
line.contains(r"path'\''with-quote"),
"expected zsh-escaped form: {line}"
);
}
#[test]
fn integration_line_pwsh_escapes_single_quote_in_path() {
let line = integration_line(Shell::Pwsh, "C:\\odd\\path'with-quote\\integration.ps1");
assert!(
!line.contains("path'with-quote"),
"raw single quote leaked into pwsh line: {line}"
);
assert!(
line.contains("path''with-quote"),
"expected pwsh-doubled-quote form: {line}"
);
}
#[test]
fn integration_line_nu_passes_double_quote_through_literal() {
let line = integration_line(Shell::Nu, "/odd/path\"with-quote/integration.nu");
assert!(
line.contains("'/odd/path\"with-quote/integration.nu'"),
"nu single-quoted literal must contain the path verbatim with the double quote intact: {line}"
);
}
#[test]
fn integration_line_nu_passes_backslashes_through_literal() {
let line = integration_line(Shell::Nu, "C:\\Users\\u\\AppData\\Local\\runex\\integration.nu");
assert!(
line.contains("'C:\\Users\\u\\AppData\\Local\\runex\\integration.nu'"),
"nu single-quoted literal must contain backslashes verbatim: {line}"
);
}
#[test]
fn integration_line_nu_drops_single_quote_in_path() {
let line = integration_line(Shell::Nu, "/odd/path'with-quote/integration.nu");
assert!(
!line.contains("path'with"),
"single quote inside the literal would break out and corrupt env.nu: {line}"
);
assert!(
line.contains("'/odd/pathwith-quote/integration.nu'"),
"expected single-quoted literal with the ' stripped: {line}"
);
}
#[test]
fn integration_line_clink_comment_is_lua_quoted() {
let line = integration_line(Shell::Clink, "/path/to/runex.lua");
assert!(line.starts_with("--"), "clink line must start with `--` comment marker: {line}");
assert!(
line.contains("\"/path/to/runex.lua\""),
"clink line must lua-quote the path: {line}"
);
}
#[test]
fn integration_line_clink_newline_in_path_does_not_inject() {
let line = integration_line(Shell::Clink, "/path/runex.lua\nos.execute('evil')");
assert!(
!line.contains('\n'),
"literal newline leaked into clink line: {line:?}"
);
assert!(
line.contains("\\n"),
"expected `\\n` escape: {line:?}"
);
}
}
mod integration_line_nu_security {
use super::*;
#[test]
fn drops_rlo_in_cache_path() {
let line = integration_line(Shell::Nu, "/home/user\u{202E}/.cache/runex/integration.nu");
assert!(
!line.contains('\u{202E}'),
"integration_line nu must drop U+202E (RLO): {line:?}"
);
}
#[test]
fn drops_bom_in_cache_path() {
let line = integration_line(Shell::Nu, "/home/user\u{FEFF}/.cache/runex/integration.nu");
assert!(
!line.contains('\u{FEFF}'),
"integration_line nu must drop U+FEFF (BOM): {line:?}"
);
}
#[test]
fn drops_zwsp_in_cache_path() {
let line = integration_line(Shell::Nu, "/home/user\u{200B}/.cache/runex/integration.nu");
assert!(
!line.contains('\u{200B}'),
"integration_line nu must drop U+200B (ZWSP): {line:?}"
);
}
#[test]
fn preserves_non_deceptive_unicode_in_cache_path() {
let line = integration_line(Shell::Nu, "/home/ユーザー/.cache/runex/integration.nu");
assert!(
line.contains("ユーザー"),
"integration_line nu must preserve non-deceptive Unicode: {line:?}"
);
}
#[test]
fn dollar_sign_passes_through_literal() {
let line = integration_line(Shell::Nu, "/home/$USER/.cache/runex/integration.nu");
assert!(
line.contains("'/home/$USER/.cache/runex/integration.nu'"),
"single-quoted literal must contain $ verbatim: {line:?}"
);
}
#[test]
fn drops_remaining_c0_controls_in_cache_path() {
let dangerous_c0: &[char] = &[
'\x01', '\x02', '\x03', '\x04', '\x05', '\x06', '\x07',
'\x08', '\x0b', '\x0c', '\x0e', '\x0f',
'\x10', '\x11', '\x12', '\x13', '\x14', '\x15', '\x16', '\x17',
'\x18', '\x19', '\x1a', '\x1b',
'\x1c', '\x1d', '\x1e', '\x1f',
];
for &ch in dangerous_c0 {
let path = format!("/home/user{}{}rest", ch, ch);
let line = integration_line(Shell::Nu, &path);
assert!(
!line.contains(ch),
"integration_line nu must drop C0 control U+{:04X}: {line:?}",
ch as u32
);
}
}
#[test]
fn drops_del_in_cache_path() {
let line = integration_line(Shell::Nu, "/home/user\x7Fevil/runex.nu");
assert!(
!line.contains('\x7F'),
"integration_line nu must drop DEL: {line:?}"
);
}
#[test]
fn newline_in_cache_path_does_not_inject() {
let line = integration_line(Shell::Nu, "/home/user/.cache\nsource /tmp/evil.nu");
assert!(
!line.contains('\n'),
"newline in cache_path must not appear raw in the rcfile line: {line:?}"
);
assert!(
!line.split('\n').any(|chunk| chunk.trim_start().starts_with("source ")),
"evil `source ...` line must not appear as a standalone statement: {line:?}"
);
}
}
mod integration_line_clink_security {
use super::*;
#[test]
fn drops_rlo_in_path() {
let line = integration_line(Shell::Clink, "/path\u{202E}/runex.lua");
assert!(!line.contains('\u{202E}'), "clink line must drop U+202E: {line:?}");
}
#[test]
fn drops_bom_in_path() {
let line = integration_line(Shell::Clink, "/path\u{FEFF}/runex.lua");
assert!(!line.contains('\u{FEFF}'), "clink line must drop U+FEFF: {line:?}");
}
}
}