Skip to main content

rd_helpdb/
vignette.rs

1//! Typed access to an installed package's `Meta/vignette.rds` index.
2
3use std::collections::BTreeMap;
4
5use rd_rds::{RObject, RStr, RValue};
6
7use crate::{Error, util::rstr_to_string};
8
9const REQUIRED_COLUMNS: [&str; 6] = ["File", "Title", "PDF", "R", "Depends", "Keywords"];
10
11/// One row of `Meta/vignette.rds`.
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct VignetteEntry {
14    pub file: String,
15    pub title: String,
16    pub pdf: String,
17    pub r: String,
18    pub depends: Vec<String>,
19    pub keywords: Vec<String>,
20}
21
22/// A validated, owned `Meta/vignette.rds` data frame.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct VignetteIndex {
25    entries: Vec<VignetteEntry>,
26}
27
28impl VignetteIndex {
29    /// Validates and copies a decoded `Meta/vignette.rds` object.
30    pub fn from_object(root: &RObject) -> Result<Self, Error> {
31        let columns = match root.value() {
32            RValue::List(columns) => columns,
33            _ => return Err(malformed("root is not a list")),
34        };
35        require_data_frame_class(root)?;
36
37        let names = root
38            .names()
39            .ok_or_else(|| malformed("missing character names attribute"))?;
40        if names.len() != columns.len() {
41            return Err(malformed(format!(
42                "has {} column names but {} columns",
43                names.len(),
44                columns.len()
45            )));
46        }
47
48        let mut positions = BTreeMap::new();
49        for (index, name) in names.iter().enumerate() {
50            let name = rstr_to_string(name).map_err(|error| {
51                malformed(format!("invalid column name at position {index}: {error}"))
52            })?;
53            if positions.insert(name.clone(), index).is_some() {
54                return Err(malformed(format!("duplicate column name {name:?}")));
55            }
56        }
57        for name in REQUIRED_COLUMNS {
58            if !positions.contains_key(name) {
59                return Err(malformed(format!("missing required column {name:?}")));
60            }
61        }
62
63        let files = character_column(columns, &positions, "File")?;
64        let titles = character_column(columns, &positions, "Title")?;
65        let pdfs = character_column(columns, &positions, "PDF")?;
66        let r_sources = character_column(columns, &positions, "R")?;
67        let depends = list_column(columns, &positions, "Depends")?;
68        let keywords = list_column(columns, &positions, "Keywords")?;
69        let nrow = files.len();
70
71        for (name, actual) in [
72            ("Title", titles.len()),
73            ("PDF", pdfs.len()),
74            ("R", r_sources.len()),
75            ("Depends", depends.len()),
76            ("Keywords", keywords.len()),
77        ] {
78            if actual != nrow {
79                return Err(malformed(format!(
80                    "column {name:?} has length {actual}, expected {nrow}"
81                )));
82            }
83        }
84
85        let row_names_count = row_names_len(root)?;
86        if row_names_count != nrow {
87            return Err(malformed(format!(
88                "row.names implies {row_names_count} rows, but columns have {nrow}"
89            )));
90        }
91
92        let mut entries = Vec::with_capacity(nrow);
93        for row in 0..nrow {
94            entries.push(VignetteEntry {
95                file: required_string(&files[row], "File", row)?,
96                title: required_string(&titles[row], "Title", row)?,
97                pdf: required_string(&pdfs[row], "PDF", row)?,
98                r: required_string(&r_sources[row], "R", row)?,
99                depends: string_vector(&depends[row], "Depends", row)?,
100                keywords: string_vector(&keywords[row], "Keywords", row)?,
101            });
102        }
103
104        Ok(Self { entries })
105    }
106
107    /// Iterates over entries in their on-disk row order.
108    pub fn entries(&self) -> impl ExactSizeIterator<Item = &VignetteEntry> {
109        self.entries.iter()
110    }
111
112    /// Returns the number of vignette entries.
113    pub fn len(&self) -> usize {
114        self.entries.len()
115    }
116
117    /// Returns whether the index contains no vignette entries.
118    pub fn is_empty(&self) -> bool {
119        self.entries.is_empty()
120    }
121}
122
123impl TryFrom<&RObject> for VignetteIndex {
124    type Error = Error;
125
126    fn try_from(value: &RObject) -> Result<Self, Self::Error> {
127        Self::from_object(value)
128    }
129}
130
131fn require_data_frame_class(root: &RObject) -> Result<(), Error> {
132    let classes = root
133        .class()
134        .ok_or_else(|| malformed("missing character class attribute"))?;
135    for (index, class) in classes.iter().enumerate() {
136        match class.as_str() {
137            Some(Ok(value)) if value == "data.frame" => return Ok(()),
138            Some(Ok(_)) | None => {}
139            Some(Err(error)) => {
140                return Err(malformed(format!(
141                    "invalid class string at position {index}: {error}"
142                )));
143            }
144        }
145    }
146    Err(malformed("class does not include \"data.frame\""))
147}
148
149/// Returns the row count implied by a data frame's `row.names` attribute.
150///
151/// R represents `row.names` either as an explicit vector of length `nrow`
152/// (character labels, or an integer sequence) or, for the common
153/// automatic-row-names case, as a compact two-element integer form
154/// `c(NA, -nrow)` (also written `c(NA, nrow)`; only the magnitude of the
155/// second element matters) -- the same wire shape `nrow()`/`.row_names_info()`
156/// recognize internally. A plain `length()` would misread the compact form
157/// as claiming 2 rows regardless of the data frame's real size, so it must
158/// be special-cased here.
159fn row_names_len(root: &RObject) -> Result<usize, Error> {
160    let row_names = root
161        .attributes()
162        .get("row.names")
163        .ok_or_else(|| malformed("missing row.names attribute"))?;
164    match row_names.value() {
165        RValue::Integer(values) if values.len() == 2 && values[0].is_none() => {
166            let count =
167                values[1].ok_or_else(|| malformed("row.names compact form has an NA row count"))?;
168            Ok(count.unsigned_abs() as usize)
169        }
170        RValue::Integer(values) => Ok(values.len()),
171        RValue::Character(values) => Ok(values.len()),
172        _ => Err(malformed(
173            "row.names attribute is not an integer or character vector",
174        )),
175    }
176}
177
178fn character_column<'a>(
179    columns: &'a [RObject],
180    positions: &BTreeMap<String, usize>,
181    name: &str,
182) -> Result<&'a [RStr], Error> {
183    match columns[positions[name]].value() {
184        RValue::Character(values) => Ok(values),
185        _ => Err(malformed(format!(
186            "column {name:?} is not a character vector"
187        ))),
188    }
189}
190
191fn list_column<'a>(
192    columns: &'a [RObject],
193    positions: &BTreeMap<String, usize>,
194    name: &str,
195) -> Result<&'a [RObject], Error> {
196    match columns[positions[name]].value() {
197        RValue::List(values) => Ok(values),
198        _ => Err(malformed(format!("column {name:?} is not a list"))),
199    }
200}
201
202fn required_string(value: &RStr, column: &str, row: usize) -> Result<String, Error> {
203    rstr_to_string(value).map_err(|error| {
204        malformed(format!(
205            "invalid value at row {row}, column {column:?}: {error}"
206        ))
207    })
208}
209
210fn string_vector(object: &RObject, column: &str, row: usize) -> Result<Vec<String>, Error> {
211    let values = match object.value() {
212        RValue::Character(values) => values,
213        _ => {
214            return Err(malformed(format!(
215                "value at row {row}, column {column:?} is not a character vector"
216            )));
217        }
218    };
219    values
220        .iter()
221        .enumerate()
222        .map(|(index, value)| {
223            rstr_to_string(value).map_err(|error| {
224                malformed(format!(
225                    "invalid value at row {row}, column {column:?}, element {index}: {error}"
226                ))
227            })
228        })
229        .collect()
230}
231
232fn malformed(message: impl Into<String>) -> Error {
233    Error::MalformedIndex(format!("invalid Meta/vignette.rds: {}", message.into()))
234}
235
236#[cfg(test)]
237mod tests {
238    use std::path::PathBuf;
239
240    use crate::read_rds_file;
241
242    use super::*;
243
244    fn fixture(name: &str) -> RObject {
245        let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
246            .join("tests/fixtures/data")
247            .join(name);
248        read_rds_file(&path).unwrap_or_else(|error| panic!("read {}: {error}", path.display()))
249    }
250
251    #[test]
252    fn parses_reordered_columns_and_list_columns() {
253        let index = VignetteIndex::from_object(&fixture("vignette_reordered_v3.rds"))
254            .expect("valid vignette fixture");
255        assert_eq!(index.len(), 2);
256        assert_eq!(
257            index.entries().collect::<Vec<_>>(),
258            vec![
259                &VignetteEntry {
260                    file: "first.Rnw".into(),
261                    title: "First vignette".into(),
262                    pdf: "first.pdf".into(),
263                    r: "first.R".into(),
264                    depends: vec!["tools".into(), "stats".into()],
265                    keywords: vec!["models".into()],
266                },
267                &VignetteEntry {
268                    file: "second.Rmd".into(),
269                    title: "Second vignette".into(),
270                    pdf: "second.html".into(),
271                    r: "second.R".into(),
272                    depends: Vec::new(),
273                    keywords: vec!["".into()],
274                },
275            ]
276        );
277    }
278
279    #[test]
280    fn accepts_zero_row_data_frame() {
281        let index = VignetteIndex::from_object(&fixture("vignette_empty_v3.rds"))
282            .expect("empty vignette fixture");
283        assert_eq!(index.len(), 0);
284        assert!(index.is_empty());
285        assert_eq!(index.entries().len(), 0);
286    }
287
288    #[test]
289    fn rejects_missing_required_column() {
290        let error = VignetteIndex::from_object(&fixture("vignette_missing_column_v3.rds"))
291            .expect_err("missing column must fail");
292        assert!(
293            error
294                .to_string()
295                .contains("missing required column \"Keywords\"")
296        );
297    }
298
299    #[test]
300    fn rejects_row_names_mismatched_with_column_length() {
301        let error = VignetteIndex::from_object(&fixture("vignette_row_names_mismatch_v3.rds"))
302            .expect_err("row.names/column length mismatch must fail");
303        assert!(
304            error
305                .to_string()
306                .contains("row.names implies 3 rows, but columns have 2")
307        );
308    }
309}