1use crate::errors::CacheDirError;
7use camino::{Utf8Path, Utf8PathBuf};
8use etcetera::{BaseStrategy, choose_base_strategy};
9use xxhash_rust::xxh3::xxh3_64;
10
11const MAX_ENCODED_LEN: usize = 96;
13
14const HASH_SUFFIX_LEN: usize = 8;
19
20pub const NEXTEST_CACHE_DIR_ENV: &str = "NEXTEST_CACHE_DIR";
25
26pub fn records_cache_dir(workspace_root: &Utf8Path) -> Result<Utf8PathBuf, CacheDirError> {
45 let base_cache_dir = if let Ok(cache_dir) = std::env::var(NEXTEST_CACHE_DIR_ENV) {
46 Utf8PathBuf::from(cache_dir)
47 } else {
48 let strategy = choose_base_strategy().map_err(CacheDirError::BaseDirStrategy)?;
49 let cache_dir = strategy.cache_dir();
50 let nextest_cache = cache_dir.join("nextest");
51 Utf8PathBuf::from_path_buf(nextest_cache.clone()).map_err(|_| {
52 CacheDirError::CacheDirNotUtf8 {
53 path: nextest_cache,
54 }
55 })?
56 };
57
58 let canonical_workspace =
61 workspace_root
62 .canonicalize_utf8()
63 .map_err(|error| CacheDirError::Canonicalize {
64 workspace_root: workspace_root.to_owned(),
65 error,
66 })?;
67
68 let encoded_workspace = encode_workspace_path(&canonical_workspace);
69 Ok(base_cache_dir
70 .join("projects")
71 .join(&encoded_workspace)
72 .join("records"))
73}
74
75pub fn encode_workspace_path(path: &Utf8Path) -> String {
101 let mut encoded = String::with_capacity(path.as_str().len() * 2);
102
103 for ch in path.as_str().chars() {
104 match ch {
105 '_' => encoded.push_str("__"),
106 '/' => encoded.push_str("_s"),
107 '\\' => encoded.push_str("_b"),
108 ':' => encoded.push_str("_c"),
109 '*' => encoded.push_str("_a"),
110 '"' => encoded.push_str("_q"),
111 '<' => encoded.push_str("_l"),
112 '>' => encoded.push_str("_g"),
113 '|' => encoded.push_str("_p"),
114 '?' => encoded.push_str("_m"),
115 _ => encoded.push(ch),
116 }
117 }
118
119 truncate_with_hash(encoded)
120}
121
122fn truncate_with_hash(encoded: String) -> String {
128 if encoded.len() <= MAX_ENCODED_LEN {
129 return encoded;
130 }
131
132 let hash = xxh3_64(encoded.as_bytes());
134 let hash_suffix = format!("{:08x}", hash & 0xFFFFFFFF);
135
136 let max_prefix_len = MAX_ENCODED_LEN - HASH_SUFFIX_LEN;
138 let bytes = encoded.as_bytes();
139 let truncated_bytes = &bytes[..max_prefix_len.min(bytes.len())];
140
141 let mut valid_len = 0;
143 for chunk in truncated_bytes.utf8_chunks() {
144 valid_len += chunk.valid().len();
145 if !chunk.invalid().is_empty() {
147 break;
148 }
149 }
150
151 let mut result = encoded[..valid_len].to_string();
152 result.push_str(&hash_suffix);
153 result
154}
155
156#[cfg_attr(not(test), expect(dead_code))] pub fn decode_workspace_path(encoded: &str) -> Option<Utf8PathBuf> {
162 let mut decoded = String::with_capacity(encoded.len());
163 let mut chars = encoded.chars().peekable();
164
165 while let Some(ch) = chars.next() {
166 if ch == '_' {
167 match chars.next() {
168 Some('_') => decoded.push('_'),
169 Some('s') => decoded.push('/'),
170 Some('b') => decoded.push('\\'),
171 Some('c') => decoded.push(':'),
172 Some('a') => decoded.push('*'),
173 Some('q') => decoded.push('"'),
174 Some('l') => decoded.push('<'),
175 Some('g') => decoded.push('>'),
176 Some('p') => decoded.push('|'),
177 Some('m') => decoded.push('?'),
178 _ => return None,
180 }
181 } else {
182 decoded.push(ch);
183 }
184 }
185
186 Some(Utf8PathBuf::from(decoded))
187}
188
189#[cfg(test)]
190mod tests {
191 use super::*;
192
193 #[test]
194 fn test_records_cache_dir() {
195 let temp_dir =
197 Utf8PathBuf::try_from(std::env::temp_dir()).expect("temp dir should be valid UTF-8");
198 let cache_dir = records_cache_dir(&temp_dir).expect("cache directory should be available");
199
200 assert!(
201 cache_dir.as_str().contains("nextest"),
202 "cache dir should contain 'nextest': {cache_dir}"
203 );
204 assert!(
205 cache_dir.as_str().contains("projects"),
206 "cache dir should contain 'projects': {cache_dir}"
207 );
208 assert!(
209 cache_dir.as_str().contains("records"),
210 "cache dir should contain 'records': {cache_dir}"
211 );
212 }
213
214 #[test]
215 fn test_records_cache_dir_canonicalizes_symlinks() {
216 let temp_dir = camino_tempfile::tempdir().expect("tempdir should be created");
218 let real_path = temp_dir.path().to_path_buf();
219
220 let workspace = real_path.join("workspace");
222 std::fs::create_dir(&workspace).expect("workspace dir should be created");
223
224 let symlink_path = real_path.join("symlink-to-workspace");
226
227 #[cfg(unix)]
228 std::os::unix::fs::symlink(&workspace, &symlink_path)
229 .expect("symlink should be created on Unix");
230
231 #[cfg(windows)]
232 std::os::windows::fs::symlink_dir(&workspace, &symlink_path)
233 .expect("symlink should be created on Windows");
234
235 let cache_via_real =
237 records_cache_dir(&workspace).expect("cache dir via real path should be available");
238
239 let cache_via_symlink =
241 records_cache_dir(&symlink_path).expect("cache dir via symlink should be available");
242
243 assert_eq!(
245 cache_via_real, cache_via_symlink,
246 "cache dir should be the same whether accessed via real path or symlink"
247 );
248 }
249
250 #[test]
252 fn test_encode_workspace_path() {
253 let cases = [
254 ("", ""),
255 ("simple", "simple"),
256 ("/home/user", "_shome_suser"),
257 ("/home/user/project", "_shome_suser_sproject"),
258 ("C:\\Users\\name", "C_c_bUsers_bname"),
259 ("D:\\dev\\project", "D_c_bdev_bproject"),
260 ("/path_with_underscore", "_spath__with__underscore"),
261 ("C:\\path_name", "C_c_bpath__name"),
262 ("/a/b/c", "_sa_sb_sc"),
263 ("/weird*path", "_sweird_apath"),
265 ("/path?query", "_spath_mquery"),
266 ("/file<name>", "_sfile_lname_g"),
267 ("/path|pipe", "_spath_ppipe"),
268 ("/\"quoted\"", "_s_qquoted_q"),
269 ("*\"<>|?", "_a_q_l_g_p_m"),
271 ];
272
273 for (input, expected) in cases {
274 let encoded = encode_workspace_path(Utf8Path::new(input));
275 assert_eq!(
276 encoded, expected,
277 "encoding failed for {input:?}: expected {expected:?}, got {encoded:?}"
278 );
279 }
280 }
281
282 #[test]
284 fn test_encode_decode_roundtrip() {
285 let cases = [
286 "/home/user/project",
287 "C:\\Users\\name\\dev",
288 "/path_with_underscore",
289 "/_",
290 "_/",
291 "__",
292 "/a_b/c_d",
293 "",
294 "no_special_chars",
295 "/mixed\\path:style",
296 "/path*with*asterisks",
298 "/file?query",
299 "/path<with>angles",
300 "/pipe|char",
301 "/\"quoted\"",
302 "/all*special?chars<in>one|path\"here\"_end",
304 ];
305
306 for original in cases {
307 let encoded = encode_workspace_path(Utf8Path::new(original));
308 let decoded = decode_workspace_path(&encoded);
309 assert_eq!(
310 decoded.as_deref(),
311 Some(Utf8Path::new(original)),
312 "roundtrip failed for {original:?}: encoded={encoded:?}, decoded={decoded:?}"
313 );
314 }
315 }
316
317 #[test]
319 fn test_encoding_is_bijective() {
320 let pairs = [
322 ("/-", "-/"),
323 ("/a", "_a"),
324 ("_s", "/"),
325 ("a_", "a/"),
326 ("__", "_"),
327 ("/", "\\"),
328 ("_a", "*"),
330 ("_q", "\""),
331 ("_l", "<"),
332 ("_g", ">"),
333 ("_p", "|"),
334 ("_m", "?"),
335 ("*", "?"),
337 ("<", ">"),
338 ("|", "\""),
339 ];
340
341 for (a, b) in pairs {
342 let encoded_a = encode_workspace_path(Utf8Path::new(a));
343 let encoded_b = encode_workspace_path(Utf8Path::new(b));
344 assert_ne!(
345 encoded_a, encoded_b,
346 "bijectivity violated: {a:?} and {b:?} both encode to {encoded_a:?}"
347 );
348 }
349 }
350
351 #[test]
353 fn test_decode_rejects_malformed() {
354 let malformed_inputs = [
355 "_", "_x", "foo_", "foo_x", "_S", ];
361
362 for input in malformed_inputs {
363 assert!(
364 decode_workspace_path(input).is_none(),
365 "should reject malformed input: {input:?}"
366 );
367 }
368 }
369
370 #[test]
372 fn test_decode_valid_escapes() {
373 let cases = [
374 ("__", "_"),
375 ("_s", "/"),
376 ("_b", "\\"),
377 ("_c", ":"),
378 ("a__b", "a_b"),
379 ("_shome", "/home"),
380 ("_a", "*"),
382 ("_q", "\""),
383 ("_l", "<"),
384 ("_g", ">"),
385 ("_p", "|"),
386 ("_m", "?"),
387 ("_spath_astar_mquery", "/path*star?query"),
389 ];
390
391 for (input, expected) in cases {
392 let decoded = decode_workspace_path(input);
393 assert_eq!(
394 decoded.as_deref(),
395 Some(Utf8Path::new(expected)),
396 "decode failed for {input:?}: expected {expected:?}, got {decoded:?}"
397 );
398 }
399 }
400
401 #[test]
403 fn test_short_paths_not_truncated() {
404 let short_path = "/a/b/c/d";
406 let encoded = encode_workspace_path(Utf8Path::new(short_path));
407 assert!(
408 encoded.len() <= MAX_ENCODED_LEN,
409 "short path should not be truncated: {encoded:?} (len={})",
410 encoded.len()
411 );
412 assert_eq!(encoded, "_sa_sb_sc_sd");
414 }
415
416 #[test]
417 fn test_long_paths_truncated_with_hash() {
418 let long_path = "/a".repeat(50); let encoded = encode_workspace_path(Utf8Path::new(&long_path));
422
423 assert_eq!(
424 encoded.len(),
425 MAX_ENCODED_LEN,
426 "truncated path should be exactly {MAX_ENCODED_LEN} bytes: {encoded:?} (len={})",
427 encoded.len()
428 );
429
430 let hash_suffix = &encoded[encoded.len() - HASH_SUFFIX_LEN..];
432 assert!(
433 hash_suffix.chars().all(|c| c.is_ascii_hexdigit()),
434 "hash suffix should be hex digits: {hash_suffix:?}"
435 );
436 }
437
438 #[test]
439 fn test_truncation_preserves_uniqueness() {
440 let path_a = "/a".repeat(50);
442 let path_b = "/b".repeat(50);
443
444 let encoded_a = encode_workspace_path(Utf8Path::new(&path_a));
445 let encoded_b = encode_workspace_path(Utf8Path::new(&path_b));
446
447 assert_ne!(
448 encoded_a, encoded_b,
449 "different paths should produce different encodings even when truncated"
450 );
451 }
452
453 #[test]
454 fn test_truncation_with_unicode() {
455 let unicode_path = "/日本語".repeat(20); let encoded = encode_workspace_path(Utf8Path::new(&unicode_path));
459
460 assert!(
461 encoded.len() <= MAX_ENCODED_LEN,
462 "encoded path should not exceed {MAX_ENCODED_LEN} bytes: len={}",
463 encoded.len()
464 );
465
466 let _ = encoded.as_str();
468
469 let hash_suffix = &encoded[encoded.len() - HASH_SUFFIX_LEN..];
471 assert!(
472 hash_suffix.chars().all(|c| c.is_ascii_hexdigit()),
473 "hash suffix should be hex digits: {hash_suffix:?}"
474 );
475 }
476
477 #[test]
478 fn test_truncation_boundary_at_96_bytes() {
479 let exactly_96 = "a".repeat(96);
485 let encoded = encode_workspace_path(Utf8Path::new(&exactly_96));
486 assert_eq!(encoded.len(), 96);
487 assert_eq!(encoded, exactly_96); let just_over = "a".repeat(97);
491 let encoded = encode_workspace_path(Utf8Path::new(&just_over));
492 assert_eq!(encoded.len(), 96);
493 let hash_suffix = &encoded[90..];
495 assert!(hash_suffix.chars().all(|c| c.is_ascii_hexdigit()));
496 }
497
498 #[test]
499 fn test_truncation_different_suffixes_same_prefix() {
500 let base = "a".repeat(90);
502 let path_a = format!("{base}XXXXXXX");
503 let path_b = format!("{base}YYYYYYY");
504
505 let encoded_a = encode_workspace_path(Utf8Path::new(&path_a));
506 let encoded_b = encode_workspace_path(Utf8Path::new(&path_b));
507
508 assert_eq!(encoded_a.len(), 96);
510 assert_eq!(encoded_b.len(), 96);
511
512 assert_ne!(
514 &encoded_a[90..],
515 &encoded_b[90..],
516 "different paths should have different hash suffixes"
517 );
518 }
519}