1use 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#[derive(Debug)]
35pub struct PackageHelpDb {
36 package: String,
37 pkg_dir: PathBuf,
38 help_dir: PathBuf,
39 data_path: PathBuf,
40 lazyload: LazyLoadDb,
41 alias_index: OnceLock<AliasIndex>,
45}
46
47#[derive(Debug)]
55struct AliasIndex {
56 entries: Vec<(String, String)>,
57 lookup: HashMap<String, usize>,
58}
59
60impl AliasIndex {
61 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 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 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 pub fn package(&self) -> &str {
136 &self.package
137 }
138
139 pub fn topics(&self) -> impl Iterator<Item = &str> {
141 self.lazyload
142 .variables()
143 .iter()
144 .map(|variable| variable.name())
145 }
146
147 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 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 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 pub fn aliases(&self) -> Result<Vec<(String, String)>, Error> {
196 Ok(self.alias_index()?.entries.clone())
197 }
198
199 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 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 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 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 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 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 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 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 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}