Skip to main content

nextest_runner/record/
state_dir.rs

1// Copyright (c) The nextest Contributors
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Platform-specific state directory discovery for nextest records.
5//!
6//! Test run recordings are stored in `XDG_STATE_HOME` (on Linux/macOS) rather
7//! than `XDG_CACHE_HOME` because they are accumulated state, not regenerable
8//! cache data. The XDG spec defines cache as "non-essential data files [that]
9//! the application must be able to regenerate," but recordings capture a
10//! specific execution at a specific point in time and cannot be regenerated.
11
12use 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
19/// Maximum length of the encoded workspace path in bytes.
20const MAX_ENCODED_LEN: usize = 96;
21
22/// Length of the hash suffix appended to truncated paths.
23///
24/// Between the first many bytes and this, we should ideally have more than
25/// enough entropy to disambiguate repos.
26const HASH_SUFFIX_LEN: usize = 8;
27
28/// Environment variable to override the nextest state directory.
29///
30/// When set, this overrides the platform-specific state directory. The records
31/// directory will be `$NEXTEST_STATE_DIR/projects/<encoded-workspace>/records/`.
32pub const NEXTEST_STATE_DIR_ENV: &str = "NEXTEST_STATE_DIR";
33
34/// Returns the platform-specific state directory for nextest records for a workspace.
35///
36/// If the `NEXTEST_STATE_DIR` environment variable is set, uses that as the base
37/// directory. Otherwise, uses the platform-specific default:
38///
39/// - Linux, macOS, and other Unix: `$XDG_STATE_HOME/nextest/projects/<encoded-workspace>/records/`
40///   or `~/.local/state/nextest/projects/<encoded-workspace>/records/`
41/// - Windows: `%LOCALAPPDATA%\nextest\projects\<encoded-workspace>\records\`
42///   (Windows has no state directory concept, so falls back to cache directory.)
43///
44/// The workspace root is canonicalized (symlinks resolved) before being encoded
45/// using `encode_workspace_path` to produce a directory-safe, bijective
46/// representation. This ensures that accessing a workspace via a symlink
47/// produces the same state directory as accessing it via the real path.
48///
49/// ## Migration from cache to state directory
50///
51/// On first access after upgrading, this function automatically migrates the
52/// entire `~/.cache/nextest/` directory (nextest <= 0.9.125) to
53/// `~/.local/state/nextest/` if the old location exists and the new one does
54/// not. This is a one-time migration.
55///
56/// Returns an error if:
57///
58/// - The platform state directory cannot be determined.
59/// - The workspace path cannot be canonicalized (e.g., doesn't exist).
60/// - Any path is not valid UTF-8.
61pub fn records_state_dir(workspace_root: &Utf8Path) -> Result<Utf8PathBuf, StateDirError> {
62    // If NEXTEST_STATE_DIR is set, use it directly (no migration).
63    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    // Canonicalize the workspace root to resolve symlinks. This ensures that
82    // accessing a workspace via a symlink produces the same state directory.
83    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    // Compute the state directory path. Use state_dir() if available, otherwise
93    // fall back to cache_dir() (Windows has no state directory concept).
94    let nextest_dir = if let Some(base_state_dir) = strategy.state_dir() {
95        // The state directory is available (Unix with XDG). Attempt a one-time
96        // migration from the old cache location.
97        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        // No state directory (Windows). Use cache directory directly.
109        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
121/// Attempts to migrate the entire nextest directory from cache to state location.
122///
123/// This is a one-time migration.
124fn 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    // Attempt an atomic rename.
141    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
155/// Encodes a workspace path into a directory-safe string.
156///
157/// The encoding is bijective (reversible) and produces valid directory names on all
158/// platforms. The encoding scheme uses underscore as an escape character:
159///
160/// - `_` → `__` (escape underscore first)
161/// - `/` → `_s` (Unix path separator)
162/// - `\` → `_b` (Windows path separator)
163/// - `:` → `_c` (Windows drive letter separator)
164/// - `*` → `_a` (asterisk, invalid on Windows)
165/// - `"` → `_q` (double quote, invalid on Windows)
166/// - `<` → `_l` (less than, invalid on Windows)
167/// - `>` → `_g` (greater than, invalid on Windows)
168/// - `|` → `_p` (pipe, invalid on Windows)
169/// - `?` → `_m` (question mark, invalid on Windows)
170///
171/// If the encoded path exceeds 96 bytes, it is truncated at a valid UTF-8 boundary
172/// and an 8-character hash suffix is appended to maintain uniqueness.
173///
174/// # Examples
175///
176/// - `/home/rain/dev/nextest` → `_shome_srain_sdev_snextest`
177/// - `C:\Users\rain\dev` → `C_c_bUsers_brain_bdev`
178/// - `/path_with_underscore` → `_spath__with__underscore`
179/// - `/weird*path?` → `_sweird_apath_m`
180pub 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
202/// Truncates an encoded string to fit within [`MAX_ENCODED_LEN`] bytes.
203///
204/// If the string is already short enough, returns it unchanged. Otherwise,
205/// truncates at a valid UTF-8 boundary and appends an 8-character hash suffix
206/// derived from the full string.
207fn truncate_with_hash(encoded: String) -> String {
208    if encoded.len() <= MAX_ENCODED_LEN {
209        return encoded;
210    }
211
212    // Compute hash of full string before truncation.
213    let hash = xxh3_64(encoded.as_bytes());
214    let hash_suffix = format!("{:08x}", hash & 0xFFFFFFFF);
215
216    // Find the longest valid UTF-8 prefix that fits.
217    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    // Use utf8_chunks to find the valid UTF-8 portion.
222    let mut valid_len = 0;
223    for chunk in truncated_bytes.utf8_chunks() {
224        valid_len += chunk.valid().len();
225        // Stop at first invalid sequence (which would be an incomplete multi-byte char).
226        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/// Decodes a workspace path that was encoded with [`encode_workspace_path`].
237///
238/// Returns `None` if the encoded string is malformed (contains an invalid escape
239/// sequence like `_x` where `x` is not a recognized escape character).
240#[cfg_attr(not(test), expect(dead_code))] // Will be used in replay phase.
241pub 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                // Malformed: `_` at end of string or followed by unknown char.
259                _ => 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        // Use a real existing path (the temp dir always exists).
276        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        // Create a temp directory and a symlink pointing to it.
297        let temp_dir = camino_tempfile::tempdir().expect("tempdir should be created");
298        let real_path = temp_dir.path().to_path_buf();
299
300        // Create a subdirectory to serve as the "workspace".
301        let workspace = real_path.join("workspace");
302        fs::create_dir(&workspace).expect("workspace dir should be created");
303
304        // Create a symlink pointing to the workspace.
305        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        // Get state dir via the real path.
316        let state_via_real =
317            records_state_dir(&workspace).expect("state dir via real path should be available");
318
319        // Get state dir via the symlink.
320        let state_via_symlink =
321            records_state_dir(&symlink_path).expect("state dir via symlink should be available");
322
323        // They should be the same because canonicalization resolves the symlink.
324        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        // This test verifies that the entire nextest directory is migrated at once.
333        let temp_dir = camino_tempfile::tempdir().expect("tempdir should be created");
334        let base = temp_dir.path();
335
336        // Create the old cache nextest directory with multiple workspaces.
337        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        // Create marker files in both workspaces.
350        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        // Verify the old location exists.
356        assert!(
357            old_nextest.exists(),
358            "old nextest dir should exist before migration"
359        );
360
361        // Simulate migration by calling migrate_nextest_dir directly.
362        let new_nextest = base.join("state").join("nextest");
363        migrate_nextest_dir(&old_nextest, &new_nextest);
364
365        // Verify migration succeeded: old is gone, new has all the content.
366        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        // Put different content in each to verify no migration occurs.
405        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        // Both should still exist with their original content.
411        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        // Migration should not occur if the old directory doesn't exist.
426        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    // Basic encoding tests.
442    #[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            // Windows-invalid characters.
455            ("/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            // All Windows-invalid characters combined.
461            ("*\"<>|?", "_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    // Roundtrip tests: encode then decode should return original.
474    #[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            // Windows-invalid characters (valid on Unix).
488            "/path*with*asterisks",
489            "/file?query",
490            "/path<with>angles",
491            "/pipe|char",
492            "/\"quoted\"",
493            // All special chars in one path.
494            "/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    // Bijectivity tests: different inputs must produce different outputs.
509    #[test]
510    fn test_encoding_is_bijective() {
511        // These pairs were problematic with the simple dash-based encoding.
512        let pairs = [
513            ("/-", "-/"),
514            ("/a", "_a"),
515            ("_s", "/"),
516            ("a_", "a/"),
517            ("__", "_"),
518            ("/", "\\"),
519            // New escape sequences for Windows-invalid characters.
520            ("_a", "*"),
521            ("_q", "\""),
522            ("_l", "<"),
523            ("_g", ">"),
524            ("_p", "|"),
525            ("_m", "?"),
526            // Ensure Windows-invalid chars don't collide with each other.
527            ("*", "?"),
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    // Decode should reject malformed inputs.
543    #[test]
544    fn test_decode_rejects_malformed() {
545        let malformed_inputs = [
546            "_",     // underscore at end
547            "_x",    // unknown escape sequence
548            "foo_",  // underscore at end after content
549            "foo_x", // unknown escape in middle
550            "_S",    // uppercase S not valid
551        ];
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    // Valid escape sequences should decode.
562    #[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            // Windows-invalid character escapes.
572            ("_a", "*"),
573            ("_q", "\""),
574            ("_l", "<"),
575            ("_g", ">"),
576            ("_p", "|"),
577            ("_m", "?"),
578            // Combined.
579            ("_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    // Truncation tests.
593    #[test]
594    fn test_short_paths_not_truncated() {
595        // A path that encodes to exactly 96 bytes should not be truncated.
596        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        // Should not contain a hash suffix (no truncation occurred).
604        assert_eq!(encoded, "_sa_sb_sc_sd");
605    }
606
607    #[test]
608    fn test_long_paths_truncated_with_hash() {
609        // Create a path that will definitely exceed 96 bytes when encoded.
610        // Each `/x` becomes `_sx` (3 bytes), so we need > 32 components.
611        let long_path = "/a".repeat(50); // 100 bytes raw, 150 bytes encoded
612        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        // Should end with an 8-character hex hash.
622        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        // Two different long paths should produce different truncated results.
632        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        // Create a path with multi-byte UTF-8 characters that would be split.
647        // '日' is 3 bytes in UTF-8.
648        let unicode_path = "/日本語".repeat(20); // Each repeat is 10 bytes raw.
649        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        // Verify the result is valid UTF-8 (this would panic if not).
658        let _ = encoded.as_str();
659
660        // Verify the hash suffix is present and valid hex.
661        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        // Create paths of varying lengths around the 96-byte boundary.
671        // The encoding doubles some characters, so we need to be careful.
672
673        // A path that encodes to exactly 96 bytes should not be truncated.
674        // 'a' stays as 'a', so we can use a string of 96 'a's.
675        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); // No hash suffix.
679
680        // A path that encodes to 97 bytes should be truncated.
681        let just_over = "a".repeat(97);
682        let encoded = encode_workspace_path(Utf8Path::new(&just_over));
683        assert_eq!(encoded.len(), 96);
684        // Should have hash suffix.
685        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        // Two paths with the same prefix but different endings should get different hashes.
692        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        // Both should be truncated (97 chars each).
700        assert_eq!(encoded_a.len(), 96);
701        assert_eq!(encoded_b.len(), 96);
702
703        // The hash suffixes should be different.
704        assert_ne!(
705            &encoded_a[90..],
706            &encoded_b[90..],
707            "different paths should have different hash suffixes"
708        );
709    }
710}