Skip to main content

cu29_export/
lib.rs

1mod fsck;
2pub mod logstats;
3
4#[cfg(feature = "mcap")]
5pub mod mcap_export;
6
7#[cfg(feature = "mcap")]
8pub mod serde_to_jsonschema;
9
10use bincode::Decode;
11use bincode::config::standard;
12use bincode::decode_from_std_read;
13use bincode::error::DecodeError;
14use clap::{Parser, Subcommand, ValueEnum};
15use cu29::UnifiedLogType;
16use cu29::prelude::*;
17use cu29_intern_strs::read_interned_strings;
18use fsck::check;
19#[cfg(feature = "mcap")]
20use indicatif::{ProgressBar, ProgressDrawTarget, ProgressStyle};
21use logstats::{compute_logstats, write_logstats};
22use serde::Serialize;
23use std::fmt::{Display, Formatter};
24#[cfg(feature = "mcap")]
25use std::io::IsTerminal;
26use std::io::Read;
27use std::path::{Path, PathBuf};
28
29#[cfg(feature = "mcap")]
30pub use mcap_export::{
31    McapExportStats, PayloadSchemas, export_to_mcap, export_to_mcap_with_schemas, mcap_info,
32};
33
34#[cfg(feature = "mcap")]
35pub use serde_to_jsonschema::trace_type_to_jsonschema;
36
37#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)]
38pub enum ExportFormat {
39    Json,
40    Csv,
41}
42
43impl Display for ExportFormat {
44    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
45        match self {
46            ExportFormat::Json => write!(f, "json"),
47            ExportFormat::Csv => write!(f, "csv"),
48        }
49    }
50}
51
52/// This is a generator for a main function to build a log extractor.
53#[derive(Parser)]
54#[command(author, version, about)]
55pub struct LogReaderCli {
56    /// The base path is the name with no _0 _1 et the end.
57    /// for example for toto_0.copper, toto_1.copper ... the base name is toto.copper
58    pub unifiedlog_base: PathBuf,
59
60    #[command(subcommand)]
61    pub command: Command,
62}
63
64#[derive(Subcommand)]
65pub enum Command {
66    /// Extract logs
67    ExtractTextLog { log_index: PathBuf },
68    /// Extract copperlists
69    ExtractCopperlists {
70        #[arg(short, long, default_value_t = ExportFormat::Json)]
71        export_format: ExportFormat,
72    },
73    /// Check the log and dump info about it.
74    Fsck {
75        #[arg(short, long, action = clap::ArgAction::Count)]
76        verbose: u8,
77        /// Decode and print RuntimeLifecycle events.
78        #[arg(long)]
79        dump_runtime_lifecycle: bool,
80    },
81    /// Export log statistics to JSON for offline DAG rendering.
82    LogStats {
83        /// Output JSON file path
84        #[arg(short, long, default_value = "cu29_logstats.json")]
85        output: PathBuf,
86        /// Config file used to map outputs to edges
87        #[arg(long, default_value = "copperconfig.ron")]
88        config: PathBuf,
89        /// Mission id to use when reading the config
90        #[arg(long)]
91        mission: Option<String>,
92    },
93    /// Export copperlists to MCAP format (requires 'mcap' feature)
94    #[cfg(feature = "mcap")]
95    ExportMcap {
96        /// Output MCAP file path
97        #[arg(short, long)]
98        output: PathBuf,
99        /// Force progress bar even when stderr is not a TTY
100        #[arg(long)]
101        progress: bool,
102        /// Suppress the progress bar
103        #[arg(long)]
104        quiet: bool,
105    },
106    /// Inspect an MCAP file and dump metadata, schemas, and stats (requires 'mcap' feature)
107    #[cfg(feature = "mcap")]
108    McapInfo {
109        /// Path to the MCAP file to inspect
110        mcap_file: PathBuf,
111        /// Show full schema content
112        #[arg(short, long)]
113        schemas: bool,
114        /// Show sample messages (first N messages per channel)
115        #[arg(short = 'n', long, default_value_t = 0)]
116        sample_messages: usize,
117    },
118}
119
120fn write_json_pretty<T: Serialize + ?Sized>(value: &T) -> CuResult<()> {
121    serde_json::to_writer_pretty(std::io::stdout(), value)
122        .map_err(|e| CuError::new_with_cause("Failed to write JSON output", e))
123}
124
125fn write_json<T: Serialize + ?Sized>(value: &T) -> CuResult<()> {
126    serde_json::to_writer(std::io::stdout(), value)
127        .map_err(|e| CuError::new_with_cause("Failed to write JSON output", e))
128}
129
130fn build_read_logger(unifiedlog_base: &Path) -> CuResult<UnifiedLoggerRead> {
131    let logger = UnifiedLoggerBuilder::new()
132        .file_base_name(unifiedlog_base)
133        .build()
134        .map_err(|e| CuError::new_with_cause("Failed to create logger", e))?;
135    match logger {
136        UnifiedLogger::Read(dl) => Ok(dl),
137        UnifiedLogger::Write(_) => Err(CuError::from(
138            "Expected read-only unified logger in export CLI",
139        )),
140    }
141}
142
143/// This is a generator for a main function to build a log extractor.
144/// It depends on the specific type of the CopperList payload that is determined at compile time from the configuration.
145///
146/// When the `mcap` feature is enabled, P must also implement `PayloadSchemas` for MCAP export support.
147#[cfg(feature = "mcap")]
148pub fn run_cli<P>() -> CuResult<()>
149where
150    P: CopperListTuple + CuPayloadRawBytes + mcap_export::PayloadSchemas,
151{
152    run_cli_inner::<P>()
153}
154
155/// This is a generator for a main function to build a log extractor.
156/// It depends on the specific type of the CopperList payload that is determined at compile time from the configuration.
157#[cfg(not(feature = "mcap"))]
158pub fn run_cli<P>() -> CuResult<()>
159where
160    P: CopperListTuple + CuPayloadRawBytes,
161{
162    run_cli_inner::<P>()
163}
164
165#[cfg(feature = "mcap")]
166fn run_cli_inner<P>() -> CuResult<()>
167where
168    P: CopperListTuple + CuPayloadRawBytes + mcap_export::PayloadSchemas,
169{
170    let args = LogReaderCli::parse();
171    let unifiedlog_base = args.unifiedlog_base;
172
173    let mut dl = build_read_logger(&unifiedlog_base)?;
174
175    match args.command {
176        Command::ExtractTextLog { log_index } => {
177            let reader = UnifiedLoggerIOReader::new(dl, UnifiedLogType::StructuredLogLine);
178            textlog_dump(reader, &log_index)?;
179        }
180        Command::ExtractCopperlists { export_format } => {
181            println!("Extracting copperlists with format: {export_format}");
182            let mut reader = UnifiedLoggerIOReader::new(dl, UnifiedLogType::CopperList);
183            let iter = copperlists_reader::<P>(&mut reader);
184
185            match export_format {
186                ExportFormat::Json => {
187                    for entry in iter {
188                        write_json_pretty(&entry)?;
189                    }
190                }
191                ExportFormat::Csv => {
192                    let mut first = true;
193                    for origin in P::get_all_task_ids() {
194                        if !first {
195                            print!(", ");
196                        } else {
197                            print!("id, ");
198                        }
199                        print!("{origin}_time, {origin}_tov, {origin},");
200                        first = false;
201                    }
202                    println!();
203                    for entry in iter {
204                        let mut first = true;
205                        for msg in entry.cumsgs() {
206                            if let Some(payload) = msg.payload() {
207                                if !first {
208                                    print!(", ");
209                                } else {
210                                    print!("{}, ", entry.id);
211                                }
212                                let metadata = msg.metadata();
213                                print!("{}, {}, ", metadata.process_time(), msg.tov());
214                                write_json(payload)?; // TODO: escape for CSV
215                                first = false;
216                            }
217                        }
218                        println!();
219                    }
220                }
221            }
222        }
223        Command::Fsck {
224            verbose,
225            dump_runtime_lifecycle,
226        } => {
227            if let Some(value) = check::<P>(&mut dl, verbose, dump_runtime_lifecycle) {
228                return value;
229            }
230        }
231        Command::LogStats {
232            output,
233            config,
234            mission,
235        } => {
236            run_logstats::<P>(dl, output, config, mission)?;
237        }
238        #[cfg(feature = "mcap")]
239        Command::ExportMcap {
240            output,
241            progress,
242            quiet,
243        } => {
244            println!("Exporting copperlists to MCAP format: {}", output.display());
245
246            let show_progress = should_show_progress(progress, quiet);
247            let total_bytes = if show_progress {
248                Some(copperlist_total_bytes(&unifiedlog_base)?)
249            } else {
250                None
251            };
252
253            let reader = UnifiedLoggerIOReader::new(dl, UnifiedLogType::CopperList);
254
255            // Export to MCAP with schemas
256            // Note: P must implement PayloadSchemas and provide schemas for each task output.
257            let stats = if let Some(total_bytes) = total_bytes {
258                let progress_bar = make_progress_bar(total_bytes);
259                let reader = ProgressReader::new(reader, progress_bar.clone());
260                let result = export_to_mcap_impl::<P>(reader, &output);
261                progress_bar.finish_and_clear();
262                result?
263            } else {
264                export_to_mcap_impl::<P>(reader, &output)?
265            };
266            println!("{stats}");
267        }
268        #[cfg(feature = "mcap")]
269        Command::McapInfo {
270            mcap_file,
271            schemas,
272            sample_messages,
273        } => {
274            mcap_info(&mcap_file, schemas, sample_messages)?;
275        }
276    }
277
278    Ok(())
279}
280
281#[cfg(not(feature = "mcap"))]
282fn run_cli_inner<P>() -> CuResult<()>
283where
284    P: CopperListTuple + CuPayloadRawBytes,
285{
286    let args = LogReaderCli::parse();
287    let unifiedlog_base = args.unifiedlog_base;
288
289    let mut dl = build_read_logger(&unifiedlog_base)?;
290
291    match args.command {
292        Command::ExtractTextLog { log_index } => {
293            let reader = UnifiedLoggerIOReader::new(dl, UnifiedLogType::StructuredLogLine);
294            textlog_dump(reader, &log_index)?;
295        }
296        Command::ExtractCopperlists { export_format } => {
297            println!("Extracting copperlists with format: {export_format}");
298            let mut reader = UnifiedLoggerIOReader::new(dl, UnifiedLogType::CopperList);
299            let iter = copperlists_reader::<P>(&mut reader);
300
301            match export_format {
302                ExportFormat::Json => {
303                    for entry in iter {
304                        write_json_pretty(&entry)?;
305                    }
306                }
307                ExportFormat::Csv => {
308                    let mut first = true;
309                    for origin in P::get_all_task_ids() {
310                        if !first {
311                            print!(", ");
312                        } else {
313                            print!("id, ");
314                        }
315                        print!("{origin}_time, {origin}_tov, {origin},");
316                        first = false;
317                    }
318                    println!();
319                    for entry in iter {
320                        let mut first = true;
321                        for msg in entry.cumsgs() {
322                            if let Some(payload) = msg.payload() {
323                                if !first {
324                                    print!(", ");
325                                } else {
326                                    print!("{}, ", entry.id);
327                                }
328                                let metadata = msg.metadata();
329                                print!("{}, {}, ", metadata.process_time(), msg.tov());
330                                write_json(payload)?;
331                                first = false;
332                            }
333                        }
334                        println!();
335                    }
336                }
337            }
338        }
339        Command::Fsck {
340            verbose,
341            dump_runtime_lifecycle,
342        } => {
343            if let Some(value) = check::<P>(&mut dl, verbose, dump_runtime_lifecycle) {
344                return value;
345            }
346        }
347        Command::LogStats {
348            output,
349            config,
350            mission,
351        } => {
352            run_logstats::<P>(dl, output, config, mission)?;
353        }
354    }
355
356    Ok(())
357}
358
359fn run_logstats<P>(
360    dl: UnifiedLoggerRead,
361    output: PathBuf,
362    config: PathBuf,
363    mission: Option<String>,
364) -> CuResult<()>
365where
366    P: CopperListTuple + CuPayloadRawBytes,
367{
368    let config_path = config
369        .to_str()
370        .ok_or_else(|| CuError::from("Config path is not valid UTF-8"))?;
371    let cfg = read_configuration(config_path)
372        .map_err(|e| CuError::new_with_cause("Failed to read configuration", e))?;
373    let reader = UnifiedLoggerIOReader::new(dl, UnifiedLogType::CopperList);
374    let stats = compute_logstats::<P>(reader, &cfg, mission.as_deref())?;
375    write_logstats(&stats, &output)
376}
377
378/// Helper function for MCAP export.
379///
380/// Uses the PayloadSchemas trait to get task payload schemas.
381#[cfg(feature = "mcap")]
382fn export_to_mcap_impl<P>(src: impl Read, output: &Path) -> CuResult<McapExportStats>
383where
384    P: CopperListTuple + mcap_export::PayloadSchemas,
385{
386    mcap_export::export_to_mcap::<P, _>(src, output)
387}
388
389#[cfg(feature = "mcap")]
390struct ProgressReader<R> {
391    inner: R,
392    progress: ProgressBar,
393}
394
395#[cfg(feature = "mcap")]
396impl<R> ProgressReader<R> {
397    fn new(inner: R, progress: ProgressBar) -> Self {
398        Self { inner, progress }
399    }
400}
401
402#[cfg(feature = "mcap")]
403impl<R: Read> Read for ProgressReader<R> {
404    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
405        let read = self.inner.read(buf)?;
406        if read > 0 {
407            self.progress.inc(read as u64);
408        }
409        Ok(read)
410    }
411}
412
413#[cfg(feature = "mcap")]
414fn make_progress_bar(total_bytes: u64) -> ProgressBar {
415    let progress_bar = ProgressBar::new(total_bytes);
416    progress_bar.set_draw_target(ProgressDrawTarget::stderr_with_hz(5));
417
418    let style = ProgressStyle::with_template(
419        "[{elapsed_precise}] {bar:40} {bytes}/{total_bytes} ({bytes_per_sec}, ETA {eta})",
420    )
421    .unwrap_or_else(|_| ProgressStyle::default_bar());
422
423    progress_bar.set_style(style.progress_chars("=>-"));
424    progress_bar
425}
426
427#[cfg(feature = "mcap")]
428fn should_show_progress(force_progress: bool, quiet: bool) -> bool {
429    !quiet && (force_progress || std::io::stderr().is_terminal())
430}
431
432#[cfg(feature = "mcap")]
433fn copperlist_total_bytes(log_base: &Path) -> CuResult<u64> {
434    let mut reader = UnifiedLoggerRead::new(log_base)
435        .map_err(|e| CuError::new_with_cause("Failed to open log for progress estimation", e))?;
436    reader
437        .scan_section_bytes(UnifiedLogType::CopperList)
438        .map_err(|e| CuError::new_with_cause("Failed to scan log for progress estimation", e))
439}
440
441fn read_next_entry<T: Decode<()>>(src: &mut impl Read) -> Option<T> {
442    let entry = decode_from_std_read::<T, _, _>(src, standard());
443    match entry {
444        Ok(entry) => Some(entry),
445        Err(DecodeError::UnexpectedEnd { .. }) => None,
446        Err(DecodeError::Io { inner, additional }) => {
447            if inner.kind() == std::io::ErrorKind::UnexpectedEof {
448                None
449            } else {
450                println!("Error {inner:?} additional:{additional}");
451                None
452            }
453        }
454        Err(e) => {
455            println!("Error {e:?}");
456            None
457        }
458    }
459}
460
461/// Extracts the copper lists from a binary representation.
462/// P is the Payload determined by the configuration of the application.
463pub fn copperlists_reader<P: CopperListTuple>(
464    mut src: impl Read,
465) -> impl Iterator<Item = CopperList<P>> {
466    std::iter::from_fn(move || read_next_entry::<CopperList<P>>(&mut src))
467}
468
469/// Extracts the keyframes from the log.
470pub fn keyframes_reader(mut src: impl Read) -> impl Iterator<Item = KeyFrame> {
471    std::iter::from_fn(move || read_next_entry::<KeyFrame>(&mut src))
472}
473
474pub fn structlog_reader(mut src: impl Read) -> impl Iterator<Item = CuResult<CuLogEntry>> {
475    std::iter::from_fn(move || {
476        let entry = decode_from_std_read::<CuLogEntry, _, _>(&mut src, standard());
477
478        match entry {
479            Err(DecodeError::UnexpectedEnd { .. }) => None,
480            Err(DecodeError::Io {
481                inner,
482                additional: _,
483            }) => {
484                if inner.kind() == std::io::ErrorKind::UnexpectedEof {
485                    None
486                } else {
487                    Some(Err(CuError::new_with_cause("Error reading log", inner)))
488                }
489            }
490            Err(e) => Some(Err(CuError::new_with_cause("Error reading log", e))),
491            Ok(entry) => {
492                if entry.msg_index == 0 {
493                    None
494                } else {
495                    Some(Ok(entry))
496                }
497            }
498        }
499    })
500}
501
502/// Full dump of the copper structured log from its binary representation.
503/// This rebuilds a textual log.
504/// src: the source of the log data
505/// index: the path to the index file (containing the interned strings constructed at build time)
506pub fn textlog_dump(src: impl Read, index: &Path) -> CuResult<()> {
507    let all_strings = read_interned_strings(index).map_err(|e| {
508        CuError::new_with_cause(
509            "Failed to read interned strings from index",
510            std::io::Error::other(e),
511        )
512    })?;
513
514    for result in structlog_reader(src) {
515        match result {
516            Ok(entry) => match rebuild_logline(&all_strings, &entry) {
517                Ok(line) => println!("{line}"),
518                Err(e) => println!("Failed to rebuild log line: {e:?}"),
519            },
520            Err(e) => return Err(e),
521        }
522    }
523
524    Ok(())
525}
526
527// only for users opting into python interface, not supported on macOS at the moment
528#[cfg(all(feature = "python", not(target_os = "macos")))]
529mod python {
530    use bincode::config::standard;
531    use bincode::decode_from_std_read;
532    use bincode::error::DecodeError;
533    use cu29::prelude::*;
534    use cu29_intern_strs::read_interned_strings;
535    use pyo3::exceptions::PyIOError;
536    use pyo3::prelude::*;
537    use pyo3::types::{PyDelta, PyDict, PyList};
538    use std::io::Read;
539    use std::path::Path;
540
541    #[pyclass]
542    pub struct PyLogIterator {
543        reader: Box<dyn Read + Send + Sync>,
544    }
545
546    #[pymethods]
547    impl PyLogIterator {
548        fn __iter__(slf: PyRefMut<Self>) -> PyRefMut<Self> {
549            slf
550        }
551
552        fn __next__(mut slf: PyRefMut<Self>) -> Option<PyResult<PyCuLogEntry>> {
553            match decode_from_std_read::<CuLogEntry, _, _>(&mut slf.reader, standard()) {
554                Ok(entry) => {
555                    if entry.msg_index == 0 {
556                        None
557                    } else {
558                        Some(Ok(PyCuLogEntry { inner: entry }))
559                    }
560                }
561                Err(DecodeError::UnexpectedEnd { .. }) => None,
562                Err(DecodeError::Io { inner, .. })
563                    if inner.kind() == std::io::ErrorKind::UnexpectedEof =>
564                {
565                    None
566                }
567                Err(e) => Some(Err(PyIOError::new_err(e.to_string()))),
568            }
569        }
570    }
571
572    /// Creates an iterator of CuLogEntries from a bare binary structured log file (ie. not within a unified log).
573    /// This is mainly used for using the structured logging out of the Copper framework.
574    /// it returns a tuple with the iterator of log entries and the list of interned strings.
575    #[pyfunction]
576    pub fn struct_log_iterator_bare(
577        bare_struct_src_path: &str,
578        index_path: &str,
579    ) -> PyResult<(PyLogIterator, Vec<String>)> {
580        let file = std::fs::File::open(bare_struct_src_path)
581            .map_err(|e| PyIOError::new_err(e.to_string()))?;
582        let all_strings = read_interned_strings(Path::new(index_path))
583            .map_err(|e| PyIOError::new_err(e.to_string()))?;
584        Ok((
585            PyLogIterator {
586                reader: Box::new(file),
587            },
588            all_strings,
589        ))
590    }
591    /// Creates an iterator of CuLogEntries from a unified log file.
592    /// This function allows you to easily use python to datamind Copper's structured text logs.
593    /// it returns a tuple with the iterator of log entries and the list of interned strings.
594    #[pyfunction]
595    pub fn struct_log_iterator_unified(
596        unified_src_path: &str,
597        index_path: &str,
598    ) -> PyResult<(PyLogIterator, Vec<String>)> {
599        let all_strings = read_interned_strings(Path::new(index_path))
600            .map_err(|e| PyIOError::new_err(e.to_string()))?;
601
602        let logger = UnifiedLoggerBuilder::new()
603            .file_base_name(Path::new(unified_src_path))
604            .build()
605            .map_err(|e| PyIOError::new_err(e.to_string()))?;
606        let dl = match logger {
607            UnifiedLogger::Read(dl) => dl,
608            UnifiedLogger::Write(_) => {
609                return Err(PyIOError::new_err(
610                    "Expected read-only unified logger for Python export",
611                ));
612            }
613        };
614
615        let reader = UnifiedLoggerIOReader::new(dl, UnifiedLogType::StructuredLogLine);
616        Ok((
617            PyLogIterator {
618                reader: Box::new(reader),
619            },
620            all_strings,
621        ))
622    }
623
624    /// This is a python wrapper for CuLogEntries.
625    #[pyclass]
626    pub struct PyCuLogEntry {
627        pub inner: CuLogEntry,
628    }
629
630    #[pymethods]
631    impl PyCuLogEntry {
632        /// Returns the timestamp of the log entry.
633        pub fn ts<'a>(&self, py: Python<'a>) -> PyResult<Bound<'a, PyDelta>> {
634            let nanoseconds: u64 = self.inner.time.into();
635
636            // Convert nanoseconds to seconds and microseconds
637            let days = (nanoseconds / 86_400_000_000_000) as i32;
638            let seconds = (nanoseconds / 1_000_000_000) as i32;
639            let microseconds = ((nanoseconds % 1_000_000_000) / 1_000) as i32;
640
641            PyDelta::new(py, days, seconds, microseconds, false)
642        }
643
644        /// Returns the index of the message in the vector of interned strings.
645        pub fn msg_index(&self) -> u32 {
646            self.inner.msg_index
647        }
648
649        /// Returns the index of the parameter names in the vector of interned strings.
650        pub fn paramname_indexes(&self) -> Vec<u32> {
651            self.inner.paramname_indexes.iter().copied().collect()
652        }
653
654        /// Returns the parameters of this log line
655        pub fn params(&self, py: Python<'_>) -> PyResult<Vec<Py<PyAny>>> {
656            self.inner
657                .params
658                .iter()
659                .map(|value| value_to_py(value, py))
660                .collect()
661        }
662    }
663
664    /// This needs to match the name of the generated '.so'
665    #[pymodule(name = "libcu29_export")]
666    fn cu29_export(m: &Bound<'_, PyModule>) -> PyResult<()> {
667        m.add_class::<PyCuLogEntry>()?;
668        m.add_class::<PyLogIterator>()?;
669        m.add_function(wrap_pyfunction!(struct_log_iterator_bare, m)?)?;
670        m.add_function(wrap_pyfunction!(struct_log_iterator_unified, m)?)?;
671        Ok(())
672    }
673
674    fn value_to_py(value: &cu29::prelude::Value, py: Python<'_>) -> PyResult<Py<PyAny>> {
675        match value {
676            Value::String(s) => Ok(s.into_pyobject(py)?.into()),
677            Value::U64(u) => Ok(u.into_pyobject(py)?.into()),
678            Value::I64(i) => Ok(i.into_pyobject(py)?.into()),
679            Value::F64(f) => Ok(f.into_pyobject(py)?.into()),
680            Value::Bool(b) => Ok(b.into_pyobject(py)?.to_owned().into()),
681            Value::CuTime(t) => Ok(t.0.into_pyobject(py)?.into()),
682            Value::Bytes(b) => Ok(b.into_pyobject(py)?.into()),
683            Value::Char(c) => Ok(c.into_pyobject(py)?.into()),
684            Value::I8(i) => Ok(i.into_pyobject(py)?.into()),
685            Value::U8(u) => Ok(u.into_pyobject(py)?.into()),
686            Value::I16(i) => Ok(i.into_pyobject(py)?.into()),
687            Value::U16(u) => Ok(u.into_pyobject(py)?.into()),
688            Value::I32(i) => Ok(i.into_pyobject(py)?.into()),
689            Value::U32(u) => Ok(u.into_pyobject(py)?.into()),
690            Value::Map(m) => {
691                let dict = PyDict::new(py);
692                for (k, v) in m.iter() {
693                    dict.set_item(value_to_py(k, py)?, value_to_py(v, py)?)?;
694                }
695                Ok(dict.into_pyobject(py)?.into())
696            }
697            Value::F32(f) => Ok(f.into_pyobject(py)?.into()),
698            Value::Option(o) => match o.as_ref() {
699                Some(value) => value_to_py(value, py),
700                None => Ok(py.None()),
701            },
702            Value::Unit => Ok(py.None()),
703            Value::Newtype(v) => value_to_py(v, py),
704            Value::Seq(s) => {
705                let items: Vec<Py<PyAny>> = s
706                    .iter()
707                    .map(|value| value_to_py(value, py))
708                    .collect::<PyResult<_>>()?;
709                let list = PyList::new(py, items)?;
710                Ok(list.into_pyobject(py)?.into())
711            }
712        }
713    }
714}
715
716#[cfg(test)]
717mod tests {
718    use super::*;
719    use bincode::{Decode, Encode, encode_into_slice};
720    use serde::Deserialize;
721    use std::env;
722    use std::fs;
723    use std::io::Cursor;
724    use std::path::PathBuf;
725    use std::sync::{Arc, Mutex};
726    use tempfile::{TempDir, tempdir};
727
728    fn copy_stringindex_to_temp(tmpdir: &TempDir) -> PathBuf {
729        // Build a minimal index on the fly so tests don't depend on build-time artifacts.
730        let fake_out_dir = tmpdir.path().join("build").join("out").join("dir");
731        fs::create_dir_all(&fake_out_dir).unwrap();
732        // SAFETY: Tests run single-threaded here and we only read the variable after setting it.
733        unsafe {
734            env::set_var("LOG_INDEX_DIR", &fake_out_dir);
735        }
736
737        // Provide entries for the message indexes used in this test module.
738        let _ = cu29_intern_strs::intern_string("unused to start counter");
739        let _ = cu29_intern_strs::intern_string("Just a String {}");
740        let _ = cu29_intern_strs::intern_string("Just a String (low level) {}");
741        let _ = cu29_intern_strs::intern_string("Just a String (end to end) {}");
742
743        let index_dir = cu29_intern_strs::default_log_index_dir();
744        cu29_intern_strs::read_interned_strings(&index_dir).unwrap();
745        index_dir
746    }
747
748    #[test]
749    fn test_extract_low_level_cu29_log() {
750        let temp_dir = TempDir::new().unwrap();
751        let temp_path = copy_stringindex_to_temp(&temp_dir);
752        let entry = CuLogEntry::new(3, CuLogLevel::Info);
753        let bytes = bincode::encode_to_vec(&entry, standard()).unwrap();
754        let reader = Cursor::new(bytes.as_slice());
755        textlog_dump(reader, temp_path.as_path()).unwrap();
756    }
757
758    #[test]
759    fn end_to_end_datalogger_and_structlog_test() {
760        let dir = tempdir().expect("Failed to create temp dir");
761        let path = dir
762            .path()
763            .join("end_to_end_datalogger_and_structlog_test.copper");
764        {
765            // Write a couple log entries
766            let UnifiedLogger::Write(logger) = UnifiedLoggerBuilder::new()
767                .write(true)
768                .create(true)
769                .file_base_name(&path)
770                .preallocated_size(100000)
771                .build()
772                .expect("Failed to create logger")
773            else {
774                panic!("Failed to create logger")
775            };
776            let data_logger = Arc::new(Mutex::new(logger));
777            let stream = stream_write(data_logger.clone(), UnifiedLogType::StructuredLogLine, 1024)
778                .expect("Failed to create stream");
779            let rt = LoggerRuntime::init(RobotClock::default(), stream, None::<NullLog>);
780
781            let mut entry = CuLogEntry::new(4, CuLogLevel::Info); // this is a "Just a String {}" log line
782            entry.add_param(0, Value::String("Parameter for the log line".into()));
783            log(&mut entry).expect("Failed to log");
784            let mut entry = CuLogEntry::new(2, CuLogLevel::Info); // this is a "Just a String {}" log line
785            entry.add_param(0, Value::String("Parameter for the log line".into()));
786            log(&mut entry).expect("Failed to log");
787
788            // everything is dropped here
789            drop(rt);
790        }
791        // Read back the log
792        let UnifiedLogger::Read(logger) = UnifiedLoggerBuilder::new()
793            .file_base_name(
794                &dir.path()
795                    .join("end_to_end_datalogger_and_structlog_test.copper"),
796            )
797            .build()
798            .expect("Failed to create logger")
799        else {
800            panic!("Failed to create logger")
801        };
802        let reader = UnifiedLoggerIOReader::new(logger, UnifiedLogType::StructuredLogLine);
803        let temp_dir = TempDir::new().unwrap();
804        textlog_dump(
805            reader,
806            Path::new(copy_stringindex_to_temp(&temp_dir).as_path()),
807        )
808        .expect("Failed to dump log");
809    }
810
811    // This is normally generated at compile time in CuPayload.
812    #[derive(Debug, PartialEq, Clone, Copy, Serialize, Deserialize, Encode, Decode, Default)]
813    struct MyMsgs((u8, i32, f32));
814
815    impl ErasedCuStampedDataSet for MyMsgs {
816        fn cumsgs(&self) -> Vec<&dyn ErasedCuStampedData> {
817            Vec::new()
818        }
819    }
820
821    impl MatchingTasks for MyMsgs {
822        fn get_all_task_ids() -> &'static [&'static str] {
823            &[]
824        }
825    }
826
827    /// Checks if we can recover the copper lists from a binary representation.
828    #[test]
829    fn test_copperlists_dump() {
830        let mut data = vec![0u8; 10000];
831        let mypls: [MyMsgs; 4] = [
832            MyMsgs((1, 2, 3.0)),
833            MyMsgs((2, 3, 4.0)),
834            MyMsgs((3, 4, 5.0)),
835            MyMsgs((4, 5, 6.0)),
836        ];
837
838        let mut offset: usize = 0;
839        for pl in mypls.iter() {
840            let cl = CopperList::<MyMsgs>::new(1, *pl);
841            offset +=
842                encode_into_slice(&cl, &mut data.as_mut_slice()[offset..], standard()).unwrap();
843        }
844
845        let reader = Cursor::new(data);
846
847        let mut iter = copperlists_reader::<MyMsgs>(reader);
848        assert_eq!(iter.next().unwrap().msgs, MyMsgs((1, 2, 3.0)));
849        assert_eq!(iter.next().unwrap().msgs, MyMsgs((2, 3, 4.0)));
850        assert_eq!(iter.next().unwrap().msgs, MyMsgs((3, 4, 5.0)));
851        assert_eq!(iter.next().unwrap().msgs, MyMsgs((4, 5, 6.0)));
852    }
853}