Skip to main content

moonpool_explorer/
replay.rs

1//! Recipe serialization for exploration replay.
2//!
3//! Recipes describe a sequence of fork points as `(rng_call_count, seed)` pairs.
4//! They can be formatted as human-readable timeline strings for debugging and
5//! parsed back for deterministic replay via RNG breakpoints.
6
7use std::fmt;
8
9/// Error parsing a timeline string.
10#[derive(Debug)]
11pub struct ParseTimelineError {
12    /// Description of the parse error.
13    pub message: String,
14}
15
16impl fmt::Display for ParseTimelineError {
17    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
18        write!(f, "parse timeline error: {}", self.message)
19    }
20}
21
22impl std::error::Error for ParseTimelineError {}
23
24/// Format a recipe as a human-readable timeline string.
25///
26/// Each segment is formatted as `count@seed`, joined by ` -> `.
27///
28/// # Example
29///
30/// ```
31/// use moonpool_explorer::format_timeline;
32///
33/// let recipe = vec![(42, 12345), (17, 67890)];
34/// assert_eq!(format_timeline(&recipe), "42@12345 -> 17@67890");
35/// ```
36pub fn format_timeline(recipe: &[(u64, u64)]) -> String {
37    recipe
38        .iter()
39        .map(|(count, seed)| format!("{count}@{seed}"))
40        .collect::<Vec<_>>()
41        .join(" -> ")
42}
43
44/// Parse a timeline string back into a recipe.
45///
46/// Accepts the format produced by [`format_timeline`]: segments of `count@seed`
47/// joined by ` -> `.
48///
49/// # Errors
50///
51/// Returns an error if the string is malformed (missing `@`, non-numeric values).
52///
53/// # Example
54///
55/// ```
56/// use moonpool_explorer::parse_timeline;
57///
58/// let recipe = parse_timeline("42@12345 -> 17@67890").unwrap();
59/// assert_eq!(recipe, vec![(42, 12345), (17, 67890)]);
60/// ```
61pub fn parse_timeline(s: &str) -> Result<Vec<(u64, u64)>, ParseTimelineError> {
62    let trimmed = s.trim();
63    if trimmed.is_empty() {
64        return Ok(Vec::new());
65    }
66
67    trimmed
68        .split(" -> ")
69        .map(|segment| {
70            let segment = segment.trim();
71            let at_pos = segment.find('@').ok_or_else(|| ParseTimelineError {
72                message: format!("missing '@' in segment: {segment}"),
73            })?;
74
75            let count_str = &segment[..at_pos];
76            let seed_str = &segment[at_pos + 1..];
77
78            let count = count_str.parse::<u64>().map_err(|e| ParseTimelineError {
79                message: format!("invalid count '{count_str}': {e}"),
80            })?;
81
82            let seed = seed_str.parse::<u64>().map_err(|e| ParseTimelineError {
83                message: format!("invalid seed '{seed_str}': {e}"),
84            })?;
85
86            Ok((count, seed))
87        })
88        .collect()
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94
95    #[test]
96    fn test_format_empty() {
97        assert_eq!(format_timeline(&[]), "");
98    }
99
100    #[test]
101    fn test_format_single() {
102        assert_eq!(format_timeline(&[(42, 12345)]), "42@12345");
103    }
104
105    #[test]
106    fn test_format_multiple() {
107        let recipe = vec![(42, 12345), (17, 67890), (100, 999)];
108        assert_eq!(format_timeline(&recipe), "42@12345 -> 17@67890 -> 100@999");
109    }
110
111    #[test]
112    fn test_roundtrip() {
113        let original = vec![(42, 12345), (17, 67890)];
114        let formatted = format_timeline(&original);
115        let parsed = parse_timeline(&formatted).expect("parse failed");
116        assert_eq!(original, parsed);
117    }
118
119    #[test]
120    fn test_parse_empty() {
121        let result = parse_timeline("").expect("parse failed");
122        assert!(result.is_empty());
123    }
124
125    #[test]
126    fn test_parse_whitespace() {
127        let result = parse_timeline("  ").expect("parse failed");
128        assert!(result.is_empty());
129    }
130
131    #[test]
132    fn test_parse_error_missing_at() {
133        let result = parse_timeline("42-12345");
134        assert!(result.is_err());
135    }
136
137    #[test]
138    fn test_parse_error_non_numeric() {
139        let result = parse_timeline("abc@12345");
140        assert!(result.is_err());
141    }
142}