mant_protocol/
presentation.rs1use std::{borrow::Cow, collections::BTreeMap, fmt::Write as _};
4
5use mant_ir::{DocumentAddress, MarkdownOrigin};
6
7use crate::DocumentCatalog;
8
9#[must_use]
16pub fn sanitize_terminal_text(value: &str) -> Cow<'_, str> {
17 if !value.chars().any(char::is_control) {
18 return Cow::Borrowed(value);
19 }
20 Cow::Owned(
21 value
22 .chars()
23 .map(|character| {
24 if character.is_control() {
25 '\u{fffd}'
26 } else {
27 character
28 }
29 })
30 .collect(),
31 )
32}
33
34#[must_use]
39pub fn render_catalog_coverage_text(catalog: &DocumentCatalog) -> Option<String> {
40 if catalog.total != 0 || catalog.coverage.scope_total != 0 {
41 return None;
42 }
43 if let Some(section) = &catalog.query.manual_section {
44 let mut message = format!(
45 "no manuals indexed for section '{}'",
46 sanitize_terminal_text(section)
47 );
48 if !catalog.coverage.manual_sections.is_empty() {
49 message.push_str("\nindexed manual sections: ");
50 message.push_str(&catalog.coverage.manual_sections.join(", "));
51 }
52 return Some(message);
53 }
54 if let Some(source) = &catalog.query.source {
55 let mut message = format!(
56 "source '{}' has no indexed Markdown documents",
57 sanitize_terminal_text(source)
58 );
59 if !catalog.coverage.markdown_sources.is_empty() {
60 message.push_str("\nindexed Markdown sources: ");
61 message.push_str(&catalog.coverage.markdown_sources.join(", "));
62 }
63 return Some(message);
64 }
65 match catalog.query.kind {
66 Some(crate::CatalogDocumentKind::Manual) => Some("no manuals indexed".to_owned()),
67 Some(crate::CatalogDocumentKind::Markdown) => {
68 Some("no Markdown documents indexed".to_owned())
69 }
70 None => Some("no documents indexed".to_owned()),
71 }
72}
73
74#[must_use]
80pub fn render_catalog_text(catalog: &DocumentCatalog, grouped: bool) -> String {
81 if !grouped {
82 let mut output = String::new();
83 for document in &catalog.documents {
84 let (_, kind) = catalog_category(&document.address);
85 writeln!(
86 output,
87 "{}\t{kind}",
88 sanitize_terminal_text(&document.catalog_path())
89 )
90 .expect("writing to String cannot fail");
91 }
92 return output;
93 }
94
95 let mut categories = BTreeMap::<String, Vec<&str>>::new();
96 for document in &catalog.documents {
97 let (category, _) = catalog_category(&document.address);
98 categories
99 .entry(category)
100 .or_default()
101 .push(match &document.address {
102 DocumentAddress::Markdown { path, .. } => path,
103 DocumentAddress::Manual { name, .. } => name,
104 });
105 }
106 let mut output = String::new();
107 for (index, (category, names)) in categories.into_iter().enumerate() {
108 if index > 0 {
109 output.push('\n');
110 }
111 output.push_str(&sanitize_terminal_text(&category));
112 output.push('\n');
113 for name in names {
114 output.push_str(" ");
115 output.push_str(&sanitize_terminal_text(name));
116 output.push('\n');
117 }
118 }
119 output
120}
121
122fn catalog_category(address: &DocumentAddress) -> (String, &'static str) {
123 match address {
124 DocumentAddress::Markdown {
125 origin: MarkdownOrigin::Documents,
126 ..
127 } => ("documents".to_owned(), "markdown"),
128 DocumentAddress::Markdown {
129 origin: MarkdownOrigin::Source { name },
130 ..
131 } => (format!("sources/{name}"), "markdown"),
132 DocumentAddress::Manual { manual_section, .. } => {
133 (format!("manual/{manual_section}"), "manual")
134 }
135 }
136}
137
138#[cfg(test)]
139mod tests {
140 use mant_ir::{DocumentAddress, MarkdownOrigin};
141
142 use crate::{
143 CatalogCoverage, CatalogDocumentKind, CatalogQuery, CatalogSchema, DocumentCatalog,
144 DocumentSummary,
145 };
146
147 use super::{render_catalog_coverage_text, render_catalog_text, sanitize_terminal_text};
148
149 #[test]
150 fn masks_terminal_controls_without_changing_unicode_text() {
151 assert_eq!(sanitize_terminal_text("safe → text"), "safe → text");
152 assert_eq!(
153 sanitize_terminal_text("bad\u{1b}[31m\nname"),
154 "bad�[31m�name"
155 );
156 }
157
158 fn catalog() -> DocumentCatalog {
159 let addresses = [
160 DocumentAddress::Markdown {
161 path: "mant".to_owned(),
162 origin: MarkdownOrigin::Documents,
163 },
164 DocumentAddress::Manual {
165 name: "git".to_owned(),
166 manual_section: "1".to_owned(),
167 },
168 ];
169 DocumentCatalog {
170 schema: CatalogSchema::V0Dot8,
171 query: crate::CatalogQuery::default(),
172 coverage: crate::CatalogCoverage::default(),
173 total: 2,
174 returned: 2,
175 offset: 0,
176 truncated: false,
177 next_offset: None,
178 documents: addresses
179 .into_iter()
180 .map(|address| DocumentSummary { address })
181 .collect(),
182 }
183 }
184
185 #[test]
186 fn flat_catalog_text_is_compact_and_machine_copyable() {
187 assert_eq!(
188 render_catalog_text(&catalog(), false),
189 "documents/mant\tmarkdown\nmanual/1/git\tmanual\n"
190 );
191 }
192
193 #[test]
194 fn grouped_catalog_text_preserves_catalog_namespaces() {
195 assert_eq!(
196 render_catalog_text(&catalog(), true),
197 "documents\n mant\n\nmanual/1\n git\n"
198 );
199 }
200
201 #[test]
202 fn catalog_text_masks_controls_from_logical_addresses() {
203 let mut catalog = catalog();
204 catalog.documents[0].address = DocumentAddress::Manual {
205 name: "tool\u{1b}[2J\nnext".to_owned(),
206 manual_section: "1".to_owned(),
207 };
208
209 let rendered = render_catalog_text(&catalog, false);
210 assert_eq!(
211 rendered,
212 "manual/1/tool�[2J�next\tmanual\nmanual/1/git\tmanual\n"
213 );
214 assert!(!rendered.contains('\u{1b}'));
215 }
216
217 #[test]
218 fn empty_catalog_explains_only_an_unindexed_scope() {
219 let unindexed = DocumentCatalog {
220 query: CatalogQuery {
221 kind: Some(CatalogDocumentKind::Manual),
222 manual_section: Some("42".to_owned()),
223 ..CatalogQuery::default()
224 },
225 coverage: CatalogCoverage {
226 scope_total: 0,
227 manual_sections: vec!["1".to_owned(), "2".to_owned(), "2const".to_owned()],
228 ..CatalogCoverage::default()
229 },
230 ..DocumentCatalog::default()
231 };
232 assert_eq!(
233 render_catalog_coverage_text(&unindexed).as_deref(),
234 Some("no manuals indexed for section '42'\nindexed manual sections: 1, 2, 2const")
235 );
236
237 let covered = DocumentCatalog {
238 coverage: CatalogCoverage {
239 scope_total: 12,
240 ..CatalogCoverage::default()
241 },
242 ..unindexed
243 };
244 assert_eq!(render_catalog_coverage_text(&covered), None);
245 }
246}