Skip to main content

mant_engine/output/
text.rs

1//! Renders query, outline, and excerpt contracts as unstyled semantic text.
2
3use mant_ir::{
4    Block, DefinitionItem, Inline, ListItem, ListKind, Section, TableCell, TldrCommandPart,
5    TldrDocument,
6};
7use mant_protocol::{ExcerptSelection, OutlineNode, QueryExcerpt, QueryOutline};
8
9use crate::ResolvedContent;
10
11/// Render a complete query without Markdown or terminal escape sequences.
12#[must_use]
13pub fn render_query_text(query: &ResolvedContent) -> String {
14    render_query_body(query, true)
15}
16
17/// Render the manual as `man(1)`-faithful plain text.
18///
19/// Identical to [`render_query_text`] except the prepended tldr block is
20/// omitted, so the output stays a faithful, noise-free subset of the manual
21/// page (no page furniture, overstrike, or hyphenation — those never enter
22/// the document model because the source is parsed directly).
23#[must_use]
24pub fn render_query_man(query: &ResolvedContent) -> String {
25    if query.document.is_none() {
26        return String::new();
27    }
28    render_query_body(query, false)
29}
30
31fn render_query_body(query: &ResolvedContent, include_tldr: bool) -> String {
32    let section = query
33        .document
34        .as_ref()
35        .and_then(|document| document.meta.manual_section.as_deref());
36    let mut parts = vec![document_label(&query.label, section)];
37    if include_tldr && let Some(tldr) = &query.tldr {
38        parts.push(render_tldr_text(tldr));
39    }
40    if let Some(document) = &query.document {
41        parts.push(render_blocks(&document.blocks, 0));
42        parts.push(render_sections(&document.sections, 0));
43    }
44    join_parts(parts)
45}
46
47/// Render a complete query outline as a copyable Unicode tree.
48#[must_use]
49pub fn render_outline_text(outline: &QueryOutline) -> String {
50    let mut lines = vec![document_label(
51        &outline.label,
52        outline
53            .meta
54            .as_ref()
55            .and_then(|meta| meta.manual_section.as_deref()),
56    )];
57    render_outline_nodes(&outline.nodes, "", &mut lines);
58    lines.join("\n").trim_end().to_owned()
59}
60
61/// Render selected query nodes as unstyled text with outline context.
62#[must_use]
63pub fn render_excerpt_text(excerpt: &QueryExcerpt) -> String {
64    let mut parts = vec![document_label(
65        &excerpt.label,
66        excerpt
67            .meta
68            .as_ref()
69            .and_then(|meta| meta.manual_section.as_deref()),
70    )];
71    for selection in &excerpt.selections {
72        parts.push(render_selection(selection));
73    }
74    join_parts(parts)
75}
76
77fn render_outline_nodes(nodes: &[OutlineNode], prefix: &str, output: &mut Vec<String>) {
78    for (index, node) in nodes.iter().enumerate() {
79        let last = index + 1 == nodes.len();
80        let connector = if last { "└─" } else { "├─" };
81        output.push(format!(
82            "{prefix}{connector} {} [{}] {}",
83            node.path(),
84            node.id(),
85            node.title()
86        ));
87        let child_prefix = format!("{prefix}{}", if last { "  " } else { "│ " });
88        render_outline_nodes(node.children(), &child_prefix, output);
89    }
90}
91
92fn render_selection(selection: &ExcerptSelection) -> String {
93    match selection {
94        ExcerptSelection::Tldr { document, .. } => render_tldr_text(document),
95        ExcerptSelection::DocumentRoot {
96            path,
97            title,
98            blocks,
99            ..
100        } => join_parts(vec![
101            format!("Outline {path}: {title}"),
102            render_blocks(blocks, 0),
103        ]),
104        ExcerptSelection::DocumentSection {
105            path,
106            title,
107            breadcrumbs,
108            section,
109            ..
110        } => {
111            let mut parts = Vec::new();
112            if !breadcrumbs.is_empty() {
113                let breadcrumb = breadcrumbs
114                    .iter()
115                    .map(|ancestor| ancestor.title.as_str())
116                    .chain(std::iter::once(title.as_str()))
117                    .collect::<Vec<_>>()
118                    .join(" > ");
119                parts.push(format!("Outline {path}: {breadcrumb}"));
120            }
121            parts.push(render_section(section, 0));
122            join_parts(parts)
123        }
124        ExcerptSelection::DocumentEntry {
125            path,
126            title,
127            breadcrumbs,
128            entry,
129            ..
130        } => {
131            let breadcrumb = breadcrumbs
132                .iter()
133                .map(|ancestor| ancestor.title.as_str())
134                .chain(std::iter::once(title.as_str()))
135                .collect::<Vec<_>>()
136                .join(" > ");
137            join_parts(vec![
138                format!("Outline {path}: {breadcrumb}"),
139                render_definitions(std::slice::from_ref(entry), true, 0),
140            ])
141        }
142    }
143}
144
145fn render_tldr_text(tldr: &TldrDocument) -> String {
146    let mut lines = vec!["TLDR".to_owned()];
147    lines.extend(tldr.description.iter().map(|line| line.trim().to_owned()));
148    if let Some(information) = &tldr.more_information {
149        lines.push(format!("More information: {}", information.trim()));
150    }
151    for example in &tldr.examples {
152        if !example.description.trim().is_empty() {
153            lines.push(example.description.trim().to_owned());
154        }
155        let command = example
156            .command_parts
157            .iter()
158            .map(|part| match part {
159                TldrCommandPart::Text { value } | TldrCommandPart::Placeholder { value } => {
160                    value.as_str()
161                }
162            })
163            .collect::<String>();
164        lines.push(if command.is_empty() {
165            example.command.clone()
166        } else {
167            command
168        });
169    }
170    lines.join("\n\n")
171}
172
173fn render_sections(sections: &[Section], depth: usize) -> String {
174    sections
175        .iter()
176        .map(|section| render_section(section, depth))
177        .filter(|section| !section.is_empty())
178        .collect::<Vec<_>>()
179        .join("\n\n")
180}
181
182fn render_section(section: &Section, depth: usize) -> String {
183    let heading_indent = "  ".repeat(depth);
184    let mut parts = vec![format!("{heading_indent}{}", section.title)];
185    let blocks = render_blocks(&section.blocks, depth.saturating_mul(2));
186    if !blocks.is_empty() {
187        parts.push(blocks);
188    }
189    let children = render_sections(&section.children, depth + 1);
190    if !children.is_empty() {
191        parts.push(children);
192    }
193    join_parts(parts)
194}
195
196fn render_blocks(blocks: &[Block], base_indent: usize) -> String {
197    // Blocks are separated by a single blank line by default. An explicit
198    // vertical-space node *sets* the gap before the next block rather than
199    // adding to it, so `.sp` and blank input lines are not double-counted
200    // against the default paragraph separation (which previously turned one
201    // requested blank line into several). Leading and trailing gaps are
202    // dropped so a section never opens or closes with blank lines.
203    let mut output = String::new();
204    let mut has_content = false;
205    let mut pending_blank_lines: Option<usize> = None;
206    for block in blocks {
207        if let Block::VerticalSpace { lines, .. } = block {
208            if has_content {
209                let requested = usize::from(*lines);
210                pending_blank_lines = Some(pending_blank_lines.unwrap_or(0).max(requested));
211            }
212            continue;
213        }
214        let Some(text) = render_block(block, base_indent) else {
215            continue;
216        };
217        if has_content {
218            let blank_lines = pending_blank_lines.unwrap_or(1);
219            output.push_str(&"\n".repeat(blank_lines + 1));
220        }
221        output.push_str(&text);
222        has_content = true;
223        pending_blank_lines = None;
224    }
225    output
226}
227
228fn render_block(block: &Block, base_indent: usize) -> Option<String> {
229    let (value, layout_indent) = match block {
230        Block::Paragraph {
231            children, layout, ..
232        }
233        | Block::Preformatted {
234            children, layout, ..
235        } => (inline_text(children), usize::from(layout.indent_columns)),
236        Block::List {
237            kind,
238            start,
239            items,
240            layout,
241            ..
242        } => (
243            render_list(*kind, *start, items, base_indent),
244            usize::from(layout.indent_columns),
245        ),
246        Block::DefinitionList {
247            items,
248            compact,
249            layout,
250            ..
251        } => (
252            render_definitions(items, *compact, base_indent),
253            usize::from(layout.indent_columns),
254        ),
255        Block::Table { rows, layout, .. } => (
256            rows.iter()
257                .map(|row| {
258                    row.cells
259                        .iter()
260                        .map(cell_text)
261                        .collect::<Vec<_>>()
262                        .join(" | ")
263                })
264                .collect::<Vec<_>>()
265                .join("\n"),
266            usize::from(layout.indent_columns),
267        ),
268        Block::Equation { value, layout, .. }
269        | Block::Unsupported {
270            text: value,
271            layout,
272            ..
273        } => (value.clone(), usize::from(layout.indent_columns)),
274        // Vertical space is handled as an inter-block separator in
275        // `render_blocks`, never as a standalone rendered block.
276        Block::VerticalSpace { .. } => return None,
277        Block::ThematicBreak { .. } => ("---".to_owned(), 0),
278    };
279    let value = value.trim_matches('\n');
280    (!value.trim().is_empty()).then(|| indent_lines(value, base_indent + layout_indent))
281}
282
283fn render_list(
284    kind: ListKind,
285    start: Option<u64>,
286    items: &[ListItem],
287    base_indent: usize,
288) -> String {
289    items
290        .iter()
291        .enumerate()
292        .filter_map(|(index, item)| {
293            let marker = match kind {
294                ListKind::Ordered => format!(
295                    "{}. ",
296                    start
297                        .unwrap_or(1)
298                        .saturating_add(u64::try_from(index).unwrap_or(u64::MAX))
299                ),
300                ListKind::Bullet => "- ".to_owned(),
301                ListKind::Plain => String::new(),
302            };
303            prefix_text_item(&render_blocks(&item.blocks, base_indent), &marker)
304        })
305        .collect::<Vec<_>>()
306        .join("\n")
307}
308
309fn render_definitions(items: &[DefinitionItem], compact: bool, base_indent: usize) -> String {
310    let rendered = items
311        .iter()
312        .filter_map(|item| {
313            let terms = item
314                .terms
315                .iter()
316                .map(|term| inline_text(term))
317                .filter(|term| !term.trim().is_empty())
318                .collect::<Vec<_>>()
319                .join(", ");
320            let description = render_blocks(&item.description, base_indent);
321            let value = match (terms.is_empty(), description.is_empty()) {
322                (false, false) => {
323                    if item.inline_term {
324                        Some(format!("{terms} {}", description.trim_start()))
325                    } else {
326                        Some(format!("{terms}\n{}", indent_lines(&description, 2)))
327                    }
328                }
329                (false, true) => Some(terms),
330                (true, false) => Some(description),
331                (true, true) => None,
332            }?;
333            Some((value, item.spacing_before_lines))
334        })
335        .collect::<Vec<_>>();
336
337    let Some((first, rest)) = rendered.split_first() else {
338        return String::new();
339    };
340    let mut output = first.0.clone();
341    for (item, spacing_before_lines) in rest {
342        let blank_lines = spacing_before_lines.unwrap_or(u16::from(!compact));
343        output.push_str(&"\n".repeat(usize::from(blank_lines) + 1));
344        output.push_str(item);
345    }
346    output
347}
348
349fn cell_text(cell: &TableCell) -> String {
350    render_blocks(&cell.blocks, 0).replace('\n', " ")
351}
352
353fn inline_text(children: &[Inline]) -> String {
354    let mut output = String::new();
355    for child in children {
356        match child {
357            Inline::Text { value } | Inline::Code { value } => output.push_str(value),
358            Inline::Strong { children }
359            | Inline::Emphasis { children }
360            | Inline::Link { children, .. } => output.push_str(&inline_text(children)),
361            Inline::Anchor { .. } => {}
362            Inline::LineBreak => output.push('\n'),
363        }
364    }
365    output
366}
367
368fn prefix_text_item(content: &str, marker: &str) -> Option<String> {
369    if content.trim().is_empty() {
370        return None;
371    }
372    let continuation = " ".repeat(marker.chars().count());
373    let mut lines = content.lines();
374    let mut output = format!("{marker}{}", lines.next()?);
375    for line in lines {
376        output.push('\n');
377        output.push_str(&continuation);
378        output.push_str(line);
379    }
380    Some(output)
381}
382
383fn indent_lines(value: &str, columns: usize) -> String {
384    if columns == 0 {
385        return value.to_owned();
386    }
387    let prefix = " ".repeat(columns);
388    value
389        .lines()
390        .map(|line| {
391            if line.is_empty() {
392                String::new()
393            } else {
394                format!("{prefix}{line}")
395            }
396        })
397        .collect::<Vec<_>>()
398        .join("\n")
399}
400
401fn document_label(document: &str, section: Option<&str>) -> String {
402    section.map_or_else(
403        || document.to_owned(),
404        |section| format!("{document}({section})"),
405    )
406}
407
408fn join_parts(parts: Vec<String>) -> String {
409    parts
410        .into_iter()
411        .filter(|part| !part.trim().is_empty())
412        .collect::<Vec<_>>()
413        .join("\n\n")
414        .trim_end()
415        .to_owned()
416}
417
418#[cfg(test)]
419mod tests {
420    use crate::ResolvedContent;
421    use mant_ir::{
422        Block, DefinitionItem, Document, DocumentMeta, DocumentSource, Inline, LayoutHint, Section,
423        SourceFormat, TldrDocument, TldrOrigin,
424    };
425
426    use super::{render_excerpt_text, render_outline_text, render_query_man, render_query_text};
427    use crate::{build_outline, select_excerpt};
428
429    fn query() -> ResolvedContent {
430        ResolvedContent {
431            address: None,
432            label: "demo".to_owned(),
433            document: Some(Document {
434                parser: None,
435                source: DocumentSource {
436                    format: SourceFormat::Man,
437                    path: None,
438                },
439                meta: DocumentMeta {
440                    manual_section: Some("1".to_owned()),
441                    ..DocumentMeta::default()
442                },
443                diagnostics: Vec::new(),
444                blocks: Vec::new(),
445                sections: vec![Section {
446                    id: "options-1".to_owned().into(),
447                    title: "OPTIONS".to_owned(),
448                    spacing_before_lines: 0,
449                    blocks: vec![paragraph("parent details", true)],
450                    children: vec![Section {
451                        id: "common-2".to_owned().into(),
452                        title: "Common options".to_owned(),
453                        spacing_before_lines: 1,
454                        blocks: vec![paragraph("child details", false)],
455                        children: Vec::new(),
456                        source: None,
457                    }],
458                    source: None,
459                }],
460            }),
461            tldr: None,
462        }
463    }
464
465    fn paragraph(value: &str, strong: bool) -> Block {
466        let text = vec![Inline::Text {
467            value: value.to_owned(),
468        }];
469        Block::Paragraph {
470            children: if strong {
471                vec![Inline::Strong { children: text }]
472            } else {
473                text
474            },
475            layout: LayoutHint::default(),
476            source: None,
477        }
478    }
479
480    #[test]
481    fn renders_plain_queries_without_markup_and_uses_resolved_manual_sections() {
482        let output = render_query_text(&query());
483
484        assert!(output.starts_with("demo(1)\n\nOPTIONS"));
485        assert!(output.contains("parent details"));
486        assert!(output.contains("Common options"));
487        assert!(!output.contains("**"));
488    }
489
490    #[test]
491    fn renders_copyable_outline_trees_and_contextual_excerpts() {
492        let query = query();
493        let outline = build_outline(&query).expect("outline");
494        assert_eq!(
495            render_outline_text(&outline),
496            "demo(1)\n└─ 1 [options-1] OPTIONS\n  └─ 1.1 [common-2] Common options"
497        );
498
499        let excerpt = select_excerpt(&query, &["1.1".to_owned()]).expect("excerpt");
500        let output = render_excerpt_text(&excerpt);
501        assert!(output.contains("Outline 1.1: OPTIONS > Common options"));
502        assert!(output.contains("child details"));
503        assert!(!output.contains("parent details"));
504    }
505
506    #[test]
507    fn renders_tldr_as_zero_in_outlines_and_standalone_excerpts() {
508        let mut query = query();
509        query.tldr = Some(TldrDocument {
510            title: "demo".to_owned(),
511            description: vec!["A small demonstration.".to_owned()],
512            more_information: None,
513            examples: Vec::new(),
514            platform: "common".to_owned(),
515            language: "en".to_owned(),
516            source_path: "/cache/tldr/demo.md".to_owned(),
517            origin: TldrOrigin::TldrPages,
518        });
519
520        let outline = render_outline_text(&build_outline(&query).expect("combined outline"));
521        assert!(outline.contains("├─ 0 [tldr] TLDR QUICK REFERENCE"));
522        assert!(outline.contains("└─ 1 [options-1] OPTIONS"));
523
524        let excerpt = select_excerpt(&query, &["tldr".to_owned()]).expect("tldr excerpt");
525        assert_eq!(
526            render_excerpt_text(&excerpt),
527            "demo\n\nTLDR\n\nA small demonstration."
528        );
529    }
530
531    #[test]
532    fn man_format_renders_the_manual_but_omits_the_prepended_tldr() {
533        let mut query = query();
534        query.tldr = Some(TldrDocument {
535            title: "demo".to_owned(),
536            description: vec!["A small demonstration.".to_owned()],
537            more_information: None,
538            examples: Vec::new(),
539            platform: "common".to_owned(),
540            language: "en".to_owned(),
541            source_path: "/cache/tldr/demo.md".to_owned(),
542            origin: TldrOrigin::TldrPages,
543        });
544
545        let text = render_query_text(&query);
546        let man = render_query_man(&query);
547
548        // text keeps the tldr block; man drops it entirely.
549        assert!(text.contains("TLDR"));
550        assert!(text.contains("A small demonstration."));
551        assert!(!man.contains("TLDR"));
552        assert!(!man.contains("A small demonstration."));
553
554        // man still renders the manual body verbatim, without markup.
555        assert!(man.starts_with("demo(1)\n\nOPTIONS"));
556        assert!(man.contains("parent details"));
557        assert!(man.contains("Common options"));
558        assert!(!man.contains("**"));
559    }
560
561    #[test]
562    fn man_format_does_not_invent_a_document_for_tldr_only_queries() {
563        let mut query = query();
564        query.document = None;
565        query.tldr = Some(TldrDocument {
566            title: "demo".to_owned(),
567            description: vec!["A small demonstration.".to_owned()],
568            more_information: None,
569            examples: Vec::new(),
570            platform: "common".to_owned(),
571            language: "en".to_owned(),
572            source_path: "/cache/tldr/demo.md".to_owned(),
573            origin: TldrOrigin::TldrPages,
574        });
575
576        assert!(render_query_man(&query).is_empty());
577    }
578
579    #[test]
580    fn vertical_space_sets_the_gap_instead_of_stacking_blank_lines() {
581        fn document_with(blocks: Vec<Block>) -> ResolvedContent {
582            ResolvedContent {
583                address: None,
584                label: "demo".to_owned(),
585                document: Some(Document {
586                    parser: None,
587                    source: DocumentSource {
588                        format: SourceFormat::Man,
589                        path: None,
590                    },
591                    meta: DocumentMeta {
592                        manual_section: Some("1".to_owned()),
593                        ..DocumentMeta::default()
594                    },
595                    diagnostics: Vec::new(),
596                    blocks: Vec::new(),
597                    sections: vec![Section {
598                        id: "s-1".to_owned().into(),
599                        title: "S".to_owned(),
600                        spacing_before_lines: 0,
601                        blocks,
602                        children: Vec::new(),
603                        source: None,
604                    }],
605                }),
606                tldr: None,
607            }
608        }
609        fn para(value: &str) -> Block {
610            Block::Paragraph {
611                children: vec![Inline::Text {
612                    value: value.to_owned(),
613                }],
614                layout: LayoutHint::default(),
615                source: None,
616            }
617        }
618        let vspace = |lines: u16| Block::VerticalSpace {
619            lines,
620            source: None,
621        };
622
623        // One vertical-space line yields exactly one blank line, not several.
624        let one = render_query_text(&document_with(vec![
625            para("first"),
626            vspace(1),
627            para("second"),
628        ]));
629        assert!(one.contains("first\n\nsecond"), "got: {one:?}");
630        assert!(!one.contains("first\n\n\nsecond"), "got: {one:?}");
631
632        // A larger explicit gap is preserved rather than collapsed.
633        let wide = render_query_text(&document_with(vec![
634            para("first"),
635            vspace(2),
636            para("second"),
637        ]));
638        assert!(wide.contains("first\n\n\nsecond"), "got: {wide:?}");
639
640        // Leading and trailing vertical space never adds blank lines at the edges.
641        let edges = render_query_text(&document_with(vec![vspace(2), para("only"), vspace(3)]));
642        assert!(edges.ends_with("only"), "got: {edges:?}");
643        assert!(edges.contains("S\n\nonly"), "got: {edges:?}");
644    }
645
646    #[test]
647    fn inline_definition_descriptions_are_tight_against_their_terms() {
648        let bundle = ResolvedContent {
649            address: None,
650            label: "demo".to_owned(),
651            document: Some(Document {
652                parser: None,
653                source: DocumentSource {
654                    format: SourceFormat::Man,
655                    path: None,
656                },
657                meta: DocumentMeta {
658                    manual_section: Some("1".to_owned()),
659                    ..DocumentMeta::default()
660                },
661                diagnostics: Vec::new(),
662                blocks: Vec::new(),
663                sections: vec![Section {
664                    id: "ops".to_owned().into(),
665                    title: "OPERATORS".to_owned(),
666                    spacing_before_lines: 0,
667                    blocks: vec![Block::DefinitionList {
668                        compact: false,
669                        layout: LayoutHint::default(),
670                        source: None,
671                        items: vec![
672                            DefinitionItem {
673                                identity: None,
674                                inline_term: true,
675                                terms: vec![vec![Inline::Text {
676                                    value: "* / %".to_owned(),
677                                }]],
678                                description: vec![Block::Paragraph {
679                                    children: vec![Inline::Text {
680                                        value: "Multiplication, division, and modulus.".to_owned(),
681                                    }],
682                                    layout: LayoutHint::default(),
683                                    source: None,
684                                }],
685                                spacing_before_lines: Some(1),
686                            },
687                            DefinitionItem {
688                                identity: None,
689                                inline_term: true,
690                                terms: vec![vec![Inline::Text {
691                                    value: "space".to_owned(),
692                                }]],
693                                description: vec![Block::Paragraph {
694                                    children: vec![Inline::Text {
695                                        value: "String concatenation.".to_owned(),
696                                    }],
697                                    layout: LayoutHint::default(),
698                                    source: None,
699                                }],
700                                spacing_before_lines: Some(1),
701                            },
702                        ],
703                    }],
704                    children: Vec::new(),
705                    source: None,
706                }],
707            }),
708            tldr: None,
709        };
710
711        let output = render_query_text(&bundle);
712        // Tight: exactly one space between term and description, no leaked indent.
713        assert!(
714            output.contains("* / % Multiplication, division, and modulus."),
715            "got: {output:?}"
716        );
717        assert!(
718            output.contains("space String concatenation."),
719            "got: {output:?}"
720        );
721        // No double-space gap between term and description.
722        assert!(!output.contains("* / %  "), "got: {output:?}");
723        assert!(!output.contains("space  "), "got: {output:?}");
724    }
725
726    #[test]
727    fn man_format_keeps_inline_definitions_tight() {
728        let bundle = ResolvedContent {
729            address: None,
730            label: "demo".to_owned(),
731            document: Some(Document {
732                parser: None,
733                source: DocumentSource {
734                    format: SourceFormat::Man,
735                    path: None,
736                },
737                meta: DocumentMeta {
738                    manual_section: Some("1".to_owned()),
739                    ..DocumentMeta::default()
740                },
741                diagnostics: Vec::new(),
742                blocks: Vec::new(),
743                sections: vec![Section {
744                    id: "ops".to_owned().into(),
745                    title: "OPERATORS".to_owned(),
746                    spacing_before_lines: 0,
747                    blocks: vec![Block::DefinitionList {
748                        compact: false,
749                        layout: LayoutHint::default(),
750                        source: None,
751                        items: vec![
752                            DefinitionItem {
753                                identity: None,
754                                inline_term: true,
755                                terms: vec![vec![Inline::Text {
756                                    value: "&&".to_owned(),
757                                }]],
758                                description: vec![Block::Paragraph {
759                                    children: vec![Inline::Text {
760                                        value: "Logical AND.".to_owned(),
761                                    }],
762                                    layout: LayoutHint::default(),
763                                    source: None,
764                                }],
765                                spacing_before_lines: Some(1),
766                            },
767                            DefinitionItem {
768                                identity: None,
769                                inline_term: false,
770                                terms: vec![vec![Inline::Text {
771                                    value: "--long-option-name".to_owned(),
772                                }]],
773                                description: vec![Block::Paragraph {
774                                    children: vec![Inline::Text {
775                                        value: "A lengthy flag.".to_owned(),
776                                    }],
777                                    layout: LayoutHint::default(),
778                                    source: None,
779                                }],
780                                spacing_before_lines: Some(1),
781                            },
782                        ],
783                    }],
784                    children: Vec::new(),
785                    source: None,
786                }],
787            }),
788            tldr: None,
789        };
790
791        let man = render_query_man(&bundle);
792        // inline_term=true in --format man: tight single-space.
793        assert!(man.contains("&& Logical AND."), "got: {man:?}");
794        // inline_term=false in --format man: term on its own line.
795        assert!(
796            man.contains("--long-option-name\n  A lengthy flag."),
797            "got: {man:?}"
798        );
799    }
800}