Skip to main content

rd_rds/package/
meta.rs

1use super::{
2    InstalledMetadataError, ValueKindName, ViewError, decode_optional, decode_required, duplicate,
3    expect_list, missing, named_values, unexpected_length, unexpected_type,
4};
5use crate::{RObject, RValue};
6use std::{collections::BTreeMap, fmt};
7
8/// Typed, owned metadata from an installed package's `Meta/package.rds`.
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct PackageMeta {
11    description: BTreeMap<String, Option<String>>,
12    built: Option<Built>,
13}
14
15impl PackageMeta {
16    /// Reads and validates `Meta/package.rds` below an installed package
17    /// directory.
18    pub fn read_installed(
19        package_dir: impl AsRef<std::path::Path>,
20    ) -> Result<Self, InstalledMetadataError> {
21        Self::read_installed_with_options(package_dir, &crate::file::ReadOptions::default())
22    }
23
24    /// Reads and validates `Meta/package.rds` with explicit file and decode
25    /// bounds.
26    pub fn read_installed_with_options(
27        package_dir: impl AsRef<std::path::Path>,
28        options: &crate::file::ReadOptions,
29    ) -> Result<Self, InstalledMetadataError> {
30        let (path, object) = super::read_installed_object(package_dir, "package.rds", options)?;
31        Self::from_object(&object).map_err(|source| InstalledMetadataError::View { path, source })
32    }
33
34    /// Validates and copies a `packageDescription2` R object.
35    pub fn from_object(object: &RObject) -> Result<Self, ViewError> {
36        let items = expect_list(object, "PackageMeta", None)?;
37        require_class(object, "PackageMeta", None, "packageDescription2")?;
38        let names = named_values(object, "PackageMeta", None)?;
39        if names.len() != items.len() {
40            return Err(unexpected_length(
41                "PackageMeta",
42                None,
43                items.len().to_string(),
44                names.len(),
45            ));
46        }
47
48        let mut positions = BTreeMap::new();
49        for (index, name) in names.iter().enumerate() {
50            let name = decode_required(name, &format!("PackageMeta[{index}]"), None)?;
51            if positions.insert(name.clone(), index).is_some() {
52                return Err(duplicate(&format!("PackageMeta.{name}"), Some(name)));
53            }
54        }
55
56        let description_index = positions
57            .get("DESCRIPTION")
58            .copied()
59            .ok_or_else(|| missing("PackageMeta.DESCRIPTION", Some("DESCRIPTION".to_owned())))?;
60        let description = parse_description(
61            &items[description_index],
62            "PackageMeta.DESCRIPTION",
63            Some("DESCRIPTION"),
64        )?;
65        let built = positions
66            .get("Built")
67            .copied()
68            .map(|index| parse_built(&items[index], "PackageMeta.Built"))
69            .transpose()?;
70
71        Ok(Self { description, built })
72    }
73
74    /// Returns the validated `Built` metadata, if the element is present.
75    pub fn built(&self) -> Option<&Built> {
76        self.built.as_ref()
77    }
78
79    /// Returns all DESCRIPTION fields in sorted key order.
80    pub fn description(&self) -> &BTreeMap<String, Option<String>> {
81        &self.description
82    }
83
84    /// Looks up a DESCRIPTION field, preserving the distinction between absent and R `NA`.
85    pub fn description_field(&self, name: &str) -> Option<Option<&str>> {
86        self.description.get(name).map(|value| value.as_deref())
87    }
88}
89
90impl TryFrom<&RObject> for PackageMeta {
91    type Error = ViewError;
92
93    fn try_from(value: &RObject) -> Result<Self, Self::Error> {
94        Self::from_object(value)
95    }
96}
97
98/// The validated `Built` element of package metadata.
99///
100/// Unlike [`PackageMeta::description_field`], whose nested `Option`
101/// distinguishes an absent field from a present R `NA`, the optional
102/// accessors here deliberately collapse both cases to `None`: for build
103/// metadata, an absent `Platform` and an `NA` `Platform` carry the same
104/// meaning to consumers ("no usable value"), so the distinction is not
105/// preserved.
106#[derive(Debug, Clone, PartialEq, Eq)]
107pub struct Built {
108    r_version: PackageVersion,
109    platform: Option<String>,
110    date: Option<String>,
111    os_type: Option<String>,
112}
113
114impl Built {
115    /// Returns the R version used to build the package.
116    pub fn r_version(&self) -> &PackageVersion {
117        &self.r_version
118    }
119
120    /// Returns the build platform, if it is present and not `NA`.
121    pub fn platform(&self) -> Option<&str> {
122        self.platform.as_deref()
123    }
124
125    /// Returns the build date, if it is present and not `NA`.
126    pub fn date(&self) -> Option<&str> {
127        self.date.as_deref()
128    }
129
130    /// Returns the operating-system type, if it is present and not `NA`.
131    pub fn os_type(&self) -> Option<&str> {
132        self.os_type.as_deref()
133    }
134}
135
136/// A validated R `package_version`/`numeric_version` value.
137#[derive(Debug, Clone, PartialEq, Eq)]
138pub struct PackageVersion {
139    pub(super) components: Vec<u32>,
140}
141
142impl PackageVersion {
143    /// Returns the numeric version components.
144    pub fn components(&self) -> &[u32] {
145        &self.components
146    }
147}
148
149impl fmt::Display for PackageVersion {
150    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
151        let mut components = self.components.iter();
152        if let Some(first) = components.next() {
153            write!(formatter, "{first}")?;
154            for component in components {
155                write!(formatter, ".{component}")?;
156            }
157        }
158        Ok(())
159    }
160}
161
162fn parse_description(
163    object: &RObject,
164    path: &str,
165    field: Option<&str>,
166) -> Result<BTreeMap<String, Option<String>>, ViewError> {
167    let values = match &object.value() {
168        RValue::Character(values) => values,
169        value => {
170            return Err(unexpected_type(
171                path,
172                field,
173                "character vector",
174                value.kind_name(),
175            ));
176        }
177    };
178    let names = named_values(object, path, field)?;
179    if names.len() != values.len() {
180        return Err(unexpected_length(
181            path,
182            field,
183            values.len().to_string(),
184            names.len(),
185        ));
186    }
187    let mut description = BTreeMap::new();
188    for (index, (name, value)) in names.iter().zip(values).enumerate() {
189        let name = decode_required(name, &format!("{path}[{index}]"), field)?;
190        if description.contains_key(&name) {
191            return Err(duplicate(&format!("{path}[\"{name}\"]"), Some(name)));
192        }
193        let value = decode_optional(value, &format!("{path}[\"{name}\"]"), Some(&name))?;
194        description.insert(name, value);
195    }
196    Ok(description)
197}
198
199fn parse_built(object: &RObject, path: &str) -> Result<Built, ViewError> {
200    let items = expect_list(object, path, Some("Built"))?;
201    let names = named_values(object, path, Some("Built"))?;
202    if names.len() != items.len() {
203        return Err(unexpected_length(
204            path,
205            Some("Built"),
206            items.len().to_string(),
207            names.len(),
208        ));
209    }
210    let mut positions = BTreeMap::new();
211    for (index, name) in names.iter().enumerate() {
212        let name = decode_required(name, &format!("{path}[{index}]"), Some("Built"))?;
213        if positions.insert(name.clone(), index).is_some() {
214            return Err(duplicate(&format!("{path}.{name}"), Some(name)));
215        }
216    }
217    let r_index = positions
218        .get("R")
219        .copied()
220        .ok_or_else(|| missing(format!("{path}.R"), Some("R".to_owned())))?;
221    let r_version = parse_version(&items[r_index], &format!("{path}.R"))?;
222    let platform = optional_built_string(&positions, items, "Platform", path)?;
223    let date = optional_built_string(&positions, items, "Date", path)?;
224    let os_type = optional_built_string(&positions, items, "OStype", path)?;
225    Ok(Built {
226        r_version,
227        platform,
228        date,
229        os_type,
230    })
231}
232
233fn parse_version(object: &RObject, path: &str) -> Result<PackageVersion, ViewError> {
234    let valid_class = match object.attributes().get("class") {
235        Some(attribute) => match &attribute.value() {
236            RValue::Character(values) => {
237                let mut has_package = false;
238                let mut has_numeric = false;
239                for value in values {
240                    match value.as_str() {
241                        Some(Ok(value)) if value == "package_version" => has_package = true,
242                        Some(Ok(value)) if value == "numeric_version" => has_numeric = true,
243                        Some(Err(_)) => {
244                            return Err(ViewError::InvalidStringEncoding {
245                                path: format!("{path}.class"),
246                                field: Some("R".to_owned()),
247                                row: None,
248                                column: None,
249                            });
250                        }
251                        _ => {}
252                    }
253                }
254                has_package && has_numeric
255            }
256            _ => false,
257        },
258        None => false,
259    };
260    if !valid_class {
261        return Err(invalid_version(
262            path,
263            "missing package_version/numeric_version class",
264        ));
265    }
266    let RValue::List(values) = &object.value() else {
267        return Err(invalid_version(path, "expected a length-one list"));
268    };
269    if values.len() != 1 {
270        return Err(invalid_version(path, "expected a length-one list"));
271    }
272    let RValue::Integer(components) = &values[0].value() else {
273        return Err(invalid_version(path, "expected an integer vector"));
274    };
275    if components.is_empty() {
276        return Err(invalid_version(
277            path,
278            "version components must not be empty",
279        ));
280    }
281    let mut owned = Vec::with_capacity(components.len());
282    for (index, component) in components.iter().enumerate() {
283        let Some(component) = component else {
284            return Err(invalid_version(
285                &format!("{path}[0][{index}]"),
286                "component is NA",
287            ));
288        };
289        if *component < 0 {
290            return Err(invalid_version(
291                &format!("{path}[0][{index}]"),
292                "component is negative",
293            ));
294        }
295        owned.push(*component as u32);
296    }
297    Ok(PackageVersion { components: owned })
298}
299
300fn optional_built_string(
301    positions: &BTreeMap<String, usize>,
302    items: &[RObject],
303    name: &str,
304    path: &str,
305) -> Result<Option<String>, ViewError> {
306    match positions.get(name) {
307        Some(index) => {
308            decode_character_scalar(&items[*index], &format!("{path}.{name}"), Some(name))
309        }
310        None => Ok(None),
311    }
312}
313
314fn require_class(
315    object: &RObject,
316    path: &str,
317    field: Option<&str>,
318    expected: &str,
319) -> Result<(), ViewError> {
320    let Some(attribute) = object.attributes().get("class") else {
321        return Err(missing(format!("{path}.class"), field.map(str::to_owned)));
322    };
323    let RValue::Character(values) = &attribute.value() else {
324        return Err(unexpected_type(
325            &format!("{path}.class"),
326            field,
327            "character vector",
328            attribute.value().kind_name(),
329        ));
330    };
331    for value in values {
332        match value.as_str() {
333            Some(Ok(value)) if value == expected => return Ok(()),
334            Some(Err(_)) => {
335                return Err(ViewError::InvalidStringEncoding {
336                    path: format!("{path}.class"),
337                    field: field.map(str::to_owned),
338                    row: None,
339                    column: None,
340                });
341            }
342            _ => {}
343        }
344    }
345    Err(unexpected_type(
346        path,
347        field,
348        "expected class",
349        "different class",
350    ))
351}
352
353fn decode_character_scalar(
354    object: &RObject,
355    path: &str,
356    field: Option<&str>,
357) -> Result<Option<String>, ViewError> {
358    let RValue::Character(values) = &object.value() else {
359        return Err(unexpected_type(
360            path,
361            field,
362            "character scalar",
363            object.value().kind_name(),
364        ));
365    };
366    if values.len() != 1 {
367        return Err(unexpected_length(path, field, "1".to_owned(), values.len()));
368    }
369    decode_optional(&values[0], path, field)
370}
371
372fn invalid_version(path: &str, reason: &str) -> ViewError {
373    ViewError::InvalidPackageVersion {
374        path: path.to_owned(),
375        field: Some("R".to_owned()),
376        reason: reason.to_owned(),
377    }
378}