Skip to main content

nextest_runner/record/
run_id_index.rs

1// Copyright (c) The nextest Contributors
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Index for run IDs enabling efficient prefix lookup and shortest unique prefix computation.
5
6use super::{format::has_zip_extension, store::RecordedRunInfo};
7use crate::errors::{InvalidRunIdOrRecordingSelector, InvalidRunIdSelector};
8use camino::Utf8PathBuf;
9use quick_junit::ReportUuid;
10use std::{fmt, str::FromStr};
11
12/// Selector that can be either a run ID (for store lookup) or a recording path.
13///
14/// Used by commands that can consume recorded runs from either source, such as
15/// `store info`, `replay`, and `run --rerun`.
16///
17/// When parsing from a string:
18/// - Paths ending in `.zip` are treated as recording paths.
19/// - Strings containing path separators (`/` or `\`) are treated as recording
20///   paths. This enables process substitution paths like `/proc/self/fd/11`
21///   (Linux) or `/dev/fd/5` (macOS).
22/// - Everything else is parsed as a [`RunIdSelector`].
23#[derive(Clone, Debug, PartialEq, Eq)]
24pub enum RunIdOrRecordingSelector {
25    /// Select from the run store.
26    RunId(RunIdSelector),
27    /// Read from a portable recording file.
28    RecordingPath(Utf8PathBuf),
29}
30
31impl FromStr for RunIdOrRecordingSelector {
32    type Err = InvalidRunIdOrRecordingSelector;
33
34    fn from_str(s: &str) -> Result<Self, Self::Err> {
35        let path = Utf8PathBuf::from(s);
36        if has_zip_extension(&path) {
37            return Ok(RunIdOrRecordingSelector::RecordingPath(path));
38        }
39
40        match s.parse::<RunIdSelector>() {
41            Ok(selector) => Ok(RunIdOrRecordingSelector::RunId(selector)),
42            Err(_) => {
43                // If the input contains path separators, treat it as a file
44                // path. This handles process substitution paths like
45                // `/proc/self/fd/11` or `/dev/fd/5`, as well as relative paths
46                // like `./recording` or `../path/to/file`. Valid run ID
47                // selectors never contain `/` or `\`, so this is unambiguous.
48                if s.contains('/') || s.contains(std::path::MAIN_SEPARATOR) {
49                    Ok(RunIdOrRecordingSelector::RecordingPath(path))
50                } else {
51                    Err(InvalidRunIdOrRecordingSelector {
52                        input: s.to_owned(),
53                    })
54                }
55            }
56        }
57    }
58}
59
60impl Default for RunIdOrRecordingSelector {
61    fn default() -> Self {
62        RunIdOrRecordingSelector::RunId(RunIdSelector::Latest)
63    }
64}
65
66impl fmt::Display for RunIdOrRecordingSelector {
67    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
68        match self {
69            RunIdOrRecordingSelector::RunId(selector) => write!(f, "{selector}"),
70            RunIdOrRecordingSelector::RecordingPath(path) => write!(f, "{path}"),
71        }
72    }
73}
74
75/// Selector for identifying a run, either the most recent or by prefix.
76///
77/// This is used by CLI commands that need to specify a run ID. The `Latest`
78/// variant selects the most recent completed run, while `Prefix` allows
79/// specifying a run by its ID prefix.
80#[derive(Clone, Debug, Default, PartialEq, Eq)]
81pub enum RunIdSelector {
82    /// Select the most recent completed run.
83    #[default]
84    Latest,
85
86    /// Select a run by ID prefix.
87    ///
88    /// The prefix contains only hex digits and optional dashes (for UUID format).
89    Prefix(String),
90}
91
92impl FromStr for RunIdSelector {
93    type Err = InvalidRunIdSelector;
94
95    fn from_str(s: &str) -> Result<Self, Self::Err> {
96        if s == "latest" {
97            Ok(RunIdSelector::Latest)
98        } else {
99            // Validate that the prefix contains only hex digits and dashes.
100            let is_valid = !s.is_empty() && s.chars().all(|c| c.is_ascii_hexdigit() || c == '-');
101            if is_valid {
102                Ok(RunIdSelector::Prefix(s.to_owned()))
103            } else {
104                Err(InvalidRunIdSelector {
105                    input: s.to_owned(),
106                })
107            }
108        }
109    }
110}
111
112impl fmt::Display for RunIdSelector {
113    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
114        match self {
115            RunIdSelector::Latest => write!(f, "latest"),
116            RunIdSelector::Prefix(prefix) => write!(f, "{prefix}"),
117        }
118    }
119}
120
121/// An index of run IDs enabling efficient prefix lookup and shortest unique
122/// prefix computation.
123///
124/// This uses a sorted index with neighbor comparison (inspired by jujutsu's
125/// approach) rather than a trie. For each ID, the shortest unique prefix is
126/// determined by comparing with the lexicographically adjacent IDs—the minimum
127/// prefix length needed to distinguish from both neighbors.
128#[derive(Clone, Debug)]
129pub struct RunIdIndex {
130    /// Run IDs paired with their normalized hex representation, sorted by hex.
131    sorted_entries: Vec<RunIdIndexEntry>,
132}
133
134/// An entry in the run ID index.
135#[derive(Clone, Debug)]
136struct RunIdIndexEntry {
137    run_id: ReportUuid,
138    /// Normalized hex representation (lowercase, no dashes).
139    hex: String,
140}
141
142impl RunIdIndex {
143    /// Creates a new index from a list of runs.
144    pub fn new(runs: &[RecordedRunInfo]) -> Self {
145        let mut sorted_entries: Vec<_> = runs
146            .iter()
147            .map(|r| RunIdIndexEntry {
148                run_id: r.run_id,
149                hex: r.run_id.to_string().replace('-', "").to_lowercase(),
150            })
151            .collect();
152
153        // Sort by normalized hex representation for consistent ordering.
154        sorted_entries.sort_by(|a, b| a.hex.cmp(&b.hex));
155        Self { sorted_entries }
156    }
157
158    /// Returns the shortest unique prefix length for the given run ID.
159    ///
160    /// The returned length is in hex characters (not including dashes). Returns `None` if the
161    /// run ID is not in the index.
162    pub fn shortest_unique_prefix_len(&self, run_id: ReportUuid) -> Option<usize> {
163        // Find the position of this ID in the sorted list.
164        let pos = self
165            .sorted_entries
166            .iter()
167            .position(|entry| entry.run_id == run_id)?;
168
169        let target_hex = &self.sorted_entries[pos].hex;
170
171        // Compare with neighbors to find the minimum distinguishing prefix length.
172        let mut min_len = 1; // At least 1 character.
173
174        // Compare with previous neighbor.
175        if pos > 0 {
176            let prev_hex = &self.sorted_entries[pos - 1].hex;
177            let common = common_hex_prefix_len(target_hex, prev_hex);
178            min_len = min_len.max(common + 1);
179        }
180
181        // Compare with next neighbor.
182        if pos + 1 < self.sorted_entries.len() {
183            let next_hex = &self.sorted_entries[pos + 1].hex;
184            let common = common_hex_prefix_len(target_hex, next_hex);
185            min_len = min_len.max(common + 1);
186        }
187
188        Some(min_len)
189    }
190
191    /// Returns the shortest unique prefix for the given run ID.
192    ///
193    /// The prefix is the minimum string needed to uniquely identify this run
194    /// among all runs in the index. Both parts include dashes in the standard
195    /// UUID positions.
196    ///
197    /// Returns `None` if the run ID is not in the index.
198    pub fn shortest_unique_prefix(&self, run_id: ReportUuid) -> Option<ShortestRunIdPrefix> {
199        let prefix_len = self.shortest_unique_prefix_len(run_id)?;
200        Some(ShortestRunIdPrefix::new(run_id, prefix_len))
201    }
202
203    /// Resolves a prefix to a run ID.
204    ///
205    /// The prefix can include or omit dashes. Returns `Ok(run_id)` if exactly
206    /// one run matches, or an error if none or multiple match.
207    pub fn resolve_prefix(&self, prefix: &str) -> Result<ReportUuid, PrefixResolutionError> {
208        // Validate and normalize the prefix.
209        let normalized = prefix.replace('-', "").to_lowercase();
210        if !normalized.chars().all(|c| c.is_ascii_hexdigit()) {
211            return Err(PrefixResolutionError::InvalidPrefix);
212        }
213
214        // Find all matching IDs using binary search for the range.
215        // First, find the start of the range.
216        let start = self
217            .sorted_entries
218            .partition_point(|entry| entry.hex.as_str() < normalized.as_str());
219
220        // Collect matches: all entries whose hex starts with the normalized prefix.
221        let matches: Vec<_> = self.sorted_entries[start..]
222            .iter()
223            .take_while(|entry| entry.hex.starts_with(&normalized))
224            .map(|entry| entry.run_id)
225            .collect();
226
227        match matches.len() {
228            0 => Err(PrefixResolutionError::NotFound),
229            1 => Ok(matches[0]),
230            n => {
231                let candidates = matches.into_iter().take(8).collect();
232                Err(PrefixResolutionError::Ambiguous {
233                    count: n,
234                    candidates,
235                })
236            }
237        }
238    }
239
240    /// Returns the number of run IDs in the index.
241    pub fn len(&self) -> usize {
242        self.sorted_entries.len()
243    }
244
245    /// Returns true if the index is empty.
246    pub fn is_empty(&self) -> bool {
247        self.sorted_entries.is_empty()
248    }
249
250    /// Returns an iterator over all run IDs in sorted order.
251    pub fn iter(&self) -> impl Iterator<Item = ReportUuid> + '_ {
252        self.sorted_entries.iter().map(|entry| entry.run_id)
253    }
254}
255
256/// The shortest unique prefix for a run ID, split into the unique prefix and remaining portion.
257///
258/// This is useful for display purposes where the unique prefix can be highlighted differently
259/// (e.g., in a different color) from the rest of the ID.
260#[derive(Clone, Debug, PartialEq, Eq)]
261pub struct ShortestRunIdPrefix {
262    /// The unique prefix portion (the minimum needed to identify this run).
263    pub prefix: String,
264    /// The remaining portion of the run ID.
265    pub rest: String,
266}
267
268impl ShortestRunIdPrefix {
269    /// Creates a new shortest prefix by splitting a UUID at the given hex character count.
270    ///
271    /// The `hex_len` is the number of hex characters (not including dashes) for the prefix.
272    fn new(run_id: ReportUuid, hex_len: usize) -> Self {
273        let full = run_id.to_string();
274
275        // The UUID format is xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx.
276        // The dash positions (0-indexed) are at 8, 13, 18, 23.
277        // We need to find the string index that corresponds to `hex_len` hex characters.
278        let split_index = hex_len_to_string_index(hex_len);
279        let split_index = split_index.min(full.len());
280
281        let (prefix, rest) = full.split_at(split_index);
282        Self {
283            prefix: prefix.to_string(),
284            rest: rest.to_string(),
285        }
286    }
287
288    /// Returns the full run ID by concatenating prefix and rest.
289    pub fn full(&self) -> String {
290        format!("{}{}", self.prefix, self.rest)
291    }
292}
293
294/// Converts a hex character count to a string index in UUID format.
295///
296/// UUID format: `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`
297/// - Positions 0-7: 8 hex chars
298/// - Position 8: dash
299/// - Positions 9-12: 4 hex chars (total 12)
300/// - Position 13: dash
301/// - Positions 14-17: 4 hex chars (total 16)
302/// - Position 18: dash
303/// - Positions 19-22: 4 hex chars (total 20)
304/// - Position 23: dash
305/// - Positions 24-35: 12 hex chars (total 32)
306fn hex_len_to_string_index(hex_len: usize) -> usize {
307    // Count how many dashes come before the hex_len'th hex character.
308    let dashes = match hex_len {
309        0..=8 => 0,
310        9..=12 => 1,
311        13..=16 => 2,
312        17..=20 => 3,
313        21..=32 => 4,
314        _ => 4, // Max 32 hex chars in a UUID.
315    };
316    hex_len + dashes
317}
318
319/// Computes the length of the common prefix between two hex strings.
320fn common_hex_prefix_len(a: &str, b: &str) -> usize {
321    a.chars()
322        .zip(b.chars())
323        .take_while(|(ca, cb)| ca == cb)
324        .count()
325}
326
327/// Internal error type for prefix resolution.
328///
329/// This is converted to [`crate::errors::RunIdResolutionError`] by
330/// [`super::store::RunStoreSnapshot::resolve_run_id`], which can enrich the
331/// error with full `RecordedRunInfo` data.
332#[derive(Clone, Debug)]
333pub enum PrefixResolutionError {
334    /// No run found matching the prefix.
335    NotFound,
336
337    /// Multiple runs match the prefix.
338    Ambiguous {
339        /// The total number of matching runs.
340        count: usize,
341        /// The candidates that matched (up to a limit).
342        candidates: Vec<ReportUuid>,
343    },
344
345    /// The prefix contains invalid characters (expected hexadecimal).
346    InvalidPrefix,
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352    use crate::record::{RecordedRunStatus, RecordedSizes, format::STORE_FORMAT_VERSION};
353    use chrono::TimeZone;
354    use semver::Version;
355    use std::collections::BTreeMap;
356
357    /// Creates a test run with the given run ID.
358    fn make_run(run_id: ReportUuid) -> RecordedRunInfo {
359        let started_at = chrono::FixedOffset::east_opt(0)
360            .unwrap()
361            .with_ymd_and_hms(2024, 1, 1, 0, 0, 0)
362            .unwrap();
363        RecordedRunInfo {
364            run_id,
365            store_format_version: STORE_FORMAT_VERSION,
366            nextest_version: Version::new(0, 1, 0),
367            started_at,
368            last_written_at: started_at,
369            duration_secs: None,
370            cli_args: Vec::new(),
371            build_scope_args: Vec::new(),
372            env_vars: BTreeMap::new(),
373            parent_run_id: None,
374            sizes: RecordedSizes::default(),
375            status: RecordedRunStatus::Incomplete,
376        }
377    }
378
379    #[test]
380    fn test_empty_index() {
381        let index = RunIdIndex::new(&[]);
382        assert!(index.is_empty());
383        assert_eq!(index.len(), 0);
384    }
385
386    #[test]
387    fn test_single_entry() {
388        let runs = vec![make_run(ReportUuid::from_u128(
389            0x550e8400_e29b_41d4_a716_446655440000,
390        ))];
391        let index = RunIdIndex::new(&runs);
392
393        assert_eq!(index.len(), 1);
394
395        // With only one entry, shortest prefix is 1 character.
396        assert_eq!(index.shortest_unique_prefix_len(runs[0].run_id), Some(1));
397
398        let prefix = index.shortest_unique_prefix(runs[0].run_id).unwrap();
399        assert_eq!(prefix.prefix, "5");
400        assert_eq!(prefix.rest, "50e8400-e29b-41d4-a716-446655440000");
401        assert_eq!(prefix.full(), "550e8400-e29b-41d4-a716-446655440000");
402    }
403
404    #[test]
405    fn test_shared_prefix() {
406        // Two UUIDs that share the first 4 hex characters "5555".
407        let runs = vec![
408            make_run(ReportUuid::from_u128(
409                0x55551111_0000_0000_0000_000000000000,
410            )),
411            make_run(ReportUuid::from_u128(
412                0x55552222_0000_0000_0000_000000000000,
413            )),
414        ];
415        let index = RunIdIndex::new(&runs);
416
417        // Both need 5 characters to be unique (shared "5555", differ at position 5).
418        assert_eq!(index.shortest_unique_prefix_len(runs[0].run_id), Some(5));
419        assert_eq!(index.shortest_unique_prefix_len(runs[1].run_id), Some(5));
420
421        let prefix0 = index.shortest_unique_prefix(runs[0].run_id).unwrap();
422        assert_eq!(prefix0.prefix, "55551");
423        assert_eq!(prefix0.rest, "111-0000-0000-0000-000000000000");
424
425        let prefix1 = index.shortest_unique_prefix(runs[1].run_id).unwrap();
426        assert_eq!(prefix1.prefix, "55552");
427    }
428
429    #[test]
430    fn test_asymmetric_neighbors() {
431        // Three UUIDs where prefix lengths differ based on neighbors.
432        // 1111... < 1112... < 2222...
433        let runs = vec![
434            make_run(ReportUuid::from_u128(
435                0x11110000_0000_0000_0000_000000000000,
436            )),
437            make_run(ReportUuid::from_u128(
438                0x11120000_0000_0000_0000_000000000000,
439            )),
440            make_run(ReportUuid::from_u128(
441                0x22220000_0000_0000_0000_000000000000,
442            )),
443        ];
444        let index = RunIdIndex::new(&runs);
445
446        // First two share "111", need 4 chars each.
447        assert_eq!(index.shortest_unique_prefix_len(runs[0].run_id), Some(4));
448        assert_eq!(index.shortest_unique_prefix_len(runs[1].run_id), Some(4));
449        // Third differs at first char from its only neighbor.
450        assert_eq!(index.shortest_unique_prefix_len(runs[2].run_id), Some(1));
451    }
452
453    #[test]
454    fn test_prefix_crosses_dash() {
455        // Prefix extends past the first dash (position 8). Share first 9 hex chars.
456        let runs = vec![
457            make_run(ReportUuid::from_u128(
458                0x12345678_9000_0000_0000_000000000000,
459            )),
460            make_run(ReportUuid::from_u128(
461                0x12345678_9111_0000_0000_000000000000,
462            )),
463        ];
464        let index = RunIdIndex::new(&runs);
465
466        assert_eq!(index.shortest_unique_prefix_len(runs[0].run_id), Some(10));
467
468        // Prefix string includes the dash.
469        let prefix = index.shortest_unique_prefix(runs[0].run_id).unwrap();
470        assert_eq!(prefix.prefix, "12345678-90");
471        assert_eq!(prefix.rest, "00-0000-0000-000000000000");
472    }
473
474    #[test]
475    fn test_resolve_prefix() {
476        let runs = vec![
477            make_run(ReportUuid::from_u128(
478                0xabcdef00_1234_5678_9abc_def012345678,
479            )),
480            make_run(ReportUuid::from_u128(
481                0x22222222_2222_2222_2222_222222222222,
482            )),
483            make_run(ReportUuid::from_u128(
484                0x23333333_3333_3333_3333_333333333333,
485            )),
486        ];
487        let index = RunIdIndex::new(&runs);
488
489        // Single match.
490        assert_eq!(index.resolve_prefix("abc").unwrap(), runs[0].run_id);
491        assert_eq!(index.resolve_prefix("22").unwrap(), runs[1].run_id);
492
493        // Case insensitive.
494        assert_eq!(index.resolve_prefix("ABC").unwrap(), runs[0].run_id);
495        assert_eq!(index.resolve_prefix("AbC").unwrap(), runs[0].run_id);
496
497        // With dashes.
498        assert_eq!(index.resolve_prefix("abcdef00-").unwrap(), runs[0].run_id);
499        assert_eq!(index.resolve_prefix("abcdef00-12").unwrap(), runs[0].run_id);
500
501        // Ambiguous.
502        let err = index.resolve_prefix("2").unwrap_err();
503        assert!(matches!(
504            err,
505            PrefixResolutionError::Ambiguous { count: 2, .. }
506        ));
507
508        // Not found.
509        let err = index.resolve_prefix("9").unwrap_err();
510        assert!(matches!(err, PrefixResolutionError::NotFound));
511
512        // Invalid.
513        let err = index.resolve_prefix("xyz").unwrap_err();
514        assert!(matches!(err, PrefixResolutionError::InvalidPrefix));
515    }
516
517    #[test]
518    fn test_not_in_index() {
519        let runs = vec![make_run(ReportUuid::from_u128(
520            0x11111111_1111_1111_1111_111111111111,
521        ))];
522        let index = RunIdIndex::new(&runs);
523
524        let other = ReportUuid::from_u128(0x22222222_2222_2222_2222_222222222222);
525        assert_eq!(index.shortest_unique_prefix_len(other), None);
526        assert_eq!(index.shortest_unique_prefix(other), None);
527    }
528
529    #[test]
530    fn test_hex_len_to_string_index() {
531        // Before first dash (position 8).
532        assert_eq!(hex_len_to_string_index(0), 0);
533        assert_eq!(hex_len_to_string_index(8), 8);
534        // After each dash.
535        assert_eq!(hex_len_to_string_index(9), 10);
536        assert_eq!(hex_len_to_string_index(13), 15);
537        assert_eq!(hex_len_to_string_index(17), 20);
538        assert_eq!(hex_len_to_string_index(21), 25);
539        // Full UUID.
540        assert_eq!(hex_len_to_string_index(32), 36);
541    }
542
543    #[test]
544    fn test_run_id_selector_default() {
545        assert_eq!(RunIdSelector::default(), RunIdSelector::Latest);
546    }
547
548    #[test]
549    fn test_run_id_selector_from_str() {
550        // Only exact "latest" parses to Latest.
551        assert_eq!(
552            "latest".parse::<RunIdSelector>().unwrap(),
553            RunIdSelector::Latest
554        );
555
556        // Valid hex prefixes.
557        assert_eq!(
558            "abc123".parse::<RunIdSelector>().unwrap(),
559            RunIdSelector::Prefix("abc123".to_owned())
560        );
561        assert_eq!(
562            "550e8400-e29b-41d4".parse::<RunIdSelector>().unwrap(),
563            RunIdSelector::Prefix("550e8400-e29b-41d4".to_owned())
564        );
565        assert_eq!(
566            "ABCDEF".parse::<RunIdSelector>().unwrap(),
567            RunIdSelector::Prefix("ABCDEF".to_owned())
568        );
569        assert_eq!(
570            "0".parse::<RunIdSelector>().unwrap(),
571            RunIdSelector::Prefix("0".to_owned())
572        );
573
574        // "Latest" contains non-hex characters.
575        assert!("Latest".parse::<RunIdSelector>().is_err());
576        assert!("LATEST".parse::<RunIdSelector>().is_err());
577
578        // Contains non-hex characters.
579        assert!("xyz".parse::<RunIdSelector>().is_err());
580        assert!("abc_123".parse::<RunIdSelector>().is_err());
581        assert!("hello".parse::<RunIdSelector>().is_err());
582
583        // Empty string is invalid.
584        assert!("".parse::<RunIdSelector>().is_err());
585    }
586
587    #[test]
588    fn test_run_id_selector_display() {
589        assert_eq!(RunIdSelector::Latest.to_string(), "latest");
590        assert_eq!(
591            RunIdSelector::Prefix("abc123".to_owned()).to_string(),
592            "abc123"
593        );
594    }
595
596    #[test]
597    fn test_run_id_or_archive_selector_default() {
598        assert_eq!(
599            RunIdOrRecordingSelector::default(),
600            RunIdOrRecordingSelector::RunId(RunIdSelector::Latest)
601        );
602    }
603
604    #[test]
605    fn test_run_id_or_archive_selector_from_str() {
606        // "latest" parses to RunId(Latest).
607        assert_eq!(
608            "latest".parse::<RunIdOrRecordingSelector>().unwrap(),
609            RunIdOrRecordingSelector::RunId(RunIdSelector::Latest)
610        );
611
612        // Hex prefixes parse to RunId(Prefix(...)).
613        assert_eq!(
614            "abc123".parse::<RunIdOrRecordingSelector>().unwrap(),
615            RunIdOrRecordingSelector::RunId(RunIdSelector::Prefix("abc123".to_owned()))
616        );
617
618        // Paths ending in .zip parse to RecordingPath.
619        assert_eq!(
620            "nextest-run-abc123.zip"
621                .parse::<RunIdOrRecordingSelector>()
622                .unwrap(),
623            RunIdOrRecordingSelector::RecordingPath(Utf8PathBuf::from("nextest-run-abc123.zip"))
624        );
625        assert_eq!(
626            "/path/to/archive.zip"
627                .parse::<RunIdOrRecordingSelector>()
628                .unwrap(),
629            RunIdOrRecordingSelector::RecordingPath(Utf8PathBuf::from("/path/to/archive.zip"))
630        );
631        assert_eq!(
632            "../relative/path.zip"
633                .parse::<RunIdOrRecordingSelector>()
634                .unwrap(),
635            RunIdOrRecordingSelector::RecordingPath(Utf8PathBuf::from("../relative/path.zip"))
636        );
637
638        // Paths with separators (no .zip) parse to RecordingPath. This
639        // covers process substitution paths and relative paths.
640        assert_eq!(
641            "/proc/self/fd/11"
642                .parse::<RunIdOrRecordingSelector>()
643                .unwrap(),
644            RunIdOrRecordingSelector::RecordingPath(Utf8PathBuf::from("/proc/self/fd/11"))
645        );
646        assert_eq!(
647            "/dev/fd/5".parse::<RunIdOrRecordingSelector>().unwrap(),
648            RunIdOrRecordingSelector::RecordingPath(Utf8PathBuf::from("/dev/fd/5"))
649        );
650        assert_eq!(
651            "./my-recording"
652                .parse::<RunIdOrRecordingSelector>()
653                .unwrap(),
654            RunIdOrRecordingSelector::RecordingPath(Utf8PathBuf::from("./my-recording"))
655        );
656        assert_eq!(
657            "../path/to/file"
658                .parse::<RunIdOrRecordingSelector>()
659                .unwrap(),
660            RunIdOrRecordingSelector::RecordingPath(Utf8PathBuf::from("../path/to/file"))
661        );
662        // Backslash is treated as a path separator (Windows paths).
663        #[cfg(windows)]
664        {
665            assert_eq!(
666                r"C:\path\to\file"
667                    .parse::<RunIdOrRecordingSelector>()
668                    .unwrap(),
669                RunIdOrRecordingSelector::RecordingPath(Utf8PathBuf::from(r"C:\path\to\file"))
670            );
671        }
672
673        // Invalid run ID selector (not hex, not .zip, no path separator)
674        // still fails.
675        assert!("xyz".parse::<RunIdOrRecordingSelector>().is_err());
676        assert!("hello".parse::<RunIdOrRecordingSelector>().is_err());
677        // Typos of "latest" should still fail, not be treated as paths.
678        assert!("latets".parse::<RunIdOrRecordingSelector>().is_err());
679        assert!("latestt".parse::<RunIdOrRecordingSelector>().is_err());
680        // Bare filenames without separators or .zip extension should fail.
681        assert!("recording".parse::<RunIdOrRecordingSelector>().is_err());
682    }
683
684    #[test]
685    fn test_run_id_or_archive_selector_display() {
686        assert_eq!(
687            RunIdOrRecordingSelector::RunId(RunIdSelector::Latest).to_string(),
688            "latest"
689        );
690        assert_eq!(
691            RunIdOrRecordingSelector::RunId(RunIdSelector::Prefix("abc123".to_owned())).to_string(),
692            "abc123"
693        );
694        assert_eq!(
695            RunIdOrRecordingSelector::RecordingPath(Utf8PathBuf::from("/path/to/archive.zip"))
696                .to_string(),
697            "/path/to/archive.zip"
698        );
699    }
700}