use std::io::{self, Write};
use std::path::{Path, PathBuf};
use crate::domain::shell::Shell;
use crate::infra::env::{xdg_cache_home_with, HomeDirResolver};
pub(crate) const INTEGRATION_CACHE_VERSION: u32 = 1;
pub(crate) const INTEGRATION_CACHE_MARKER: &str = "runex-integration-version:";
#[derive(Debug, thiserror::Error)]
pub(crate) enum CacheError {
#[error("cannot resolve cache directory (set $XDG_CACHE_HOME or $HOME)")]
NoCacheDir,
#[error("refusing to write through a symlink at {path}")]
SymlinkAtTarget { path: PathBuf },
#[error("cache path has no parent directory: {path}")]
NoParent { path: PathBuf },
#[error("cache path has no file name: {path}")]
NoFileName { path: PathBuf },
#[error("OS error writing cache at {path}: {source}")]
Io {
path: PathBuf,
#[source]
source: io::Error,
},
}
pub(crate) fn cache_path(
shell: Shell,
env: &dyn HomeDirResolver,
) -> Result<Option<PathBuf>, CacheError> {
let ext = match shell {
Shell::Bash => "bash",
Shell::Zsh => "zsh",
Shell::Pwsh => "ps1",
Shell::Nu => "nu",
Shell::Clink => return Ok(None),
};
let cache_root = xdg_cache_home_with(env).ok_or(CacheError::NoCacheDir)?;
Ok(Some(cache_root.join("runex").join(format!("integration.{ext}"))))
}
pub(crate) fn write_cache_file(path: &Path, contents: &str) -> Result<(), CacheError> {
let parent = path
.parent()
.filter(|p| !p.as_os_str().is_empty())
.ok_or_else(|| CacheError::NoParent { path: path.to_path_buf() })?;
std::fs::create_dir_all(parent).map_err(|e| CacheError::Io {
path: parent.to_path_buf(),
source: e,
})?;
if let Ok(meta) = std::fs::symlink_metadata(path) {
if meta.file_type().is_symlink() {
return Err(CacheError::SymlinkAtTarget {
path: path.to_path_buf(),
});
}
}
let file_name = path
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| CacheError::NoFileName { path: path.to_path_buf() })?;
let tmp_path = parent.join(format!(".{file_name}.runex.tmp"));
let _ = std::fs::remove_file(&tmp_path);
let mut tmp_opts = std::fs::OpenOptions::new();
tmp_opts.create_new(true).write(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
tmp_opts.custom_flags(libc::O_NOFOLLOW);
}
let mut tmp_file = tmp_opts.open(&tmp_path).map_err(|e| CacheError::Io {
path: tmp_path.clone(),
source: e,
})?;
tmp_file
.write_all(contents.as_bytes())
.map_err(|e| CacheError::Io {
path: tmp_path.clone(),
source: e,
})?;
tmp_file.sync_all().map_err(|e| CacheError::Io {
path: tmp_path.clone(),
source: e,
})?;
drop(tmp_file);
if let Err(e) = std::fs::rename(&tmp_path, path) {
let _ = std::fs::remove_file(&tmp_path);
return Err(CacheError::Io {
path: path.to_path_buf(),
source: e,
});
}
Ok(())
}
pub(crate) fn cache_header(comment_prefix: &str, bin: &str) -> String {
format!(
"{cp} {marker} {ver}\n\
{cp} runex-bin: {bin}\n\
{cp} Generated by `runex init <shell>`; do not edit.\n",
cp = comment_prefix,
marker = INTEGRATION_CACHE_MARKER,
ver = INTEGRATION_CACHE_VERSION,
bin = bin
)
}
pub(crate) fn comment_prefix_for(shell: Shell) -> &'static str {
match shell {
Shell::Bash | Shell::Zsh | Shell::Pwsh | Shell::Nu => "#",
Shell::Clink => "--",
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::infra::env::EnvHomeDir;
use std::collections::HashMap;
use tempfile::tempdir;
fn env_with(map: HashMap<&'static str, String>) -> EnvHomeDir<impl Fn(&str) -> Option<String> + Send + Sync> {
let owned: HashMap<String, String> = map.into_iter().map(|(k, v)| (k.to_string(), v)).collect();
EnvHomeDir::new(move |n| owned.get(n).cloned())
}
#[test]
fn cache_path_returns_none_for_clink() {
let env = env_with(HashMap::from([("HOME", "/test/home".to_string())]));
assert_eq!(cache_path(Shell::Clink, &env).unwrap(), None);
}
#[test]
fn cache_path_uses_xdg_cache_home_when_set() {
let env = env_with(HashMap::from([
("XDG_CACHE_HOME", "/explicit/cache".to_string()),
("HOME", "/test/home".to_string()),
]));
let p = cache_path(Shell::Bash, &env).unwrap().unwrap();
assert_eq!(p, PathBuf::from("/explicit/cache/runex/integration.bash"));
}
#[test]
#[cfg(not(windows))]
fn cache_path_falls_back_to_home_dotcache_on_unix() {
let env = env_with(HashMap::from([("HOME", "/test/home".to_string())]));
for (shell, ext) in [(Shell::Bash, "bash"), (Shell::Zsh, "zsh"), (Shell::Pwsh, "ps1"), (Shell::Nu, "nu")] {
let p = cache_path(shell, &env).unwrap().unwrap();
assert_eq!(p, PathBuf::from(format!("/test/home/.cache/runex/integration.{ext}")));
}
}
#[test]
fn cache_path_returns_no_cache_dir_when_no_signal() {
let env = env_with(HashMap::new());
assert!(matches!(
cache_path(Shell::Bash, &env),
Err(CacheError::NoCacheDir)
));
}
#[test]
fn write_cache_file_creates_parent_dirs() {
let dir = tempdir().unwrap();
let target = dir.path().join("nested/deep/integration.bash");
write_cache_file(&target, "hello").unwrap();
assert_eq!(std::fs::read_to_string(&target).unwrap(), "hello");
}
#[test]
fn write_cache_file_replaces_existing_atomically() {
let dir = tempdir().unwrap();
let target = dir.path().join("integration.bash");
std::fs::write(&target, "old contents").unwrap();
write_cache_file(&target, "new contents").unwrap();
assert_eq!(std::fs::read_to_string(&target).unwrap(), "new contents");
let entries: Vec<_> = std::fs::read_dir(dir.path())
.unwrap()
.filter_map(Result::ok)
.collect();
assert_eq!(
entries.len(),
1,
"atomic rename must not leave a sibling temp behind: {entries:?}"
);
}
#[test]
#[cfg(unix)]
fn write_cache_file_rejects_symlink_target() {
let dir = tempdir().unwrap();
let real = dir.path().join("real.txt");
std::fs::write(&real, "real").unwrap();
let link = dir.path().join("integration.bash");
std::os::unix::fs::symlink(&real, &link).unwrap();
let err = write_cache_file(&link, "hijack attempt").unwrap_err();
assert!(matches!(err, CacheError::SymlinkAtTarget { .. }));
assert_eq!(std::fs::read_to_string(&real).unwrap(), "real");
}
#[test]
fn cache_header_contains_required_fields() {
let h = cache_header("#", "/abs/path/to/runex");
assert!(h.contains("runex-integration-version: 1"));
assert!(h.contains("runex-bin: /abs/path/to/runex"));
assert!(h.contains("do not edit"));
}
#[test]
fn comment_prefix_pwsh_is_hash() {
assert_eq!(comment_prefix_for(Shell::Pwsh), "#");
assert_eq!(comment_prefix_for(Shell::Bash), "#");
assert_eq!(comment_prefix_for(Shell::Clink), "--");
}
}