1use 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 #[arg(long, value_name = "PATH")]
59 pub debug_base: Option<String>,
60
61 #[arg(long, value_name = "PATH")]
63 pub log_base: Option<PathBuf>,
64
65 #[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}