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> {
235 let path = self.pkg_dir.join("Meta").join("hsearch.rds");
236 read_rds_file(&path)
237 }
238
239 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 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 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 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 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}