1use std::{
5 collections::{BTreeMap, BTreeSet},
6 path::{Path, PathBuf},
7};
8
9use ci_config::Check;
10use thiserror::Error;
11
12pub const CACHE_ENV_PREFIX: &str = "HCI_CACHE_";
14
15#[derive(Debug, Error)]
17pub enum CachePathError {
18 #[error(
20 "check {check:?} cache path {path:?} is not a worktree-relative directory (absolute paths and .. are refused)"
21 )]
22 EscapesWorktree {
23 check: String,
25 path: String,
27 },
28 #[error("check {check:?} cache path {path:?} could not be saved: {reason}")]
30 SaveFailed {
31 check: String,
33 path: String,
35 reason: String,
37 },
38}
39
40#[derive(Debug, Clone, Default)]
42pub struct PreparedCaches {
43 pub env: BTreeMap<String, String>,
45 pub dirs: Vec<PathBuf>,
47 slots: Vec<BoundSlot>,
48}
49
50#[derive(Debug, Clone)]
51struct BoundSlot {
52 check: String,
53 path: String,
54 worktree: PathBuf,
55 slot: PathBuf,
56}
57
58pub fn prepare_caches(
68 check_name: &str,
69 paths: &[String],
70 workdir: &Path,
71 cache_root: &Path,
72) -> Result<PreparedCaches, CachePathError> {
73 let mut prepared = PreparedCaches::default();
74 for path in paths {
75 if !ci_config::cache_path_is_worktree_relative(path) {
76 return Err(CachePathError::EscapesWorktree {
77 check: check_name.to_string(),
78 path: path.clone(),
79 });
80 }
81 let worktree = workdir.join(path);
82 let slot = cache_root.join(path);
83 hydrate_or_cold(&slot, &worktree);
84 prepared.env.insert(
85 format!("{CACHE_ENV_PREFIX}{}", slot_name(path)),
86 worktree.display().to_string(),
87 );
88 prepared.dirs.push(worktree.clone());
89 prepared.slots.push(BoundSlot {
90 check: check_name.to_string(),
91 path: path.clone(),
92 worktree,
93 slot,
94 });
95 }
96 Ok(prepared)
97}
98
99pub fn save_caches(prepared: &PreparedCaches) -> Result<(), CachePathError> {
105 for bound in &prepared.slots {
106 if bound.worktree.exists() {
107 replace_dir(&bound.worktree, &bound.slot).map_err(|error| {
108 CachePathError::SaveFailed {
109 check: bound.check.clone(),
110 path: bound.path.clone(),
111 reason: error.to_string(),
112 }
113 })?;
114 }
115 }
116 Ok(())
117}
118
119pub fn restore_worktree_cache_dirs(workdir: &Path, checks: &[Check]) {
124 let mut seen = BTreeSet::new();
125 for check in checks {
126 for path in &check.cache_paths {
127 if !ci_config::cache_path_is_worktree_relative(path) {
128 continue;
129 }
130 if !seen.insert(path.as_str()) {
131 continue;
132 }
133 let directory = workdir.join(path);
134 if directory.exists() {
135 let _ = std::fs::remove_dir_all(&directory);
136 }
137 }
138 }
139}
140
141fn hydrate_or_cold(slot: &Path, worktree: &Path) {
142 if !worktree_is_missing_or_empty(worktree) {
143 return;
144 }
145 if slot_has_entries(slot) {
146 if copy_tree(slot, worktree).is_err() {
147 let _ = std::fs::remove_dir_all(worktree);
148 let _ = std::fs::create_dir_all(worktree);
149 }
150 return;
151 }
152 let _ = std::fs::create_dir_all(worktree);
153}
154
155fn worktree_is_missing_or_empty(worktree: &Path) -> bool {
156 match std::fs::symlink_metadata(worktree) {
157 Err(_) => true,
158 Ok(meta) if meta.is_dir() => dir_is_empty(worktree),
159 Ok(_) => false,
160 }
161}
162
163fn slot_has_entries(slot: &Path) -> bool {
164 match std::fs::symlink_metadata(slot) {
165 Err(_) => false,
166 Ok(meta) if meta.is_dir() => !dir_is_empty(slot),
167 Ok(_) => true,
168 }
169}
170
171fn dir_is_empty(path: &Path) -> bool {
172 std::fs::read_dir(path)
173 .ok()
174 .is_none_or(|mut entries| entries.next().is_none())
175}
176
177fn replace_dir(src: &Path, slot: &Path) -> std::io::Result<()> {
178 let Some(parent) = slot.parent() else {
179 return Err(std::io::Error::new(
180 std::io::ErrorKind::InvalidInput,
181 "cache slot is missing a parent directory",
182 ));
183 };
184 let Some(name) = slot.file_name() else {
185 return Err(std::io::Error::new(
186 std::io::ErrorKind::InvalidInput,
187 "cache slot is missing a file name",
188 ));
189 };
190 std::fs::create_dir_all(parent)?;
191 let staging = parent.join(format!(".{}.staging", name.to_string_lossy()));
192 if staging.exists() {
193 std::fs::remove_dir_all(&staging)?;
194 }
195 copy_tree(src, &staging)?;
196 if slot.exists() {
197 std::fs::remove_dir_all(slot)?;
198 }
199 std::fs::rename(&staging, slot)
200}
201
202fn copy_tree(src: &Path, dst: &Path) -> std::io::Result<()> {
203 std::fs::create_dir_all(dst)?;
204 for entry in std::fs::read_dir(src)? {
205 let entry = entry?;
206 let from = entry.path();
207 let to = dst.join(entry.file_name());
208 let file_type = entry.file_type()?;
209 if file_type.is_symlink() {
210 copy_symlink(&from, &to)?;
211 } else if file_type.is_dir() {
212 copy_tree(&from, &to)?;
213 } else if file_type.is_file() {
214 std::fs::copy(&from, &to)?;
215 }
216 }
217 Ok(())
218}
219
220fn copy_symlink(from: &Path, to: &Path) -> std::io::Result<()> {
221 let target = std::fs::read_link(from)?;
222 if let Ok(meta) = std::fs::symlink_metadata(to) {
223 if meta.is_dir() && !meta.file_type().is_symlink() {
224 std::fs::remove_dir_all(to)?;
225 } else {
226 std::fs::remove_file(to)?;
227 }
228 }
229 #[cfg(unix)]
230 {
231 std::os::unix::fs::symlink(target, to)
232 }
233 #[cfg(not(unix))]
234 {
235 let _ = target;
236 Err(std::io::Error::new(
237 std::io::ErrorKind::Unsupported,
238 "copying cache symlinks requires a unix host-exec",
239 ))
240 }
241}
242
243fn slot_name(path: &str) -> String {
244 let mut output = String::with_capacity(path.len());
245 let mut separated = true;
246 for character in path.chars() {
247 if character.is_ascii_alphanumeric() {
248 output.push(character.to_ascii_uppercase());
249 separated = false;
250 } else if !separated {
251 output.push('_');
252 separated = true;
253 }
254 }
255 while output.ends_with('_') {
256 output.pop();
257 }
258 if output.is_empty() {
259 "CACHE".to_string()
260 } else {
261 output
262 }
263}