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/// ```
36#[must_use]
37pub fn format_timeline(recipe: &[(u64, u64)]) -> String {
38    recipe
39        .iter()
40        .map(|(count, seed)| format!("{count}@{seed}"))
41        .collect::<Vec<_>>()
42        .join(" -> ")
43}
44
45/// Parse a timeline string back into a recipe.
46///
47/// Accepts the format produced by [`format_timeline`]: segments of `count@seed`
48/// joined by ` -> `.
49///
50/// # Errors
51///
52/// Returns an error if the string is malformed (missing `@`, non-numeric values).
53///
54/// # Example
55///
56/// ```
57/// use moonpool_explorer::parse_timeline;
58///
59/// let recipe = parse_timeline("42@12345 -> 17@67890").unwrap();
60/// assert_eq!(recipe, vec![(42, 12345), (17, 67890)]);
61/// ```
62pub fn parse_timeline(s: &str) -> Result<Vec<(u64, u64)>, ParseTimelineError> {
63    let trimmed = s.trim();
64    if trimmed.is_empty() {
65        return Ok(Vec::new());
66    }
67
68    trimmed
69        .split(" -> ")
70        .map(|segment| {
71            let segment = segment.trim();
72            let at_pos = segment.find('@').ok_or_else(|| ParseTimelineError {
73                message: format!("missing '@' in segment: {segment}"),
74            })?;
75
76            let count_str = &segment[..at_pos];
77            let seed_str = &segment[at_pos + 1..];
78
79            let count = count_str.parse::<u64>().map_err(|e| ParseTimelineError {
80                message: format!("invalid count '{count_str}': {e}"),
81            })?;
82
83            let seed = seed_str.parse::<u64>().map_err(|e| ParseTimelineError {
84                message: format!("invalid seed '{seed_str}': {e}"),
85            })?;
86
87            Ok((count, seed))
88        })
89        .collect()
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95
96    #[test]
97    fn test_format_empty() {
98        assert_eq!(format_timeline(&[]), "");
99    }
100
101    #[test]
102    fn test_format_single() {
103        assert_eq!(format_timeline(&[(42, 12345)]), "42@12345");
104    }
105
106    #[test]
107    fn test_format_multiple() {
108        let recipe = vec![(42, 12345), (17, 67890), (100, 999)];
109        assert_eq!(format_timeline(&recipe), "42@12345 -> 17@67890 -> 100@999");
110    }
111
112    #[test]
113    fn test_roundtrip() {
114        let original = vec![(42, 12345), (17, 67890)];
115        let formatted = format_timeline(&original);
116        let parsed = parse_timeline(&formatted).expect("parse failed");
117        assert_eq!(original, parsed);
118    }
119
120    #[test]
121    fn test_parse_empty() {
122        let result = parse_timeline("").expect("parse failed");
123        assert!(result.is_empty());
124    }
125
126    #[test]
127    fn test_parse_whitespace() {
128        let result = parse_timeline("  ").expect("parse failed");
129        assert!(result.is_empty());
130    }
131
132    #[test]
133    fn test_parse_error_missing_at() {
134        let result = parse_timeline("42-12345");
135        assert!(result.is_err());
136    }
137
138    #[test]
139    fn test_parse_error_non_numeric() {
140        let result = parse_timeline("abc@12345");
141        assert!(result.is_err());
142    }
143}