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