Skip to main content

cu29_runtime/
replay.rs

1//! Shared Clap-backed helpers for replay and resimulation binaries.
2//!
3//! Use [`ReplayCli`] when the binary only needs the standard replay flags.
4//! Use [`ReplayArgs`] with `#[command(flatten)]` when the binary has its own
5//! app-specific CLI, such as an extra mission selector.
6
7use clap::{Args, Parser};
8use core::sync::atomic::{AtomicU64, Ordering};
9use cu29_traits::{CuError, CuResult};
10use std::ffi::OsString;
11use std::fs;
12use std::path::{Path, PathBuf};
13
14#[cfg(feature = "remote-debug")]
15use crate::app::{CuSimApplication, CurrentRuntimeCopperList};
16#[cfg(feature = "remote-debug")]
17use crate::copperlist::CopperList;
18#[cfg(feature = "remote-debug")]
19use crate::reflect::ReflectTaskIntrospection;
20#[cfg(feature = "remote-debug")]
21use crate::remote_debug::{RemoteDebugPaths, RemoteDebugZenohServer, SessionOpenParams};
22#[cfg(feature = "remote-debug")]
23use crate::simulation::SimOverride;
24#[cfg(feature = "remote-debug")]
25use cu29_clock::{CuTime, RobotClock, RobotClockMock};
26#[cfg(feature = "remote-debug")]
27use cu29_traits::CopperListTuple;
28#[cfg(feature = "remote-debug")]
29use cu29_unifiedlog::{SectionStorage, UnifiedLogWrite};
30
31static NEXT_REPLAY_SESSION_ID: AtomicU64 = AtomicU64::new(0);
32
33#[derive(Debug, Clone)]
34pub struct ReplayDefaults {
35    pub debug_base: Option<String>,
36    pub log_base: PathBuf,
37    pub replay_log_base: PathBuf,
38}
39
40impl ReplayDefaults {
41    pub fn new(log_base: impl Into<PathBuf>, replay_log_base: impl Into<PathBuf>) -> Self {
42        Self {
43            debug_base: None,
44            log_base: log_base.into(),
45            replay_log_base: replay_log_base.into(),
46        }
47    }
48
49    pub fn with_debug_base(mut self, debug_base: impl Into<String>) -> Self {
50        self.debug_base = Some(debug_base.into());
51        self
52    }
53}
54
55#[derive(Debug, Clone, Args)]
56pub struct ReplayArgs {
57    /// Remote debug namespace base. When set, starts the remote debug server.
58    #[arg(long, value_name = "PATH")]
59    pub debug_base: Option<String>,
60
61    /// Recorded Copper log base.
62    #[arg(long, value_name = "PATH")]
63    pub log_base: Option<PathBuf>,
64
65    /// Replay log base or per-session template.
66    #[arg(long, value_name = "PATH")]
67    pub replay_log_base: Option<PathBuf>,
68}
69
70impl ReplayArgs {
71    pub fn resolve(self, defaults: &ReplayDefaults) -> ReplayCli {
72        ReplayCli {
73            debug_base: self
74                .debug_base
75                .or_else(|| defaults.debug_base.as_ref().cloned()),
76            log_base: self.log_base.unwrap_or_else(|| defaults.log_base.clone()),
77            replay_log_base: self
78                .replay_log_base
79                .unwrap_or_else(|| defaults.replay_log_base.clone()),
80        }
81    }
82}
83
84#[derive(Debug, Clone)]
85pub struct ReplayCli {
86    pub debug_base: Option<String>,
87    pub log_base: PathBuf,
88    pub replay_log_base: PathBuf,
89}
90
91#[derive(Debug, Clone, Parser)]
92struct StandaloneReplayParser {
93    #[command(flatten)]
94    replay: ReplayArgs,
95}
96
97impl ReplayCli {
98    pub fn parse(defaults: ReplayDefaults) -> Self {
99        match Self::try_parse_from(std::env::args_os(), defaults) {
100            Ok(cli) => cli,
101            Err(err) => err.exit(),
102        }
103    }
104
105    pub fn try_parse_from<I, T>(args: I, defaults: ReplayDefaults) -> Result<Self, clap::Error>
106    where
107        I: IntoIterator<Item = T>,
108        T: Into<OsString> + Clone,
109    {
110        let mut argv: Vec<OsString> = args.into_iter().map(Into::into).collect();
111
112        if let Some(debug_base) = defaults.debug_base.as_ref()
113            && !has_long_flag(&argv, "--debug-base")
114        {
115            argv.push("--debug-base".into());
116            argv.push(debug_base.clone().into());
117        }
118
119        if !has_long_flag(&argv, "--log-base") {
120            argv.push("--log-base".into());
121            argv.push(defaults.log_base.clone().into_os_string());
122        }
123
124        if !has_long_flag(&argv, "--replay-log-base") {
125            argv.push("--replay-log-base".into());
126            argv.push(defaults.replay_log_base.clone().into_os_string());
127        }
128
129        let parsed = StandaloneReplayParser::try_parse_from(argv)?;
130        Ok(parsed.replay.resolve(&defaults))
131    }
132}
133
134pub fn ensure_log_family_exists(log_base: &Path) -> CuResult<()> {
135    if log_base.exists() {
136        return Ok(());
137    }
138
139    let first_slab = first_slab_path(log_base)?;
140    if first_slab.exists() {
141        return Ok(());
142    }
143
144    Err(CuError::from(format!(
145        "log family not found for {} (expected {})",
146        log_base.display(),
147        first_slab.display()
148    )))
149}
150
151pub fn remove_log_family(log_base: &Path) -> CuResult<()> {
152    let parent = log_base
153        .parent()
154        .ok_or_else(|| CuError::from("log path must have a parent directory"))?;
155    fs::create_dir_all(parent)
156        .map_err(|err| CuError::new_with_cause("failed to create replay log directory", err))?;
157
158    let stem = log_base
159        .file_stem()
160        .and_then(|s| s.to_str())
161        .ok_or_else(|| CuError::from("invalid UTF-8 replay log stem"))?;
162    let ext = log_base
163        .extension()
164        .and_then(|s| s.to_str())
165        .unwrap_or("copper");
166    let family_prefix = format!("{stem}_");
167    let family_ext = format!(".{ext}");
168    let base_file_name = format!("{stem}.{ext}");
169
170    let entries = fs::read_dir(parent)
171        .map_err(|err| CuError::new_with_cause("failed to scan replay log directory", err))?;
172    for entry_result in entries {
173        let entry = entry_result
174            .map_err(|err| CuError::new_with_cause("failed to read replay log entry", err))?;
175        let path = entry.path();
176        if !path.is_file() {
177            continue;
178        }
179        let Some(name) = path.file_name().and_then(|s| s.to_str()) else {
180            continue;
181        };
182        let in_family = name == base_file_name
183            || (name.starts_with(&family_prefix) && name.ends_with(&family_ext));
184        if in_family {
185            fs::remove_file(&path)
186                .map_err(|err| CuError::new_with_cause("failed to remove replay log file", err))?;
187        }
188    }
189
190    Ok(())
191}
192
193pub fn first_slab_path(log_base: &Path) -> CuResult<PathBuf> {
194    let parent = log_base
195        .parent()
196        .ok_or_else(|| CuError::from("log path must have a parent directory"))?;
197    let stem = log_base
198        .file_stem()
199        .and_then(|s| s.to_str())
200        .ok_or_else(|| CuError::from("invalid UTF-8 log stem"))?;
201    let ext = log_base
202        .extension()
203        .and_then(|s| s.to_str())
204        .unwrap_or("copper");
205    Ok(parent.join(format!("{stem}_0.{ext}")))
206}
207
208pub fn per_session_replay_log_base<I, S>(template: &Path, labels: I) -> PathBuf
209where
210    I: IntoIterator<Item = S>,
211    S: AsRef<str>,
212{
213    let seq = NEXT_REPLAY_SESSION_ID.fetch_add(1, Ordering::Relaxed);
214    let parent = template.parent().unwrap_or_else(|| Path::new("."));
215    let stem = template
216        .file_stem()
217        .and_then(|s| s.to_str())
218        .unwrap_or("replay");
219    let ext = template
220        .extension()
221        .and_then(|s| s.to_str())
222        .unwrap_or("copper");
223
224    let mut parts = Vec::new();
225    for label in labels {
226        let sanitized = sanitize_label(label.as_ref());
227        if !sanitized.is_empty() {
228            parts.push(sanitized);
229        }
230    }
231    if parts.is_empty() {
232        parts.push("session".to_owned());
233    }
234
235    parent.join(format!("{stem}_{}_{}.{}", parts.join("_"), seq, ext))
236}
237
238#[cfg(feature = "remote-debug")]
239pub fn serve_remote_debug<App, P, CB, TF, S, L, AF>(
240    debug_base: &str,
241    log_base: &Path,
242    app_factory: AF,
243    build_callback: CB,
244    time_of: TF,
245) -> CuResult<()>
246where
247    App: CuSimApplication<S, L> + ReflectTaskIntrospection + CurrentRuntimeCopperList<P>,
248    L: UnifiedLogWrite<S> + 'static,
249    S: SectionStorage,
250    P: CopperListTuple + 'static,
251    CB: for<'a> Fn(
252            &'a CopperList<P>,
253            RobotClock,
254            RobotClockMock,
255        ) -> Box<dyn for<'z> FnMut(App::Step<'z>) -> SimOverride + 'a>
256        + Clone,
257    TF: Fn(&CopperList<P>) -> Option<CuTime> + Clone,
258    AF: Fn(&SessionOpenParams) -> CuResult<(App, RobotClock, RobotClockMock)>,
259{
260    ensure_log_family_exists(log_base)?;
261    let paths = RemoteDebugPaths::new(debug_base);
262    let mut server = RemoteDebugZenohServer::<App, P, CB, TF, S, L, AF>::new(
263        paths,
264        app_factory,
265        build_callback,
266        time_of,
267    )?;
268    server.serve_until_stopped()
269}
270
271fn sanitize_label(label: &str) -> String {
272    let mut sanitized = String::with_capacity(label.len());
273    for ch in label.chars() {
274        if ch.is_ascii_alphanumeric() {
275            sanitized.push(ch.to_ascii_lowercase());
276        } else {
277            sanitized.push('_');
278        }
279    }
280    sanitized.trim_matches('_').to_owned()
281}
282
283fn has_long_flag(args: &[OsString], flag: &str) -> bool {
284    args.iter().any(|arg| {
285        let rendered = arg.to_string_lossy();
286        rendered == flag
287            || rendered
288                .strip_prefix(flag)
289                .is_some_and(|suffix| suffix.starts_with('='))
290    })
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296
297    #[test]
298    fn replay_cli_applies_runtime_defaults() {
299        let defaults = ReplayDefaults::new("logs/input.copper", "logs/output.copper")
300            .with_debug_base("copper/test/debug/v1");
301        let cli = ReplayCli::try_parse_from(["resim"], defaults).expect("parse replay CLI");
302        assert_eq!(cli.debug_base.as_deref(), Some("copper/test/debug/v1"));
303        assert_eq!(cli.log_base, PathBuf::from("logs/input.copper"));
304        assert_eq!(cli.replay_log_base, PathBuf::from("logs/output.copper"));
305    }
306
307    #[test]
308    fn per_session_replay_log_base_sanitizes_labels() {
309        let path = per_session_replay_log_base(
310            Path::new("logs/example_resim.copper"),
311            ["Controller Session", "role/a"],
312        );
313        assert!(path.starts_with(Path::new("logs")));
314        let file_name = path
315            .file_name()
316            .and_then(|name| name.to_str())
317            .expect("UTF-8 replay log file name");
318        assert!(file_name.starts_with("example_resim_controller_session_role_a_"));
319        assert!(file_name.ends_with(".copper"));
320    }
321
322    #[test]
323    fn replay_cli_respects_equals_style_flags() {
324        let defaults = ReplayDefaults::new("logs/input.copper", "logs/output.copper")
325            .with_debug_base("copper/test/debug/v1");
326        let cli = ReplayCli::try_parse_from(
327            [
328                "resim",
329                "--log-base=logs/override.copper",
330                "--replay-log-base=logs/replay.copper",
331            ],
332            defaults,
333        )
334        .expect("parse replay CLI");
335        assert_eq!(cli.debug_base.as_deref(), Some("copper/test/debug/v1"));
336        assert_eq!(cli.log_base, PathBuf::from("logs/override.copper"));
337        assert_eq!(cli.replay_log_base, PathBuf::from("logs/replay.copper"));
338    }
339}