1use 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#[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#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct VignetteIndex {
25 entries: Vec<VignetteEntry>,
26}
27
28impl VignetteIndex {
29 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 pub fn entries(&self) -> impl ExactSizeIterator<Item = &VignetteEntry> {
109 self.entries.iter()
110 }
111
112 pub fn len(&self) -> usize {
114 self.entries.len()
115 }
116
117 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
149fn 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}