use std::{
collections::{BTreeMap, BTreeSet},
path::{Path, PathBuf},
};
use ci_config::Check;
use thiserror::Error;
pub const CACHE_ENV_PREFIX: &str = "HCI_CACHE_";
#[derive(Debug, Error)]
pub enum CachePathError {
#[error(
"check {check:?} cache path {path:?} is not a worktree-relative directory (absolute paths and .. are refused)"
)]
EscapesWorktree {
check: String,
path: String,
},
#[error("check {check:?} cache path {path:?} could not be saved: {reason}")]
SaveFailed {
check: String,
path: String,
reason: String,
},
}
#[derive(Debug, Clone, Default)]
pub struct PreparedCaches {
pub env: BTreeMap<String, String>,
pub dirs: Vec<PathBuf>,
slots: Vec<BoundSlot>,
}
#[derive(Debug, Clone)]
struct BoundSlot {
check: String,
path: String,
worktree: PathBuf,
slot: PathBuf,
}
pub fn prepare_caches(
check_name: &str,
paths: &[String],
workdir: &Path,
cache_root: &Path,
) -> Result<PreparedCaches, CachePathError> {
let mut prepared = PreparedCaches::default();
for path in paths {
if !ci_config::cache_path_is_worktree_relative(path) {
return Err(CachePathError::EscapesWorktree {
check: check_name.to_string(),
path: path.clone(),
});
}
let worktree = workdir.join(path);
let slot = cache_root.join(path);
hydrate_or_cold(&slot, &worktree);
prepared.env.insert(
format!("{CACHE_ENV_PREFIX}{}", slot_name(path)),
worktree.display().to_string(),
);
prepared.dirs.push(worktree.clone());
prepared.slots.push(BoundSlot {
check: check_name.to_string(),
path: path.clone(),
worktree,
slot,
});
}
Ok(prepared)
}
pub fn save_caches(prepared: &PreparedCaches) -> Result<(), CachePathError> {
for bound in &prepared.slots {
if bound.worktree.exists() {
replace_dir(&bound.worktree, &bound.slot).map_err(|error| {
CachePathError::SaveFailed {
check: bound.check.clone(),
path: bound.path.clone(),
reason: error.to_string(),
}
})?;
}
}
Ok(())
}
pub fn restore_worktree_cache_dirs(workdir: &Path, checks: &[Check]) {
let mut seen = BTreeSet::new();
for check in checks {
for path in &check.cache_paths {
if !ci_config::cache_path_is_worktree_relative(path) {
continue;
}
if !seen.insert(path.as_str()) {
continue;
}
let directory = workdir.join(path);
if directory.exists() {
let _ = std::fs::remove_dir_all(&directory);
}
}
}
}
fn hydrate_or_cold(slot: &Path, worktree: &Path) {
if !worktree_is_missing_or_empty(worktree) {
return;
}
if slot_has_entries(slot) {
if copy_tree(slot, worktree).is_err() {
let _ = std::fs::remove_dir_all(worktree);
let _ = std::fs::create_dir_all(worktree);
}
return;
}
let _ = std::fs::create_dir_all(worktree);
}
fn worktree_is_missing_or_empty(worktree: &Path) -> bool {
match std::fs::symlink_metadata(worktree) {
Err(_) => true,
Ok(meta) if meta.is_dir() => dir_is_empty(worktree),
Ok(_) => false,
}
}
fn slot_has_entries(slot: &Path) -> bool {
match std::fs::symlink_metadata(slot) {
Err(_) => false,
Ok(meta) if meta.is_dir() => !dir_is_empty(slot),
Ok(_) => true,
}
}
fn dir_is_empty(path: &Path) -> bool {
std::fs::read_dir(path)
.ok()
.is_none_or(|mut entries| entries.next().is_none())
}
fn replace_dir(src: &Path, slot: &Path) -> std::io::Result<()> {
let Some(parent) = slot.parent() else {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"cache slot is missing a parent directory",
));
};
let Some(name) = slot.file_name() else {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"cache slot is missing a file name",
));
};
std::fs::create_dir_all(parent)?;
let staging = parent.join(format!(".{}.staging", name.to_string_lossy()));
if staging.exists() {
std::fs::remove_dir_all(&staging)?;
}
copy_tree(src, &staging)?;
if slot.exists() {
std::fs::remove_dir_all(slot)?;
}
std::fs::rename(&staging, slot)
}
fn copy_tree(src: &Path, dst: &Path) -> std::io::Result<()> {
std::fs::create_dir_all(dst)?;
for entry in std::fs::read_dir(src)? {
let entry = entry?;
let from = entry.path();
let to = dst.join(entry.file_name());
let file_type = entry.file_type()?;
if file_type.is_symlink() {
copy_symlink(&from, &to)?;
} else if file_type.is_dir() {
copy_tree(&from, &to)?;
} else if file_type.is_file() {
std::fs::copy(&from, &to)?;
}
}
Ok(())
}
fn copy_symlink(from: &Path, to: &Path) -> std::io::Result<()> {
let target = std::fs::read_link(from)?;
if let Ok(meta) = std::fs::symlink_metadata(to) {
if meta.is_dir() && !meta.file_type().is_symlink() {
std::fs::remove_dir_all(to)?;
} else {
std::fs::remove_file(to)?;
}
}
#[cfg(unix)]
{
std::os::unix::fs::symlink(target, to)
}
#[cfg(not(unix))]
{
let _ = target;
Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
"copying cache symlinks requires a unix host-exec",
))
}
}
fn slot_name(path: &str) -> String {
let mut output = String::with_capacity(path.len());
let mut separated = true;
for character in path.chars() {
if character.is_ascii_alphanumeric() {
output.push(character.to_ascii_uppercase());
separated = false;
} else if !separated {
output.push('_');
separated = true;
}
}
while output.ends_with('_') {
output.pop();
}
if output.is_empty() {
"CACHE".to_string()
} else {
output
}
}