Skip to main content

mant_protocol/
presentation.rs

1//! Deterministic, transport-neutral presentations of protocol projections.
2
3use std::{borrow::Cow, collections::BTreeMap, fmt::Write as _};
4
5use mant_ir::{DocumentAddress, MarkdownOrigin};
6
7use crate::DocumentCatalog;
8
9/// Replace control characters in dynamic text before terminal presentation.
10///
11/// Logical document identities and diagnostics can originate in local file
12/// names or parser input. JSON keeps those values as data, while text-oriented
13/// frontends must never let them inject terminal control sequences or extra
14/// display lines.
15#[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/// Explain why an empty catalog query selected no indexable scope.
35///
36/// A covered scope with no name matches intentionally returns `None`; callers
37/// can retain their ordinary grep-like empty-result behavior.
38#[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/// Render a catalog page as stable, unstyled text.
75///
76/// Flat output is one `<catalog-path>\t<kind>` row per document. Grouped
77/// output uses catalog namespaces as headings and indents their document
78/// names. Neither form contains terminal escape sequences.
79#[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}