use std::cmp::Ordering;
use std::collections::BTreeMap;
use std::path::PathBuf;
use anyhow::Context as _;
use parking_lot::Mutex;
use re_log_types::{TimeType, TimelineName};
use re_mcap::decoders::{DecoderRegistry, TopicFilter};
use super::info::print_table;
#[derive(Debug, Clone, clap::Parser)]
pub struct CheckCommand {
path: PathBuf,
#[clap(long)]
full: bool,
#[clap(long)]
recover: bool,
}
impl CheckCommand {
pub fn run(&self) -> anyhow::Result<()> {
let Self {
path,
full,
recover,
} = self;
let file = std::fs::File::open(path)
.with_context(|| format!("Failed to open MCAP file\nFile path: {}", path.display()))?;
#[expect(unsafe_code)]
let mmap = unsafe { memmap2::Mmap::map(&file) }.with_context(|| {
format!(
"Failed to memory-map MCAP file\nFile path: {}",
path.display()
)
})?;
let mcap_file = re_mcap::McapFile::new(mmap, *recover);
let summary = mcap_file.summary().with_context(|| {
format!("Failed to inspect MCAP file\nFile path: {}", path.display())
})?;
let by_topic = if *full {
collect_by_topic_full(mcap_file.bytes(), &summary)?
} else {
collect_by_topic_raw(mcap_file.bytes(), &summary)?
};
println!("{}", path.display());
print_timeline_checks(&by_topic, &summary, *full);
Ok(())
}
}
fn print_timeline_checks(by_topic: &ByTopic, summary: &mcap::Summary, full: bool) {
let timeline_names = timeline_names(by_topic);
let channel_id_by_topic: BTreeMap<&str, u16> = summary
.channels
.iter()
.map(|(id, channel)| (channel.topic.as_str(), *id))
.collect();
let mut rows = Vec::new();
for (topic, chunks) in by_topic {
let num_conflicting_chunks = chunks
.iter()
.filter(|times| !times.timelines_agree())
.count();
let mut whole_topic = TimeColumns::default();
for times in chunks {
whole_topic.append(times);
}
let mut issues = Vec::new();
if num_conflicting_chunks > 0 {
issues.push(format!(
"{num_conflicting_chunks} chunk row-order conflicts"
));
}
if !whole_topic.timelines_agree() {
issues.push("whole-topic row-order conflict".to_owned());
}
for (timeline, count) in unordered_chunk_counts(chunks, &timeline_names) {
if count > 0 {
issues.push(format!("{count} unordered chunks on {timeline}"));
}
}
rows.push(vec![
if issues.is_empty() { "ok" } else { "PROBLEM" }.to_owned(),
channel_id_by_topic
.get(topic.as_str())
.map_or_else(|| "?".to_owned(), u16::to_string),
chunks.len().to_string(),
topic.clone(),
if issues.is_empty() {
"—".to_owned()
} else {
issues.join(", ")
},
]);
}
rows.sort_by_key(|row| row[1].parse::<u16>().unwrap_or(u16::MAX));
println!();
println!(
"Timelines: {}",
timeline_names
.iter()
.map(TimelineName::to_string)
.collect::<Vec<_>>()
.join(", ")
);
if !full {
println!("Run with --full to decode all timelines.");
}
println!();
print_table(
&["Status", "ID", "Chunks", "Topic", "Issues"],
&rows,
&[1, 2],
);
}
#[derive(Default)]
struct TimeColumns {
columns: BTreeMap<TimelineName, Vec<i64>>,
}
impl TimeColumns {
fn push_pairs(&mut self, pairs: impl IntoIterator<Item = (TimelineName, i64)>) {
for (name, v) in pairs {
self.columns.entry(name).or_default().push(v);
}
}
fn append(&mut self, other: &Self) {
for (k, vs) in &other.columns {
self.columns.entry(*k).or_default().extend_from_slice(vs);
}
}
fn len(&self) -> usize {
self.columns.values().next().map_or(0, Vec::len)
}
fn sorted_permutation(&self) -> Vec<usize> {
let count = self.len();
let cols: Vec<&Vec<i64>> = self.columns.values().collect();
let mut perm: Vec<usize> = (0..count).collect();
perm.sort_by(|&a, &b| {
for col in &cols {
let ord = col[a].cmp(&col[b]);
if ord != Ordering::Equal {
return ord;
}
}
Ordering::Equal
});
perm
}
fn timelines_agree(&self) -> bool {
if self.len() < 2 {
return true;
}
let perm = self.sorted_permutation();
self.columns
.values()
.all(|col| perm.windows(2).all(|w| col[w[0]] <= col[w[1]]))
}
}
type ByTopic = BTreeMap<String, Vec<TimeColumns>>;
fn collect_by_topic_raw(bytes: &[u8], summary: &mcap::Summary) -> anyhow::Result<ByTopic> {
let mut by_topic_chunk: BTreeMap<String, BTreeMap<usize, TimeColumns>> = BTreeMap::new();
for (mcap_idx, chunk) in summary.chunk_indexes.iter().enumerate() {
for msg in summary.stream_chunk(bytes, chunk)? {
let msg = msg?;
by_topic_chunk
.entry(msg.channel.topic.clone())
.or_default()
.entry(mcap_idx)
.or_default()
.push_pairs([
(
TimelineName::from("message_log_time"),
msg.log_time.cast_signed(),
),
(
TimelineName::from("message_publish_time"),
msg.publish_time.cast_signed(),
),
]);
}
}
Ok(by_topic_chunk
.into_iter()
.map(|(topic, chunks)| (topic, chunks.into_values().collect()))
.collect())
}
fn collect_by_topic_full(bytes: &[u8], summary: &mcap::Summary) -> anyhow::Result<ByTopic> {
let plan =
DecoderRegistry::all_with_raw_fallback().plan(bytes, summary, &TopicFilter::default())?;
let chunks: Mutex<Vec<re_chunk::Chunk>> = Mutex::new(Vec::new());
plan.run(bytes, summary, TimeType::TimestampNs, &|chunk| {
chunks.lock().push(chunk);
})?;
let chunks = chunks.into_inner();
let mut by_topic: ByTopic = BTreeMap::new();
for chunk in chunks {
if chunk.timelines().is_empty() {
continue;
}
let topic = chunk.entity_path().to_string();
let mut times = TimeColumns::default();
for (name, time_col) in chunk.timelines() {
let column = times.columns.entry(*name).or_default();
column.extend_from_slice(time_col.times_raw());
}
by_topic.entry(topic).or_default().push(times);
}
Ok(by_topic)
}
fn unordered_chunk_counts(
chunks: &[TimeColumns],
timelines: &[TimelineName],
) -> Vec<(TimelineName, usize)> {
timelines
.iter()
.map(|tl| {
let mut prev_max: Option<i64> = None;
let mut unordered = 0usize;
for tc in chunks {
let Some(col) = tc.columns.get(tl) else {
continue;
};
let Some(&min) = col.iter().min() else {
continue;
};
let max = *col.iter().max().expect("col non-empty");
if let Some(p) = prev_max
&& min < p
{
unordered += 1;
}
prev_max = Some(prev_max.map_or(max, |p| p.max(max)));
}
(*tl, unordered)
})
.collect()
}
fn timeline_names(by_topic: &ByTopic) -> Vec<TimelineName> {
let mut names: std::collections::BTreeSet<TimelineName> = std::collections::BTreeSet::new();
for chunks in by_topic.values() {
for tc in chunks {
names.extend(tc.columns.keys().copied());
}
}
names.into_iter().collect()
}