use std::path::{Path, PathBuf};
use ciborium::Value as CborValue;
use netcdf::AttributeValue;
use tensogram::types::{ByteOrder, DataObjectDescriptor, GlobalMetadata};
use tensogram::{DecodeOptions, Dtype, decode};
use crate::error::NetcdfError;
const INTERNAL_KEYS: &[&str] = &[
"_dims",
"_file",
"_global",
"_attr_types",
"_global_types",
"record_index",
];
type DecodedMessage = (GlobalMetadata, Vec<(DataObjectDescriptor, Vec<u8>)>);
pub fn to_netcdf(message: &[u8], out_path: &Path) -> Result<(), NetcdfError> {
to_netcdf_messages(std::slice::from_ref(&message), out_path)
}
pub fn to_netcdf_messages(messages: &[&[u8]], out_path: &Path) -> Result<(), NetcdfError> {
if messages.is_empty() {
return Err(NetcdfError::InvalidData(
"to-netcdf: no messages to write".into(),
));
}
let decoded: Vec<DecodedMessage> = messages
.iter()
.map(|m| {
decode(m, &DecodeOptions::default())
.map_err(|e| NetcdfError::InvalidData(format!("decode tensogram message: {e}")))
})
.collect::<Result<_, _>>()?;
let tmp = temp_output_path(out_path);
match write_messages(&decoded, &tmp) {
Ok(()) => std::fs::rename(&tmp, out_path).map_err(|e| {
let _ = std::fs::remove_file(&tmp);
NetcdfError::Io(e)
}),
Err(e) => {
let _ = std::fs::remove_file(&tmp);
Err(e)
}
}
}
fn write_messages(decoded: &[DecodedMessage], path: &Path) -> Result<(), NetcdfError> {
let mut file = netcdf::create(path)?;
for (name, len, unlimited) in collect_dims(decoded)? {
if unlimited {
file.add_unlimited_dimension(&name)?;
} else {
file.add_dimension(&name, len)?;
}
}
if let Some((meta0, _)) = decoded.first() {
for (name, av) in global_attrs(meta0) {
file.add_attribute(&name, av)?;
}
}
for (meta, objects) in decoded {
for (i, (desc, payload)) in objects.iter().enumerate() {
let name = var_name(meta, i)?;
let dim_names = var_dim_names(meta, i)?;
let dim_refs: Vec<&str> = dim_names.iter().map(String::as_str).collect();
let attrs = var_attrs(meta, i);
add_variable(
&mut file,
&name,
&dim_refs,
desc.dtype,
desc.byte_order,
payload,
&attrs,
)?;
}
}
Ok(())
}
fn collect_dims(decoded: &[DecodedMessage]) -> Result<Vec<(String, usize, bool)>, NetcdfError> {
let mut out: Vec<(String, usize, bool)> = Vec::new();
for (meta, _) in decoded {
for (name, len, unlimited) in file_dims(meta)? {
if let Some((_, elen, eunlim)) = out.iter().find(|(n, _, _)| *n == name) {
if *elen != len || *eunlim != unlimited {
return Err(NetcdfError::InvalidData(format!(
"dimension '{name}' has conflicting definitions across messages"
)));
}
} else {
out.push((name, len, unlimited));
}
}
}
Ok(out)
}
fn temp_output_path(out_path: &Path) -> PathBuf {
use std::sync::atomic::{AtomicU64, Ordering};
static CTR: AtomicU64 = AtomicU64::new(0);
let n = CTR.fetch_add(1, Ordering::Relaxed);
let pid = std::process::id();
let stem = out_path
.file_name()
.map(|f| f.to_string_lossy().into_owned())
.unwrap_or_else(|| "out.nc".to_string());
let tmp_name = format!(".{stem}.tmp.{pid}.{n}");
match out_path.parent() {
Some(dir) if !dir.as_os_str().is_empty() => dir.join(tmp_name),
_ => PathBuf::from(tmp_name),
}
}
fn add_variable(
file: &mut netcdf::FileMut,
name: &str,
dims: &[&str],
dtype: Dtype,
order: ByteOrder,
payload: &[u8],
attrs: &[(String, AttributeValue)],
) -> Result<(), NetcdfError> {
macro_rules! arm {
($t:ty) => {{
const SZ: usize = std::mem::size_of::<$t>();
let mut vals: Vec<$t> = Vec::with_capacity(payload.len() / SZ);
for c in payload.chunks_exact(SZ) {
let mut b = [0u8; SZ];
b.copy_from_slice(c);
vals.push(match order {
ByteOrder::Little => <$t>::from_le_bytes(b),
ByteOrder::Big => <$t>::from_be_bytes(b),
});
}
let mut var = file.add_variable::<$t>(name, dims)?;
for (an, av) in attrs {
var.put_attribute(an, av.clone())?;
}
var.put_values(&vals, ..)?;
}};
}
match dtype {
Dtype::Int8 => arm!(i8),
Dtype::Uint8 => arm!(u8),
Dtype::Int16 => arm!(i16),
Dtype::Uint16 => arm!(u16),
Dtype::Int32 => arm!(i32),
Dtype::Uint32 => arm!(u32),
Dtype::Int64 => arm!(i64),
Dtype::Uint64 => arm!(u64),
Dtype::Float32 => arm!(f32),
Dtype::Float64 => arm!(f64),
other => {
return Err(NetcdfError::InvalidData(format!(
"to-netcdf: unsupported dtype {other} for variable '{name}'"
)));
}
}
Ok(())
}
fn map_get<'a>(map: &'a [(CborValue, CborValue)], key: &str) -> Option<&'a CborValue> {
map.iter()
.find(|(k, _)| matches!(k, CborValue::Text(s) if s == key))
.map(|(_, v)| v)
}
fn netcdf_map(meta: &GlobalMetadata, i: usize) -> Option<&Vec<(CborValue, CborValue)>> {
let entry = meta.base.get(i)?;
match entry.get("netcdf") {
Some(CborValue::Map(m)) => Some(m),
_ => None,
}
}
fn file_dims(meta: &GlobalMetadata) -> Result<Vec<(String, usize, bool)>, NetcdfError> {
let nc = netcdf_map(meta, 0)
.ok_or_else(|| NetcdfError::InvalidData("no netcdf metadata on object 0".into()))?;
let Some(CborValue::Map(fm)) = map_get(nc, "_file") else {
return Err(NetcdfError::InvalidData(
"missing '_file' dimension registry; was this produced by convert-netcdf?".into(),
));
};
let Some(CborValue::Array(dims)) = map_get(fm, "dims") else {
return Err(NetcdfError::InvalidData(
"'_file' has no 'dims' array".into(),
));
};
let mut out = Vec::with_capacity(dims.len());
for d in dims {
let CborValue::Map(dm) = d else { continue };
let name = match map_get(dm, "name") {
Some(CborValue::Text(s)) => s.clone(),
_ => continue,
};
let len = match map_get(dm, "len") {
Some(CborValue::Integer(i)) => usize::try_from(i128::from(*i)).unwrap_or(0),
_ => 0,
};
let unlimited = matches!(map_get(dm, "unlimited"), Some(CborValue::Bool(true)));
out.push((name, len, unlimited));
}
Ok(out)
}
fn var_name(meta: &GlobalMetadata, i: usize) -> Result<String, NetcdfError> {
match meta.base.get(i).and_then(|e| e.get("name")) {
Some(CborValue::Text(s)) => Ok(s.clone()),
_ => Err(NetcdfError::InvalidData(format!(
"object {i} has no variable 'name'"
))),
}
}
fn var_dim_names(meta: &GlobalMetadata, i: usize) -> Result<Vec<String>, NetcdfError> {
let nc = netcdf_map(meta, i)
.ok_or_else(|| NetcdfError::InvalidData(format!("object {i} has no netcdf metadata")))?;
let Some(CborValue::Array(dims)) = map_get(nc, "_dims") else {
return Err(NetcdfError::InvalidData(format!(
"object {i} has no '_dims' list"
)));
};
Ok(dims
.iter()
.filter_map(|d| match d {
CborValue::Text(s) => Some(s.clone()),
_ => None,
})
.collect())
}
fn type_tags<'a>(
map: &'a [(CborValue, CborValue)],
key: &str,
) -> Option<&'a Vec<(CborValue, CborValue)>> {
match map_get(map, key) {
Some(CborValue::Map(m)) => Some(m),
_ => None,
}
}
fn tag_for<'a>(tags: Option<&'a Vec<(CborValue, CborValue)>>, name: &str) -> Option<&'a str> {
let tags = tags?;
match map_get(tags, name) {
Some(CborValue::Text(s)) => Some(s.as_str()),
_ => None,
}
}
fn var_attrs(meta: &GlobalMetadata, i: usize) -> Vec<(String, AttributeValue)> {
let Some(nc) = netcdf_map(meta, i) else {
return Vec::new();
};
let tags = type_tags(nc, "_attr_types");
nc.iter()
.filter_map(|(k, v)| match k {
CborValue::Text(name) if !INTERNAL_KEYS.contains(&name.as_str()) => {
cbor_to_attr_typed(v, tag_for(tags, name)).map(|av| (name.clone(), av))
}
_ => None,
})
.collect()
}
fn global_attrs(meta: &GlobalMetadata) -> Vec<(String, AttributeValue)> {
let Some(nc) = netcdf_map(meta, 0) else {
return Vec::new();
};
let tags = type_tags(nc, "_global_types");
match map_get(nc, "_global") {
Some(CborValue::Map(g)) => g
.iter()
.filter_map(|(k, v)| match k {
CborValue::Text(name) => {
cbor_to_attr_typed(v, tag_for(tags, name)).map(|av| (name.clone(), av))
}
_ => None,
})
.collect(),
_ => Vec::new(),
}
}
fn as_f64(v: &CborValue) -> Option<f64> {
match v {
CborValue::Float(f) => Some(*f),
CborValue::Integer(i) => Some(i128::from(*i) as f64),
_ => None,
}
}
fn as_i64(v: &CborValue) -> Option<i64> {
match v {
CborValue::Integer(i) => i64::try_from(*i).ok(),
CborValue::Float(f) => Some(*f as i64),
_ => None,
}
}
fn as_u64(v: &CborValue) -> Option<u64> {
match v {
CborValue::Integer(i) => u64::try_from(i128::from(*i)).ok(),
CborValue::Float(f) => Some(*f as u64),
_ => None,
}
}
fn cbor_to_attr_typed(v: &CborValue, tag: Option<&str>) -> Option<AttributeValue> {
if let CborValue::Array(a) = v {
return cbor_array_to_attr(a, tag);
}
match tag {
Some("string") => match v {
CborValue::Text(s) => Some(AttributeValue::Str(s.clone())),
_ => None,
},
Some("double") => as_f64(v).map(AttributeValue::Double),
Some("float") => as_f64(v).map(|f| AttributeValue::Float(f as f32)),
Some("int") => as_i64(v).map(|i| AttributeValue::Int(i as i32)),
Some("uint") => as_i64(v).map(|i| AttributeValue::Uint(i as u32)),
Some("short") => as_i64(v).map(|i| AttributeValue::Short(i as i16)),
Some("ushort") => as_i64(v).map(|i| AttributeValue::Ushort(i as u16)),
Some("int64") => as_i64(v).map(AttributeValue::Longlong),
Some("uint64") => as_u64(v).map(AttributeValue::Ulonglong),
Some("byte") => as_i64(v).map(|i| AttributeValue::Schar(i as i8)),
Some("ubyte") => as_i64(v).map(|i| AttributeValue::Uchar(i as u8)),
_ => match v {
CborValue::Text(s) => Some(AttributeValue::Str(s.clone())),
CborValue::Integer(_) => as_i64(v).map(AttributeValue::Longlong),
CborValue::Float(f) => Some(AttributeValue::Double(*f)),
_ => None,
},
}
}
fn cbor_array_to_attr(a: &[CborValue], tag: Option<&str>) -> Option<AttributeValue> {
match tag {
Some("string") => Some(AttributeValue::Strs(
a.iter()
.filter_map(|x| match x {
CborValue::Text(s) => Some(s.clone()),
_ => None,
})
.collect(),
)),
Some("double") => Some(AttributeValue::Doubles(
a.iter().filter_map(as_f64).collect(),
)),
Some("float") => Some(AttributeValue::Floats(
a.iter()
.filter_map(|x| as_f64(x).map(|f| f as f32))
.collect(),
)),
Some("int") => Some(AttributeValue::Ints(
a.iter()
.filter_map(|x| as_i64(x).map(|i| i as i32))
.collect(),
)),
Some("uint") => Some(AttributeValue::Uints(
a.iter()
.filter_map(|x| as_i64(x).map(|i| i as u32))
.collect(),
)),
Some("short") => Some(AttributeValue::Shorts(
a.iter()
.filter_map(|x| as_i64(x).map(|i| i as i16))
.collect(),
)),
Some("ushort") => Some(AttributeValue::Ushorts(
a.iter()
.filter_map(|x| as_i64(x).map(|i| i as u16))
.collect(),
)),
Some("int64") => Some(AttributeValue::Longlongs(
a.iter().filter_map(as_i64).collect(),
)),
Some("uint64") => Some(AttributeValue::Ulonglongs(
a.iter().filter_map(as_u64).collect(),
)),
Some("byte") => Some(AttributeValue::Schars(
a.iter()
.filter_map(|x| as_i64(x).map(|i| i as i8))
.collect(),
)),
Some("ubyte") => Some(AttributeValue::Uchars(
a.iter()
.filter_map(|x| as_i64(x).map(|i| i as u8))
.collect(),
)),
_ if a.iter().all(|x| matches!(x, CborValue::Text(_))) => Some(AttributeValue::Strs(
a.iter()
.filter_map(|x| match x {
CborValue::Text(s) => Some(s.clone()),
_ => None,
})
.collect(),
)),
_ if a.iter().all(|x| matches!(x, CborValue::Integer(_))) => Some(
AttributeValue::Longlongs(a.iter().filter_map(as_i64).collect()),
),
_ if a.iter().all(|x| matches!(x, CborValue::Float(_))) => Some(AttributeValue::Doubles(
a.iter().filter_map(as_f64).collect(),
)),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::metadata::{attr_value_to_cbor, attr_value_type_tag};
use std::collections::BTreeMap;
fn meta_with_netcdf(netcdf: Vec<(CborValue, CborValue)>) -> GlobalMetadata {
let mut entry = BTreeMap::new();
entry.insert("netcdf".to_string(), CborValue::Map(netcdf));
GlobalMetadata {
base: vec![entry],
..Default::default()
}
}
#[test]
fn file_dims_errors_without_registry() {
let err = file_dims(&GlobalMetadata::default()).unwrap_err();
assert!(matches!(err, NetcdfError::InvalidData(_)));
}
#[test]
fn var_name_errors_without_name() {
let err = var_name(&meta_with_netcdf(vec![]), 0).unwrap_err();
assert!(
matches!(err, NetcdfError::InvalidData(m) if m.contains("name")),
"a nameless object must report the missing 'name'"
);
}
#[test]
fn var_dim_names_errors_without_dims() {
let err = var_dim_names(&meta_with_netcdf(vec![]), 0).unwrap_err();
assert!(
matches!(err, NetcdfError::InvalidData(m) if m.contains("_dims")),
"a variable without _dims must report it"
);
}
#[test]
fn to_netcdf_rejects_undecodable_message() {
let out = std::env::temp_dir().join("tensogram_to_netcdf_undecodable.nc");
let _ = std::fs::remove_file(&out);
assert!(to_netcdf(b"not a tensogram message", &out).is_err());
assert!(!out.exists(), "a rejected message must not create output");
}
#[test]
fn every_type_tag_round_trips_through_reconstruction() {
use AttributeValue::*;
let samples: &[AttributeValue] = &[
Str("k".into()),
Strs(vec!["a".into(), "b".into()]),
Double(2.5),
Doubles(vec![1.0, 2.0]),
Float(1.5),
Floats(vec![1.0, 2.0]),
Int(-7),
Ints(vec![1, 2]),
Uint(9),
Uints(vec![1, 2]),
Short(3),
Shorts(vec![1, 2]),
Ushort(4),
Ushorts(vec![1, 2]),
Longlong(11),
Longlongs(vec![1, 2]),
Ulonglong(12),
Ulonglongs(vec![1, 2]),
Schar(-5),
Schars(vec![1, 2]),
Uchar(255),
Uchars(vec![1, 2]),
];
for original in samples {
let tag = attr_value_type_tag(original);
let cbor = attr_value_to_cbor(original);
let restored = cbor_to_attr_typed(&cbor, Some(tag))
.unwrap_or_else(|| panic!("tag {tag:?} reconstructed to no AttributeValue"));
assert_eq!(
attr_value_type_tag(&restored),
tag,
"tag {tag:?} reconstructed to a different NetCDF type: {restored:?}",
);
}
}
}