use std::io::Write;
use std::path::{Path, PathBuf};
use crate::application::artifact_store::{FileDestination, OptionalFileDestination};
use anyhow::{bail, Context, Result};
use clap::Args;
use super::{detect, print_json, read_input, Artifact};
#[derive(Debug, Args)]
#[command(mut_arg("output", |arg| arg.value_name("FILE").requires("stream")),
mut_arg("force", |arg| arg.requires("output")))]
pub(crate) struct FidelityArgs {
pub(super) file: PathBuf,
#[arg(long, value_name = "NAME")]
stream: Option<String>,
#[command(flatten)]
output: OptionalFileDestination,
#[arg(long, requires = "stream")]
binary_stdout: bool,
#[arg(long, conflicts_with = "stream")]
json: bool,
}
impl FidelityArgs {
pub(super) fn mode(&self) -> Result<FidelityMode<'_>> {
let Some(stream) = self.stream.as_deref() else {
return Ok(if self.json {
FidelityMode::Json
} else {
FidelityMode::Table
});
};
let sink = if let Some(file) = self.output.0.as_ref() {
Sink::File(file)
} else if self.binary_stdout {
Sink::Stdout
} else {
bail!(
"retained stream bytes are binary output that a terminal cannot read; \
pass `-o FILE` or `--binary-stdout` to stream the bytes anyway"
);
};
Ok(FidelityMode::Extract { stream, sink })
}
}
#[derive(Debug, Clone, Copy)]
pub(in crate::query) enum FidelityMode<'a> {
Table,
Json,
Extract { stream: &'a str, sink: Sink<'a> },
}
#[derive(Debug, Clone, Copy)]
pub(in crate::query) enum Sink<'a> {
File(&'a FileDestination),
Stdout,
}
pub(super) fn run(file: &Path, mode: FidelityMode<'_>) -> Result<()> {
let bytes = read_input(file)?;
match detect(&bytes, file)? {
Artifact::Sidecar(_) => {}
Artifact::Cadir(_) => bail!(
"{} is a CADIR document; the fidelity payload lives in the \
`<stem>.fidelity.json` decode sidecar `cadmpeg dump` writes \
next to it",
file.display()
),
Artifact::Report(_) => bail!(
"{} is a command report; the fidelity payload lives in the \
`<stem>.fidelity.json` decode sidecar `cadmpeg dump` writes",
file.display()
),
}
let text = std::str::from_utf8(&bytes)
.with_context(|| format!("reading the decode sidecar {} as UTF-8", file.display()))?;
let sidecar = cadmpeg_ir::DecodeSidecar::from_json(text)
.with_context(|| format!("validating the decode sidecar {}", file.display()))?;
let payload = &sidecar.fidelity;
match mode {
FidelityMode::Extract { stream, sink } => extract(file, payload, stream, sink),
FidelityMode::Json => {
let records: Vec<serde_json::Value> = payload
.retained_records()
.iter()
.map(|(id, record)| {
serde_json::json!({
"id": id,
"stream": record.stream(),
"offset": record.offset(),
"byte_len": record.byte_len(),
"data_retained": record.data().is_some(),
})
})
.collect();
let value = serde_json::json!({
"annotations": {
"streams": payload.annotations.stream_count(),
"provenance": payload.annotations.provenance.len(),
"exactness": payload.annotations.exactness().len(),
},
"retained_records": records,
});
print_json("fidelity", value)?;
Ok(())
}
FidelityMode::Table => {
println!("stream\toffset\tbytes\tdata\tid");
for (id, record) in payload.retained_records() {
println!(
"{}\t{}\t{}\t{}\t{}",
super::cell(record.stream()),
record.offset(),
record.byte_len(),
if record.data().is_some() { "yes" } else { "no" },
super::cell(id.as_str()),
);
}
if payload.retained_records().is_empty() {
eprintln!("(this sidecar retains no source records)");
}
eprintln!(
"annotations: {} streams, {} provenance entries, {} exactness \
notes; extract retained bytes with `cadmpeg query fidelity \
FILE --stream NAME -o OUT`",
payload.annotations.stream_count(),
payload.annotations.provenance.len(),
payload.annotations.exactness().len(),
);
Ok(())
}
}
}
fn extract(
input: &Path,
payload: &cadmpeg_ir::SourceFidelity,
stream: &str,
sink: Sink<'_>,
) -> Result<()> {
const SHOWN: usize = 20;
let selected: Vec<(
&cadmpeg_ir::ids::UnknownId,
&cadmpeg_ir::RetainedSourceRecord,
)> = payload
.retained_records()
.iter()
.filter(|(_, record)| record.stream() == stream)
.collect();
if selected.is_empty() {
if payload.retained_records().is_empty() {
bail!("this sidecar retains no source records");
}
let mut streams: Vec<&str> = payload
.retained_records()
.values()
.map(cadmpeg_ir::RetainedSourceRecord::stream)
.collect();
streams.sort_unstable();
streams.dedup();
let shown: Vec<&str> = streams.iter().take(SHOWN).copied().collect();
bail!(
"no retained record has stream {stream:?}; retained streams: {}{}",
shown.join(", "),
if streams.len() > SHOWN { ", …" } else { "" }
);
}
let mut matched = Vec::new();
let mut missing = Vec::new();
for (id, record) in selected {
match record.data() {
Some(data) => matched.push((id, record, data)),
None => missing.push(id.as_str()),
}
}
if !missing.is_empty() {
bail!(
"stream {stream:?} is retained without bytes (extent and digest \
only) for: {}",
missing.join(", ")
);
}
matched.sort_by_key(|(_, record, _)| record.offset());
let mut assembled: Vec<u8> = Vec::new();
let mut expected_offset: Option<u64> = None;
for (_, record, data) in &matched {
if let Some(expected) = expected_offset {
if record.offset() != expected {
let extents: Vec<String> = matched
.iter()
.map(|(id, record, _)| {
format!("{}+{} ({id})", record.offset(), record.byte_len())
})
.collect();
bail!(
"the retained extents of stream {stream:?} are not \
contiguous: {}; extract records individually (their \
`data` fields are base64)",
extents.join(", ")
);
}
}
expected_offset = Some(record.end_offset());
assembled.extend_from_slice(data);
}
match sink {
Sink::File(destination) => {
destination.write(input, &assembled)?;
eprintln!(
"wrote {} bytes from {} record(s) of stream {stream:?} to {}",
assembled.len(),
matched.len(),
destination.path.display()
);
Ok(())
}
Sink::Stdout => std::io::stdout()
.write_all(&assembled)
.context("writing extracted bytes to stdout"),
}
}