1use crate::errors::{DisplayErrorChain, StateDirError};
13use camino::{Utf8Path, Utf8PathBuf};
14use etcetera::{BaseStrategy, choose_base_strategy};
15use std::fs;
16use tracing::{info, warn};
17use xxhash_rust::xxh3::xxh3_64;
18
19const MAX_ENCODED_LEN: usize = 96;
21
22const HASH_SUFFIX_LEN: usize = 8;
27
28pub const NEXTEST_STATE_DIR_ENV: &str = "NEXTEST_STATE_DIR";
33
34pub fn records_state_dir(workspace_root: &Utf8Path) -> Result<Utf8PathBuf, StateDirError> {
62 if let Ok(state_dir) = std::env::var(NEXTEST_STATE_DIR_ENV) {
64 let base_dir = Utf8PathBuf::from(state_dir);
65 let canonical_workspace =
66 workspace_root
67 .canonicalize_utf8()
68 .map_err(|error| StateDirError::Canonicalize {
69 workspace_root: workspace_root.to_owned(),
70 error,
71 })?;
72 let encoded_workspace = encode_workspace_path(&canonical_workspace);
73 return Ok(base_dir
74 .join("projects")
75 .join(&encoded_workspace)
76 .join("records"));
77 }
78
79 let strategy = choose_base_strategy().map_err(StateDirError::BaseDirStrategy)?;
80
81 let canonical_workspace =
84 workspace_root
85 .canonicalize_utf8()
86 .map_err(|error| StateDirError::Canonicalize {
87 workspace_root: workspace_root.to_owned(),
88 error,
89 })?;
90 let encoded_workspace = encode_workspace_path(&canonical_workspace);
91
92 let nextest_dir = if let Some(base_state_dir) = strategy.state_dir() {
95 let nextest_state = base_state_dir.join("nextest");
98 let nextest_cache = strategy.cache_dir().join("nextest");
99 if let (Ok(nextest_state_utf8), Ok(nextest_cache_utf8)) = (
100 Utf8PathBuf::from_path_buf(nextest_state.clone()),
101 Utf8PathBuf::from_path_buf(nextest_cache),
102 ) && nextest_state_utf8 != nextest_cache_utf8
103 {
104 migrate_nextest_dir(&nextest_cache_utf8, &nextest_state_utf8);
105 };
106 nextest_state
107 } else {
108 strategy.cache_dir().join("nextest")
110 };
111
112 let nextest_dir_utf8 = Utf8PathBuf::from_path_buf(nextest_dir.clone())
113 .map_err(|_| StateDirError::StateDirNotUtf8 { path: nextest_dir })?;
114
115 Ok(nextest_dir_utf8
116 .join("projects")
117 .join(&encoded_workspace)
118 .join("records"))
119}
120
121fn migrate_nextest_dir(old_dir: &Utf8Path, new_dir: &Utf8Path) {
125 if !old_dir.exists() || new_dir.exists() {
126 return;
127 }
128
129 if let Some(parent) = new_dir.parent()
130 && let Err(error) = fs::create_dir_all(parent)
131 {
132 warn!(
133 "failed to create parent directory for new state location \
134 at `{new_dir}`: {}",
135 DisplayErrorChain::new(&error),
136 );
137 return;
138 }
139
140 match fs::rename(old_dir, new_dir) {
142 Ok(()) => {
143 info!("migrated nextest recordings from `{old_dir}` to `{new_dir}`");
144 }
145 Err(error) => {
146 warn!(
147 "failed to migrate nextest recordings from `{old_dir}` to `{new_dir}` \
148 (cross-filesystem move or permission issue): {}",
149 DisplayErrorChain::new(&error),
150 );
151 }
152 }
153}
154
155pub fn encode_workspace_path(path: &Utf8Path) -> String {
181 let mut encoded = String::with_capacity(path.as_str().len() * 2);
182
183 for ch in path.as_str().chars() {
184 match ch {
185 '_' => encoded.push_str("__"),
186 '/' => encoded.push_str("_s"),
187 '\\' => encoded.push_str("_b"),
188 ':' => encoded.push_str("_c"),
189 '*' => encoded.push_str("_a"),
190 '"' => encoded.push_str("_q"),
191 '<' => encoded.push_str("_l"),
192 '>' => encoded.push_str("_g"),
193 '|' => encoded.push_str("_p"),
194 '?' => encoded.push_str("_m"),
195 _ => encoded.push(ch),
196 }
197 }
198
199 truncate_with_hash(encoded)
200}
201
202fn truncate_with_hash(encoded: String) -> String {
208 if encoded.len() <= MAX_ENCODED_LEN {
209 return encoded;
210 }
211
212 let hash = xxh3_64(encoded.as_bytes());
214 let hash_suffix = format!("{:08x}", hash & 0xFFFFFFFF);
215
216 let max_prefix_len = MAX_ENCODED_LEN - HASH_SUFFIX_LEN;
218 let bytes = encoded.as_bytes();
219 let truncated_bytes = &bytes[..max_prefix_len.min(bytes.len())];
220
221 let mut valid_len = 0;
223 for chunk in truncated_bytes.utf8_chunks() {
224 valid_len += chunk.valid().len();
225 if !chunk.invalid().is_empty() {
227 break;
228 }
229 }
230
231 let mut result = encoded[..valid_len].to_string();
232 result.push_str(&hash_suffix);
233 result
234}
235
236#[cfg_attr(not(test), expect(dead_code))] pub fn decode_workspace_path(encoded: &str) -> Option<Utf8PathBuf> {
242 let mut decoded = String::with_capacity(encoded.len());
243 let mut chars = encoded.chars().peekable();
244
245 while let Some(ch) = chars.next() {
246 if ch == '_' {
247 match chars.next() {
248 Some('_') => decoded.push('_'),
249 Some('s') => decoded.push('/'),
250 Some('b') => decoded.push('\\'),
251 Some('c') => decoded.push(':'),
252 Some('a') => decoded.push('*'),
253 Some('q') => decoded.push('"'),
254 Some('l') => decoded.push('<'),
255 Some('g') => decoded.push('>'),
256 Some('p') => decoded.push('|'),
257 Some('m') => decoded.push('?'),
258 _ => return None,
260 }
261 } else {
262 decoded.push(ch);
263 }
264 }
265
266 Some(Utf8PathBuf::from(decoded))
267}
268
269#[cfg(test)]
270mod tests {
271 use super::*;
272
273 #[test]
274 fn test_records_state_dir() {
275 let temp_dir =
277 Utf8PathBuf::try_from(std::env::temp_dir()).expect("temp dir should be valid UTF-8");
278 let state_dir = records_state_dir(&temp_dir).expect("state directory should be available");
279
280 assert!(
281 state_dir.as_str().contains("nextest"),
282 "state dir should contain 'nextest': {state_dir}"
283 );
284 assert!(
285 state_dir.as_str().contains("projects"),
286 "state dir should contain 'projects': {state_dir}"
287 );
288 assert!(
289 state_dir.as_str().contains("records"),
290 "state dir should contain 'records': {state_dir}"
291 );
292 }
293
294 #[test]
295 fn test_records_state_dir_canonicalizes_symlinks() {
296 let temp_dir = camino_tempfile::tempdir().expect("tempdir should be created");
298 let real_path = temp_dir.path().to_path_buf();
299
300 let workspace = real_path.join("workspace");
302 fs::create_dir(&workspace).expect("workspace dir should be created");
303
304 let symlink_path = real_path.join("symlink-to-workspace");
306
307 #[cfg(unix)]
308 std::os::unix::fs::symlink(&workspace, &symlink_path)
309 .expect("symlink should be created on Unix");
310
311 #[cfg(windows)]
312 std::os::windows::fs::symlink_dir(&workspace, &symlink_path)
313 .expect("symlink should be created on Windows");
314
315 let state_via_real =
317 records_state_dir(&workspace).expect("state dir via real path should be available");
318
319 let state_via_symlink =
321 records_state_dir(&symlink_path).expect("state dir via symlink should be available");
322
323 assert_eq!(
325 state_via_real, state_via_symlink,
326 "state dir should be the same whether accessed via real path or symlink"
327 );
328 }
329
330 #[test]
331 fn test_migration_from_cache_to_state() {
332 let temp_dir = camino_tempfile::tempdir().expect("tempdir should be created");
334 let base = temp_dir.path();
335
336 let old_nextest = base.join("cache").join("nextest");
338 let workspace1_records = old_nextest
339 .join("projects")
340 .join("workspace1")
341 .join("records");
342 let workspace2_records = old_nextest
343 .join("projects")
344 .join("workspace2")
345 .join("records");
346 fs::create_dir_all(&workspace1_records).expect("workspace1 dir should be created");
347 fs::create_dir_all(&workspace2_records).expect("workspace2 dir should be created");
348
349 fs::write(workspace1_records.join("runs.json.zst"), b"workspace1 data")
351 .expect("workspace1 marker should be created");
352 fs::write(workspace2_records.join("runs.json.zst"), b"workspace2 data")
353 .expect("workspace2 marker should be created");
354
355 assert!(
357 old_nextest.exists(),
358 "old nextest dir should exist before migration"
359 );
360
361 let new_nextest = base.join("state").join("nextest");
363 migrate_nextest_dir(&old_nextest, &new_nextest);
364
365 assert!(
367 !old_nextest.exists(),
368 "old nextest dir should not exist after migration"
369 );
370 assert!(
371 new_nextest.exists(),
372 "new nextest dir should exist after migration"
373 );
374 assert!(
375 new_nextest
376 .join("projects")
377 .join("workspace1")
378 .join("records")
379 .join("runs.json.zst")
380 .exists(),
381 "workspace1 marker should exist in new location"
382 );
383 assert!(
384 new_nextest
385 .join("projects")
386 .join("workspace2")
387 .join("records")
388 .join("runs.json.zst")
389 .exists(),
390 "workspace2 marker should exist in new location"
391 );
392 }
393
394 #[test]
395 fn test_migration_skipped_if_new_exists() {
396 let temp_dir = camino_tempfile::tempdir().expect("tempdir should be created");
397 let base = temp_dir.path();
398
399 let old_nextest = base.join("cache").join("nextest");
400 let new_nextest = base.join("state").join("nextest");
401 fs::create_dir_all(old_nextest.join("projects")).expect("old dir should be created");
402 fs::create_dir_all(new_nextest.join("projects")).expect("new dir should be created");
403
404 fs::write(old_nextest.join("old_marker"), b"old").expect("old marker should be created");
406 fs::write(new_nextest.join("new_marker"), b"new").expect("new marker should be created");
407
408 migrate_nextest_dir(&old_nextest, &new_nextest);
409
410 assert!(old_nextest.exists(), "old dir should still exist");
412 assert!(new_nextest.exists(), "new dir should still exist");
413 assert!(
414 old_nextest.join("old_marker").exists(),
415 "old marker should still exist"
416 );
417 assert!(
418 new_nextest.join("new_marker").exists(),
419 "new marker should still exist"
420 );
421 }
422
423 #[test]
424 fn test_migration_skipped_if_old_does_not_exist() {
425 let temp_dir = camino_tempfile::tempdir().expect("tempdir should be created");
427 let base = temp_dir.path();
428
429 let old_nextest = base.join("cache").join("nextest");
430 let new_nextest = base.join("state").join("nextest");
431
432 assert!(!old_nextest.exists());
433 assert!(!new_nextest.exists());
434
435 migrate_nextest_dir(&old_nextest, &new_nextest);
436
437 assert!(!old_nextest.exists());
438 assert!(!new_nextest.exists());
439 }
440
441 #[test]
443 fn test_encode_workspace_path() {
444 let cases = [
445 ("", ""),
446 ("simple", "simple"),
447 ("/home/user", "_shome_suser"),
448 ("/home/user/project", "_shome_suser_sproject"),
449 ("C:\\Users\\name", "C_c_bUsers_bname"),
450 ("D:\\dev\\project", "D_c_bdev_bproject"),
451 ("/path_with_underscore", "_spath__with__underscore"),
452 ("C:\\path_name", "C_c_bpath__name"),
453 ("/a/b/c", "_sa_sb_sc"),
454 ("/weird*path", "_sweird_apath"),
456 ("/path?query", "_spath_mquery"),
457 ("/file<name>", "_sfile_lname_g"),
458 ("/path|pipe", "_spath_ppipe"),
459 ("/\"quoted\"", "_s_qquoted_q"),
460 ("*\"<>|?", "_a_q_l_g_p_m"),
462 ];
463
464 for (input, expected) in cases {
465 let encoded = encode_workspace_path(Utf8Path::new(input));
466 assert_eq!(
467 encoded, expected,
468 "encoding failed for {input:?}: expected {expected:?}, got {encoded:?}"
469 );
470 }
471 }
472
473 #[test]
475 fn test_encode_decode_roundtrip() {
476 let cases = [
477 "/home/user/project",
478 "C:\\Users\\name\\dev",
479 "/path_with_underscore",
480 "/_",
481 "_/",
482 "__",
483 "/a_b/c_d",
484 "",
485 "no_special_chars",
486 "/mixed\\path:style",
487 "/path*with*asterisks",
489 "/file?query",
490 "/path<with>angles",
491 "/pipe|char",
492 "/\"quoted\"",
493 "/all*special?chars<in>one|path\"here\"_end",
495 ];
496
497 for original in cases {
498 let encoded = encode_workspace_path(Utf8Path::new(original));
499 let decoded = decode_workspace_path(&encoded);
500 assert_eq!(
501 decoded.as_deref(),
502 Some(Utf8Path::new(original)),
503 "roundtrip failed for {original:?}: encoded={encoded:?}, decoded={decoded:?}"
504 );
505 }
506 }
507
508 #[test]
510 fn test_encoding_is_bijective() {
511 let pairs = [
513 ("/-", "-/"),
514 ("/a", "_a"),
515 ("_s", "/"),
516 ("a_", "a/"),
517 ("__", "_"),
518 ("/", "\\"),
519 ("_a", "*"),
521 ("_q", "\""),
522 ("_l", "<"),
523 ("_g", ">"),
524 ("_p", "|"),
525 ("_m", "?"),
526 ("*", "?"),
528 ("<", ">"),
529 ("|", "\""),
530 ];
531
532 for (a, b) in pairs {
533 let encoded_a = encode_workspace_path(Utf8Path::new(a));
534 let encoded_b = encode_workspace_path(Utf8Path::new(b));
535 assert_ne!(
536 encoded_a, encoded_b,
537 "bijectivity violated: {a:?} and {b:?} both encode to {encoded_a:?}"
538 );
539 }
540 }
541
542 #[test]
544 fn test_decode_rejects_malformed() {
545 let malformed_inputs = [
546 "_", "_x", "foo_", "foo_x", "_S", ];
552
553 for input in malformed_inputs {
554 assert!(
555 decode_workspace_path(input).is_none(),
556 "should reject malformed input: {input:?}"
557 );
558 }
559 }
560
561 #[test]
563 fn test_decode_valid_escapes() {
564 let cases = [
565 ("__", "_"),
566 ("_s", "/"),
567 ("_b", "\\"),
568 ("_c", ":"),
569 ("a__b", "a_b"),
570 ("_shome", "/home"),
571 ("_a", "*"),
573 ("_q", "\""),
574 ("_l", "<"),
575 ("_g", ">"),
576 ("_p", "|"),
577 ("_m", "?"),
578 ("_spath_astar_mquery", "/path*star?query"),
580 ];
581
582 for (input, expected) in cases {
583 let decoded = decode_workspace_path(input);
584 assert_eq!(
585 decoded.as_deref(),
586 Some(Utf8Path::new(expected)),
587 "decode failed for {input:?}: expected {expected:?}, got {decoded:?}"
588 );
589 }
590 }
591
592 #[test]
594 fn test_short_paths_not_truncated() {
595 let short_path = "/a/b/c/d";
597 let encoded = encode_workspace_path(Utf8Path::new(short_path));
598 assert!(
599 encoded.len() <= MAX_ENCODED_LEN,
600 "short path should not be truncated: {encoded:?} (len={})",
601 encoded.len()
602 );
603 assert_eq!(encoded, "_sa_sb_sc_sd");
605 }
606
607 #[test]
608 fn test_long_paths_truncated_with_hash() {
609 let long_path = "/a".repeat(50); let encoded = encode_workspace_path(Utf8Path::new(&long_path));
613
614 assert_eq!(
615 encoded.len(),
616 MAX_ENCODED_LEN,
617 "truncated path should be exactly {MAX_ENCODED_LEN} bytes: {encoded:?} (len={})",
618 encoded.len()
619 );
620
621 let hash_suffix = &encoded[encoded.len() - HASH_SUFFIX_LEN..];
623 assert!(
624 hash_suffix.chars().all(|c| c.is_ascii_hexdigit()),
625 "hash suffix should be hex digits: {hash_suffix:?}"
626 );
627 }
628
629 #[test]
630 fn test_truncation_preserves_uniqueness() {
631 let path_a = "/a".repeat(50);
633 let path_b = "/b".repeat(50);
634
635 let encoded_a = encode_workspace_path(Utf8Path::new(&path_a));
636 let encoded_b = encode_workspace_path(Utf8Path::new(&path_b));
637
638 assert_ne!(
639 encoded_a, encoded_b,
640 "different paths should produce different encodings even when truncated"
641 );
642 }
643
644 #[test]
645 fn test_truncation_with_unicode() {
646 let unicode_path = "/日本語".repeat(20); let encoded = encode_workspace_path(Utf8Path::new(&unicode_path));
650
651 assert!(
652 encoded.len() <= MAX_ENCODED_LEN,
653 "encoded path should not exceed {MAX_ENCODED_LEN} bytes: len={}",
654 encoded.len()
655 );
656
657 let _ = encoded.as_str();
659
660 let hash_suffix = &encoded[encoded.len() - HASH_SUFFIX_LEN..];
662 assert!(
663 hash_suffix.chars().all(|c| c.is_ascii_hexdigit()),
664 "hash suffix should be hex digits: {hash_suffix:?}"
665 );
666 }
667
668 #[test]
669 fn test_truncation_boundary_at_96_bytes() {
670 let exactly_96 = "a".repeat(96);
676 let encoded = encode_workspace_path(Utf8Path::new(&exactly_96));
677 assert_eq!(encoded.len(), 96);
678 assert_eq!(encoded, exactly_96); let just_over = "a".repeat(97);
682 let encoded = encode_workspace_path(Utf8Path::new(&just_over));
683 assert_eq!(encoded.len(), 96);
684 let hash_suffix = &encoded[90..];
686 assert!(hash_suffix.chars().all(|c| c.is_ascii_hexdigit()));
687 }
688
689 #[test]
690 fn test_truncation_different_suffixes_same_prefix() {
691 let base = "a".repeat(90);
693 let path_a = format!("{base}XXXXXXX");
694 let path_b = format!("{base}YYYYYYY");
695
696 let encoded_a = encode_workspace_path(Utf8Path::new(&path_a));
697 let encoded_b = encode_workspace_path(Utf8Path::new(&path_b));
698
699 assert_eq!(encoded_a.len(), 96);
701 assert_eq!(encoded_b.len(), 96);
702
703 assert_ne!(
705 &encoded_a[90..],
706 &encoded_b[90..],
707 "different paths should have different hash suffixes"
708 );
709 }
710}