Skip to main content

sigmf/
collection.rs

1//! The SigMF collection object (`.sigmf-collection` files).
2//!
3//! A collection groups related recordings, for example the channels of a
4//! phased-array capture. Its single top-level `collection` object carries
5//! `core:version`, optional descriptive fields, `core:extensions`, and
6//! `core:streams`: a list of [`RecordingRef`]s naming each recording's
7//! metadata file and its SHA-512 digest.
8//!
9//! [`Collection`] is the whole document and [`CollectionMetadata`] is the
10//! `collection` object. Unknown top-level and `collection` fields, including
11//! extension fields, are preserved. Legacy `[name, hash]` stream tuples are
12//! accepted when reading; see [`Collection::to_vec_preserving_stream_forms`]
13//! to write them back in that form. Use
14//! [`Collection::validate`](crate::collection::Collection::validate) to check a
15//! collection against the specification.
16//!
17//! # Example
18//!
19//! ```rust
20//! use sigmf::collection::{Collection, CollectionMetadata};
21//! use sigmf::recording::RecordingRef;
22//!
23//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
24//! let mut metadata = CollectionMetadata::new(
25//!     "1.2.6",
26//!     vec![RecordingRef::new("channel-0", &"0".repeat(128))],
27//! );
28//! metadata.description = Some("two-channel capture".into());
29//! let collection = Collection::new(metadata);
30//!
31//! let bytes = collection.to_vec()?;
32//! let parsed = Collection::from_slice(&bytes)?;
33//! assert_eq!(parsed.collection.streams[0].get_name(), "channel-0");
34//! # Ok(())
35//! # }
36//! ```
37
38use serde::de::{self, Deserializer};
39use serde_derive::{Deserialize, Serialize};
40use serde_json::{Map, Value};
41
42use super::{global::ExtensionDeclaration, recording::RecordingRef};
43
44/// A complete `.sigmf-collection` document.
45///
46/// Top-level fields other than `collection` are preserved in
47/// [`extra_fields`](Self::extra_fields).
48#[derive(Debug, Clone, Deserialize, Serialize)]
49pub struct Collection {
50    /// The `collection` object.
51    pub collection: CollectionMetadata,
52    #[serde(flatten)]
53    extra_fields: Map<String, Value>,
54}
55
56/// The `collection` object of a `.sigmf-collection` document.
57///
58/// Fields not modeled here, such as extension fields, are preserved in
59/// [`extra_fields`](Self::extra_fields). Typed extension access is available
60/// through [`extension`](Self::extension) and related methods.
61#[derive(Debug, Clone, Deserialize, Serialize)]
62pub struct CollectionMetadata {
63    // Required fields
64    /// `core:version`: the SigMF version of the collection (required).
65    #[serde(rename = "core:version")]
66    pub version: String,
67
68    // Optional fields
69    /// `core:description`: a text description of the collection.
70    #[serde(rename = "core:description", skip_serializing_if = "Option::is_none")]
71    pub description: Option<String>,
72    /// `core:author`: the author's name and optionally email address.
73    #[serde(rename = "core:author", skip_serializing_if = "Option::is_none")]
74    pub author: Option<String>,
75    /// `core:collection_doi`: the registered DOI for the collection.
76    #[serde(
77        rename = "core:collection_doi",
78        skip_serializing_if = "Option::is_none"
79    )]
80    pub collection_doi: Option<String>,
81    /// `core:license`: a URL for the license under which the collection is
82    /// offered.
83    #[serde(rename = "core:license", skip_serializing_if = "Option::is_none")]
84    pub license: Option<String>,
85    /// `core:extensions`: the extensions used by the collection.
86    #[serde(rename = "core:extensions", skip_serializing_if = "Option::is_none")]
87    pub extensions: Option<Vec<ExtensionDeclaration>>,
88    /// `core:streams`: references to the recordings in the collection
89    /// (required).
90    ///
91    /// Both the object form and the legacy `[name, hash]` tuple form are
92    /// accepted when deserializing; references are serialized as objects.
93    #[serde(rename = "core:streams", deserialize_with = "deserialize_streams")]
94    pub streams: Vec<RecordingRef>,
95    #[serde(flatten)]
96    extra_fields: Map<String, Value>,
97}
98
99impl Collection {
100    /// Creates a collection document with no extra top-level fields.
101    pub fn new(collection: CollectionMetadata) -> Self {
102        Self {
103            collection,
104            extra_fields: Map::new(),
105        }
106    }
107
108    /// Returns the preserved top-level fields other than `collection`.
109    pub fn extra_fields(&self) -> &Map<String, Value> {
110        &self.extra_fields
111    }
112
113    /// Parses a collection document from JSON bytes.
114    ///
115    /// Parsing is stricter than plain deserialization: duplicate object keys
116    /// are rejected, `core:version` and `core:streams` are required, each
117    /// stream must be a `{"name", "hash"}` object or a two-string legacy
118    /// tuple, and optional `core:` fields may not be `null`.
119    ///
120    /// # Errors
121    ///
122    /// Returns [`SigmfError::ResourceLimit`](crate::SigmfError::ResourceLimit)
123    /// if `bytes` exceeds the 1,000,000-byte metadata limit,
124    /// [`SigmfError::InvalidJson`](crate::SigmfError::InvalidJson) if the input
125    /// is not valid JSON or contains duplicate keys, and
126    /// [`SigmfError::InvalidMetadata`](crate::SigmfError::InvalidMetadata) if
127    /// it is not a well-formed collection.
128    pub fn from_slice(bytes: &[u8]) -> Result<Self, crate::SigmfError> {
129        if bytes.len() > crate::MAX_META_SIZE {
130            return Err(crate::SigmfError::ResourceLimit(format!(
131                "collection metadata exceeds the {} byte limit",
132                crate::MAX_META_SIZE
133            )));
134        }
135        let value = crate::json::parse_unique_value(bytes)?;
136        let root = value.as_object().ok_or_else(|| {
137            crate::SigmfError::InvalidMetadata("expected collection object at /".to_string())
138        })?;
139        let collection = root
140            .get("collection")
141            .and_then(Value::as_object)
142            .ok_or_else(|| {
143                crate::SigmfError::InvalidMetadata(
144                    "expected collection object at /collection".to_string(),
145                )
146            })?;
147        for required in ["core:version", "core:streams"] {
148            if !collection.contains_key(required) {
149                return Err(crate::SigmfError::InvalidMetadata(format!(
150                    "missing required collection field at /collection/{required}"
151                )));
152            }
153        }
154        if !collection["core:version"].is_string() {
155            return Err(crate::SigmfError::InvalidMetadata(
156                "expected a string at /collection/core:version".into(),
157            ));
158        }
159        let Some(streams) = collection["core:streams"].as_array() else {
160            return Err(crate::SigmfError::InvalidMetadata(
161                "expected an array at /collection/core:streams".into(),
162            ));
163        };
164        for (index, stream) in streams.iter().enumerate() {
165            let pointer = format!("/collection/core:streams/{index}");
166            match stream {
167                Value::Array(tuple) => {
168                    if tuple.len() != 2 || !tuple[0].is_string() || !tuple[1].is_string() {
169                        return Err(crate::SigmfError::InvalidMetadata(format!(
170                            "expected a two-string legacy tuple at {pointer}"
171                        )));
172                    }
173                }
174                Value::Object(object) => {
175                    for field in ["name", "hash"] {
176                        if !object.get(field).is_some_and(Value::is_string) {
177                            return Err(crate::SigmfError::InvalidMetadata(format!(
178                                "expected a string at {pointer}/{field}"
179                            )));
180                        }
181                    }
182                }
183                _ => {
184                    return Err(crate::SigmfError::InvalidMetadata(format!(
185                        "expected an object or legacy tuple at {pointer}"
186                    )));
187                }
188            }
189        }
190        for field in [
191            "core:description",
192            "core:author",
193            "core:collection_doi",
194            "core:license",
195            "core:extensions",
196        ] {
197            if collection.get(field).is_some_and(Value::is_null) {
198                return Err(crate::SigmfError::InvalidMetadata(format!(
199                    "null is not permitted at /collection/{field}; omit the field instead"
200                )));
201            }
202        }
203        serde_json::from_value(value)
204            .map_err(|error| crate::SigmfError::InvalidMetadata(error.to_string()))
205    }
206
207    /// Serializes the collection to compact JSON.
208    ///
209    /// All stream references are written in object form.
210    ///
211    /// # Errors
212    ///
213    /// Returns an error if serialization fails.
214    pub fn to_vec(&self) -> Result<Vec<u8>, crate::SigmfError> {
215        Ok(serde_json::to_vec(self)?)
216    }
217
218    /// Serializes the collection to compact JSON, writing stream references
219    /// that were read as legacy `[name, hash]` tuples back as tuples.
220    ///
221    /// Any extra fields on such references are dropped in tuple form.
222    ///
223    /// # Errors
224    ///
225    /// Returns an error if serialization fails.
226    pub fn to_vec_preserving_stream_forms(&self) -> Result<Vec<u8>, crate::SigmfError> {
227        let mut value = serde_json::to_value(self)?;
228        let streams = value
229            .get_mut("collection")
230            .and_then(Value::as_object_mut)
231            .and_then(|collection| collection.get_mut("core:streams"))
232            .and_then(Value::as_array_mut)
233            .ok_or_else(|| {
234                crate::SigmfError::InvalidMetadata(
235                    "serialized collection is missing /collection/core:streams".into(),
236                )
237            })?;
238        for (reference, serialized) in self.collection.streams.iter().zip(streams) {
239            if reference.was_legacy_tuple() {
240                *serialized = Value::Array(vec![
241                    Value::String(reference.get_name().to_owned()),
242                    Value::String(reference.get_hash().to_owned()),
243                ]);
244            }
245        }
246        Ok(serde_json::to_vec(&value)?)
247    }
248
249    /// Inserts or replaces a preserved top-level field.
250    ///
251    /// Returns the previous value for `key`, if any.
252    ///
253    /// # Errors
254    ///
255    /// Returns [`SigmfError::InvalidMetadata`](crate::SigmfError::InvalidMetadata)
256    /// if `key` is `"collection"`.
257    pub fn insert_extra(
258        &mut self,
259        key: impl Into<String>,
260        value: Value,
261    ) -> Result<Option<Value>, crate::SigmfError> {
262        let key = key.into();
263        if key == "collection" {
264            return Err(crate::SigmfError::InvalidMetadata(
265                "cannot insert known field \"collection\" as an extension".into(),
266            ));
267        }
268        Ok(self.extra_fields.insert(key, value))
269    }
270}
271
272impl CollectionMetadata {
273    /// Creates a `collection` object with the given version and stream
274    /// references and no optional fields.
275    ///
276    /// # Example
277    ///
278    /// ```rust
279    /// use sigmf::collection::CollectionMetadata;
280    /// use sigmf::recording::RecordingRef;
281    ///
282    /// let hash = "ab".repeat(64);
283    /// let mut metadata = CollectionMetadata::new(
284    ///     "1.2.6",
285    ///     vec![RecordingRef::new("rx0", &hash), RecordingRef::new("rx1", &hash)],
286    /// );
287    /// metadata.author = Some("Jane Doe <jane@example.com>".into());
288    /// assert_eq!(metadata.streams.len(), 2);
289    /// assert!(metadata.extensions.is_none());
290    /// ```
291    pub fn new(version: impl Into<String>, streams: Vec<RecordingRef>) -> Self {
292        Self {
293            version: version.into(),
294            description: None,
295            author: None,
296            collection_doi: None,
297            license: None,
298            extensions: None,
299            streams,
300            extra_fields: Map::new(),
301        }
302    }
303
304    /// Returns the preserved fields not modeled by this struct, such as
305    /// extension fields.
306    pub fn extra_fields(&self) -> &Map<String, Value> {
307        &self.extra_fields
308    }
309
310    pub(crate) fn extension_fields_mut(&mut self) -> &mut Map<String, Value> {
311        &mut self.extra_fields
312    }
313
314    /// Inserts or replaces a preserved field, such as an extension field.
315    ///
316    /// Returns the previous value for `key`, if any.
317    ///
318    /// # Errors
319    ///
320    /// Returns [`SigmfError::InvalidMetadata`](crate::SigmfError::InvalidMetadata)
321    /// if `key` names a field modeled by this struct (e.g. `core:version` or
322    /// `core:streams`).
323    pub fn insert_extra(
324        &mut self,
325        key: impl Into<String>,
326        value: Value,
327    ) -> Result<Option<Value>, crate::SigmfError> {
328        let key = key.into();
329        if matches!(
330            key.as_str(),
331            "core:version"
332                | "core:description"
333                | "core:author"
334                | "core:collection_doi"
335                | "core:license"
336                | "core:extensions"
337                | "core:streams"
338        ) {
339            return Err(crate::SigmfError::InvalidMetadata(format!(
340                "cannot insert known field {key:?} as an extension"
341            )));
342        }
343        Ok(self.extra_fields.insert(key, value))
344    }
345}
346
347fn deserialize_streams<'de, D>(deserializer: D) -> Result<Vec<RecordingRef>, D::Error>
348where
349    D: Deserializer<'de>,
350{
351    let entries = <Vec<Value> as serde::Deserialize>::deserialize(deserializer)?;
352    entries
353        .into_iter()
354        .map(|entry| {
355            if let Some(tuple) = entry.as_array() {
356                if tuple.len() != 2 {
357                    return Err(de::Error::custom(
358                        "legacy recording references must be exact two-element tuples",
359                    ));
360                }
361                let name = tuple[0]
362                    .as_str()
363                    .ok_or_else(|| de::Error::custom("tuple recording name must be a string"))?;
364                let hash = tuple[1]
365                    .as_str()
366                    .ok_or_else(|| de::Error::custom("tuple recording hash must be a string"))?;
367                Ok(RecordingRef::from_legacy_tuple(name, hash))
368            } else {
369                serde_json::from_value(entry).map_err(de::Error::custom)
370            }
371        })
372        .collect()
373}
374
375#[cfg(test)]
376mod tests {
377    use super::*;
378
379    #[test]
380    fn reads_legacy_tuples_and_writes_normalized_reference_objects() {
381        let collection = Collection::from_slice(
382            br#"{"collection":{"core:version":"1.2.6","core:streams":[["a","abcd"],{"name":"b","hash":"ef01","vendor:tag":1}]}}"#,
383        )
384        .unwrap();
385        let value: Value = serde_json::from_slice(&collection.to_vec().unwrap()).unwrap();
386        assert_eq!(value["collection"]["core:streams"][0]["name"], "a");
387        assert_eq!(value["collection"]["core:streams"][0]["hash"], "abcd");
388        assert_eq!(value["collection"]["core:streams"][1]["vendor:tag"], 1);
389        let preserved: Value =
390            serde_json::from_slice(&collection.to_vec_preserving_stream_forms().unwrap()).unwrap();
391        assert_eq!(
392            preserved["collection"]["core:streams"][0],
393            serde_json::json!(["a", "abcd"])
394        );
395        assert!(preserved["collection"]["core:streams"][1].is_object());
396    }
397
398    #[test]
399    fn collection_parser_enforces_metadata_size_limit() {
400        let oversized = vec![b' '; crate::MAX_META_SIZE + 1];
401        assert!(matches!(
402            Collection::from_slice(&oversized),
403            Err(crate::SigmfError::ResourceLimit(_))
404        ));
405    }
406}