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`].
234    ///
235    /// Use [`crate::HelpSearchIndex`] for a validated standalone typed view;
236    /// this compatibility method intentionally keeps its raw return type.
237    pub fn search_index(&self) -> Result<RObject, Error> {
238        let path = self.pkg_dir.join("Meta").join("hsearch.rds");
239        read_rds_file(&path)
240    }
241
242    /// Reads and validates `Meta/vignette.rds`.
243    ///
244    /// Unlike [`PackageHelpDb::aliases`] and [`PackageHelpDb::search_index`],
245    /// a missing file returns `Ok(None)`: packages without vignettes normally
246    /// omit this file. An existing zero-row data frame returns a present,
247    /// empty index, while malformed files and non-`NotFound` I/O failures are
248    /// errors.
249    pub fn vignettes(&self) -> Result<Option<VignetteIndex>, Error> {
250        let path = self.pkg_dir.join("Meta").join("vignette.rds");
251        let Some(root) = read_optional_rds_file(&path)? else {
252            return Ok(None);
253        };
254        VignetteIndex::from_object(&root).map(Some)
255    }
256
257    /// Reads and validates `Meta/demo.rds`.
258    ///
259    /// Unlike [`PackageHelpDb::aliases`] and [`PackageHelpDb::search_index`],
260    /// a missing file returns `Ok(None)`: packages without demos normally omit
261    /// this file. An existing zero-row matrix returns a present, empty index,
262    /// while malformed files and non-`NotFound` I/O failures are errors.
263    pub fn demos(&self) -> Result<Option<DemoIndex>, Error> {
264        let path = self.pkg_dir.join("Meta").join("demo.rds");
265        let Some(root) = read_optional_rds_file(&path)? else {
266            return Ok(None);
267        };
268        DemoIndex::from_object(&root).map(Some)
269    }
270}
271
272fn map_lazyload_open_error(error: rd_rds::lazyload::Error, index_path: &Path) -> Error {
273    match error {
274        rd_rds::lazyload::Error::Io { path, source } => Error::io(path, source),
275        rd_rds::lazyload::Error::IndexSizeLimitExceeded { limit } => {
276            Error::IndexSizeLimitExceeded { limit }
277        }
278        rd_rds::lazyload::Error::IndexChanged => Error::DatabaseChanged {
279            path: index_path.to_path_buf(),
280        },
281        other => Error::MalformedIndex(format!("invalid lazy-load database: {other}")),
282    }
283}
284
285fn map_lazyload_record_error(
286    error: rd_rds::lazyload::Error,
287    name: &str,
288    reference: bool,
289    data_path: &Path,
290) -> Error {
291    match error {
292        rd_rds::lazyload::Error::Io { path, source } => Error::io(path, source),
293        rd_rds::lazyload::Error::UnknownReference { .. } if reference => {
294            Error::UnknownReference { key: name.into() }
295        }
296        rd_rds::lazyload::Error::UnsupportedRecordReference { name } if reference => {
297            Error::UnsupportedReference { key: name }
298        }
299        rd_rds::lazyload::Error::CompressionUnsupported { compression } => {
300            Error::UnsupportedRecordCompression { compression }
301        }
302        rd_rds::lazyload::Error::StoredRecordSizeLimitExceeded { limit } => {
303            Error::StoredRecordSizeLimitExceeded { limit }
304        }
305        rd_rds::lazyload::Error::DecompressedRecordSizeLimitExceeded { limit } => {
306            Error::DecompressedRecordSizeLimitExceeded { limit }
307        }
308        rd_rds::lazyload::Error::DataFileChanged => Error::DatabaseChanged {
309            path: data_path.to_path_buf(),
310        },
311        rd_rds::lazyload::Error::RecordSizeMismatch { declared, actual } => {
312            Error::RecordSizeMismatch {
313                expected: declared,
314                actual,
315            }
316        }
317        other => Error::MalformedIndex(format!("failed to read record {name:?}: {other}")),
318    }
319}
320
321fn read_optional_rds_file(path: &Path) -> Result<Option<RObject>, Error> {
322    match read_rds_file(path) {
323        Ok(root) => Ok(Some(root)),
324        Err(Error::Io { ref source, .. }) if source.kind() == std::io::ErrorKind::NotFound => {
325            Ok(None)
326        }
327        Err(error) => Err(error),
328    }
329}
330
331#[cfg(test)]
332mod tests {
333    use std::path::PathBuf;
334
335    use super::*;
336
337    /// `tests/fixtures/data/aliases_vector_dup_v3.rds`: a named character
338    /// vector `c(shared = "first-topic", unique = "unique-topic", shared =
339    /// "second-topic")`, generated by `tests/fixtures/generate_fixtures.R`
340    /// (section 9b) -- `RObject`/`RValue` can't be hand-built outside
341    /// `rd-rds` (its `Attributes` constructor is crate-private), so the
342    /// duplicate-alias input has to come from a real parsed `.rds` file
343    /// rather than a struct literal.
344    fn dup_aliases_root() -> RObject {
345        let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
346            .join("tests/fixtures/data/aliases_vector_dup_v3.rds");
347        read_rds_file(&path).unwrap_or_else(|err| panic!("read {}: {err}", path.display()))
348    }
349
350    #[test]
351    fn alias_index_resolves_duplicates_last_wins() {
352        let root = dup_aliases_root();
353        let index = AliasIndex::from_root(&root).expect("AliasIndex::from_root");
354
355        // `entries` keeps both occurrences of "shared", in on-disk order.
356        assert_eq!(
357            index.entries,
358            vec![
359                ("shared".to_string(), "first-topic".to_string()),
360                ("unique".to_string(), "unique-topic".to_string()),
361                ("shared".to_string(), "second-topic".to_string()),
362            ]
363        );
364
365        // `lookup` resolves the duplicate to the LAST occurrence.
366        let shared_position = *index.lookup.get("shared").expect("'shared' in lookup");
367        assert_eq!(index.entries[shared_position].1, "second-topic");
368        let unique_position = *index.lookup.get("unique").expect("'unique' in lookup");
369        assert_eq!(index.entries[unique_position].1, "unique-topic");
370    }
371
372    #[test]
373    fn maps_lazyload_limits_staleness_and_unsupported_references() {
374        let data_path = PathBuf::from("/tmp/example.rdb");
375        assert!(matches!(
376            map_lazyload_open_error(
377                rd_rds::lazyload::Error::IndexSizeLimitExceeded { limit: 7 },
378                Path::new("/tmp/example.rdx")
379            ),
380            Error::IndexSizeLimitExceeded { limit: 7 }
381        ));
382        assert!(matches!(
383            map_lazyload_open_error(
384                rd_rds::lazyload::Error::IndexChanged,
385                Path::new("/tmp/example.rdx")
386            ),
387            Error::DatabaseChanged { path } if path == Path::new("/tmp/example.rdx")
388        ));
389        assert!(matches!(
390            map_lazyload_record_error(
391                rd_rds::lazyload::Error::StoredRecordSizeLimitExceeded { limit: 11 },
392                "topic",
393                false,
394                &data_path,
395            ),
396            Error::StoredRecordSizeLimitExceeded { limit: 11 }
397        ));
398        assert!(matches!(
399            map_lazyload_record_error(
400                rd_rds::lazyload::Error::DecompressedRecordSizeLimitExceeded { limit: 13 },
401                "topic",
402                false,
403                &data_path,
404            ),
405            Error::DecompressedRecordSizeLimitExceeded { limit: 13 }
406        ));
407        assert!(matches!(
408            map_lazyload_record_error(
409                rd_rds::lazyload::Error::DataFileChanged,
410                "topic",
411                false,
412                &data_path,
413            ),
414            Error::DatabaseChanged { path } if path == data_path
415        ));
416        assert!(matches!(
417            map_lazyload_record_error(
418                rd_rds::lazyload::Error::CompressionUnsupported {
419                    compression: rd_rds::lazyload::Compression::Bzip2,
420                },
421                "topic",
422                false,
423                &data_path,
424            ),
425            Error::UnsupportedRecordCompression {
426                compression: rd_rds::lazyload::Compression::Bzip2,
427            }
428        ));
429        assert!(matches!(
430            map_lazyload_record_error(
431                rd_rds::lazyload::Error::UnknownReference { name: "missing".into() },
432                "missing",
433                true,
434                &data_path,
435            ),
436            Error::UnknownReference { key } if key == "missing"
437        ));
438        assert!(matches!(
439            map_lazyload_record_error(
440                rd_rds::lazyload::Error::UnsupportedRecordReference { name: "env".into() },
441                "env",
442                true,
443                &data_path,
444            ),
445            Error::UnsupportedReference { key } if key == "env"
446        ));
447    }
448}