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