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