1use asdf_yaml::{Document, NodeId, ScalarStyle, Tag};
10
11use crate::core::time::Time;
12use crate::error::{Result, err};
13
14pub const SOFTWARE_TAG: &str = "tag:stsci.edu:asdf/core/software-1.0.0";
16
17#[derive(Clone, PartialEq, Eq, Debug)]
19pub struct Software {
20 pub name: String,
22 pub version: String,
24 pub author: Option<String>,
26 pub homepage: Option<String>,
28}
29
30impl Software {
31 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 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 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 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 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
79fn 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
88fn 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#[derive(Clone, PartialEq, Debug)]
101pub struct HistoryEntry {
102 pub description: Option<String>,
104 pub time: Option<Time>,
106 pub software: Vec<Software>,
108}
109
110impl HistoryEntry {
111 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#[derive(Clone, PartialEq, Debug)]
127pub struct ExtensionMetadata {
128 pub extension_class: String,
130 pub extension_uri: Option<String>,
132 pub package: Option<Software>,
138 pub software: Option<Software>,
140 pub manifest_software: Option<Software>,
142}
143
144impl ExtensionMetadata {
145 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#[derive(Clone, PartialEq, Debug, Default)]
171pub struct History {
172 pub extensions: Vec<ExtensionMetadata>,
174 pub entries: Vec<HistoryEntry>,
176}
177
178#[derive(Clone, PartialEq, Debug, Default)]
180pub struct Meta {
181 pub asdf_library: Option<Software>,
183 pub history: History,
185}
186
187impl Meta {
188 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 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
219fn 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 #[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 #[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 #[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 #[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}