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            env_vars: BTreeMap::new(),
372            parent_run_id: None,
373            sizes: RecordedSizes::default(),
374            status: RecordedRunStatus::Incomplete,
375        }
376    }
377
378    #[test]
379    fn test_empty_index() {
380        let index = RunIdIndex::new(&[]);
381        assert!(index.is_empty());
382        assert_eq!(index.len(), 0);
383    }
384
385    #[test]
386    fn test_single_entry() {
387        let runs = vec![make_run(ReportUuid::from_u128(
388            0x550e8400_e29b_41d4_a716_446655440000,
389        ))];
390        let index = RunIdIndex::new(&runs);
391
392        assert_eq!(index.len(), 1);
393
394        // With only one entry, shortest prefix is 1 character.
395        assert_eq!(index.shortest_unique_prefix_len(runs[0].run_id), Some(1));
396
397        let prefix = index.shortest_unique_prefix(runs[0].run_id).unwrap();
398        assert_eq!(prefix.prefix, "5");
399        assert_eq!(prefix.rest, "50e8400-e29b-41d4-a716-446655440000");
400        assert_eq!(prefix.full(), "550e8400-e29b-41d4-a716-446655440000");
401    }
402
403    #[test]
404    fn test_shared_prefix() {
405        // Two UUIDs that share the first 4 hex characters "5555".
406        let runs = vec![
407            make_run(ReportUuid::from_u128(
408                0x55551111_0000_0000_0000_000000000000,
409            )),
410            make_run(ReportUuid::from_u128(
411                0x55552222_0000_0000_0000_000000000000,
412            )),
413        ];
414        let index = RunIdIndex::new(&runs);
415
416        // Both need 5 characters to be unique (shared "5555", differ at position 5).
417        assert_eq!(index.shortest_unique_prefix_len(runs[0].run_id), Some(5));
418        assert_eq!(index.shortest_unique_prefix_len(runs[1].run_id), Some(5));
419
420        let prefix0 = index.shortest_unique_prefix(runs[0].run_id).unwrap();
421        assert_eq!(prefix0.prefix, "55551");
422        assert_eq!(prefix0.rest, "111-0000-0000-0000-000000000000");
423
424        let prefix1 = index.shortest_unique_prefix(runs[1].run_id).unwrap();
425        assert_eq!(prefix1.prefix, "55552");
426    }
427
428    #[test]
429    fn test_asymmetric_neighbors() {
430        // Three UUIDs where prefix lengths differ based on neighbors.
431        // 1111... < 1112... < 2222...
432        let runs = vec![
433            make_run(ReportUuid::from_u128(
434                0x11110000_0000_0000_0000_000000000000,
435            )),
436            make_run(ReportUuid::from_u128(
437                0x11120000_0000_0000_0000_000000000000,
438            )),
439            make_run(ReportUuid::from_u128(
440                0x22220000_0000_0000_0000_000000000000,
441            )),
442        ];
443        let index = RunIdIndex::new(&runs);
444
445        // First two share "111", need 4 chars each.
446        assert_eq!(index.shortest_unique_prefix_len(runs[0].run_id), Some(4));
447        assert_eq!(index.shortest_unique_prefix_len(runs[1].run_id), Some(4));
448        // Third differs at first char from its only neighbor.
449        assert_eq!(index.shortest_unique_prefix_len(runs[2].run_id), Some(1));
450    }
451
452    #[test]
453    fn test_prefix_crosses_dash() {
454        // Prefix extends past the first dash (position 8). Share first 9 hex chars.
455        let runs = vec![
456            make_run(ReportUuid::from_u128(
457                0x12345678_9000_0000_0000_000000000000,
458            )),
459            make_run(ReportUuid::from_u128(
460                0x12345678_9111_0000_0000_000000000000,
461            )),
462        ];
463        let index = RunIdIndex::new(&runs);
464
465        assert_eq!(index.shortest_unique_prefix_len(runs[0].run_id), Some(10));
466
467        // Prefix string includes the dash.
468        let prefix = index.shortest_unique_prefix(runs[0].run_id).unwrap();
469        assert_eq!(prefix.prefix, "12345678-90");
470        assert_eq!(prefix.rest, "00-0000-0000-000000000000");
471    }
472
473    #[test]
474    fn test_resolve_prefix() {
475        let runs = vec![
476            make_run(ReportUuid::from_u128(
477                0xabcdef00_1234_5678_9abc_def012345678,
478            )),
479            make_run(ReportUuid::from_u128(
480                0x22222222_2222_2222_2222_222222222222,
481            )),
482            make_run(ReportUuid::from_u128(
483                0x23333333_3333_3333_3333_333333333333,
484            )),
485        ];
486        let index = RunIdIndex::new(&runs);
487
488        // Single match.
489        assert_eq!(index.resolve_prefix("abc").unwrap(), runs[0].run_id);
490        assert_eq!(index.resolve_prefix("22").unwrap(), runs[1].run_id);
491
492        // Case insensitive.
493        assert_eq!(index.resolve_prefix("ABC").unwrap(), runs[0].run_id);
494        assert_eq!(index.resolve_prefix("AbC").unwrap(), runs[0].run_id);
495
496        // With dashes.
497        assert_eq!(index.resolve_prefix("abcdef00-").unwrap(), runs[0].run_id);
498        assert_eq!(index.resolve_prefix("abcdef00-12").unwrap(), runs[0].run_id);
499
500        // Ambiguous.
501        let err = index.resolve_prefix("2").unwrap_err();
502        assert!(matches!(
503            err,
504            PrefixResolutionError::Ambiguous { count: 2, .. }
505        ));
506
507        // Not found.
508        let err = index.resolve_prefix("9").unwrap_err();
509        assert!(matches!(err, PrefixResolutionError::NotFound));
510
511        // Invalid.
512        let err = index.resolve_prefix("xyz").unwrap_err();
513        assert!(matches!(err, PrefixResolutionError::InvalidPrefix));
514    }
515
516    #[test]
517    fn test_not_in_index() {
518        let runs = vec![make_run(ReportUuid::from_u128(
519            0x11111111_1111_1111_1111_111111111111,
520        ))];
521        let index = RunIdIndex::new(&runs);
522
523        let other = ReportUuid::from_u128(0x22222222_2222_2222_2222_222222222222);
524        assert_eq!(index.shortest_unique_prefix_len(other), None);
525        assert_eq!(index.shortest_unique_prefix(other), None);
526    }
527
528    #[test]
529    fn test_hex_len_to_string_index() {
530        // Before first dash (position 8).
531        assert_eq!(hex_len_to_string_index(0), 0);
532        assert_eq!(hex_len_to_string_index(8), 8);
533        // After each dash.
534        assert_eq!(hex_len_to_string_index(9), 10);
535        assert_eq!(hex_len_to_string_index(13), 15);
536        assert_eq!(hex_len_to_string_index(17), 20);
537        assert_eq!(hex_len_to_string_index(21), 25);
538        // Full UUID.
539        assert_eq!(hex_len_to_string_index(32), 36);
540    }
541
542    #[test]
543    fn test_run_id_selector_default() {
544        assert_eq!(RunIdSelector::default(), RunIdSelector::Latest);
545    }
546
547    #[test]
548    fn test_run_id_selector_from_str() {
549        // Only exact "latest" parses to Latest.
550        assert_eq!(
551            "latest".parse::<RunIdSelector>().unwrap(),
552            RunIdSelector::Latest
553        );
554
555        // Valid hex prefixes.
556        assert_eq!(
557            "abc123".parse::<RunIdSelector>().unwrap(),
558            RunIdSelector::Prefix("abc123".to_owned())
559        );
560        assert_eq!(
561            "550e8400-e29b-41d4".parse::<RunIdSelector>().unwrap(),
562            RunIdSelector::Prefix("550e8400-e29b-41d4".to_owned())
563        );
564        assert_eq!(
565            "ABCDEF".parse::<RunIdSelector>().unwrap(),
566            RunIdSelector::Prefix("ABCDEF".to_owned())
567        );
568        assert_eq!(
569            "0".parse::<RunIdSelector>().unwrap(),
570            RunIdSelector::Prefix("0".to_owned())
571        );
572
573        // "Latest" contains non-hex characters.
574        assert!("Latest".parse::<RunIdSelector>().is_err());
575        assert!("LATEST".parse::<RunIdSelector>().is_err());
576
577        // Contains non-hex characters.
578        assert!("xyz".parse::<RunIdSelector>().is_err());
579        assert!("abc_123".parse::<RunIdSelector>().is_err());
580        assert!("hello".parse::<RunIdSelector>().is_err());
581
582        // Empty string is invalid.
583        assert!("".parse::<RunIdSelector>().is_err());
584    }
585
586    #[test]
587    fn test_run_id_selector_display() {
588        assert_eq!(RunIdSelector::Latest.to_string(), "latest");
589        assert_eq!(
590            RunIdSelector::Prefix("abc123".to_owned()).to_string(),
591            "abc123"
592        );
593    }
594
595    #[test]
596    fn test_run_id_or_archive_selector_default() {
597        assert_eq!(
598            RunIdOrRecordingSelector::default(),
599            RunIdOrRecordingSelector::RunId(RunIdSelector::Latest)
600        );
601    }
602
603    #[test]
604    fn test_run_id_or_archive_selector_from_str() {
605        // "latest" parses to RunId(Latest).
606        assert_eq!(
607            "latest".parse::<RunIdOrRecordingSelector>().unwrap(),
608            RunIdOrRecordingSelector::RunId(RunIdSelector::Latest)
609        );
610
611        // Hex prefixes parse to RunId(Prefix(...)).
612        assert_eq!(
613            "abc123".parse::<RunIdOrRecordingSelector>().unwrap(),
614            RunIdOrRecordingSelector::RunId(RunIdSelector::Prefix("abc123".to_owned()))
615        );
616
617        // Paths ending in .zip parse to RecordingPath.
618        assert_eq!(
619            "nextest-run-abc123.zip"
620                .parse::<RunIdOrRecordingSelector>()
621                .unwrap(),
622            RunIdOrRecordingSelector::RecordingPath(Utf8PathBuf::from("nextest-run-abc123.zip"))
623        );
624        assert_eq!(
625            "/path/to/archive.zip"
626                .parse::<RunIdOrRecordingSelector>()
627                .unwrap(),
628            RunIdOrRecordingSelector::RecordingPath(Utf8PathBuf::from("/path/to/archive.zip"))
629        );
630        assert_eq!(
631            "../relative/path.zip"
632                .parse::<RunIdOrRecordingSelector>()
633                .unwrap(),
634            RunIdOrRecordingSelector::RecordingPath(Utf8PathBuf::from("../relative/path.zip"))
635        );
636
637        // Paths with separators (no .zip) parse to RecordingPath. This
638        // covers process substitution paths and relative paths.
639        assert_eq!(
640            "/proc/self/fd/11"
641                .parse::<RunIdOrRecordingSelector>()
642                .unwrap(),
643            RunIdOrRecordingSelector::RecordingPath(Utf8PathBuf::from("/proc/self/fd/11"))
644        );
645        assert_eq!(
646            "/dev/fd/5".parse::<RunIdOrRecordingSelector>().unwrap(),
647            RunIdOrRecordingSelector::RecordingPath(Utf8PathBuf::from("/dev/fd/5"))
648        );
649        assert_eq!(
650            "./my-recording"
651                .parse::<RunIdOrRecordingSelector>()
652                .unwrap(),
653            RunIdOrRecordingSelector::RecordingPath(Utf8PathBuf::from("./my-recording"))
654        );
655        assert_eq!(
656            "../path/to/file"
657                .parse::<RunIdOrRecordingSelector>()
658                .unwrap(),
659            RunIdOrRecordingSelector::RecordingPath(Utf8PathBuf::from("../path/to/file"))
660        );
661        // Backslash is treated as a path separator (Windows paths).
662        #[cfg(windows)]
663        {
664            assert_eq!(
665                r"C:\path\to\file"
666                    .parse::<RunIdOrRecordingSelector>()
667                    .unwrap(),
668                RunIdOrRecordingSelector::RecordingPath(Utf8PathBuf::from(r"C:\path\to\file"))
669            );
670        }
671
672        // Invalid run ID selector (not hex, not .zip, no path separator)
673        // still fails.
674        assert!("xyz".parse::<RunIdOrRecordingSelector>().is_err());
675        assert!("hello".parse::<RunIdOrRecordingSelector>().is_err());
676        // Typos of "latest" should still fail, not be treated as paths.
677        assert!("latets".parse::<RunIdOrRecordingSelector>().is_err());
678        assert!("latestt".parse::<RunIdOrRecordingSelector>().is_err());
679        // Bare filenames without separators or .zip extension should fail.
680        assert!("recording".parse::<RunIdOrRecordingSelector>().is_err());
681    }
682
683    #[test]
684    fn test_run_id_or_archive_selector_display() {
685        assert_eq!(
686            RunIdOrRecordingSelector::RunId(RunIdSelector::Latest).to_string(),
687            "latest"
688        );
689        assert_eq!(
690            RunIdOrRecordingSelector::RunId(RunIdSelector::Prefix("abc123".to_owned())).to_string(),
691            "abc123"
692        );
693        assert_eq!(
694            RunIdOrRecordingSelector::RecordingPath(Utf8PathBuf::from("/path/to/archive.zip"))
695                .to_string(),
696            "/path/to/archive.zip"
697        );
698    }
699}