rd-rds 0.5.0

Read-only reader for the subset of R's RDS serialization format used by installed-package help databases
Documentation
//! Typed access to installed-package metadata, installed code databases, and
//! CRAN-like repository indexes.
//!
//! [`PackageMeta`] covers the `packageDescription2` shape written by R for an
//! installed package. It is intentionally a typed reader rather than a
//! general R object model: values are validated and copied into owned Rust
//! data during construction. The raw [`crate::RObject`] remains available for
//! metadata shapes not covered here.
//!
//! A missing package field is represented by an outer [`Option`], while an R
//! `NA` character value is represented by an inner [`Option`]. Thus
//! [`PackageMeta::description_field`] can distinguish an absent field from a
//! present field whose value is `NA`. [`Built`] is the deliberate exception:
//! its optional character accessors collapse both cases to `None`, because an
//! absent and an `NA` build field carry the same meaning to consumers.
//!
//! [`PackagesMatrix`] covers CRAN-like `PACKAGES.rds` character matrices. It
//! validates and owns the matrix data, absorbing R's column-major layout.
//! Row/column lookup uses an outer `Option` for a missing column or row and an
//! inner `Option` for an R `NA` cell.
//!
//! [`NamespaceMetadata`] provides a separate owned view of static declarations
//! from `Meta/nsInfo.rds`. It does not represent runtime namespace state or
//! stored lazy-load bindings.
//!
//! [`PackageMeta::read_installed`] and [`NamespaceMetadata::read_installed`]
//! are thin convenience readers for the canonical `Meta/package.rds` and
//! `Meta/nsInfo.rds` paths below a caller-supplied installed package
//! directory. They do not discover packages or apply runtime policy. Their
//! `_with_options` variants expose the bounded [`crate::file::ReadOptions`]
//! used by the standalone file layer.

use std::path::{Path, PathBuf};

use thiserror::Error;

use crate::{RObject, RStr, RValue};

mod namespace;

#[cfg(feature = "lazyload")]
mod installed_code;

#[cfg(feature = "lazyload")]
pub use crate::inspection::{
    BodyValidation, DefaultPresence, FailureCause, FailurePhase, Formal, FormalsInspection,
    FormalsNotApplicable, FormalsUnavailable, FunctionFormals, InspectionExtent, PrefixFailure,
    StoredKind, StoredObjectInspection,
};

#[cfg(feature = "lazyload")]
pub use installed_code::{
    CodeDbGeneration, CodeDbProvenance, InstalledCodeDb, InstalledCodeError, InstalledCodeOptions,
    StoredBinding,
};

pub use namespace::{
    ImportedName, MetadataField, NamespaceExport, NamespaceImport, NamespaceMetadata, S3MethodName,
    S3Registration,
};

/// Errors reading a typed view from an installed-package metadata artifact.
///
/// The file layer and typed view layer remain separate: callers can inspect
/// whether a failure came from bounded file reading or from validating the
/// decoded metadata object. The artifact path is retained for both variants,
/// including read failures that do not carry a path themselves (for example,
/// a decompression or size-limit error).
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum InstalledMetadataError {
    /// Reading the canonical installed metadata artifact failed.
    #[error("failed to read installed metadata at {path}: {source}")]
    Read {
        path: PathBuf,
        #[source]
        source: crate::file::ReadError,
    },
    /// The artifact was read, but its decoded object did not match the typed
    /// metadata view's supported schema.
    #[error("invalid installed metadata at {path}: {source}")]
    View {
        path: PathBuf,
        #[source]
        source: ViewError,
    },
}

impl InstalledMetadataError {
    /// Returns the canonical artifact path selected for the read.
    #[must_use]
    pub fn path(&self) -> &Path {
        match self {
            Self::Read { path, .. } | Self::View { path, .. } => path,
        }
    }
}

/// A construction error from the typed installed-package metadata view.
#[derive(Debug, Error, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum ViewError {
    #[error("missing value at {path}")]
    Missing { path: String, field: Option<String> },
    #[error("unexpected type at {path}: expected {expected}, got {actual}")]
    UnexpectedType {
        path: String,
        field: Option<String>,
        expected: &'static str,
        actual: &'static str,
    },
    #[error("unexpected length at {path}: expected {expected}, got {actual}")]
    UnexpectedLength {
        path: String,
        field: Option<String>,
        expected: String,
        actual: usize,
    },
    #[error("duplicate name at {path}")]
    DuplicateName { path: String, field: Option<String> },
    #[error("invalid string encoding at {path}")]
    InvalidStringEncoding {
        path: String,
        field: Option<String>,
        row: Option<usize>,
        column: Option<String>,
    },
    #[error("invalid dimensions at {path}: {reason}")]
    InvalidDimensions {
        path: String,
        field: Option<String>,
        reason: String,
    },
    #[error("invalid package version at {path}: {reason}")]
    InvalidPackageVersion {
        path: String,
        field: Option<String>,
        reason: String,
    },
}

impl ViewError {
    /// Returns the logical location of the invalid value.
    pub fn path(&self) -> String {
        match self {
            Self::Missing { path, .. }
            | Self::UnexpectedType { path, .. }
            | Self::UnexpectedLength { path, .. }
            | Self::DuplicateName { path, .. }
            | Self::InvalidStringEncoding { path, .. }
            | Self::InvalidDimensions { path, .. }
            | Self::InvalidPackageVersion { path, .. } => path.clone(),
        }
    }

    /// Returns the metadata field associated with the error, when there is one.
    pub fn field(&self) -> Option<&str> {
        match self {
            Self::Missing { field, .. }
            | Self::UnexpectedType { field, .. }
            | Self::UnexpectedLength { field, .. }
            | Self::DuplicateName { field, .. }
            | Self::InvalidStringEncoding { field, .. }
            | Self::InvalidDimensions { field, .. }
            | Self::InvalidPackageVersion { field, .. } => field.as_deref(),
        }
    }

    /// Returns row context when the error was caused by a matrix cell.
    pub fn row(&self) -> Option<usize> {
        match self {
            Self::InvalidStringEncoding { row, .. } => *row,
            _ => None,
        }
    }

    /// Returns column-name context when the error was caused by a matrix cell.
    pub fn column(&self) -> Option<&str> {
        match self {
            Self::InvalidStringEncoding { column, .. } => column.as_deref(),
            _ => None,
        }
    }
}

mod meta;
mod packages;

pub use meta::{Built, PackageMeta, PackageVersion};
pub use packages::{PackagesColumn, PackagesMatrix, PackagesRow};

fn read_installed_object(
    package_dir: impl AsRef<Path>,
    artifact: &str,
    options: &crate::file::ReadOptions,
) -> Result<(PathBuf, RObject), InstalledMetadataError> {
    let path = package_dir.as_ref().join("Meta").join(artifact);
    let object = crate::file::read_with_options(&path, options).map_err(|source| {
        InstalledMetadataError::Read {
            path: path.clone(),
            source,
        }
    })?;
    Ok((path, object))
}

fn expect_list<'a>(
    object: &'a RObject,
    path: &str,
    field: Option<&str>,
) -> Result<&'a [RObject], ViewError> {
    match &object.value() {
        RValue::List(values) => Ok(values),
        value => Err(unexpected_type(path, field, "list", value.kind_name())),
    }
}

fn named_values<'a>(
    object: &'a RObject,
    path: &str,
    field: Option<&str>,
) -> Result<&'a [RStr], ViewError> {
    let Some(attribute) = object.attributes().get("names") else {
        return Err(missing(format!("{path}.names"), field.map(str::to_owned)));
    };
    match &attribute.value() {
        RValue::Character(values) => Ok(values),
        value => Err(unexpected_type(
            &format!("{path}.names"),
            field,
            "character vector",
            value.kind_name(),
        )),
    }
}

fn decode_optional(
    value: &RStr,
    path: &str,
    field: Option<&str>,
) -> Result<Option<String>, ViewError> {
    match value.as_str() {
        None => Ok(None),
        Some(Ok(value)) => Ok(Some(value.into_owned())),
        Some(Err(_)) => Err(ViewError::InvalidStringEncoding {
            path: path.to_owned(),
            field: field.map(str::to_owned),
            row: None,
            column: None,
        }),
    }
}

fn invalid_dimensions(path: &str, reason: &str) -> ViewError {
    ViewError::InvalidDimensions {
        path: path.to_owned(),
        field: None,
        reason: reason.to_owned(),
    }
}

fn decode_required(value: &RStr, path: &str, field: Option<&str>) -> Result<String, ViewError> {
    match value.as_str() {
        None => Err(unexpected_type(path, field, "non-NA string", "NA")),
        Some(Ok(value)) => Ok(value.into_owned()),
        Some(Err(_)) => Err(ViewError::InvalidStringEncoding {
            path: path.to_owned(),
            field: field.map(str::to_owned),
            row: None,
            column: None,
        }),
    }
}

fn missing(path: impl Into<String>, field: Option<String>) -> ViewError {
    ViewError::Missing {
        path: path.into(),
        field,
    }
}

fn unexpected_type(
    path: &str,
    field: Option<&str>,
    expected: &'static str,
    actual: &'static str,
) -> ViewError {
    ViewError::UnexpectedType {
        path: path.to_owned(),
        field: field.map(str::to_owned),
        expected,
        actual,
    }
}

fn unexpected_length(
    path: &str,
    field: Option<&str>,
    expected: String,
    actual: usize,
) -> ViewError {
    ViewError::UnexpectedLength {
        path: path.to_owned(),
        field: field.map(str::to_owned),
        expected,
        actual,
    }
}

fn duplicate(path: &str, field: Option<String>) -> ViewError {
    ViewError::DuplicateName {
        path: path.to_owned(),
        field,
    }
}

trait ValueKindName {
    fn kind_name(&self) -> &'static str;
}

impl ValueKindName for RValue {
    fn kind_name(&self) -> &'static str {
        match self {
            Self::Null => "null",
            Self::Logical(_) => "logical vector",
            Self::Integer(_) => "integer vector",
            Self::Real(_) => "real vector",
            Self::Character(_) => "character vector",
            Self::List(_) => "list",
            Self::Symbol(_) => "symbol",
            Self::Persisted(_) => "persisted value",
            Self::Environment(_) => "environment",
        }
    }
}

#[cfg(test)]
mod tests;