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, NativeEncodingSource, 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| {
927                        RStr::new(
928                            value.as_bytes(),
929                            REncoding::Native,
930                            NativeEncodingSource::Unknown,
931                        )
932                    })
933                    .collect(),
934            ),
935            Attributes::default(),
936        )
937    }
938
939    fn replace_dimnames(object: &mut RObject, replacement: Vec<RObject>) {
940        let dim = object.attributes().get("dim").unwrap().clone();
941        set_attributes(
942            object,
943            Attributes::new(vec![
944                Attribute::new(Symbol::new("dim"), dim),
945                Attribute::new(
946                    Symbol::new("dimnames"),
947                    RObject::from_parts(RValue::List(replacement), Attributes::default()),
948                ),
949            ]),
950        );
951    }
952
953    fn set_attributes(object: &mut RObject, attributes: Attributes) {
954        let (value, _) = object.clone().into_parts();
955        *object = RObject::from_parts(value, attributes);
956    }
957
958    fn set_value(object: &mut RObject, value: RValue) {
959        let (_, attributes) = object.clone().into_parts();
960        *object = RObject::from_parts(value, attributes);
961    }
962
963    #[test]
964    fn packages_matrix_rejects_malformed_shape_and_names() {
965        let valid = || {
966            matrix(
967                vec![Some(1), Some(1)],
968                vec![names(&["row"]), names(&["Package"])],
969                vec![RStr::new(
970                    b"x",
971                    REncoding::Native,
972                    NativeEncodingSource::Unknown,
973                )],
974            )
975        };
976        assert!(
977            matches!(PackagesMatrix::from_object(&RObject::from_parts(RValue::Character(vec![]), Attributes::default())), Err(ViewError::Missing { path, .. }) if path == "PACKAGES.attributes.dim")
978        );
979        let mut object = valid();
980        set_attributes(
981            &mut object,
982            Attributes::new(vec![Attribute::new(
983                Symbol::new("dim"),
984                RObject::from_parts(RValue::Character(vec![]), Attributes::default()),
985            )]),
986        );
987        assert!(
988            matches!(PackagesMatrix::from_object(&object), Err(ViewError::UnexpectedType { path, .. }) if path == "PACKAGES.attributes.dim")
989        );
990        let mut object = valid();
991        set_attributes(
992            &mut object,
993            Attributes::new(vec![Attribute::new(
994                Symbol::new("dim"),
995                RObject::from_parts(RValue::Integer(vec![Some(1)]), Attributes::default()),
996            )]),
997        );
998        assert!(
999            matches!(PackagesMatrix::from_object(&object), Err(ViewError::UnexpectedLength { path, .. }) if path == "PACKAGES.attributes.dim")
1000        );
1001        let mut object = valid();
1002        set_attributes(
1003            &mut object,
1004            Attributes::new(vec![
1005                Attribute::new(
1006                    Symbol::new("dim"),
1007                    RObject::from_parts(
1008                        RValue::Integer(vec![None, Some(1)]),
1009                        Attributes::default(),
1010                    ),
1011                ),
1012                Attribute::new(
1013                    Symbol::new("dimnames"),
1014                    RObject::from_parts(
1015                        RValue::List(vec![names(&["row"]), names(&["Package"])]),
1016                        Attributes::default(),
1017                    ),
1018                ),
1019            ]),
1020        );
1021        assert!(
1022            matches!(PackagesMatrix::from_object(&object), Err(ViewError::InvalidDimensions { path, .. }) if path == "PACKAGES.attributes.dim")
1023        );
1024        let mut object = valid();
1025        set_attributes(
1026            &mut object,
1027            Attributes::new(vec![
1028                Attribute::new(
1029                    Symbol::new("dim"),
1030                    RObject::from_parts(
1031                        RValue::Integer(vec![Some(-1), Some(1)]),
1032                        Attributes::default(),
1033                    ),
1034                ),
1035                Attribute::new(
1036                    Symbol::new("dimnames"),
1037                    RObject::from_parts(
1038                        RValue::List(vec![names(&[]), names(&["Package"])]),
1039                        Attributes::default(),
1040                    ),
1041                ),
1042            ]),
1043        );
1044        assert!(
1045            matches!(PackagesMatrix::from_object(&object), Err(ViewError::InvalidDimensions { path, .. }) if path == "PACKAGES.attributes.dim")
1046        );
1047        let mut object = valid();
1048        replace_dimnames(
1049            &mut object,
1050            vec![names(&["row"]), names(&["Package", "Version"])],
1051        );
1052        assert!(
1053            matches!(PackagesMatrix::from_object(&object), Err(ViewError::UnexpectedLength { path, .. }) if path == "PACKAGES.attributes.dimnames[1]")
1054        );
1055        let mut object = valid();
1056        replace_dimnames(&mut object, vec![names(&["row"]), names(&["Package"])]);
1057        set_value(&mut object, RValue::Character(vec![]));
1058        assert!(
1059            matches!(PackagesMatrix::from_object(&object), Err(ViewError::UnexpectedLength { path, .. }) if path == "PACKAGES")
1060        );
1061        let object = RObject::from_parts(
1062            RValue::Character(vec![RStr::new(
1063                b"x",
1064                REncoding::Native,
1065                NativeEncodingSource::Unknown,
1066            )]),
1067            Attributes::new(vec![Attribute::new(
1068                Symbol::new("dim"),
1069                RObject::from_parts(
1070                    RValue::Integer(vec![Some(1), Some(1)]),
1071                    Attributes::default(),
1072                ),
1073            )]),
1074        );
1075        assert!(
1076            matches!(PackagesMatrix::from_object(&object), Err(ViewError::Missing { path, .. }) if path == "PACKAGES.attributes.dimnames")
1077        );
1078        let mut object = valid();
1079        replace_dimnames(&mut object, vec![names(&["row"])]);
1080        assert!(
1081            matches!(PackagesMatrix::from_object(&object), Err(ViewError::UnexpectedLength { path, .. }) if path == "PACKAGES.attributes.dimnames")
1082        );
1083        let object = matrix(
1084            vec![Some(i32::MAX), Some(i32::MAX)],
1085            vec![
1086                RObject::from_parts(RValue::Null, Attributes::default()),
1087                RObject::from_parts(RValue::Character(vec![]), Attributes::default()),
1088            ],
1089            vec![],
1090        );
1091        // i32::MAX * i32::MAX fits in a 64-bit usize (data-length mismatch)
1092        // but overflows a 32-bit usize (dimension-product overflow).
1093        let error = PackagesMatrix::from_object(&object).unwrap_err();
1094        if usize::BITS >= 64 {
1095            assert!(
1096                matches!(error, ViewError::UnexpectedLength { ref path, .. } if path == "PACKAGES")
1097            );
1098        } else {
1099            assert!(matches!(error, ViewError::InvalidDimensions { .. }));
1100        }
1101    }
1102
1103    #[test]
1104    fn packages_matrix_rejects_na_and_duplicate_column_names() {
1105        let mut object = matrix(
1106            vec![Some(1), Some(2)],
1107            vec![names(&["row"]), names(&["Package", "Package"])],
1108            vec![
1109                RStr::new(b"x", REncoding::Native, NativeEncodingSource::Unknown),
1110                RStr::new(b"y", REncoding::Native, NativeEncodingSource::Unknown),
1111            ],
1112        );
1113        assert!(
1114            matches!(PackagesMatrix::from_object(&object), Err(ViewError::DuplicateName { path, .. }) if path == "PACKAGES.attributes.dimnames[1][1]")
1115        );
1116        replace_dimnames(
1117            &mut object,
1118            vec![
1119                names(&["row"]),
1120                RObject::from_parts(
1121                    RValue::Character(vec![
1122                        RStr::Na,
1123                        RStr::new(b"Version", REncoding::Native, NativeEncodingSource::Unknown),
1124                    ]),
1125                    Attributes::default(),
1126                ),
1127            ],
1128        );
1129        assert!(
1130            matches!(PackagesMatrix::from_object(&object), Err(ViewError::UnexpectedType { path, .. }) if path == "PACKAGES.attributes.dimnames[1][0]")
1131        );
1132    }
1133}