Skip to main content

cu29_export/
lib.rs

1//! Log export helpers for Copper applications.
2//!
3//! This crate serves two related use cases:
4//!
5//! - Rust logreader binaries built with [`run_cli`]
6//! - optional Python-facing iterators for offline analysis of structured logs,
7//!   runtime lifecycle records, and app-specific CopperLists
8//!
9//! Python support here is intentionally offline. It reads data that Copper has
10//! already recorded. That is very different from putting Python on the runtime
11//! execution path, and it does not compromise realtime behavior during robot
12//! execution.
13//!
14//! For runtime Python task prototyping, see `cu-python-task` instead.
15
16mod fsck;
17pub mod logstats;
18
19#[cfg(feature = "mcap")]
20pub mod mcap_export;
21
22#[cfg(feature = "mcap")]
23pub mod serde_to_jsonschema;
24
25use bincode::Decode;
26use bincode::config::standard;
27use bincode::decode_from_std_read;
28use bincode::error::DecodeError;
29use clap::{Parser, Subcommand, ValueEnum};
30use cu29::UnifiedLogType;
31use cu29::prelude::*;
32use cu29_intern_strs::read_interned_strings;
33use fsck::check;
34#[cfg(feature = "mcap")]
35use indicatif::{ProgressBar, ProgressDrawTarget, ProgressStyle};
36use logstats::{compute_logstats, write_logstats};
37use serde::Serialize;
38use std::fmt::{Display, Formatter};
39#[cfg(feature = "mcap")]
40use std::io::IsTerminal;
41use std::io::Read;
42use std::path::{Path, PathBuf};
43
44#[cfg(feature = "mcap")]
45pub use mcap_export::{McapExportStats, export_to_mcap, export_to_mcap_with_schemas, mcap_info};
46
47#[cfg(feature = "mcap")]
48#[allow(deprecated)]
49pub use cu29::prelude::PayloadSchemas;
50
51#[cfg(feature = "mcap")]
52pub use serde_to_jsonschema::trace_type_to_jsonschema;
53
54/// Registers the typed CopperList decoder used by the generic Python iterator.
55///
56/// Applications normally call this indirectly through
57/// [`copperlist_iterator_unified_typed_py`].
58#[cfg(feature = "python")]
59pub use python::register_copperlist_python_type;
60
61/// Creates a Python CopperList iterator for a specific CopperList tuple type.
62///
63/// This is intended for app-specific Python modules that know their generated
64/// CopperList type at compile time. The helper registers the decoder and returns
65/// an iterator object that yields Python objects built from the recorded
66/// CopperLists.
67#[cfg(feature = "python")]
68pub fn copperlist_iterator_unified_typed_py<P>(
69    unified_src_path: &str,
70    py: pyo3::Python<'_>,
71) -> pyo3::PyResult<pyo3::Py<pyo3::PyAny>>
72where
73    P: CopperListTuple + 'static,
74{
75    let _ = cu29::logcodec::seed_effective_config_from_log::<P>(Path::new(unified_src_path))
76        .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?;
77    register_copperlist_python_type::<P>()
78        .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?;
79    let iter = python::copperlist_iterator_unified(unified_src_path)?;
80    pyo3::Py::new(py, iter).map(|obj| obj.into())
81}
82
83/// Creates a Python `RuntimeLifecycleRecord` iterator from a unified log.
84///
85/// This is useful for offline analysis scripts that need to inspect mission
86/// starts, stops, faults, and related runtime events.
87#[cfg(feature = "python")]
88pub fn runtime_lifecycle_iterator_unified_py(
89    unified_src_path: &str,
90    py: pyo3::Python<'_>,
91) -> pyo3::PyResult<pyo3::Py<pyo3::PyAny>> {
92    let iter = python::runtime_lifecycle_iterator_unified(unified_src_path)?;
93    pyo3::Py::new(py, iter).map(|obj| obj.into())
94}
95#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)]
96pub enum ExportFormat {
97    Json,
98    Csv,
99}
100
101impl Display for ExportFormat {
102    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
103        match self {
104            ExportFormat::Json => write!(f, "json"),
105            ExportFormat::Csv => write!(f, "csv"),
106        }
107    }
108}
109
110/// This is a generator for a main function to build a log extractor.
111#[derive(Parser)]
112#[command(author, version, about)]
113pub struct LogReaderCli {
114    /// The base path is the name with no _0 _1 et the end.
115    /// for example for toto_0.copper, toto_1.copper ... the base name is toto.copper
116    pub unifiedlog_base: PathBuf,
117
118    #[command(subcommand)]
119    pub command: Command,
120}
121
122#[derive(Subcommand)]
123pub enum Command {
124    /// Extract logs
125    ExtractTextLog { log_index: PathBuf },
126    /// Extract copperlists
127    ExtractCopperlists {
128        #[arg(short, long, default_value_t = ExportFormat::Json)]
129        export_format: ExportFormat,
130    },
131    /// Check the log and dump info about it.
132    Fsck {
133        #[arg(short, long, action = clap::ArgAction::Count)]
134        verbose: u8,
135        /// Decode and print RuntimeLifecycle events.
136        #[arg(long)]
137        dump_runtime_lifecycle: bool,
138    },
139    /// Export log statistics to JSON for offline DAG rendering.
140    LogStats {
141        /// Output JSON file path
142        #[arg(short, long, default_value = "cu29_logstats.json")]
143        output: PathBuf,
144        /// Config file used to map outputs to edges
145        #[arg(long, default_value = "copperconfig.ron")]
146        config: PathBuf,
147        /// Mission id override; defaults to the mission recorded in the log
148        #[arg(long)]
149        mission: Option<String>,
150        /// Comma-separated Cargo features used by conditional config fragments
151        #[arg(long, value_delimiter = ',')]
152        features: Vec<String>,
153    },
154    /// Export copperlists to MCAP format (requires 'mcap' feature)
155    #[cfg(feature = "mcap")]
156    ExportMcap {
157        /// Output MCAP file path
158        #[arg(short, long)]
159        output: PathBuf,
160        /// Force progress bar even when stderr is not a TTY
161        #[arg(long)]
162        progress: bool,
163        /// Suppress the progress bar
164        #[arg(long)]
165        quiet: bool,
166    },
167    /// Inspect an MCAP file and dump metadata, schemas, and stats (requires 'mcap' feature)
168    #[cfg(feature = "mcap")]
169    McapInfo {
170        /// Path to the MCAP file to inspect
171        mcap_file: PathBuf,
172        /// Show full schema content
173        #[arg(short, long)]
174        schemas: bool,
175        /// Show sample messages (first N messages per channel)
176        #[arg(short = 'n', long, default_value_t = 0)]
177        sample_messages: usize,
178    },
179}
180
181fn write_json_pretty<T: Serialize + ?Sized>(value: &T) -> CuResult<()> {
182    serde_json::to_writer_pretty(std::io::stdout(), value)
183        .map_err(|e| CuError::new_with_cause("Failed to write JSON output", e))
184}
185
186fn write_json<T: Serialize + ?Sized>(value: &T) -> CuResult<()> {
187    serde_json::to_writer(std::io::stdout(), value)
188        .map_err(|e| CuError::new_with_cause("Failed to write JSON output", e))
189}
190
191fn build_read_logger(unifiedlog_base: &Path) -> CuResult<UnifiedLoggerRead> {
192    let logger = UnifiedLoggerBuilder::new()
193        .file_base_name(unifiedlog_base)
194        .build()
195        .map_err(|e| CuError::new_with_cause("Failed to create logger", e))?;
196    match logger {
197        UnifiedLogger::Read(dl) => Ok(dl),
198        UnifiedLogger::Write(_) => Err(CuError::from(
199            "Expected read-only unified logger in export CLI",
200        )),
201    }
202}
203
204/// This is a generator for a main function to build a log extractor.
205/// It depends on the specific type of the CopperList payload that is determined at compile time from the configuration.
206///
207#[cfg(feature = "mcap")]
208pub fn run_cli<P>() -> CuResult<()>
209where
210    P: CopperListTuple + CuPayloadRawBytes + 'static,
211{
212    #[cfg(feature = "python")]
213    let _ = python::register_copperlist_python_type::<P>();
214
215    run_cli_inner::<P>()
216}
217
218/// This is a generator for a main function to build a log extractor.
219/// It depends on the specific type of the CopperList payload that is determined at compile time from the configuration.
220#[cfg(not(feature = "mcap"))]
221pub fn run_cli<P>() -> CuResult<()>
222where
223    P: CopperListTuple + CuPayloadRawBytes + 'static,
224{
225    #[cfg(feature = "python")]
226    let _ = python::register_copperlist_python_type::<P>();
227
228    run_cli_inner::<P>()
229}
230
231#[cfg(feature = "mcap")]
232fn run_cli_inner<P>() -> CuResult<()>
233where
234    P: CopperListTuple + CuPayloadRawBytes + 'static,
235{
236    let args = LogReaderCli::parse();
237    let unifiedlog_base = args.unifiedlog_base;
238    let _ = cu29::logcodec::seed_effective_config_from_log::<P>(&unifiedlog_base)?;
239
240    let mut dl = build_read_logger(&unifiedlog_base)?;
241
242    match args.command {
243        Command::ExtractTextLog { log_index } => {
244            let reader = UnifiedLoggerIOReader::new(dl, UnifiedLogType::StructuredLogLine);
245            textlog_dump(reader, &log_index)?;
246        }
247        Command::ExtractCopperlists { export_format } => {
248            println!("Extracting copperlists with format: {export_format}");
249            let mut reader = UnifiedLoggerIOReader::new(dl, UnifiedLogType::CopperList);
250            let iter = copperlists_reader::<P>(&mut reader);
251
252            match export_format {
253                ExportFormat::Json => {
254                    for entry in iter {
255                        write_json_pretty(&entry)?;
256                    }
257                }
258                ExportFormat::Csv => {
259                    let mut first = true;
260                    for origin in P::get_all_task_ids() {
261                        if !first {
262                            print!(", ");
263                        } else {
264                            print!("id, ");
265                        }
266                        print!("{origin}_time, {origin}_tov, {origin},");
267                        first = false;
268                    }
269                    println!();
270                    for entry in iter {
271                        let mut first = true;
272                        for msg in entry.cumsgs() {
273                            if let Some(payload) = msg.payload() {
274                                if !first {
275                                    print!(", ");
276                                } else {
277                                    print!("{}, ", entry.id);
278                                }
279                                let metadata = msg.metadata();
280                                print!("{}, {}, ", metadata.process_time(), msg.tov());
281                                write_json(payload)?; // TODO: escape for CSV
282                                first = false;
283                            }
284                        }
285                        println!();
286                    }
287                }
288            }
289        }
290        Command::Fsck {
291            verbose,
292            dump_runtime_lifecycle,
293        } => {
294            if let Some(value) = check::<P>(&mut dl, verbose, dump_runtime_lifecycle) {
295                return value;
296            }
297        }
298        Command::LogStats {
299            output,
300            config,
301            mission,
302            features,
303        } => {
304            run_logstats::<P>(&unifiedlog_base, dl, output, config, mission, &features)?;
305        }
306        #[cfg(feature = "mcap")]
307        Command::ExportMcap {
308            output,
309            progress,
310            quiet,
311        } => {
312            println!("Exporting copperlists to MCAP format: {}", output.display());
313
314            let show_progress = should_show_progress(progress, quiet);
315            let total_bytes = if show_progress {
316                Some(copperlist_total_bytes(&unifiedlog_base)?)
317            } else {
318                None
319            };
320
321            let reader = UnifiedLoggerIOReader::new(dl, UnifiedLogType::CopperList);
322
323            // Export to MCAP with schemas derived from generated output metadata.
324            let stats = if let Some(total_bytes) = total_bytes {
325                let progress_bar = make_progress_bar(total_bytes);
326                let reader = ProgressReader::new(reader, progress_bar.clone());
327                let result = export_to_mcap_impl::<P>(reader, &output);
328                progress_bar.finish_and_clear();
329                result?
330            } else {
331                export_to_mcap_impl::<P>(reader, &output)?
332            };
333            println!("{stats}");
334        }
335        #[cfg(feature = "mcap")]
336        Command::McapInfo {
337            mcap_file,
338            schemas,
339            sample_messages,
340        } => {
341            mcap_info(&mcap_file, schemas, sample_messages)?;
342        }
343    }
344
345    Ok(())
346}
347
348#[cfg(not(feature = "mcap"))]
349fn run_cli_inner<P>() -> CuResult<()>
350where
351    P: CopperListTuple + CuPayloadRawBytes + 'static,
352{
353    let args = LogReaderCli::parse();
354    let unifiedlog_base = args.unifiedlog_base;
355    let _ = cu29::logcodec::seed_effective_config_from_log::<P>(&unifiedlog_base)?;
356
357    let mut dl = build_read_logger(&unifiedlog_base)?;
358
359    match args.command {
360        Command::ExtractTextLog { log_index } => {
361            let reader = UnifiedLoggerIOReader::new(dl, UnifiedLogType::StructuredLogLine);
362            textlog_dump(reader, &log_index)?;
363        }
364        Command::ExtractCopperlists { export_format } => {
365            println!("Extracting copperlists with format: {export_format}");
366            let mut reader = UnifiedLoggerIOReader::new(dl, UnifiedLogType::CopperList);
367            let iter = copperlists_reader::<P>(&mut reader);
368
369            match export_format {
370                ExportFormat::Json => {
371                    for entry in iter {
372                        write_json_pretty(&entry)?;
373                    }
374                }
375                ExportFormat::Csv => {
376                    let mut first = true;
377                    for origin in P::get_all_task_ids() {
378                        if !first {
379                            print!(", ");
380                        } else {
381                            print!("id, ");
382                        }
383                        print!("{origin}_time, {origin}_tov, {origin},");
384                        first = false;
385                    }
386                    println!();
387                    for entry in iter {
388                        let mut first = true;
389                        for msg in entry.cumsgs() {
390                            if let Some(payload) = msg.payload() {
391                                if !first {
392                                    print!(", ");
393                                } else {
394                                    print!("{}, ", entry.id);
395                                }
396                                let metadata = msg.metadata();
397                                print!("{}, {}, ", metadata.process_time(), msg.tov());
398                                write_json(payload)?;
399                                first = false;
400                            }
401                        }
402                        println!();
403                    }
404                }
405            }
406        }
407        Command::Fsck {
408            verbose,
409            dump_runtime_lifecycle,
410        } => {
411            if let Some(value) = check::<P>(&mut dl, verbose, dump_runtime_lifecycle) {
412                return value;
413            }
414        }
415        Command::LogStats {
416            output,
417            config,
418            mission,
419            features,
420        } => {
421            run_logstats::<P>(&unifiedlog_base, dl, output, config, mission, &features)?;
422        }
423    }
424
425    Ok(())
426}
427
428fn run_logstats<P>(
429    unifiedlog_base: &Path,
430    dl: UnifiedLoggerRead,
431    output: PathBuf,
432    config: PathBuf,
433    mission: Option<String>,
434    features: &[String],
435) -> CuResult<()>
436where
437    P: CopperListTuple + CuPayloadRawBytes,
438{
439    let config_path = config
440        .to_str()
441        .ok_or_else(|| CuError::from("Config path is not valid UTF-8"))?;
442    let feature_refs = features.iter().map(String::as_str).collect::<Vec<_>>();
443    let cfg = cu29::config::read_configuration_with_features(config_path, &feature_refs)
444        .map_err(|e| CuError::new_with_cause("Failed to read configuration", e))?;
445    let logged_mission =
446        if mission.is_none() && matches!(&cfg.graphs, cu29::config::ConfigGraphs::Missions(_)) {
447            unified_log_mission(unifiedlog_base)?
448        } else {
449            None
450        };
451    let mission = resolve_logstats_mission(&cfg, mission, logged_mission);
452    let reader = UnifiedLoggerIOReader::new(dl, UnifiedLogType::CopperList);
453    let stats = compute_logstats::<P>(reader, &cfg, mission.as_deref())?;
454    write_logstats(&stats, &output)
455}
456
457fn resolve_logstats_mission(
458    config: &CuConfig,
459    requested: Option<String>,
460    logged: Option<String>,
461) -> Option<String> {
462    if requested.is_some() {
463        return requested;
464    }
465    let cu29::config::ConfigGraphs::Missions(graphs) = &config.graphs else {
466        return None;
467    };
468    if let Some(logged) = logged
469        && graphs.contains_key(&logged)
470    {
471        return Some(logged);
472    }
473    if graphs.contains_key("default") {
474        return Some("default".to_string());
475    }
476    graphs.keys().min().cloned()
477}
478
479/// Helper function for MCAP export.
480///
481/// Uses generated per-slot payload type metadata to derive schemas.
482#[cfg(feature = "mcap")]
483fn export_to_mcap_impl<P>(src: impl Read, output: &Path) -> CuResult<McapExportStats>
484where
485    P: CopperListTuple,
486{
487    mcap_export::export_to_mcap::<P, _>(src, output)
488}
489
490#[cfg(feature = "mcap")]
491struct ProgressReader<R> {
492    inner: R,
493    progress: ProgressBar,
494}
495
496#[cfg(feature = "mcap")]
497impl<R> ProgressReader<R> {
498    fn new(inner: R, progress: ProgressBar) -> Self {
499        Self { inner, progress }
500    }
501}
502
503#[cfg(feature = "mcap")]
504impl<R: Read> Read for ProgressReader<R> {
505    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
506        let read = self.inner.read(buf)?;
507        if read > 0 {
508            self.progress.inc(read as u64);
509        }
510        Ok(read)
511    }
512}
513
514#[cfg(feature = "mcap")]
515fn make_progress_bar(total_bytes: u64) -> ProgressBar {
516    let progress_bar = ProgressBar::new(total_bytes);
517    progress_bar.set_draw_target(ProgressDrawTarget::stderr_with_hz(5));
518
519    let style = ProgressStyle::with_template(
520        "[{elapsed_precise}] {bar:40} {bytes}/{total_bytes} ({bytes_per_sec}, ETA {eta})",
521    )
522    .unwrap_or_else(|_| ProgressStyle::default_bar());
523
524    progress_bar.set_style(style.progress_chars("=>-"));
525    progress_bar
526}
527
528#[cfg(feature = "mcap")]
529fn should_show_progress(force_progress: bool, quiet: bool) -> bool {
530    !quiet && (force_progress || std::io::stderr().is_terminal())
531}
532
533#[cfg(feature = "mcap")]
534fn copperlist_total_bytes(log_base: &Path) -> CuResult<u64> {
535    let mut reader = UnifiedLoggerRead::new(log_base)
536        .map_err(|e| CuError::new_with_cause("Failed to open log for progress estimation", e))?;
537    reader
538        .scan_section_bytes(UnifiedLogType::CopperList)
539        .map_err(|e| CuError::new_with_cause("Failed to scan log for progress estimation", e))
540}
541
542fn read_next_entry<T: Decode<()>>(src: &mut impl Read) -> Option<T> {
543    let entry = decode_from_std_read::<T, _, _>(src, standard());
544    match entry {
545        Ok(entry) => Some(entry),
546        Err(DecodeError::UnexpectedEnd { .. }) => None,
547        Err(DecodeError::Io { inner, additional }) => {
548            if inner.kind() == std::io::ErrorKind::UnexpectedEof {
549                None
550            } else {
551                println!("Error {inner:?} additional:{additional}");
552                None
553            }
554        }
555        Err(e) => {
556            println!("Error {e:?}");
557            None
558        }
559    }
560}
561
562/// Extracts the copper lists from a binary representation.
563/// P is the Payload determined by the configuration of the application.
564pub fn copperlists_reader<P: CopperListTuple>(
565    mut src: impl Read,
566) -> impl Iterator<Item = CopperList<P>> {
567    std::iter::from_fn(move || read_next_entry::<CopperList<P>>(&mut src))
568}
569
570/// Extracts the keyframes from the log.
571pub fn keyframes_reader(mut src: impl Read) -> impl Iterator<Item = KeyFrame> {
572    std::iter::from_fn(move || read_next_entry::<KeyFrame>(&mut src))
573}
574
575/// Extracts the runtime lifecycle records from the log.
576pub fn runtime_lifecycle_reader(
577    mut src: impl Read,
578) -> impl Iterator<Item = RuntimeLifecycleRecord> {
579    std::iter::from_fn(move || read_next_entry::<RuntimeLifecycleRecord>(&mut src))
580}
581
582/// Returns the first mission announced by the runtime lifecycle section, if any.
583pub fn unified_log_mission(unifiedlog_base: &Path) -> CuResult<Option<String>> {
584    let dl = build_read_logger(unifiedlog_base)?;
585    let reader = UnifiedLoggerIOReader::new(dl, UnifiedLogType::RuntimeLifecycle);
586    Ok(
587        runtime_lifecycle_reader(reader).find_map(|entry| match entry.event {
588            RuntimeLifecycleEvent::MissionStarted { mission } => Some(mission),
589            _ => None,
590        }),
591    )
592}
593
594/// Ensures the unified log was recorded for the expected mission.
595pub fn assert_unified_log_mission(unifiedlog_base: &Path, expected_mission: &str) -> CuResult<()> {
596    match unified_log_mission(unifiedlog_base)? {
597        Some(actual_mission) if actual_mission == expected_mission => Ok(()),
598        Some(actual_mission) => Err(CuError::from(format!(
599            "Mission mismatch: expected '{expected_mission}', found '{actual_mission}'"
600        ))),
601        None => Err(CuError::from(format!(
602            "No MissionStarted runtime lifecycle event found while validating expected mission '{expected_mission}'"
603        ))),
604    }
605}
606
607pub fn structlog_reader(mut src: impl Read) -> impl Iterator<Item = CuResult<CuLogEntry>> {
608    std::iter::from_fn(move || {
609        let entry = decode_from_std_read::<CuLogEntry, _, _>(&mut src, standard());
610
611        match entry {
612            Err(DecodeError::UnexpectedEnd { .. }) => None,
613            Err(DecodeError::Io {
614                inner,
615                additional: _,
616            }) => {
617                if inner.kind() == std::io::ErrorKind::UnexpectedEof {
618                    None
619                } else {
620                    Some(Err(CuError::new_with_cause("Error reading log", inner)))
621                }
622            }
623            Err(e) => Some(Err(CuError::new_with_cause("Error reading log", e))),
624            Ok(entry) => {
625                if entry.msg_index == 0 {
626                    None
627                } else {
628                    Some(Ok(entry))
629                }
630            }
631        }
632    })
633}
634
635/// Full dump of the copper structured log from its binary representation.
636/// This rebuilds a textual log.
637/// src: the source of the log data
638/// index: the path to the index file (containing the interned strings constructed at build time)
639pub fn textlog_dump(src: impl Read, index: &Path) -> CuResult<()> {
640    let all_strings = read_interned_strings(index).map_err(|e| {
641        CuError::new_with_cause(
642            "Failed to read interned strings from index",
643            std::io::Error::other(e),
644        )
645    })?;
646
647    for result in structlog_reader(src) {
648        let entry = result?;
649        match rebuild_logline(&all_strings, &entry) {
650            Ok(line) => println!("{line}"),
651            Err(e) => println!("Failed to rebuild log line: {e:?}"),
652        }
653    }
654
655    Ok(())
656}
657
658// Only compiled for users opting into the Python interface.
659#[cfg(feature = "python")]
660mod python {
661    use bincode::config::standard;
662    use bincode::decode_from_std_read;
663    use bincode::error::DecodeError;
664    use cu29::bevy_reflect::{PartialReflect, ReflectRef, VariantType};
665    use cu29::prelude::*;
666    use cu29_intern_strs::read_interned_strings;
667    use pyo3::exceptions::{PyIOError, PyRuntimeError};
668    use pyo3::prelude::*;
669    use pyo3::types::{PyDelta, PyDict, PyList};
670    use std::io::Read;
671    use std::path::Path;
672    use std::sync::OnceLock;
673
674    type CopperListDecodeFn =
675        for<'py> fn(&mut Box<dyn Read + Send + Sync>, Python<'py>) -> Option<PyResult<Py<PyAny>>>;
676    static COPPERLIST_DECODER: OnceLock<CopperListDecodeFn> = OnceLock::new();
677
678    /// Iterator over structured Copper log entries.
679    #[pyclass]
680    pub struct PyLogIterator {
681        reader: Box<dyn Read + Send + Sync>,
682    }
683
684    /// Iterator over application-specific CopperLists decoded into Python values.
685    #[pyclass]
686    pub struct PyCopperListIterator {
687        reader: Box<dyn Read + Send + Sync>,
688        decode_next: CopperListDecodeFn,
689    }
690
691    /// Iterator over runtime lifecycle records stored in a unified log.
692    #[pyclass]
693    pub struct PyRuntimeLifecycleIterator {
694        reader: Box<dyn Read + Send + Sync>,
695    }
696
697    /// Helper wrapper used when reflected unit-bearing values are exposed to Python.
698    #[pyclass(get_all)]
699    pub struct PyUnitValue {
700        pub value: f64,
701        pub unit: String,
702    }
703
704    /// Register the Python decoder for one concrete CopperList tuple type.
705    ///
706    /// App-specific extension modules call this once before constructing a
707    /// `PyCopperListIterator`.
708    pub fn register_copperlist_python_type<P>() -> CuResult<()>
709    where
710        P: CopperListTuple,
711    {
712        if COPPERLIST_DECODER.get().is_none() {
713            COPPERLIST_DECODER
714                .set(decode_next_copperlist::<P>)
715                .map_err(|_| CuError::from("Failed to register CopperList Python decoder"))?;
716        }
717        Ok(())
718    }
719    #[pymethods]
720    impl PyLogIterator {
721        fn __iter__(slf: PyRefMut<Self>) -> PyRefMut<Self> {
722            slf
723        }
724
725        fn __next__(mut slf: PyRefMut<Self>) -> Option<PyResult<PyCuLogEntry>> {
726            match decode_from_std_read::<CuLogEntry, _, _>(&mut slf.reader, standard()) {
727                Ok(entry) => {
728                    if entry.msg_index == 0 {
729                        None
730                    } else {
731                        Some(Ok(PyCuLogEntry { inner: entry }))
732                    }
733                }
734                Err(DecodeError::UnexpectedEnd { .. }) => None,
735                Err(DecodeError::Io { inner, .. })
736                    if inner.kind() == std::io::ErrorKind::UnexpectedEof =>
737                {
738                    None
739                }
740                Err(e) => Some(Err(PyIOError::new_err(e.to_string()))),
741            }
742        }
743    }
744
745    #[pymethods]
746    impl PyCopperListIterator {
747        fn __iter__(slf: PyRefMut<Self>) -> PyRefMut<Self> {
748            slf
749        }
750
751        fn __next__(mut slf: PyRefMut<Self>, py: Python<'_>) -> Option<PyResult<Py<PyAny>>> {
752            (slf.decode_next)(&mut slf.reader, py)
753        }
754    }
755
756    #[pymethods]
757    impl PyRuntimeLifecycleIterator {
758        fn __iter__(slf: PyRefMut<Self>) -> PyRefMut<Self> {
759            slf
760        }
761
762        fn __next__(mut slf: PyRefMut<Self>, py: Python<'_>) -> Option<PyResult<Py<PyAny>>> {
763            let entry = super::read_next_entry::<RuntimeLifecycleRecord>(&mut slf.reader)?;
764            Some(runtime_lifecycle_record_to_py(&entry, py))
765        }
766    }
767    /// Create an iterator over structured log entries from a bare structured log file.
768    ///
769    /// This is the non-unified-log path used by standalone structured log setups.
770    /// The function returns the iterator together with the interned string table
771    /// needed to format each message.
772    #[pyfunction]
773    pub fn struct_log_iterator_bare(
774        bare_struct_src_path: &str,
775        index_path: &str,
776    ) -> PyResult<(PyLogIterator, Vec<String>)> {
777        let file = std::fs::File::open(bare_struct_src_path)
778            .map_err(|e| PyIOError::new_err(e.to_string()))?;
779        let all_strings = read_interned_strings(Path::new(index_path))
780            .map_err(|e| PyIOError::new_err(e.to_string()))?;
781        Ok((
782            PyLogIterator {
783                reader: Box::new(file),
784            },
785            all_strings,
786        ))
787    }
788    /// Create an iterator over structured log entries from a unified log file.
789    ///
790    /// The function returns the iterator together with the interned string table
791    /// needed to rebuild the text messages.
792    #[pyfunction]
793    pub fn struct_log_iterator_unified(
794        unified_src_path: &str,
795        index_path: &str,
796    ) -> PyResult<(PyLogIterator, Vec<String>)> {
797        let all_strings = read_interned_strings(Path::new(index_path))
798            .map_err(|e| PyIOError::new_err(e.to_string()))?;
799
800        let logger = UnifiedLoggerBuilder::new()
801            .file_base_name(Path::new(unified_src_path))
802            .build()
803            .map_err(|e| PyIOError::new_err(e.to_string()))?;
804        let dl = match logger {
805            UnifiedLogger::Read(dl) => dl,
806            UnifiedLogger::Write(_) => {
807                return Err(PyIOError::new_err(
808                    "Expected read-only unified logger for Python export",
809                ));
810            }
811        };
812
813        let reader = UnifiedLoggerIOReader::new(dl, UnifiedLogType::StructuredLogLine);
814        Ok((
815            PyLogIterator {
816                reader: Box::new(reader),
817            },
818            all_strings,
819        ))
820    }
821
822    /// Create an iterator over CopperLists from a unified log file.
823    ///
824    /// The concrete CopperList tuple type must be registered from Rust first with
825    /// `register_copperlist_python_type::<P>()`.
826    #[pyfunction]
827    pub fn copperlist_iterator_unified(unified_src_path: &str) -> PyResult<PyCopperListIterator> {
828        let decode_next = *COPPERLIST_DECODER.get().ok_or_else(|| {
829            PyRuntimeError::new_err(
830                "CopperList decoder is not registered. \
831Call register_copperlist_python_type::<P>() from Rust before using this function.",
832            )
833        })?;
834
835        let logger = UnifiedLoggerBuilder::new()
836            .file_base_name(Path::new(unified_src_path))
837            .build()
838            .map_err(|e| PyIOError::new_err(e.to_string()))?;
839        let dl = match logger {
840            UnifiedLogger::Read(dl) => dl,
841            UnifiedLogger::Write(_) => {
842                return Err(PyIOError::new_err(
843                    "Expected read-only unified logger for Python export",
844                ));
845            }
846        };
847
848        let reader = UnifiedLoggerIOReader::new(dl, UnifiedLogType::CopperList);
849        Ok(PyCopperListIterator {
850            reader: Box::new(reader),
851            decode_next,
852        })
853    }
854
855    /// Create an iterator over runtime lifecycle records from a unified log file.
856    #[pyfunction]
857    pub fn runtime_lifecycle_iterator_unified(
858        unified_src_path: &str,
859    ) -> PyResult<PyRuntimeLifecycleIterator> {
860        let logger = UnifiedLoggerBuilder::new()
861            .file_base_name(Path::new(unified_src_path))
862            .build()
863            .map_err(|e| PyIOError::new_err(e.to_string()))?;
864        let dl = match logger {
865            UnifiedLogger::Read(dl) => dl,
866            UnifiedLogger::Write(_) => {
867                return Err(PyIOError::new_err(
868                    "Expected read-only unified logger for Python export",
869                ));
870            }
871        };
872
873        let reader = UnifiedLoggerIOReader::new(dl, UnifiedLogType::RuntimeLifecycle);
874        Ok(PyRuntimeLifecycleIterator {
875            reader: Box::new(reader),
876        })
877    }
878    /// Python wrapper for [`CuLogEntry`].
879    #[pyclass]
880    pub struct PyCuLogEntry {
881        pub inner: CuLogEntry,
882    }
883
884    #[pymethods]
885    impl PyCuLogEntry {
886        /// Return the timestamp of the log entry as a `datetime.timedelta`.
887        pub fn ts<'a>(&self, py: Python<'a>) -> PyResult<Bound<'a, PyDelta>> {
888            let nanoseconds: u64 = self.inner.time.into();
889
890            // Convert nanoseconds to seconds and microseconds
891            let days = (nanoseconds / 86_400_000_000_000) as i32;
892            let seconds = (nanoseconds / 1_000_000_000) as i32;
893            let microseconds = ((nanoseconds % 1_000_000_000) / 1_000) as i32;
894
895            PyDelta::new(py, days, seconds, microseconds, false)
896        }
897
898        /// Return the index of the message format string in the interned string table.
899        pub fn msg_index(&self) -> u32 {
900            self.inner.msg_index
901        }
902
903        /// Return the CopperList id captured for this log entry, if any.
904        pub fn culistid(&self) -> Option<u64> {
905            self.inner.origin.culistid
906        }
907
908        /// Return the runtime component id captured for this log entry, if any.
909        pub fn component_id(&self) -> Option<u32> {
910            self.inner.origin.component_id
911        }
912
913        /// Return the task index captured for this log entry, if any.
914        pub fn task_index(&self) -> Option<u32> {
915            self.inner.origin.task_index
916        }
917
918        /// Return the indexes of the parameter names in the interned string table.
919        pub fn paramname_indexes(&self) -> Vec<u32> {
920            self.inner.paramname_indexes.iter().copied().collect()
921        }
922
923        /// Return the structured parameters carried by this log line.
924        pub fn params(&self, py: Python<'_>) -> PyResult<Vec<Py<PyAny>>> {
925            self.inner
926                .params
927                .iter()
928                .map(|value| value_to_py(value, py))
929                .collect()
930        }
931    }
932
933    /// This needs to match the name of the generated '.so'
934    #[pymodule(name = "libcu29_export")]
935    fn cu29_export(m: &Bound<'_, PyModule>) -> PyResult<()> {
936        m.add_class::<PyCuLogEntry>()?;
937        m.add_class::<PyLogIterator>()?;
938        m.add_class::<PyCopperListIterator>()?;
939        m.add_class::<PyRuntimeLifecycleIterator>()?;
940        m.add_class::<PyUnitValue>()?;
941        m.add_function(wrap_pyfunction!(struct_log_iterator_bare, m)?)?;
942        m.add_function(wrap_pyfunction!(struct_log_iterator_unified, m)?)?;
943        m.add_function(wrap_pyfunction!(copperlist_iterator_unified, m)?)?;
944        m.add_function(wrap_pyfunction!(runtime_lifecycle_iterator_unified, m)?)?;
945        Ok(())
946    }
947
948    fn decode_next_copperlist<P>(
949        reader: &mut Box<dyn Read + Send + Sync>,
950        py: Python<'_>,
951    ) -> Option<PyResult<Py<PyAny>>>
952    where
953        P: CopperListTuple,
954    {
955        let entry = super::read_next_entry::<CopperList<P>>(reader)?;
956        Some(copperlist_to_py::<P>(&entry, py))
957    }
958
959    fn copperlist_to_py<P>(entry: &CopperList<P>, py: Python<'_>) -> PyResult<Py<PyAny>>
960    where
961        P: CopperListTuple,
962    {
963        let task_ids = P::get_all_task_ids();
964        let root = PyDict::new(py);
965        root.set_item("id", entry.id)?;
966        root.set_item("state", entry.get_state().to_string())?;
967
968        let mut messages: Vec<Py<PyAny>> = Vec::new();
969        for (idx, msg) in entry.cumsgs().into_iter().enumerate() {
970            let message = PyDict::new(py);
971            message.set_item("task_id", task_ids.get(idx).copied().unwrap_or("unknown"))?;
972            message.set_item("tov", tov_to_py(msg.tov(), py)?)?;
973            message.set_item("metadata", metadata_to_py(msg.metadata(), py)?)?;
974            match msg.payload_reflect() {
975                Some(payload) => message.set_item(
976                    "payload",
977                    partial_reflect_to_py(payload.as_partial_reflect(), py)?,
978                )?,
979                None => message.set_item("payload", py.None())?,
980            }
981            messages.push(dict_to_namespace(message, py)?);
982        }
983
984        root.set_item("messages", PyList::new(py, messages)?)?;
985        dict_to_namespace(root, py)
986    }
987
988    fn runtime_lifecycle_record_to_py(
989        entry: &RuntimeLifecycleRecord,
990        py: Python<'_>,
991    ) -> PyResult<Py<PyAny>> {
992        let root = PyDict::new(py);
993        root.set_item("timestamp_ns", entry.timestamp.as_nanos())?;
994        root.set_item("event", runtime_lifecycle_event_to_py(&entry.event, py)?)?;
995        dict_to_namespace(root, py)
996    }
997
998    fn runtime_lifecycle_event_to_py(
999        event: &RuntimeLifecycleEvent,
1000        py: Python<'_>,
1001    ) -> PyResult<Py<PyAny>> {
1002        let root = PyDict::new(py);
1003        match event {
1004            RuntimeLifecycleEvent::Instantiated {
1005                config_source,
1006                effective_config_ron,
1007                stack,
1008            } => {
1009                root.set_item("kind", "instantiated")?;
1010                root.set_item("config_source", runtime_config_source_to_py(config_source))?;
1011                root.set_item("effective_config_ron", effective_config_ron)?;
1012
1013                let stack_py = PyDict::new(py);
1014                stack_py.set_item("app_name", &stack.app_name)?;
1015                stack_py.set_item("app_version", &stack.app_version)?;
1016                stack_py.set_item("git_commit", &stack.git_commit)?;
1017                stack_py.set_item("git_dirty", stack.git_dirty)?;
1018                stack_py.set_item("subsystem_id", &stack.subsystem_id)?;
1019                stack_py.set_item("subsystem_code", stack.subsystem_code)?;
1020                stack_py.set_item("instance_id", stack.instance_id)?;
1021                root.set_item("stack", dict_to_namespace(stack_py, py)?)?;
1022            }
1023            RuntimeLifecycleEvent::MissionStarted { mission } => {
1024                root.set_item("kind", "mission_started")?;
1025                root.set_item("mission", mission)?;
1026            }
1027            RuntimeLifecycleEvent::MissionStopped { mission, reason } => {
1028                root.set_item("kind", "mission_stopped")?;
1029                root.set_item("mission", mission)?;
1030                root.set_item("reason", reason)?;
1031            }
1032            RuntimeLifecycleEvent::Panic {
1033                message,
1034                file,
1035                line,
1036                column,
1037            } => {
1038                root.set_item("kind", "panic")?;
1039                root.set_item("message", message)?;
1040                root.set_item("file", file)?;
1041                root.set_item("line", line)?;
1042                root.set_item("column", column)?;
1043            }
1044            RuntimeLifecycleEvent::ShutdownCompleted => {
1045                root.set_item("kind", "shutdown_completed")?;
1046            }
1047        }
1048
1049        dict_to_namespace(root, py)
1050    }
1051
1052    fn runtime_config_source_to_py(source: &RuntimeLifecycleConfigSource) -> &'static str {
1053        match source {
1054            RuntimeLifecycleConfigSource::ProgrammaticOverride => "programmatic_override",
1055            RuntimeLifecycleConfigSource::ExternalFile => "external_file",
1056            RuntimeLifecycleConfigSource::BundledDefault => "bundled_default",
1057        }
1058    }
1059
1060    fn metadata_to_py(metadata: &dyn CuMsgMetadataTrait, py: Python<'_>) -> PyResult<Py<PyAny>> {
1061        let process = metadata.process_time();
1062        let start: Option<CuTime> = process.start.into();
1063        let end: Option<CuTime> = process.end.into();
1064
1065        let process_time = PyDict::new(py);
1066        process_time.set_item("start_ns", start.map(|t| t.as_nanos()))?;
1067        process_time.set_item("end_ns", end.map(|t| t.as_nanos()))?;
1068
1069        let metadata_py = PyDict::new(py);
1070        metadata_py.set_item("process_time", dict_to_namespace(process_time, py)?)?;
1071        metadata_py.set_item("status_txt", metadata.status_txt().0.to_string())?;
1072        if let Some(origin) = metadata.origin() {
1073            let origin_py = PyDict::new(py);
1074            origin_py.set_item("subsystem_code", origin.subsystem_code)?;
1075            origin_py.set_item("instance_id", origin.instance_id)?;
1076            origin_py.set_item("cl_id", origin.cl_id)?;
1077            metadata_py.set_item("origin", dict_to_namespace(origin_py, py)?)?;
1078        } else {
1079            metadata_py.set_item("origin", py.None())?;
1080        }
1081        dict_to_namespace(metadata_py, py)
1082    }
1083
1084    fn tov_to_py(tov: Tov, py: Python<'_>) -> PyResult<Py<PyAny>> {
1085        let tov_py = PyDict::new(py);
1086        match tov {
1087            Tov::None => {
1088                tov_py.set_item("kind", "none")?;
1089            }
1090            Tov::Time(t) => {
1091                tov_py.set_item("kind", "time")?;
1092                tov_py.set_item("time_ns", t.as_nanos())?;
1093            }
1094            Tov::Range(r) => {
1095                tov_py.set_item("kind", "range")?;
1096                tov_py.set_item("start_ns", r.start.as_nanos())?;
1097                tov_py.set_item("end_ns", r.end.as_nanos())?;
1098            }
1099        }
1100        dict_to_namespace(tov_py, py)
1101    }
1102
1103    fn partial_reflect_to_py(value: &dyn PartialReflect, py: Python<'_>) -> PyResult<Py<PyAny>> {
1104        #[allow(unreachable_patterns)]
1105        match value.reflect_ref() {
1106            ReflectRef::Struct(s) => struct_to_py(s, py),
1107            ReflectRef::TupleStruct(ts) => tuple_struct_to_py(ts, py),
1108            ReflectRef::Tuple(t) => tuple_to_py(t, py),
1109            ReflectRef::List(list) => list_to_py(list, py),
1110            ReflectRef::Array(array) => array_to_py(array, py),
1111            ReflectRef::Map(map) => map_to_py(map, py),
1112            ReflectRef::Set(set) => set_to_py(set, py),
1113            ReflectRef::Enum(e) => enum_to_py(e, py),
1114            ReflectRef::Opaque(opaque) => opaque_to_py(opaque, py),
1115            _ => Ok(py.None()),
1116        }
1117    }
1118
1119    fn struct_to_py(value: &dyn cu29::bevy_reflect::Struct, py: Python<'_>) -> PyResult<Py<PyAny>> {
1120        let dict = PyDict::new(py);
1121        for idx in 0..value.field_len() {
1122            if let Some(field) = value.field_at(idx) {
1123                let name = value
1124                    .name_at(idx)
1125                    .map(str::to_owned)
1126                    .unwrap_or_else(|| format!("field_{idx}"));
1127                dict.set_item(name, partial_reflect_to_py(field, py)?)?;
1128            }
1129        }
1130
1131        if let Some(unit) = unit_abbrev_for_type_path(value.reflect_type_path())
1132            && let Some(raw_value) = dict.get_item("value")?
1133        {
1134            if let Ok(v) = raw_value.extract::<f64>() {
1135                let unit_value = PyUnitValue {
1136                    value: v,
1137                    unit: unit.to_string(),
1138                };
1139                return Ok(Py::new(py, unit_value)?.into());
1140            }
1141            if let Ok(v) = raw_value.extract::<f32>() {
1142                let unit_value = PyUnitValue {
1143                    value: v as f64,
1144                    unit: unit.to_string(),
1145                };
1146                return Ok(Py::new(py, unit_value)?.into());
1147            }
1148        }
1149
1150        dict_to_namespace(dict, py)
1151    }
1152
1153    fn tuple_struct_to_py(
1154        value: &dyn cu29::bevy_reflect::TupleStruct,
1155        py: Python<'_>,
1156    ) -> PyResult<Py<PyAny>> {
1157        let mut fields = Vec::with_capacity(value.field_len());
1158        for idx in 0..value.field_len() {
1159            if let Some(field) = value.field(idx) {
1160                fields.push(partial_reflect_to_py(field, py)?);
1161            } else {
1162                fields.push(py.None());
1163            }
1164        }
1165        Ok(PyList::new(py, fields)?.into_pyobject(py)?.into())
1166    }
1167
1168    fn tuple_to_py(value: &dyn cu29::bevy_reflect::Tuple, py: Python<'_>) -> PyResult<Py<PyAny>> {
1169        let mut fields = Vec::with_capacity(value.field_len());
1170        for idx in 0..value.field_len() {
1171            if let Some(field) = value.field(idx) {
1172                fields.push(partial_reflect_to_py(field, py)?);
1173            } else {
1174                fields.push(py.None());
1175            }
1176        }
1177        Ok(PyList::new(py, fields)?.into_pyobject(py)?.into())
1178    }
1179
1180    fn list_to_py(value: &dyn cu29::bevy_reflect::List, py: Python<'_>) -> PyResult<Py<PyAny>> {
1181        let mut items = Vec::with_capacity(value.len());
1182        for item in value.iter() {
1183            items.push(partial_reflect_to_py(item, py)?);
1184        }
1185        Ok(PyList::new(py, items)?.into_pyobject(py)?.into())
1186    }
1187
1188    fn array_to_py(value: &dyn cu29::bevy_reflect::Array, py: Python<'_>) -> PyResult<Py<PyAny>> {
1189        let mut items = Vec::with_capacity(value.len());
1190        for item in value.iter() {
1191            items.push(partial_reflect_to_py(item, py)?);
1192        }
1193        Ok(PyList::new(py, items)?.into_pyobject(py)?.into())
1194    }
1195
1196    fn map_to_py(value: &dyn cu29::bevy_reflect::Map, py: Python<'_>) -> PyResult<Py<PyAny>> {
1197        let dict = PyDict::new(py);
1198        for (key, val) in value.iter() {
1199            let key_str = reflect_key_to_string(key);
1200            dict.set_item(key_str, partial_reflect_to_py(val, py)?)?;
1201        }
1202        Ok(dict.into_pyobject(py)?.into())
1203    }
1204
1205    fn set_to_py(value: &dyn cu29::bevy_reflect::Set, py: Python<'_>) -> PyResult<Py<PyAny>> {
1206        let mut items = Vec::with_capacity(value.len());
1207        for item in value.iter() {
1208            items.push(partial_reflect_to_py(item, py)?);
1209        }
1210        Ok(PyList::new(py, items)?.into_pyobject(py)?.into())
1211    }
1212
1213    fn enum_to_py(value: &dyn cu29::bevy_reflect::Enum, py: Python<'_>) -> PyResult<Py<PyAny>> {
1214        let dict = PyDict::new(py);
1215        dict.set_item("variant", value.variant_name())?;
1216
1217        match value.variant_type() {
1218            VariantType::Unit => {}
1219            VariantType::Tuple => {
1220                let mut fields = Vec::with_capacity(value.field_len());
1221                for idx in 0..value.field_len() {
1222                    if let Some(field) = value.field_at(idx) {
1223                        fields.push(partial_reflect_to_py(field, py)?);
1224                    } else {
1225                        fields.push(py.None());
1226                    }
1227                }
1228                dict.set_item("fields", PyList::new(py, fields)?)?;
1229            }
1230            VariantType::Struct => {
1231                let fields = PyDict::new(py);
1232                for idx in 0..value.field_len() {
1233                    if let Some(field) = value.field_at(idx) {
1234                        let name = value
1235                            .name_at(idx)
1236                            .map(str::to_owned)
1237                            .unwrap_or_else(|| format!("field_{idx}"));
1238                        fields.set_item(name, partial_reflect_to_py(field, py)?)?;
1239                    }
1240                }
1241                dict.set_item("fields", fields)?;
1242            }
1243        }
1244
1245        dict_to_namespace(dict, py)
1246    }
1247
1248    fn dict_to_namespace(dict: Bound<'_, PyDict>, py: Python<'_>) -> PyResult<Py<PyAny>> {
1249        let types = py.import("types")?;
1250        let namespace_ctor = types.getattr("SimpleNamespace")?;
1251        let namespace = namespace_ctor.call((), Some(&dict))?;
1252        Ok(namespace.into())
1253    }
1254
1255    fn reflect_key_to_string(value: &dyn PartialReflect) -> String {
1256        if let Some(v) = value.try_downcast_ref::<String>() {
1257            return v.clone();
1258        }
1259        if let Some(v) = value.try_downcast_ref::<&'static str>() {
1260            return (*v).to_string();
1261        }
1262        if let Some(v) = value.try_downcast_ref::<char>() {
1263            return v.to_string();
1264        }
1265        if let Some(v) = value.try_downcast_ref::<bool>() {
1266            return v.to_string();
1267        }
1268        if let Some(v) = value.try_downcast_ref::<u64>() {
1269            return v.to_string();
1270        }
1271        if let Some(v) = value.try_downcast_ref::<i64>() {
1272            return v.to_string();
1273        }
1274        if let Some(v) = value.try_downcast_ref::<usize>() {
1275            return v.to_string();
1276        }
1277        if let Some(v) = value.try_downcast_ref::<isize>() {
1278            return v.to_string();
1279        }
1280        format!("{value:?}")
1281    }
1282
1283    fn unit_abbrev_for_type_path(type_path: &str) -> Option<&'static str> {
1284        match type_path.rsplit("::").next()? {
1285            "Acceleration" => Some("m/s^2"),
1286            "Angle" => Some("rad"),
1287            "AngularVelocity" => Some("rad/s"),
1288            "ElectricPotential" => Some("V"),
1289            "Length" => Some("m"),
1290            "MagneticFluxDensity" => Some("T"),
1291            "Pressure" => Some("Pa"),
1292            "Ratio" => Some("1"),
1293            "ThermodynamicTemperature" => Some("K"),
1294            "Time" => Some("s"),
1295            "Velocity" => Some("m/s"),
1296            _ => None,
1297        }
1298    }
1299
1300    fn opaque_to_py(value: &dyn PartialReflect, py: Python<'_>) -> PyResult<Py<PyAny>> {
1301        macro_rules! downcast_copy {
1302            ($ty:ty) => {
1303                if let Some(v) = value.try_downcast_ref::<$ty>() {
1304                    return Ok(v.into_pyobject(py)?.to_owned().into());
1305                }
1306            };
1307        }
1308
1309        downcast_copy!(bool);
1310        downcast_copy!(u8);
1311        downcast_copy!(u16);
1312        downcast_copy!(u32);
1313        downcast_copy!(u64);
1314        downcast_copy!(u128);
1315        downcast_copy!(usize);
1316        downcast_copy!(i8);
1317        downcast_copy!(i16);
1318        downcast_copy!(i32);
1319        downcast_copy!(i64);
1320        downcast_copy!(i128);
1321        downcast_copy!(isize);
1322        downcast_copy!(f32);
1323        downcast_copy!(f64);
1324        downcast_copy!(char);
1325
1326        if let Some(v) = value.try_downcast_ref::<String>() {
1327            return Ok(v.into_pyobject(py)?.into());
1328        }
1329        if let Some(v) = value.try_downcast_ref::<&'static str>() {
1330            return Ok(v.into_pyobject(py)?.into());
1331        }
1332        if let Some(v) = value.try_downcast_ref::<Vec<u8>>() {
1333            return Ok(v.into_pyobject(py)?.into());
1334        }
1335
1336        let fallback = format!("{value:?}");
1337        Ok(fallback.into_pyobject(py)?.into())
1338    }
1339    fn value_to_py(value: &cu29::prelude::Value, py: Python<'_>) -> PyResult<Py<PyAny>> {
1340        match value {
1341            Value::String(s) => Ok(s.into_pyobject(py)?.into()),
1342            Value::U64(u) => Ok(u.into_pyobject(py)?.into()),
1343            Value::U128(u) => Ok(u.into_pyobject(py)?.into()),
1344            Value::I64(i) => Ok(i.into_pyobject(py)?.into()),
1345            Value::I128(i) => Ok(i.into_pyobject(py)?.into()),
1346            Value::F64(f) => Ok(f.into_pyobject(py)?.into()),
1347            Value::Bool(b) => Ok(b.into_pyobject(py)?.to_owned().into()),
1348            Value::CuTime(t) => Ok(t.0.into_pyobject(py)?.into()),
1349            Value::Bytes(b) => Ok(b.into_pyobject(py)?.into()),
1350            Value::Char(c) => Ok(c.into_pyobject(py)?.into()),
1351            Value::I8(i) => Ok(i.into_pyobject(py)?.into()),
1352            Value::U8(u) => Ok(u.into_pyobject(py)?.into()),
1353            Value::I16(i) => Ok(i.into_pyobject(py)?.into()),
1354            Value::U16(u) => Ok(u.into_pyobject(py)?.into()),
1355            Value::I32(i) => Ok(i.into_pyobject(py)?.into()),
1356            Value::U32(u) => Ok(u.into_pyobject(py)?.into()),
1357            Value::Map(m) => {
1358                let dict = PyDict::new(py);
1359                for (k, v) in m.iter() {
1360                    dict.set_item(value_to_py(k, py)?, value_to_py(v, py)?)?;
1361                }
1362                Ok(dict.into_pyobject(py)?.into())
1363            }
1364            Value::F32(f) => Ok(f.into_pyobject(py)?.into()),
1365            Value::Option(o) => match o.as_ref() {
1366                Some(value) => value_to_py(value, py),
1367                None => Ok(py.None()),
1368            },
1369            Value::Unit => Ok(py.None()),
1370            Value::Newtype(v) => value_to_py(v, py),
1371            Value::Seq(s) => {
1372                let items: Vec<Py<PyAny>> = s
1373                    .iter()
1374                    .map(|value| value_to_py(value, py))
1375                    .collect::<PyResult<_>>()?;
1376                let list = PyList::new(py, items)?;
1377                Ok(list.into_pyobject(py)?.into())
1378            }
1379        }
1380    }
1381
1382    #[cfg(test)]
1383    mod tests {
1384        use super::*;
1385
1386        #[test]
1387        fn value_to_py_preserves_128_bit_integers() {
1388            Python::initialize();
1389            Python::attach(|py| {
1390                let u128_value = u128::from(u64::MAX) + 99;
1391                let u128_py = value_to_py(&Value::U128(u128_value), py).unwrap();
1392                assert_eq!(u128_py.bind(py).extract::<u128>().unwrap(), u128_value);
1393
1394                let i128_value = i128::from(i64::MIN) - 99;
1395                let i128_py = value_to_py(&Value::I128(i128_value), py).unwrap();
1396                assert_eq!(i128_py.bind(py).extract::<i128>().unwrap(), i128_value);
1397            });
1398        }
1399    }
1400}
1401
1402#[cfg(test)]
1403mod tests {
1404    use super::*;
1405    use bincode::{Decode, Encode, encode_into_slice};
1406    use serde::Deserialize;
1407    use std::env;
1408    use std::fs;
1409    use std::io::Cursor;
1410    use std::path::PathBuf;
1411    use std::sync::{Arc, Mutex};
1412    use tempfile::{TempDir, tempdir};
1413
1414    fn copy_stringindex_to_temp(tmpdir: &TempDir) -> PathBuf {
1415        // Build a minimal index on the fly so tests don't depend on build-time artifacts.
1416        let fake_out_dir = tmpdir.path().join("build").join("out").join("dir");
1417        fs::create_dir_all(&fake_out_dir).unwrap();
1418        // SAFETY: Tests run single-threaded here and we only read the variable after setting it.
1419        unsafe {
1420            env::set_var("LOG_INDEX_DIR", &fake_out_dir);
1421        }
1422
1423        // Provide entries for the message indexes used in this test module.
1424        let _ = cu29_intern_strs::intern_string("unused to start counter");
1425        let _ = cu29_intern_strs::intern_string("Just a String {}");
1426        let _ = cu29_intern_strs::intern_string("Just a String (low level) {}");
1427        let _ = cu29_intern_strs::intern_string("Just a String (end to end) {}");
1428
1429        let index_dir = cu29_intern_strs::default_log_index_dir();
1430        cu29_intern_strs::read_interned_strings(&index_dir).unwrap();
1431        index_dir
1432    }
1433
1434    #[test]
1435    fn test_extract_low_level_cu29_log() {
1436        let temp_dir = TempDir::new().unwrap();
1437        let temp_path = copy_stringindex_to_temp(&temp_dir);
1438        let entry = CuLogEntry::new(3, CuLogLevel::Info);
1439        let bytes = bincode::encode_to_vec(&entry, standard()).unwrap();
1440        let reader = Cursor::new(bytes.as_slice());
1441        textlog_dump(reader, temp_path.as_path()).unwrap();
1442    }
1443
1444    #[test]
1445    fn end_to_end_datalogger_and_structlog_test() {
1446        let dir = tempdir().expect("Failed to create temp dir");
1447        let path = dir
1448            .path()
1449            .join("end_to_end_datalogger_and_structlog_test.copper");
1450        {
1451            // Write a couple log entries
1452            let UnifiedLogger::Write(logger) = UnifiedLoggerBuilder::new()
1453                .write(true)
1454                .create(true)
1455                .file_base_name(&path)
1456                .preallocated_size(100000)
1457                .build()
1458                .expect("Failed to create logger")
1459            else {
1460                panic!("Failed to create logger")
1461            };
1462            let data_logger = Arc::new(Mutex::new(logger));
1463            let stream = stream_write(data_logger.clone(), UnifiedLogType::StructuredLogLine, 1024)
1464                .expect("Failed to create stream");
1465            let rt = LoggerRuntime::init(RobotClock::default(), stream, None::<NullLog>);
1466
1467            let mut entry = CuLogEntry::new(4, CuLogLevel::Info); // this is a "Just a String {}" log line
1468            entry.add_param(0, Value::String("Parameter for the log line".into()));
1469            log(&mut entry).expect("Failed to log");
1470            let mut entry = CuLogEntry::new(2, CuLogLevel::Info); // this is a "Just a String {}" log line
1471            entry.add_param(0, Value::String("Parameter for the log line".into()));
1472            log(&mut entry).expect("Failed to log");
1473
1474            // everything is dropped here
1475            drop(rt);
1476        }
1477        // Read back the log
1478        let UnifiedLogger::Read(logger) = UnifiedLoggerBuilder::new()
1479            .file_base_name(
1480                &dir.path()
1481                    .join("end_to_end_datalogger_and_structlog_test.copper"),
1482            )
1483            .build()
1484            .expect("Failed to create logger")
1485        else {
1486            panic!("Failed to create logger")
1487        };
1488        let reader = UnifiedLoggerIOReader::new(logger, UnifiedLogType::StructuredLogLine);
1489        let temp_dir = TempDir::new().unwrap();
1490        textlog_dump(
1491            reader,
1492            Path::new(copy_stringindex_to_temp(&temp_dir).as_path()),
1493        )
1494        .expect("Failed to dump log");
1495    }
1496
1497    // This is normally generated at compile time in CuPayload.
1498    #[derive(Debug, PartialEq, Clone, Copy, Serialize, Deserialize, Encode, Decode, Default)]
1499    struct MyMsgs((u8, i32, f32));
1500
1501    impl ErasedCuStampedDataSet for MyMsgs {
1502        fn cumsgs(&self) -> Vec<&dyn ErasedCuStampedData> {
1503            Vec::new()
1504        }
1505    }
1506
1507    impl MatchingTasks for MyMsgs {
1508        fn get_all_task_ids() -> &'static [&'static str] {
1509            &[]
1510        }
1511    }
1512
1513    /// Checks if we can recover the copper lists from a binary representation.
1514    #[test]
1515    fn test_copperlists_dump() {
1516        let mut data = vec![0u8; 10000];
1517        let mypls: [MyMsgs; 4] = [
1518            MyMsgs((1, 2, 3.0)),
1519            MyMsgs((2, 3, 4.0)),
1520            MyMsgs((3, 4, 5.0)),
1521            MyMsgs((4, 5, 6.0)),
1522        ];
1523
1524        let mut offset: usize = 0;
1525        for pl in mypls.iter() {
1526            let cl = CopperList::<MyMsgs>::new(1, *pl);
1527            offset +=
1528                encode_into_slice(&cl, &mut data.as_mut_slice()[offset..], standard()).unwrap();
1529        }
1530
1531        let reader = Cursor::new(data);
1532
1533        let mut iter = copperlists_reader::<MyMsgs>(reader);
1534        assert_eq!(iter.next().unwrap().msgs, MyMsgs((1, 2, 3.0)));
1535        assert_eq!(iter.next().unwrap().msgs, MyMsgs((2, 3, 4.0)));
1536        assert_eq!(iter.next().unwrap().msgs, MyMsgs((3, 4, 5.0)));
1537        assert_eq!(iter.next().unwrap().msgs, MyMsgs((4, 5, 6.0)));
1538    }
1539
1540    #[test]
1541    fn runtime_lifecycle_reader_extracts_started_mission() {
1542        let records = vec![
1543            RuntimeLifecycleRecord {
1544                timestamp: CuTime::default(),
1545                event: RuntimeLifecycleEvent::Instantiated {
1546                    config_source: RuntimeLifecycleConfigSource::BundledDefault,
1547                    effective_config_ron: "(missions: [])".to_string(),
1548                    stack: RuntimeLifecycleStackInfo {
1549                        app_name: "demo".to_string(),
1550                        app_version: "0.1.0".to_string(),
1551                        git_commit: None,
1552                        git_dirty: None,
1553                        subsystem_id: Some("ping".to_string()),
1554                        subsystem_code: 7,
1555                        instance_id: 42,
1556                    },
1557                },
1558            },
1559            RuntimeLifecycleRecord {
1560                timestamp: CuTime::from_nanos(42),
1561                event: RuntimeLifecycleEvent::MissionStarted {
1562                    mission: "gnss".to_string(),
1563                },
1564            },
1565        ];
1566        let mut bytes = Vec::new();
1567        for record in &records {
1568            bytes.extend(bincode::encode_to_vec(record, standard()).unwrap());
1569        }
1570
1571        let mission =
1572            runtime_lifecycle_reader(Cursor::new(bytes)).find_map(|entry| match entry.event {
1573                RuntimeLifecycleEvent::MissionStarted { mission } => Some(mission),
1574                _ => None,
1575            });
1576        assert_eq!(mission.as_deref(), Some("gnss"));
1577    }
1578
1579    #[test]
1580    fn logstats_mission_defaults_to_log_then_named_default_then_first() {
1581        let config = CuConfig::deserialize_ron(
1582            r#"(
1583                missions: [(id: "zeta"), (id: "default"), (id: "alpha")],
1584                tasks: [],
1585                cnx: [],
1586            )"#,
1587        )
1588        .unwrap();
1589        assert_eq!(
1590            resolve_logstats_mission(&config, None, Some("zeta".to_string())).as_deref(),
1591            Some("zeta")
1592        );
1593        assert_eq!(
1594            resolve_logstats_mission(&config, None, Some("missing".to_string())).as_deref(),
1595            Some("default")
1596        );
1597        assert_eq!(
1598            resolve_logstats_mission(&config, Some("alpha".to_string()), None).as_deref(),
1599            Some("alpha")
1600        );
1601
1602        let config = CuConfig::deserialize_ron(
1603            r#"(
1604                missions: [(id: "zeta"), (id: "alpha")],
1605                tasks: [],
1606                cnx: [],
1607            )"#,
1608        )
1609        .unwrap();
1610        assert_eq!(
1611            resolve_logstats_mission(&config, None, None).as_deref(),
1612            Some("alpha")
1613        );
1614    }
1615}