Skip to main content

rd_helpdb/
db.rs

1//! [`PackageHelpDb`], the main entry point for reading an installed
2//! package's compiled help database.
3
4use std::{
5    collections::HashMap,
6    path::{Path, PathBuf},
7    sync::OnceLock,
8};
9
10use rd_rds::{RObject, lazyload::LazyLoadDb};
11
12use crate::{DemoIndex, Error, VignetteIndex, rds::read_rds_file, util::rstr_to_string};
13
14/// A reader over an installed R package's compiled help database:
15/// `help/<pkg>.rdx` + `help/<pkg>.rdb`, plus the sibling `help/aliases.rds`
16/// and `Meta/hsearch.rds`, `Meta/vignette.rds`, and `Meta/demo.rds` files.
17///
18/// The `.rdx` index is parsed eagerly at [`PackageHelpDb::open`] (it's
19/// small); `.rdb` records are read on demand by seeking into the `.rdb`
20/// file, since records are independent and there's no need to hold the
21/// whole file in memory. `help/aliases.rds` is parsed lazily, on the first
22/// call to [`PackageHelpDb::aliases`] or [`PackageHelpDb::resolve_alias`],
23/// and the resulting index is cached for the lifetime of this reader -- a
24/// failed parse is NOT cached, so a transient failure (a missing or
25/// momentarily corrupt file) is retried on the next call rather than
26/// poisoning every subsequent lookup. A duplicated alias resolves to its
27/// LAST occurrence, matching the last-wins semantics of R's own
28/// `list2env()`-based alias loader; [`PackageHelpDb::aliases`] still
29/// returns every entry, including duplicates, in on-disk order, since
30/// that's what oracle comparisons against R's `readRDS()` expect.
31/// `search_index()` re-reads `Meta/hsearch.rds` on every call rather than
32/// caching -- it's read infrequently relative to alias/topic lookups, and
33/// re-reading keeps the API simple and always up to date.
34#[derive(Debug)]
35pub struct PackageHelpDb {
36    package: String,
37    pkg_dir: PathBuf,
38    help_dir: PathBuf,
39    data_path: PathBuf,
40    lazyload: LazyLoadDb,
41    /// Lazily-built `help/aliases.rds` index, cached after the first
42    /// successful parse. See the struct-level docs above for the caching
43    /// and duplicate-alias semantics.
44    alias_index: OnceLock<AliasIndex>,
45}
46
47/// A parsed `help/aliases.rds` index: the alias -> topic pairs in on-disk
48/// order (`entries`, duplicates included), plus a `lookup` map from alias
49/// to its index into `entries`.
50///
51/// `lookup` is built by inserting `entries` in file order, so a later
52/// duplicate alias overwrites an earlier one -- i.e. it always resolves to
53/// the LAST occurrence, matching R's own `list2env()`-based alias loader.
54#[derive(Debug)]
55struct AliasIndex {
56    entries: Vec<(String, String)>,
57    lookup: HashMap<String, usize>,
58}
59
60impl AliasIndex {
61    /// Builds an [`AliasIndex`] from the parsed `aliases.rds` root object,
62    /// a named character vector mapping alias -> topic.
63    fn from_root(root: &RObject) -> Result<Self, Error> {
64        let rd_rds::RValue::Character(values) = &root.value() else {
65            return Err(Error::MalformedIndex(
66                "aliases.rds root is not a character vector".into(),
67            ));
68        };
69        let names = root.names().ok_or_else(|| {
70            Error::MalformedIndex("aliases.rds is missing a names attribute".into())
71        })?;
72        if names.len() != values.len() {
73            return Err(Error::MalformedIndex(format!(
74                "aliases.rds has {} names but {} values",
75                names.len(),
76                values.len()
77            )));
78        }
79
80        let entries = names
81            .iter()
82            .zip(values.iter())
83            .map(|(alias, topic)| Ok((rstr_to_string(alias)?, rstr_to_string(topic)?)))
84            .collect::<Result<Vec<(String, String)>, Error>>()?;
85
86        // Insert in file order so a later duplicate alias overwrites an
87        // earlier one's index -- last-wins.
88        let mut lookup = HashMap::with_capacity(entries.len());
89        for (position, (alias, _)) in entries.iter().enumerate() {
90            lookup.insert(alias.clone(), position);
91        }
92
93        Ok(Self { entries, lookup })
94    }
95}
96
97impl PackageHelpDb {
98    /// Opens `<pkg_dir>/help/<pkg>.rdx` (+ `.rdb`), where `<pkg>` is the
99    /// basename of `pkg_dir`, e.g. `/opt/R/4.6.1/lib/R/library/utils`.
100    ///
101    /// Library-path discovery (R's `.libPaths()`) is out of scope: callers
102    /// must supply the installed package directory explicitly.
103    pub fn open(pkg_dir: impl AsRef<Path>) -> Result<Self, Error> {
104        let pkg_dir = pkg_dir.as_ref();
105        let package = pkg_dir
106            .file_name()
107            .and_then(|name| name.to_str())
108            .ok_or_else(|| {
109                Error::MalformedIndex(format!(
110                    "cannot determine a package name from directory {}",
111                    pkg_dir.display()
112                ))
113            })?
114            .to_string();
115
116        let help_dir = pkg_dir.join("help");
117        let rdx_path = help_dir.join(format!("{package}.rdx"));
118        let rdb_path = help_dir.join(format!("{package}.rdb"));
119
120        let lazyload = LazyLoadDb::open(&rdx_path, &rdb_path)
121            .map_err(|error| map_lazyload_open_error(error, &rdx_path))?;
122
123        Ok(Self {
124            package,
125            pkg_dir: pkg_dir.to_path_buf(),
126            help_dir,
127            data_path: rdb_path,
128            lazyload,
129            alias_index: OnceLock::new(),
130        })
131    }
132
133    /// The package name (the basename of the directory passed to
134    /// [`PackageHelpDb::open`]).
135    pub fn package(&self) -> &str {
136        &self.package
137    }
138
139    /// Topic names present in the `.rdx` `variables` map, in index order.
140    pub fn topics(&self) -> impl Iterator<Item = &str> {
141        self.lazyload
142            .variables()
143            .iter()
144            .map(|variable| variable.name())
145    }
146
147    /// Persistence keys present in the `.rdx` `references` map (e.g.
148    /// `"env::0"`), in index order.
149    pub fn reference_keys(&self) -> impl Iterator<Item = &str> {
150        self.lazyload
151            .references()
152            .iter()
153            .map(|(name, _)| name.as_str())
154    }
155
156    /// Raw decoded Rd object for `topic`: a `.rdx` `variables` lookup, a
157    /// `.rdb` record fetch, zlib decompression, and `rd_rds::parse`.
158    pub fn raw_topic(&self, topic: &str) -> Result<RObject, Error> {
159        if self.lazyload.variable(topic).is_none() {
160            return Err(Error::UnknownTopic {
161                topic: topic.to_string(),
162            });
163        }
164        let record = self
165            .lazyload
166            .read(topic)
167            .map_err(|error| map_lazyload_record_error(error, topic, false, &self.data_path))?;
168        rd_rds::parse(record.decompressed_bytes()).map_err(Error::Rds)
169    }
170
171    /// Fetches a record via the `.rdx` `references` map (persistence keys
172    /// like `"env::0"`). Same mechanics as [`PackageHelpDb::raw_topic`],
173    /// different key space.
174    ///
175    /// `rd-helpdb` does not attempt to resolve `PERSISTSXP` nodes into
176    /// environments; this only exposes the raw record a reference key
177    /// points at.
178    pub fn reference(&self, key: &str) -> Result<RObject, Error> {
179        if self.lazyload.reference(key).is_none() {
180            return Err(Error::UnknownReference {
181                key: key.to_string(),
182            });
183        }
184        let record = self
185            .lazyload
186            .read_reference(key)
187            .map_err(|error| map_lazyload_record_error(error, key, true, &self.data_path))?;
188        rd_rds::parse(record.decompressed_bytes()).map_err(Error::Rds)
189    }
190
191    /// alias -> topic map from `help/aliases.rds`, in on-disk order,
192    /// including duplicate aliases if the file has any (see the
193    /// struct-level docs for how [`PackageHelpDb::resolve_alias`] handles
194    /// duplicates).
195    pub fn aliases(&self) -> Result<Vec<(String, String)>, Error> {
196        Ok(self.alias_index()?.entries.clone())
197    }
198
199    /// Resolves `alias` to its topic name via `help/aliases.rds`. If
200    /// `alias` occurs more than once in the file, resolves to the topic of
201    /// its LAST occurrence, matching R's own `list2env()`-based alias
202    /// loader.
203    pub fn resolve_alias(&self, alias: &str) -> Result<Option<&str>, Error> {
204        let index = self.alias_index()?;
205        Ok(index
206            .lookup
207            .get(alias)
208            .map(|&position| index.entries[position].1.as_str()))
209    }
210
211    /// Returns the cached `help/aliases.rds` index, parsing and caching it
212    /// on the first call. Parse failures are not cached: on error, the
213    /// `OnceLock` is left unset so the next call retries from scratch.
214    fn alias_index(&self) -> Result<&AliasIndex, Error> {
215        if let Some(index) = self.alias_index.get() {
216            return Ok(index);
217        }
218
219        let path = self.help_dir.join("aliases.rds");
220        let root = read_rds_file(&path)?;
221        let index = AliasIndex::from_root(&root)?;
222
223        // If another thread won the race and set the index first, that's
224        // fine -- both builds are equivalent, so just fetch whichever one
225        // landed.
226        let _ = self.alias_index.set(index);
227        Ok(self
228            .alias_index
229            .get()
230            .expect("alias_index was just set above"))
231    }
232
233    /// Decoded `Meta/hsearch.rds` as a raw [`RObject`] (no typed model yet).
234    pub fn search_index(&self) -> Result<RObject, Error> {
235        let path = self.pkg_dir.join("Meta").join("hsearch.rds");
236        read_rds_file(&path)
237    }
238
239    /// Reads and validates `Meta/vignette.rds`.
240    ///
241    /// Unlike [`PackageHelpDb::aliases`] and [`PackageHelpDb::search_index`],
242    /// a missing file returns `Ok(None)`: packages without vignettes normally
243    /// omit this file. An existing zero-row data frame returns a present,
244    /// empty index, while malformed files and non-`NotFound` I/O failures are
245    /// errors.
246    pub fn vignettes(&self) -> Result<Option<VignetteIndex>, Error> {
247        let path = self.pkg_dir.join("Meta").join("vignette.rds");
248        let Some(root) = read_optional_rds_file(&path)? else {
249            return Ok(None);
250        };
251        VignetteIndex::from_object(&root).map(Some)
252    }
253
254    /// Reads and validates `Meta/demo.rds`.
255    ///
256    /// Unlike [`PackageHelpDb::aliases`] and [`PackageHelpDb::search_index`],
257    /// a missing file returns `Ok(None)`: packages without demos normally omit
258    /// this file. An existing zero-row matrix returns a present, empty index,
259    /// while malformed files and non-`NotFound` I/O failures are errors.
260    pub fn demos(&self) -> Result<Option<DemoIndex>, Error> {
261        let path = self.pkg_dir.join("Meta").join("demo.rds");
262        let Some(root) = read_optional_rds_file(&path)? else {
263            return Ok(None);
264        };
265        DemoIndex::from_object(&root).map(Some)
266    }
267}
268
269fn map_lazyload_open_error(error: rd_rds::lazyload::Error, index_path: &Path) -> Error {
270    match error {
271        rd_rds::lazyload::Error::Io { path, source } => Error::io(path, source),
272        rd_rds::lazyload::Error::IndexSizeLimitExceeded { limit } => {
273            Error::IndexSizeLimitExceeded { limit }
274        }
275        rd_rds::lazyload::Error::IndexChanged => Error::DatabaseChanged {
276            path: index_path.to_path_buf(),
277        },
278        other => Error::MalformedIndex(format!("invalid lazy-load database: {other}")),
279    }
280}
281
282fn map_lazyload_record_error(
283    error: rd_rds::lazyload::Error,
284    name: &str,
285    reference: bool,
286    data_path: &Path,
287) -> Error {
288    match error {
289        rd_rds::lazyload::Error::Io { path, source } => Error::io(path, source),
290        rd_rds::lazyload::Error::UnknownReference { .. } if reference => {
291            Error::UnknownReference { key: name.into() }
292        }
293        rd_rds::lazyload::Error::UnsupportedRecordReference { name } if reference => {
294            Error::UnsupportedReference { key: name }
295        }
296        rd_rds::lazyload::Error::CompressionUnsupported { compression } => {
297            Error::UnsupportedRecordCompression { compression }
298        }
299        rd_rds::lazyload::Error::StoredRecordSizeLimitExceeded { limit } => {
300            Error::StoredRecordSizeLimitExceeded { limit }
301        }
302        rd_rds::lazyload::Error::DecompressedRecordSizeLimitExceeded { limit } => {
303            Error::DecompressedRecordSizeLimitExceeded { limit }
304        }
305        rd_rds::lazyload::Error::DataFileChanged => Error::DatabaseChanged {
306            path: data_path.to_path_buf(),
307        },
308        rd_rds::lazyload::Error::RecordSizeMismatch { declared, actual } => {
309            Error::RecordSizeMismatch {
310                expected: declared,
311                actual,
312            }
313        }
314        other => Error::MalformedIndex(format!("failed to read record {name:?}: {other}")),
315    }
316}
317
318fn read_optional_rds_file(path: &Path) -> Result<Option<RObject>, Error> {
319    match read_rds_file(path) {
320        Ok(root) => Ok(Some(root)),
321        Err(Error::Io { ref source, .. }) if source.kind() == std::io::ErrorKind::NotFound => {
322            Ok(None)
323        }
324        Err(error) => Err(error),
325    }
326}
327
328#[cfg(test)]
329mod tests {
330    use std::path::PathBuf;
331
332    use super::*;
333
334    /// `tests/fixtures/data/aliases_vector_dup_v3.rds`: a named character
335    /// vector `c(shared = "first-topic", unique = "unique-topic", shared =
336    /// "second-topic")`, generated by `tests/fixtures/generate_fixtures.R`
337    /// (section 9b) -- `RObject`/`RValue` can't be hand-built outside
338    /// `rd-rds` (its `Attributes` constructor is crate-private), so the
339    /// duplicate-alias input has to come from a real parsed `.rds` file
340    /// rather than a struct literal.
341    fn dup_aliases_root() -> RObject {
342        let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
343            .join("tests/fixtures/data/aliases_vector_dup_v3.rds");
344        read_rds_file(&path).unwrap_or_else(|err| panic!("read {}: {err}", path.display()))
345    }
346
347    #[test]
348    fn alias_index_resolves_duplicates_last_wins() {
349        let root = dup_aliases_root();
350        let index = AliasIndex::from_root(&root).expect("AliasIndex::from_root");
351
352        // `entries` keeps both occurrences of "shared", in on-disk order.
353        assert_eq!(
354            index.entries,
355            vec![
356                ("shared".to_string(), "first-topic".to_string()),
357                ("unique".to_string(), "unique-topic".to_string()),
358                ("shared".to_string(), "second-topic".to_string()),
359            ]
360        );
361
362        // `lookup` resolves the duplicate to the LAST occurrence.
363        let shared_position = *index.lookup.get("shared").expect("'shared' in lookup");
364        assert_eq!(index.entries[shared_position].1, "second-topic");
365        let unique_position = *index.lookup.get("unique").expect("'unique' in lookup");
366        assert_eq!(index.entries[unique_position].1, "unique-topic");
367    }
368
369    #[test]
370    fn maps_lazyload_limits_staleness_and_unsupported_references() {
371        let data_path = PathBuf::from("/tmp/example.rdb");
372        assert!(matches!(
373            map_lazyload_open_error(
374                rd_rds::lazyload::Error::IndexSizeLimitExceeded { limit: 7 },
375                Path::new("/tmp/example.rdx")
376            ),
377            Error::IndexSizeLimitExceeded { limit: 7 }
378        ));
379        assert!(matches!(
380            map_lazyload_open_error(
381                rd_rds::lazyload::Error::IndexChanged,
382                Path::new("/tmp/example.rdx")
383            ),
384            Error::DatabaseChanged { path } if path == Path::new("/tmp/example.rdx")
385        ));
386        assert!(matches!(
387            map_lazyload_record_error(
388                rd_rds::lazyload::Error::StoredRecordSizeLimitExceeded { limit: 11 },
389                "topic",
390                false,
391                &data_path,
392            ),
393            Error::StoredRecordSizeLimitExceeded { limit: 11 }
394        ));
395        assert!(matches!(
396            map_lazyload_record_error(
397                rd_rds::lazyload::Error::DecompressedRecordSizeLimitExceeded { limit: 13 },
398                "topic",
399                false,
400                &data_path,
401            ),
402            Error::DecompressedRecordSizeLimitExceeded { limit: 13 }
403        ));
404        assert!(matches!(
405            map_lazyload_record_error(
406                rd_rds::lazyload::Error::DataFileChanged,
407                "topic",
408                false,
409                &data_path,
410            ),
411            Error::DatabaseChanged { path } if path == data_path
412        ));
413        assert!(matches!(
414            map_lazyload_record_error(
415                rd_rds::lazyload::Error::CompressionUnsupported {
416                    compression: rd_rds::lazyload::Compression::Bzip2,
417                },
418                "topic",
419                false,
420                &data_path,
421            ),
422            Error::UnsupportedRecordCompression {
423                compression: rd_rds::lazyload::Compression::Bzip2,
424            }
425        ));
426        assert!(matches!(
427            map_lazyload_record_error(
428                rd_rds::lazyload::Error::UnknownReference { name: "missing".into() },
429                "missing",
430                true,
431                &data_path,
432            ),
433            Error::UnknownReference { key } if key == "missing"
434        ));
435        assert!(matches!(
436            map_lazyload_record_error(
437                rd_rds::lazyload::Error::UnsupportedRecordReference { name: "env".into() },
438                "env",
439                true,
440                &data_path,
441            ),
442            Error::UnsupportedReference { key } if key == "env"
443        ));
444    }
445}