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