Skip to main content

mant_core/
projection.rs

1//! Projects complete structured documents into outlines and selectable excerpts.
2
3use 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
20/// Whether an identifier belongs to the selector namespace rather than a
21/// document-defined node.
22///
23/// Section paths use dotted positive indices (`2.1`), while semantic entries
24/// append an option index (`2.1/o3`). The parser reserves the complete grammar,
25/// not only selectors present in one particular document, so source-defined
26/// IDs can never make excerpt lookup ambiguous.
27pub(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/// Failure to derive an addressable view from a complete query.
51#[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/// One stable qualification offered when a semantic alias is ambiguous.
74#[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
119/// Build a block-free, addressable outline for one complete query.
120///
121/// # Errors
122///
123/// Returns [`ProjectionError::MissingContent`] when neither tldr nor a manual
124/// is available.
125pub fn build_outline(query: &QueryBundle) -> Result<QueryOutline, ProjectionError> {
126    build_outline_with_detail(query, OutlineDetail::Sections)
127}
128
129/// Build an outline with optional semantic definition entries.
130///
131/// # Errors
132///
133/// Returns [`ProjectionError::MissingContent`] when neither tldr nor a manual
134/// is available.
135pub 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
208/// Select tldr, document-root content, or complete section subtrees by path or ID.
209///
210/// Duplicate selections and descendants of another selected node are omitted.
211/// The result always follows source order, independent of argument order.
212///
213/// # Errors
214///
215/// Returns an error when no content exists or any selector is empty or unknown.
216pub 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
319/// Select exactly one semantic entry by stable path, ID, or alias.
320///
321/// Exact paths and IDs take precedence over aliases. Repeated aliases are
322/// rejected with deterministic candidates instead of silently choosing the
323/// first entry in source order.
324///
325/// # Errors
326///
327/// Returns an error when the selector is empty, unknown, names a section, or
328/// matches more than one semantic entry.
329pub 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(&section.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(&section.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, .. } => &section.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(&section.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(&section.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        // Heading paths remain stable and independent from the synthetic root.
809        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}