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 CopperList id captured for this log entry, if any.
869        pub fn culistid(&self) -> Option<u64> {
870            self.inner.origin.culistid
871        }
872
873        /// Return the runtime component id captured for this log entry, if any.
874        pub fn component_id(&self) -> Option<u32> {
875            self.inner.origin.component_id
876        }
877
878        /// Return the task index captured for this log entry, if any.
879        pub fn task_index(&self) -> Option<u32> {
880            self.inner.origin.task_index
881        }
882
883        /// Return the indexes of the parameter names in the interned string table.
884        pub fn paramname_indexes(&self) -> Vec<u32> {
885            self.inner.paramname_indexes.iter().copied().collect()
886        }
887
888        /// Return the structured parameters carried by this log line.
889        pub fn params(&self, py: Python<'_>) -> PyResult<Vec<Py<PyAny>>> {
890            self.inner
891                .params
892                .iter()
893                .map(|value| value_to_py(value, py))
894                .collect()
895        }
896    }
897
898    /// This needs to match the name of the generated '.so'
899    #[pymodule(name = "libcu29_export")]
900    fn cu29_export(m: &Bound<'_, PyModule>) -> PyResult<()> {
901        m.add_class::<PyCuLogEntry>()?;
902        m.add_class::<PyLogIterator>()?;
903        m.add_class::<PyCopperListIterator>()?;
904        m.add_class::<PyRuntimeLifecycleIterator>()?;
905        m.add_class::<PyUnitValue>()?;
906        m.add_function(wrap_pyfunction!(struct_log_iterator_bare, m)?)?;
907        m.add_function(wrap_pyfunction!(struct_log_iterator_unified, m)?)?;
908        m.add_function(wrap_pyfunction!(copperlist_iterator_unified, m)?)?;
909        m.add_function(wrap_pyfunction!(runtime_lifecycle_iterator_unified, m)?)?;
910        Ok(())
911    }
912
913    fn decode_next_copperlist<P>(
914        reader: &mut Box<dyn Read + Send + Sync>,
915        py: Python<'_>,
916    ) -> Option<PyResult<Py<PyAny>>>
917    where
918        P: CopperListTuple,
919    {
920        let entry = super::read_next_entry::<CopperList<P>>(reader)?;
921        Some(copperlist_to_py::<P>(&entry, py))
922    }
923
924    fn copperlist_to_py<P>(entry: &CopperList<P>, py: Python<'_>) -> PyResult<Py<PyAny>>
925    where
926        P: CopperListTuple,
927    {
928        let task_ids = P::get_all_task_ids();
929        let root = PyDict::new(py);
930        root.set_item("id", entry.id)?;
931        root.set_item("state", entry.get_state().to_string())?;
932
933        let mut messages: Vec<Py<PyAny>> = Vec::new();
934        for (idx, msg) in entry.cumsgs().into_iter().enumerate() {
935            let message = PyDict::new(py);
936            message.set_item("task_id", task_ids.get(idx).copied().unwrap_or("unknown"))?;
937            message.set_item("tov", tov_to_py(msg.tov(), py)?)?;
938            message.set_item("metadata", metadata_to_py(msg.metadata(), py)?)?;
939            match msg.payload_reflect() {
940                Some(payload) => message.set_item(
941                    "payload",
942                    partial_reflect_to_py(payload.as_partial_reflect(), py)?,
943                )?,
944                None => message.set_item("payload", py.None())?,
945            }
946            messages.push(dict_to_namespace(message, py)?);
947        }
948
949        root.set_item("messages", PyList::new(py, messages)?)?;
950        dict_to_namespace(root, py)
951    }
952
953    fn runtime_lifecycle_record_to_py(
954        entry: &RuntimeLifecycleRecord,
955        py: Python<'_>,
956    ) -> PyResult<Py<PyAny>> {
957        let root = PyDict::new(py);
958        root.set_item("timestamp_ns", entry.timestamp.as_nanos())?;
959        root.set_item("event", runtime_lifecycle_event_to_py(&entry.event, py)?)?;
960        dict_to_namespace(root, py)
961    }
962
963    fn runtime_lifecycle_event_to_py(
964        event: &RuntimeLifecycleEvent,
965        py: Python<'_>,
966    ) -> PyResult<Py<PyAny>> {
967        let root = PyDict::new(py);
968        match event {
969            RuntimeLifecycleEvent::Instantiated {
970                config_source,
971                effective_config_ron,
972                stack,
973            } => {
974                root.set_item("kind", "instantiated")?;
975                root.set_item("config_source", runtime_config_source_to_py(config_source))?;
976                root.set_item("effective_config_ron", effective_config_ron)?;
977
978                let stack_py = PyDict::new(py);
979                stack_py.set_item("app_name", &stack.app_name)?;
980                stack_py.set_item("app_version", &stack.app_version)?;
981                stack_py.set_item("git_commit", &stack.git_commit)?;
982                stack_py.set_item("git_dirty", stack.git_dirty)?;
983                stack_py.set_item("subsystem_id", &stack.subsystem_id)?;
984                stack_py.set_item("subsystem_code", stack.subsystem_code)?;
985                stack_py.set_item("instance_id", stack.instance_id)?;
986                root.set_item("stack", dict_to_namespace(stack_py, py)?)?;
987            }
988            RuntimeLifecycleEvent::MissionStarted { mission } => {
989                root.set_item("kind", "mission_started")?;
990                root.set_item("mission", mission)?;
991            }
992            RuntimeLifecycleEvent::MissionStopped { mission, reason } => {
993                root.set_item("kind", "mission_stopped")?;
994                root.set_item("mission", mission)?;
995                root.set_item("reason", reason)?;
996            }
997            RuntimeLifecycleEvent::Panic {
998                message,
999                file,
1000                line,
1001                column,
1002            } => {
1003                root.set_item("kind", "panic")?;
1004                root.set_item("message", message)?;
1005                root.set_item("file", file)?;
1006                root.set_item("line", line)?;
1007                root.set_item("column", column)?;
1008            }
1009            RuntimeLifecycleEvent::ShutdownCompleted => {
1010                root.set_item("kind", "shutdown_completed")?;
1011            }
1012        }
1013
1014        dict_to_namespace(root, py)
1015    }
1016
1017    fn runtime_config_source_to_py(source: &RuntimeLifecycleConfigSource) -> &'static str {
1018        match source {
1019            RuntimeLifecycleConfigSource::ProgrammaticOverride => "programmatic_override",
1020            RuntimeLifecycleConfigSource::ExternalFile => "external_file",
1021            RuntimeLifecycleConfigSource::BundledDefault => "bundled_default",
1022        }
1023    }
1024
1025    fn metadata_to_py(metadata: &dyn CuMsgMetadataTrait, py: Python<'_>) -> PyResult<Py<PyAny>> {
1026        let process = metadata.process_time();
1027        let start: Option<CuTime> = process.start.into();
1028        let end: Option<CuTime> = process.end.into();
1029
1030        let process_time = PyDict::new(py);
1031        process_time.set_item("start_ns", start.map(|t| t.as_nanos()))?;
1032        process_time.set_item("end_ns", end.map(|t| t.as_nanos()))?;
1033
1034        let metadata_py = PyDict::new(py);
1035        metadata_py.set_item("process_time", dict_to_namespace(process_time, py)?)?;
1036        metadata_py.set_item("status_txt", metadata.status_txt().0.to_string())?;
1037        if let Some(origin) = metadata.origin() {
1038            let origin_py = PyDict::new(py);
1039            origin_py.set_item("subsystem_code", origin.subsystem_code)?;
1040            origin_py.set_item("instance_id", origin.instance_id)?;
1041            origin_py.set_item("cl_id", origin.cl_id)?;
1042            metadata_py.set_item("origin", dict_to_namespace(origin_py, py)?)?;
1043        } else {
1044            metadata_py.set_item("origin", py.None())?;
1045        }
1046        dict_to_namespace(metadata_py, py)
1047    }
1048
1049    fn tov_to_py(tov: Tov, py: Python<'_>) -> PyResult<Py<PyAny>> {
1050        let tov_py = PyDict::new(py);
1051        match tov {
1052            Tov::None => {
1053                tov_py.set_item("kind", "none")?;
1054            }
1055            Tov::Time(t) => {
1056                tov_py.set_item("kind", "time")?;
1057                tov_py.set_item("time_ns", t.as_nanos())?;
1058            }
1059            Tov::Range(r) => {
1060                tov_py.set_item("kind", "range")?;
1061                tov_py.set_item("start_ns", r.start.as_nanos())?;
1062                tov_py.set_item("end_ns", r.end.as_nanos())?;
1063            }
1064        }
1065        dict_to_namespace(tov_py, py)
1066    }
1067
1068    fn partial_reflect_to_py(value: &dyn PartialReflect, py: Python<'_>) -> PyResult<Py<PyAny>> {
1069        #[allow(unreachable_patterns)]
1070        match value.reflect_ref() {
1071            ReflectRef::Struct(s) => struct_to_py(s, py),
1072            ReflectRef::TupleStruct(ts) => tuple_struct_to_py(ts, py),
1073            ReflectRef::Tuple(t) => tuple_to_py(t, py),
1074            ReflectRef::List(list) => list_to_py(list, py),
1075            ReflectRef::Array(array) => array_to_py(array, py),
1076            ReflectRef::Map(map) => map_to_py(map, py),
1077            ReflectRef::Set(set) => set_to_py(set, py),
1078            ReflectRef::Enum(e) => enum_to_py(e, py),
1079            ReflectRef::Opaque(opaque) => opaque_to_py(opaque, py),
1080            _ => Ok(py.None()),
1081        }
1082    }
1083
1084    fn struct_to_py(value: &dyn cu29::bevy_reflect::Struct, py: Python<'_>) -> PyResult<Py<PyAny>> {
1085        let dict = PyDict::new(py);
1086        for idx in 0..value.field_len() {
1087            if let Some(field) = value.field_at(idx) {
1088                let name = value
1089                    .name_at(idx)
1090                    .map(str::to_owned)
1091                    .unwrap_or_else(|| format!("field_{idx}"));
1092                dict.set_item(name, partial_reflect_to_py(field, py)?)?;
1093            }
1094        }
1095
1096        if let Some(unit) = unit_abbrev_for_type_path(value.reflect_type_path())
1097            && let Some(raw_value) = dict.get_item("value")?
1098        {
1099            if let Ok(v) = raw_value.extract::<f64>() {
1100                let unit_value = PyUnitValue {
1101                    value: v,
1102                    unit: unit.to_string(),
1103                };
1104                return Ok(Py::new(py, unit_value)?.into());
1105            }
1106            if let Ok(v) = raw_value.extract::<f32>() {
1107                let unit_value = PyUnitValue {
1108                    value: v as f64,
1109                    unit: unit.to_string(),
1110                };
1111                return Ok(Py::new(py, unit_value)?.into());
1112            }
1113        }
1114
1115        dict_to_namespace(dict, py)
1116    }
1117
1118    fn tuple_struct_to_py(
1119        value: &dyn cu29::bevy_reflect::TupleStruct,
1120        py: Python<'_>,
1121    ) -> PyResult<Py<PyAny>> {
1122        let mut fields = Vec::with_capacity(value.field_len());
1123        for idx in 0..value.field_len() {
1124            if let Some(field) = value.field(idx) {
1125                fields.push(partial_reflect_to_py(field, py)?);
1126            } else {
1127                fields.push(py.None());
1128            }
1129        }
1130        Ok(PyList::new(py, fields)?.into_pyobject(py)?.into())
1131    }
1132
1133    fn tuple_to_py(value: &dyn cu29::bevy_reflect::Tuple, py: Python<'_>) -> PyResult<Py<PyAny>> {
1134        let mut fields = Vec::with_capacity(value.field_len());
1135        for idx in 0..value.field_len() {
1136            if let Some(field) = value.field(idx) {
1137                fields.push(partial_reflect_to_py(field, py)?);
1138            } else {
1139                fields.push(py.None());
1140            }
1141        }
1142        Ok(PyList::new(py, fields)?.into_pyobject(py)?.into())
1143    }
1144
1145    fn list_to_py(value: &dyn cu29::bevy_reflect::List, py: Python<'_>) -> PyResult<Py<PyAny>> {
1146        let mut items = Vec::with_capacity(value.len());
1147        for item in value.iter() {
1148            items.push(partial_reflect_to_py(item, py)?);
1149        }
1150        Ok(PyList::new(py, items)?.into_pyobject(py)?.into())
1151    }
1152
1153    fn array_to_py(value: &dyn cu29::bevy_reflect::Array, py: Python<'_>) -> PyResult<Py<PyAny>> {
1154        let mut items = Vec::with_capacity(value.len());
1155        for item in value.iter() {
1156            items.push(partial_reflect_to_py(item, py)?);
1157        }
1158        Ok(PyList::new(py, items)?.into_pyobject(py)?.into())
1159    }
1160
1161    fn map_to_py(value: &dyn cu29::bevy_reflect::Map, py: Python<'_>) -> PyResult<Py<PyAny>> {
1162        let dict = PyDict::new(py);
1163        for (key, val) in value.iter() {
1164            let key_str = reflect_key_to_string(key);
1165            dict.set_item(key_str, partial_reflect_to_py(val, py)?)?;
1166        }
1167        Ok(dict.into_pyobject(py)?.into())
1168    }
1169
1170    fn set_to_py(value: &dyn cu29::bevy_reflect::Set, py: Python<'_>) -> PyResult<Py<PyAny>> {
1171        let mut items = Vec::with_capacity(value.len());
1172        for item in value.iter() {
1173            items.push(partial_reflect_to_py(item, py)?);
1174        }
1175        Ok(PyList::new(py, items)?.into_pyobject(py)?.into())
1176    }
1177
1178    fn enum_to_py(value: &dyn cu29::bevy_reflect::Enum, py: Python<'_>) -> PyResult<Py<PyAny>> {
1179        let dict = PyDict::new(py);
1180        dict.set_item("variant", value.variant_name())?;
1181
1182        match value.variant_type() {
1183            VariantType::Unit => {}
1184            VariantType::Tuple => {
1185                let mut fields = Vec::with_capacity(value.field_len());
1186                for idx in 0..value.field_len() {
1187                    if let Some(field) = value.field_at(idx) {
1188                        fields.push(partial_reflect_to_py(field, py)?);
1189                    } else {
1190                        fields.push(py.None());
1191                    }
1192                }
1193                dict.set_item("fields", PyList::new(py, fields)?)?;
1194            }
1195            VariantType::Struct => {
1196                let fields = PyDict::new(py);
1197                for idx in 0..value.field_len() {
1198                    if let Some(field) = value.field_at(idx) {
1199                        let name = value
1200                            .name_at(idx)
1201                            .map(str::to_owned)
1202                            .unwrap_or_else(|| format!("field_{idx}"));
1203                        fields.set_item(name, partial_reflect_to_py(field, py)?)?;
1204                    }
1205                }
1206                dict.set_item("fields", fields)?;
1207            }
1208        }
1209
1210        dict_to_namespace(dict, py)
1211    }
1212
1213    fn dict_to_namespace(dict: Bound<'_, PyDict>, py: Python<'_>) -> PyResult<Py<PyAny>> {
1214        let types = py.import("types")?;
1215        let namespace_ctor = types.getattr("SimpleNamespace")?;
1216        let namespace = namespace_ctor.call((), Some(&dict))?;
1217        Ok(namespace.into())
1218    }
1219
1220    fn reflect_key_to_string(value: &dyn PartialReflect) -> String {
1221        if let Some(v) = value.try_downcast_ref::<String>() {
1222            return v.clone();
1223        }
1224        if let Some(v) = value.try_downcast_ref::<&'static str>() {
1225            return (*v).to_string();
1226        }
1227        if let Some(v) = value.try_downcast_ref::<char>() {
1228            return v.to_string();
1229        }
1230        if let Some(v) = value.try_downcast_ref::<bool>() {
1231            return v.to_string();
1232        }
1233        if let Some(v) = value.try_downcast_ref::<u64>() {
1234            return v.to_string();
1235        }
1236        if let Some(v) = value.try_downcast_ref::<i64>() {
1237            return v.to_string();
1238        }
1239        if let Some(v) = value.try_downcast_ref::<usize>() {
1240            return v.to_string();
1241        }
1242        if let Some(v) = value.try_downcast_ref::<isize>() {
1243            return v.to_string();
1244        }
1245        format!("{value:?}")
1246    }
1247
1248    fn unit_abbrev_for_type_path(type_path: &str) -> Option<&'static str> {
1249        match type_path.rsplit("::").next()? {
1250            "Acceleration" => Some("m/s^2"),
1251            "Angle" => Some("rad"),
1252            "AngularVelocity" => Some("rad/s"),
1253            "ElectricPotential" => Some("V"),
1254            "Length" => Some("m"),
1255            "MagneticFluxDensity" => Some("T"),
1256            "Pressure" => Some("Pa"),
1257            "Ratio" => Some("1"),
1258            "ThermodynamicTemperature" => Some("K"),
1259            "Time" => Some("s"),
1260            "Velocity" => Some("m/s"),
1261            _ => None,
1262        }
1263    }
1264
1265    fn opaque_to_py(value: &dyn PartialReflect, py: Python<'_>) -> PyResult<Py<PyAny>> {
1266        macro_rules! downcast_copy {
1267            ($ty:ty) => {
1268                if let Some(v) = value.try_downcast_ref::<$ty>() {
1269                    return Ok(v.into_pyobject(py)?.to_owned().into());
1270                }
1271            };
1272        }
1273
1274        downcast_copy!(bool);
1275        downcast_copy!(u8);
1276        downcast_copy!(u16);
1277        downcast_copy!(u32);
1278        downcast_copy!(u64);
1279        downcast_copy!(u128);
1280        downcast_copy!(usize);
1281        downcast_copy!(i8);
1282        downcast_copy!(i16);
1283        downcast_copy!(i32);
1284        downcast_copy!(i64);
1285        downcast_copy!(i128);
1286        downcast_copy!(isize);
1287        downcast_copy!(f32);
1288        downcast_copy!(f64);
1289        downcast_copy!(char);
1290
1291        if let Some(v) = value.try_downcast_ref::<String>() {
1292            return Ok(v.into_pyobject(py)?.into());
1293        }
1294        if let Some(v) = value.try_downcast_ref::<&'static str>() {
1295            return Ok(v.into_pyobject(py)?.into());
1296        }
1297        if let Some(v) = value.try_downcast_ref::<Vec<u8>>() {
1298            return Ok(v.into_pyobject(py)?.into());
1299        }
1300
1301        let fallback = format!("{value:?}");
1302        Ok(fallback.into_pyobject(py)?.into())
1303    }
1304    fn value_to_py(value: &cu29::prelude::Value, py: Python<'_>) -> PyResult<Py<PyAny>> {
1305        match value {
1306            Value::String(s) => Ok(s.into_pyobject(py)?.into()),
1307            Value::U64(u) => Ok(u.into_pyobject(py)?.into()),
1308            Value::U128(u) => Ok(u.into_pyobject(py)?.into()),
1309            Value::I64(i) => Ok(i.into_pyobject(py)?.into()),
1310            Value::I128(i) => Ok(i.into_pyobject(py)?.into()),
1311            Value::F64(f) => Ok(f.into_pyobject(py)?.into()),
1312            Value::Bool(b) => Ok(b.into_pyobject(py)?.to_owned().into()),
1313            Value::CuTime(t) => Ok(t.0.into_pyobject(py)?.into()),
1314            Value::Bytes(b) => Ok(b.into_pyobject(py)?.into()),
1315            Value::Char(c) => Ok(c.into_pyobject(py)?.into()),
1316            Value::I8(i) => Ok(i.into_pyobject(py)?.into()),
1317            Value::U8(u) => Ok(u.into_pyobject(py)?.into()),
1318            Value::I16(i) => Ok(i.into_pyobject(py)?.into()),
1319            Value::U16(u) => Ok(u.into_pyobject(py)?.into()),
1320            Value::I32(i) => Ok(i.into_pyobject(py)?.into()),
1321            Value::U32(u) => Ok(u.into_pyobject(py)?.into()),
1322            Value::Map(m) => {
1323                let dict = PyDict::new(py);
1324                for (k, v) in m.iter() {
1325                    dict.set_item(value_to_py(k, py)?, value_to_py(v, py)?)?;
1326                }
1327                Ok(dict.into_pyobject(py)?.into())
1328            }
1329            Value::F32(f) => Ok(f.into_pyobject(py)?.into()),
1330            Value::Option(o) => match o.as_ref() {
1331                Some(value) => value_to_py(value, py),
1332                None => Ok(py.None()),
1333            },
1334            Value::Unit => Ok(py.None()),
1335            Value::Newtype(v) => value_to_py(v, py),
1336            Value::Seq(s) => {
1337                let items: Vec<Py<PyAny>> = s
1338                    .iter()
1339                    .map(|value| value_to_py(value, py))
1340                    .collect::<PyResult<_>>()?;
1341                let list = PyList::new(py, items)?;
1342                Ok(list.into_pyobject(py)?.into())
1343            }
1344        }
1345    }
1346
1347    #[cfg(test)]
1348    mod tests {
1349        use super::*;
1350
1351        #[test]
1352        fn value_to_py_preserves_128_bit_integers() {
1353            Python::initialize();
1354            Python::attach(|py| {
1355                let u128_value = u128::from(u64::MAX) + 99;
1356                let u128_py = value_to_py(&Value::U128(u128_value), py).unwrap();
1357                assert_eq!(u128_py.bind(py).extract::<u128>().unwrap(), u128_value);
1358
1359                let i128_value = i128::from(i64::MIN) - 99;
1360                let i128_py = value_to_py(&Value::I128(i128_value), py).unwrap();
1361                assert_eq!(i128_py.bind(py).extract::<i128>().unwrap(), i128_value);
1362            });
1363        }
1364    }
1365}
1366
1367#[cfg(test)]
1368mod tests {
1369    use super::*;
1370    use bincode::{Decode, Encode, encode_into_slice};
1371    use serde::Deserialize;
1372    use std::env;
1373    use std::fs;
1374    use std::io::Cursor;
1375    use std::path::PathBuf;
1376    use std::sync::{Arc, Mutex};
1377    use tempfile::{TempDir, tempdir};
1378
1379    fn copy_stringindex_to_temp(tmpdir: &TempDir) -> PathBuf {
1380        // Build a minimal index on the fly so tests don't depend on build-time artifacts.
1381        let fake_out_dir = tmpdir.path().join("build").join("out").join("dir");
1382        fs::create_dir_all(&fake_out_dir).unwrap();
1383        // SAFETY: Tests run single-threaded here and we only read the variable after setting it.
1384        unsafe {
1385            env::set_var("LOG_INDEX_DIR", &fake_out_dir);
1386        }
1387
1388        // Provide entries for the message indexes used in this test module.
1389        let _ = cu29_intern_strs::intern_string("unused to start counter");
1390        let _ = cu29_intern_strs::intern_string("Just a String {}");
1391        let _ = cu29_intern_strs::intern_string("Just a String (low level) {}");
1392        let _ = cu29_intern_strs::intern_string("Just a String (end to end) {}");
1393
1394        let index_dir = cu29_intern_strs::default_log_index_dir();
1395        cu29_intern_strs::read_interned_strings(&index_dir).unwrap();
1396        index_dir
1397    }
1398
1399    #[test]
1400    fn test_extract_low_level_cu29_log() {
1401        let temp_dir = TempDir::new().unwrap();
1402        let temp_path = copy_stringindex_to_temp(&temp_dir);
1403        let entry = CuLogEntry::new(3, CuLogLevel::Info);
1404        let bytes = bincode::encode_to_vec(&entry, standard()).unwrap();
1405        let reader = Cursor::new(bytes.as_slice());
1406        textlog_dump(reader, temp_path.as_path()).unwrap();
1407    }
1408
1409    #[test]
1410    fn end_to_end_datalogger_and_structlog_test() {
1411        let dir = tempdir().expect("Failed to create temp dir");
1412        let path = dir
1413            .path()
1414            .join("end_to_end_datalogger_and_structlog_test.copper");
1415        {
1416            // Write a couple log entries
1417            let UnifiedLogger::Write(logger) = UnifiedLoggerBuilder::new()
1418                .write(true)
1419                .create(true)
1420                .file_base_name(&path)
1421                .preallocated_size(100000)
1422                .build()
1423                .expect("Failed to create logger")
1424            else {
1425                panic!("Failed to create logger")
1426            };
1427            let data_logger = Arc::new(Mutex::new(logger));
1428            let stream = stream_write(data_logger.clone(), UnifiedLogType::StructuredLogLine, 1024)
1429                .expect("Failed to create stream");
1430            let rt = LoggerRuntime::init(RobotClock::default(), stream, None::<NullLog>);
1431
1432            let mut entry = CuLogEntry::new(4, CuLogLevel::Info); // this is a "Just a String {}" log line
1433            entry.add_param(0, Value::String("Parameter for the log line".into()));
1434            log(&mut entry).expect("Failed to log");
1435            let mut entry = CuLogEntry::new(2, CuLogLevel::Info); // this is a "Just a String {}" log line
1436            entry.add_param(0, Value::String("Parameter for the log line".into()));
1437            log(&mut entry).expect("Failed to log");
1438
1439            // everything is dropped here
1440            drop(rt);
1441        }
1442        // Read back the log
1443        let UnifiedLogger::Read(logger) = UnifiedLoggerBuilder::new()
1444            .file_base_name(
1445                &dir.path()
1446                    .join("end_to_end_datalogger_and_structlog_test.copper"),
1447            )
1448            .build()
1449            .expect("Failed to create logger")
1450        else {
1451            panic!("Failed to create logger")
1452        };
1453        let reader = UnifiedLoggerIOReader::new(logger, UnifiedLogType::StructuredLogLine);
1454        let temp_dir = TempDir::new().unwrap();
1455        textlog_dump(
1456            reader,
1457            Path::new(copy_stringindex_to_temp(&temp_dir).as_path()),
1458        )
1459        .expect("Failed to dump log");
1460    }
1461
1462    // This is normally generated at compile time in CuPayload.
1463    #[derive(Debug, PartialEq, Clone, Copy, Serialize, Deserialize, Encode, Decode, Default)]
1464    struct MyMsgs((u8, i32, f32));
1465
1466    impl ErasedCuStampedDataSet for MyMsgs {
1467        fn cumsgs(&self) -> Vec<&dyn ErasedCuStampedData> {
1468            Vec::new()
1469        }
1470    }
1471
1472    impl MatchingTasks for MyMsgs {
1473        fn get_all_task_ids() -> &'static [&'static str] {
1474            &[]
1475        }
1476    }
1477
1478    /// Checks if we can recover the copper lists from a binary representation.
1479    #[test]
1480    fn test_copperlists_dump() {
1481        let mut data = vec![0u8; 10000];
1482        let mypls: [MyMsgs; 4] = [
1483            MyMsgs((1, 2, 3.0)),
1484            MyMsgs((2, 3, 4.0)),
1485            MyMsgs((3, 4, 5.0)),
1486            MyMsgs((4, 5, 6.0)),
1487        ];
1488
1489        let mut offset: usize = 0;
1490        for pl in mypls.iter() {
1491            let cl = CopperList::<MyMsgs>::new(1, *pl);
1492            offset +=
1493                encode_into_slice(&cl, &mut data.as_mut_slice()[offset..], standard()).unwrap();
1494        }
1495
1496        let reader = Cursor::new(data);
1497
1498        let mut iter = copperlists_reader::<MyMsgs>(reader);
1499        assert_eq!(iter.next().unwrap().msgs, MyMsgs((1, 2, 3.0)));
1500        assert_eq!(iter.next().unwrap().msgs, MyMsgs((2, 3, 4.0)));
1501        assert_eq!(iter.next().unwrap().msgs, MyMsgs((3, 4, 5.0)));
1502        assert_eq!(iter.next().unwrap().msgs, MyMsgs((4, 5, 6.0)));
1503    }
1504
1505    #[test]
1506    fn runtime_lifecycle_reader_extracts_started_mission() {
1507        let records = vec![
1508            RuntimeLifecycleRecord {
1509                timestamp: CuTime::default(),
1510                event: RuntimeLifecycleEvent::Instantiated {
1511                    config_source: RuntimeLifecycleConfigSource::BundledDefault,
1512                    effective_config_ron: "(missions: [])".to_string(),
1513                    stack: RuntimeLifecycleStackInfo {
1514                        app_name: "demo".to_string(),
1515                        app_version: "0.1.0".to_string(),
1516                        git_commit: None,
1517                        git_dirty: None,
1518                        subsystem_id: Some("ping".to_string()),
1519                        subsystem_code: 7,
1520                        instance_id: 42,
1521                    },
1522                },
1523            },
1524            RuntimeLifecycleRecord {
1525                timestamp: CuTime::from_nanos(42),
1526                event: RuntimeLifecycleEvent::MissionStarted {
1527                    mission: "gnss".to_string(),
1528                },
1529            },
1530        ];
1531        let mut bytes = Vec::new();
1532        for record in &records {
1533            bytes.extend(bincode::encode_to_vec(record, standard()).unwrap());
1534        }
1535
1536        let mission =
1537            runtime_lifecycle_reader(Cursor::new(bytes)).find_map(|entry| match entry.event {
1538                RuntimeLifecycleEvent::MissionStarted { mission } => Some(mission),
1539                _ => None,
1540            });
1541        assert_eq!(mission.as_deref(), Some("gnss"));
1542    }
1543}