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