Skip to content
32 changes: 30 additions & 2 deletions rust/mcap_cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,9 +91,37 @@ pub struct GetCommand {
#[derive(Subcommand, Debug, PartialEq, Eq)]
pub enum GetSubcommand {
/// Get an attachment by name or offset
Attachment,
Attachment(GetAttachmentCommand),
/// Get metadata by name
Metadata,
Metadata(GetMetadataCommand),
}

#[derive(clap::Args, Debug, PartialEq, Eq)]
pub struct GetAttachmentCommand {
/// Local path to the MCAP file
pub file: PathBuf,

/// Name of attachment to extract
#[arg(short = 'n', long = "name")]
pub name: String,

/// Offset of attachment to extract
#[arg(long = "offset")]
pub offset: Option<u64>,

/// Location to write attachment bytes
#[arg(short = 'o', long = "output")]
pub output: Option<PathBuf>,
}

#[derive(clap::Args, Debug, PartialEq, Eq)]
pub struct GetMetadataCommand {
/// Local path to the MCAP file
pub file: PathBuf,

/// Name of metadata record to get
#[arg(short = 'n', long = "name")]
pub name: String,
}

#[derive(clap::Args, Debug, PartialEq, Eq)]
Expand Down
39 changes: 34 additions & 5 deletions rust/mcap_cli/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,8 @@ pub fn dispatch(ctx: &CommandContext, command: Command) -> Result<()> {
AddSubcommand::Metadata => add_metadata::run(ctx),
},
Command::Get(args) => match args.command {
GetSubcommand::Attachment => get_attachment::run(ctx),
GetSubcommand::Metadata => get_metadata::run(ctx),
GetSubcommand::Attachment(args) => get_attachment::run(ctx, args),
GetSubcommand::Metadata(args) => get_metadata::run(ctx, args),
},
Command::List(args) => match args.command {
ListSubcommand::Attachments(args) => list_attachments::run(ctx, args),
Expand Down Expand Up @@ -70,9 +70,9 @@ mod tests {

use super::dispatch;
use crate::cli::{
AddCommand, AddSubcommand, Command, InfoCommand, ListAttachmentsCommand,
ListChannelsCommand, ListChunksCommand, ListCommand, ListMetadataCommand,
ListSchemasCommand, ListSubcommand,
AddCommand, AddSubcommand, Command, GetAttachmentCommand, GetMetadataCommand, InfoCommand,
ListAttachmentsCommand, ListChannelsCommand, ListChunksCommand, ListCommand,
ListMetadataCommand, ListSchemasCommand, ListSubcommand,
};
use crate::context::CommandContext;

Expand Down Expand Up @@ -114,6 +114,35 @@ mod tests {
assert_eq!(err.to_string(), "'add attachment' is not implemented yet");
}

#[test]
fn get_subcommands_require_existing_file() {
let attachment_err = dispatch(
&CommandContext::default(),
Command::Get(crate::cli::GetCommand {
command: crate::cli::GetSubcommand::Attachment(GetAttachmentCommand {
file: PathBuf::from("does-not-exist.mcap"),
name: "attachment.bin".to_string(),
offset: None,
output: None,
}),
}),
)
.expect_err("get attachment should fail on missing file");
assert!(attachment_err.to_string().contains("couldn't open"));

let metadata_err = dispatch(
&CommandContext::default(),
Command::Get(crate::cli::GetCommand {
command: crate::cli::GetSubcommand::Metadata(GetMetadataCommand {
file: PathBuf::from("does-not-exist.mcap"),
name: "demo".to_string(),
}),
}),
)
.expect_err("get metadata should fail on missing file");
assert!(metadata_err.to_string().contains("couldn't open"));
}
Comment thread
claude[bot] marked this conversation as resolved.

#[test]
fn list_all_subcommands_are_wired() {
let ctx = CommandContext::default();
Expand Down
139 changes: 135 additions & 4 deletions rust/mcap_cli/src/commands/get_attachment.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,139 @@
use anyhow::Result;
use std::io::IsTerminal as _;
use std::io::Write as _;

use crate::commands::not_implemented;
use anyhow::{Context, Result};

use crate::cli::GetAttachmentCommand;
use crate::commands::common;
use crate::context::CommandContext;

pub fn run(_ctx: &CommandContext) -> Result<()> {
Err(not_implemented("get attachment"))
const PLEASE_REDIRECT: &str =
"Binary output can screw up your terminal. Supply -o or redirect to a file or pipe";

pub fn run(_ctx: &CommandContext, args: GetAttachmentCommand) -> Result<()> {
let mcap = common::map_file(&args.file)?;
let parsed = common::parse_mcap(&mcap)?;
let index = select_attachment_index(&parsed.attachment_indexes, &args.name, args.offset)?;
let attachment = mcap::read::attachment(&mcap, index).with_context(|| {
format!(
"failed to read attachment {} at offset {}",
args.name, index.offset
)
})?;

if let Some(output) = args.output {
std::fs::write(&output, &attachment.data)
.with_context(|| format!("failed to write attachment to '{}'", output.display()))?;
} else if std::io::stdout().is_terminal() {
anyhow::bail!("{PLEASE_REDIRECT}");
} else {
std::io::stdout()
.write_all(&attachment.data)
.context("failed to write attachment to stdout")?;
}

Ok(())
}

fn select_attachment_index<'a>(
indexes: &'a [mcap::records::AttachmentIndex],
name: &str,
offset: Option<u64>,
) -> Result<&'a mcap::records::AttachmentIndex> {
let matches: Vec<&mcap::records::AttachmentIndex> =
indexes.iter().filter(|index| index.name == name).collect();

match matches.len() {
0 => anyhow::bail!("attachment {name} not found"),
1 => {
let first_match = matches[0];
if let Some(offset) = offset {
if first_match.offset != offset {
anyhow::bail!("failed to find attachment {name} at offset {offset}");
}
}
Ok(first_match)
Comment on lines +48 to +55

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: the Rust CLI now validates --offset even when there's only one matching attachment (lines 50-53), which is stricter than the Go CLI (go/cli/mcap/cmd/attachment.go:97-100 — Go silently returns the single match regardless of --offset). I think the Rust behavior is better, but worth noting the divergence. If you want parity, the Go side could get the same fix in a follow-up.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rust behavior is better

}
_ => {
let offset = offset.ok_or_else(|| {
anyhow::anyhow!("multiple attachments named {name} exist (specify an offset)")
})?;

matches
.into_iter()
.find(|index| index.offset == offset)
.ok_or_else(|| {
anyhow::anyhow!("failed to find attachment {name} at offset {offset}")
})
}
}
}

#[cfg(test)]
mod tests {
use super::select_attachment_index;
use mcap::records::AttachmentIndex;

fn attachment(name: &str, offset: u64) -> AttachmentIndex {
AttachmentIndex {
offset,
length: 1,
log_time: 0,
create_time: 0,
data_size: 1,
name: name.to_string(),
media_type: "application/octet-stream".to_string(),
}
}

#[test]
fn selects_single_match_without_offset() {
let indexes = vec![attachment("a", 10)];
let selected =
select_attachment_index(&indexes, "a", None).expect("attachment should resolve");
assert_eq!(selected.offset, 10);
}

#[test]
fn errors_when_name_not_found() {
let indexes = vec![attachment("a", 10)];
let err = select_attachment_index(&indexes, "b", None)
.expect_err("missing attachment should error");
assert_eq!(err.to_string(), "attachment b not found");
}

#[test]
fn errors_when_duplicate_without_offset() {
let indexes = vec![attachment("a", 10), attachment("a", 20)];
let err = select_attachment_index(&indexes, "a", None)
.expect_err("duplicate attachments need offset");
assert_eq!(
err.to_string(),
"multiple attachments named a exist (specify an offset)"
);
}

#[test]
fn resolves_duplicate_with_matching_offset() {
let indexes = vec![attachment("a", 10), attachment("a", 20)];
let selected =
select_attachment_index(&indexes, "a", Some(20)).expect("offset should disambiguate");
assert_eq!(selected.offset, 20);
}

#[test]
fn errors_when_duplicate_offset_missing() {
let indexes = vec![attachment("a", 10), attachment("a", 20)];
let err = select_attachment_index(&indexes, "a", Some(999))
.expect_err("unknown offset should error");
assert_eq!(err.to_string(), "failed to find attachment a at offset 999");
}

#[test]
fn errors_when_single_match_has_different_offset() {
let indexes = vec![attachment("a", 10)];
let err = select_attachment_index(&indexes, "a", Some(999))
.expect_err("single record should enforce provided offset");
assert_eq!(err.to_string(), "failed to find attachment a at offset 999");
}
}
111 changes: 107 additions & 4 deletions rust/mcap_cli/src/commands/get_metadata.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,111 @@
use anyhow::Result;
use std::collections::BTreeMap;

use crate::commands::not_implemented;
use anyhow::{Context, Result};

use crate::cli::GetMetadataCommand;
use crate::commands::common;
use crate::context::CommandContext;

pub fn run(_ctx: &CommandContext) -> Result<()> {
Err(not_implemented("get metadata"))
pub fn run(_ctx: &CommandContext, args: GetMetadataCommand) -> Result<()> {
let mcap = common::map_file(&args.file)?;
let parsed = common::parse_mcap(&mcap)?;
let metadata = merged_metadata_for_name(&mcap, &parsed.metadata_indexes, &args.name)?;
let pretty =
serde_json::to_string_pretty(&metadata).context("failed to serialize metadata to JSON")?;
println!("{pretty}");
Ok(())
}

fn merged_metadata_for_name(
mcap: &[u8],
indexes: &[mcap::records::MetadataIndex],
name: &str,
) -> Result<BTreeMap<String, String>> {
let mut matching_indexes: Vec<&mcap::records::MetadataIndex> =
indexes.iter().filter(|index| index.name == name).collect();
if matching_indexes.is_empty() {
anyhow::bail!("metadata {name} does not exist");
}
matching_indexes.sort_by_key(|index| index.offset);

let mut output = BTreeMap::new();
for index in matching_indexes {
let record = mcap::read::metadata(mcap, index)
.with_context(|| format!("failed to read metadata at offset {}", index.offset))?;
for (key, value) in record.metadata {
output.insert(key, value);
}
}
Ok(output)
}

#[cfg(test)]
mod tests {
use std::collections::BTreeMap;

use mcap::records::MetadataIndex;

use super::merged_metadata_for_name;

fn metadata_index(name: &str, offset: u64, length: u64) -> MetadataIndex {
MetadataIndex {
offset,
length,
name: name.to_string(),
}
}

#[test]
fn errors_when_metadata_name_missing() {
let err = merged_metadata_for_name(&[], &[metadata_index("demo", 0, 0)], "other")
.expect_err("missing metadata should fail");
assert_eq!(err.to_string(), "metadata other does not exist");
}

#[test]
fn merges_metadata_records_by_offset_order() {
let mut mcap_bytes = Vec::new();
let (first, second) = {
let mut writer = mcap::WriteOptions::new()
.emit_metadata_indexes(true)
.emit_summary_records(true)
.emit_summary_offsets(true)
.create(std::io::Cursor::new(&mut mcap_bytes))
.expect("writer");
writer
.write_metadata(&mcap::records::Metadata {
name: "config".to_string(),
metadata: BTreeMap::from([
("a".to_string(), "1".to_string()),
("b".to_string(), "1".to_string()),
]),
})
.expect("first metadata");
writer
.write_metadata(&mcap::records::Metadata {
name: "config".to_string(),
metadata: BTreeMap::from([
("b".to_string(), "2".to_string()),
("c".to_string(), "3".to_string()),
]),
})
.expect("second metadata");
let summary = writer.finish().expect("finish");
let mut indexes: Vec<MetadataIndex> = summary.metadata_indexes;
indexes.sort_by_key(|index| index.offset);
(indexes[0].clone(), indexes[1].clone())
};

let latest =
merged_metadata_for_name(&mcap_bytes, &[second.clone(), first.clone()], "config")
.expect("metadata should merge");
assert_eq!(
latest,
BTreeMap::from([
("a".to_string(), "1".to_string()),
("b".to_string(), "2".to_string()),
("c".to_string(), "3".to_string()),
])
);
Comment thread
claude[bot] marked this conversation as resolved.
}
}
Loading
Loading