use std::{
collections::HashMap,
path::{Path, PathBuf},
sync::OnceLock,
};
use rd_rds::{RObject, lazyload::LazyLoadDb};
use crate::{DemoIndex, Error, VignetteIndex, rds::read_rds_file, util::rstr_to_string};
#[derive(Debug)]
pub struct PackageHelpDb {
package: String,
pkg_dir: PathBuf,
help_dir: PathBuf,
data_path: PathBuf,
lazyload: LazyLoadDb,
alias_index: OnceLock<AliasIndex>,
}
#[derive(Debug)]
struct AliasIndex {
entries: Vec<(String, String)>,
lookup: HashMap<String, usize>,
}
impl AliasIndex {
fn from_root(root: &RObject) -> Result<Self, Error> {
let rd_rds::RValue::Character(values) = &root.value() else {
return Err(Error::MalformedIndex(
"aliases.rds root is not a character vector".into(),
));
};
let names = root.names().ok_or_else(|| {
Error::MalformedIndex("aliases.rds is missing a names attribute".into())
})?;
if names.len() != values.len() {
return Err(Error::MalformedIndex(format!(
"aliases.rds has {} names but {} values",
names.len(),
values.len()
)));
}
let entries = names
.iter()
.zip(values.iter())
.map(|(alias, topic)| Ok((rstr_to_string(alias)?, rstr_to_string(topic)?)))
.collect::<Result<Vec<(String, String)>, Error>>()?;
let mut lookup = HashMap::with_capacity(entries.len());
for (position, (alias, _)) in entries.iter().enumerate() {
lookup.insert(alias.clone(), position);
}
Ok(Self { entries, lookup })
}
}
impl PackageHelpDb {
pub fn open(pkg_dir: impl AsRef<Path>) -> Result<Self, Error> {
let pkg_dir = pkg_dir.as_ref();
let package = pkg_dir
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| {
Error::MalformedIndex(format!(
"cannot determine a package name from directory {}",
pkg_dir.display()
))
})?
.to_string();
let help_dir = pkg_dir.join("help");
let rdx_path = help_dir.join(format!("{package}.rdx"));
let rdb_path = help_dir.join(format!("{package}.rdb"));
let lazyload = LazyLoadDb::open(&rdx_path, &rdb_path)
.map_err(|error| map_lazyload_open_error(error, &rdx_path))?;
Ok(Self {
package,
pkg_dir: pkg_dir.to_path_buf(),
help_dir,
data_path: rdb_path,
lazyload,
alias_index: OnceLock::new(),
})
}
pub fn package(&self) -> &str {
&self.package
}
pub fn topics(&self) -> impl Iterator<Item = &str> {
self.lazyload
.variables()
.iter()
.map(|variable| variable.name())
}
pub fn reference_keys(&self) -> impl Iterator<Item = &str> {
self.lazyload
.references()
.iter()
.map(|(name, _)| name.as_str())
}
pub fn raw_topic(&self, topic: &str) -> Result<RObject, Error> {
if self.lazyload.variable(topic).is_none() {
return Err(Error::UnknownTopic {
topic: topic.to_string(),
});
}
let record = self
.lazyload
.read(topic)
.map_err(|error| map_lazyload_record_error(error, topic, false, &self.data_path))?;
rd_rds::parse(record.decompressed_bytes()).map_err(Error::Rds)
}
pub fn reference(&self, key: &str) -> Result<RObject, Error> {
if self.lazyload.reference(key).is_none() {
return Err(Error::UnknownReference {
key: key.to_string(),
});
}
let record = self
.lazyload
.read_reference(key)
.map_err(|error| map_lazyload_record_error(error, key, true, &self.data_path))?;
rd_rds::parse(record.decompressed_bytes()).map_err(Error::Rds)
}
pub fn aliases(&self) -> Result<Vec<(String, String)>, Error> {
Ok(self.alias_index()?.entries.clone())
}
pub fn resolve_alias(&self, alias: &str) -> Result<Option<&str>, Error> {
let index = self.alias_index()?;
Ok(index
.lookup
.get(alias)
.map(|&position| index.entries[position].1.as_str()))
}
fn alias_index(&self) -> Result<&AliasIndex, Error> {
if let Some(index) = self.alias_index.get() {
return Ok(index);
}
let path = self.help_dir.join("aliases.rds");
let root = read_rds_file(&path)?;
let index = AliasIndex::from_root(&root)?;
let _ = self.alias_index.set(index);
Ok(self
.alias_index
.get()
.expect("alias_index was just set above"))
}
pub fn search_index(&self) -> Result<RObject, Error> {
let path = self.pkg_dir.join("Meta").join("hsearch.rds");
read_rds_file(&path)
}
pub fn vignettes(&self) -> Result<Option<VignetteIndex>, Error> {
let path = self.pkg_dir.join("Meta").join("vignette.rds");
let Some(root) = read_optional_rds_file(&path)? else {
return Ok(None);
};
VignetteIndex::from_object(&root).map(Some)
}
pub fn demos(&self) -> Result<Option<DemoIndex>, Error> {
let path = self.pkg_dir.join("Meta").join("demo.rds");
let Some(root) = read_optional_rds_file(&path)? else {
return Ok(None);
};
DemoIndex::from_object(&root).map(Some)
}
}
fn map_lazyload_open_error(error: rd_rds::lazyload::Error, index_path: &Path) -> Error {
match error {
rd_rds::lazyload::Error::Io { path, source } => Error::io(path, source),
rd_rds::lazyload::Error::IndexSizeLimitExceeded { limit } => {
Error::IndexSizeLimitExceeded { limit }
}
rd_rds::lazyload::Error::IndexChanged => Error::DatabaseChanged {
path: index_path.to_path_buf(),
},
other => Error::MalformedIndex(format!("invalid lazy-load database: {other}")),
}
}
fn map_lazyload_record_error(
error: rd_rds::lazyload::Error,
name: &str,
reference: bool,
data_path: &Path,
) -> Error {
match error {
rd_rds::lazyload::Error::Io { path, source } => Error::io(path, source),
rd_rds::lazyload::Error::UnknownReference { .. } if reference => {
Error::UnknownReference { key: name.into() }
}
rd_rds::lazyload::Error::UnsupportedRecordReference { name } if reference => {
Error::UnsupportedReference { key: name }
}
rd_rds::lazyload::Error::CompressionUnsupported { compression } => {
Error::UnsupportedRecordCompression { compression }
}
rd_rds::lazyload::Error::StoredRecordSizeLimitExceeded { limit } => {
Error::StoredRecordSizeLimitExceeded { limit }
}
rd_rds::lazyload::Error::DecompressedRecordSizeLimitExceeded { limit } => {
Error::DecompressedRecordSizeLimitExceeded { limit }
}
rd_rds::lazyload::Error::DataFileChanged => Error::DatabaseChanged {
path: data_path.to_path_buf(),
},
rd_rds::lazyload::Error::RecordSizeMismatch { declared, actual } => {
Error::RecordSizeMismatch {
expected: declared,
actual,
}
}
other => Error::MalformedIndex(format!("failed to read record {name:?}: {other}")),
}
}
fn read_optional_rds_file(path: &Path) -> Result<Option<RObject>, Error> {
match read_rds_file(path) {
Ok(root) => Ok(Some(root)),
Err(Error::Io { ref source, .. }) if source.kind() == std::io::ErrorKind::NotFound => {
Ok(None)
}
Err(error) => Err(error),
}
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use super::*;
fn dup_aliases_root() -> RObject {
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/data/aliases_vector_dup_v3.rds");
read_rds_file(&path).unwrap_or_else(|err| panic!("read {}: {err}", path.display()))
}
#[test]
fn alias_index_resolves_duplicates_last_wins() {
let root = dup_aliases_root();
let index = AliasIndex::from_root(&root).expect("AliasIndex::from_root");
assert_eq!(
index.entries,
vec![
("shared".to_string(), "first-topic".to_string()),
("unique".to_string(), "unique-topic".to_string()),
("shared".to_string(), "second-topic".to_string()),
]
);
let shared_position = *index.lookup.get("shared").expect("'shared' in lookup");
assert_eq!(index.entries[shared_position].1, "second-topic");
let unique_position = *index.lookup.get("unique").expect("'unique' in lookup");
assert_eq!(index.entries[unique_position].1, "unique-topic");
}
#[test]
fn maps_lazyload_limits_staleness_and_unsupported_references() {
let data_path = PathBuf::from("/tmp/example.rdb");
assert!(matches!(
map_lazyload_open_error(
rd_rds::lazyload::Error::IndexSizeLimitExceeded { limit: 7 },
Path::new("/tmp/example.rdx")
),
Error::IndexSizeLimitExceeded { limit: 7 }
));
assert!(matches!(
map_lazyload_open_error(
rd_rds::lazyload::Error::IndexChanged,
Path::new("/tmp/example.rdx")
),
Error::DatabaseChanged { path } if path == Path::new("/tmp/example.rdx")
));
assert!(matches!(
map_lazyload_record_error(
rd_rds::lazyload::Error::StoredRecordSizeLimitExceeded { limit: 11 },
"topic",
false,
&data_path,
),
Error::StoredRecordSizeLimitExceeded { limit: 11 }
));
assert!(matches!(
map_lazyload_record_error(
rd_rds::lazyload::Error::DecompressedRecordSizeLimitExceeded { limit: 13 },
"topic",
false,
&data_path,
),
Error::DecompressedRecordSizeLimitExceeded { limit: 13 }
));
assert!(matches!(
map_lazyload_record_error(
rd_rds::lazyload::Error::DataFileChanged,
"topic",
false,
&data_path,
),
Error::DatabaseChanged { path } if path == data_path
));
assert!(matches!(
map_lazyload_record_error(
rd_rds::lazyload::Error::CompressionUnsupported {
compression: rd_rds::lazyload::Compression::Bzip2,
},
"topic",
false,
&data_path,
),
Error::UnsupportedRecordCompression {
compression: rd_rds::lazyload::Compression::Bzip2,
}
));
assert!(matches!(
map_lazyload_record_error(
rd_rds::lazyload::Error::UnknownReference { name: "missing".into() },
"missing",
true,
&data_path,
),
Error::UnknownReference { key } if key == "missing"
));
assert!(matches!(
map_lazyload_record_error(
rd_rds::lazyload::Error::UnsupportedRecordReference { name: "env".into() },
"env",
true,
&data_path,
),
Error::UnsupportedReference { key } if key == "env"
));
}
}