Skip to main content

asdf_core/core/
provenance.rs

1//! The provenance schemas: `core/software`, `core/history_entry`,
2//! `core/extension_metadata` and the `core/asdf` root.
3//!
4//! These are what a file says about itself -- what wrote it, what extensions
5//! it needs, what was done to it. Readers act on them: the workaround for the
6//! Python checksum bug keys off `asdf_library`, and a reader that cannot
7//! honour a listed extension knows so before it reaches the value.
8
9use asdf_yaml::{Document, NodeId, ScalarStyle, Tag};
10
11use crate::core::time::Time;
12use crate::error::{Result, err};
13
14/// The tag a `core/software` record carries.
15pub const SOFTWARE_TAG: &str = "tag:stsci.edu:asdf/core/software-1.0.0";
16
17/// A `core/software` record: a piece of software, named and versioned.
18#[derive(Clone, PartialEq, Eq, Debug)]
19pub struct Software {
20    /// The software's name.
21    pub name: String,
22    /// Its version, as written -- not necessarily semantic.
23    pub version: String,
24    /// Who wrote it.
25    pub author: Option<String>,
26    /// Where to find it.
27    pub homepage: Option<String>,
28}
29
30impl Software {
31    /// This library's own identity, for stamping files it writes.
32    pub fn this_library() -> Self {
33        Self {
34            name: "libasdf-rs".to_string(),
35            version: env!("CARGO_PKG_VERSION").to_string(),
36            author: Some("The libasdf-rs Developers".to_string()),
37            homepage: Some("https://github.com/cruzzil/asdf".to_string()),
38        }
39    }
40
41    /// Read a `core/software` record from the tree.
42    pub fn parse(doc: &Document, id: NodeId) -> Result<Self> {
43        if !doc.resolved(id).is_mapping() {
44            return Err(err!(InvalidArgument, "core/software must be a mapping"));
45        }
46        let field = |key: &str| -> Option<String> {
47            doc.mapping_get(id, key).and_then(|n| doc.resolved(n).as_str().map(str::to_string))
48        };
49        // `name` and `version` are the schema's required properties.
50        let (Some(name), Some(version)) = (field("name"), field("version")) else {
51            return Err(err!(InvalidArgument, "core/software needs a name and a version"));
52        };
53        Ok(Self { name, version, author: field("author"), homepage: field("homepage") })
54    }
55
56    /// Build the tree node for this record, tagged `core/software`.
57    pub fn to_node(&self, doc: &mut Document) -> NodeId {
58        let mut pairs = Vec::new();
59        let mut put = |doc: &mut Document, key: &str, value: &str| {
60            let k = doc.add_scalar(key);
61            // Quoted where a bare version like `1.0` would read as a number.
62            let v = doc.add_scalar_styled(value, string_style(value));
63            pairs.push((k, v));
64        };
65        put(doc, "name", &self.name);
66        put(doc, "version", &self.version);
67        if let Some(author) = &self.author {
68            put(doc, "author", author);
69        }
70        if let Some(homepage) = &self.homepage {
71            put(doc, "homepage", homepage);
72        }
73        let node = doc.add_mapping(pairs);
74        doc.node_mut(node).tag = Some(Tag::parse(SOFTWARE_TAG));
75        node
76    }
77}
78
79/// Plain style unless the text would read back as something other than a
80/// string.
81fn string_style(text: &str) -> ScalarStyle {
82    match asdf_yaml::resolve(text, ScalarStyle::Plain, asdf_yaml::Schema::Libasdf) {
83        asdf_yaml::Resolved::String => ScalarStyle::Plain,
84        _ => ScalarStyle::SingleQuoted,
85    }
86}
87
88/// Read a `software` key that may hold one record or a sequence of them.
89fn software_list(doc: &Document, id: NodeId, key: &str) -> Vec<Software> {
90    let Some(entry) = doc.mapping_get(id, key) else {
91        return Vec::new();
92    };
93    match doc.sequence_items(doc.resolve(entry)) {
94        Some(items) => items.iter().filter_map(|n| Software::parse(doc, *n).ok()).collect(),
95        None => Software::parse(doc, entry).into_iter().collect(),
96    }
97}
98
99/// A `core/history_entry` record: something that was done to the file.
100#[derive(Clone, PartialEq, Debug)]
101pub struct HistoryEntry {
102    /// What was done.
103    pub description: Option<String>,
104    /// When, if the writer recorded it.
105    pub time: Option<Time>,
106    /// What did it. The schema allows one record or several.
107    pub software: Vec<Software>,
108}
109
110impl HistoryEntry {
111    /// Read a `core/history_entry` record from the tree.
112    pub fn parse(doc: &Document, id: NodeId) -> Result<Self> {
113        if !doc.resolved(id).is_mapping() {
114            return Err(err!(InvalidArgument, "core/history_entry must be a mapping"));
115        }
116        let description = doc
117            .mapping_get(id, "description")
118            .and_then(|n| doc.resolved(n).as_str().map(str::to_string));
119        let time = doc.mapping_get(id, "time").and_then(|n| Time::parse(doc, n).ok());
120        Ok(Self { description, time, software: software_list(doc, id, "software") })
121    }
122}
123
124/// A `core/extension_metadata` record: an extension the file was written
125/// with, and which a reader needs to make sense of its tagged values.
126#[derive(Clone, PartialEq, Debug)]
127pub struct ExtensionMetadata {
128    /// The extension class that wrote the values.
129    pub extension_class: String,
130    /// The URI identifying the extension.
131    pub extension_uri: Option<String>,
132    /// The package providing it.
133    ///
134    /// Only the `package` key: `software` and `manifest_software` are
135    /// different things, and reading either here would make a file that
136    /// names no package look as though it does.
137    pub package: Option<Software>,
138    /// The software that wrote the values.
139    pub software: Option<Software>,
140    /// The software providing the manifest the extension follows.
141    pub manifest_software: Option<Software>,
142}
143
144impl ExtensionMetadata {
145    /// Read a `core/extension_metadata` record from the tree.
146    pub fn parse(doc: &Document, id: NodeId) -> Result<Self> {
147        if !doc.resolved(id).is_mapping() {
148            return Err(err!(InvalidArgument, "core/extension_metadata must be a mapping"));
149        }
150        let Some(extension_class) = doc
151            .mapping_get(id, "extension_class")
152            .and_then(|n| doc.resolved(n).as_str().map(str::to_string))
153        else {
154            return Err(err!(InvalidArgument, "core/extension_metadata needs an extension_class"));
155        };
156        let one = |key: &str| doc.mapping_get(id, key).and_then(|n| Software::parse(doc, n).ok());
157        Ok(Self {
158            extension_class,
159            extension_uri: doc
160                .mapping_get(id, "extension_uri")
161                .and_then(|n| doc.resolved(n).as_str().map(str::to_string)),
162            package: one("package"),
163            software: one("software"),
164            manifest_software: one("manifest_software"),
165        })
166    }
167}
168
169/// A file's `history`: the extensions it needs and what was done to it.
170#[derive(Clone, PartialEq, Debug, Default)]
171pub struct History {
172    /// The extensions the file was written with.
173    pub extensions: Vec<ExtensionMetadata>,
174    /// What was done to the file, oldest first.
175    pub entries: Vec<HistoryEntry>,
176}
177
178/// The `core/asdf` root's own metadata.
179#[derive(Clone, PartialEq, Debug, Default)]
180pub struct Meta {
181    /// What wrote the file.
182    pub asdf_library: Option<Software>,
183    /// The file's history.
184    pub history: History,
185}
186
187impl Meta {
188    /// Read the metadata from a `core/asdf` root.
189    ///
190    /// Everything is optional: a file that says nothing about itself yields
191    /// an empty `Meta` rather than an error.
192    pub fn parse(doc: &Document, id: NodeId) -> Result<Self> {
193        if !doc.resolved(id).is_mapping() {
194            return Err(err!(InvalidArgument, "the ASDF root must be a mapping"));
195        }
196        let asdf_library =
197            doc.mapping_get(id, "asdf_library").and_then(|n| Software::parse(doc, n).ok());
198
199        // `history` is a mapping of extensions and entries in the 1.1.0
200        // form, and a bare sequence of entries in the older one.
201        let mut history = History::default();
202        if let Some(node) = doc.mapping_get(id, "history") {
203            let node = doc.resolve(node);
204            if doc.resolved(node).is_mapping() {
205                if let Some(list) = doc.mapping_get(node, "extensions") {
206                    history.extensions = read_list(doc, list, ExtensionMetadata::parse);
207                }
208                if let Some(list) = doc.mapping_get(node, "entries") {
209                    history.entries = read_list(doc, list, HistoryEntry::parse);
210                }
211            } else {
212                history.entries = read_list(doc, node, HistoryEntry::parse);
213            }
214        }
215        Ok(Self { asdf_library, history })
216    }
217}
218
219/// Read a sequence of records, or a single record not in a sequence.
220fn read_list<T>(doc: &Document, id: NodeId, parse: fn(&Document, NodeId) -> Result<T>) -> Vec<T> {
221    match doc.sequence_items(doc.resolve(id)) {
222        Some(items) => items.iter().filter_map(|n| parse(doc, *n).ok()).collect(),
223        None => parse(doc, id).into_iter().collect(),
224    }
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230    use asdf_yaml::parse_document;
231
232    fn tree(yaml: &str) -> Document {
233        parse_document(&format!(
234            "%YAML 1.1\n%TAG ! tag:stsci.edu:asdf/\n--- !core/asdf-1.1.0\n{yaml}"
235        ))
236        .unwrap()
237    }
238
239    #[test]
240    fn software_needs_a_name_and_a_version() {
241        let doc = tree("s: !core/software-1.0.0 {name: asdf, version: 4.1.0}\n");
242        let root = doc.root().unwrap();
243        let s = Software::parse(&doc, doc.mapping_get(root, "s").unwrap()).unwrap();
244        assert_eq!(s.name, "asdf");
245        assert_eq!(s.version, "4.1.0");
246        assert_eq!(s.author, None);
247
248        let doc = tree("s: !core/software-1.0.0 {name: asdf}\n");
249        let root = doc.root().unwrap();
250        assert!(Software::parse(&doc, doc.mapping_get(root, "s").unwrap()).is_err());
251    }
252
253    #[test]
254    fn software_round_trips_through_the_tree() {
255        let original = Software::this_library();
256        let mut doc = Document::new_asdf();
257        let node = original.to_node(&mut doc);
258
259        assert_eq!(doc.tag_of(node).map(Tag::full).as_deref(), Some(SOFTWARE_TAG));
260        assert_eq!(Software::parse(&doc, node).unwrap(), original);
261    }
262
263    /// A version like `1.0` would read back as a number if written plainly.
264    #[test]
265    fn a_numeric_looking_version_stays_a_string() {
266        let original = Software {
267            name: "asdf".to_string(),
268            version: "1.0".to_string(),
269            author: None,
270            homepage: None,
271        };
272        let mut doc = Document::new_asdf();
273        let node = original.to_node(&mut doc);
274        assert_eq!(Software::parse(&doc, node).unwrap().version, "1.0");
275    }
276
277    #[test]
278    fn a_history_entry_carries_its_time_and_software() {
279        let doc = tree(
280            "e: !core/history_entry-1.0.0\n  \
281             description: did a thing\n  \
282             time: !<tag:stsci.edu:asdf/time/time-1.4.0> '2025-07-23 11:56:15+00:00'\n  \
283             software: !core/software-1.0.0 {name: asdf, version: 4.1.0}\n",
284        );
285        let root = doc.root().unwrap();
286        let entry = HistoryEntry::parse(&doc, doc.mapping_get(root, "e").unwrap()).unwrap();
287
288        assert_eq!(entry.description.as_deref(), Some("did a thing"));
289        assert_eq!(entry.software.len(), 1);
290        assert_eq!(entry.software[0].name, "asdf");
291        let time = entry.time.expect("a time");
292        assert_eq!(time.civil.unwrap().unix_seconds, 1_753_271_775);
293    }
294
295    /// The schema allows one software record or a list of them.
296    #[test]
297    fn a_history_entrys_software_may_be_one_or_many() {
298        let doc = tree(
299            "e: !core/history_entry-1.0.0\n  \
300             description: two of them\n  \
301             software:\n  \
302             - !core/software-1.0.0 {name: a, version: '1'}\n  \
303             - !core/software-1.0.0 {name: b, version: '2'}\n",
304        );
305        let root = doc.root().unwrap();
306        let entry = HistoryEntry::parse(&doc, doc.mapping_get(root, "e").unwrap()).unwrap();
307        assert_eq!(entry.software.len(), 2);
308        assert_eq!(entry.software[1].name, "b");
309    }
310
311    /// `package` is the package; `software` and `manifest_software` are not.
312    #[test]
313    fn extension_metadata_keeps_its_three_software_records_apart() {
314        let doc = tree(
315            "x: !core/extension_metadata-1.0.0\n  \
316             extension_class: asdf.extension._manifest.ManifestExtension\n  \
317             extension_uri: asdf://asdf-format.org/core/extensions/core-1.6.0\n  \
318             manifest_software: !core/software-1.0.0 {name: asdf_standard, version: 1.1.1}\n  \
319             software: !core/software-1.0.0 {name: asdf, version: 4.1.0}\n",
320        );
321        let root = doc.root().unwrap();
322        let x = ExtensionMetadata::parse(&doc, doc.mapping_get(root, "x").unwrap()).unwrap();
323
324        assert_eq!(x.extension_class, "asdf.extension._manifest.ManifestExtension");
325        assert_eq!(
326            x.extension_uri.as_deref(),
327            Some("asdf://asdf-format.org/core/extensions/core-1.6.0")
328        );
329        assert!(x.package.is_none(), "this record names no package");
330        assert_eq!(x.software.unwrap().name, "asdf");
331        assert_eq!(x.manifest_software.unwrap().name, "asdf_standard");
332    }
333
334    #[test]
335    fn meta_reads_the_whole_provenance_block() {
336        let doc = tree(
337            "asdf_library: !core/software-1.0.0 {name: asdf, version: 4.1.0}\n\
338             history:\n  \
339             extensions:\n  \
340             - !core/extension_metadata-1.0.0\n    \
341             extension_class: asdf.extension._manifest.ManifestExtension\n  \
342             entries:\n  \
343             - !core/history_entry-1.0.0 {description: first}\n  \
344             - !core/history_entry-1.0.0 {description: second}\n",
345        );
346        let meta = Meta::parse(&doc, doc.root().unwrap()).unwrap();
347
348        assert_eq!(meta.asdf_library.unwrap().name, "asdf");
349        assert_eq!(meta.history.extensions.len(), 1);
350        assert_eq!(meta.history.entries.len(), 2);
351        assert_eq!(meta.history.entries[1].description.as_deref(), Some("second"));
352    }
353
354    /// The older form puts the entries directly under `history`.
355    #[test]
356    fn the_pre_1_1_0_history_form_is_a_bare_sequence() {
357        let doc = tree(
358            "history:\n\
359             - !core/history_entry-1.0.0 {description: only}\n",
360        );
361        let meta = Meta::parse(&doc, doc.root().unwrap()).unwrap();
362        assert!(meta.history.extensions.is_empty());
363        assert_eq!(meta.history.entries.len(), 1);
364        assert_eq!(meta.history.entries[0].description.as_deref(), Some("only"));
365    }
366
367    #[test]
368    fn a_file_that_says_nothing_about_itself_is_not_an_error() {
369        let doc = tree("x: 1\n");
370        let meta = Meta::parse(&doc, doc.root().unwrap()).unwrap();
371        assert_eq!(meta, Meta::default());
372    }
373}