use std::path::{Path, PathBuf};
use crate::domain::sanitize::sanitize_for_display;
use crate::domain::shell::Shell;
use crate::infra::env::{rc_file_for, SystemHomeDir};
pub(crate) const RUNEX_INIT_MARKER: &str = "# runex-init";
const MAX_PROBE_FILE_BYTES: u64 = 10 * 1024 * 1024;
fn read_capped_regular_file(path: &Path) -> Option<String> {
use std::io::Read;
let lmeta = std::fs::symlink_metadata(path).ok()?;
if lmeta.file_type().is_symlink() {
return None;
}
#[cfg(unix)]
let mut file = {
use std::os::unix::fs::OpenOptionsExt;
std::fs::OpenOptions::new()
.read(true)
.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK)
.open(path)
.ok()?
};
#[cfg(not(unix))]
let mut file = std::fs::File::open(path).ok()?;
let meta = file.metadata().ok()?;
if !meta.is_file() {
return None;
}
if meta.len() > MAX_PROBE_FILE_BYTES {
return None;
}
let mut content = String::new();
file.read_to_string(&mut content).ok()?;
Some(content)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum IntegrationCheck {
Ok { name: String, detail: String },
Outdated {
name: String,
detail: String,
path: PathBuf,
},
Missing { name: String, detail: String },
Skipped { name: String, detail: String },
}
pub(crate) fn check_clink_lua_freshness(current_export: &str, search_paths: &[PathBuf]) -> IntegrationCheck {
for candidate in search_paths {
if let Some(on_disk) = read_capped_regular_file(candidate) {
return if normalize_newlines(&on_disk) == normalize_newlines(current_export) {
IntegrationCheck::Ok {
name: "integration:clink".into(),
detail: format!(
"up-to-date at {}",
sanitize_for_display(&candidate.display().to_string())
),
}
} else {
IntegrationCheck::Outdated {
name: "integration:clink".into(),
detail: format!(
"outdated at {} — re-run `runex init clink` to refresh",
sanitize_for_display(&candidate.display().to_string())
),
path: candidate.clone(),
}
};
}
}
IntegrationCheck::Skipped {
name: "integration:clink".into(),
detail: "no clink integration found — assuming clink is not in use".into(),
}
}
pub(crate) fn check_rcfile_marker(shell: Shell, rcfile_override: Option<&Path>) -> IntegrationCheck {
let name = format!("integration:{}", shell_short_name(shell));
let path = match rcfile_override {
Some(p) => p.to_path_buf(),
None => match rc_file_for(shell, &SystemHomeDir) {
Some(p) => p,
None => {
return IntegrationCheck::Skipped {
name,
detail: "no rcfile concept for this shell".into(),
}
}
},
};
if !path.exists() {
return IntegrationCheck::Skipped {
name,
detail: format!(
"rcfile not found at {} — assuming this shell is not in use",
sanitize_for_display(&path.display().to_string())
),
};
}
let content = match read_capped_regular_file(&path) {
Some(s) => s,
None => {
return IntegrationCheck::Missing {
name,
detail: format!(
"could not read {} — `runex init {}` may not have been run, or the file is not a regular file or exceeds the safety cap",
sanitize_for_display(&path.display().to_string()),
shell_short_name(shell)
),
};
}
};
if !content.contains(RUNEX_INIT_MARKER) {
return IntegrationCheck::Missing {
name,
detail: format!(
"marker missing in {} — run `runex init {}`",
sanitize_for_display(&path.display().to_string()),
shell_short_name(shell)
),
};
}
let cache_path = match crate::infra::integration_cache::cache_path(shell, &SystemHomeDir) {
Ok(Some(p)) => p,
_ => {
return IntegrationCheck::Ok {
name,
detail: format!(
"marker found in {}",
sanitize_for_display(&path.display().to_string())
),
};
}
};
let cache_str = cache_path.to_string_lossy().to_string();
let expected_line = crate::app::init::integration_line(shell, &cache_str);
match classify_rcfile_content(shell, &content, &expected_line) {
RcfileIntegrationStatus::Ok => IntegrationCheck::Ok {
name,
detail: format!(
"marker found in {}",
sanitize_for_display(&path.display().to_string())
),
},
RcfileIntegrationStatus::Outdated { has_expected, .. } => {
let rcfile_disp = sanitize_for_display(&path.display().to_string());
let cache_disp = sanitize_for_display(&cache_str);
let short = shell_short_name(shell);
let detail = if has_expected {
format!(
"marker found in {rcfile_disp} but a legacy `runex export {short}` line is still present alongside the cache source line — delete the old line and re-run `runex doctor`"
)
} else {
format!(
"marker found in {rcfile_disp} but the rcfile still calls `runex export {short}` directly instead of sourcing the integration cache at {cache_disp} — delete the old line and re-run `runex init {short}`"
)
};
IntegrationCheck::Outdated {
name,
detail,
path,
}
}
}
}
pub(crate) fn default_clink_lua_paths() -> Vec<PathBuf> {
default_clink_lua_paths_with(&crate::infra::env::SystemHomeDir)
}
pub(crate) fn default_clink_lua_paths_with(env: &dyn crate::infra::env::HomeDirResolver) -> Vec<PathBuf> {
let mut out = Vec::new();
if let Some(p) = env.env_var("RUNEX_CLINK_LUA_PATH") {
out.push(PathBuf::from(p));
}
if let Some(local) = env.env_var("LOCALAPPDATA") {
out.push(PathBuf::from(local).join("clink").join("runex.lua"));
}
if let Some(home) = env.home_dir() {
out.push(home.join(".local").join("share").join("clink").join("runex.lua"));
}
out
}
fn normalize_newlines(s: &str) -> String {
s.replace("\r\n", "\n")
}
fn shell_short_name(shell: Shell) -> &'static str {
match shell {
Shell::Bash => "bash",
Shell::Zsh => "zsh",
Shell::Pwsh => "pwsh",
Shell::Clink => "clink",
Shell::Nu => "nu",
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum RcfileIntegrationStatus {
Ok,
Outdated { has_expected: bool, has_legacy: bool },
}
pub(crate) fn classify_rcfile_content(
shell: Shell,
content: &str,
expected_line: &str,
) -> RcfileIntegrationStatus {
let has_expected = contains_expected_line(content, expected_line);
let has_legacy = contains_legacy_export_token(shell, content);
if has_legacy {
return RcfileIntegrationStatus::Outdated { has_expected, has_legacy: true };
}
RcfileIntegrationStatus::Ok
}
fn contains_expected_line(content: &str, expected_line: &str) -> bool {
let expected = expected_line.trim();
if expected.is_empty() {
return false;
}
normalize_newlines(content)
.lines()
.any(|l| l.trim() == expected)
}
fn contains_legacy_export_token(shell: Shell, content: &str) -> bool {
let token = legacy_export_token(shell);
normalize_newlines(content)
.lines()
.filter(|l| !l.trim_start().starts_with('#'))
.any(|l| l.contains(token))
}
fn legacy_export_token(shell: Shell) -> &'static str {
match shell {
Shell::Bash => "export bash",
Shell::Zsh => "export zsh",
Shell::Pwsh => "export pwsh",
Shell::Nu => "export nu",
Shell::Clink => "export clink",
}
}
pub(crate) fn check_cache_freshness(
shell: Shell,
env: &dyn crate::infra::env::HomeDirResolver,
) -> IntegrationCheck {
use crate::infra::integration_cache::{
cache_path, INTEGRATION_CACHE_MARKER, INTEGRATION_CACHE_VERSION,
};
let name = format!("integration:{}:cache", shell_short_name(shell));
let target = match cache_path(shell, env) {
Ok(Some(p)) => p,
Ok(None) => {
return IntegrationCheck::Skipped {
name,
detail: "cache layout does not apply to clink (uses lua autoloader instead)"
.into(),
};
}
Err(e) => {
return IntegrationCheck::Skipped {
name,
detail: format!("cache path could not be resolved: {e}"),
};
}
};
if !target.exists() {
return IntegrationCheck::Skipped {
name,
detail: format!(
"no cache at {} — run `runex init {}` to install",
sanitize_for_display(&target.display().to_string()),
shell_short_name(shell)
),
};
}
let content = match read_capped_regular_file(&target) {
Some(s) => s,
None => {
return IntegrationCheck::Missing {
name,
detail: format!(
"cache file at {} exists but could not be read (not a regular file, or exceeds the 10 MB safety cap)",
sanitize_for_display(&target.display().to_string())
),
};
}
};
let mut version_line: Option<&str> = None;
let mut bin_line: Option<&str> = None;
for line in content.lines().take(5) {
if let Some(v) = line.split_once(INTEGRATION_CACHE_MARKER) {
version_line = Some(v.1.trim());
} else if let Some(b) = line.find("runex-bin: ") {
bin_line = Some(&line[b + "runex-bin: ".len()..]);
}
}
let Some(version_str) = version_line else {
return IntegrationCheck::Outdated {
name,
detail: format!(
"cache at {} has no `runex-integration-version:` header — likely legacy \
`eval \"$(runex export {})\"` content. Re-run `runex init {}` to upgrade",
sanitize_for_display(&target.display().to_string()),
shell_short_name(shell),
shell_short_name(shell)
),
path: target,
};
};
let parsed: u32 = match version_str.parse() {
Ok(v) => v,
Err(_) => {
return IntegrationCheck::Outdated {
name,
detail: format!(
"cache at {} has malformed version header `{}` — re-run `runex init {}` to refresh",
sanitize_for_display(&target.display().to_string()),
sanitize_for_display(version_str),
shell_short_name(shell)
),
path: target,
};
}
};
if parsed != INTEGRATION_CACHE_VERSION {
return IntegrationCheck::Outdated {
name,
detail: format!(
"cache at {} is version {} but this runex expects version {} — re-run `runex init {}` to refresh",
sanitize_for_display(&target.display().to_string()),
parsed,
INTEGRATION_CACHE_VERSION,
shell_short_name(shell)
),
path: target,
};
}
if let Some(bin) = bin_line {
let bin = bin.trim();
if bin != "runex" && !std::path::Path::new(bin).exists() {
return IntegrationCheck::Outdated {
name,
detail: format!(
"cache at {} bakes a `runex-bin:` of {} which no longer exists. \
Re-run `runex init {}` to point the cache at the current binary",
sanitize_for_display(&target.display().to_string()),
sanitize_for_display(bin),
shell_short_name(shell)
),
path: target,
};
}
}
IntegrationCheck::Ok {
name,
detail: format!(
"cache up-to-date at {}",
sanitize_for_display(&target.display().to_string())
),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::TempDir;
fn write(path: &Path, content: &str) {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).unwrap();
}
let mut f = std::fs::File::create(path).unwrap();
f.write_all(content.as_bytes()).unwrap();
}
#[test]
fn clink_lua_match_returns_ok() {
let tmp = TempDir::new().unwrap();
let p = tmp.path().join("runex.lua");
write(&p, "-- runex shell integration for clink\nlocal RUNEX_BIN = \"r\"\n");
let r = check_clink_lua_freshness(
"-- runex shell integration for clink\nlocal RUNEX_BIN = \"r\"\n",
&[p.clone()],
);
assert!(
matches!(r, IntegrationCheck::Ok { .. }),
"expected Ok, got {r:?}"
);
}
#[test]
fn clink_lua_match_normalises_newlines() {
let tmp = TempDir::new().unwrap();
let p = tmp.path().join("runex.lua");
write(&p, "line1\r\nline2\r\n");
let r = check_clink_lua_freshness("line1\nline2\n", &[p.clone()]);
assert!(
matches!(r, IntegrationCheck::Ok { .. }),
"CRLF/LF mismatch must not flag drift; got {r:?}"
);
}
#[test]
fn clink_lua_drift_returns_outdated() {
let tmp = TempDir::new().unwrap();
let p = tmp.path().join("runex.lua");
write(&p, "old script\n");
let r = check_clink_lua_freshness("new script\n", &[p.clone()]);
match r {
IntegrationCheck::Outdated { path, .. } => assert_eq!(path, p),
other => panic!("expected Outdated, got {other:?}"),
}
}
#[test]
fn clink_lua_not_found_is_skipped() {
let tmp = TempDir::new().unwrap();
let p = tmp.path().join("does_not_exist.lua");
let r = check_clink_lua_freshness("anything\n", &[p]);
assert!(matches!(r, IntegrationCheck::Skipped { .. }), "got {r:?}");
}
#[test]
fn rcfile_marker_present_returns_ok() {
let tmp = TempDir::new().unwrap();
let p = tmp.path().join(".bashrc");
write(
&p,
"alias ll=ls\n\n# runex-init\n[ -r '/some/cache/integration.bash' ] && . '/some/cache/integration.bash'\n",
);
let r = check_rcfile_marker(Shell::Bash, Some(&p));
assert!(matches!(r, IntegrationCheck::Ok { .. }), "got {r:?}");
}
#[test]
fn rcfile_marker_absent_returns_missing() {
let tmp = TempDir::new().unwrap();
let p = tmp.path().join(".bashrc");
write(&p, "alias ll=ls\nexport PATH=...\n");
let r = check_rcfile_marker(Shell::Bash, Some(&p));
assert!(matches!(r, IntegrationCheck::Missing { .. }), "got {r:?}");
}
#[test]
fn rcfile_missing_returns_skipped() {
let tmp = TempDir::new().unwrap();
let p = tmp.path().join("nonexistent.zshrc");
let r = check_rcfile_marker(Shell::Zsh, Some(&p));
assert!(matches!(r, IntegrationCheck::Skipped { .. }), "got {r:?}");
}
#[test]
fn rcfile_check_for_clink_skips_when_no_override() {
let r = check_rcfile_marker(Shell::Clink, None);
assert!(
matches!(r, IntegrationCheck::Skipped { .. }),
"clink without override must skip; got {r:?}"
);
}
#[test]
fn rcfile_marker_check_oversized_file_is_missing() {
let tmp = TempDir::new().unwrap();
let p = tmp.path().join(".bashrc");
let big: Vec<u8> = vec![b'x'; 11 * 1024 * 1024];
std::fs::write(&p, &big).unwrap();
let r = check_rcfile_marker(Shell::Bash, Some(&p));
assert!(
matches!(r, IntegrationCheck::Missing { .. }),
"oversized rcfile must be reported as Missing — path.exists() \
but content can't be inspected; got {r:?}"
);
}
#[cfg(unix)]
#[test]
fn rcfile_marker_check_rejects_symlink() {
use std::os::unix::fs::symlink;
let tmp = TempDir::new().unwrap();
let real = tmp.path().join("real.bashrc");
write(&real, "alias ll=ls\n# runex-init\neval \"$(runex export bash)\"\n");
let link = tmp.path().join(".bashrc");
symlink(&real, &link).unwrap();
let r = check_rcfile_marker(Shell::Bash, Some(&link));
assert!(
matches!(r, IntegrationCheck::Missing { .. }),
"rcfile_marker must reject a symlink rcfile (return Missing); got {r:?}"
);
}
#[cfg(windows)]
#[test]
fn rcfile_marker_check_rejects_symlink_windows() {
use std::os::windows::fs::symlink_file;
let tmp = TempDir::new().unwrap();
let real = tmp.path().join("real.bashrc");
write(&real, "alias ll=ls\n# runex-init\neval \"$(runex export bash)\"\n");
let link = tmp.path().join(".bashrc");
if symlink_file(&real, &link).is_err() {
eprintln!("skipping: symlink creation requires Developer Mode / admin");
return;
}
let r = check_rcfile_marker(Shell::Bash, Some(&link));
assert!(
matches!(r, IntegrationCheck::Missing { .. }),
"rcfile_marker (Windows) must reject a symlink rcfile; got {r:?}"
);
}
#[cfg(unix)]
#[test]
fn clink_lua_freshness_rejects_symlink() {
use std::os::unix::fs::symlink;
let tmp = TempDir::new().unwrap();
let real = tmp.path().join("real.lua");
write(&real, "anything\n");
let link = tmp.path().join("runex.lua");
symlink(&real, &link).unwrap();
let r = check_clink_lua_freshness("anything\n", &[link]);
assert!(
matches!(r, IntegrationCheck::Skipped { .. }),
"clink_lua_freshness must reject a symlink lua (return Skipped); got {r:?}"
);
}
#[cfg(windows)]
#[test]
fn clink_lua_freshness_rejects_symlink_windows() {
use std::os::windows::fs::symlink_file;
let tmp = TempDir::new().unwrap();
let real = tmp.path().join("real.lua");
write(&real, "anything\n");
let link = tmp.path().join("runex.lua");
if symlink_file(&real, &link).is_err() {
eprintln!("skipping: symlink creation requires Developer Mode / admin");
return;
}
let r = check_clink_lua_freshness("anything\n", &[link]);
assert!(
matches!(r, IntegrationCheck::Skipped { .. }),
"clink_lua_freshness (Windows) must reject a symlink lua; got {r:?}"
);
}
#[test]
fn clink_lua_freshness_oversized_file_is_skipped() {
let tmp = TempDir::new().unwrap();
let p = tmp.path().join("runex.lua");
let big: Vec<u8> = vec![b'x'; 11 * 1024 * 1024];
std::fs::write(&p, &big).unwrap();
let r = check_clink_lua_freshness("anything\n", &[p]);
assert!(
matches!(r, IntegrationCheck::Skipped { .. }),
"oversized clink lua at the only candidate must be Skipped \
(drift undetectable, treat as no integration installed), got {r:?}"
);
}
#[test]
fn default_clink_lua_paths_with_includes_explicit_override() {
use crate::infra::env::EnvHomeDir;
use std::collections::HashMap;
let owned: HashMap<String, String> = HashMap::from([
("RUNEX_CLINK_LUA_PATH".to_string(), "/explicit/runex.lua".to_string()),
]);
let env = EnvHomeDir::new(move |n| owned.get(n).cloned());
let paths = default_clink_lua_paths_with(&env);
assert_eq!(
paths.first().map(|p| p.as_path()),
Some(std::path::Path::new("/explicit/runex.lua")),
"RUNEX_CLINK_LUA_PATH must be the first probed path"
);
}
#[test]
fn default_clink_lua_paths_with_uses_localappdata_when_set() {
use crate::infra::env::EnvHomeDir;
use std::collections::HashMap;
let owned: HashMap<String, String> = HashMap::from([
("LOCALAPPDATA".to_string(), r"C:\Users\test\AppData\Local".to_string()),
]);
let env = EnvHomeDir::new(move |n| owned.get(n).cloned());
let paths = default_clink_lua_paths_with(&env);
assert!(
paths.iter().any(|p| p.ends_with("clink/runex.lua") || p.ends_with(r"clink\runex.lua")),
"LOCALAPPDATA-derived path missing from {paths:?}"
);
}
#[test]
fn default_clink_lua_paths_with_includes_home_for_linux_fork() {
use crate::infra::env::EnvHomeDir;
use std::collections::HashMap;
let owned: HashMap<String, String> = HashMap::from([
("HOME".to_string(), "/test/home".to_string()),
]);
let env = EnvHomeDir::new(move |n| owned.get(n).cloned());
let paths = default_clink_lua_paths_with(&env);
assert!(
paths.iter().any(|p| p == std::path::Path::new("/test/home/.local/share/clink/runex.lua")),
"Linux clink fork path missing from {paths:?}"
);
}
#[test]
fn default_clink_lua_paths_with_empty_resolver_returns_empty() {
use crate::infra::env::EnvHomeDir;
use std::collections::HashMap;
let env = EnvHomeDir::new(|_| -> Option<String> {
let _: HashMap<String, String> = HashMap::new();
None
});
let paths = default_clink_lua_paths_with(&env);
assert!(
paths.is_empty(),
"no env vars set + no home → no paths probed; got {paths:?}"
);
}
mod cache_freshness {
use super::*;
use crate::infra::env::EnvHomeDir;
use crate::infra::integration_cache::{
cache_header, cache_path, INTEGRATION_CACHE_VERSION,
};
use std::collections::HashMap;
fn env_with(home: &std::path::Path) -> EnvHomeDir<impl Fn(&str) -> Option<String> + Send + Sync> {
let owned: HashMap<String, String> = HashMap::from([
("HOME".to_string(), home.to_string_lossy().into_owned()),
(
"XDG_CACHE_HOME".to_string(),
home.join(".cache").to_string_lossy().into_owned(),
),
]);
EnvHomeDir::new(move |n| owned.get(n).cloned())
}
#[test]
fn skipped_when_clink() {
let dir = TempDir::new().unwrap();
let env = env_with(dir.path());
let r = check_cache_freshness(Shell::Clink, &env);
assert!(matches!(r, IntegrationCheck::Skipped { .. }), "got {r:?}");
}
#[test]
fn skipped_when_cache_does_not_exist() {
let dir = TempDir::new().unwrap();
let env = env_with(dir.path());
let r = check_cache_freshness(Shell::Bash, &env);
assert!(
matches!(&r, IntegrationCheck::Skipped { detail, .. } if detail.contains("runex init")),
"got {r:?}"
);
}
#[test]
fn ok_when_version_matches_and_bin_exists() {
let dir = TempDir::new().unwrap();
let env = env_with(dir.path());
let cache = cache_path(Shell::Bash, &env).unwrap().unwrap();
std::fs::create_dir_all(cache.parent().unwrap()).unwrap();
let bin = std::env::current_exe().unwrap();
let bin_str = bin.to_string_lossy().into_owned();
let header = cache_header("#", &bin_str);
std::fs::write(&cache, format!("{header}# body placeholder\n")).unwrap();
let r = check_cache_freshness(Shell::Bash, &env);
assert!(matches!(r, IntegrationCheck::Ok { .. }), "got {r:?}");
}
#[test]
fn outdated_when_version_mismatch() {
let dir = TempDir::new().unwrap();
let env = env_with(dir.path());
let cache = cache_path(Shell::Bash, &env).unwrap().unwrap();
std::fs::create_dir_all(cache.parent().unwrap()).unwrap();
let bin = std::env::current_exe().unwrap();
let header = format!(
"# runex-integration-version: {}\n# runex-bin: {}\n# Generated.\n",
INTEGRATION_CACHE_VERSION + 99,
bin.display()
);
std::fs::write(&cache, format!("{header}# body\n")).unwrap();
let r = check_cache_freshness(Shell::Bash, &env);
match r {
IntegrationCheck::Outdated { detail, .. } => {
assert!(detail.contains("version"), "expected version mismatch detail, got {detail}");
}
_ => panic!("expected Outdated, got {r:?}"),
}
}
#[test]
fn outdated_when_legacy_no_header() {
let dir = TempDir::new().unwrap();
let env = env_with(dir.path());
let cache = cache_path(Shell::Bash, &env).unwrap().unwrap();
std::fs::create_dir_all(cache.parent().unwrap()).unwrap();
std::fs::write(&cache, "# runex shell integration for bash\n__runex_expand() {}\n").unwrap();
let r = check_cache_freshness(Shell::Bash, &env);
match r {
IntegrationCheck::Outdated { detail, .. } => {
assert!(detail.contains("legacy") || detail.contains("no `runex-integration-version:` header"),
"expected legacy-cache hint, got {detail}");
}
_ => panic!("expected Outdated, got {r:?}"),
}
}
#[test]
fn outdated_when_baked_bin_does_not_exist() {
let dir = TempDir::new().unwrap();
let env = env_with(dir.path());
let cache = cache_path(Shell::Bash, &env).unwrap().unwrap();
std::fs::create_dir_all(cache.parent().unwrap()).unwrap();
let header = cache_header("#", "/nonexistent/path/runex");
std::fs::write(&cache, format!("{header}# body\n")).unwrap();
let r = check_cache_freshness(Shell::Bash, &env);
match r {
IntegrationCheck::Outdated { detail, .. } => {
assert!(detail.contains("no longer exists"),
"expected missing-bin hint, got {detail}");
}
_ => panic!("expected Outdated, got {r:?}"),
}
}
#[test]
fn ok_when_baked_bin_is_bare_runex() {
let dir = TempDir::new().unwrap();
let env = env_with(dir.path());
let cache = cache_path(Shell::Bash, &env).unwrap().unwrap();
std::fs::create_dir_all(cache.parent().unwrap()).unwrap();
let header = cache_header("#", "runex");
std::fs::write(&cache, format!("{header}# body\n")).unwrap();
let r = check_cache_freshness(Shell::Bash, &env);
assert!(matches!(r, IntegrationCheck::Ok { .. }), "got {r:?}");
}
}
mod classify_rcfile_content_tests {
use super::*;
fn bash_expected() -> &'static str {
"[ -r '/cache/integration.bash' ] && . '/cache/integration.bash'"
}
#[test]
fn ok_when_only_expected_line_present() {
let exp = bash_expected();
let content = format!("# runex-init\n{exp}\n");
assert_eq!(
classify_rcfile_content(Shell::Bash, &content, exp),
RcfileIntegrationStatus::Ok
);
}
#[test]
fn outdated_when_only_legacy_eval_present() {
let exp = bash_expected();
let content = "# runex-init\neval \"$(runex export bash)\"\n";
assert_eq!(
classify_rcfile_content(Shell::Bash, content, exp),
RcfileIntegrationStatus::Outdated { has_expected: false, has_legacy: true },
);
}
#[test]
fn outdated_when_both_legacy_and_expected_present() {
let exp = bash_expected();
let content = format!("# runex-init\n{exp}\neval \"$(runex export bash)\"\n");
assert_eq!(
classify_rcfile_content(Shell::Bash, &content, exp),
RcfileIntegrationStatus::Outdated { has_expected: true, has_legacy: true },
);
}
#[test]
fn ok_when_neither_present_user_customised() {
let exp = bash_expected();
let content = "# runex-init\n# user wrote their own thing here\n";
assert_eq!(
classify_rcfile_content(Shell::Bash, content, exp),
RcfileIntegrationStatus::Ok,
);
}
#[test]
fn outdated_pwsh_legacy_invoke_expression() {
let exp = "if (Test-Path '/cache/integration.ps1') { . '/cache/integration.ps1' }";
let content = "# runex-init\nInvoke-Expression (& 'runex' export pwsh | Out-String)\n";
assert_eq!(
classify_rcfile_content(Shell::Pwsh, content, exp),
RcfileIntegrationStatus::Outdated { has_expected: false, has_legacy: true },
);
}
#[test]
fn outdated_zsh_legacy_with_bin_arg() {
let exp = "[ -r '/cache/integration.zsh' ] && . '/cache/integration.zsh'";
let content = "# runex-init\neval \"$(runex export zsh --bin '/home/u/.cargo/bin/runex')\"\n";
assert_eq!(
classify_rcfile_content(Shell::Zsh, content, exp),
RcfileIntegrationStatus::Outdated { has_expected: false, has_legacy: true },
);
}
#[test]
fn outdated_nu_legacy() {
let exp = "if ('/cache/integration.nu' | path exists) { source '/cache/integration.nu' }";
let content = "# runex-init\nsource ($'(runex export nu)' | save -f /tmp/x.nu)\n";
assert_eq!(
classify_rcfile_content(Shell::Nu, content, exp),
RcfileIntegrationStatus::Outdated { has_expected: false, has_legacy: true },
);
}
#[test]
fn legacy_token_is_scoped_per_shell() {
let exp = bash_expected();
let content = "# runex-init\n# note: pwsh users still run `runex export pwsh` themselves\n";
assert_eq!(
classify_rcfile_content(Shell::Bash, content, exp),
RcfileIntegrationStatus::Ok,
);
}
#[test]
fn expected_line_match_ignores_surrounding_whitespace() {
let exp = bash_expected();
let content = format!("# runex-init\n\t{exp} \n");
assert_eq!(
classify_rcfile_content(Shell::Bash, &content, exp),
RcfileIntegrationStatus::Ok,
);
}
#[test]
fn expected_line_match_normalises_crlf() {
let exp = bash_expected();
let content = format!("# runex-init\r\n{exp}\r\n");
assert_eq!(
classify_rcfile_content(Shell::Bash, &content, exp),
RcfileIntegrationStatus::Ok,
);
}
}
mod rcfile_marker_outdated {
use super::*;
#[test]
fn bash_legacy_line_is_outdated() {
let tmp = TempDir::new().unwrap();
let p = tmp.path().join(".bashrc");
write(&p, "# runex-init\neval \"$(runex export bash)\"\n");
let r = check_rcfile_marker(Shell::Bash, Some(&p));
match r {
IntegrationCheck::Outdated { name, detail, .. } => {
assert_eq!(name, "integration:bash");
assert!(detail.contains("runex export bash"), "detail: {detail}");
assert!(detail.contains("runex init bash"), "detail: {detail}");
}
other => panic!("expected Outdated, got {other:?}"),
}
}
#[test]
fn pwsh_legacy_invoke_expression_is_outdated() {
let tmp = TempDir::new().unwrap();
let p = tmp.path().join("Microsoft.PowerShell_profile.ps1");
write(
&p,
"# runex-init\nInvoke-Expression (& 'runex' export pwsh | Out-String)\n",
);
let r = check_rcfile_marker(Shell::Pwsh, Some(&p));
match r {
IntegrationCheck::Outdated { name, detail, .. } => {
assert_eq!(name, "integration:pwsh");
assert!(detail.contains("runex export pwsh"), "detail: {detail}");
assert!(detail.contains("runex init pwsh"), "detail: {detail}");
}
other => panic!("expected Outdated, got {other:?}"),
}
}
#[test]
fn coexistence_legacy_plus_expected_is_outdated() {
let tmp = TempDir::new().unwrap();
let p = tmp.path().join(".bashrc");
write(
&p,
"# runex-init\n[ -r '/some/cache/integration.bash' ] && . '/some/cache/integration.bash'\neval \"$(runex export bash)\"\n",
);
let r = check_rcfile_marker(Shell::Bash, Some(&p));
assert!(matches!(r, IntegrationCheck::Outdated { .. }), "got {r:?}");
}
}
}