1use std::{collections::HashSet, error::Error, fmt};
4
5use mant_ast::{
6 Block, DefinitionCase, DefinitionItem, DefinitionRole, ExcerptSchema, ExcerptSelection,
7 OutlineDetail, OutlineNode, OutlineReference, OutlineSchema, QueryBundle, QueryExcerpt,
8 QueryOutline, Section,
9};
10
11use crate::definitions::definition_entries;
12
13const TLDR_PATH: &str = "0";
14pub(crate) const TLDR_ID: &str = "tldr";
15const TLDR_TITLE: &str = "TLDR QUICK REFERENCE";
16pub(crate) const DOCUMENT_ROOT_PATH: &str = "root";
17pub(crate) const DOCUMENT_ROOT_ID: &str = "document-overview";
18pub(crate) const DOCUMENT_ROOT_TITLE: &str = "OVERVIEW";
19
20pub(crate) fn is_reserved_selector(value: &str) -> bool {
28 matches!(
29 value,
30 TLDR_PATH | TLDR_ID | DOCUMENT_ROOT_PATH | DOCUMENT_ROOT_ID
31 ) || is_outline_path(value)
32}
33
34fn is_outline_path(value: &str) -> bool {
35 if let Some(entry) = value.strip_prefix("root/o") {
36 return !entry.is_empty() && entry.bytes().all(|byte| byte.is_ascii_digit());
37 }
38 let (sections, entry) = value
39 .split_once("/o")
40 .map_or((value, None), |(sections, entry)| (sections, Some(entry)));
41 let section_path = !sections.is_empty()
42 && sections
43 .split('.')
44 .all(|index| !index.is_empty() && index.bytes().all(|byte| byte.is_ascii_digit()));
45 let entry_path = entry
46 .is_none_or(|index| !index.is_empty() && index.bytes().all(|byte| byte.is_ascii_digit()));
47 section_path && entry_path
48}
49
50#[derive(Debug, Clone, PartialEq, Eq)]
52pub enum ProjectionError {
53 MissingContent {
54 document: String,
55 },
56 EmptySelection,
57 EmptySelector,
58 UnknownSelector {
59 document: String,
60 selector: String,
61 },
62 AmbiguousSelector {
63 document: String,
64 selector: String,
65 candidates: Vec<SelectorCandidate>,
66 },
67 ExplanationRequiresEntry {
68 document: String,
69 selector: String,
70 },
71}
72
73#[derive(Debug, Clone, PartialEq, Eq)]
75pub struct SelectorCandidate {
76 pub path: String,
77 pub id: String,
78}
79
80impl fmt::Display for ProjectionError {
81 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
82 match self {
83 Self::MissingContent { document } => {
84 write!(formatter, "document '{document}' has no available content")
85 }
86 Self::EmptySelection => formatter.write_str("at least one outline node is required"),
87 Self::EmptySelector => formatter.write_str("outline node must not be empty"),
88 Self::UnknownSelector { document, selector } => write!(
89 formatter,
90 "document '{document}' has no outline node '{selector}'; inspect its entries outline as JSON for available selectors and diagnostics"
91 ),
92 Self::AmbiguousSelector {
93 document,
94 selector,
95 candidates,
96 } => {
97 write!(
98 formatter,
99 "document '{document}' has multiple semantic entries named '{selector}': "
100 )?;
101 for (index, candidate) in candidates.iter().enumerate() {
102 if index > 0 {
103 formatter.write_str(", ")?;
104 }
105 write!(formatter, "{} ({})", candidate.path, candidate.id)?;
106 }
107 formatter.write_str("; select one by path or ID")
108 }
109 Self::ExplanationRequiresEntry { document, selector } => write!(
110 formatter,
111 "document '{document}' outline node '{selector}' is not a semantic entry; use --node for sections"
112 ),
113 }
114 }
115}
116
117impl Error for ProjectionError {}
118
119pub fn build_outline(query: &QueryBundle) -> Result<QueryOutline, ProjectionError> {
126 build_outline_with_detail(query, OutlineDetail::Sections)
127}
128
129pub fn build_outline_with_detail(
136 query: &QueryBundle,
137 detail: OutlineDetail,
138) -> Result<QueryOutline, ProjectionError> {
139 if query.tldr.is_none() && query.document.is_none() {
140 return Err(ProjectionError::MissingContent {
141 document: query.label.clone(),
142 });
143 }
144 let diagnostics = query
145 .document
146 .as_ref()
147 .map_or_else(Vec::new, |document| document.diagnostics.clone());
148 let entries_complete = diagnostics.iter().all(|diagnostic| {
149 !diagnostic
150 .code
151 .as_deref()
152 .is_some_and(|code| code.starts_with("markdown.semantic-entry"))
153 });
154 let mut nodes = Vec::new();
155 if query.tldr.is_some() {
156 nodes.push(OutlineNode::Tldr {
157 path: TLDR_PATH.to_owned(),
158 id: TLDR_ID.to_owned(),
159 title: TLDR_TITLE.to_owned(),
160 });
161 }
162 if let Some(manual) = &query.document {
163 if !manual.blocks.is_empty() {
164 nodes.push(OutlineNode::DocumentRoot {
165 path: DOCUMENT_ROOT_PATH.to_owned(),
166 id: DOCUMENT_ROOT_ID.to_owned(),
167 title: DOCUMENT_ROOT_TITLE.to_owned(),
168 });
169 if detail == OutlineDetail::Entries {
170 nodes.extend(
171 definition_entries(&manual.blocks)
172 .into_iter()
173 .enumerate()
174 .filter_map(|(index, (entry, _))| {
175 let identity = entry.identity.as_ref()?;
176 Some(OutlineNode::DocumentEntry {
177 path: format!("{DOCUMENT_ROOT_PATH}/o{}", index + 1),
178 id: identity.id.clone(),
179 title: identity.names.join(", "),
180 role: identity.role,
181 case: identity.case,
182 names: identity.names.clone(),
183 })
184 }),
185 );
186 }
187 }
188 nodes.extend(outline_nodes(&manual.sections, &[], detail));
189 }
190 Ok(QueryOutline {
191 schema: OutlineSchema::V6,
192 detail,
193 label: query.label.clone(),
194 source: query
195 .document
196 .as_ref()
197 .map(|document| document.source.clone()),
198 meta: query
199 .document
200 .as_ref()
201 .map(|document| document.meta.clone()),
202 diagnostics,
203 entries_complete,
204 nodes,
205 })
206}
207
208pub fn select_excerpt(
217 query: &QueryBundle,
218 selectors: &[String],
219) -> Result<QueryExcerpt, ProjectionError> {
220 if selectors.is_empty() {
221 return Err(ProjectionError::EmptySelection);
222 }
223 if query.tldr.is_none() && query.document.is_none() {
224 return Err(ProjectionError::MissingContent {
225 document: query.label.clone(),
226 });
227 }
228 let mut located = Vec::new();
229 if let Some(manual) = &query.document {
230 collect_root_entries(&manual.blocks, &mut located);
231 collect_sections(&manual.sections, &[], &[], &mut located);
232 }
233
234 let mut tldr_selected = false;
235 let mut document_root_selected = false;
236 let mut selected_ids = HashSet::new();
237 let mut selected = Vec::new();
238 for raw_selector in selectors {
239 let selector = raw_selector.trim();
240 if selector.is_empty() {
241 return Err(ProjectionError::EmptySelector);
242 }
243 if matches!(selector, TLDR_PATH | TLDR_ID) && query.tldr.is_some() {
244 tldr_selected = true;
245 continue;
246 }
247 if matches!(selector, DOCUMENT_ROOT_PATH | DOCUMENT_ROOT_ID)
248 && query
249 .document
250 .as_ref()
251 .is_some_and(|document| !document.blocks.is_empty())
252 {
253 document_root_selected = true;
254 continue;
255 }
256 let candidate = resolve_candidate(query, &located, selector)?;
257 if selected_ids.insert(candidate.id()) {
258 selected.push(candidate);
259 }
260 }
261 let selected_sections = selected
262 .iter()
263 .filter(|candidate| candidate.is_section())
264 .map(|candidate| candidate.coordinates().to_vec())
265 .collect::<Vec<_>>();
266 selected.retain(|candidate| {
267 if document_root_selected && candidate.path().starts_with("root/o") {
268 return false;
269 }
270 !selected_sections.iter().any(|ancestor| {
271 if candidate.is_section() {
272 ancestor != candidate.coordinates()
273 && is_ancestor(ancestor, candidate.coordinates())
274 } else {
275 ancestor == candidate.coordinates()
276 || is_ancestor(ancestor, candidate.coordinates())
277 }
278 })
279 });
280 selected.sort_by_key(|candidate| candidate.order());
281
282 let document = if selected.is_empty() && !document_root_selected {
283 None
284 } else {
285 query.document.as_ref()
286 };
287 let mut selections = Vec::new();
288 if let (true, Some(document)) = (tldr_selected, query.tldr.clone()) {
289 selections.push(ExcerptSelection::Tldr {
290 path: TLDR_PATH.to_owned(),
291 id: TLDR_ID.to_owned(),
292 title: TLDR_TITLE.to_owned(),
293 document,
294 });
295 }
296 if let (true, Some(document)) = (document_root_selected, query.document.as_ref()) {
297 selections.push(ExcerptSelection::DocumentRoot {
298 path: DOCUMENT_ROOT_PATH.to_owned(),
299 id: DOCUMENT_ROOT_ID.to_owned(),
300 title: DOCUMENT_ROOT_TITLE.to_owned(),
301 blocks: document.blocks.clone(),
302 });
303 }
304 selections.extend(selected.into_iter().map(LocatedNode::selection));
305
306 Ok(QueryExcerpt {
307 schema: ExcerptSchema::V6,
308 label: query.label.clone(),
309 producer: document.map(|document| document.producer.clone()),
310 source: document.map(|document| document.source.clone()),
311 meta: document.map(|document| document.meta.clone()),
312 diagnostics: document
313 .map(|document| document.diagnostics.clone())
314 .unwrap_or_default(),
315 selections,
316 })
317}
318
319pub fn select_explanation(
330 query: &QueryBundle,
331 selector: &str,
332) -> Result<QueryExcerpt, ProjectionError> {
333 if query.tldr.is_none() && query.document.is_none() {
334 return Err(ProjectionError::MissingContent {
335 document: query.label.clone(),
336 });
337 }
338 let selector = selector.trim();
339 if selector.is_empty() {
340 return Err(ProjectionError::EmptySelector);
341 }
342 let mut located = Vec::new();
343 if let Some(manual) = &query.document {
344 collect_root_entries(&manual.blocks, &mut located);
345 collect_sections(&manual.sections, &[], &[], &mut located);
346 }
347 let candidate = resolve_explanation_candidate(query, &located, selector)?;
348 select_excerpt(query, &[candidate.path().to_owned()])
349}
350
351fn resolve_explanation_candidate<'a>(
352 query: &QueryBundle,
353 located: &'a [LocatedNode<'a>],
354 selector: &str,
355) -> Result<&'a LocatedNode<'a>, ProjectionError> {
356 if let Some(candidate) = located.iter().find(|candidate| {
357 !candidate.is_section() && (candidate.path() == selector || candidate.id() == selector)
358 }) {
359 return Ok(candidate);
360 }
361
362 let matches = located
363 .iter()
364 .filter(|candidate| candidate.matches_alias(selector))
365 .collect::<Vec<_>>();
366 match matches.as_slice() {
367 [candidate] => return Ok(candidate),
368 [] => {}
369 _ => {
370 return Err(ProjectionError::AmbiguousSelector {
371 document: query.label.clone(),
372 selector: selector.to_owned(),
373 candidates: matches
374 .into_iter()
375 .map(|candidate| SelectorCandidate {
376 path: candidate.path().to_owned(),
377 id: candidate.id().to_owned(),
378 })
379 .collect(),
380 });
381 }
382 }
383
384 let selects_tldr = matches!(selector, TLDR_PATH | TLDR_ID) && query.tldr.is_some();
385 let selects_root = matches!(selector, DOCUMENT_ROOT_PATH | DOCUMENT_ROOT_ID)
386 && query
387 .document
388 .as_ref()
389 .is_some_and(|document| !document.blocks.is_empty());
390 let selects_section = located.iter().any(|candidate| {
391 candidate.is_section() && (candidate.path() == selector || candidate.id() == selector)
392 });
393 if selects_tldr || selects_root || selects_section {
394 return Err(ProjectionError::ExplanationRequiresEntry {
395 document: query.label.clone(),
396 selector: selector.to_owned(),
397 });
398 }
399
400 Err(ProjectionError::UnknownSelector {
401 document: query.label.clone(),
402 selector: selector.to_owned(),
403 })
404}
405
406fn resolve_candidate<'a>(
407 query: &QueryBundle,
408 located: &'a [LocatedNode<'a>],
409 selector: &str,
410) -> Result<&'a LocatedNode<'a>, ProjectionError> {
411 if let Some(candidate) = located
412 .iter()
413 .find(|candidate| candidate.path() == selector || candidate.id() == selector)
414 {
415 return Ok(candidate);
416 }
417
418 let matches = located
419 .iter()
420 .filter(|candidate| candidate.matches_alias(selector))
421 .collect::<Vec<_>>();
422 match matches.as_slice() {
423 [] => Err(ProjectionError::UnknownSelector {
424 document: query.label.clone(),
425 selector: selector.to_owned(),
426 }),
427 [candidate] => Ok(candidate),
428 _ => Err(ProjectionError::AmbiguousSelector {
429 document: query.label.clone(),
430 selector: selector.to_owned(),
431 candidates: matches
432 .into_iter()
433 .map(|candidate| SelectorCandidate {
434 path: candidate.path().to_owned(),
435 id: candidate.id().to_owned(),
436 })
437 .collect(),
438 }),
439 }
440}
441
442fn outline_nodes(
443 sections: &[Section],
444 parent: &[usize],
445 detail: OutlineDetail,
446) -> Vec<OutlineNode> {
447 sections
448 .iter()
449 .enumerate()
450 .map(|(index, section)| {
451 let mut coordinates = parent.to_vec();
452 coordinates.push(index + 1);
453 let path = format_path(&coordinates);
454 let mut children = Vec::new();
455 if detail == OutlineDetail::Entries {
456 children.extend(
457 definition_entries(§ion.blocks)
458 .into_iter()
459 .enumerate()
460 .filter_map(|(index, (entry, _))| {
461 let identity = entry.identity.as_ref()?;
462 Some(OutlineNode::DocumentEntry {
463 path: format!("{path}/o{}", index + 1),
464 id: identity.id.clone(),
465 title: identity.names.join(", "),
466 role: identity.role,
467 case: identity.case,
468 names: identity.names.clone(),
469 })
470 }),
471 );
472 }
473 children.extend(outline_nodes(§ion.children, &coordinates, detail));
474 OutlineNode::DocumentSection {
475 path,
476 id: section.id.clone(),
477 title: section.title.clone(),
478 children,
479 }
480 })
481 .collect()
482}
483
484enum LocatedNode<'a> {
485 Section {
486 order: usize,
487 coordinates: Vec<usize>,
488 path: String,
489 breadcrumbs: Vec<OutlineReference>,
490 section: &'a Section,
491 },
492 Entry {
493 order: usize,
494 coordinates: Vec<usize>,
495 path: String,
496 title: String,
497 breadcrumbs: Vec<OutlineReference>,
498 entry: &'a DefinitionItem,
499 },
500}
501
502impl LocatedNode<'_> {
503 fn order(&self) -> usize {
504 match self {
505 Self::Section { order, .. } | Self::Entry { order, .. } => *order,
506 }
507 }
508
509 fn coordinates(&self) -> &[usize] {
510 match self {
511 Self::Section { coordinates, .. } | Self::Entry { coordinates, .. } => coordinates,
512 }
513 }
514
515 fn path(&self) -> &str {
516 match self {
517 Self::Section { path, .. } | Self::Entry { path, .. } => path,
518 }
519 }
520
521 fn id(&self) -> &str {
522 match self {
523 Self::Section { section, .. } => §ion.id,
524 Self::Entry { entry, .. } => {
525 &entry
526 .identity
527 .as_ref()
528 .expect("located entries have identities")
529 .id
530 }
531 }
532 }
533
534 fn matches_alias(&self, selector: &str) -> bool {
535 match self {
536 Self::Entry { entry, .. } => entry.identity.as_ref().is_some_and(|identity| {
537 identity
538 .names
539 .iter()
540 .any(|name| semantic_name_matches(identity.role, identity.case, name, selector))
541 }),
542 Self::Section { .. } => false,
543 }
544 }
545
546 const fn is_section(&self) -> bool {
547 matches!(self, Self::Section { .. })
548 }
549
550 fn selection(&self) -> ExcerptSelection {
551 match self {
552 Self::Section {
553 path,
554 breadcrumbs,
555 section,
556 ..
557 } => ExcerptSelection::DocumentSection {
558 path: path.clone(),
559 id: section.id.clone(),
560 title: section.title.clone(),
561 breadcrumbs: breadcrumbs.clone(),
562 section: (*section).clone(),
563 },
564 Self::Entry {
565 path,
566 title,
567 breadcrumbs,
568 entry,
569 ..
570 } => ExcerptSelection::DocumentEntry {
571 path: path.clone(),
572 id: entry
573 .identity
574 .as_ref()
575 .expect("located entries have identities")
576 .id
577 .clone(),
578 title: title.clone(),
579 breadcrumbs: breadcrumbs.clone(),
580 entry: (*entry).clone(),
581 },
582 }
583 }
584}
585
586fn semantic_name_matches(
587 role: DefinitionRole,
588 case: DefinitionCase,
589 name: &str,
590 selector: &str,
591) -> bool {
592 let equivalent = |left: &str, right: &str| match case {
593 DefinitionCase::Sensitive => left == right,
594 DefinitionCase::Insensitive => left.eq_ignore_ascii_case(right),
595 };
596 if equivalent(name, selector) {
597 return true;
598 }
599 match role {
600 DefinitionRole::Option => equivalent(name.trim_start_matches('-'), selector),
601 DefinitionRole::EnvironmentVariable => {
602 let normalized = name
603 .strip_prefix("$env:")
604 .or_else(|| name.strip_prefix("$ENV:"))
605 .unwrap_or(name);
606 equivalent(normalized, selector)
607 }
608 DefinitionRole::Command | DefinitionRole::Variable => false,
609 }
610}
611
612fn collect_sections<'a>(
613 sections: &'a [Section],
614 parent_coordinates: &[usize],
615 breadcrumbs: &[OutlineReference],
616 output: &mut Vec<LocatedNode<'a>>,
617) {
618 for (index, section) in sections.iter().enumerate() {
619 let mut coordinates = parent_coordinates.to_vec();
620 coordinates.push(index + 1);
621 let path = format_path(&coordinates);
622 let order = output.len();
623 output.push(LocatedNode::Section {
624 order,
625 coordinates: coordinates.clone(),
626 path: path.clone(),
627 breadcrumbs: breadcrumbs.to_vec(),
628 section,
629 });
630 let mut child_breadcrumbs = breadcrumbs.to_vec();
631 child_breadcrumbs.push(OutlineReference {
632 path: path.clone(),
633 id: section.id.clone(),
634 title: section.title.clone(),
635 });
636 for (index, (entry, _)) in definition_entries(§ion.blocks).into_iter().enumerate() {
637 let Some(identity) = &entry.identity else {
638 continue;
639 };
640 output.push(LocatedNode::Entry {
641 order: output.len(),
642 coordinates: coordinates.clone(),
643 path: format!("{path}/o{}", index + 1),
644 title: identity.names.join(", "),
645 breadcrumbs: child_breadcrumbs.clone(),
646 entry,
647 });
648 }
649 collect_sections(§ion.children, &coordinates, &child_breadcrumbs, output);
650 }
651}
652
653fn collect_root_entries<'a>(blocks: &'a [Block], output: &mut Vec<LocatedNode<'a>>) {
654 let breadcrumbs = vec![OutlineReference {
655 path: DOCUMENT_ROOT_PATH.to_owned(),
656 id: DOCUMENT_ROOT_ID.to_owned(),
657 title: DOCUMENT_ROOT_TITLE.to_owned(),
658 }];
659 for (index, (entry, _)) in definition_entries(blocks).into_iter().enumerate() {
660 let Some(identity) = &entry.identity else {
661 continue;
662 };
663 output.push(LocatedNode::Entry {
664 order: output.len(),
665 coordinates: Vec::new(),
666 path: format!("{DOCUMENT_ROOT_PATH}/o{}", index + 1),
667 title: identity.names.join(", "),
668 breadcrumbs: breadcrumbs.clone(),
669 entry,
670 });
671 }
672}
673
674fn format_path(coordinates: &[usize]) -> String {
675 coordinates
676 .iter()
677 .map(usize::to_string)
678 .collect::<Vec<_>>()
679 .join(".")
680}
681
682fn is_ancestor(ancestor: &[usize], descendant: &[usize]) -> bool {
683 ancestor.len() < descendant.len() && descendant.starts_with(ancestor)
684}
685
686#[cfg(test)]
687mod tests {
688 use mant_ast::{
689 Block, DocumentMeta, DocumentSchema, DocumentSource, ExcerptSelection, Inline, LayoutHint,
690 MantDocument, OutlineNode, Producer, QueryBundle, QuerySchema, Section, SourceFormat,
691 TldrDocument, TldrOrigin,
692 };
693
694 use super::{ProjectionError, build_outline, select_excerpt};
695
696 fn section(id: &str, title: &str, children: Vec<Section>) -> Section {
697 Section {
698 id: id.to_owned(),
699 title: title.to_owned(),
700 spacing_before_lines: 0,
701 blocks: Vec::new(),
702 children,
703 source: None,
704 }
705 }
706
707 fn query() -> QueryBundle {
708 QueryBundle {
709 schema: QuerySchema::V6,
710 label: "demo".to_owned(),
711 document: Some(MantDocument {
712 schema: DocumentSchema::V6,
713 producer: Producer {
714 name: "test".to_owned(),
715 version: "1".to_owned(),
716 engine: None,
717 },
718 source: DocumentSource {
719 format: SourceFormat::Man,
720 path: Some("/man/demo.1".to_owned()),
721 },
722 meta: DocumentMeta {
723 section: Some("1".to_owned()),
724 ..DocumentMeta::default()
725 },
726 diagnostics: Vec::new(),
727 blocks: Vec::new(),
728 sections: vec![
729 section("name-1", "NAME", Vec::new()),
730 section(
731 "options-2",
732 "OPTIONS",
733 vec![
734 section("common-3", "Common options", Vec::new()),
735 section("other-4", "Other options", Vec::new()),
736 ],
737 ),
738 section("files-5", "FILES", Vec::new()),
739 ],
740 }),
741 tldr: None,
742 }
743 }
744
745 fn tldr() -> TldrDocument {
746 TldrDocument {
747 title: "demo".to_owned(),
748 description: vec!["A small demonstration.".to_owned()],
749 more_information: Some("https://example.com/demo".to_owned()),
750 examples: Vec::new(),
751 platform: "common".to_owned(),
752 language: "en".to_owned(),
753 source_path: "/tldr/pages/common/demo.md".to_owned(),
754 origin: TldrOrigin::TldrPages,
755 }
756 }
757
758 #[test]
759 fn builds_one_based_tree_paths_without_copying_blocks() {
760 let outline = build_outline(&query()).expect("outline");
761
762 assert_eq!(
763 outline
764 .meta
765 .as_ref()
766 .and_then(|meta| meta.section.as_deref()),
767 Some("1")
768 );
769 assert_eq!(outline.nodes[1].path(), "2");
770 assert_eq!(outline.nodes[1].id(), "options-2");
771 assert_eq!(outline.nodes[1].children()[0].path(), "2.1");
772 assert_eq!(outline.nodes[1].children()[1].path(), "2.2");
773 }
774
775 #[test]
776 fn prepends_tldr_as_zero_without_renumbering_manual_sections() {
777 let mut query = query();
778 query.tldr = Some(tldr());
779
780 let outline = build_outline(&query).expect("combined outline");
781
782 assert!(matches!(outline.nodes[0], OutlineNode::Tldr { .. }));
783 assert_eq!(outline.nodes[0].path(), "0");
784 assert_eq!(outline.nodes[0].id(), "tldr");
785 assert_eq!(outline.nodes[1].path(), "1");
786 assert_eq!(outline.nodes[2].path(), "2");
787 }
788
789 #[test]
790 fn addresses_document_content_before_the_first_heading_as_root() {
791 let mut query = query();
792 let document = query.document.as_mut().expect("document");
793 document.source.format = SourceFormat::Markdown;
794 document.blocks.push(Block::Paragraph {
795 children: vec![Inline::Text {
796 value: "Document preface.".to_owned(),
797 }],
798 layout: LayoutHint::default(),
799 source: None,
800 });
801
802 let outline = build_outline(&query).expect("Markdown outline");
803 assert!(matches!(
804 &outline.nodes[0],
805 OutlineNode::DocumentRoot { path, id, title }
806 if path == "root" && id == "document-overview" && title == "OVERVIEW"
807 ));
808 assert_eq!(outline.nodes[1].path(), "1");
810
811 let excerpt = select_excerpt(&query, &["document-overview".to_owned(), "root".to_owned()])
812 .expect("root excerpt");
813 assert!(matches!(
814 excerpt.selections.as_slice(),
815 [ExcerptSelection::DocumentRoot { path, blocks, .. }]
816 if path == "root" && blocks.len() == 1
817 ));
818 assert_eq!(
819 excerpt.source.as_ref().map(|source| source.format),
820 Some(SourceFormat::Markdown)
821 );
822 }
823
824 #[test]
825 fn selects_paths_or_ids_in_source_order_and_suppresses_descendant_duplicates() {
826 let excerpt = select_excerpt(
827 &query(),
828 &[
829 "files-5".to_owned(),
830 "2.1".to_owned(),
831 "2".to_owned(),
832 "options-2".to_owned(),
833 ],
834 )
835 .expect("excerpt");
836
837 let paths = excerpt
838 .selections
839 .iter()
840 .map(|selection| match selection {
841 ExcerptSelection::Tldr { path, .. }
842 | ExcerptSelection::DocumentRoot { path, .. }
843 | ExcerptSelection::DocumentSection { path, .. }
844 | ExcerptSelection::DocumentEntry { path, .. } => path.as_str(),
845 })
846 .collect::<Vec<_>>();
847 assert_eq!(paths, ["2", "3"]);
848 let ExcerptSelection::DocumentSection {
849 section,
850 breadcrumbs,
851 ..
852 } = &excerpt.selections[0]
853 else {
854 panic!("expected manual selection");
855 };
856 assert_eq!(section.children.len(), 2);
857 assert!(breadcrumbs.is_empty());
858 }
859
860 #[test]
861 fn child_selection_retains_ancestor_breadcrumbs() {
862 let excerpt = select_excerpt(&query(), &["2.2".to_owned()]).expect("excerpt");
863
864 let ExcerptSelection::DocumentSection {
865 title, breadcrumbs, ..
866 } = &excerpt.selections[0]
867 else {
868 panic!("expected manual selection");
869 };
870 assert_eq!(title, "Other options");
871 assert_eq!(breadcrumbs[0].path, "2");
872 assert_eq!(breadcrumbs[0].title, "OPTIONS");
873 }
874
875 #[test]
876 fn selects_tldr_by_zero_or_id_and_supports_tldr_only_outlines() {
877 let mut combined = query();
878 combined.tldr = Some(tldr());
879 let excerpt = select_excerpt(
880 &combined,
881 &["2".to_owned(), "tldr".to_owned(), "0".to_owned()],
882 )
883 .expect("combined excerpt");
884 assert!(matches!(
885 excerpt.selections.as_slice(),
886 [ExcerptSelection::Tldr { path, .. }, ExcerptSelection::DocumentSection { .. }]
887 if path == "0"
888 ));
889
890 let mut tldr_only = combined;
891 tldr_only.document = None;
892 let outline = build_outline(&tldr_only).expect("tldr-only outline");
893 assert_eq!(outline.nodes.len(), 1);
894 assert_eq!(outline.nodes[0].path(), "0");
895 assert!(outline.source.is_none());
896 assert!(outline.meta.is_none());
897 }
898
899 #[test]
900 fn reports_missing_content_and_unknown_or_empty_selectors() {
901 let mut empty = query();
902 empty.document = None;
903 assert!(matches!(
904 build_outline(&empty),
905 Err(ProjectionError::MissingContent { .. })
906 ));
907 assert_eq!(
908 select_excerpt(&query(), &[]),
909 Err(ProjectionError::EmptySelection)
910 );
911 assert_eq!(
912 select_excerpt(&query(), &[" ".to_owned()]),
913 Err(ProjectionError::EmptySelector)
914 );
915 assert!(matches!(
916 select_excerpt(&query(), &["9".to_owned()]),
917 Err(ProjectionError::UnknownSelector { .. })
918 ));
919 }
920}