Skip to main content

rd_helpdb/
search.rs

1//! Typed access to an installed package's `Meta/hsearch.rds` index.
2
3use std::{collections::BTreeMap, path::Path};
4
5use rd_rds::{RObject, RValue, file::ReadOptions, matrix::CharacterMatrix};
6
7use crate::{Error, rds::map_file_error};
8
9/// One row of the help-file matrix in `Meta/hsearch.rds`.
10///
11/// Values are kept as optional strings so R `NA` remains distinct from an
12/// empty string.  This view does not interpret IDs or establish references
13/// between matrices.
14#[derive(Debug, Clone, PartialEq, Eq)]
15#[non_exhaustive]
16pub struct HelpSearchBaseEntry {
17    pub package: Option<String>,
18    pub lib_path: Option<String>,
19    pub id: Option<String>,
20    pub name: Option<String>,
21    pub title: Option<String>,
22    pub topic: Option<String>,
23    pub encoding: Option<String>,
24}
25
26/// One row of the alias matrix in `Meta/hsearch.rds`.
27#[derive(Debug, Clone, PartialEq, Eq)]
28#[non_exhaustive]
29pub struct HelpSearchAliasEntry {
30    pub alias: Option<String>,
31    pub id: Option<String>,
32    pub package: Option<String>,
33}
34
35/// One row of the keyword matrix in `Meta/hsearch.rds`.
36#[derive(Debug, Clone, PartialEq, Eq)]
37#[non_exhaustive]
38pub struct HelpSearchKeywordEntry {
39    pub keyword: Option<String>,
40    pub id: Option<String>,
41    pub package: Option<String>,
42}
43
44/// One row of the concept matrix in `Meta/hsearch.rds`.
45#[derive(Debug, Clone, PartialEq, Eq)]
46#[non_exhaustive]
47pub struct HelpSearchConceptEntry {
48    pub concept: Option<String>,
49    pub id: Option<String>,
50    pub package: Option<String>,
51}
52
53/// A validated, owned view of the four matrices in `Meta/hsearch.rds`.
54///
55/// The matrices and their rows retain the stored order, duplicate values,
56/// empty strings, and R `NA` values.  This is a metadata reader only: it does
57/// not implement `utils::help.search()` matching, ranking, or cross-package
58/// lookup policy.
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct HelpSearchIndex {
61    base: Vec<HelpSearchBaseEntry>,
62    aliases: Vec<HelpSearchAliasEntry>,
63    keywords: Vec<HelpSearchKeywordEntry>,
64    concepts: Vec<HelpSearchConceptEntry>,
65}
66
67impl HelpSearchIndex {
68    /// Reads `Meta/hsearch.rds` below an explicitly named installed-package
69    /// directory.
70    ///
71    /// `Ok(None)` means that the file is absent.  A present, valid empty
72    /// index is returned as `Ok(Some(index))`; all decoding and schema errors
73    /// are returned as errors.
74    pub fn read_installed(package_dir: impl AsRef<Path>) -> Result<Option<Self>, Error> {
75        Self::read_installed_with_options(package_dir, &ReadOptions::default())
76    }
77
78    /// Reads installed help-search metadata with explicit RDS read options.
79    pub fn read_installed_with_options(
80        package_dir: impl AsRef<Path>,
81        options: &ReadOptions,
82    ) -> Result<Option<Self>, Error> {
83        let path = package_dir.as_ref().join("Meta/hsearch.rds");
84        match rd_rds::file::read_with_options(&path, options) {
85            Ok(root) => Self::from_object(&root).map(Some),
86            Err(rd_rds::file::ReadError::Io { source, .. })
87                if source.kind() == std::io::ErrorKind::NotFound =>
88            {
89                Ok(None)
90            }
91            Err(error) => Err(map_file_error(&path, error)),
92        }
93    }
94
95    /// Validates and copies a decoded `Meta/hsearch.rds` object.
96    ///
97    /// The root must be an unnamed list of four character matrices. Current
98    /// R schemas use `Package, LibPath, ID, Name, Title, Topic, Encoding` for
99    /// the first matrix and singular `Alias`, `Keyword`, and `Concept` names
100    /// for the remaining matrices. The known R 1.8/R 2.9-compatible lower-case
101    /// topic columns, plural relation names, and six- or seven-column forms
102    /// without or with `Encoding` are accepted as bounded compatibility cases.
103    /// Matrix column order is resolved by column name, but unknown, missing,
104    /// duplicate, or NA column names are rejected. A missing legacy `Encoding`
105    /// column is represented as `Some("")`, matching R's compatibility reader.
106    /// Historical compatibility is limited to these documented shapes; this
107    /// does not promise decoding every file produced by every old R release.
108    /// RDS serialization versions 2 and 3 are accepted according to the
109    /// profiles supported by `rd-rds`.
110    pub fn from_object(root: &RObject) -> Result<Self, Error> {
111        let RValue::List(matrices) = root.value() else {
112            return Err(malformed("root is not a list"));
113        };
114        if root.attributes().get("names").is_some() {
115            return Err(malformed("root list must be unnamed"));
116        }
117        if matrices.len() != 4 {
118            return Err(malformed(format!(
119                "root has {} matrices, expected 4",
120                matrices.len()
121            )));
122        }
123
124        let base = CharacterMatrix::from_object(&matrices[0])
125            .map_err(|error| malformed(format!("invalid Base matrix: {error}")))?;
126        let aliases = CharacterMatrix::from_object(&matrices[1])
127            .map_err(|error| malformed(format!("invalid Aliases matrix: {error}")))?;
128        let keywords = CharacterMatrix::from_object(&matrices[2])
129            .map_err(|error| malformed(format!("invalid Keywords matrix: {error}")))?;
130        let concepts = CharacterMatrix::from_object(&matrices[3])
131            .map_err(|error| malformed(format!("invalid Concepts matrix: {error}")))?;
132
133        let base_columns = columns(
134            &base,
135            "Base",
136            &[
137                "Package", "LibPath", "ID", "Name", "Title", "Topic", "Encoding",
138            ],
139            &["Package", "LibPath", "ID", "Name", "Title", "Topic"],
140            &["Package", "LibPath", "ID", "name", "title", "topic"],
141            &[
142                "Package", "LibPath", "ID", "name", "title", "topic", "Encoding",
143            ],
144        )?;
145        let alias_columns = relation_columns(&aliases, "Aliases", "Alias", "Aliases")?;
146        let keyword_columns = relation_columns(&keywords, "Keywords", "Keyword", "Keywords")?;
147        let concept_columns = relation_columns(&concepts, "Concepts", "Concept", "Concepts")?;
148
149        let mut base_entries = Vec::with_capacity(base.nrow());
150        for row in 0..base.nrow() {
151            base_entries.push(HelpSearchBaseEntry {
152                package: cell(&base, row, base_columns[0]),
153                lib_path: cell(&base, row, base_columns[1]),
154                id: cell(&base, row, base_columns[2]),
155                name: cell(&base, row, base_columns[3]),
156                title: cell(&base, row, base_columns[4]),
157                topic: cell(&base, row, base_columns[5]),
158                encoding: base_columns
159                    .get(6)
160                    .map(|&column| cell(&base, row, column))
161                    .unwrap_or_else(|| Some(String::new())),
162            });
163        }
164
165        let mut alias_entries = Vec::with_capacity(aliases.nrow());
166        for row in 0..aliases.nrow() {
167            alias_entries.push(HelpSearchAliasEntry {
168                alias: cell(&aliases, row, alias_columns[0]),
169                id: cell(&aliases, row, alias_columns[1]),
170                package: cell(&aliases, row, alias_columns[2]),
171            });
172        }
173        let mut keyword_entries = Vec::with_capacity(keywords.nrow());
174        for row in 0..keywords.nrow() {
175            keyword_entries.push(HelpSearchKeywordEntry {
176                keyword: cell(&keywords, row, keyword_columns[0]),
177                id: cell(&keywords, row, keyword_columns[1]),
178                package: cell(&keywords, row, keyword_columns[2]),
179            });
180        }
181        let mut concept_entries = Vec::with_capacity(concepts.nrow());
182        for row in 0..concepts.nrow() {
183            concept_entries.push(HelpSearchConceptEntry {
184                concept: cell(&concepts, row, concept_columns[0]),
185                id: cell(&concepts, row, concept_columns[1]),
186                package: cell(&concepts, row, concept_columns[2]),
187            });
188        }
189
190        Ok(Self {
191            base: base_entries,
192            aliases: alias_entries,
193            keywords: keyword_entries,
194            concepts: concept_entries,
195        })
196    }
197
198    /// Iterates over help-file rows in stored order.
199    pub fn base_entries(&self) -> impl ExactSizeIterator<Item = &HelpSearchBaseEntry> {
200        self.base.iter()
201    }
202
203    /// Iterates over alias rows in stored order.
204    pub fn aliases(&self) -> impl ExactSizeIterator<Item = &HelpSearchAliasEntry> {
205        self.aliases.iter()
206    }
207
208    /// Iterates over keyword rows in stored order.
209    pub fn keywords(&self) -> impl ExactSizeIterator<Item = &HelpSearchKeywordEntry> {
210        self.keywords.iter()
211    }
212
213    /// Iterates over concept rows in stored order.
214    pub fn concepts(&self) -> impl ExactSizeIterator<Item = &HelpSearchConceptEntry> {
215        self.concepts.iter()
216    }
217
218    /// Returns the number of help-file rows.
219    pub fn base_len(&self) -> usize {
220        self.base.len()
221    }
222
223    /// Returns the number of stored alias rows.
224    pub fn aliases_len(&self) -> usize {
225        self.aliases.len()
226    }
227
228    /// Returns the number of stored keyword rows.
229    pub fn keywords_len(&self) -> usize {
230        self.keywords.len()
231    }
232
233    /// Returns the number of stored concept rows.
234    pub fn concepts_len(&self) -> usize {
235        self.concepts.len()
236    }
237
238    /// Returns whether all four matrices contain no rows.
239    pub fn is_empty(&self) -> bool {
240        self.base.is_empty()
241            && self.aliases.is_empty()
242            && self.keywords.is_empty()
243            && self.concepts.is_empty()
244    }
245}
246
247impl TryFrom<&RObject> for HelpSearchIndex {
248    type Error = Error;
249
250    fn try_from(value: &RObject) -> Result<Self, Self::Error> {
251        Self::from_object(value)
252    }
253}
254
255fn columns(
256    matrix: &CharacterMatrix,
257    label: &str,
258    modern: &[&str],
259    modern_without_encoding: &[&str],
260    legacy_without_encoding: &[&str],
261    legacy: &[&str],
262) -> Result<Vec<usize>, Error> {
263    let names = matrix_column_positions(matrix, label)?;
264    let matched = if names.len() == modern.len() && same_names(&names, modern) {
265        modern
266    } else if names.len() == modern_without_encoding.len()
267        && same_names(&names, modern_without_encoding)
268    {
269        modern_without_encoding
270    } else if names.len() == legacy_without_encoding.len()
271        && same_names(&names, legacy_without_encoding)
272    {
273        legacy_without_encoding
274    } else if names.len() == legacy.len() && same_names(&names, legacy) {
275        legacy
276    } else {
277        return Err(malformed(format!(
278            "{label} matrix has unsupported columns: {:?}",
279            names.keys().collect::<Vec<_>>()
280        )));
281    };
282    Ok(matched
283        .iter()
284        .filter_map(|name| names.get(*name).copied())
285        .collect())
286}
287
288fn relation_columns(
289    matrix: &CharacterMatrix,
290    label: &str,
291    current_value: &str,
292    legacy_value: &str,
293) -> Result<[usize; 3], Error> {
294    let names = matrix_column_positions(matrix, label)?;
295    let current = [current_value, "ID", "Package"];
296    let legacy = [legacy_value, "ID", "Package"];
297    let expected = if names.len() == 3 && same_names(&names, &current) {
298        current
299    } else if names.len() == 3 && same_names(&names, &legacy) {
300        legacy
301    } else {
302        return Err(malformed(format!(
303            "{label} matrix has unsupported columns: {:?}",
304            names.keys().collect::<Vec<_>>()
305        )));
306    };
307    Ok([names[expected[0]], names[expected[1]], names[expected[2]]])
308}
309
310fn matrix_column_positions<'a>(
311    matrix: &'a CharacterMatrix,
312    label: &str,
313) -> Result<BTreeMap<&'a str, usize>, Error> {
314    let mut positions = BTreeMap::new();
315    for column in 0..matrix.ncol() {
316        let Some(name) = matrix.column_name(column) else {
317            return Err(malformed(format!(
318                "{label} matrix has missing or NA column name at position {column}"
319            )));
320        };
321        if positions.insert(name, column).is_some() {
322            return Err(malformed(format!(
323                "{label} matrix has duplicate column name {name:?}"
324            )));
325        }
326    }
327    Ok(positions)
328}
329
330fn same_names(names: &BTreeMap<&str, usize>, expected: &[&str]) -> bool {
331    expected.iter().all(|name| names.contains_key(name))
332}
333
334fn cell(matrix: &CharacterMatrix, row: usize, column: usize) -> Option<String> {
335    matrix.get(row, column).flatten().map(str::to_owned)
336}
337
338fn malformed(message: impl Into<String>) -> Error {
339    Error::MalformedIndex(format!("invalid Meta/hsearch.rds: {}", message.into()))
340}