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