Skip to main content

rd_rds/
package.rs

1//! Typed access to an installed package's `Meta/package.rds` metadata.
2//!
3//! This module covers the `packageDescription2` shape written by R for an
4//! installed package. It is intentionally a typed reader rather than a
5//! general R object model: values are validated and copied into owned Rust
6//! data during construction. The raw [`crate::RObject`] remains available for
7//! metadata shapes not covered here.
8//!
9//! A missing package field is represented by an outer [`Option`], while an R
10//! `NA` character value is represented by an inner [`Option`]. Thus
11//! [`PackageMeta::description_field`] can distinguish an absent field from a
12//! present field whose value is `NA`. [`Built`] is the deliberate exception:
13//! its optional character accessors collapse both cases to `None`, because an
14//! absent and an `NA` build field carry the same meaning to consumers.
15//!
16//! [`PackagesMatrix`] covers CRAN-like `PACKAGES.rds` character matrices. It
17//! validates and owns the matrix data, absorbing R's column-major layout.
18//! Row/column lookup uses an outer `Option` for a missing column or row and an
19//! inner `Option` for an R `NA` cell.
20
21use std::{collections::BTreeMap, fmt};
22
23use thiserror::Error;
24
25use crate::{RObject, RStr, RValue};
26
27/// A construction error from the typed installed-package metadata view.
28#[derive(Debug, Error, PartialEq, Eq)]
29#[non_exhaustive]
30pub enum ViewError {
31    #[error("missing value at {path}")]
32    Missing { path: String, field: Option<String> },
33    #[error("unexpected type at {path}: expected {expected}, got {actual}")]
34    UnexpectedType {
35        path: String,
36        field: Option<String>,
37        expected: &'static str,
38        actual: &'static str,
39    },
40    #[error("unexpected length at {path}: expected {expected}, got {actual}")]
41    UnexpectedLength {
42        path: String,
43        field: Option<String>,
44        expected: String,
45        actual: usize,
46    },
47    #[error("duplicate name at {path}")]
48    DuplicateName { path: String, field: Option<String> },
49    #[error("invalid string encoding at {path}")]
50    InvalidStringEncoding {
51        path: String,
52        field: Option<String>,
53        row: Option<usize>,
54        column: Option<String>,
55    },
56    #[error("invalid dimensions at {path}: {reason}")]
57    InvalidDimensions {
58        path: String,
59        field: Option<String>,
60        reason: String,
61    },
62    #[error("invalid package version at {path}: {reason}")]
63    InvalidPackageVersion {
64        path: String,
65        field: Option<String>,
66        reason: String,
67    },
68}
69
70impl ViewError {
71    /// Returns the logical location of the invalid value.
72    pub fn path(&self) -> String {
73        match self {
74            Self::Missing { path, .. }
75            | Self::UnexpectedType { path, .. }
76            | Self::UnexpectedLength { path, .. }
77            | Self::DuplicateName { path, .. }
78            | Self::InvalidStringEncoding { path, .. }
79            | Self::InvalidDimensions { path, .. }
80            | Self::InvalidPackageVersion { path, .. } => path.clone(),
81        }
82    }
83
84    /// Returns the metadata field associated with the error, when there is one.
85    pub fn field(&self) -> Option<&str> {
86        match self {
87            Self::Missing { field, .. }
88            | Self::UnexpectedType { field, .. }
89            | Self::UnexpectedLength { field, .. }
90            | Self::DuplicateName { field, .. }
91            | Self::InvalidStringEncoding { field, .. }
92            | Self::InvalidDimensions { field, .. }
93            | Self::InvalidPackageVersion { field, .. } => field.as_deref(),
94        }
95    }
96
97    /// Returns row context when the error was caused by a matrix cell.
98    pub fn row(&self) -> Option<usize> {
99        match self {
100            Self::InvalidStringEncoding { row, .. } => *row,
101            _ => None,
102        }
103    }
104
105    /// Returns column-name context when the error was caused by a matrix cell.
106    pub fn column(&self) -> Option<&str> {
107        match self {
108            Self::InvalidStringEncoding { column, .. } => column.as_deref(),
109            _ => None,
110        }
111    }
112}
113
114/// A validated, owned view of a CRAN-like `PACKAGES.rds` character matrix.
115#[derive(Debug, Clone, PartialEq, Eq)]
116pub struct PackagesMatrix {
117    nrow: usize,
118    column_names: Vec<String>,
119    cells: Vec<Option<String>>,
120}
121
122impl PackagesMatrix {
123    /// Validates and copies a `PACKAGES.rds` character matrix.
124    pub fn from_object(object: &RObject) -> Result<Self, ViewError> {
125        let values = match &object.value() {
126            RValue::Character(values) => values,
127            value => {
128                return Err(unexpected_type(
129                    "PACKAGES",
130                    None,
131                    "character vector",
132                    value.kind_name(),
133                ));
134            }
135        };
136        let dimensions = object
137            .attributes()
138            .get("dim")
139            .ok_or_else(|| missing("PACKAGES.attributes.dim", None))?;
140        let dimensions = match &dimensions.value() {
141            RValue::Integer(values) => values,
142            value => {
143                return Err(unexpected_type(
144                    "PACKAGES.attributes.dim",
145                    None,
146                    "integer vector",
147                    value.kind_name(),
148                ));
149            }
150        };
151        if dimensions.len() != 2 {
152            return Err(unexpected_length(
153                "PACKAGES.attributes.dim",
154                None,
155                "2".to_owned(),
156                dimensions.len(),
157            ));
158        }
159        let mut shape = [0usize; 2];
160        for (index, value) in dimensions.iter().enumerate() {
161            let Some(value) = value else {
162                return Err(invalid_dimensions(
163                    "PACKAGES.attributes.dim",
164                    "dimensions must not contain NA",
165                ));
166            };
167            if *value < 0 {
168                return Err(invalid_dimensions(
169                    "PACKAGES.attributes.dim",
170                    "dimensions must not be negative",
171                ));
172            }
173            shape[index] = *value as usize;
174        }
175        let element_count = shape[0].checked_mul(shape[1]).ok_or_else(|| {
176            invalid_dimensions("PACKAGES.attributes.dim", "dimension product overflows")
177        })?;
178        if values.len() != element_count {
179            return Err(unexpected_length(
180                "PACKAGES",
181                None,
182                element_count.to_string(),
183                values.len(),
184            ));
185        }
186
187        let dimnames = object
188            .attributes()
189            .get("dimnames")
190            .ok_or_else(|| missing("PACKAGES.attributes.dimnames", None))?;
191        let dimnames = match &dimnames.value() {
192            RValue::List(values) => values,
193            value => {
194                return Err(unexpected_type(
195                    "PACKAGES.attributes.dimnames",
196                    None,
197                    "list",
198                    value.kind_name(),
199                ));
200            }
201        };
202        if dimnames.len() != 2 {
203            return Err(unexpected_length(
204                "PACKAGES.attributes.dimnames",
205                None,
206                "2".to_owned(),
207                dimnames.len(),
208            ));
209        }
210        validate_row_names(&dimnames[0], shape[0])?;
211        let column_values = match &dimnames[1].value() {
212            RValue::Character(values) => values,
213            value => {
214                return Err(unexpected_type(
215                    "PACKAGES.attributes.dimnames[1]",
216                    None,
217                    "character vector",
218                    value.kind_name(),
219                ));
220            }
221        };
222        if column_values.len() != shape[1] {
223            return Err(unexpected_length(
224                "PACKAGES.attributes.dimnames[1]",
225                None,
226                shape[1].to_string(),
227                column_values.len(),
228            ));
229        }
230        let mut column_names = Vec::with_capacity(shape[1]);
231        let mut seen_names = std::collections::BTreeSet::new();
232        for (index, value) in column_values.iter().enumerate() {
233            let name = decode_required(
234                value,
235                &format!("PACKAGES.attributes.dimnames[1][{index}]"),
236                None,
237            )?;
238            if !seen_names.insert(name.clone()) {
239                return Err(duplicate(
240                    &format!("PACKAGES.attributes.dimnames[1][{index}]"),
241                    Some(name),
242                ));
243            }
244            column_names.push(name);
245        }
246
247        let mut cells = Vec::with_capacity(element_count);
248        for row in 0..shape[0] {
249            for column in 0..shape[1] {
250                cells.push(decode_matrix_cell(
251                    &values[row + column * shape[0]],
252                    row,
253                    &column_names[column],
254                )?);
255            }
256        }
257        Ok(Self {
258            nrow: shape[0],
259            column_names,
260            cells,
261        })
262    }
263
264    pub fn len(&self) -> usize {
265        self.nrow
266    }
267    pub fn is_empty(&self) -> bool {
268        self.nrow == 0
269    }
270    pub fn column_names(&self) -> impl ExactSizeIterator<Item = &str> + '_ {
271        self.column_names.iter().map(String::as_str)
272    }
273    pub fn column(&self, name: &str) -> Option<PackagesColumn<'_>> {
274        self.column_names
275            .iter()
276            .position(|column| column == name)
277            .map(|index| PackagesColumn {
278                matrix: self,
279                index,
280            })
281    }
282    pub fn row(&self, index: usize) -> Option<PackagesRow<'_>> {
283        (index < self.nrow).then_some(PackagesRow {
284            matrix: self,
285            index,
286        })
287    }
288    pub fn rows(&self) -> impl ExactSizeIterator<Item = PackagesRow<'_>> + '_ {
289        (0..self.nrow).map(|index| PackagesRow {
290            matrix: self,
291            index,
292        })
293    }
294}
295
296impl TryFrom<&RObject> for PackagesMatrix {
297    type Error = ViewError;
298    fn try_from(value: &RObject) -> Result<Self, Self::Error> {
299        Self::from_object(value)
300    }
301}
302
303/// A row in a validated [`PackagesMatrix`].
304#[derive(Debug, Clone, Copy)]
305pub struct PackagesRow<'a> {
306    matrix: &'a PackagesMatrix,
307    index: usize,
308}
309
310impl<'a> PackagesRow<'a> {
311    pub fn index(&self) -> usize {
312        self.index
313    }
314    pub fn get(&self, column: &str) -> Option<Option<&'a str>> {
315        let column = self
316            .matrix
317            .column_names
318            .iter()
319            .position(|name| name == column)?;
320        Some(self.matrix.cells[self.index * self.matrix.column_names.len() + column].as_deref())
321    }
322}
323
324/// A column in a validated [`PackagesMatrix`].
325#[derive(Debug, Clone, Copy)]
326pub struct PackagesColumn<'a> {
327    matrix: &'a PackagesMatrix,
328    index: usize,
329}
330
331impl<'a> PackagesColumn<'a> {
332    pub fn name(&self) -> &str {
333        &self.matrix.column_names[self.index]
334    }
335    pub fn len(&self) -> usize {
336        self.matrix.nrow
337    }
338    pub fn is_empty(&self) -> bool {
339        self.matrix.is_empty()
340    }
341    pub fn get(&self, row: usize) -> Option<Option<&'a str>> {
342        (row < self.matrix.nrow).then(|| {
343            self.matrix.cells[row * self.matrix.column_names.len() + self.index].as_deref()
344        })
345    }
346}
347
348/// Typed, owned metadata from an installed package's `Meta/package.rds`.
349#[derive(Debug, Clone, PartialEq, Eq)]
350pub struct PackageMeta {
351    description: BTreeMap<String, Option<String>>,
352    built: Option<Built>,
353}
354
355impl PackageMeta {
356    /// Validates and copies a `packageDescription2` R object.
357    pub fn from_object(object: &RObject) -> Result<Self, ViewError> {
358        let items = expect_list(object, "PackageMeta", None)?;
359        require_class(object, "PackageMeta", None, "packageDescription2")?;
360        let names = named_values(object, "PackageMeta", None)?;
361        if names.len() != items.len() {
362            return Err(unexpected_length(
363                "PackageMeta",
364                None,
365                items.len().to_string(),
366                names.len(),
367            ));
368        }
369
370        let mut positions = BTreeMap::new();
371        for (index, name) in names.iter().enumerate() {
372            let name = decode_required(name, &format!("PackageMeta[{index}]"), None)?;
373            if positions.insert(name.clone(), index).is_some() {
374                return Err(duplicate(&format!("PackageMeta.{name}"), Some(name)));
375            }
376        }
377
378        let description_index = positions
379            .get("DESCRIPTION")
380            .copied()
381            .ok_or_else(|| missing("PackageMeta.DESCRIPTION", Some("DESCRIPTION".to_owned())))?;
382        let description = parse_description(
383            &items[description_index],
384            "PackageMeta.DESCRIPTION",
385            Some("DESCRIPTION"),
386        )?;
387        let built = positions
388            .get("Built")
389            .copied()
390            .map(|index| parse_built(&items[index], "PackageMeta.Built"))
391            .transpose()?;
392
393        Ok(Self { description, built })
394    }
395
396    /// Returns the validated `Built` metadata, if the element is present.
397    pub fn built(&self) -> Option<&Built> {
398        self.built.as_ref()
399    }
400
401    /// Returns all DESCRIPTION fields in sorted key order.
402    pub fn description(&self) -> &BTreeMap<String, Option<String>> {
403        &self.description
404    }
405
406    /// Looks up a DESCRIPTION field, preserving the distinction between absent and R `NA`.
407    pub fn description_field(&self, name: &str) -> Option<Option<&str>> {
408        self.description.get(name).map(|value| value.as_deref())
409    }
410}
411
412impl TryFrom<&RObject> for PackageMeta {
413    type Error = ViewError;
414
415    fn try_from(value: &RObject) -> Result<Self, Self::Error> {
416        Self::from_object(value)
417    }
418}
419
420/// The validated `Built` element of package metadata.
421///
422/// Unlike [`PackageMeta::description_field`], whose nested `Option`
423/// distinguishes an absent field from a present R `NA`, the optional
424/// accessors here deliberately collapse both cases to `None`: for build
425/// metadata, an absent `Platform` and an `NA` `Platform` carry the same
426/// meaning to consumers ("no usable value"), so the distinction is not
427/// preserved.
428#[derive(Debug, Clone, PartialEq, Eq)]
429pub struct Built {
430    r_version: PackageVersion,
431    platform: Option<String>,
432    date: Option<String>,
433    os_type: Option<String>,
434}
435
436impl Built {
437    /// Returns the R version used to build the package.
438    pub fn r_version(&self) -> &PackageVersion {
439        &self.r_version
440    }
441
442    /// Returns the build platform, if it is present and not `NA`.
443    pub fn platform(&self) -> Option<&str> {
444        self.platform.as_deref()
445    }
446
447    /// Returns the build date, if it is present and not `NA`.
448    pub fn date(&self) -> Option<&str> {
449        self.date.as_deref()
450    }
451
452    /// Returns the operating-system type, if it is present and not `NA`.
453    pub fn os_type(&self) -> Option<&str> {
454        self.os_type.as_deref()
455    }
456}
457
458/// A validated R `package_version`/`numeric_version` value.
459#[derive(Debug, Clone, PartialEq, Eq)]
460pub struct PackageVersion {
461    components: Vec<u32>,
462}
463
464impl PackageVersion {
465    /// Returns the numeric version components.
466    pub fn components(&self) -> &[u32] {
467        &self.components
468    }
469}
470
471impl fmt::Display for PackageVersion {
472    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
473        let mut components = self.components.iter();
474        if let Some(first) = components.next() {
475            write!(formatter, "{first}")?;
476            for component in components {
477                write!(formatter, ".{component}")?;
478            }
479        }
480        Ok(())
481    }
482}
483
484fn parse_description(
485    object: &RObject,
486    path: &str,
487    field: Option<&str>,
488) -> Result<BTreeMap<String, Option<String>>, ViewError> {
489    let values = match &object.value() {
490        RValue::Character(values) => values,
491        value => {
492            return Err(unexpected_type(
493                path,
494                field,
495                "character vector",
496                value.kind_name(),
497            ));
498        }
499    };
500    let names = named_values(object, path, field)?;
501    if names.len() != values.len() {
502        return Err(unexpected_length(
503            path,
504            field,
505            values.len().to_string(),
506            names.len(),
507        ));
508    }
509    let mut description = BTreeMap::new();
510    for (index, (name, value)) in names.iter().zip(values).enumerate() {
511        let name = decode_required(name, &format!("{path}[{index}]"), field)?;
512        if description.contains_key(&name) {
513            return Err(duplicate(&format!("{path}[\"{name}\"]"), Some(name)));
514        }
515        let value = decode_optional(value, &format!("{path}[\"{name}\"]"), Some(&name))?;
516        description.insert(name, value);
517    }
518    Ok(description)
519}
520
521fn parse_built(object: &RObject, path: &str) -> Result<Built, ViewError> {
522    let items = expect_list(object, path, Some("Built"))?;
523    let names = named_values(object, path, Some("Built"))?;
524    if names.len() != items.len() {
525        return Err(unexpected_length(
526            path,
527            Some("Built"),
528            items.len().to_string(),
529            names.len(),
530        ));
531    }
532    let mut positions = BTreeMap::new();
533    for (index, name) in names.iter().enumerate() {
534        let name = decode_required(name, &format!("{path}[{index}]"), Some("Built"))?;
535        if positions.insert(name.clone(), index).is_some() {
536            return Err(duplicate(&format!("{path}.{name}"), Some(name)));
537        }
538    }
539    let r_index = positions
540        .get("R")
541        .copied()
542        .ok_or_else(|| missing(format!("{path}.R"), Some("R".to_owned())))?;
543    let r_version = parse_version(&items[r_index], &format!("{path}.R"))?;
544    let platform = optional_built_string(&positions, items, "Platform", path)?;
545    let date = optional_built_string(&positions, items, "Date", path)?;
546    let os_type = optional_built_string(&positions, items, "OStype", path)?;
547    Ok(Built {
548        r_version,
549        platform,
550        date,
551        os_type,
552    })
553}
554
555fn parse_version(object: &RObject, path: &str) -> Result<PackageVersion, ViewError> {
556    let valid_class = match object.attributes().get("class") {
557        Some(attribute) => match &attribute.value() {
558            RValue::Character(values) => {
559                let mut has_package = false;
560                let mut has_numeric = false;
561                for value in values {
562                    match value.as_str() {
563                        Some(Ok(value)) if value == "package_version" => has_package = true,
564                        Some(Ok(value)) if value == "numeric_version" => has_numeric = true,
565                        Some(Err(_)) => {
566                            return Err(ViewError::InvalidStringEncoding {
567                                path: format!("{path}.class"),
568                                field: Some("R".to_owned()),
569                                row: None,
570                                column: None,
571                            });
572                        }
573                        _ => {}
574                    }
575                }
576                has_package && has_numeric
577            }
578            _ => false,
579        },
580        None => false,
581    };
582    if !valid_class {
583        return Err(invalid_version(
584            path,
585            "missing package_version/numeric_version class",
586        ));
587    }
588    let RValue::List(values) = &object.value() else {
589        return Err(invalid_version(path, "expected a length-one list"));
590    };
591    if values.len() != 1 {
592        return Err(invalid_version(path, "expected a length-one list"));
593    }
594    let RValue::Integer(components) = &values[0].value() else {
595        return Err(invalid_version(path, "expected an integer vector"));
596    };
597    if components.is_empty() {
598        return Err(invalid_version(
599            path,
600            "version components must not be empty",
601        ));
602    }
603    let mut owned = Vec::with_capacity(components.len());
604    for (index, component) in components.iter().enumerate() {
605        let Some(component) = component else {
606            return Err(invalid_version(
607                &format!("{path}[0][{index}]"),
608                "component is NA",
609            ));
610        };
611        if *component < 0 {
612            return Err(invalid_version(
613                &format!("{path}[0][{index}]"),
614                "component is negative",
615            ));
616        }
617        owned.push(*component as u32);
618    }
619    Ok(PackageVersion { components: owned })
620}
621
622fn optional_built_string(
623    positions: &BTreeMap<String, usize>,
624    items: &[RObject],
625    name: &str,
626    path: &str,
627) -> Result<Option<String>, ViewError> {
628    match positions.get(name) {
629        Some(index) => {
630            decode_character_scalar(&items[*index], &format!("{path}.{name}"), Some(name))
631        }
632        None => Ok(None),
633    }
634}
635
636fn expect_list<'a>(
637    object: &'a RObject,
638    path: &str,
639    field: Option<&str>,
640) -> Result<&'a [RObject], ViewError> {
641    match &object.value() {
642        RValue::List(values) => Ok(values),
643        value => Err(unexpected_type(path, field, "list", value.kind_name())),
644    }
645}
646
647fn named_values<'a>(
648    object: &'a RObject,
649    path: &str,
650    field: Option<&str>,
651) -> Result<&'a [RStr], ViewError> {
652    let Some(attribute) = object.attributes().get("names") else {
653        return Err(missing(format!("{path}.names"), field.map(str::to_owned)));
654    };
655    match &attribute.value() {
656        RValue::Character(values) => Ok(values),
657        value => Err(unexpected_type(
658            &format!("{path}.names"),
659            field,
660            "character vector",
661            value.kind_name(),
662        )),
663    }
664}
665
666fn require_class(
667    object: &RObject,
668    path: &str,
669    field: Option<&str>,
670    expected: &str,
671) -> Result<(), ViewError> {
672    let Some(attribute) = object.attributes().get("class") else {
673        return Err(missing(format!("{path}.class"), field.map(str::to_owned)));
674    };
675    let RValue::Character(values) = &attribute.value() else {
676        return Err(unexpected_type(
677            &format!("{path}.class"),
678            field,
679            "character vector",
680            attribute.value().kind_name(),
681        ));
682    };
683    for value in values {
684        match value.as_str() {
685            Some(Ok(value)) if value == expected => return Ok(()),
686            Some(Err(_)) => {
687                return Err(ViewError::InvalidStringEncoding {
688                    path: format!("{path}.class"),
689                    field: field.map(str::to_owned),
690                    row: None,
691                    column: None,
692                });
693            }
694            _ => {}
695        }
696    }
697    Err(unexpected_type(
698        path,
699        field,
700        "expected class",
701        "different class",
702    ))
703}
704
705fn decode_required(value: &RStr, path: &str, field: Option<&str>) -> Result<String, ViewError> {
706    match value.as_str() {
707        None => Err(unexpected_type(path, field, "non-NA string", "NA")),
708        Some(Ok(value)) => Ok(value.into_owned()),
709        Some(Err(_)) => Err(ViewError::InvalidStringEncoding {
710            path: path.to_owned(),
711            field: field.map(str::to_owned),
712            row: None,
713            column: None,
714        }),
715    }
716}
717
718fn decode_optional(
719    value: &RStr,
720    path: &str,
721    field: Option<&str>,
722) -> Result<Option<String>, ViewError> {
723    match value.as_str() {
724        None => Ok(None),
725        Some(Ok(value)) => Ok(Some(value.into_owned())),
726        Some(Err(_)) => Err(ViewError::InvalidStringEncoding {
727            path: path.to_owned(),
728            field: field.map(str::to_owned),
729            row: None,
730            column: None,
731        }),
732    }
733}
734
735fn decode_character_scalar(
736    object: &RObject,
737    path: &str,
738    field: Option<&str>,
739) -> Result<Option<String>, ViewError> {
740    let RValue::Character(values) = &object.value() else {
741        return Err(unexpected_type(
742            path,
743            field,
744            "character scalar",
745            object.value().kind_name(),
746        ));
747    };
748    if values.len() != 1 {
749        return Err(unexpected_length(path, field, "1".to_owned(), values.len()));
750    }
751    decode_optional(&values[0], path, field)
752}
753
754fn validate_row_names(object: &RObject, expected: usize) -> Result<(), ViewError> {
755    let values = match &object.value() {
756        RValue::Null => return Ok(()),
757        RValue::Character(values) => values,
758        value => {
759            return Err(unexpected_type(
760                "PACKAGES.attributes.dimnames[0]",
761                None,
762                "character vector",
763                value.kind_name(),
764            ));
765        }
766    };
767    if values.len() != expected {
768        return Err(unexpected_length(
769            "PACKAGES.attributes.dimnames[0]",
770            None,
771            expected.to_string(),
772            values.len(),
773        ));
774    }
775    for (index, value) in values.iter().enumerate() {
776        if let Some(Err(_)) = value.as_str() {
777            return Err(ViewError::InvalidStringEncoding {
778                path: format!("PACKAGES.attributes.dimnames[0][{index}]"),
779                field: None,
780                row: None,
781                column: None,
782            });
783        }
784    }
785    Ok(())
786}
787
788fn decode_matrix_cell(value: &RStr, row: usize, column: &str) -> Result<Option<String>, ViewError> {
789    match value.as_str() {
790        None => Ok(None),
791        Some(Ok(value)) => Ok(Some(value.into_owned())),
792        Some(Err(_)) => Err(ViewError::InvalidStringEncoding {
793            path: format!("PACKAGES[row={row},column=\"{column}\"]"),
794            field: Some(column.to_owned()),
795            row: Some(row),
796            column: Some(column.to_owned()),
797        }),
798    }
799}
800
801fn missing(path: impl Into<String>, field: Option<String>) -> ViewError {
802    ViewError::Missing {
803        path: path.into(),
804        field,
805    }
806}
807
808fn invalid_dimensions(path: &str, reason: &str) -> ViewError {
809    ViewError::InvalidDimensions {
810        path: path.to_owned(),
811        field: None,
812        reason: reason.to_owned(),
813    }
814}
815
816fn unexpected_type(
817    path: &str,
818    field: Option<&str>,
819    expected: &'static str,
820    actual: &'static str,
821) -> ViewError {
822    ViewError::UnexpectedType {
823        path: path.to_owned(),
824        field: field.map(str::to_owned),
825        expected,
826        actual,
827    }
828}
829
830fn unexpected_length(
831    path: &str,
832    field: Option<&str>,
833    expected: String,
834    actual: usize,
835) -> ViewError {
836    ViewError::UnexpectedLength {
837        path: path.to_owned(),
838        field: field.map(str::to_owned),
839        expected,
840        actual,
841    }
842}
843
844fn duplicate(path: &str, field: Option<String>) -> ViewError {
845    ViewError::DuplicateName {
846        path: path.to_owned(),
847        field,
848    }
849}
850
851fn invalid_version(path: &str, reason: &str) -> ViewError {
852    ViewError::InvalidPackageVersion {
853        path: path.to_owned(),
854        field: Some("R".to_owned()),
855        reason: reason.to_owned(),
856    }
857}
858
859trait ValueKindName {
860    fn kind_name(&self) -> &'static str;
861}
862
863impl ValueKindName for RValue {
864    fn kind_name(&self) -> &'static str {
865        match self {
866            Self::Null => "null",
867            Self::Logical(_) => "logical vector",
868            Self::Integer(_) => "integer vector",
869            Self::Real(_) => "real vector",
870            Self::Character(_) => "character vector",
871            Self::List(_) => "list",
872            Self::Symbol(_) => "symbol",
873            Self::Persisted(_) => "persisted value",
874            Self::Environment(_) => "environment",
875        }
876    }
877}
878
879#[cfg(test)]
880mod tests {
881    use super::*;
882    use crate::{Attribute, Attributes, REncoding, Symbol};
883
884    #[test]
885    fn package_version_displays_components() {
886        let version = PackageVersion {
887            components: vec![4, 6, 1],
888        };
889        assert_eq!(version.to_string(), "4.6.1");
890        assert_eq!(version.components(), &[4, 6, 1]);
891    }
892
893    #[test]
894    fn view_error_exposes_logical_context() {
895        let error = ViewError::DuplicateName {
896            path: "PackageMeta.DESCRIPTION[\"Package\"]".to_owned(),
897            field: Some("Package".to_owned()),
898        };
899        assert_eq!(error.path(), "PackageMeta.DESCRIPTION[\"Package\"]");
900        assert_eq!(error.field(), Some("Package"));
901        assert_eq!(error.row(), None);
902        assert_eq!(error.column(), None);
903    }
904
905    fn matrix(dim: Vec<Option<i32>>, dimnames: Vec<RObject>, values: Vec<RStr>) -> RObject {
906        RObject::from_parts(
907            RValue::Character(values),
908            Attributes::new(vec![
909                Attribute::new(
910                    Symbol::new("dim"),
911                    RObject::from_parts(RValue::Integer(dim), Attributes::default()),
912                ),
913                Attribute::new(
914                    Symbol::new("dimnames"),
915                    RObject::from_parts(RValue::List(dimnames), Attributes::default()),
916                ),
917            ]),
918        )
919    }
920
921    fn names(values: &[&str]) -> RObject {
922        RObject::from_parts(
923            RValue::Character(
924                values
925                    .iter()
926                    .map(|value| RStr::new(value.as_bytes(), REncoding::Native, None))
927                    .collect(),
928            ),
929            Attributes::default(),
930        )
931    }
932
933    fn replace_dimnames(object: &mut RObject, replacement: Vec<RObject>) {
934        let dim = object.attributes().get("dim").unwrap().clone();
935        set_attributes(
936            object,
937            Attributes::new(vec![
938                Attribute::new(Symbol::new("dim"), dim),
939                Attribute::new(
940                    Symbol::new("dimnames"),
941                    RObject::from_parts(RValue::List(replacement), Attributes::default()),
942                ),
943            ]),
944        );
945    }
946
947    fn set_attributes(object: &mut RObject, attributes: Attributes) {
948        let (value, _) = object.clone().into_parts();
949        *object = RObject::from_parts(value, attributes);
950    }
951
952    fn set_value(object: &mut RObject, value: RValue) {
953        let (_, attributes) = object.clone().into_parts();
954        *object = RObject::from_parts(value, attributes);
955    }
956
957    #[test]
958    fn packages_matrix_rejects_malformed_shape_and_names() {
959        let valid = || {
960            matrix(
961                vec![Some(1), Some(1)],
962                vec![names(&["row"]), names(&["Package"])],
963                vec![RStr::new(b"x", REncoding::Native, None)],
964            )
965        };
966        assert!(
967            matches!(PackagesMatrix::from_object(&RObject::from_parts(RValue::Character(vec![]), Attributes::default())), Err(ViewError::Missing { path, .. }) if path == "PACKAGES.attributes.dim")
968        );
969        let mut object = valid();
970        set_attributes(
971            &mut object,
972            Attributes::new(vec![Attribute::new(
973                Symbol::new("dim"),
974                RObject::from_parts(RValue::Character(vec![]), Attributes::default()),
975            )]),
976        );
977        assert!(
978            matches!(PackagesMatrix::from_object(&object), Err(ViewError::UnexpectedType { path, .. }) if path == "PACKAGES.attributes.dim")
979        );
980        let mut object = valid();
981        set_attributes(
982            &mut object,
983            Attributes::new(vec![Attribute::new(
984                Symbol::new("dim"),
985                RObject::from_parts(RValue::Integer(vec![Some(1)]), Attributes::default()),
986            )]),
987        );
988        assert!(
989            matches!(PackagesMatrix::from_object(&object), Err(ViewError::UnexpectedLength { path, .. }) if path == "PACKAGES.attributes.dim")
990        );
991        let mut object = valid();
992        set_attributes(
993            &mut object,
994            Attributes::new(vec![
995                Attribute::new(
996                    Symbol::new("dim"),
997                    RObject::from_parts(
998                        RValue::Integer(vec![None, Some(1)]),
999                        Attributes::default(),
1000                    ),
1001                ),
1002                Attribute::new(
1003                    Symbol::new("dimnames"),
1004                    RObject::from_parts(
1005                        RValue::List(vec![names(&["row"]), names(&["Package"])]),
1006                        Attributes::default(),
1007                    ),
1008                ),
1009            ]),
1010        );
1011        assert!(
1012            matches!(PackagesMatrix::from_object(&object), Err(ViewError::InvalidDimensions { path, .. }) if path == "PACKAGES.attributes.dim")
1013        );
1014        let mut object = valid();
1015        set_attributes(
1016            &mut object,
1017            Attributes::new(vec![
1018                Attribute::new(
1019                    Symbol::new("dim"),
1020                    RObject::from_parts(
1021                        RValue::Integer(vec![Some(-1), Some(1)]),
1022                        Attributes::default(),
1023                    ),
1024                ),
1025                Attribute::new(
1026                    Symbol::new("dimnames"),
1027                    RObject::from_parts(
1028                        RValue::List(vec![names(&[]), names(&["Package"])]),
1029                        Attributes::default(),
1030                    ),
1031                ),
1032            ]),
1033        );
1034        assert!(
1035            matches!(PackagesMatrix::from_object(&object), Err(ViewError::InvalidDimensions { path, .. }) if path == "PACKAGES.attributes.dim")
1036        );
1037        let mut object = valid();
1038        replace_dimnames(
1039            &mut object,
1040            vec![names(&["row"]), names(&["Package", "Version"])],
1041        );
1042        assert!(
1043            matches!(PackagesMatrix::from_object(&object), Err(ViewError::UnexpectedLength { path, .. }) if path == "PACKAGES.attributes.dimnames[1]")
1044        );
1045        let mut object = valid();
1046        replace_dimnames(&mut object, vec![names(&["row"]), names(&["Package"])]);
1047        set_value(&mut object, RValue::Character(vec![]));
1048        assert!(
1049            matches!(PackagesMatrix::from_object(&object), Err(ViewError::UnexpectedLength { path, .. }) if path == "PACKAGES")
1050        );
1051        let object = RObject::from_parts(
1052            RValue::Character(vec![RStr::new(b"x", REncoding::Native, None)]),
1053            Attributes::new(vec![Attribute::new(
1054                Symbol::new("dim"),
1055                RObject::from_parts(
1056                    RValue::Integer(vec![Some(1), Some(1)]),
1057                    Attributes::default(),
1058                ),
1059            )]),
1060        );
1061        assert!(
1062            matches!(PackagesMatrix::from_object(&object), Err(ViewError::Missing { path, .. }) if path == "PACKAGES.attributes.dimnames")
1063        );
1064        let mut object = valid();
1065        replace_dimnames(&mut object, vec![names(&["row"])]);
1066        assert!(
1067            matches!(PackagesMatrix::from_object(&object), Err(ViewError::UnexpectedLength { path, .. }) if path == "PACKAGES.attributes.dimnames")
1068        );
1069        let object = matrix(
1070            vec![Some(i32::MAX), Some(i32::MAX)],
1071            vec![
1072                RObject::from_parts(RValue::Null, Attributes::default()),
1073                RObject::from_parts(RValue::Character(vec![]), Attributes::default()),
1074            ],
1075            vec![],
1076        );
1077        // i32::MAX * i32::MAX fits in a 64-bit usize (data-length mismatch)
1078        // but overflows a 32-bit usize (dimension-product overflow).
1079        let error = PackagesMatrix::from_object(&object).unwrap_err();
1080        if usize::BITS >= 64 {
1081            assert!(
1082                matches!(error, ViewError::UnexpectedLength { ref path, .. } if path == "PACKAGES")
1083            );
1084        } else {
1085            assert!(matches!(error, ViewError::InvalidDimensions { .. }));
1086        }
1087    }
1088
1089    #[test]
1090    fn packages_matrix_rejects_na_and_duplicate_column_names() {
1091        let mut object = matrix(
1092            vec![Some(1), Some(2)],
1093            vec![names(&["row"]), names(&["Package", "Package"])],
1094            vec![
1095                RStr::new(b"x", REncoding::Native, None),
1096                RStr::new(b"y", REncoding::Native, None),
1097            ],
1098        );
1099        assert!(
1100            matches!(PackagesMatrix::from_object(&object), Err(ViewError::DuplicateName { path, .. }) if path == "PACKAGES.attributes.dimnames[1][1]")
1101        );
1102        replace_dimnames(
1103            &mut object,
1104            vec![
1105                names(&["row"]),
1106                RObject::from_parts(
1107                    RValue::Character(vec![
1108                        RStr::Na,
1109                        RStr::new(b"Version", REncoding::Native, None),
1110                    ]),
1111                    Attributes::default(),
1112                ),
1113            ],
1114        );
1115        assert!(
1116            matches!(PackagesMatrix::from_object(&object), Err(ViewError::UnexpectedType { path, .. }) if path == "PACKAGES.attributes.dimnames[1][0]")
1117        );
1118    }
1119}