Skip to main content

mant_engine/
projection.rs

1//! Projects complete structured documents into outlines and selectable excerpts.
2
3use std::{
4    collections::{BTreeSet, HashSet},
5    error::Error,
6    fmt,
7};
8
9use mant_ir::{
10    Block, DefinitionCase, DefinitionIdentity, DefinitionItem, DefinitionRole, Diagnostic,
11    DiagnosticLevel, OutlinePath, Section, SourceSpan,
12};
13use mant_protocol::{
14    ExcerptSchema, ExcerptSelection, OutlineDetail, OutlineNode, OutlineReference, OutlineSchema,
15    QueryExcerpt, QueryOutline,
16};
17
18use crate::{ResolvedContent, definitions::definition_entries};
19
20pub(crate) const TLDR_ID: &str = "tldr";
21const TLDR_TITLE: &str = "TLDR QUICK REFERENCE";
22pub(crate) use mant_ir::DOCUMENT_ROOT_ID;
23pub(crate) const DOCUMENT_ROOT_TITLE: &str = "OVERVIEW";
24
25/// Whether an identifier belongs to the selector namespace rather than a
26/// document-defined node.
27///
28/// Section paths use dotted positive indices (`2.1`), while semantic entries
29/// append a semantic-entry index (`2.1/e3`). The parser reserves the complete grammar,
30/// not only selectors present in one particular document, so source-defined
31/// IDs can never make excerpt lookup ambiguous.
32pub(crate) fn is_reserved_selector(value: &str) -> bool {
33    matches!(value, TLDR_ID | DOCUMENT_ROOT_ID) || value.parse::<OutlinePath>().is_ok()
34}
35
36/// Failure to derive an addressable view from a complete query.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub enum ProjectionError {
39    /// Neither an authoritative document nor a quick reference is available.
40    MissingContent {
41        /// Requested document label.
42        document: String,
43    },
44    /// Excerpt projection received no selectors.
45    EmptySelection,
46    /// One selector was empty after trimming.
47    EmptySelector,
48    /// No addressable node matched a selector.
49    UnknownSelector {
50        /// Requested document label.
51        document: String,
52        /// Unresolved selector.
53        selector: String,
54    },
55    /// An alias matched more than one semantic entry.
56    AmbiguousSelector {
57        /// Requested document label.
58        document: String,
59        /// Ambiguous selector.
60        selector: String,
61        /// Stable paths and IDs that disambiguate the match.
62        candidates: Vec<SelectorCandidate>,
63    },
64    /// Explanation lookup selected a non-entry node.
65    ExplanationRequiresEntry {
66        /// Requested document label.
67        document: String,
68        /// Selector naming the non-entry node.
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    /// Canonical structural outline path.
77    pub path: String,
78    /// Stable document-local identity.
79    pub id: String,
80}
81
82impl fmt::Display for ProjectionError {
83    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
84        match self {
85            Self::MissingContent { document } => {
86                write!(formatter, "document '{document}' has no available content")
87            }
88            Self::EmptySelection => formatter.write_str("at least one outline node is required"),
89            Self::EmptySelector => formatter.write_str("outline node must not be empty"),
90            Self::UnknownSelector { document, selector } => write!(
91                formatter,
92                "document '{document}' has no outline node '{selector}'; inspect its entries outline as JSON for available selectors and diagnostics"
93            ),
94            Self::AmbiguousSelector {
95                document,
96                selector,
97                candidates,
98            } => {
99                write!(
100                    formatter,
101                    "document '{document}' has multiple semantic entries named '{selector}': "
102                )?;
103                for (index, candidate) in candidates.iter().enumerate() {
104                    if index > 0 {
105                        formatter.write_str(", ")?;
106                    }
107                    write!(formatter, "{} ({})", candidate.path, candidate.id)?;
108                }
109                formatter.write_str("; select one by path or ID")
110            }
111            Self::ExplanationRequiresEntry { document, selector } => write!(
112                formatter,
113                "document '{document}' outline node '{selector}' is not a semantic entry; use --node for sections"
114            ),
115        }
116    }
117}
118
119impl Error for ProjectionError {}
120
121/// Build a block-free, addressable outline for one complete query.
122///
123/// # Errors
124///
125/// Returns [`ProjectionError::MissingContent`] when neither tldr nor a manual
126/// is available.
127pub fn build_outline(query: &ResolvedContent) -> Result<QueryOutline, ProjectionError> {
128    build_outline_with_detail(query, OutlineDetail::Sections)
129}
130
131/// Build an outline with optional semantic definition entries.
132///
133/// # Errors
134///
135/// Returns [`ProjectionError::MissingContent`] when neither tldr nor a manual
136/// is available.
137pub fn build_outline_with_detail(
138    query: &ResolvedContent,
139    detail: OutlineDetail,
140) -> Result<QueryOutline, ProjectionError> {
141    if query.tldr.is_none() && query.document.is_none() {
142        return Err(ProjectionError::MissingContent {
143            document: query.label.clone(),
144        });
145    }
146    let diagnostics = query
147        .document
148        .as_ref()
149        .map_or_else(Vec::new, |document| document.diagnostics.clone());
150    let entries_complete = diagnostics.iter().all(|diagnostic| {
151        !diagnostic
152            .code
153            .as_deref()
154            .is_some_and(|code| code.starts_with("markdown.semantic-entry"))
155    });
156    let mut nodes = Vec::new();
157    if query.tldr.is_some() {
158        nodes.push(OutlineNode::Tldr {
159            path: OutlinePath::Tldr.to_string().into(),
160            id: TLDR_ID.into(),
161            title: TLDR_TITLE.to_owned(),
162        });
163    }
164    if let Some(manual) = &query.document {
165        if !manual.blocks.is_empty() {
166            nodes.push(OutlineNode::DocumentRoot {
167                path: OutlinePath::DocumentRoot.to_string().into(),
168                id: DOCUMENT_ROOT_ID.into(),
169                title: DOCUMENT_ROOT_TITLE.to_owned(),
170            });
171            if detail == OutlineDetail::Entries {
172                nodes.extend(
173                    definition_entries(&manual.blocks)
174                        .into_iter()
175                        .enumerate()
176                        .filter_map(|(index, (entry, _))| {
177                            let identity = entry.identity.as_ref()?;
178                            Some(OutlineNode::DocumentEntry {
179                                path: OutlinePath::entry(None, index + 1)?.to_string().into(),
180                                id: identity.id.clone(),
181                                title: identity.names.join(", "),
182                                role: identity.role,
183                                case: identity.case,
184                                names: identity.names.clone(),
185                            })
186                        }),
187                );
188            }
189        }
190        nodes.extend(outline_nodes(&manual.sections, &[], detail));
191    }
192    Ok(QueryOutline {
193        schema: OutlineSchema::V7,
194        detail,
195        label: query.label.clone(),
196        source: query
197            .document
198            .as_ref()
199            .map(|document| document.source.clone()),
200        meta: query
201            .document
202            .as_ref()
203            .map(|document| document.meta.clone()),
204        diagnostics,
205        entries_complete,
206        nodes,
207    })
208}
209
210/// Select tldr, document-root content, or complete section subtrees by path or ID.
211///
212/// Duplicate selections and descendants of another selected node are omitted.
213/// The result always follows source order, independent of argument order.
214///
215/// # Errors
216///
217/// Returns an error when no content exists or any selector is empty or unknown.
218pub fn select_excerpt<S: AsRef<str>>(
219    query: &ResolvedContent,
220    selectors: &[S],
221) -> Result<QueryExcerpt, ProjectionError> {
222    if selectors.is_empty() {
223        return Err(ProjectionError::EmptySelection);
224    }
225    if query.tldr.is_none() && query.document.is_none() {
226        return Err(ProjectionError::MissingContent {
227            document: query.label.clone(),
228        });
229    }
230    let mut located = Vec::new();
231    if let Some(manual) = &query.document {
232        collect_root_entries(&manual.blocks, &mut located);
233        collect_sections(&manual.sections, &[], &[], &mut located);
234    }
235
236    let mut tldr_selected = false;
237    let mut document_root_selected = false;
238    let mut selected_ids = HashSet::new();
239    let mut selected = Vec::new();
240    for raw_selector in selectors {
241        let selector = raw_selector.as_ref().trim();
242        if selector.is_empty() {
243            return Err(ProjectionError::EmptySelector);
244        }
245        if (selector == TLDR_ID || selector.parse() == Ok(OutlinePath::Tldr))
246            && query.tldr.is_some()
247        {
248            tldr_selected = true;
249            continue;
250        }
251        if (selector == DOCUMENT_ROOT_ID || selector.parse() == Ok(OutlinePath::DocumentRoot))
252            && query
253                .document
254                .as_ref()
255                .is_some_and(|document| !document.blocks.is_empty())
256        {
257            document_root_selected = true;
258            continue;
259        }
260        let candidate = resolve_candidate(query, &located, selector)?;
261        if selected_ids.insert(candidate.id()) {
262            selected.push(candidate);
263        }
264    }
265    let selected_sections = selected
266        .iter()
267        .filter(|candidate| candidate.is_section())
268        .map(|candidate| candidate.coordinates().to_vec())
269        .collect::<Vec<_>>();
270    selected.retain(|candidate| {
271        if document_root_selected && candidate.path().is_document_root_entry() {
272            return false;
273        }
274        !selected_sections.iter().any(|ancestor| {
275            if candidate.is_section() {
276                ancestor != candidate.coordinates()
277                    && is_ancestor(ancestor, candidate.coordinates())
278            } else {
279                ancestor == candidate.coordinates()
280                    || is_ancestor(ancestor, candidate.coordinates())
281            }
282        })
283    });
284    selected.sort_by_key(|candidate| candidate.order());
285
286    let document = if selected.is_empty() && !document_root_selected {
287        None
288    } else {
289        query.document.as_ref()
290    };
291    let mut selections = Vec::new();
292    if let (true, Some(document)) = (tldr_selected, query.tldr.clone()) {
293        selections.push(ExcerptSelection::Tldr {
294            path: OutlinePath::Tldr.to_string().into(),
295            id: TLDR_ID.into(),
296            title: TLDR_TITLE.to_owned(),
297            document,
298        });
299    }
300    if let (true, Some(document)) = (document_root_selected, query.document.as_ref()) {
301        selections.push(ExcerptSelection::DocumentRoot {
302            path: OutlinePath::DocumentRoot.to_string().into(),
303            id: DOCUMENT_ROOT_ID.into(),
304            title: DOCUMENT_ROOT_TITLE.to_owned(),
305            blocks: document.blocks.clone(),
306        });
307    }
308    selections.extend(selected.into_iter().map(LocatedNode::selection));
309
310    Ok(QueryExcerpt {
311        schema: ExcerptSchema::V7,
312        label: query.label.clone(),
313        producer: document.map(mant_protocol::Producer::for_document),
314        source: document.map(|document| document.source.clone()),
315        meta: document.map(|document| document.meta.clone()),
316        diagnostics: document
317            .map(|document| document.diagnostics.clone())
318            .unwrap_or_default(),
319        selections,
320    })
321}
322
323/// Select exactly one semantic entry by stable path, ID, or alias.
324///
325/// Exact paths and IDs take precedence over aliases. Repeated aliases are
326/// rejected with deterministic candidates instead of silently choosing the
327/// first entry in source order.
328///
329/// # Errors
330///
331/// Returns an error when the selector is empty, unknown, names a section, or
332/// matches more than one semantic entry.
333pub fn select_explanation(
334    query: &ResolvedContent,
335    selector: &str,
336) -> Result<QueryExcerpt, ProjectionError> {
337    if query.tldr.is_none() && query.document.is_none() {
338        return Err(ProjectionError::MissingContent {
339            document: query.label.clone(),
340        });
341    }
342    let selector = selector.trim();
343    if selector.is_empty() {
344        return Err(ProjectionError::EmptySelector);
345    }
346    let mut located = Vec::new();
347    if let Some(manual) = &query.document {
348        collect_root_entries(&manual.blocks, &mut located);
349        collect_sections(&manual.sections, &[], &[], &mut located);
350    }
351    let candidate = resolve_explanation_candidate(query, &located, selector)?;
352    select_excerpt(query, &[candidate.path().to_string()])
353}
354
355fn resolve_explanation_candidate<'a>(
356    query: &ResolvedContent,
357    located: &'a [LocatedNode<'a>],
358    selector: &str,
359) -> Result<&'a LocatedNode<'a>, ProjectionError> {
360    if let Some(candidate) = located.iter().find(|candidate| {
361        !candidate.is_section() && (candidate.matches_path(selector) || candidate.id() == selector)
362    }) {
363        return Ok(candidate);
364    }
365
366    let matches = matching_aliases(located, selector).1;
367    match matches.as_slice() {
368        [candidate] => return Ok(candidate),
369        [] => {}
370        _ => {
371            return Err(ProjectionError::AmbiguousSelector {
372                document: query.label.clone(),
373                selector: selector.to_owned(),
374                candidates: matches
375                    .into_iter()
376                    .map(|candidate| SelectorCandidate {
377                        path: candidate.path().to_string(),
378                        id: candidate.id().into(),
379                    })
380                    .collect(),
381            });
382        }
383    }
384
385    let selects_tldr =
386        (selector == TLDR_ID || selector.parse() == Ok(OutlinePath::Tldr)) && query.tldr.is_some();
387    let selects_root = (selector == DOCUMENT_ROOT_ID
388        || selector.parse() == Ok(OutlinePath::DocumentRoot))
389        && query
390            .document
391            .as_ref()
392            .is_some_and(|document| !document.blocks.is_empty());
393    let selects_section = located.iter().any(|candidate| {
394        candidate.is_section() && (candidate.matches_path(selector) || candidate.id() == selector)
395    });
396    if selects_tldr || selects_root || selects_section {
397        return Err(ProjectionError::ExplanationRequiresEntry {
398            document: query.label.clone(),
399            selector: selector.to_owned(),
400        });
401    }
402
403    Err(ProjectionError::UnknownSelector {
404        document: query.label.clone(),
405        selector: selector.to_owned(),
406    })
407}
408
409fn resolve_candidate<'a>(
410    query: &ResolvedContent,
411    located: &'a [LocatedNode<'a>],
412    selector: &str,
413) -> Result<&'a LocatedNode<'a>, ProjectionError> {
414    if let Some(candidate) = located
415        .iter()
416        .find(|candidate| candidate.matches_path(selector) || candidate.id() == selector)
417    {
418        return Ok(candidate);
419    }
420
421    let matches = matching_aliases(located, selector).1;
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_string(),
435                    id: candidate.id().into(),
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 =
454                OutlinePath::section(&coordinates).expect("enumerated section paths are one-based");
455            let mut children = Vec::new();
456            if detail == OutlineDetail::Entries {
457                children.extend(
458                    definition_entries(&section.blocks)
459                        .into_iter()
460                        .enumerate()
461                        .filter_map(|(index, (entry, _))| {
462                            let identity = entry.identity.as_ref()?;
463                            Some(OutlineNode::DocumentEntry {
464                                path: OutlinePath::entry(Some(&coordinates), index + 1)
465                                    .expect("enumerated entry paths are one-based")
466                                    .to_string()
467                                    .into(),
468                                id: identity.id.clone(),
469                                title: identity.names.join(", "),
470                                role: identity.role,
471                                case: identity.case,
472                                names: identity.names.clone(),
473                            })
474                        }),
475                );
476            }
477            children.extend(outline_nodes(&section.children, &coordinates, detail));
478            OutlineNode::DocumentSection {
479                path: path.to_string().into(),
480                id: section.id.clone(),
481                title: section.title.clone(),
482                children,
483            }
484        })
485        .collect()
486}
487
488enum LocatedNode<'a> {
489    Section {
490        order: usize,
491        coordinates: Vec<usize>,
492        path: OutlinePath,
493        breadcrumbs: Vec<OutlineReference>,
494        section: &'a Section,
495    },
496    Entry {
497        order: usize,
498        coordinates: Vec<usize>,
499        path: OutlinePath,
500        title: String,
501        breadcrumbs: Vec<OutlineReference>,
502        entry: &'a DefinitionItem,
503        source: Option<SourceSpan>,
504    },
505}
506
507impl LocatedNode<'_> {
508    fn order(&self) -> usize {
509        match self {
510            Self::Section { order, .. } | Self::Entry { order, .. } => *order,
511        }
512    }
513
514    fn coordinates(&self) -> &[usize] {
515        match self {
516            Self::Section { coordinates, .. } | Self::Entry { coordinates, .. } => coordinates,
517        }
518    }
519
520    fn path(&self) -> &OutlinePath {
521        match self {
522            Self::Section { path, .. } | Self::Entry { path, .. } => path,
523        }
524    }
525
526    fn matches_path(&self, selector: &str) -> bool {
527        selector
528            .parse::<OutlinePath>()
529            .is_ok_and(|path| path == *self.path())
530    }
531
532    fn id(&self) -> &str {
533        match self {
534            Self::Section { section, .. } => &section.id,
535            Self::Entry { entry, .. } => {
536                &entry
537                    .identity
538                    .as_ref()
539                    .expect("located entries have identities")
540                    .id
541            }
542        }
543    }
544
545    fn matches_exact_alias(&self, selector: &str) -> bool {
546        match self {
547            Self::Entry { entry, .. } => entry.identity.as_ref().is_some_and(|identity| {
548                identity
549                    .names
550                    .iter()
551                    .any(|name| semantic_name_equivalent(identity.case, name, selector))
552            }),
553            Self::Section { .. } => false,
554        }
555    }
556
557    fn matches_shorthand_alias(&self, selector: &str) -> bool {
558        match self {
559            Self::Entry { entry, .. } => entry.identity.as_ref().is_some_and(|identity| {
560                identity.names.iter().any(|name| {
561                    semantic_name_shorthand(identity.role, name).is_some_and(|shorthand| {
562                        semantic_name_equivalent(identity.case, shorthand, selector)
563                    })
564                })
565            }),
566            Self::Section { .. } => false,
567        }
568    }
569
570    fn identity(&self) -> Option<&DefinitionIdentity> {
571        match self {
572            Self::Entry { entry, .. } => entry.identity.as_ref(),
573            Self::Section { .. } => None,
574        }
575    }
576
577    fn source(&self) -> Option<SourceSpan> {
578        match self {
579            Self::Entry { source, .. } => *source,
580            Self::Section { section, .. } => section.source,
581        }
582    }
583
584    const fn is_section(&self) -> bool {
585        matches!(self, Self::Section { .. })
586    }
587
588    fn selection(&self) -> ExcerptSelection {
589        match self {
590            Self::Section {
591                path,
592                breadcrumbs,
593                section,
594                ..
595            } => ExcerptSelection::DocumentSection {
596                path: path.to_string().into(),
597                id: section.id.clone(),
598                title: section.title.clone(),
599                breadcrumbs: breadcrumbs.clone(),
600                section: (*section).clone(),
601            },
602            Self::Entry {
603                path,
604                title,
605                breadcrumbs,
606                entry,
607                ..
608            } => ExcerptSelection::DocumentEntry {
609                path: path.to_string().into(),
610                id: entry
611                    .identity
612                    .as_ref()
613                    .expect("located entries have identities")
614                    .id
615                    .clone(),
616                title: title.clone(),
617                breadcrumbs: breadcrumbs.clone(),
618                entry: (*entry).clone(),
619            },
620        }
621    }
622}
623
624fn semantic_name_equivalent(case: DefinitionCase, left: &str, right: &str) -> bool {
625    match case {
626        DefinitionCase::Sensitive => left == right,
627        DefinitionCase::Insensitive => left.eq_ignore_ascii_case(right),
628    }
629}
630
631fn semantic_name_shorthand(role: DefinitionRole, name: &str) -> Option<&str> {
632    match role {
633        DefinitionRole::Option => {
634            let shorthand = name.trim_start_matches('-');
635            (shorthand != name && !shorthand.is_empty()).then_some(shorthand)
636        }
637        DefinitionRole::EnvironmentVariable => name
638            .strip_prefix("$env:")
639            .or_else(|| name.strip_prefix("$ENV:")),
640        DefinitionRole::Command | DefinitionRole::Variable => None,
641    }
642}
643
644#[derive(Clone, Copy)]
645enum AliasMatchKind {
646    Exact,
647    Shorthand,
648}
649
650impl AliasMatchKind {
651    const fn label(self) -> &'static str {
652        match self {
653            Self::Exact => "exact alias",
654            Self::Shorthand => "normalized shorthand",
655        }
656    }
657}
658
659fn matching_aliases<'a>(
660    located: &'a [LocatedNode<'a>],
661    selector: &str,
662) -> (AliasMatchKind, Vec<&'a LocatedNode<'a>>) {
663    let exact = located
664        .iter()
665        .filter(|candidate| candidate.matches_exact_alias(selector))
666        .collect::<Vec<_>>();
667    if !exact.is_empty() {
668        return (AliasMatchKind::Exact, exact);
669    }
670    (
671        AliasMatchKind::Shorthand,
672        located
673            .iter()
674            .filter(|candidate| candidate.matches_shorthand_alias(selector))
675            .collect(),
676    )
677}
678
679/// Report selectors that cannot address exactly one semantic entry.
680///
681/// The lookup policy itself remains usable through stable paths and IDs, but
682/// Markdown authors receive a source diagnostic before an agent discovers the
683/// ambiguity at query time.
684pub(crate) fn semantic_selector_diagnostics(
685    blocks: &[Block],
686    sections: &[Section],
687) -> Vec<Diagnostic> {
688    let mut located = Vec::new();
689    collect_root_entries(blocks, &mut located);
690    collect_sections(sections, &[], &[], &mut located);
691    let mut selectors = BTreeSet::new();
692    for candidate in &located {
693        let Some(identity) = candidate.identity() else {
694            continue;
695        };
696        for name in &identity.names {
697            selectors.insert(name.clone());
698            if let Some(shorthand) = semantic_name_shorthand(identity.role, name) {
699                selectors.insert(shorthand.to_owned());
700            }
701        }
702    }
703
704    let mut reported = HashSet::new();
705    let mut diagnostics = Vec::new();
706    for selector in selectors {
707        let (kind, matches) = matching_aliases(&located, &selector);
708        if matches.len() < 2 {
709            continue;
710        }
711        let key = matches
712            .iter()
713            .map(|candidate| candidate.id())
714            .collect::<Vec<_>>()
715            .join("\u{1f}");
716        if !reported.insert(key) {
717            continue;
718        }
719        let candidates = matches
720            .iter()
721            .map(|candidate| format!("{} ({})", candidate.path(), candidate.id()))
722            .collect::<Vec<_>>()
723            .join(", ");
724        diagnostics.push(Diagnostic {
725            level: DiagnosticLevel::Warning,
726            code: Some("markdown.semantic-entry.ambiguous-selector".to_owned()),
727            message: format!(
728                "semantic selector '{selector}' has multiple {} matches: {candidates}; select by path or ID",
729                kind.label()
730            ),
731            source: matches.first().and_then(|candidate| candidate.source()),
732        });
733    }
734    diagnostics
735}
736
737fn collect_sections<'a>(
738    sections: &'a [Section],
739    parent_coordinates: &[usize],
740    breadcrumbs: &[OutlineReference],
741    output: &mut Vec<LocatedNode<'a>>,
742) {
743    for (index, section) in sections.iter().enumerate() {
744        let mut coordinates = parent_coordinates.to_vec();
745        coordinates.push(index + 1);
746        let path =
747            OutlinePath::section(&coordinates).expect("enumerated section paths are one-based");
748        let order = output.len();
749        output.push(LocatedNode::Section {
750            order,
751            coordinates: coordinates.clone(),
752            path: path.clone(),
753            breadcrumbs: breadcrumbs.to_vec(),
754            section,
755        });
756        let mut child_breadcrumbs = breadcrumbs.to_vec();
757        child_breadcrumbs.push(OutlineReference {
758            path: path.to_string().into(),
759            id: section.id.clone(),
760            title: section.title.clone(),
761        });
762        for (index, (entry, source)) in definition_entries(&section.blocks).into_iter().enumerate()
763        {
764            let Some(identity) = &entry.identity else {
765                continue;
766            };
767            output.push(LocatedNode::Entry {
768                order: output.len(),
769                coordinates: coordinates.clone(),
770                path: OutlinePath::entry(Some(&coordinates), index + 1)
771                    .expect("enumerated entry paths are one-based"),
772                title: identity.names.join(", "),
773                breadcrumbs: child_breadcrumbs.clone(),
774                entry,
775                source,
776            });
777        }
778        collect_sections(&section.children, &coordinates, &child_breadcrumbs, output);
779    }
780}
781
782fn collect_root_entries<'a>(blocks: &'a [Block], output: &mut Vec<LocatedNode<'a>>) {
783    let breadcrumbs = vec![OutlineReference {
784        path: OutlinePath::DocumentRoot.to_string().into(),
785        id: DOCUMENT_ROOT_ID.into(),
786        title: DOCUMENT_ROOT_TITLE.to_owned(),
787    }];
788    for (index, (entry, source)) in definition_entries(blocks).into_iter().enumerate() {
789        let Some(identity) = &entry.identity else {
790            continue;
791        };
792        output.push(LocatedNode::Entry {
793            order: output.len(),
794            coordinates: Vec::new(),
795            path: OutlinePath::entry(None, index + 1)
796                .expect("enumerated entry paths are one-based"),
797            title: identity.names.join(", "),
798            breadcrumbs: breadcrumbs.clone(),
799            entry,
800            source,
801        });
802    }
803}
804
805fn is_ancestor(ancestor: &[usize], descendant: &[usize]) -> bool {
806    ancestor.len() < descendant.len() && descendant.starts_with(ancestor)
807}
808
809#[cfg(test)]
810mod tests {
811    use crate::ResolvedContent;
812    use mant_ir::{
813        Block, Document, DocumentMeta, DocumentSource, Inline, LayoutHint, Section, SourceFormat,
814        TldrDocument, TldrOrigin,
815    };
816    use mant_protocol::{ExcerptSelection, OutlineNode};
817
818    use super::{ProjectionError, build_outline, select_excerpt};
819
820    fn section(id: &str, title: &str, children: Vec<Section>) -> Section {
821        Section {
822            id: id.to_owned().into(),
823            title: title.to_owned(),
824            spacing_before_lines: 0,
825            blocks: Vec::new(),
826            children,
827            source: None,
828        }
829    }
830
831    fn query() -> ResolvedContent {
832        ResolvedContent {
833            address: None,
834            label: "demo".to_owned(),
835            document: Some(Document {
836                parser: None,
837                source: DocumentSource {
838                    format: SourceFormat::Man,
839                    path: Some("/man/demo.1".to_owned()),
840                },
841                meta: DocumentMeta {
842                    manual_section: Some("1".to_owned()),
843                    ..DocumentMeta::default()
844                },
845                diagnostics: Vec::new(),
846                blocks: Vec::new(),
847                sections: vec![
848                    section("name-1", "NAME", Vec::new()),
849                    section(
850                        "options-2",
851                        "OPTIONS",
852                        vec![
853                            section("common-3", "Common options", Vec::new()),
854                            section("other-4", "Other options", Vec::new()),
855                        ],
856                    ),
857                    section("files-5", "FILES", Vec::new()),
858                ],
859            }),
860            tldr: None,
861        }
862    }
863
864    fn tldr() -> TldrDocument {
865        TldrDocument {
866            title: "demo".to_owned(),
867            description: vec!["A small demonstration.".to_owned()],
868            more_information: Some("https://example.com/demo".to_owned()),
869            examples: Vec::new(),
870            platform: "common".to_owned(),
871            language: "en".to_owned(),
872            source_path: "/tldr/pages/common/demo.md".to_owned(),
873            origin: TldrOrigin::TldrPages,
874        }
875    }
876
877    #[test]
878    fn builds_one_based_tree_paths_without_copying_blocks() {
879        let outline = build_outline(&query()).expect("outline");
880
881        assert_eq!(
882            outline
883                .meta
884                .as_ref()
885                .and_then(|meta| meta.manual_section.as_deref()),
886            Some("1")
887        );
888        assert_eq!(outline.nodes[1].path(), "2");
889        assert_eq!(outline.nodes[1].id(), "options-2");
890        assert_eq!(outline.nodes[1].children()[0].path(), "2.1");
891        assert_eq!(outline.nodes[1].children()[1].path(), "2.2");
892    }
893
894    #[test]
895    fn prepends_tldr_as_zero_without_renumbering_manual_sections() {
896        let mut query = query();
897        query.tldr = Some(tldr());
898
899        let outline = build_outline(&query).expect("combined outline");
900
901        assert!(matches!(outline.nodes[0], OutlineNode::Tldr { .. }));
902        assert_eq!(outline.nodes[0].path(), "0");
903        assert_eq!(outline.nodes[0].id(), "tldr");
904        assert_eq!(outline.nodes[1].path(), "1");
905        assert_eq!(outline.nodes[2].path(), "2");
906    }
907
908    #[test]
909    fn addresses_document_content_before_the_first_heading_as_root() {
910        let mut query = query();
911        let document = query.document.as_mut().expect("document");
912        document.source.format = SourceFormat::Markdown;
913        document.blocks.push(Block::Paragraph {
914            children: vec![Inline::Text {
915                value: "Document preface.".to_owned(),
916            }],
917            layout: LayoutHint::default(),
918            source: None,
919        });
920
921        let outline = build_outline(&query).expect("Markdown outline");
922        assert!(matches!(
923            &outline.nodes[0],
924            OutlineNode::DocumentRoot { path, id, title }
925                if path == "root" && id == "document-overview" && title == "OVERVIEW"
926        ));
927        // Heading paths remain stable and independent from the synthetic root.
928        assert_eq!(outline.nodes[1].path(), "1");
929
930        let excerpt = select_excerpt(&query, &["document-overview".to_owned(), "root".to_owned()])
931            .expect("root excerpt");
932        assert!(matches!(
933            excerpt.selections.as_slice(),
934            [ExcerptSelection::DocumentRoot { path, blocks, .. }]
935                if path == "root" && blocks.len() == 1
936        ));
937        assert_eq!(
938            excerpt.source.as_ref().map(|source| source.format),
939            Some(SourceFormat::Markdown)
940        );
941    }
942
943    #[test]
944    fn selects_paths_or_ids_in_source_order_and_suppresses_descendant_duplicates() {
945        let excerpt = select_excerpt(
946            &query(),
947            &[
948                "files-5".to_owned(),
949                "2.1".to_owned(),
950                "2".to_owned(),
951                "options-2".to_owned(),
952            ],
953        )
954        .expect("excerpt");
955
956        let paths = excerpt
957            .selections
958            .iter()
959            .map(|selection| match selection {
960                ExcerptSelection::Tldr { path, .. }
961                | ExcerptSelection::DocumentRoot { path, .. }
962                | ExcerptSelection::DocumentSection { path, .. }
963                | ExcerptSelection::DocumentEntry { path, .. } => path.as_str(),
964            })
965            .collect::<Vec<_>>();
966        assert_eq!(paths, ["2", "3"]);
967        let ExcerptSelection::DocumentSection {
968            section,
969            breadcrumbs,
970            ..
971        } = &excerpt.selections[0]
972        else {
973            panic!("expected manual selection");
974        };
975        assert_eq!(section.children.len(), 2);
976        assert!(breadcrumbs.is_empty());
977    }
978
979    #[test]
980    fn child_selection_retains_ancestor_breadcrumbs() {
981        let excerpt = select_excerpt(&query(), &["2.2".to_owned()]).expect("excerpt");
982
983        let ExcerptSelection::DocumentSection {
984            title, breadcrumbs, ..
985        } = &excerpt.selections[0]
986        else {
987            panic!("expected manual selection");
988        };
989        assert_eq!(title, "Other options");
990        assert_eq!(breadcrumbs[0].path, "2");
991        assert_eq!(breadcrumbs[0].title, "OPTIONS");
992    }
993
994    #[test]
995    fn selects_tldr_by_zero_or_id_and_supports_tldr_only_outlines() {
996        let mut combined = query();
997        combined.tldr = Some(tldr());
998        let excerpt = select_excerpt(
999            &combined,
1000            &["2".to_owned(), "tldr".to_owned(), "0".to_owned()],
1001        )
1002        .expect("combined excerpt");
1003        assert!(matches!(
1004            excerpt.selections.as_slice(),
1005            [ExcerptSelection::Tldr { path, .. }, ExcerptSelection::DocumentSection { .. }]
1006                if path == "0"
1007        ));
1008
1009        let mut tldr_only = combined;
1010        tldr_only.document = None;
1011        let outline = build_outline(&tldr_only).expect("tldr-only outline");
1012        assert_eq!(outline.nodes.len(), 1);
1013        assert_eq!(outline.nodes[0].path(), "0");
1014        assert!(outline.source.is_none());
1015        assert!(outline.meta.is_none());
1016    }
1017
1018    #[test]
1019    fn reports_missing_content_and_unknown_or_empty_selectors() {
1020        let mut empty = query();
1021        empty.document = None;
1022        assert!(matches!(
1023            build_outline(&empty),
1024            Err(ProjectionError::MissingContent { .. })
1025        ));
1026        assert_eq!(
1027            select_excerpt(&query(), &[] as &[String]),
1028            Err(ProjectionError::EmptySelection)
1029        );
1030        assert_eq!(
1031            select_excerpt(&query(), &[" ".to_owned()]),
1032            Err(ProjectionError::EmptySelector)
1033        );
1034        assert!(matches!(
1035            select_excerpt(&query(), &["9".to_owned()]),
1036            Err(ProjectionError::UnknownSelector { .. })
1037        ));
1038    }
1039}