#![cfg(windows)]
use std::path::{Path, PathBuf};
use std::process::Command;
use tempfile::tempdir;
fn runex_bin() -> &'static str {
env!("CARGO_BIN_EXE_runex")
}
fn git_bash() -> Option<PathBuf> {
let candidates = [
r"C:\Program Files\Git\bin\bash.exe",
r"C:\Program Files\Git\usr\bin\bash.exe",
r"C:\Program Files (x86)\Git\bin\bash.exe",
];
candidates.iter().map(PathBuf::from).find(|p| p.exists())
}
fn msys2_bash() -> Option<PathBuf> {
let candidates = [
r"C:\msys64\usr\bin\bash.exe",
r"C:\msys2\usr\bin\bash.exe",
r"C:\tools\msys64\usr\bin\bash.exe",
];
let env_paths = [
std::env::var("MSYS2_PATH_TYPE").ok(),
std::env::var("MSYS").ok(),
];
candidates
.iter()
.map(PathBuf::from)
.chain(env_paths.iter().flatten().map(|p| {
PathBuf::from(p)
.join("usr")
.join("bin")
.join("bash.exe")
}))
.find(|p| p.exists())
}
fn cygwin_bash() -> Option<PathBuf> {
let candidates = [
r"C:\cygwin64\bin\bash.exe",
r"C:\cygwin\bin\bash.exe",
r"C:\tools\cygwin\bin\bash.exe",
];
candidates.iter().map(PathBuf::from).find(|p| p.exists())
}
fn cygwin_family_bashes() -> Vec<(&'static str, PathBuf)> {
let mut out = Vec::new();
if let Some(p) = git_bash() {
out.push(("Git Bash", p));
}
if let Some(p) = msys2_bash() {
out.push(("MSYS2 bash", p));
}
if let Some(p) = cygwin_bash() {
out.push(("Cygwin bash", p));
}
out
}
fn build_cache(home: &Path) -> (PathBuf, String) {
let cfg_dir = home.join(".config").join("runex");
std::fs::create_dir_all(&cfg_dir).unwrap();
let cfg = cfg_dir.join("config.toml");
std::fs::write(
&cfg,
r#"version = 1
[keybind.trigger]
default = "space"
[[abbr]]
key = "gst"
expand = "git status"
[[abbr]]
key = "gca"
expand = "git commit -am '{}'"
[[abbr]]
key = "up{number}"
expand = "cd {number}"
number = "../"
"#,
)
.unwrap();
let bin = runex_bin().to_string();
let cache_path = home
.join(".cache")
.join("runex")
.join("integration.bash");
std::fs::create_dir_all(cache_path.parent().unwrap()).unwrap();
let out = Command::new(&bin)
.args([
"--config",
cfg.to_str().unwrap(),
"export",
"bash",
"--bin",
&bin,
])
.output()
.unwrap();
assert!(
out.status.success(),
"`runex export bash` must succeed\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr),
);
std::fs::write(&cache_path, &out.stdout).unwrap();
(cache_path, bin)
}
fn to_posix_path(p: &Path) -> String {
let s = p.to_string_lossy().replace('\\', "/");
if s.len() >= 2 && s.as_bytes()[1] == b':' {
let drive = s.as_bytes()[0].to_ascii_lowercase() as char;
format!("/{}{}", drive, &s[2..])
} else {
s
}
}
fn cache_without_interactive_guard(src: &Path, dst: &Path) {
let body = std::fs::read_to_string(src).unwrap();
let mut out_lines: Vec<&str> = Vec::with_capacity(body.lines().count());
let mut skipping = 0u8;
for line in body.lines() {
let trimmed = line.trim_start();
if skipping == 0 && trimmed.starts_with("case $- in") {
skipping = 4;
}
if skipping > 0 {
skipping -= 1;
continue;
}
out_lines.push(line);
}
std::fs::write(dst, out_lines.join("\n") + "\n").unwrap();
}
fn run_under_gitbash(bash: &Path, cache: &Path, ostype: &str, script: &str) -> String {
let dst = cache.with_extension("bash.test");
cache_without_interactive_guard(cache, &dst);
let wrapper = format!(
"export OSTYPE={ostype}\nsource '{cache}'\n{script}",
ostype = ostype,
cache = to_posix_path(&dst),
script = script,
);
let out = Command::new(bash)
.args(["--norc", "--noprofile", "-c", &wrapper])
.output()
.unwrap_or_else(|e| panic!("failed to invoke bash at {}: {e}", bash.display()));
assert!(
out.status.success(),
"bash script must succeed at {} (OSTYPE={ostype})\nscript:\n{script}\nstdout:\n{}\nstderr:\n{}",
bash.display(),
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr),
);
String::from_utf8(out.stdout).expect("bash stdout must be UTF-8")
}
fn run_with_label(label: &str, bash: &Path, cache: &Path, ostype: &str, script: &str) -> String {
let out = run_under_gitbash(bash, cache, ostype, script);
eprintln!("[{label}] OSTYPE={ostype} stdout:\n{out}");
out
}
fn for_each_cygwin_bash(test_name: &str, body: impl Fn(&str, &Path)) {
let bashes = cygwin_family_bashes();
if bashes.is_empty() {
eprintln!("{test_name}: skipping (no Git Bash or MSYS2 bash installed)");
return;
}
for (label, bash) in bashes {
body(label, &bash);
}
}
#[test]
fn generated_cache_passes_syntax_check_on_every_cygwin_bash() {
for_each_cygwin_bash("generated_cache_passes_syntax_check", |label, bash| {
let dir = tempdir().unwrap();
let (cache, _bin) = build_cache(dir.path());
let out = Command::new(bash)
.args(["-n", &to_posix_path(&cache)])
.output()
.unwrap();
assert!(
out.status.success(),
"[{label}] `bash -n` must accept the generated cache file\n\
stdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr),
);
});
}
#[test]
fn routes_to_bake_dispatcher_under_cygwin_family_ostypes() {
for_each_cygwin_bash("routes_to_bake_dispatcher", |label, bash| {
let dir = tempdir().unwrap();
let (cache, _bin) = build_cache(dir.path());
for ostype in ["msys", "cygwin", "msys2"] {
let out = run_with_label(
label,
bash,
&cache,
ostype,
"declare -f __runex_expand | grep -q __runex_cyg_expand && echo CYG || echo OTHER",
);
assert_eq!(
out.trim(),
"CYG",
"[{label}] OSTYPE={ostype} must route to bake dispatcher"
);
}
});
}
#[test]
fn routes_to_exec_dispatcher_under_non_cygwin_ostype() {
for_each_cygwin_bash("routes_to_exec_dispatcher", |label, bash| {
let dir = tempdir().unwrap();
let (cache, _bin) = build_cache(dir.path());
let out = run_with_label(
label,
bash,
&cache,
"linux-gnu",
"declare -f __runex_expand | grep -q __runex_exec_expand && echo EXEC || echo OTHER",
);
assert_eq!(
out.trim(),
"EXEC",
"[{label}] OSTYPE=linux-gnu must route to exec dispatcher"
);
});
}
#[test]
fn bake_expands_simple_abbreviation_on_every_cygwin_bash() {
for_each_cygwin_bash("bake_expands_simple", |label, bash| {
let dir = tempdir().unwrap();
let (cache, _bin) = build_cache(dir.path());
let out = run_with_label(
label,
bash,
&cache,
"msys",
r#"READLINE_LINE="gst"
READLINE_POINT=3
__runex_expand
echo "LINE=$READLINE_LINE"
echo "POINT=$READLINE_POINT""#,
);
assert!(
out.contains("LINE=git status"),
"[{label}] bake path must rewrite `gst` to `git status`; got:\n{out}"
);
assert!(
out.contains("POINT=11"),
"[{label}] bake path must place the cursor at end of `git status ` (11); got:\n{out}"
);
});
}
#[test]
fn bake_expands_number_pattern_on_every_cygwin_bash() {
for_each_cygwin_bash("bake_expands_number_pattern", |label, bash| {
let dir = tempdir().unwrap();
let (cache, _bin) = build_cache(dir.path());
let out = run_with_label(
label,
bash,
&cache,
"msys",
r#"READLINE_LINE="up3"
READLINE_POINT=3
__runex_expand
echo "LINE=$READLINE_LINE""#,
);
assert!(
out.contains("LINE=cd ../../../"),
"[{label}] bake path must render `up3` via the pattern table to `cd ../../../`; got:\n{out}"
);
});
}
#[test]
fn bake_strips_cursor_placeholder_on_every_cygwin_bash() {
for_each_cygwin_bash("bake_strips_cursor_placeholder", |label, bash| {
let dir = tempdir().unwrap();
let (cache, _bin) = build_cache(dir.path());
let out = run_with_label(
label,
bash,
&cache,
"msys",
r#"READLINE_LINE="gca"
READLINE_POINT=3
__runex_expand
echo "LINE=$READLINE_LINE"
echo "POINT=$READLINE_POINT""#,
);
assert!(
out.contains("LINE=git commit -am ''"),
"[{label}] bake path must drop the `{{}}` placeholder; got:\n{out}"
);
assert!(
out.contains("POINT=16"),
"[{label}] bake path must report cursor offset 16 (between the quotes); got:\n{out}"
);
});
}
#[test]
fn bake_self_inserts_unknown_token_on_every_cygwin_bash() {
for_each_cygwin_bash("bake_self_inserts_unknown_token", |label, bash| {
let dir = tempdir().unwrap();
let (cache, _bin) = build_cache(dir.path());
let out = run_with_label(
label,
bash,
&cache,
"msys",
r#"READLINE_LINE="zzzzz"
READLINE_POINT=5
__runex_expand
echo "LINE=$READLINE_LINE"
echo "POINT=$READLINE_POINT""#,
);
assert!(
out.contains("LINE=zzzzz "),
"[{label}] unknown token must self-insert a space; got:\n{out}"
);
assert!(
out.contains("POINT=6"),
"[{label}] unknown-token self-insert must advance cursor by 1; got:\n{out}"
);
});
}
#[test]
fn bake_skips_expansion_after_echo_on_every_cygwin_bash() {
for_each_cygwin_bash("bake_skips_expansion_after_echo", |label, bash| {
let dir = tempdir().unwrap();
let (cache, _bin) = build_cache(dir.path());
let out = run_with_label(
label,
bash,
&cache,
"msys",
r#"READLINE_LINE="echo gst"
READLINE_POINT=8
__runex_expand
echo "LINE=$READLINE_LINE""#,
);
assert!(
out.contains("LINE=echo gst "),
"[{label}] bake must NOT expand `gst` after `echo ` (issue #9 \
argument-position parity); got:\n{out}"
);
assert!(
!out.contains("git status"),
"[{label}] bake leaked the expanded form into argument position: \
{out}"
);
});
}
#[test]
fn bake_expands_after_sudo_on_every_cygwin_bash() {
for_each_cygwin_bash("bake_expands_after_sudo", |label, bash| {
let dir = tempdir().unwrap();
let (cache, _bin) = build_cache(dir.path());
let out = run_with_label(
label,
bash,
&cache,
"msys",
r#"READLINE_LINE="sudo gst"
READLINE_POINT=8
__runex_expand
echo "LINE=$READLINE_LINE""#,
);
assert!(
out.contains("LINE=sudo git status "),
"[{label}] bake must expand `gst` after `sudo ` (issue #9 sudo \
recursion); got:\n{out}"
);
});
}
#[test]
fn bake_expands_after_pipe_on_every_cygwin_bash() {
for_each_cygwin_bash("bake_expands_after_pipe", |label, bash| {
let dir = tempdir().unwrap();
let (cache, _bin) = build_cache(dir.path());
let out = run_with_label(
label,
bash,
&cache,
"msys",
r#"READLINE_LINE="cat foo | gst"
READLINE_POINT=13
__runex_expand
echo "LINE=$READLINE_LINE""#,
);
assert!(
out.contains("LINE=cat foo | git status "),
"[{label}] bake must expand `gst` after `|` (issue #9 pipeline \
command position); got:\n{out}"
);
});
}
#[test]
fn bake_expands_after_and_on_every_cygwin_bash() {
for_each_cygwin_bash("bake_expands_after_and", |label, bash| {
let dir = tempdir().unwrap();
let (cache, _bin) = build_cache(dir.path());
let out = run_with_label(
label,
bash,
&cache,
"msys",
r#"READLINE_LINE="true && gst"
READLINE_POINT=11
__runex_expand
echo "LINE=$READLINE_LINE""#,
);
assert!(
out.contains("LINE=true && git status "),
"[{label}] bake must expand `gst` after `&&` (issue #9 list \
command position); got:\n{out}"
);
});
}