Skip to main content

mant_engine/output/
search.rs

1//! Presents structure-aware search results for terminals and language models.
2
3use std::fmt::Write as _;
4
5use mant_protocol::QuerySearch;
6
7/// Render grep-like results with stable Markdown coordinates and node paths.
8#[must_use]
9pub fn render_search_text(search: &QuerySearch) -> String {
10    let label = document_label(search);
11    if search.total == 0 {
12        return format!("No matches for \"{}\" in {label}.", search.query.pattern);
13    }
14    if search.matches.is_empty() {
15        return format!(
16            "No matches returned at offset {} for \"{}\" in {label} ({} total).",
17            search.offset, search.query.pattern, search.total
18        );
19    }
20
21    let mut rendered = search
22        .matches
23        .iter()
24        .map(|found| {
25            let mut lines = vec![format!(
26                "{label}:{}:{} [{}] {}",
27                found.markdown.start_line,
28                found.markdown.start_column,
29                found.node.path(),
30                found.node.title()
31            )];
32            if found.context.is_empty() {
33                lines.push(format!("  {}", found.preview));
34            } else {
35                lines.extend(found.context.iter().map(|line| {
36                    format!(
37                        "  {} {} {}",
38                        if line.matched { ">" } else { " " },
39                        line.line,
40                        line.text
41                    )
42                }));
43            }
44            lines.join("\n")
45        })
46        .collect::<Vec<_>>()
47        .join("\n\n");
48    if let Some(next_offset) = search.next_offset {
49        let _ = write!(
50            rendered,
51            "\n\n{} total matches; continue with --offset {next_offset}.",
52            search.total
53        );
54    }
55    rendered
56}
57
58/// Render a readable Markdown report whose coordinates target the full page.
59#[must_use]
60pub fn render_search_markdown(search: &QuerySearch) -> String {
61    let label = document_label(search);
62    let mut blocks = vec![format!(
63        "# Search results for {} in {}",
64        code_span(&search.query.pattern),
65        escape_text(&label)
66    )];
67    blocks.push(format!(
68        "{} {} in the full Markdown document.",
69        search.total,
70        if search.total == 1 {
71            "match"
72        } else {
73            "matches"
74        }
75    ));
76    if search.returned < search.total {
77        if search.returned == 0 {
78            blocks.push(format!(
79                "No matches were returned at offset {}.",
80                search.offset
81            ));
82        } else {
83            let range_start = search.offset.saturating_add(1);
84            let range_end = search.offset.saturating_add(search.returned);
85            let continuation = search
86                .next_offset
87                .map_or(String::new(), |offset| format!(" Next offset: `{offset}`."));
88            blocks.push(format!(
89                "Showing matches {range_start}–{range_end}.{continuation}"
90            ));
91        }
92    }
93
94    for found in &search.matches {
95        blocks.push(format!(
96            "## {}. {}",
97            found.ordinal,
98            code_span(found.node.title())
99        ));
100        let mut details = vec![
101            format!("- Node: {}", code_span(found.node.path())),
102            format!(
103                "- Markdown: line {}, column {}",
104                found.markdown.start_line, found.markdown.start_column
105            ),
106        ];
107        if let Some(section) = &found.section {
108            details.push(format!(
109                "- Section: {} ({})",
110                code_span(&section.title),
111                code_span(&section.path)
112            ));
113        }
114        if let Some(source) = found.source {
115            details.push(format!(
116                "- Source: line {}, column {}",
117                source.line, source.column
118            ));
119        }
120        blocks.push(details.join("\n"));
121        blocks.push(format!("> {}", found.preview.replace('\n', "\n> ")));
122    }
123    blocks.join("\n\n").trim_end().to_owned()
124}
125
126fn document_label(search: &QuerySearch) -> String {
127    search
128        .meta
129        .as_ref()
130        .and_then(|meta| meta.manual_section.as_deref())
131        .map_or_else(
132            || search.label.clone(),
133            |section| format!("{}({section})", search.label),
134        )
135}
136
137fn code_span(value: &str) -> String {
138    let width = value
139        .split(|character| character != '`')
140        .map(str::len)
141        .max()
142        .unwrap_or(0)
143        .saturating_add(1)
144        .max(1);
145    let delimiter = "`".repeat(width);
146    format!("{delimiter}{value}{delimiter}")
147}
148
149fn escape_text(value: &str) -> String {
150    value
151        .replace('\\', "\\\\")
152        .replace('*', "\\*")
153        .replace('_', "\\_")
154        .replace('[', "\\[")
155        .replace(']', "\\]")
156}
157
158#[cfg(test)]
159mod tests {
160    use mant_protocol::{
161        MarkdownSchema, QuerySearch, SearchCase, SearchMarkdownRange, SearchMatch, SearchNode,
162        SearchQuery, SearchRender, SearchRenderFormat, SearchRenderScope, SearchSchema,
163        SearchScope, SearchSyntax,
164    };
165
166    use super::{render_search_markdown, render_search_text};
167
168    fn result() -> QuerySearch {
169        QuerySearch {
170            schema: SearchSchema::V7,
171            label: "tar".to_owned(),
172            source: None,
173            meta: Some(mant_ir::DocumentMeta {
174                manual_section: Some("1".to_owned()),
175                ..mant_ir::DocumentMeta::default()
176            }),
177            query: SearchQuery {
178                pattern: "--acls".to_owned(),
179                syntax: SearchSyntax::Literal,
180                case: SearchCase::Insensitive,
181                scope: SearchScope::Visible,
182                word: false,
183                context_lines: 0,
184                limit: 100,
185                offset: 0,
186            },
187            render: SearchRender {
188                schema: MarkdownSchema::V1,
189                format: SearchRenderFormat::Markdown,
190                scope: SearchRenderScope::Full,
191                line_base: 1,
192                column_base: 1,
193                line_count: 900,
194            },
195            total: 1,
196            returned: 1,
197            offset: 0,
198            truncated: false,
199            next_offset: None,
200            matches: vec![SearchMatch {
201                ordinal: 1,
202                node: SearchNode::DocumentEntry {
203                    path: "5.3/e17".to_owned().into(),
204                    id: "acls-option".to_owned().into(),
205                    title: "--acls".to_owned(),
206                    role: mant_ir::DefinitionRole::Option,
207                    case: mant_ir::DefinitionCase::Sensitive,
208                    names: vec!["--acls".to_owned()],
209                },
210                section: None,
211                matched_text: "--acls".to_owned(),
212                markdown: SearchMarkdownRange {
213                    start_byte: 10,
214                    end_byte: 16,
215                    start_line: 824,
216                    start_column: 3,
217                    end_line: 824,
218                    end_column: 9,
219                },
220                source: None,
221                preview: "- `--acls`".to_owned(),
222                context: Vec::new(),
223            }],
224        }
225    }
226
227    #[test]
228    fn search_reports_are_human_readable_but_keep_machine_node_paths() {
229        let result = result();
230        assert!(render_search_text(&result).contains("tar(1):824:3 [5.3/e17] --acls"));
231        let markdown = render_search_markdown(&result);
232        assert!(markdown.contains("# Search results for `--acls` in tar(1)"));
233        assert!(markdown.contains("- Node: `5.3/e17`"));
234    }
235}