1use std::{error::Error, fmt, path::PathBuf};
4
5use grep_matcher::Matcher;
6use grep_regex::RegexMatcherBuilder;
7use mant_protocol::{
8 CatalogDocumentKind, CatalogMatchRank, CatalogQuery, CatalogSchema, DocumentAddress,
9 DocumentCatalog, DocumentSummary, MarkdownOrigin, SearchCase, SearchSyntax,
10};
11
12use mant_sources::{
13 BUILTIN_CONTENT_PRIORITY, RegisteredDocument, RegisteredDocumentOrigin, SourceConfigError,
14 list_registered_documents,
15};
16
17use crate::{ManualIndex, discover_manual_roots};
18
19#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
21pub enum AvailableDocumentKind {
22 Markdown,
24 Manual,
26}
27
28#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
30pub enum AvailableDocumentOrigin {
31 Documents,
33 Source(String),
35 ManualPath,
37}
38
39#[derive(Clone, Debug, Eq, PartialEq)]
41pub struct AvailableDocument {
42 pub name: String,
44 pub logical_path: String,
46 pub kind: AvailableDocumentKind,
48 pub manual_section: Option<String>,
50 pub path: PathBuf,
52 pub origin: AvailableDocumentOrigin,
54 pub source_priority: Option<i32>,
56}
57
58#[derive(Clone, Debug, Eq, PartialEq)]
60pub enum CatalogError {
61 EmptyPattern,
63 PatternTooLong,
65 InvalidLimit,
67 ConflictingSelectors,
69 InvalidPattern(String),
71}
72
73impl fmt::Display for CatalogError {
74 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
75 match self {
76 Self::EmptyPattern => formatter.write_str("catalog pattern must not be empty"),
77 Self::PatternTooLong => {
78 formatter.write_str("catalog pattern exceeds the 4096-byte limit")
79 }
80 Self::InvalidLimit => formatter.write_str("catalog limit must be between 1 and 10000"),
81 Self::ConflictingSelectors => {
82 formatter.write_str("catalog source and manual-section filters cannot be combined")
83 }
84 Self::InvalidPattern(message) => {
85 write!(formatter, "invalid catalog pattern: {message}")
86 }
87 }
88 }
89}
90
91impl Error for CatalogError {}
92
93pub fn list_available_documents() -> Result<Vec<AvailableDocument>, SourceConfigError> {
100 let manuals = ManualIndex::from_roots(discover_manual_roots());
101 Ok(list_available_documents_from(
102 list_registered_documents()?,
103 manuals.pages(),
104 ))
105}
106
107pub fn query_available_documents(
113 documents: &[AvailableDocument],
114 query: &CatalogQuery,
115) -> Result<DocumentCatalog, CatalogError> {
116 validate_catalog_query(query)?;
117 let compiled_pattern = query
118 .pattern
119 .as_deref()
120 .map(|pattern| build_matcher(pattern, query.syntax, query.case))
121 .transpose()?;
122 let mut filtered = documents
123 .iter()
124 .filter(|document| {
125 query.kind.is_none_or(|kind| match kind {
126 CatalogDocumentKind::Markdown => document.kind == AvailableDocumentKind::Markdown,
127 CatalogDocumentKind::Manual => document.kind == AvailableDocumentKind::Manual,
128 }) && query.manual_section.as_ref().is_none_or(|section| {
129 document.manual_section.as_ref().is_some_and(|value| value == section)
130 }) && query.source.as_ref().is_none_or(|source| {
131 matches!(&document.origin, AvailableDocumentOrigin::Source(value) if value == source)
132 })
133 })
134 .filter_map(|document| {
135 let match_catalog_path = query
136 .pattern
137 .as_deref()
138 .is_some_and(|pattern| pattern.contains('/'));
139 let matched = compiled_pattern.as_ref().map_or(Ok(true), |matcher| {
140 matcher
141 .is_match(document.name.as_bytes())
142 .and_then(|matched| {
143 if matched {
144 Ok(true)
145 } else {
146 matcher.is_match(document.logical_path.as_bytes())
147 }
148 })
149 .and_then(|matched| {
150 if matched {
151 Ok(true)
152 } else if !match_catalog_path {
153 Ok(false)
154 } else {
155 matcher.is_match(available_catalog_path(document).as_bytes())
156 }
157 })
158 });
159 matched.ok().filter(|matched| *matched).map(|_| document)
160 })
161 .collect::<Vec<_>>();
162 filtered.sort_by(|left, right| {
163 match_rank(left, query)
164 .cmp(&match_rank(right, query))
165 .then_with(|| {
166 left.logical_path
167 .to_lowercase()
168 .cmp(&right.logical_path.to_lowercase())
169 })
170 .then_with(|| left.logical_path.cmp(&right.logical_path))
171 .then_with(|| left.name.to_lowercase().cmp(&right.name.to_lowercase()))
172 .then_with(|| left.name.cmp(&right.name))
173 .then_with(|| compare_precedence(left, right))
174 .then_with(|| left.manual_section.cmp(&right.manual_section))
175 .then_with(|| left.origin.cmp(&right.origin))
176 });
177
178 let total = filtered.len();
179 let offset = usize::try_from(query.offset)
180 .unwrap_or(usize::MAX)
181 .min(total);
182 let limit = usize::try_from(query.limit).unwrap_or(usize::MAX);
183 let end = offset.saturating_add(limit).min(total);
184 let documents = filtered[offset..end]
185 .iter()
186 .copied()
187 .map(document_summary)
188 .collect::<Vec<_>>();
189 Ok(DocumentCatalog {
190 schema: CatalogSchema::V7,
191 total: u32::try_from(total).unwrap_or(u32::MAX),
192 returned: u32::try_from(documents.len()).unwrap_or(u32::MAX),
193 offset: u32::try_from(offset).unwrap_or(u32::MAX),
194 truncated: end < total,
195 next_offset: (end < total).then(|| u32::try_from(end).unwrap_or(u32::MAX)),
196 documents,
197 })
198}
199
200pub fn discover_documents(query: &CatalogQuery) -> Result<DocumentCatalog, String> {
207 let documents = list_available_documents().map_err(|error| error.to_string())?;
208 query_available_documents(&documents, query).map_err(|error| error.to_string())
209}
210
211fn validate_catalog_query(query: &CatalogQuery) -> Result<(), CatalogError> {
212 if query.pattern.as_deref().is_some_and(str::is_empty) {
213 return Err(CatalogError::EmptyPattern);
214 }
215 if query
216 .pattern
217 .as_ref()
218 .is_some_and(|pattern| pattern.len() > 4096)
219 {
220 return Err(CatalogError::PatternTooLong);
221 }
222 if query.limit == 0 || query.limit > 10_000 {
223 return Err(CatalogError::InvalidLimit);
224 }
225 if query.source.is_some() && query.manual_section.is_some() {
226 return Err(CatalogError::ConflictingSelectors);
227 }
228 if query.source.is_some() && query.kind == Some(CatalogDocumentKind::Manual)
229 || query.manual_section.is_some() && query.kind == Some(CatalogDocumentKind::Markdown)
230 {
231 return Err(CatalogError::ConflictingSelectors);
232 }
233 Ok(())
234}
235
236fn build_matcher(
237 pattern: &str,
238 syntax: SearchSyntax,
239 case: SearchCase,
240) -> Result<grep_regex::RegexMatcher, CatalogError> {
241 let mut builder = RegexMatcherBuilder::new();
242 builder.fixed_strings(syntax == SearchSyntax::Literal);
243 match case {
244 SearchCase::Insensitive => {
245 builder.case_insensitive(true);
246 }
247 SearchCase::Sensitive => {
248 builder.case_insensitive(false);
249 }
250 SearchCase::Smart => {
251 builder.case_smart(true);
252 }
253 }
254 builder
255 .build(pattern)
256 .map_err(|error| CatalogError::InvalidPattern(error.to_string()))
257}
258
259fn match_rank(document: &AvailableDocument, query: &CatalogQuery) -> CatalogMatchRank {
260 if query.syntax != SearchSyntax::Literal {
261 return CatalogMatchRank::Unranked;
262 }
263 let Some(pattern) = query.pattern.as_deref() else {
264 return CatalogMatchRank::Unranked;
265 };
266 let catalog_path = available_catalog_path(document);
267 [
268 Some(document.name.as_str()),
269 Some(document.logical_path.as_str()),
270 pattern.contains('/').then_some(catalog_path.as_str()),
271 ]
272 .into_iter()
273 .flatten()
274 .map(|candidate| {
275 mant_protocol::catalog_literal_match_rank(candidate, Some(pattern), query.case)
276 })
277 .min()
278 .unwrap_or(CatalogMatchRank::Unranked)
279}
280
281fn document_summary(document: &AvailableDocument) -> DocumentSummary {
282 let address = match &document.origin {
283 AvailableDocumentOrigin::Documents => DocumentAddress::Markdown {
284 path: document.logical_path.clone(),
285 origin: MarkdownOrigin::Documents,
286 },
287 AvailableDocumentOrigin::Source(source) => DocumentAddress::Markdown {
288 path: document.logical_path.clone(),
289 origin: MarkdownOrigin::Source {
290 name: source.clone(),
291 },
292 },
293 AvailableDocumentOrigin::ManualPath => DocumentAddress::Manual {
294 name: document.name.clone(),
295 manual_section: document.manual_section.clone().unwrap_or_default(),
296 },
297 };
298 DocumentSummary {
299 catalog_path: address.catalog_path(),
300 address,
301 }
302}
303
304fn available_catalog_path(document: &AvailableDocument) -> String {
305 match &document.origin {
306 AvailableDocumentOrigin::Documents => format!("documents/{}", document.logical_path),
307 AvailableDocumentOrigin::Source(source) => {
308 format!("sources/{source}/{}", document.logical_path)
309 }
310 AvailableDocumentOrigin::ManualPath => format!(
311 "manual/{}/{}",
312 document.manual_section.as_deref().unwrap_or_default(),
313 document.name
314 ),
315 }
316}
317
318fn compare_precedence(left: &AvailableDocument, right: &AvailableDocument) -> std::cmp::Ordering {
319 fn class(document: &AvailableDocument) -> u8 {
320 match (&document.origin, document.source_priority) {
321 (AvailableDocumentOrigin::Documents, _) => 0,
322 (AvailableDocumentOrigin::Source(_), Some(priority))
323 if priority > BUILTIN_CONTENT_PRIORITY =>
324 {
325 1
326 }
327 (AvailableDocumentOrigin::ManualPath, _) => 2,
328 (AvailableDocumentOrigin::Source(_), _) => 3,
329 }
330 }
331
332 class(left)
333 .cmp(&class(right))
334 .then_with(|| match (&left.origin, &right.origin) {
335 (AvailableDocumentOrigin::Source(_), AvailableDocumentOrigin::Source(_)) => right
336 .source_priority
337 .unwrap_or_default()
338 .cmp(&left.source_priority.unwrap_or_default()),
339 _ => std::cmp::Ordering::Equal,
340 })
341}
342
343pub(crate) fn list_available_documents_from(
344 registered: Vec<RegisteredDocument>,
345 manuals: &[crate::ManualPage],
346) -> Vec<AvailableDocument> {
347 let mut documents = registered
348 .into_iter()
349 .map(|document| AvailableDocument {
350 name: document
351 .logical_path
352 .rsplit('/')
353 .next()
354 .unwrap_or(&document.logical_path)
355 .to_owned(),
356 logical_path: document.logical_path,
357 kind: AvailableDocumentKind::Markdown,
358 manual_section: None,
359 path: document.path,
360 source_priority: document.source_priority,
361 origin: match document.origin {
362 RegisteredDocumentOrigin::Documents => AvailableDocumentOrigin::Documents,
363 RegisteredDocumentOrigin::Source(source) => AvailableDocumentOrigin::Source(source),
364 },
365 })
366 .chain(manuals.iter().map(|page| AvailableDocument {
367 name: page.name.clone(),
368 logical_path: page.name.clone(),
369 kind: AvailableDocumentKind::Manual,
370 manual_section: Some(page.section.clone()),
371 path: page.path.clone(),
372 origin: AvailableDocumentOrigin::ManualPath,
373 source_priority: None,
374 }))
375 .collect::<Vec<_>>();
376 documents.sort_by(|left, right| {
377 left.logical_path
378 .cmp(&right.logical_path)
379 .then_with(|| compare_precedence(left, right))
380 .then_with(|| left.manual_section.cmp(&right.manual_section))
381 .then_with(|| left.origin.cmp(&right.origin))
382 });
383 documents
384}
385
386#[cfg(test)]
387mod tests {
388 use std::path::PathBuf;
389
390 use mant_sources::{RegisteredDocument, RegisteredDocumentOrigin};
391
392 use crate::ManualPage;
393
394 use mant_protocol::{
395 CatalogDocumentKind, CatalogQuery, DocumentAddress, SearchCase, SearchSyntax,
396 };
397
398 use super::{
399 AvailableDocument, AvailableDocumentKind, AvailableDocumentOrigin,
400 list_available_documents_from, query_available_documents,
401 };
402
403 #[test]
404 fn merges_both_namespaces_without_hiding_manual_sections() {
405 let documents = list_available_documents_from(
406 vec![RegisteredDocument {
407 logical_path: "printf".to_owned(),
408 path: PathBuf::from("/home/demo/.local/share/mant/documents/printf.md"),
409 origin: RegisteredDocumentOrigin::Documents,
410 source_priority: None,
411 }],
412 &[
413 ManualPage {
414 name: "printf".to_owned(),
415 section: "1".to_owned(),
416 path: PathBuf::from("/usr/share/man/man1/printf.1.gz"),
417 manual_root: PathBuf::from("/usr/share/man"),
418 },
419 ManualPage {
420 name: "printf".to_owned(),
421 section: "3".to_owned(),
422 path: PathBuf::from("/usr/share/man/man3/printf.3.gz"),
423 manual_root: PathBuf::from("/usr/share/man"),
424 },
425 ],
426 );
427
428 assert_eq!(documents.len(), 3);
429 assert_eq!(documents[0].kind, AvailableDocumentKind::Markdown);
430 assert_eq!(documents[0].origin, AvailableDocumentOrigin::Documents);
431 assert_eq!(documents[1].manual_section.as_deref(), Some("1"));
432 assert_eq!(documents[2].manual_section.as_deref(), Some("3"));
433 }
434
435 #[test]
436 fn keeps_shadowed_markdown_candidates_in_fallback_order() {
437 let documents = list_available_documents_from(
438 vec![
439 RegisteredDocument {
440 logical_path: "tool".to_owned(),
441 path: PathBuf::from("/data/mant/documents/tool.md"),
442 origin: RegisteredDocumentOrigin::Documents,
443 source_priority: None,
444 },
445 RegisteredDocument {
446 logical_path: "tool".to_owned(),
447 path: PathBuf::from("/data/mant/sources/alpha/tool.md"),
448 origin: RegisteredDocumentOrigin::Source("alpha".to_owned()),
449 source_priority: Some(1),
450 },
451 ],
452 &[],
453 );
454 assert_eq!(documents.len(), 2);
455 assert_eq!(documents[0].origin, AvailableDocumentOrigin::Documents);
456 assert_eq!(
457 documents[1].origin,
458 AvailableDocumentOrigin::Source("alpha".to_owned())
459 );
460 }
461
462 #[test]
463 fn catalog_orders_sources_around_the_native_manual_zero_baseline() {
464 let documents = list_available_documents_from(
465 vec![
466 RegisteredDocument {
467 logical_path: "tool".to_owned(),
468 path: PathBuf::from("/sources/low/tool.md"),
469 origin: RegisteredDocumentOrigin::Source("low".to_owned()),
470 source_priority: Some(-1),
471 },
472 RegisteredDocument {
473 logical_path: "tool".to_owned(),
474 path: PathBuf::from("/sources/high/tool.md"),
475 origin: RegisteredDocumentOrigin::Source("high".to_owned()),
476 source_priority: Some(1),
477 },
478 RegisteredDocument {
479 logical_path: "tool".to_owned(),
480 path: PathBuf::from("/sources/tie/tool.md"),
481 origin: RegisteredDocumentOrigin::Source("tie".to_owned()),
482 source_priority: Some(0),
483 },
484 ],
485 &[ManualPage {
486 name: "tool".to_owned(),
487 section: "1".to_owned(),
488 path: PathBuf::from("/man/tool.1"),
489 manual_root: PathBuf::from("/man"),
490 }],
491 );
492
493 assert_eq!(
494 documents
495 .iter()
496 .map(|document| match &document.origin {
497 AvailableDocumentOrigin::Source(name) => format!("source:{name}"),
498 AvailableDocumentOrigin::ManualPath => "manual".to_owned(),
499 AvailableDocumentOrigin::Documents => "documents".to_owned(),
500 })
501 .collect::<Vec<_>>(),
502 ["source:high", "manual", "source:tie", "source:low"]
503 );
504 }
505
506 #[test]
507 fn catalog_search_ranks_exact_prefix_and_substring_matches() {
508 let documents = ["process", "Start-Process", "process-tree"]
509 .into_iter()
510 .map(|name| AvailableDocument {
511 name: name.to_owned(),
512 logical_path: name.to_owned(),
513 kind: AvailableDocumentKind::Markdown,
514 manual_section: None,
515 path: PathBuf::from(format!("/data/{name}.md")),
516 origin: AvailableDocumentOrigin::Source("pwsh7".to_owned()),
517 source_priority: Some(1),
518 })
519 .collect::<Vec<_>>();
520 let catalog = query_available_documents(
521 &documents,
522 &CatalogQuery {
523 pattern: Some("process".to_owned()),
524 limit: 10,
525 ..CatalogQuery::default()
526 },
527 )
528 .expect("catalog");
529
530 assert_eq!(catalog.total, 3);
531 assert_eq!(catalog.documents[0].address.name(), "process");
532 assert_eq!(catalog.documents[1].address.name(), "process-tree");
533 assert_eq!(catalog.documents[2].address.name(), "Start-Process");
534 }
535
536 #[test]
537 fn catalog_puts_an_exact_manual_before_every_prefix_and_substring() {
538 let documents = ["woman", "manpath", "man", "man.conf", "printf"]
539 .into_iter()
540 .map(|name| AvailableDocument {
541 name: name.to_owned(),
542 logical_path: name.to_owned(),
543 kind: AvailableDocumentKind::Manual,
544 manual_section: Some("1".to_owned()),
545 path: PathBuf::from(format!("/man/{name}.1")),
546 origin: AvailableDocumentOrigin::ManualPath,
547 source_priority: None,
548 })
549 .collect::<Vec<_>>();
550 let catalog = query_available_documents(
551 &documents,
552 &CatalogQuery {
553 pattern: Some("man".to_owned()),
554 limit: 10,
555 ..CatalogQuery::default()
556 },
557 )
558 .expect("catalog");
559 let names = catalog
560 .documents
561 .iter()
562 .map(|document| document.address.name())
563 .collect::<Vec<_>>();
564
565 assert_eq!(names, ["man", "man.conf", "manpath", "woman"]);
566 }
567
568 #[test]
569 fn catalog_ranks_hierarchical_exact_suffix_prefix_and_substring_matches() {
570 let documents = ["tool", "languages/en/tool", "toolbox", "guides/mytool"]
571 .into_iter()
572 .map(|logical_path| AvailableDocument {
573 name: logical_path.rsplit('/').next().expect("leaf").to_owned(),
574 logical_path: logical_path.to_owned(),
575 kind: AvailableDocumentKind::Markdown,
576 manual_section: None,
577 path: PathBuf::from(format!("/documents/{logical_path}.md")),
578 origin: AvailableDocumentOrigin::Documents,
579 source_priority: None,
580 })
581 .collect::<Vec<_>>();
582 let catalog = query_available_documents(
583 &documents,
584 &CatalogQuery {
585 pattern: Some("tool".to_owned()),
586 limit: 10,
587 ..CatalogQuery::default()
588 },
589 )
590 .expect("hierarchical catalog");
591 assert_eq!(
592 catalog
593 .documents
594 .iter()
595 .map(|document| document.catalog_path.as_str())
596 .collect::<Vec<_>>(),
597 [
598 "documents/languages/en/tool",
599 "documents/tool",
600 "documents/toolbox",
601 "documents/guides/mytool",
602 ]
603 );
604
605 let exact = AvailableDocument {
606 name: "tool".to_owned(),
607 logical_path: "en/tool".to_owned(),
608 kind: AvailableDocumentKind::Markdown,
609 manual_section: None,
610 path: PathBuf::from("/documents/en/tool.md"),
611 origin: AvailableDocumentOrigin::Documents,
612 source_priority: None,
613 };
614 let catalog = query_available_documents(
615 &[exact, documents[1].clone()],
616 &CatalogQuery {
617 pattern: Some("en/tool".to_owned()),
618 limit: 10,
619 ..CatalogQuery::default()
620 },
621 )
622 .expect("component suffix catalog");
623 assert_eq!(
624 catalog
625 .documents
626 .iter()
627 .map(|document| document.catalog_path.as_str())
628 .collect::<Vec<_>>(),
629 ["documents/en/tool", "documents/languages/en/tool"]
630 );
631 }
632
633 #[test]
634 fn catalog_filters_keep_manual_sections_and_exact_addresses() {
635 let documents = vec![
636 AvailableDocument {
637 name: "printf".to_owned(),
638 logical_path: "printf".to_owned(),
639 kind: AvailableDocumentKind::Manual,
640 manual_section: Some("1".to_owned()),
641 path: PathBuf::from("/man/printf.1"),
642 origin: AvailableDocumentOrigin::ManualPath,
643 source_priority: None,
644 },
645 AvailableDocument {
646 name: "printf".to_owned(),
647 logical_path: "printf".to_owned(),
648 kind: AvailableDocumentKind::Manual,
649 manual_section: Some("3".to_owned()),
650 path: PathBuf::from("/man/printf.3"),
651 origin: AvailableDocumentOrigin::ManualPath,
652 source_priority: None,
653 },
654 ];
655 let catalog = query_available_documents(
656 &documents,
657 &CatalogQuery {
658 pattern: Some("^PRINT".to_owned()),
659 syntax: SearchSyntax::Regex,
660 case: SearchCase::Insensitive,
661 kind: Some(CatalogDocumentKind::Manual),
662 manual_section: Some("3".to_owned()),
663 limit: 10,
664 ..CatalogQuery::default()
665 },
666 )
667 .expect("catalog");
668
669 assert_eq!(catalog.documents.len(), 1);
670 assert_eq!(
671 catalog.documents[0].address,
672 DocumentAddress::Manual {
673 name: "printf".to_owned(),
674 manual_section: "3".to_owned(),
675 }
676 );
677 }
678}