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#[doc(hidden)]
19pub mod pgs;
20mod runs;
21
22#[cfg(feature = "self-describing-logs")]
23pub mod catalog;
24#[cfg(feature = "self-describing-logs")]
25mod catalog_cli;
26#[cfg(all(feature = "self-describing-logs", feature = "python"))]
27mod catalog_python;
28#[cfg(all(test, feature = "self-describing-logs"))]
29mod catalog_tests;
30#[cfg(feature = "self-describing-logs")]
31mod value_export;
32
33#[cfg(feature = "mcap")]
34pub mod mcap_export;
35
36#[cfg(feature = "mcap")]
37pub mod serde_to_jsonschema;
38
39use bincode::Decode;
40use bincode::config::standard;
41use bincode::decode_from_std_read;
42use bincode::error::DecodeError;
43use clap::{Parser, Subcommand, ValueEnum};
44use cu29::UnifiedLogType;
45use cu29::prelude::*;
46use cu29_intern_strs::read_interned_strings;
47use fsck::check;
48#[cfg(feature = "mcap")]
49use indicatif::{ProgressBar, ProgressDrawTarget, ProgressStyle};
50use logstats::{compute_logstats, write_logstats};
51use serde::Serialize;
52use std::collections::BTreeMap;
53use std::fmt::Write as _;
54use std::fmt::{Display, Formatter};
55#[cfg(feature = "mcap")]
56use std::io::IsTerminal;
57use std::io::Read;
58use std::path::{Path, PathBuf};
59
60#[cfg(feature = "mcap")]
61pub use mcap_export::{McapExportStats, export_to_mcap, export_to_mcap_with_schemas, mcap_info};
62
63#[cfg(feature = "mcap")]
64#[allow(deprecated)]
65pub use cu29::prelude::PayloadSchemas;
66
67#[cfg(feature = "mcap")]
68pub use serde_to_jsonschema::trace_type_to_jsonschema;
69
70/// Registers the typed CopperList decoder used by the generic Python iterator.
71///
72/// Applications normally call this indirectly through
73/// [`copperlist_iterator_unified_typed_py`].
74#[cfg(feature = "python")]
75pub use python::register_copperlist_python_type;
76
77/// Creates a Python CopperList iterator for a specific CopperList tuple type.
78///
79/// This is intended for app-specific Python modules that know their generated
80/// CopperList type at compile time. The helper registers the decoder and returns
81/// an iterator object that yields Python objects built from the recorded
82/// CopperLists.
83#[cfg(feature = "python")]
84pub fn copperlist_iterator_unified_typed_py<P>(
85    unified_src_path: &str,
86    py: pyo3::Python<'_>,
87) -> pyo3::PyResult<pyo3::Py<pyo3::PyAny>>
88where
89    P: CopperListTuple + 'static,
90{
91    let _ = cu29::logcodec::seed_effective_config_from_log::<P>(Path::new(unified_src_path))
92        .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?;
93    register_copperlist_python_type::<P>()
94        .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?;
95    let iter = python::copperlist_iterator_unified(unified_src_path)?;
96    pyo3::Py::new(py, iter).map(|obj| obj.into())
97}
98
99/// Creates a Python `RuntimeLifecycleRecord` iterator from a unified log.
100///
101/// This is useful for offline analysis scripts that need to inspect mission
102/// starts, stops, faults, and related runtime events.
103#[cfg(feature = "python")]
104pub fn runtime_lifecycle_iterator_unified_py(
105    unified_src_path: &str,
106    py: pyo3::Python<'_>,
107) -> pyo3::PyResult<pyo3::Py<pyo3::PyAny>> {
108    let iter = python::runtime_lifecycle_iterator_unified(unified_src_path)?;
109    pyo3::Py::new(py, iter).map(|obj| obj.into())
110}
111#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)]
112pub enum ExportFormat {
113    Json,
114    Csv,
115    Jsonl,
116}
117
118impl Display for ExportFormat {
119    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
120        match self {
121            ExportFormat::Json => write!(f, "json"),
122            ExportFormat::Csv => write!(f, "csv"),
123            ExportFormat::Jsonl => write!(f, "jsonl"),
124        }
125    }
126}
127
128/// Source of the CopperList payload decoder (experimental catalog support).
129#[cfg(feature = "self-describing-logs")]
130#[derive(Clone, Copy, PartialEq, Eq, ValueEnum)]
131pub enum CopperListDecoder {
132    Typed,
133    Catalog,
134}
135
136/// Complete catalog dump or human inspection format (experimental).
137#[cfg(feature = "self-describing-logs")]
138#[derive(Clone, Copy, PartialEq, Eq, ValueEnum)]
139pub enum CatalogFormat {
140    Human,
141    Ron,
142    Json,
143}
144
145/// This is a generator for a main function to build a log extractor.
146#[derive(Parser)]
147#[command(author, version, about)]
148pub struct LogReaderCli {
149    /// The base path is the name with no _0 _1 et the end.
150    /// for example for toto_0.copper, toto_1.copper ... the base name is toto.copper
151    pub unifiedlog_base: PathBuf,
152
153    /// Select the recorded run by its zero-based index from list-runs.
154    #[arg(long, global = true)]
155    pub run: Option<usize>,
156
157    /// Colorize human output on terminals, always, or never.
158    #[arg(long, global = true, value_enum, default_value_t = clap::ColorChoice::Auto)]
159    pub color: clap::ColorChoice,
160
161    #[command(subcommand)]
162    pub command: Command,
163}
164
165#[derive(Subcommand)]
166pub enum Command {
167    /// List recorded runs announced by Instantiated lifecycle records.
168    ListRuns,
169    /// Inspect the embedded payload catalog.
170    #[cfg(feature = "self-describing-logs")]
171    Catalog {
172        #[arg(short, long, value_enum, default_value = "human")]
173        export_format: CatalogFormat,
174    },
175    /// Extract logs
176    ExtractTextLog { log_index: PathBuf },
177    /// Extract copperlists
178    ExtractCopperlists {
179        #[arg(short, long, default_value_t = ExportFormat::Json)]
180        export_format: ExportFormat,
181        /// Select the compiled application decoder or embedded catalog.
182        #[cfg(feature = "self-describing-logs")]
183        #[arg(long, value_enum, default_value = "typed")]
184        decoder: CopperListDecoder,
185    },
186    /// Check the log and dump info about it.
187    Fsck {
188        #[arg(short, long, action = clap::ArgAction::Count)]
189        verbose: u8,
190        /// Decode and print RuntimeLifecycle events.
191        #[arg(long)]
192        dump_runtime_lifecycle: bool,
193        /// Decode every recorded CopperList using the embedded catalog.
194        #[arg(long)]
195        deep: bool,
196    },
197    /// Export log statistics to JSON for offline DAG rendering.
198    LogStats {
199        /// Output JSON file path
200        #[arg(short, long, default_value = "cu29_logstats.json")]
201        output: PathBuf,
202        /// Config override; defaults to the selected run's recorded configuration.
203        #[arg(long)]
204        config: Option<PathBuf>,
205        /// Mission id override; defaults to the mission recorded in the log
206        #[arg(long)]
207        mission: Option<String>,
208        /// Comma-separated Cargo features used by conditional config fragments
209        #[arg(long, value_delimiter = ',')]
210        features: Vec<String>,
211    },
212    /// Build ranked schedules from this recorded run.
213    OptimizeSchedule {
214        /// Scheduling requirements and allowed CPUs in RON format.
215        #[arg(long)]
216        contract: PathBuf,
217        /// Directory for the profile, plans, prepared configs, and report.
218        #[arg(short, long, default_value = "schedule")]
219        output: PathBuf,
220        /// Maximum number of distinct candidate plans to emit.
221        #[arg(long, default_value_t = 3)]
222        candidates: usize,
223        /// Candidate measurement as NAME=LOG_BASE; may be repeated.
224        #[arg(long = "measure", value_name = "NAME=LOG_BASE")]
225        measurements: Vec<String>,
226    },
227    /// Export copperlists to MCAP format (requires 'mcap' feature)
228    #[cfg(feature = "mcap")]
229    ExportMcap {
230        /// Output MCAP file path
231        #[arg(short, long)]
232        output: PathBuf,
233        /// Force progress bar even when stderr is not a TTY
234        #[arg(long)]
235        progress: bool,
236        /// Suppress the progress bar
237        #[arg(long)]
238        quiet: bool,
239    },
240    /// Inspect an MCAP file and dump metadata, schemas, and stats (requires 'mcap' feature)
241    #[cfg(feature = "mcap")]
242    McapInfo {
243        /// Path to the MCAP file to inspect
244        mcap_file: PathBuf,
245        /// Show full schema content
246        #[arg(short, long)]
247        schemas: bool,
248        /// Show sample messages (first N messages per channel)
249        #[arg(short = 'n', long, default_value_t = 0)]
250        sample_messages: usize,
251    },
252}
253
254/// Standalone catalog CLI entrypoint used by cu29-logextract.
255#[cfg(feature = "self-describing-logs")]
256#[doc(hidden)]
257pub fn run_catalog_cli() -> CuResult<()> {
258    catalog_cli::run_cli()
259}
260
261fn print_runs(runs: &[runs::RecordedRun]) {
262    for run in runs {
263        println!(
264            "Run {}: mission={} app={} instance_id={} started={} shutdown={}",
265            run.index,
266            run.missions.join(","),
267            run.stack
268                .as_ref()
269                .map_or("unknown", |stack| stack.app_name.as_str()),
270            run.stack.as_ref().map_or_else(
271                || "unknown".to_string(),
272                |stack| stack.instance_id.to_string()
273            ),
274            run.started_at
275                .map_or_else(|| "unknown".to_string(), |time| time.to_string()),
276            run.shutdown_completed,
277        );
278    }
279}
280
281fn write_json_pretty<T: Serialize + ?Sized>(value: &T) -> CuResult<()> {
282    serde_json::to_writer_pretty(std::io::stdout(), value)
283        .map_err(|e| CuError::new_with_cause("Failed to write JSON output", e))
284}
285
286fn write_json<T: Serialize + ?Sized>(value: &T) -> CuResult<()> {
287    serde_json::to_writer(std::io::stdout(), value)
288        .map_err(|e| CuError::new_with_cause("Failed to write JSON output", e))
289}
290
291fn build_read_logger(unifiedlog_base: &Path) -> CuResult<UnifiedLoggerRead> {
292    let logger = UnifiedLoggerBuilder::new()
293        .file_base_name(unifiedlog_base)
294        .build()
295        .map_err(|e| CuError::new_with_cause("Failed to create logger", e))?;
296    match logger {
297        UnifiedLogger::Read(dl) => Ok(dl),
298        UnifiedLogger::Write(_) => Err(CuError::from(
299            "Expected read-only unified logger in export CLI",
300        )),
301    }
302}
303
304/// This is a generator for a main function to build a log extractor.
305/// It depends on the specific type of the CopperList payload that is determined at compile time from the configuration.
306///
307#[cfg(feature = "mcap")]
308pub fn run_cli<P>() -> CuResult<()>
309where
310    P: CopperListTuple + CuPayloadRawBytes + 'static,
311{
312    #[cfg(feature = "python")]
313    let _ = python::register_copperlist_python_type::<P>();
314
315    run_cli_inner::<P>()
316}
317
318/// This is a generator for a main function to build a log extractor.
319/// It depends on the specific type of the CopperList payload that is determined at compile time from the configuration.
320#[cfg(not(feature = "mcap"))]
321pub fn run_cli<P>() -> CuResult<()>
322where
323    P: CopperListTuple + CuPayloadRawBytes + 'static,
324{
325    #[cfg(feature = "python")]
326    let _ = python::register_copperlist_python_type::<P>();
327
328    run_cli_inner::<P>()
329}
330
331fn run_cli_inner<P>() -> CuResult<()>
332where
333    P: CopperListTuple + CuPayloadRawBytes + 'static,
334{
335    run_cli_with_args::<P>(LogReaderCli::parse())
336}
337
338fn run_cli_with_args<P>(args: LogReaderCli) -> CuResult<()>
339where
340    P: CopperListTuple + CuPayloadRawBytes + 'static,
341{
342    let unifiedlog_base = args.unifiedlog_base;
343    #[cfg(feature = "mcap")]
344    if let Command::McapInfo {
345        mcap_file,
346        schemas,
347        sample_messages,
348    } = &args.command
349    {
350        return mcap_info(mcap_file, *schemas, *sample_messages);
351    }
352
353    let runs = runs::discover(&unifiedlog_base)?;
354    if matches!(args.command, Command::ListRuns) {
355        print_runs(&runs);
356        return Ok(());
357    }
358    let run = runs::select(&runs, args.run)?;
359    #[cfg(feature = "self-describing-logs")]
360    let needs_typed_config = !matches!(
361        args.command,
362        Command::Catalog { .. }
363            | Command::ExtractCopperlists {
364                decoder: CopperListDecoder::Catalog,
365                ..
366            }
367            | Command::Fsck { deep: true, .. }
368    );
369    #[cfg(not(feature = "self-describing-logs"))]
370    let needs_typed_config = true;
371    if needs_typed_config && let Some(config) = &run.config {
372        cu29::logcodec::set_effective_config_ron::<P>(config);
373    }
374    let mut dl = run.reader(&unifiedlog_base)?;
375
376    match args.command {
377        Command::ListRuns => unreachable!("run listing handled before selection"),
378        Command::ExtractTextLog { log_index } => {
379            let reader = dl.stream(UnifiedLogType::StructuredLogLine);
380            textlog_dump(reader, &log_index)?;
381        }
382        #[cfg(feature = "self-describing-logs")]
383        Command::Catalog { export_format } => {
384            catalog_cli::dump_catalog(
385                run,
386                &unifiedlog_base,
387                export_format,
388                args.color,
389                &mut std::io::stdout().lock(),
390            )?;
391        }
392        Command::ExtractCopperlists {
393            export_format,
394            #[cfg(feature = "self-describing-logs")]
395            decoder,
396        } => {
397            #[cfg(feature = "self-describing-logs")]
398            if decoder == CopperListDecoder::Catalog {
399                return catalog_cli::extract(
400                    &unifiedlog_base,
401                    Some(run.index),
402                    export_format,
403                    &mut std::io::stdout().lock(),
404                );
405            }
406            eprintln!("Extracting copperlists with format: {export_format}");
407            let mut reader = dl.stream(UnifiedLogType::CopperList);
408            let iter = copperlists_reader::<P>(&mut reader);
409
410            match export_format {
411                ExportFormat::Json => {
412                    for entry in iter {
413                        write_json_pretty(&entry)?;
414                    }
415                }
416                ExportFormat::Jsonl => {
417                    for entry in iter {
418                        write_json(&entry)?;
419                        println!();
420                    }
421                }
422                ExportFormat::Csv => {
423                    let mut first = true;
424                    for origin in P::get_all_task_ids() {
425                        if !first {
426                            print!(", ");
427                        } else {
428                            print!("id, ");
429                        }
430                        print!("{origin}_time, {origin}_tov, {origin},");
431                        first = false;
432                    }
433                    println!();
434                    for entry in iter {
435                        let mut first = true;
436                        for msg in entry.cumsgs() {
437                            if let Some(payload) = msg.payload() {
438                                if !first {
439                                    print!(", ");
440                                } else {
441                                    print!("{}, ", entry.id);
442                                }
443                                let metadata = msg.metadata();
444                                print!("{}, {}, ", metadata.process_time(), msg.tov());
445                                write_json(payload)?; // TODO: escape for CSV
446                                first = false;
447                            }
448                        }
449                        println!();
450                    }
451                }
452            }
453        }
454        Command::Fsck {
455            verbose,
456            dump_runtime_lifecycle,
457            deep,
458        } => {
459            if deep {
460                #[cfg(feature = "self-describing-logs")]
461                return catalog_cli::deep_check(
462                    run,
463                    &unifiedlog_base,
464                    verbose,
465                    dump_runtime_lifecycle,
466                );
467                #[cfg(not(feature = "self-describing-logs"))]
468                return Err("Deep validation requires the self-describing-logs feature".into());
469            }
470            check::<P>(&mut dl, verbose, dump_runtime_lifecycle)?;
471        }
472        Command::LogStats {
473            output,
474            config,
475            mission,
476            features,
477        } => {
478            run_logstats::<P>(run, dl, output, config, mission, &features)?;
479        }
480        Command::OptimizeSchedule {
481            contract,
482            output,
483            candidates,
484            measurements,
485        } => {
486            run_schedule_optimizer::<P>(run, dl, &contract, &output, candidates, &measurements)?;
487        }
488        #[cfg(feature = "mcap")]
489        Command::ExportMcap {
490            output,
491            progress,
492            quiet,
493        } => {
494            println!("Exporting copperlists to MCAP format: {}", output.display());
495
496            let show_progress = should_show_progress(progress, quiet);
497            let total_bytes = if show_progress {
498                Some(run.copperlist_bytes)
499            } else {
500                None
501            };
502
503            let reader = dl.stream(UnifiedLogType::CopperList);
504
505            // Export to MCAP with schemas derived from generated output metadata.
506            let stats = if let Some(total_bytes) = total_bytes {
507                let progress_bar = make_progress_bar(total_bytes);
508                let reader = ProgressReader::new(reader, progress_bar.clone());
509                let result = export_to_mcap_impl::<P>(reader, &output);
510                progress_bar.finish_and_clear();
511                result?
512            } else {
513                export_to_mcap_impl::<P>(reader, &output)?
514            };
515            println!("{stats}");
516        }
517        #[cfg(feature = "mcap")]
518        Command::McapInfo {
519            mcap_file,
520            schemas,
521            sample_messages,
522        } => {
523            mcap_info(&mcap_file, schemas, sample_messages)?;
524        }
525    }
526
527    Ok(())
528}
529
530fn run_logstats<P>(
531    run: &runs::RecordedRun,
532    dl: runs::RunReader,
533    output: PathBuf,
534    config: Option<PathBuf>,
535    mission: Option<String>,
536    features: &[String],
537) -> CuResult<()>
538where
539    P: CopperListTuple + CuPayloadRawBytes,
540{
541    let cfg = if config.is_none() && run.config.is_some() {
542        CuConfig::deserialize_ron(run.config.as_deref().unwrap()).map_err(|e| {
543            CuError::new_with_cause("Failed to read the selected run's configuration", e)
544        })?
545    } else {
546        let config = config.unwrap_or_else(|| PathBuf::from("copperconfig.ron"));
547        let config_path = config
548            .to_str()
549            .ok_or_else(|| CuError::from("Config path is not valid UTF-8"))?;
550        let feature_refs = features.iter().map(String::as_str).collect::<Vec<_>>();
551        cu29::config::read_configuration_with_features(config_path, &feature_refs)
552            .map_err(|e| CuError::new_with_cause("Failed to read configuration", e))?
553    };
554    let mission = resolve_logstats_mission(&cfg, mission, run.missions.first().cloned())?;
555    let reader = dl.stream(UnifiedLogType::CopperList);
556    let stats = compute_logstats::<P>(reader, &cfg, mission.as_deref())?;
557    write_logstats(&stats, &output)
558}
559
560fn run_schedule_optimizer<P>(
561    run: &runs::RecordedRun,
562    dl: runs::RunReader,
563    contract_path: &Path,
564    output: &Path,
565    candidate_count: usize,
566    measurement_args: &[String],
567) -> CuResult<()>
568where
569    P: CopperListTuple + CuPayloadRawBytes,
570{
571    let config_ron = run.config.as_deref().ok_or_else(|| {
572        CuError::from(
573            "The selected run has no recorded configuration; profile-guided scheduling requires a runtime lifecycle record",
574        )
575    })?;
576    let config = CuConfig::deserialize_ron(config_ron).map_err(|error| {
577        CuError::new_with_cause("Failed to read the recorded configuration", error)
578    })?;
579    let mission = resolve_schedule_mission(&config, &run.missions)?;
580    let mission_id = mission
581        .as_deref()
582        .unwrap_or(cu29::config::DEFAULT_MISSION_ID);
583    let contract = cu29::planner::CuContract::read(contract_path)?;
584    let profile = pgs::compute_profile::<P>(
585        dl.stream(UnifiedLogType::CopperList),
586        &config,
587        mission.as_deref(),
588        &contract,
589    )?;
590    let candidates = cu29::planner::propose(&cu29::planner::ProposeRequest {
591        config: &config,
592        mission: mission_id,
593        contract: &contract,
594        profile: &profile,
595        candidates: candidate_count,
596    })?;
597
598    std::fs::create_dir_all(output).map_err(|error| {
599        CuError::new_with_cause("Could not create schedule output directory", error)
600    })?;
601    contract.write(&output.join("contract.ron"))?;
602    profile.write(&output.join("profile.ron"))?;
603
604    let mut predictions = BTreeMap::new();
605    for (index, candidate) in candidates.iter().enumerate() {
606        let name = format!("plan-{}", index + 1);
607        candidate
608            .plan
609            .write(&output.join(format!("{name}.plan.ron")))?;
610        let mut prepared = config.clone();
611        cu29::planner::ExplicitSchedule::new(candidate.plan.clone()).apply(&mut prepared)?;
612        write_text(
613            &output.join(format!("{name}.config.ron")),
614            prepared.serialize_ron()?,
615            "candidate configuration",
616        )?;
617        predictions.insert(name, candidate.prediction.clone());
618    }
619    let predictions_ron =
620        ron::ser::to_string_pretty(&predictions, ron::ser::PrettyConfig::default()).map_err(
621            |error| CuError::new_with_cause("Could not serialize schedule predictions", error),
622        )?;
623    write_text(
624        &output.join("predictions.ron"),
625        predictions_ron,
626        "schedule predictions",
627    )?;
628
629    let mut report = prediction_report(&candidates, mission_id);
630    if !measurement_args.is_empty() {
631        let mut measured = BTreeMap::from([("baseline".to_string(), profile)]);
632        for argument in measurement_args {
633            let (name, log_base) = parse_measurement(argument)?;
634            if measured.contains_key(name) {
635                return Err(CuError::from(format!(
636                    "Schedule measurement name '{name}' is repeated"
637                )));
638            }
639            let measured_runs = runs::discover(log_base)?;
640            let measured_run = runs::select(&measured_runs, None)?;
641            let measured_config_ron = measured_run.config.as_deref().ok_or_else(|| {
642                CuError::from(format!(
643                    "Measurement '{name}' has no recorded configuration"
644                ))
645            })?;
646            let measured_config =
647                CuConfig::deserialize_ron(measured_config_ron).map_err(|error| {
648                    CuError::from(format!(
649                        "Failed to read measurement '{name}' configuration: {error}"
650                    ))
651                })?;
652            let measured_mission =
653                resolve_schedule_mission(&measured_config, &measured_run.missions)?;
654            let measured_reader = measured_run.reader(log_base)?;
655            let measured_profile = pgs::compute_profile::<P>(
656                measured_reader.stream(UnifiedLogType::CopperList),
657                &measured_config,
658                measured_mission.as_deref(),
659                &contract,
660            )?;
661            measured.insert(name.to_string(), measured_profile);
662        }
663        let score = cu29::planner::CuScoreTable::new(&contract, &predictions, &measured)?;
664        write_text(
665            &output.join("measurements.ron"),
666            score.serialize_ron()?,
667            "schedule measurements",
668        )?;
669        writeln!(report, "\nMeasured ranking:\n{score}")
670            .map_err(|_| CuError::from("Could not format schedule report"))?;
671        if let Some(best) = score.best() {
672            let selected = output.join("selected.plan.ron");
673            if best.candidate == "baseline" {
674                cu29::planner::CuPlan::from_config(&config)?.write(&selected)?;
675            } else if let Some(index) = candidate_index(&best.candidate) {
676                let candidate = candidates.get(index).ok_or_else(|| {
677                    CuError::from(format!(
678                        "Measurement '{}' does not name an emitted candidate",
679                        best.candidate
680                    ))
681                })?;
682                candidate.plan.write(&selected)?;
683            }
684        }
685    }
686    write_text(&output.join("report.txt"), &report, "schedule report")?;
687    print!("{report}");
688    println!("Artifacts: {}", output.display());
689    Ok(())
690}
691
692fn write_text(path: &Path, contents: impl AsRef<[u8]>, kind: &str) -> CuResult<()> {
693    std::fs::write(path, contents)
694        .map_err(|error| CuError::from(format!("Could not write {kind}: {error}")))
695}
696
697fn parse_measurement(argument: &str) -> CuResult<(&str, &Path)> {
698    let (name, path) = argument.split_once('=').ok_or_else(|| {
699        CuError::from(format!(
700            "Invalid measurement '{argument}'; expected NAME=LOG_BASE"
701        ))
702    })?;
703    if name.is_empty() || path.is_empty() || name == "baseline" {
704        return Err(CuError::from(format!(
705            "Invalid measurement '{argument}'; use a non-empty name other than 'baseline'"
706        )));
707    }
708    Ok((name, Path::new(path)))
709}
710
711fn candidate_index(name: &str) -> Option<usize> {
712    name.split('/')
713        .next()?
714        .strip_prefix("plan-")?
715        .parse::<usize>()
716        .ok()?
717        .checked_sub(1)
718}
719
720fn prediction_report(candidates: &[cu29::planner::CuCandidate], mission_id: &str) -> String {
721    let mut report = String::from("Predicted schedule ranking\n");
722    for (index, candidate) in candidates.iter().enumerate() {
723        let mission = candidate.plan.missions.get(mission_id);
724        let shape = mission.map_or_else(
725            || "unknown".to_string(),
726            |mission| {
727                format!(
728                    "{} CopperList(s)/cycle, {} in flight, {} worker(s)",
729                    mission.copperlists_per_cycle,
730                    mission.max_in_flight,
731                    mission.workers.len()
732                )
733            },
734        );
735        let _ = writeln!(
736            report,
737            "{}. plan-{} score {:?} ({shape})",
738            index + 1,
739            index + 1,
740            candidate.prediction.score
741        );
742        for (chain, prediction) in &candidate.prediction.chains {
743            let _ = writeln!(
744                report,
745                "   {chain}: {:.2} ms / {:.2} ms ({:.1}%)",
746                prediction.latency_ns as f64 / 1e6,
747                prediction.deadline_ns as f64 / 1e6,
748                prediction.ratio * 100.0
749            );
750        }
751        for (source, rate) in &candidate.prediction.sources {
752            let _ = writeln!(report, "   {source}: {:.1}% delivered", rate * 100.0);
753        }
754        for (worker, prediction) in &candidate.prediction.workers {
755            let response = prediction.response_ns.map_or_else(
756                || "unbounded".to_string(),
757                |ns| format!("{:.2} ms", ns as f64 / 1e6),
758            );
759            let _ = writeln!(
760                report,
761                "   {worker}: CPU {}, {:.1}% load, {:.1}% rate, {response} response",
762                prediction.cpu,
763                prediction.load * 100.0,
764                prediction.rate * 100.0,
765            );
766        }
767    }
768    report
769}
770
771fn resolve_schedule_mission(config: &CuConfig, logged: &[String]) -> CuResult<Option<String>> {
772    if logged.len() > 1 {
773        return Err(CuError::from(format!(
774            "Profile-guided scheduling needs a run containing one mission; this run contains {}",
775            logged.join(", ")
776        )));
777    }
778    resolve_logstats_mission(config, None, logged.first().cloned())
779}
780
781fn resolve_logstats_mission(
782    config: &CuConfig,
783    requested: Option<String>,
784    logged: Option<String>,
785) -> CuResult<Option<String>> {
786    let mission = requested.or(logged);
787    let cu29::config::ConfigGraphs::Missions(graphs) = &config.graphs else {
788        return Ok(None);
789    };
790    if let Some(mission) = mission {
791        if !graphs.contains_key(&mission) {
792            return Err(CuError::from(format!(
793                "Mission '{mission}' is absent from the statistics configuration"
794            )));
795        }
796        return Ok(Some(mission));
797    }
798    if graphs.contains_key("default") {
799        return Ok(Some("default".to_string()));
800    }
801    Ok(graphs.keys().min().cloned())
802}
803
804/// Helper function for MCAP export.
805///
806/// Uses generated per-slot payload type metadata to derive schemas.
807#[cfg(feature = "mcap")]
808fn export_to_mcap_impl<P>(src: impl Read, output: &Path) -> CuResult<McapExportStats>
809where
810    P: CopperListTuple,
811{
812    mcap_export::export_to_mcap::<P, _>(src, output)
813}
814
815#[cfg(feature = "mcap")]
816struct ProgressReader<R> {
817    inner: R,
818    progress: ProgressBar,
819}
820
821#[cfg(feature = "mcap")]
822impl<R> ProgressReader<R> {
823    fn new(inner: R, progress: ProgressBar) -> Self {
824        Self { inner, progress }
825    }
826}
827
828#[cfg(feature = "mcap")]
829impl<R: Read> Read for ProgressReader<R> {
830    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
831        let read = self.inner.read(buf)?;
832        if read > 0 {
833            self.progress.inc(read as u64);
834        }
835        Ok(read)
836    }
837}
838
839#[cfg(feature = "mcap")]
840fn make_progress_bar(total_bytes: u64) -> ProgressBar {
841    let progress_bar = ProgressBar::new(total_bytes);
842    progress_bar.set_draw_target(ProgressDrawTarget::stderr_with_hz(5));
843
844    let style = ProgressStyle::with_template(
845        "[{elapsed_precise}] {bar:40} {bytes}/{total_bytes} ({bytes_per_sec}, ETA {eta})",
846    )
847    .unwrap_or_else(|_| ProgressStyle::default_bar());
848
849    progress_bar.set_style(style.progress_chars("=>-"));
850    progress_bar
851}
852
853#[cfg(feature = "mcap")]
854fn should_show_progress(force_progress: bool, quiet: bool) -> bool {
855    !quiet && (force_progress || std::io::stderr().is_terminal())
856}
857
858fn read_next_entry<T: Decode<()>>(src: &mut impl Read) -> Option<T> {
859    let entry = decode_from_std_read::<T, _, _>(src, standard());
860    match entry {
861        Ok(entry) => Some(entry),
862        Err(DecodeError::UnexpectedEnd { .. }) => None,
863        Err(DecodeError::Io { inner, additional }) => {
864            if inner.kind() == std::io::ErrorKind::UnexpectedEof {
865                None
866            } else {
867                println!("Error {inner:?} additional:{additional}");
868                None
869            }
870        }
871        Err(e) => {
872            println!("Error {e:?}");
873            None
874        }
875    }
876}
877
878/// Reads received archive provenance, inclusive source gaps and verified recovery points.
879pub fn stream_continuity_reader(
880    mut src: impl Read,
881) -> impl Iterator<Item = cu29::continuity::StreamContinuityRecord> {
882    std::iter::from_fn(move || read_next_entry(&mut src))
883}
884
885/// Extracts the copper lists from a binary representation.
886/// P is the Payload determined by the configuration of the application.
887pub fn copperlists_reader<P: CopperListTuple>(
888    mut src: impl Read,
889) -> impl Iterator<Item = CopperList<P>> {
890    std::iter::from_fn(move || read_next_entry::<CopperList<P>>(&mut src))
891}
892
893/// Extracts the keyframes from the log.
894pub fn keyframes_reader(mut src: impl Read) -> impl Iterator<Item = KeyFrame> {
895    std::iter::from_fn(move || read_next_entry::<KeyFrame>(&mut src))
896}
897
898/// Extracts the runtime lifecycle records from the log.
899pub fn runtime_lifecycle_reader(
900    mut src: impl Read,
901) -> impl Iterator<Item = RuntimeLifecycleRecord> {
902    std::iter::from_fn(move || read_next_entry::<RuntimeLifecycleRecord>(&mut src))
903}
904
905/// Returns the first mission announced by the runtime lifecycle section, if any.
906pub fn unified_log_mission(unifiedlog_base: &Path) -> CuResult<Option<String>> {
907    let mut dl = build_read_logger(unifiedlog_base)?;
908    let Some(metadata) = dl.application_metadata()? else {
909        return Ok(None);
910    };
911    loop {
912        let header = dl.raw_skip_section()?;
913        if header.entry_type == UnifiedLogType::LastEntry {
914            return Ok(None);
915        }
916        if header.context.run_id != 0 {
917            return metadata
918                .missions
919                .get(header.context.mission_index as usize)
920                .cloned()
921                .map(Some)
922                .ok_or(CuError::from("Invalid mission index"));
923        }
924    }
925}
926
927/// Ensures the unified log was recorded for the expected mission.
928pub fn assert_unified_log_mission(unifiedlog_base: &Path, expected_mission: &str) -> CuResult<()> {
929    match unified_log_mission(unifiedlog_base)? {
930        Some(actual_mission) if actual_mission == expected_mission => Ok(()),
931        Some(actual_mission) => Err(CuError::from(format!(
932            "Mission mismatch: expected '{expected_mission}', found '{actual_mission}'"
933        ))),
934        None => Err(CuError::from(format!(
935            "No MissionStarted runtime lifecycle event found while validating expected mission '{expected_mission}'"
936        ))),
937    }
938}
939
940pub fn structlog_reader(mut src: impl Read) -> impl Iterator<Item = CuResult<CuLogEntry>> {
941    std::iter::from_fn(move || {
942        let entry = decode_from_std_read::<CuLogEntry, _, _>(&mut src, standard());
943
944        match entry {
945            Err(DecodeError::UnexpectedEnd { .. }) => None,
946            Err(DecodeError::Io {
947                inner,
948                additional: _,
949            }) => {
950                if inner.kind() == std::io::ErrorKind::UnexpectedEof {
951                    None
952                } else {
953                    Some(Err(CuError::new_with_cause("Error reading log", inner)))
954                }
955            }
956            Err(e) => Some(Err(CuError::new_with_cause("Error reading log", e))),
957            Ok(entry) => {
958                if entry.msg_index == 0 {
959                    None
960                } else {
961                    Some(Ok(entry))
962                }
963            }
964        }
965    })
966}
967
968/// Full dump of the copper structured log from its binary representation.
969/// This rebuilds a textual log.
970/// src: the source of the log data
971/// index: the path to the index file (containing the interned strings constructed at build time)
972pub fn textlog_dump(src: impl Read, index: &Path) -> CuResult<()> {
973    let all_strings = read_interned_strings(index).map_err(|e| {
974        CuError::new_with_cause(
975            "Failed to read interned strings from index",
976            std::io::Error::other(e),
977        )
978    })?;
979
980    for result in structlog_reader(src) {
981        let entry = result?;
982        match rebuild_logline(&all_strings, &entry) {
983            Ok(line) => println!("{line}"),
984            Err(e) => println!("Failed to rebuild log line: {e:?}"),
985        }
986    }
987
988    Ok(())
989}
990
991// Only compiled for users opting into the Python interface.
992#[cfg(feature = "python")]
993mod python {
994    use bincode::config::standard;
995    use bincode::decode_from_std_read;
996    use bincode::error::DecodeError;
997    use cu29::bevy_reflect::{PartialReflect, ReflectRef, VariantType};
998    use cu29::prelude::*;
999    use cu29_intern_strs::read_interned_strings;
1000    use pyo3::exceptions::{PyIOError, PyRuntimeError};
1001    use pyo3::prelude::*;
1002    use pyo3::types::{PyDelta, PyDict, PyList};
1003    use std::io::Read;
1004    use std::path::Path;
1005    use std::sync::OnceLock;
1006
1007    type CopperListDecodeFn =
1008        for<'py> fn(&mut Box<dyn Read + Send + Sync>, Python<'py>) -> Option<PyResult<Py<PyAny>>>;
1009    static COPPERLIST_DECODER: OnceLock<CopperListDecodeFn> = OnceLock::new();
1010
1011    /// Iterator over structured Copper log entries.
1012    #[pyclass]
1013    pub struct PyLogIterator {
1014        reader: Box<dyn Read + Send + Sync>,
1015    }
1016
1017    /// Iterator over application-specific CopperLists decoded into Python values.
1018    #[pyclass]
1019    pub struct PyCopperListIterator {
1020        reader: Box<dyn Read + Send + Sync>,
1021        decode_next: CopperListDecodeFn,
1022    }
1023
1024    /// Iterator over runtime lifecycle records stored in a unified log.
1025    #[pyclass]
1026    pub struct PyRuntimeLifecycleIterator {
1027        reader: Box<dyn Read + Send + Sync>,
1028    }
1029
1030    /// Helper wrapper used when reflected unit-bearing values are exposed to Python.
1031    #[pyclass(get_all)]
1032    pub struct PyUnitValue {
1033        pub value: f64,
1034        pub unit: String,
1035    }
1036
1037    /// Register the Python decoder for one concrete CopperList tuple type.
1038    ///
1039    /// App-specific extension modules call this once before constructing a
1040    /// `PyCopperListIterator`.
1041    pub fn register_copperlist_python_type<P>() -> CuResult<()>
1042    where
1043        P: CopperListTuple,
1044    {
1045        if COPPERLIST_DECODER.get().is_none() {
1046            COPPERLIST_DECODER
1047                .set(decode_next_copperlist::<P>)
1048                .map_err(|_| CuError::from("Failed to register CopperList Python decoder"))?;
1049        }
1050        Ok(())
1051    }
1052    #[pymethods]
1053    impl PyLogIterator {
1054        fn __iter__(slf: PyRefMut<Self>) -> PyRefMut<Self> {
1055            slf
1056        }
1057
1058        fn __next__(mut slf: PyRefMut<Self>) -> Option<PyResult<PyCuLogEntry>> {
1059            match decode_from_std_read::<CuLogEntry, _, _>(&mut slf.reader, standard()) {
1060                Ok(entry) => {
1061                    if entry.msg_index == 0 {
1062                        None
1063                    } else {
1064                        Some(Ok(PyCuLogEntry { inner: entry }))
1065                    }
1066                }
1067                Err(DecodeError::UnexpectedEnd { .. }) => None,
1068                Err(DecodeError::Io { inner, .. })
1069                    if inner.kind() == std::io::ErrorKind::UnexpectedEof =>
1070                {
1071                    None
1072                }
1073                Err(e) => Some(Err(PyIOError::new_err(e.to_string()))),
1074            }
1075        }
1076    }
1077
1078    #[pymethods]
1079    impl PyCopperListIterator {
1080        fn __iter__(slf: PyRefMut<Self>) -> PyRefMut<Self> {
1081            slf
1082        }
1083
1084        fn __next__(mut slf: PyRefMut<Self>, py: Python<'_>) -> Option<PyResult<Py<PyAny>>> {
1085            (slf.decode_next)(&mut slf.reader, py)
1086        }
1087    }
1088
1089    #[pymethods]
1090    impl PyRuntimeLifecycleIterator {
1091        fn __iter__(slf: PyRefMut<Self>) -> PyRefMut<Self> {
1092            slf
1093        }
1094
1095        fn __next__(mut slf: PyRefMut<Self>, py: Python<'_>) -> Option<PyResult<Py<PyAny>>> {
1096            let entry = super::read_next_entry::<RuntimeLifecycleRecord>(&mut slf.reader)?;
1097            Some(runtime_lifecycle_record_to_py(&entry, py))
1098        }
1099    }
1100    /// Create an iterator over structured log entries from a bare structured log file.
1101    ///
1102    /// This is the non-unified-log path used by standalone structured log setups.
1103    /// The function returns the iterator together with the interned string table
1104    /// needed to format each message.
1105    #[pyfunction]
1106    pub fn struct_log_iterator_bare(
1107        bare_struct_src_path: &str,
1108        index_path: &str,
1109    ) -> PyResult<(PyLogIterator, Vec<String>)> {
1110        let file = std::fs::File::open(bare_struct_src_path)
1111            .map_err(|e| PyIOError::new_err(e.to_string()))?;
1112        let all_strings = read_interned_strings(Path::new(index_path))
1113            .map_err(|e| PyIOError::new_err(e.to_string()))?;
1114        Ok((
1115            PyLogIterator {
1116                reader: Box::new(file),
1117            },
1118            all_strings,
1119        ))
1120    }
1121    /// Create an iterator over structured log entries from a unified log file.
1122    ///
1123    /// The function returns the iterator together with the interned string table
1124    /// needed to rebuild the text messages.
1125    #[pyfunction]
1126    pub fn struct_log_iterator_unified(
1127        unified_src_path: &str,
1128        index_path: &str,
1129    ) -> PyResult<(PyLogIterator, Vec<String>)> {
1130        let all_strings = read_interned_strings(Path::new(index_path))
1131            .map_err(|e| PyIOError::new_err(e.to_string()))?;
1132
1133        let logger = UnifiedLoggerBuilder::new()
1134            .file_base_name(Path::new(unified_src_path))
1135            .build()
1136            .map_err(|e| PyIOError::new_err(e.to_string()))?;
1137        let dl = match logger {
1138            UnifiedLogger::Read(dl) => dl,
1139            UnifiedLogger::Write(_) => {
1140                return Err(PyIOError::new_err(
1141                    "Expected read-only unified logger for Python export",
1142                ));
1143            }
1144        };
1145
1146        let reader = UnifiedLoggerIOReader::new(dl, UnifiedLogType::StructuredLogLine);
1147        Ok((
1148            PyLogIterator {
1149                reader: Box::new(reader),
1150            },
1151            all_strings,
1152        ))
1153    }
1154
1155    /// Create an iterator over CopperLists from a unified log file.
1156    ///
1157    /// The concrete CopperList tuple type must be registered from Rust first with
1158    /// `register_copperlist_python_type::<P>()`.
1159    #[pyfunction]
1160    pub fn copperlist_iterator_unified(unified_src_path: &str) -> PyResult<PyCopperListIterator> {
1161        let decode_next = *COPPERLIST_DECODER.get().ok_or_else(|| {
1162            PyRuntimeError::new_err(
1163                "CopperList decoder is not registered. \
1164Call register_copperlist_python_type::<P>() from Rust before using this function.",
1165            )
1166        })?;
1167
1168        let logger = UnifiedLoggerBuilder::new()
1169            .file_base_name(Path::new(unified_src_path))
1170            .build()
1171            .map_err(|e| PyIOError::new_err(e.to_string()))?;
1172        let dl = match logger {
1173            UnifiedLogger::Read(dl) => dl,
1174            UnifiedLogger::Write(_) => {
1175                return Err(PyIOError::new_err(
1176                    "Expected read-only unified logger for Python export",
1177                ));
1178            }
1179        };
1180
1181        let reader = UnifiedLoggerIOReader::new(dl, UnifiedLogType::CopperList);
1182        Ok(PyCopperListIterator {
1183            reader: Box::new(reader),
1184            decode_next,
1185        })
1186    }
1187
1188    /// Create an iterator over runtime lifecycle records from a unified log file.
1189    #[pyfunction]
1190    pub fn runtime_lifecycle_iterator_unified(
1191        unified_src_path: &str,
1192    ) -> PyResult<PyRuntimeLifecycleIterator> {
1193        let logger = UnifiedLoggerBuilder::new()
1194            .file_base_name(Path::new(unified_src_path))
1195            .build()
1196            .map_err(|e| PyIOError::new_err(e.to_string()))?;
1197        let dl = match logger {
1198            UnifiedLogger::Read(dl) => dl,
1199            UnifiedLogger::Write(_) => {
1200                return Err(PyIOError::new_err(
1201                    "Expected read-only unified logger for Python export",
1202                ));
1203            }
1204        };
1205
1206        let reader = UnifiedLoggerIOReader::new(dl, UnifiedLogType::RuntimeLifecycle);
1207        Ok(PyRuntimeLifecycleIterator {
1208            reader: Box::new(reader),
1209        })
1210    }
1211    /// Python wrapper for [`CuLogEntry`].
1212    #[pyclass]
1213    pub struct PyCuLogEntry {
1214        pub inner: CuLogEntry,
1215    }
1216
1217    #[pymethods]
1218    impl PyCuLogEntry {
1219        /// Return the timestamp of the log entry as a `datetime.timedelta`.
1220        pub fn ts<'a>(&self, py: Python<'a>) -> PyResult<Bound<'a, PyDelta>> {
1221            let nanoseconds: u64 = self.inner.time.into();
1222
1223            // Convert nanoseconds to seconds and microseconds
1224            let days = (nanoseconds / 86_400_000_000_000) as i32;
1225            let seconds = (nanoseconds / 1_000_000_000) as i32;
1226            let microseconds = ((nanoseconds % 1_000_000_000) / 1_000) as i32;
1227
1228            PyDelta::new(py, days, seconds, microseconds, false)
1229        }
1230
1231        /// Return the index of the message format string in the interned string table.
1232        pub fn msg_index(&self) -> u32 {
1233            self.inner.msg_index
1234        }
1235
1236        /// Return the CopperList id captured for this log entry, if any.
1237        pub fn culistid(&self) -> Option<u64> {
1238            self.inner.origin.culistid
1239        }
1240
1241        /// Return the runtime component id captured for this log entry, if any.
1242        pub fn component_id(&self) -> Option<u32> {
1243            self.inner.origin.component_id
1244        }
1245
1246        /// Return the task index captured for this log entry, if any.
1247        pub fn task_index(&self) -> Option<u32> {
1248            self.inner.origin.task_index
1249        }
1250
1251        /// Return the indexes of the parameter names in the interned string table.
1252        pub fn paramname_indexes(&self) -> Vec<u32> {
1253            self.inner.paramname_indexes.iter().copied().collect()
1254        }
1255
1256        /// Return the structured parameters carried by this log line.
1257        pub fn params(&self, py: Python<'_>) -> PyResult<Vec<Py<PyAny>>> {
1258            self.inner
1259                .params
1260                .iter()
1261                .map(|value| value_to_py(value, py))
1262                .collect()
1263        }
1264    }
1265
1266    /// This needs to match the name of the generated '.so'
1267    #[pymodule(name = "libcu29_export")]
1268    fn cu29_export(m: &Bound<'_, PyModule>) -> PyResult<()> {
1269        #[cfg(feature = "self-describing-logs")]
1270        crate::catalog_python::add_functions(m)?;
1271        m.add_class::<PyCuLogEntry>()?;
1272        m.add_class::<PyLogIterator>()?;
1273        m.add_class::<PyCopperListIterator>()?;
1274        m.add_class::<PyRuntimeLifecycleIterator>()?;
1275        m.add_class::<PyUnitValue>()?;
1276        m.add_function(wrap_pyfunction!(struct_log_iterator_bare, m)?)?;
1277        m.add_function(wrap_pyfunction!(struct_log_iterator_unified, m)?)?;
1278        m.add_function(wrap_pyfunction!(copperlist_iterator_unified, m)?)?;
1279        m.add_function(wrap_pyfunction!(runtime_lifecycle_iterator_unified, m)?)?;
1280        Ok(())
1281    }
1282
1283    fn decode_next_copperlist<P>(
1284        reader: &mut Box<dyn Read + Send + Sync>,
1285        py: Python<'_>,
1286    ) -> Option<PyResult<Py<PyAny>>>
1287    where
1288        P: CopperListTuple,
1289    {
1290        let entry = super::read_next_entry::<CopperList<P>>(reader)?;
1291        Some(copperlist_to_py::<P>(&entry, py))
1292    }
1293
1294    fn copperlist_to_py<P>(entry: &CopperList<P>, py: Python<'_>) -> PyResult<Py<PyAny>>
1295    where
1296        P: CopperListTuple,
1297    {
1298        let task_ids = P::get_all_task_ids();
1299        let root = PyDict::new(py);
1300        root.set_item("id", entry.id)?;
1301
1302        let mut messages: Vec<Py<PyAny>> = Vec::new();
1303        for (idx, msg) in entry.cumsgs().into_iter().enumerate() {
1304            let message = PyDict::new(py);
1305            message.set_item("task_id", task_ids.get(idx).copied().unwrap_or("unknown"))?;
1306            message.set_item("tov", tov_to_py(msg.tov(), py)?)?;
1307            message.set_item("metadata", metadata_to_py(msg.metadata(), py)?)?;
1308            match msg.payload_reflect() {
1309                Some(payload) => message.set_item(
1310                    "payload",
1311                    partial_reflect_to_py(payload.as_partial_reflect(), py)?,
1312                )?,
1313                None => message.set_item("payload", py.None())?,
1314            }
1315            messages.push(dict_to_namespace(message, py)?);
1316        }
1317
1318        root.set_item("messages", PyList::new(py, messages)?)?;
1319        dict_to_namespace(root, py)
1320    }
1321
1322    fn runtime_lifecycle_record_to_py(
1323        entry: &RuntimeLifecycleRecord,
1324        py: Python<'_>,
1325    ) -> PyResult<Py<PyAny>> {
1326        let root = PyDict::new(py);
1327        root.set_item("timestamp_ns", entry.timestamp.as_nanos())?;
1328        root.set_item("event", runtime_lifecycle_event_to_py(&entry.event, py)?)?;
1329        dict_to_namespace(root, py)
1330    }
1331
1332    fn runtime_lifecycle_event_to_py(
1333        event: &RuntimeLifecycleEvent,
1334        py: Python<'_>,
1335    ) -> PyResult<Py<PyAny>> {
1336        let root = PyDict::new(py);
1337        match event {
1338            RuntimeLifecycleEvent::Instantiated { config_source } => {
1339                root.set_item("kind", "instantiated")?;
1340                root.set_item("config_source", runtime_config_source_to_py(config_source))?;
1341            }
1342            RuntimeLifecycleEvent::MissionStarted => {
1343                root.set_item("kind", "mission_started")?;
1344            }
1345            RuntimeLifecycleEvent::MissionStopped { reason } => {
1346                root.set_item("kind", "mission_stopped")?;
1347                root.set_item("reason", format!("{reason:?}"))?;
1348            }
1349            RuntimeLifecycleEvent::LifecycleFailed { operation, error } => {
1350                root.set_item("kind", "lifecycle_failed")?;
1351                root.set_item("operation", format!("{operation:?}"))?;
1352                root.set_item("error", error)?;
1353            }
1354            RuntimeLifecycleEvent::Panic {
1355                message,
1356                file,
1357                line,
1358                column,
1359            } => {
1360                root.set_item("kind", "panic")?;
1361                root.set_item("message", message)?;
1362                root.set_item("file", file)?;
1363                root.set_item("line", line)?;
1364                root.set_item("column", column)?;
1365            }
1366            RuntimeLifecycleEvent::ShutdownCompleted => {
1367                root.set_item("kind", "shutdown_completed")?;
1368            }
1369            #[allow(unreachable_patterns)] // dependency features may enable extended records
1370            event => {
1371                if let Some(record) = event.clock_sync_record() {
1372                    root.set_item("kind", "clock_sync")?;
1373                    root.set_item("culistid", record.culistid)?;
1374                    let snapshot = serde_json::to_string(&record.snapshot).map_err(|error| {
1375                        pyo3::exceptions::PyValueError::new_err(error.to_string())
1376                    })?;
1377                    root.set_item(
1378                        "snapshot",
1379                        py.import("json")?.getattr("loads")?.call1((snapshot,))?,
1380                    )?;
1381                }
1382            }
1383        }
1384
1385        dict_to_namespace(root, py)
1386    }
1387
1388    fn runtime_config_source_to_py(source: &RuntimeLifecycleConfigSource) -> &'static str {
1389        match source {
1390            RuntimeLifecycleConfigSource::ProgrammaticOverride => "programmatic_override",
1391            RuntimeLifecycleConfigSource::ExternalFile => "external_file",
1392            RuntimeLifecycleConfigSource::BundledDefault => "bundled_default",
1393        }
1394    }
1395
1396    fn metadata_to_py(metadata: &dyn CuMsgMetadataTrait, py: Python<'_>) -> PyResult<Py<PyAny>> {
1397        let process = metadata.process_time();
1398        let start: Option<CuTime> = process.start.into();
1399        let end: Option<CuTime> = process.end.into();
1400
1401        let process_time = PyDict::new(py);
1402        process_time.set_item("start_ns", start.map(|t| t.as_nanos()))?;
1403        process_time.set_item("end_ns", end.map(|t| t.as_nanos()))?;
1404
1405        let metadata_py = PyDict::new(py);
1406        metadata_py.set_item("process_time", dict_to_namespace(process_time, py)?)?;
1407        metadata_py.set_item("status_txt", metadata.status_txt().0.to_string())?;
1408        if let Some(origin) = metadata.origin() {
1409            let origin_py = PyDict::new(py);
1410            origin_py.set_item("subsystem_code", origin.subsystem_code)?;
1411            origin_py.set_item("instance_id", origin.instance_id)?;
1412            origin_py.set_item("cl_id", origin.cl_id)?;
1413            metadata_py.set_item("origin", dict_to_namespace(origin_py, py)?)?;
1414        } else {
1415            metadata_py.set_item("origin", py.None())?;
1416        }
1417        dict_to_namespace(metadata_py, py)
1418    }
1419
1420    fn tov_to_py(tov: Tov, py: Python<'_>) -> PyResult<Py<PyAny>> {
1421        let tov_py = PyDict::new(py);
1422        match tov {
1423            Tov::None => {
1424                tov_py.set_item("kind", "none")?;
1425            }
1426            Tov::Time(t) => {
1427                tov_py.set_item("kind", "time")?;
1428                tov_py.set_item("time_ns", t.as_nanos())?;
1429            }
1430            Tov::Range(r) => {
1431                tov_py.set_item("kind", "range")?;
1432                tov_py.set_item("start_ns", r.start.as_nanos())?;
1433                tov_py.set_item("end_ns", r.end.as_nanos())?;
1434            }
1435        }
1436        dict_to_namespace(tov_py, py)
1437    }
1438
1439    fn partial_reflect_to_py(value: &dyn PartialReflect, py: Python<'_>) -> PyResult<Py<PyAny>> {
1440        #[allow(unreachable_patterns)]
1441        match value.reflect_ref() {
1442            ReflectRef::Struct(s) => struct_to_py(s, py),
1443            ReflectRef::TupleStruct(ts) => tuple_struct_to_py(ts, py),
1444            ReflectRef::Tuple(t) => tuple_to_py(t, py),
1445            ReflectRef::List(list) => list_to_py(list, py),
1446            ReflectRef::Array(array) => array_to_py(array, py),
1447            ReflectRef::Map(map) => map_to_py(map, py),
1448            ReflectRef::Set(set) => set_to_py(set, py),
1449            ReflectRef::Enum(e) => enum_to_py(e, py),
1450            ReflectRef::Opaque(opaque) => opaque_to_py(opaque, py),
1451            _ => Ok(py.None()),
1452        }
1453    }
1454
1455    fn struct_to_py(value: &dyn cu29::bevy_reflect::Struct, py: Python<'_>) -> PyResult<Py<PyAny>> {
1456        let dict = PyDict::new(py);
1457        for idx in 0..value.field_len() {
1458            if let Some(field) = value.field_at(idx) {
1459                let name = value
1460                    .name_at(idx)
1461                    .map(str::to_owned)
1462                    .unwrap_or_else(|| format!("field_{idx}"));
1463                dict.set_item(name, partial_reflect_to_py(field, py)?)?;
1464            }
1465        }
1466
1467        if let Some(unit) = unit_abbrev_for_type_path(value.reflect_type_path())
1468            && let Some(raw_value) = dict.get_item("value")?
1469        {
1470            if let Ok(v) = raw_value.extract::<f64>() {
1471                let unit_value = PyUnitValue {
1472                    value: v,
1473                    unit: unit.to_string(),
1474                };
1475                return Ok(Py::new(py, unit_value)?.into());
1476            }
1477            if let Ok(v) = raw_value.extract::<f32>() {
1478                let unit_value = PyUnitValue {
1479                    value: v as f64,
1480                    unit: unit.to_string(),
1481                };
1482                return Ok(Py::new(py, unit_value)?.into());
1483            }
1484        }
1485
1486        dict_to_namespace(dict, py)
1487    }
1488
1489    fn tuple_struct_to_py(
1490        value: &dyn cu29::bevy_reflect::TupleStruct,
1491        py: Python<'_>,
1492    ) -> PyResult<Py<PyAny>> {
1493        let mut fields = Vec::with_capacity(value.field_len());
1494        for idx in 0..value.field_len() {
1495            if let Some(field) = value.field(idx) {
1496                fields.push(partial_reflect_to_py(field, py)?);
1497            } else {
1498                fields.push(py.None());
1499            }
1500        }
1501        Ok(PyList::new(py, fields)?.into_pyobject(py)?.into())
1502    }
1503
1504    fn tuple_to_py(value: &dyn cu29::bevy_reflect::Tuple, py: Python<'_>) -> PyResult<Py<PyAny>> {
1505        let mut fields = Vec::with_capacity(value.field_len());
1506        for idx in 0..value.field_len() {
1507            if let Some(field) = value.field(idx) {
1508                fields.push(partial_reflect_to_py(field, py)?);
1509            } else {
1510                fields.push(py.None());
1511            }
1512        }
1513        Ok(PyList::new(py, fields)?.into_pyobject(py)?.into())
1514    }
1515
1516    fn list_to_py(value: &dyn cu29::bevy_reflect::List, py: Python<'_>) -> PyResult<Py<PyAny>> {
1517        let mut items = Vec::with_capacity(value.len());
1518        for item in value.iter() {
1519            items.push(partial_reflect_to_py(item, py)?);
1520        }
1521        Ok(PyList::new(py, items)?.into_pyobject(py)?.into())
1522    }
1523
1524    fn array_to_py(value: &dyn cu29::bevy_reflect::Array, py: Python<'_>) -> PyResult<Py<PyAny>> {
1525        let mut items = Vec::with_capacity(value.len());
1526        for item in value.iter() {
1527            items.push(partial_reflect_to_py(item, py)?);
1528        }
1529        Ok(PyList::new(py, items)?.into_pyobject(py)?.into())
1530    }
1531
1532    fn map_to_py(value: &dyn cu29::bevy_reflect::Map, py: Python<'_>) -> PyResult<Py<PyAny>> {
1533        let dict = PyDict::new(py);
1534        for (key, val) in value.iter() {
1535            let key_str = reflect_key_to_string(key);
1536            dict.set_item(key_str, partial_reflect_to_py(val, py)?)?;
1537        }
1538        Ok(dict.into_pyobject(py)?.into())
1539    }
1540
1541    fn set_to_py(value: &dyn cu29::bevy_reflect::Set, py: Python<'_>) -> PyResult<Py<PyAny>> {
1542        let mut items = Vec::with_capacity(value.len());
1543        for item in value.iter() {
1544            items.push(partial_reflect_to_py(item, py)?);
1545        }
1546        Ok(PyList::new(py, items)?.into_pyobject(py)?.into())
1547    }
1548
1549    fn enum_to_py(value: &dyn cu29::bevy_reflect::Enum, py: Python<'_>) -> PyResult<Py<PyAny>> {
1550        let dict = PyDict::new(py);
1551        dict.set_item("variant", value.variant_name())?;
1552
1553        match value.variant_type() {
1554            VariantType::Unit => {}
1555            VariantType::Tuple => {
1556                let mut fields = Vec::with_capacity(value.field_len());
1557                for idx in 0..value.field_len() {
1558                    if let Some(field) = value.field_at(idx) {
1559                        fields.push(partial_reflect_to_py(field, py)?);
1560                    } else {
1561                        fields.push(py.None());
1562                    }
1563                }
1564                dict.set_item("fields", PyList::new(py, fields)?)?;
1565            }
1566            VariantType::Struct => {
1567                let fields = PyDict::new(py);
1568                for idx in 0..value.field_len() {
1569                    if let Some(field) = value.field_at(idx) {
1570                        let name = value
1571                            .name_at(idx)
1572                            .map(str::to_owned)
1573                            .unwrap_or_else(|| format!("field_{idx}"));
1574                        fields.set_item(name, partial_reflect_to_py(field, py)?)?;
1575                    }
1576                }
1577                dict.set_item("fields", fields)?;
1578            }
1579        }
1580
1581        dict_to_namespace(dict, py)
1582    }
1583
1584    fn dict_to_namespace(dict: Bound<'_, PyDict>, py: Python<'_>) -> PyResult<Py<PyAny>> {
1585        let types = py.import("types")?;
1586        let namespace_ctor = types.getattr("SimpleNamespace")?;
1587        let namespace = namespace_ctor.call((), Some(&dict))?;
1588        Ok(namespace.into())
1589    }
1590
1591    fn reflect_key_to_string(value: &dyn PartialReflect) -> String {
1592        if let Some(v) = value.try_downcast_ref::<String>() {
1593            return v.clone();
1594        }
1595        if let Some(v) = value.try_downcast_ref::<&'static str>() {
1596            return (*v).to_string();
1597        }
1598        if let Some(v) = value.try_downcast_ref::<char>() {
1599            return v.to_string();
1600        }
1601        if let Some(v) = value.try_downcast_ref::<bool>() {
1602            return v.to_string();
1603        }
1604        if let Some(v) = value.try_downcast_ref::<u64>() {
1605            return v.to_string();
1606        }
1607        if let Some(v) = value.try_downcast_ref::<i64>() {
1608            return v.to_string();
1609        }
1610        if let Some(v) = value.try_downcast_ref::<usize>() {
1611            return v.to_string();
1612        }
1613        if let Some(v) = value.try_downcast_ref::<isize>() {
1614            return v.to_string();
1615        }
1616        format!("{value:?}")
1617    }
1618
1619    fn unit_abbrev_for_type_path(type_path: &str) -> Option<&'static str> {
1620        match type_path.rsplit("::").next()? {
1621            "Acceleration" => Some("m/s^2"),
1622            "Angle" => Some("rad"),
1623            "AngularVelocity" => Some("rad/s"),
1624            "ElectricPotential" => Some("V"),
1625            "Length" => Some("m"),
1626            "MagneticFluxDensity" => Some("T"),
1627            "Pressure" => Some("Pa"),
1628            "Ratio" => Some("1"),
1629            "ThermodynamicTemperature" => Some("K"),
1630            "Time" => Some("s"),
1631            "Velocity" => Some("m/s"),
1632            _ => None,
1633        }
1634    }
1635
1636    fn opaque_to_py(value: &dyn PartialReflect, py: Python<'_>) -> PyResult<Py<PyAny>> {
1637        macro_rules! downcast_copy {
1638            ($ty:ty) => {
1639                if let Some(v) = value.try_downcast_ref::<$ty>() {
1640                    return Ok(v.into_pyobject(py)?.to_owned().into());
1641                }
1642            };
1643        }
1644
1645        downcast_copy!(bool);
1646        downcast_copy!(u8);
1647        downcast_copy!(u16);
1648        downcast_copy!(u32);
1649        downcast_copy!(u64);
1650        downcast_copy!(u128);
1651        downcast_copy!(usize);
1652        downcast_copy!(i8);
1653        downcast_copy!(i16);
1654        downcast_copy!(i32);
1655        downcast_copy!(i64);
1656        downcast_copy!(i128);
1657        downcast_copy!(isize);
1658        downcast_copy!(f32);
1659        downcast_copy!(f64);
1660        downcast_copy!(char);
1661
1662        if let Some(v) = value.try_downcast_ref::<String>() {
1663            return Ok(v.into_pyobject(py)?.into());
1664        }
1665        if let Some(v) = value.try_downcast_ref::<&'static str>() {
1666            return Ok(v.into_pyobject(py)?.into());
1667        }
1668        if let Some(v) = value.try_downcast_ref::<Vec<u8>>() {
1669            return Ok(v.into_pyobject(py)?.into());
1670        }
1671
1672        let fallback = format!("{value:?}");
1673        Ok(fallback.into_pyobject(py)?.into())
1674    }
1675    pub(super) fn value_to_py(value: &cu29::prelude::Value, py: Python<'_>) -> PyResult<Py<PyAny>> {
1676        match value {
1677            Value::String(s) => Ok(s.into_pyobject(py)?.into()),
1678            Value::U64(u) => Ok(u.into_pyobject(py)?.into()),
1679            Value::U128(u) => Ok(u.into_pyobject(py)?.into()),
1680            Value::I64(i) => Ok(i.into_pyobject(py)?.into()),
1681            Value::I128(i) => Ok(i.into_pyobject(py)?.into()),
1682            Value::F64(f) => Ok(f.into_pyobject(py)?.into()),
1683            Value::Bool(b) => Ok(b.into_pyobject(py)?.to_owned().into()),
1684            Value::CuTime(t) => Ok(t.0.into_pyobject(py)?.into()),
1685            Value::Bytes(b) => Ok(b.into_pyobject(py)?.into()),
1686            Value::Char(c) => Ok(c.into_pyobject(py)?.into()),
1687            Value::I8(i) => Ok(i.into_pyobject(py)?.into()),
1688            Value::U8(u) => Ok(u.into_pyobject(py)?.into()),
1689            Value::I16(i) => Ok(i.into_pyobject(py)?.into()),
1690            Value::U16(u) => Ok(u.into_pyobject(py)?.into()),
1691            Value::I32(i) => Ok(i.into_pyobject(py)?.into()),
1692            Value::U32(u) => Ok(u.into_pyobject(py)?.into()),
1693            Value::Map(m) => {
1694                let dict = PyDict::new(py);
1695                for (k, v) in m.iter() {
1696                    dict.set_item(value_to_py(k, py)?, value_to_py(v, py)?)?;
1697                }
1698                Ok(dict.into_pyobject(py)?.into())
1699            }
1700            Value::F32(f) => Ok(f.into_pyobject(py)?.into()),
1701            Value::Option(o) => match o.as_ref() {
1702                Some(value) => value_to_py(value, py),
1703                None => Ok(py.None()),
1704            },
1705            Value::Unit => Ok(py.None()),
1706            Value::Newtype(v) => value_to_py(v, py),
1707            Value::Seq(s) => {
1708                let items: Vec<Py<PyAny>> = s
1709                    .iter()
1710                    .map(|value| value_to_py(value, py))
1711                    .collect::<PyResult<_>>()?;
1712                let list = PyList::new(py, items)?;
1713                Ok(list.into_pyobject(py)?.into())
1714            }
1715        }
1716    }
1717
1718    #[cfg(test)]
1719    mod tests {
1720        use super::*;
1721
1722        #[derive(Debug, Default, bincode::Encode, bincode::Decode, serde::Serialize)]
1723        struct EmptyMessages;
1724
1725        impl ErasedCuStampedDataSet for EmptyMessages {
1726            fn cumsgs(&self) -> Vec<&dyn ErasedCuStampedData> {
1727                Vec::new()
1728            }
1729        }
1730
1731        impl MatchingTasks for EmptyMessages {
1732            fn get_all_task_ids() -> &'static [&'static str] {
1733                &[]
1734            }
1735        }
1736
1737        #[test]
1738        fn copperlist_python_export_omits_runtime_state() {
1739            Python::initialize();
1740            Python::attach(|py| {
1741                let list = CopperList::new(7, EmptyMessages);
1742                let value = copperlist_to_py(&list, py).unwrap();
1743                assert!(!value.bind(py).hasattr("state").unwrap());
1744                assert_eq!(
1745                    value
1746                        .bind(py)
1747                        .getattr("id")
1748                        .unwrap()
1749                        .extract::<u64>()
1750                        .unwrap(),
1751                    7
1752                );
1753            });
1754        }
1755
1756        #[test]
1757        fn value_to_py_preserves_128_bit_integers() {
1758            Python::initialize();
1759            Python::attach(|py| {
1760                let u128_value = u128::from(u64::MAX) + 99;
1761                let u128_py = value_to_py(&Value::U128(u128_value), py).unwrap();
1762                assert_eq!(u128_py.bind(py).extract::<u128>().unwrap(), u128_value);
1763
1764                let i128_value = i128::from(i64::MIN) - 99;
1765                let i128_py = value_to_py(&Value::I128(i128_value), py).unwrap();
1766                assert_eq!(i128_py.bind(py).extract::<i128>().unwrap(), i128_value);
1767            });
1768        }
1769    }
1770}
1771
1772#[cfg(test)]
1773mod tests {
1774    use super::*;
1775    use bincode::{Decode, Encode, encode_into_slice};
1776    use serde::Deserialize;
1777    use std::env;
1778    use std::fs;
1779    use std::io::Cursor;
1780    use std::path::PathBuf;
1781    use std::sync::{Arc, Mutex};
1782    use tempfile::{TempDir, tempdir};
1783
1784    fn copy_stringindex_to_temp(tmpdir: &TempDir) -> PathBuf {
1785        // Build a minimal index on the fly so tests don't depend on build-time artifacts.
1786        let fake_out_dir = tmpdir.path().join("build").join("out").join("dir");
1787        fs::create_dir_all(&fake_out_dir).unwrap();
1788        // SAFETY: Tests run single-threaded here and we only read the variable after setting it.
1789        unsafe {
1790            env::set_var("LOG_INDEX_DIR", &fake_out_dir);
1791        }
1792
1793        // Provide entries for the message indexes used in this test module.
1794        let _ = cu29_intern_strs::intern_string("unused to start counter");
1795        let _ = cu29_intern_strs::intern_string("Just a String {}");
1796        let _ = cu29_intern_strs::intern_string("Just a String (low level) {}");
1797        let _ = cu29_intern_strs::intern_string("Just a String (end to end) {}");
1798
1799        let index_dir = cu29_intern_strs::default_log_index_dir();
1800        cu29_intern_strs::read_interned_strings(&index_dir).unwrap();
1801        index_dir
1802    }
1803
1804    #[test]
1805    fn test_extract_low_level_cu29_log() {
1806        let temp_dir = TempDir::new().unwrap();
1807        let temp_path = copy_stringindex_to_temp(&temp_dir);
1808        let entry = CuLogEntry::new(3, CuLogLevel::Info);
1809        let bytes = bincode::encode_to_vec(&entry, standard()).unwrap();
1810        let reader = Cursor::new(bytes.as_slice());
1811        textlog_dump(reader, temp_path.as_path()).unwrap();
1812    }
1813
1814    #[test]
1815    fn end_to_end_datalogger_and_structlog_test() {
1816        let dir = tempdir().expect("Failed to create temp dir");
1817        let path = dir
1818            .path()
1819            .join("end_to_end_datalogger_and_structlog_test.copper");
1820        {
1821            // Write a couple log entries
1822            let UnifiedLogger::Write(logger) = UnifiedLoggerBuilder::new()
1823                .write(true)
1824                .create(true)
1825                .file_base_name(&path)
1826                .preallocated_size(100000)
1827                .build()
1828                .expect("Failed to create logger")
1829            else {
1830                panic!("Failed to create logger")
1831            };
1832            let data_logger = Arc::new(Mutex::new(logger));
1833            let stream = stream_write(data_logger.clone(), UnifiedLogType::StructuredLogLine, 1024)
1834                .expect("Failed to create stream");
1835            let rt = LoggerRuntime::init(RobotClock::default(), stream, None::<NullLog>);
1836
1837            let mut entry = CuLogEntry::new(4, CuLogLevel::Info); // this is a "Just a String {}" log line
1838            entry.add_param(0, Value::String("Parameter for the log line".into()));
1839            log(&mut entry).expect("Failed to log");
1840            let mut entry = CuLogEntry::new(2, CuLogLevel::Info); // this is a "Just a String {}" log line
1841            entry.add_param(0, Value::String("Parameter for the log line".into()));
1842            log(&mut entry).expect("Failed to log");
1843
1844            // everything is dropped here
1845            drop(rt);
1846        }
1847        // Read back the log
1848        let UnifiedLogger::Read(logger) = UnifiedLoggerBuilder::new()
1849            .file_base_name(
1850                &dir.path()
1851                    .join("end_to_end_datalogger_and_structlog_test.copper"),
1852            )
1853            .build()
1854            .expect("Failed to create logger")
1855        else {
1856            panic!("Failed to create logger")
1857        };
1858        let reader = UnifiedLoggerIOReader::new(logger, UnifiedLogType::StructuredLogLine);
1859        let temp_dir = TempDir::new().unwrap();
1860        textlog_dump(
1861            reader,
1862            Path::new(copy_stringindex_to_temp(&temp_dir).as_path()),
1863        )
1864        .expect("Failed to dump log");
1865    }
1866
1867    // This is normally generated at compile time in CuPayload.
1868    #[derive(Debug, PartialEq, Clone, Copy, Serialize, Deserialize, Encode, Decode, Default)]
1869    struct MyMsgs((u8, i32, f32));
1870
1871    impl ErasedCuStampedDataSet for MyMsgs {
1872        fn cumsgs(&self) -> Vec<&dyn ErasedCuStampedData> {
1873            Vec::new()
1874        }
1875    }
1876
1877    impl MatchingTasks for MyMsgs {
1878        fn get_all_task_ids() -> &'static [&'static str] {
1879            &[]
1880        }
1881    }
1882
1883    /// Checks if we can recover the copper lists from a binary representation.
1884    #[test]
1885    fn test_copperlists_dump() {
1886        let mut data = vec![0u8; 10000];
1887        let mypls: [MyMsgs; 4] = [
1888            MyMsgs((1, 2, 3.0)),
1889            MyMsgs((2, 3, 4.0)),
1890            MyMsgs((3, 4, 5.0)),
1891            MyMsgs((4, 5, 6.0)),
1892        ];
1893
1894        let mut offset: usize = 0;
1895        for pl in mypls.iter() {
1896            let cl = CopperList::<MyMsgs>::new(1, *pl);
1897            offset +=
1898                encode_into_slice(&cl, &mut data.as_mut_slice()[offset..], standard()).unwrap();
1899        }
1900
1901        let reader = Cursor::new(data);
1902
1903        let mut iter = copperlists_reader::<MyMsgs>(reader);
1904        assert_eq!(iter.next().unwrap().msgs, MyMsgs((1, 2, 3.0)));
1905        assert_eq!(iter.next().unwrap().msgs, MyMsgs((2, 3, 4.0)));
1906        assert_eq!(iter.next().unwrap().msgs, MyMsgs((3, 4, 5.0)));
1907        assert_eq!(iter.next().unwrap().msgs, MyMsgs((4, 5, 6.0)));
1908    }
1909
1910    #[test]
1911    fn runtime_lifecycle_reader_retains_timestamps_and_events() {
1912        let records = vec![
1913            RuntimeLifecycleRecord {
1914                timestamp: CuTime::default(),
1915                event: RuntimeLifecycleEvent::Instantiated {
1916                    config_source: RuntimeLifecycleConfigSource::BundledDefault,
1917                },
1918            },
1919            RuntimeLifecycleRecord {
1920                timestamp: CuTime::from_nanos(42),
1921                event: RuntimeLifecycleEvent::MissionStarted,
1922            },
1923        ];
1924        let mut bytes = Vec::new();
1925        for record in &records {
1926            bytes.extend(bincode::encode_to_vec(record, standard()).unwrap());
1927        }
1928
1929        assert_eq!(
1930            runtime_lifecycle_reader(Cursor::new(bytes)).collect::<Vec<_>>(),
1931            records
1932        );
1933    }
1934
1935    #[test]
1936    fn logstats_mission_defaults_to_log_then_named_default_then_first() {
1937        let config = CuConfig::deserialize_ron(
1938            r#"(
1939                missions: [(id: "zeta"), (id: "default"), (id: "alpha")],
1940                tasks: [],
1941                cnx: [],
1942            )"#,
1943        )
1944        .unwrap();
1945        assert_eq!(
1946            resolve_logstats_mission(&config, None, Some("zeta".to_string()))
1947                .unwrap()
1948                .as_deref(),
1949            Some("zeta")
1950        );
1951        assert!(resolve_logstats_mission(&config, None, Some("missing".to_string())).is_err());
1952        assert_eq!(
1953            resolve_logstats_mission(&config, Some("alpha".to_string()), None)
1954                .unwrap()
1955                .as_deref(),
1956            Some("alpha")
1957        );
1958
1959        let config = CuConfig::deserialize_ron(
1960            r#"(
1961                missions: [(id: "zeta"), (id: "alpha")],
1962                tasks: [],
1963                cnx: [],
1964            )"#,
1965        )
1966        .unwrap();
1967        assert_eq!(
1968            resolve_logstats_mission(&config, None, None)
1969                .unwrap()
1970                .as_deref(),
1971            Some("alpha")
1972        );
1973    }
1974
1975    #[test]
1976    fn schedule_optimizer_cli_is_explicit_and_measurements_are_validated() {
1977        let args = LogReaderCli::try_parse_from([
1978            "logreader",
1979            "robot.copper",
1980            "optimize-schedule",
1981            "--contract",
1982            "schedule.ron",
1983            "--output",
1984            "target/pgs",
1985            "--candidates",
1986            "2",
1987            "--measure",
1988            "plan-1=logs/plan-1.copper",
1989        ])
1990        .unwrap();
1991        let Command::OptimizeSchedule {
1992            contract,
1993            output,
1994            candidates,
1995            measurements,
1996        } = args.command
1997        else {
1998            panic!("expected optimize-schedule command");
1999        };
2000        assert_eq!(contract, PathBuf::from("schedule.ron"));
2001        assert_eq!(output, PathBuf::from("target/pgs"));
2002        assert_eq!(candidates, 2);
2003        assert_eq!(measurements, ["plan-1=logs/plan-1.copper"]);
2004        assert_eq!(
2005            parse_measurement(&measurements[0]).unwrap(),
2006            ("plan-1", Path::new("logs/plan-1.copper"))
2007        );
2008        assert!(parse_measurement("plan-1").is_err());
2009        assert!(parse_measurement("baseline=other.copper").is_err());
2010        assert_eq!(candidate_index("plan-2/round-3"), Some(1));
2011        assert_eq!(candidate_index("baseline"), None);
2012
2013        let config = CuConfig::deserialize_ron(
2014            r#"(
2015                missions: [(id: "drive"), (id: "park")],
2016                tasks: [],
2017                cnx: [],
2018            )"#,
2019        )
2020        .unwrap();
2021        assert!(resolve_schedule_mission(&config, &["drive".into(), "park".into()]).is_err());
2022        assert_eq!(
2023            resolve_schedule_mission(&config, &["park".into()])
2024                .unwrap()
2025                .as_deref(),
2026            Some("park")
2027        );
2028    }
2029}