1use crate::errors::StateDirError;
13use camino::{Utf8Path, Utf8PathBuf};
14use etcetera::{BaseStrategy, choose_base_strategy};
15use xxhash_rust::xxh3::xxh3_64;
16
17const MAX_ENCODED_LEN: usize = 96;
19
20const HASH_SUFFIX_LEN: usize = 8;
25
26pub const NEXTEST_STATE_DIR_ENV: &str = "NEXTEST_STATE_DIR";
31
32pub fn records_state_dir(workspace_root: &Utf8Path) -> Result<Utf8PathBuf, StateDirError> {
53 if let Ok(state_dir) = std::env::var(NEXTEST_STATE_DIR_ENV) {
55 let base_dir = Utf8PathBuf::from(state_dir);
56 let canonical_workspace =
57 workspace_root
58 .canonicalize_utf8()
59 .map_err(|error| StateDirError::Canonicalize {
60 workspace_root: workspace_root.to_owned(),
61 error,
62 })?;
63 let encoded_workspace = encode_workspace_path(&canonical_workspace);
64 return Ok(base_dir
65 .join("projects")
66 .join(&encoded_workspace)
67 .join("records"));
68 }
69
70 let strategy = choose_base_strategy().map_err(StateDirError::BaseDirStrategy)?;
71
72 let canonical_workspace =
75 workspace_root
76 .canonicalize_utf8()
77 .map_err(|error| StateDirError::Canonicalize {
78 workspace_root: workspace_root.to_owned(),
79 error,
80 })?;
81 let encoded_workspace = encode_workspace_path(&canonical_workspace);
82
83 let nextest_dir = if let Some(base_state_dir) = strategy.state_dir() {
86 base_state_dir.join("nextest")
87 } else {
88 strategy.cache_dir().join("nextest")
89 };
90
91 let nextest_dir_utf8 = Utf8PathBuf::from_path_buf(nextest_dir.clone())
92 .map_err(|_| StateDirError::StateDirNotUtf8 { path: nextest_dir })?;
93
94 Ok(nextest_dir_utf8
95 .join("projects")
96 .join(&encoded_workspace)
97 .join("records"))
98}
99
100pub fn encode_workspace_path(path: &Utf8Path) -> String {
126 let mut encoded = String::with_capacity(path.as_str().len() * 2);
127
128 for ch in path.as_str().chars() {
129 match ch {
130 '_' => encoded.push_str("__"),
131 '/' => encoded.push_str("_s"),
132 '\\' => encoded.push_str("_b"),
133 ':' => encoded.push_str("_c"),
134 '*' => encoded.push_str("_a"),
135 '"' => encoded.push_str("_q"),
136 '<' => encoded.push_str("_l"),
137 '>' => encoded.push_str("_g"),
138 '|' => encoded.push_str("_p"),
139 '?' => encoded.push_str("_m"),
140 _ => encoded.push(ch),
141 }
142 }
143
144 truncate_with_hash(encoded)
145}
146
147fn truncate_with_hash(encoded: String) -> String {
153 if encoded.len() <= MAX_ENCODED_LEN {
154 return encoded;
155 }
156
157 let hash = xxh3_64(encoded.as_bytes());
159 let hash_suffix = format!("{:08x}", hash & 0xFFFFFFFF);
160
161 let max_prefix_len = MAX_ENCODED_LEN - HASH_SUFFIX_LEN;
163 let bytes = encoded.as_bytes();
164 let truncated_bytes = &bytes[..max_prefix_len.min(bytes.len())];
165
166 let mut valid_len = 0;
168 for chunk in truncated_bytes.utf8_chunks() {
169 valid_len += chunk.valid().len();
170 if !chunk.invalid().is_empty() {
172 break;
173 }
174 }
175
176 let mut result = encoded[..valid_len].to_string();
177 result.push_str(&hash_suffix);
178 result
179}
180
181#[cfg_attr(not(test), expect(dead_code))] pub fn decode_workspace_path(encoded: &str) -> Option<Utf8PathBuf> {
187 let mut decoded = String::with_capacity(encoded.len());
188 let mut chars = encoded.chars().peekable();
189
190 while let Some(ch) = chars.next() {
191 if ch == '_' {
192 match chars.next() {
193 Some('_') => decoded.push('_'),
194 Some('s') => decoded.push('/'),
195 Some('b') => decoded.push('\\'),
196 Some('c') => decoded.push(':'),
197 Some('a') => decoded.push('*'),
198 Some('q') => decoded.push('"'),
199 Some('l') => decoded.push('<'),
200 Some('g') => decoded.push('>'),
201 Some('p') => decoded.push('|'),
202 Some('m') => decoded.push('?'),
203 _ => return None,
205 }
206 } else {
207 decoded.push(ch);
208 }
209 }
210
211 Some(Utf8PathBuf::from(decoded))
212}
213
214#[cfg(test)]
215mod tests {
216 use super::*;
217 use std::fs;
218
219 #[test]
220 fn test_records_state_dir() {
221 let temp_dir =
223 Utf8PathBuf::try_from(std::env::temp_dir()).expect("temp dir should be valid UTF-8");
224 let state_dir = records_state_dir(&temp_dir).expect("state directory should be available");
225
226 assert!(
227 state_dir.as_str().contains("nextest"),
228 "state dir should contain 'nextest': {state_dir}"
229 );
230 assert!(
231 state_dir.as_str().contains("projects"),
232 "state dir should contain 'projects': {state_dir}"
233 );
234 assert!(
235 state_dir.as_str().contains("records"),
236 "state dir should contain 'records': {state_dir}"
237 );
238 }
239
240 #[test]
241 fn test_records_state_dir_canonicalizes_symlinks() {
242 let temp_dir = camino_tempfile::tempdir().expect("tempdir should be created");
244 let real_path = temp_dir.path().to_path_buf();
245
246 let workspace = real_path.join("workspace");
248 fs::create_dir(&workspace).expect("workspace dir should be created");
249
250 let symlink_path = real_path.join("symlink-to-workspace");
252
253 #[cfg(unix)]
254 std::os::unix::fs::symlink(&workspace, &symlink_path)
255 .expect("symlink should be created on Unix");
256
257 #[cfg(windows)]
258 std::os::windows::fs::symlink_dir(&workspace, &symlink_path)
259 .expect("symlink should be created on Windows");
260
261 let state_via_real =
263 records_state_dir(&workspace).expect("state dir via real path should be available");
264
265 let state_via_symlink =
267 records_state_dir(&symlink_path).expect("state dir via symlink should be available");
268
269 assert_eq!(
271 state_via_real, state_via_symlink,
272 "state dir should be the same whether accessed via real path or symlink"
273 );
274 }
275
276 #[test]
278 fn test_encode_workspace_path() {
279 let cases = [
280 ("", ""),
281 ("simple", "simple"),
282 ("/home/user", "_shome_suser"),
283 ("/home/user/project", "_shome_suser_sproject"),
284 ("C:\\Users\\name", "C_c_bUsers_bname"),
285 ("D:\\dev\\project", "D_c_bdev_bproject"),
286 ("/path_with_underscore", "_spath__with__underscore"),
287 ("C:\\path_name", "C_c_bpath__name"),
288 ("/a/b/c", "_sa_sb_sc"),
289 ("/weird*path", "_sweird_apath"),
291 ("/path?query", "_spath_mquery"),
292 ("/file<name>", "_sfile_lname_g"),
293 ("/path|pipe", "_spath_ppipe"),
294 ("/\"quoted\"", "_s_qquoted_q"),
295 ("*\"<>|?", "_a_q_l_g_p_m"),
297 ];
298
299 for (input, expected) in cases {
300 let encoded = encode_workspace_path(Utf8Path::new(input));
301 assert_eq!(
302 encoded, expected,
303 "encoding failed for {input:?}: expected {expected:?}, got {encoded:?}"
304 );
305 }
306 }
307
308 #[test]
310 fn test_encode_decode_roundtrip() {
311 let cases = [
312 "/home/user/project",
313 "C:\\Users\\name\\dev",
314 "/path_with_underscore",
315 "/_",
316 "_/",
317 "__",
318 "/a_b/c_d",
319 "",
320 "no_special_chars",
321 "/mixed\\path:style",
322 "/path*with*asterisks",
324 "/file?query",
325 "/path<with>angles",
326 "/pipe|char",
327 "/\"quoted\"",
328 "/all*special?chars<in>one|path\"here\"_end",
330 ];
331
332 for original in cases {
333 let encoded = encode_workspace_path(Utf8Path::new(original));
334 let decoded = decode_workspace_path(&encoded);
335 assert_eq!(
336 decoded.as_deref(),
337 Some(Utf8Path::new(original)),
338 "roundtrip failed for {original:?}: encoded={encoded:?}, decoded={decoded:?}"
339 );
340 }
341 }
342
343 #[test]
345 fn test_encoding_is_bijective() {
346 let pairs = [
348 ("/-", "-/"),
349 ("/a", "_a"),
350 ("_s", "/"),
351 ("a_", "a/"),
352 ("__", "_"),
353 ("/", "\\"),
354 ("_a", "*"),
356 ("_q", "\""),
357 ("_l", "<"),
358 ("_g", ">"),
359 ("_p", "|"),
360 ("_m", "?"),
361 ("*", "?"),
363 ("<", ">"),
364 ("|", "\""),
365 ];
366
367 for (a, b) in pairs {
368 let encoded_a = encode_workspace_path(Utf8Path::new(a));
369 let encoded_b = encode_workspace_path(Utf8Path::new(b));
370 assert_ne!(
371 encoded_a, encoded_b,
372 "bijectivity violated: {a:?} and {b:?} both encode to {encoded_a:?}"
373 );
374 }
375 }
376
377 #[test]
379 fn test_decode_rejects_malformed() {
380 let malformed_inputs = [
381 "_", "_x", "foo_", "foo_x", "_S", ];
387
388 for input in malformed_inputs {
389 assert!(
390 decode_workspace_path(input).is_none(),
391 "should reject malformed input: {input:?}"
392 );
393 }
394 }
395
396 #[test]
398 fn test_decode_valid_escapes() {
399 let cases = [
400 ("__", "_"),
401 ("_s", "/"),
402 ("_b", "\\"),
403 ("_c", ":"),
404 ("a__b", "a_b"),
405 ("_shome", "/home"),
406 ("_a", "*"),
408 ("_q", "\""),
409 ("_l", "<"),
410 ("_g", ">"),
411 ("_p", "|"),
412 ("_m", "?"),
413 ("_spath_astar_mquery", "/path*star?query"),
415 ];
416
417 for (input, expected) in cases {
418 let decoded = decode_workspace_path(input);
419 assert_eq!(
420 decoded.as_deref(),
421 Some(Utf8Path::new(expected)),
422 "decode failed for {input:?}: expected {expected:?}, got {decoded:?}"
423 );
424 }
425 }
426
427 #[test]
429 fn test_short_paths_not_truncated() {
430 let short_path = "/a/b/c/d";
432 let encoded = encode_workspace_path(Utf8Path::new(short_path));
433 assert!(
434 encoded.len() <= MAX_ENCODED_LEN,
435 "short path should not be truncated: {encoded:?} (len={})",
436 encoded.len()
437 );
438 assert_eq!(encoded, "_sa_sb_sc_sd");
440 }
441
442 #[test]
443 fn test_long_paths_truncated_with_hash() {
444 let long_path = "/a".repeat(50); let encoded = encode_workspace_path(Utf8Path::new(&long_path));
448
449 assert_eq!(
450 encoded.len(),
451 MAX_ENCODED_LEN,
452 "truncated path should be exactly {MAX_ENCODED_LEN} bytes: {encoded:?} (len={})",
453 encoded.len()
454 );
455
456 let hash_suffix = &encoded[encoded.len() - HASH_SUFFIX_LEN..];
458 assert!(
459 hash_suffix.chars().all(|c| c.is_ascii_hexdigit()),
460 "hash suffix should be hex digits: {hash_suffix:?}"
461 );
462 }
463
464 #[test]
465 fn test_truncation_preserves_uniqueness() {
466 let path_a = "/a".repeat(50);
468 let path_b = "/b".repeat(50);
469
470 let encoded_a = encode_workspace_path(Utf8Path::new(&path_a));
471 let encoded_b = encode_workspace_path(Utf8Path::new(&path_b));
472
473 assert_ne!(
474 encoded_a, encoded_b,
475 "different paths should produce different encodings even when truncated"
476 );
477 }
478
479 #[test]
480 fn test_truncation_with_unicode() {
481 let unicode_path = "/日本語".repeat(20); let encoded = encode_workspace_path(Utf8Path::new(&unicode_path));
485
486 assert!(
487 encoded.len() <= MAX_ENCODED_LEN,
488 "encoded path should not exceed {MAX_ENCODED_LEN} bytes: len={}",
489 encoded.len()
490 );
491
492 let _ = encoded.as_str();
494
495 let hash_suffix = &encoded[encoded.len() - HASH_SUFFIX_LEN..];
497 assert!(
498 hash_suffix.chars().all(|c| c.is_ascii_hexdigit()),
499 "hash suffix should be hex digits: {hash_suffix:?}"
500 );
501 }
502
503 #[test]
504 fn test_truncation_boundary_at_96_bytes() {
505 let exactly_96 = "a".repeat(96);
511 let encoded = encode_workspace_path(Utf8Path::new(&exactly_96));
512 assert_eq!(encoded.len(), 96);
513 assert_eq!(encoded, exactly_96); let just_over = "a".repeat(97);
517 let encoded = encode_workspace_path(Utf8Path::new(&just_over));
518 assert_eq!(encoded.len(), 96);
519 let hash_suffix = &encoded[90..];
521 assert!(hash_suffix.chars().all(|c| c.is_ascii_hexdigit()));
522 }
523
524 #[test]
525 fn test_truncation_different_suffixes_same_prefix() {
526 let base = "a".repeat(90);
528 let path_a = format!("{base}XXXXXXX");
529 let path_b = format!("{base}YYYYYYY");
530
531 let encoded_a = encode_workspace_path(Utf8Path::new(&path_a));
532 let encoded_b = encode_workspace_path(Utf8Path::new(&path_b));
533
534 assert_eq!(encoded_a.len(), 96);
536 assert_eq!(encoded_b.len(), 96);
537
538 assert_ne!(
540 &encoded_a[90..],
541 &encoded_b[90..],
542 "different paths should have different hash suffixes"
543 );
544 }
545}