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) {
IntegrationCheck::Ok {
name,
detail: format!(
"marker found in {}",
sanitize_for_display(&path.display().to_string())
),
}
} else {
IntegrationCheck::Missing {
name,
detail: format!(
"marker missing in {} — run `runex init {}`",
sanitize_for_display(&path.display().to_string()),
shell_short_name(shell)
),
}
}
}
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",
}
}
#[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\neval \"$(runex export 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:?}"
);
}
}