Skip to main content

hashiverse_lib/tools/
plain_text_post.rs

1//! # Plain-text → hashiverse HTML conversion
2//!
3//! Hashiverse posts are stored and transmitted as a constrained subset of HTML (so that
4//! rich posts from the web client, API clients, and plain-text API clients are all the
5//! same format on the wire). This module provides the one-way convenience path for
6//! callers that have nothing but a string of text — mainly the Python client, plain-text
7//! API integrations, and quick CLI posts.
8//!
9//! The output is the same HTML shape produced by the Tiptap editor in the web client:
10//! HTML-escaped body, `#hashtag` tokens rewritten as `<hashtag>` elements, `@<64-hex-id>`
11//! mentions rewritten as `<mention>` elements, and literal newlines turned into `<br>`.
12//! `submit_post()` then parses the result into the canonical on-wire representation.
13
14/// Converts a plain-text post into well-formed HTML that `submit_post()` can parse.
15///
16/// - HTML-escapes `<`, `>`, `&`, `"` in the input to prevent injection
17/// - Converts `#hashtag` patterns into `<hashtag hashtag="...">` elements
18/// - Converts `@<64-hex-char-id>` patterns into `<mention client_id="...">` elements
19/// - Converts newlines into `<br>` tags
20pub fn convert_text_to_hashiverse_html(text: &str) -> String {
21    let escaped = html_escape(text);
22    let chars: Vec<char> = escaped.chars().collect();
23    let len = chars.len();
24    let mut output = String::with_capacity(escaped.len() * 2);
25    let mut i = 0;
26
27    while i < len {
28        match chars[i] {
29            '#' => {
30                let start = i + 1;
31                let mut end = start;
32                while end < len && chars[end].is_alphanumeric() {
33                    end += 1;
34                }
35                if end > start {
36                    let hashtag_text: String = chars[start..end].iter().collect();
37                    output.push_str(&convert_text_to_hashiverse_html_x_hashtag(&hashtag_text));
38                    i = end;
39                } else {
40                    output.push('#');
41                    i += 1;
42                }
43            }
44            '@' => {
45                let start = i + 1;
46                let mut end = start;
47                while end < len && end - start < 64 && is_hex_char(chars[end]) {
48                    end += 1;
49                }
50                let hex_len = end - start;
51                // Must be exactly 64 hex chars, and the next char (if any) must NOT be hex
52                // to avoid matching a prefix of a longer hex string
53                if hex_len == 64 && (end >= len || !is_hex_char(chars[end])) {
54                    let hex_string: String = chars[start..end].iter().collect();
55                    output.push_str(&convert_text_to_hashiverse_html_x_mention(&hex_string));
56                    i = end;
57                } else {
58                    output.push('@');
59                    i += 1;
60                }
61            }
62            '\n' => {
63                output.push_str("<br>");
64                i += 1;
65            }
66            '\r' => {
67                // Skip carriage returns — \r\n is handled by skipping \r and emitting <br> on \n
68                i += 1;
69            }
70            c => {
71                output.push(c);
72                i += 1;
73            }
74        }
75    }
76
77    output
78}
79
80/// Render a hashtag as the canonical hashiverse element.
81///
82/// Accepts either `"rust"` or `"#rust"` — a single leading `#` is stripped
83/// before validation. If the remaining text is empty or contains any
84/// non-alphanumeric character, this function returns the original `hashtag`
85/// parameter **untouched** (an identity no-op) so malformed input never
86/// produces malformed HTML. Otherwise it emits the canonical element with a
87/// lower-cased `hashtag` attribute (used for indexing) and a case-preserving
88/// visible span.
89pub fn convert_text_to_hashiverse_html_x_hashtag(hashtag: &str) -> String {
90    let stripped = hashtag.strip_prefix('#').unwrap_or(hashtag);
91    if stripped.is_empty() || !stripped.chars().all(char::is_alphanumeric) {
92        return hashtag.to_string();
93    }
94    let stripped_lower = stripped.to_lowercase();
95    format!(
96        "<hashtag hashtag=\"{}\"><span class=\"plugin-hashtag-left\">#</span><span class=\"plugin-hashtag-right\">{}</span></hashtag>",
97        stripped_lower, stripped,
98    )
99}
100
101/// Render a 64-hex client_id as a `<mention>` element. Caller is responsible
102/// for validating the hex length; we accept any string.
103pub fn convert_text_to_hashiverse_html_x_mention(client_id: &str) -> String {
104    format!("<mention client_id=\"{}\"></mention>", client_id)
105}
106
107/// Render the canonical URL preview card. Same shape as the web client's
108/// `build_card_dom` (hashiverse-client-web/src/tabs/compose/UrlPreviewExtension.ts):
109///
110/// - With `image_url`: a `card-image-container` wraps the `<img>` and the
111///   domain label, sibling to a `card-inner` column holding the title link
112///   (and optional description).
113/// - Without `image_url`: no image container; the domain label moves *inside*
114///   `card-inner`, above the title link.
115///
116/// The description div is omitted entirely when `description` is empty.
117/// Domain is derived from `url` internally. All field values are HTML-escaped.
118pub fn convert_text_to_hashiverse_html_x_url_preview(
119    title: &str,
120    description: &str,
121    image_url: &str,
122    url: &str,
123) -> String {
124    let domain = extract_host_or_url(url);
125    let mut out = String::with_capacity(512);
126    out.push_str("<div class=\"plugin-urlpreview-card\">");
127    if !image_url.is_empty() {
128        out.push_str("<div class=\"plugin-urlpreview-card-image-container\">");
129        out.push_str(&format!(
130            "<a class=\"plugin-urlpreview-card-image-link\" href=\"{}\" rel=\"noopener noreferrer nofollow\"><img src=\"{}\" alt=\"\" class=\"plugin-urlpreview-card-image unblur-image\"></a>",
131            html_escape(url),
132            html_escape(image_url),
133        ));
134        out.push_str(&format!(
135            "<div class=\"plugin-urlpreview-card-domain\">{}</div>",
136            html_escape(domain),
137        ));
138        out.push_str("</div>");
139    }
140    out.push_str("<div class=\"plugin-urlpreview-card-inner\">");
141    if image_url.is_empty() {
142        out.push_str(&format!(
143            "<div class=\"plugin-urlpreview-card-domain\">{}</div>",
144            html_escape(domain),
145        ));
146    }
147    out.push_str(&format!(
148        "<a class=\"plugin-urlpreview-card-title\" href=\"{}\" rel=\"noopener noreferrer nofollow\">{}</a>",
149        html_escape(url),
150        html_escape(title),
151    ));
152    if !description.is_empty() {
153        out.push_str(&format!(
154            "<div class=\"plugin-urlpreview-card-description\">{}</div>",
155            html_escape(description),
156        ));
157    }
158    out.push_str("</div>");
159    out.push_str("</div>");
160    out
161}
162
163fn extract_host_or_url(url: &str) -> &str {
164    match url.split_once("://") {
165        Some((_, after)) => after
166            .split(['/', '?', '#'])
167            .next()
168            .filter(|s| !s.is_empty())
169            .unwrap_or(url),
170        None => url,
171    }
172}
173
174fn html_escape(text: &str) -> String {
175    // Reserve a little more room in case we escape
176    let mut escaped = String::with_capacity(11 * text.len() / 10);
177    for c in text.chars() {
178        match c {
179            '&' => escaped.push_str("&amp;"),
180            '<' => escaped.push_str("&lt;"),
181            '>' => escaped.push_str("&gt;"),
182            '"' => escaped.push_str("&quot;"),
183            other => escaped.push(other),
184        }
185    }
186    escaped
187}
188
189fn is_hex_char(c: char) -> bool {
190    c.is_ascii_hexdigit()
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196
197    // --- Hashtag tests ---
198
199    #[test]
200    fn test_hashtag_at_start() {
201        let result = convert_text_to_hashiverse_html("#rust is great");
202        assert!(result.contains("<hashtag hashtag=\"rust\">"));
203        assert!(result.contains("<span class=\"plugin-hashtag-right\">rust</span>"));
204        assert!(result.ends_with(" is great"));
205    }
206
207    #[test]
208    fn test_hashtag_at_end() {
209        let result = convert_text_to_hashiverse_html("hello #rust");
210        assert!(result.starts_with("hello "));
211        assert!(result.contains("<hashtag hashtag=\"rust\">"));
212    }
213
214    #[test]
215    fn test_hashtag_in_middle() {
216        let result = convert_text_to_hashiverse_html("I love #rust programming");
217        assert!(result.contains("<hashtag hashtag=\"rust\">"));
218        assert!(result.contains(" programming"));
219    }
220
221    #[test]
222    fn test_multiple_hashtags() {
223        let result = convert_text_to_hashiverse_html("#rust and #golang");
224        assert!(result.contains("<hashtag hashtag=\"rust\">"));
225        assert!(result.contains("<hashtag hashtag=\"golang\">"));
226    }
227
228    #[test]
229    fn test_adjacent_hashtags() {
230        let result = convert_text_to_hashiverse_html("#rust#golang");
231        assert!(result.contains("<hashtag hashtag=\"rust\">"));
232        assert!(result.contains("<hashtag hashtag=\"golang\">"));
233    }
234
235    #[test]
236    fn test_hashtag_case_lowered_in_attribute() {
237        let result = convert_text_to_hashiverse_html("#RuStLang");
238        assert!(result.contains("hashtag=\"rustlang\""));
239        // The display text preserves original case
240        assert!(result.contains("<span class=\"plugin-hashtag-right\">RuStLang</span>"));
241    }
242
243    #[test]
244    fn test_bare_hash_no_alphanumeric() {
245        assert_eq!(convert_text_to_hashiverse_html("# alone"), "# alone");
246    }
247
248    #[test]
249    fn test_hash_at_end_of_string() {
250        assert_eq!(convert_text_to_hashiverse_html("test #"), "test #");
251    }
252
253    #[test]
254    fn test_unicode_hashtag() {
255        let result = convert_text_to_hashiverse_html("#日本語");
256        assert!(result.contains("hashtag=\"日本語\""));
257        assert!(result.contains("<span class=\"plugin-hashtag-right\">日本語</span>"));
258    }
259
260    #[test]
261    fn test_hashtag_with_numbers() {
262        let result = convert_text_to_hashiverse_html("#web3");
263        assert!(result.contains("hashtag=\"web3\""));
264    }
265
266    #[test]
267    fn test_hashtag_terminated_by_punctuation() {
268        let result = convert_text_to_hashiverse_html("#rust, nice");
269        assert!(result.contains("<hashtag hashtag=\"rust\">"));
270        assert!(result.contains("</hashtag>, nice"));
271    }
272
273    // --- Mention tests ---
274
275    #[test]
276    fn test_valid_mention() {
277        let hex_id = "a".repeat(64);
278        let input = format!("hello @{} world", hex_id);
279        let result = convert_text_to_hashiverse_html(&input);
280        assert!(result.contains(&format!("<mention client_id=\"{}\"></mention>", hex_id)));
281        assert!(result.starts_with("hello "));
282        assert!(result.ends_with(" world"));
283    }
284
285    #[test]
286    fn test_mention_mixed_case_hex() {
287        let hex_id = "aAbBcCdDeEfF0011223344556677889900112233445566778899aAbBcCdDeEfF";
288        assert_eq!(hex_id.len(), 64);
289        let input = format!("@{}", hex_id);
290        let result = convert_text_to_hashiverse_html(&input);
291        assert!(result.contains(&format!("<mention client_id=\"{}\"></mention>", hex_id)));
292    }
293
294    #[test]
295    fn test_mention_too_short() {
296        let result = convert_text_to_hashiverse_html("@abcdef");
297        assert_eq!(result, "@abcdef");
298        assert!(!result.contains("<mention"));
299    }
300
301    #[test]
302    fn test_mention_non_hex_after_at() {
303        let result = convert_text_to_hashiverse_html("@hello");
304        assert_eq!(result, "@hello");
305    }
306
307    #[test]
308    fn test_bare_at() {
309        assert_eq!(convert_text_to_hashiverse_html("@"), "@");
310    }
311
312    #[test]
313    fn test_at_end_of_string() {
314        assert_eq!(convert_text_to_hashiverse_html("test @"), "test @");
315    }
316
317    #[test]
318    fn test_mention_65_hex_chars_not_matched() {
319        // 65 hex chars — should NOT match as a mention (next char is also hex)
320        let hex_65 = "a".repeat(65);
321        let input = format!("@{}", hex_65);
322        let result = convert_text_to_hashiverse_html(&input);
323        assert!(!result.contains("<mention"));
324    }
325
326    #[test]
327    fn test_mention_64_hex_then_non_hex() {
328        let hex_id = "b".repeat(64);
329        let input = format!("@{}xyz", hex_id);
330        let result = convert_text_to_hashiverse_html(&input);
331        assert!(result.contains(&format!("<mention client_id=\"{}\"></mention>", hex_id)));
332        assert!(result.ends_with("xyz"));
333    }
334
335    // --- HTML escaping tests ---
336
337    #[test]
338    fn test_html_injection_escaped() {
339        let result = convert_text_to_hashiverse_html("<script>alert(1)</script>");
340        assert!(result.contains("&lt;script&gt;"));
341        assert!(!result.contains("<script>"));
342    }
343
344    #[test]
345    fn test_ampersand_escaped() {
346        let result = convert_text_to_hashiverse_html("AT&T");
347        assert_eq!(result, "AT&amp;T");
348    }
349
350    #[test]
351    fn test_quotes_escaped() {
352        let result = convert_text_to_hashiverse_html("he said \"hello\"");
353        assert!(result.contains("&quot;"));
354    }
355
356    // --- Newline tests ---
357
358    #[test]
359    fn test_newline_to_br() {
360        let result = convert_text_to_hashiverse_html("line1\nline2");
361        assert_eq!(result, "line1<br>line2");
362    }
363
364    #[test]
365    fn test_crlf_to_br() {
366        let result = convert_text_to_hashiverse_html("line1\r\nline2");
367        assert_eq!(result, "line1<br>line2");
368    }
369
370    #[test]
371    fn test_bare_cr_skipped() {
372        let result = convert_text_to_hashiverse_html("line1\rline2");
373        assert_eq!(result, "line1line2");
374    }
375
376    // --- Combined tests ---
377
378    #[test]
379    fn test_combined_post() {
380        let hex_id = "c".repeat(64);
381        let input = format!("Hello #hashiverse from @{}!\nGreat to be here.", hex_id);
382        let result = convert_text_to_hashiverse_html(&input);
383        assert!(result.contains("<hashtag hashtag=\"hashiverse\">"));
384        assert!(result.contains(&format!("<mention client_id=\"{}\"></mention>", hex_id)));
385        assert!(result.contains("<br>"));
386        assert!(result.contains("Great to be here."));
387    }
388
389    #[test]
390    fn test_empty_string() {
391        assert_eq!(convert_text_to_hashiverse_html(""), "");
392    }
393
394    #[test]
395    fn test_plain_text_no_specials() {
396        assert_eq!(convert_text_to_hashiverse_html("just a normal post"), "just a normal post");
397    }
398
399    // --- Round-trip test: verify scraper can parse the output the same way submit_post does ---
400
401    #[test]
402    fn test_round_trip_hashtag_extraction() {
403        let result = convert_text_to_hashiverse_html("I love #Rust and #golang");
404        let html = scraper::Html::parse_fragment(&result);
405        let selector = scraper::Selector::parse("hashtag").unwrap();
406        let hashtags: Vec<&str> = html.select(&selector)
407            .filter_map(|el| el.attr("hashtag"))
408            .collect();
409        assert_eq!(hashtags, vec!["rust", "golang"]);
410    }
411
412    #[test]
413    fn test_round_trip_mention_extraction() {
414        let hex_id = "d".repeat(64);
415        let result = convert_text_to_hashiverse_html(&format!("hello @{}", hex_id));
416        let html = scraper::Html::parse_fragment(&result);
417        let selector = scraper::Selector::parse("mention").unwrap();
418        let client_ids: Vec<&str> = html.select(&selector)
419            .filter_map(|el| el.attr("client_id"))
420            .collect();
421        assert_eq!(client_ids, vec![hex_id.as_str()]);
422    }
423
424    #[test]
425    fn test_round_trip_combined() {
426        let hex_id = "e".repeat(64);
427        let input = format!("#hashiverse post by @{} about #Rust", hex_id);
428        let result = convert_text_to_hashiverse_html(&input);
429        let html = scraper::Html::parse_fragment(&result);
430
431        let hashtag_selector = scraper::Selector::parse("hashtag").unwrap();
432        let hashtags: Vec<&str> = html.select(&hashtag_selector)
433            .filter_map(|el| el.attr("hashtag"))
434            .collect();
435        assert_eq!(hashtags, vec!["hashiverse", "rust"]);
436
437        let mention_selector = scraper::Selector::parse("mention").unwrap();
438        let client_ids: Vec<&str> = html.select(&mention_selector)
439            .filter_map(|el| el.attr("client_id"))
440            .collect();
441        assert_eq!(client_ids, vec![hex_id.as_str()]);
442    }
443
444    // --- Fragment helpers: _x_hashtag, _x_mention, _x_url_preview ---
445
446    #[test]
447    fn test_x_hashtag_lowercases_attribute_preserves_span_text() {
448        let result = convert_text_to_hashiverse_html_x_hashtag("RuStLang");
449        assert!(result.contains("hashtag=\"rustlang\""));
450        assert!(result.contains("<span class=\"plugin-hashtag-right\">RuStLang</span>"));
451        assert!(result.contains("<span class=\"plugin-hashtag-left\">#</span>"));
452    }
453
454    #[test]
455    fn test_x_hashtag_handles_unicode() {
456        let result = convert_text_to_hashiverse_html_x_hashtag("日本語");
457        assert!(result.contains("hashtag=\"日本語\""));
458        assert!(result.contains("<span class=\"plugin-hashtag-right\">日本語</span>"));
459    }
460
461    #[test]
462    fn test_x_hashtag_non_alphanumeric_returns_input_untouched() {
463        // Bad input must NOT produce a <hashtag> element (would be malformed
464        // HTML). The contract is "identity no-op" — return the original
465        // parameter byte-for-byte.
466        for bad in ["a<b", "a\"b", "a b", "a&b", "##rust", "#a<b"] {
467            assert_eq!(convert_text_to_hashiverse_html_x_hashtag(bad), bad);
468        }
469    }
470
471    #[test]
472    fn test_x_hashtag_empty_returns_empty() {
473        assert_eq!(convert_text_to_hashiverse_html_x_hashtag(""), "");
474    }
475
476    #[test]
477    fn test_x_hashtag_lone_hash_returns_lone_hash() {
478        // Strip the '#' → empty → fail validation → return the input.
479        assert_eq!(convert_text_to_hashiverse_html_x_hashtag("#"), "#");
480    }
481
482    #[test]
483    fn test_x_hashtag_strips_leading_hash_if_provided() {
484        let with_hash = convert_text_to_hashiverse_html_x_hashtag("#rust");
485        let without_hash = convert_text_to_hashiverse_html_x_hashtag("rust");
486        assert_eq!(with_hash, without_hash);
487        assert!(with_hash.contains("hashtag=\"rust\""));
488        assert!(with_hash.contains("<span class=\"plugin-hashtag-right\">rust</span>"));
489    }
490
491    #[test]
492    fn test_x_hashtag_no_op_does_not_emit_hashtag_element() {
493        // Defensive: any caller-supplied invalid input must never end up in a
494        // `<hashtag>` element.
495        for bad in ["a<b", "a\"b", "a b", "", "##rust", "#a<b"] {
496            let result = convert_text_to_hashiverse_html_x_hashtag(bad);
497            assert!(!result.contains("<hashtag"), "unexpected element for {bad:?}: {result:?}");
498            assert!(!result.contains("plugin-hashtag-left"), "unexpected span for {bad:?}: {result:?}");
499        }
500    }
501
502    #[test]
503    fn test_x_mention_emits_64hex_client_id() {
504        let hex_id = "f".repeat(64);
505        let result = convert_text_to_hashiverse_html_x_mention(&hex_id);
506        assert_eq!(result, format!("<mention client_id=\"{}\"></mention>", hex_id));
507    }
508
509    #[test]
510    fn test_x_url_preview_with_image_renders_image_container() {
511        let result = convert_text_to_hashiverse_html_x_url_preview(
512            "Title",
513            "Desc",
514            "https://img.example/x.png",
515            "https://example.com/path",
516        );
517        assert!(result.starts_with("<div class=\"plugin-urlpreview-card\"><div class=\"plugin-urlpreview-card-image-container\">"));
518        assert!(result.contains(
519            "<a class=\"plugin-urlpreview-card-image-link\" href=\"https://example.com/path\" rel=\"noopener noreferrer nofollow\"><img src=\"https://img.example/x.png\" alt=\"\" class=\"plugin-urlpreview-card-image unblur-image\"></a>"
520        ));
521        assert!(result.contains("<div class=\"plugin-urlpreview-card-domain\">example.com</div>"));
522        assert!(result.contains("<a class=\"plugin-urlpreview-card-title\" href=\"https://example.com/path\" rel=\"noopener noreferrer nofollow\">Title</a>"));
523        assert!(result.contains("<div class=\"plugin-urlpreview-card-description\">Desc</div>"));
524    }
525
526    #[test]
527    fn test_x_url_preview_image_link_uses_same_href_as_title() {
528        // Clicking the image must navigate to the same URL as clicking the title,
529        // and the URL must be HTML-escaped consistently in both anchors.
530        let url = "https://example.com/?a=1&b=2";
531        let result = convert_text_to_hashiverse_html_x_url_preview("Title", "", "https://img.example/x.png", url);
532        assert!(result.contains(
533            "<a class=\"plugin-urlpreview-card-image-link\" href=\"https://example.com/?a=1&amp;b=2\" rel=\"noopener noreferrer nofollow\"><img"
534        ));
535        assert!(result.contains(
536            "<a class=\"plugin-urlpreview-card-title\" href=\"https://example.com/?a=1&amp;b=2\" rel=\"noopener noreferrer nofollow\">Title</a>"
537        ));
538    }
539
540    #[test]
541    fn test_x_url_preview_without_image_moves_domain_into_inner() {
542        let result = convert_text_to_hashiverse_html_x_url_preview(
543            "Title",
544            "Desc",
545            "",
546            "https://example.com/path",
547        );
548        assert!(!result.contains("plugin-urlpreview-card-image-container"));
549        assert!(!result.contains("<img "));
550        // domain div sits inside card-inner, BEFORE the title link
551        let inner_pos = result.find("plugin-urlpreview-card-inner").unwrap();
552        let domain_pos = result.find("plugin-urlpreview-card-domain").unwrap();
553        let title_pos = result.find("plugin-urlpreview-card-title").unwrap();
554        assert!(inner_pos < domain_pos && domain_pos < title_pos);
555    }
556
557    #[test]
558    fn test_x_url_preview_omits_description_when_blank() {
559        let result = convert_text_to_hashiverse_html_x_url_preview(
560            "Title",
561            "",
562            "https://img.example/x.png",
563            "https://example.com/",
564        );
565        assert!(!result.contains("plugin-urlpreview-card-description"));
566    }
567
568    #[test]
569    fn test_x_url_preview_extracts_domain_from_https_url() {
570        let result = convert_text_to_hashiverse_html_x_url_preview(
571            "T", "", "", "https://sub.example.com:8443/path?q=1#frag",
572        );
573        assert!(result.contains("<div class=\"plugin-urlpreview-card-domain\">sub.example.com:8443</div>"));
574    }
575
576    #[test]
577    fn test_x_url_preview_falls_back_to_full_url_when_no_scheme() {
578        let result = convert_text_to_hashiverse_html_x_url_preview("T", "", "", "not-a-url");
579        assert!(result.contains("<div class=\"plugin-urlpreview-card-domain\">not-a-url</div>"));
580    }
581
582    #[test]
583    fn test_x_url_preview_html_escapes_attribute_and_text_values() {
584        let result = convert_text_to_hashiverse_html_x_url_preview(
585            "Title with <script> & \"quotes\"",
586            "Desc with <html> & \"chars\"",
587            "https://i/x?a=1&b=2",
588            "https://example.com/?q=1&r=2",
589        );
590        // Title text inside the <a>:
591        assert!(result.contains(">Title with &lt;script&gt; &amp; &quot;quotes&quot;</a>"));
592        // Description text inside the description div:
593        assert!(result.contains(">Desc with &lt;html&gt; &amp; &quot;chars&quot;</div>"));
594        // URLs in attributes — & must become &amp;:
595        assert!(result.contains("src=\"https://i/x?a=1&amp;b=2\""));
596        assert!(result.contains("href=\"https://example.com/?q=1&amp;r=2\""));
597    }
598
599    #[test]
600    fn test_existing_convert_text_to_hashiverse_html_unchanged_after_refactor() {
601        // Sanity-check that the post-refactor output is byte-identical for a
602        // representative input — the inline format!s were lifted into helpers,
603        // not changed.
604        let hex = "a".repeat(64);
605        let input = format!("Hi #Rust @{} bye", hex);
606        let result = convert_text_to_hashiverse_html(&input);
607        assert_eq!(
608            result,
609            format!(
610                "Hi <hashtag hashtag=\"rust\"><span class=\"plugin-hashtag-left\">#</span><span class=\"plugin-hashtag-right\">Rust</span></hashtag> <mention client_id=\"{}\"></mention> bye",
611                hex
612            )
613        );
614    }
615}