Skip to main content

headless_lms_utils/
strings.rs

1use std::borrow::Cow;
2
3use once_cell::sync::Lazy;
4use rand::{RngExt, distr::Alphanumeric, rng};
5use regex::Regex;
6
7static IETF_LANGUAGE_CODE_REGEX: Lazy<Regex> = Lazy::new(|| {
8    Regex::new(r"^[a-z]{2,3}(-[A-Z][a-z]{3})?(-[A-Z]{2})?$")
9        .expect("Invalid IETF language code regex.")
10});
11
12/// One HTML tag, `<` to the next `>`.
13pub static HTML_TAG_REGEX: Lazy<Regex> =
14    Lazy::new(|| Regex::new(r"<[^>]*>").expect("Invalid HTML tag regex."));
15
16pub fn generate_random_string(length: usize) -> String {
17    rng()
18        .sample_iter(Alphanumeric)
19        .take(length)
20        .map(char::from)
21        .collect()
22}
23
24pub fn generate_easily_writable_random_string(length: usize) -> String {
25    rng()
26        .sample_iter(Alphanumeric)
27        .filter(|c: &u8| c.is_ascii_lowercase() || c.is_ascii_digit())
28        // Filter out characters that might be confused with each other
29        .filter(|c| c != &b'l' && c != &b'1' && c != &b'o' && c != &b'0')
30        .take(length)
31        .map(char::from)
32        .collect()
33}
34
35/// Checks whether the string is IETF language code where subtags are separated with underscore.
36pub fn is_ietf_language_code_like(string: &str) -> bool {
37    IETF_LANGUAGE_CODE_REGEX.is_match(string)
38}
39
40/// Removes all HTML tags from the input, leaving only the text content. Borrows the input when
41/// it has no tags.
42pub fn strip_html_tags(input: &str) -> Cow<'_, str> {
43    HTML_TAG_REGEX.replace_all(input, "")
44}
45
46/// Decodes the entities rich text commonly holds: `&amp;`, `&lt;`, `&gt;`, `&quot;`, `&#39;` and
47/// `&nbsp;` (as a space). Decodes once, so `&amp;lt;` becomes `&lt;`. Borrows the input when it
48/// has no `&`.
49pub fn decode_html_entities(text: &str) -> Cow<'_, str> {
50    if !text.contains('&') {
51        return Cow::Borrowed(text);
52    }
53    Cow::Owned(
54        text.replace("&nbsp;", " ")
55            .replace("&lt;", "<")
56            .replace("&gt;", ">")
57            .replace("&quot;", "\"")
58            .replace("&#39;", "'")
59            .replace("&amp;", "&"),
60    )
61}
62
63/// `text` as HTML text content: `&`, `<` and `>` as entities. Leaves quotes as they are, so it
64/// isn't safe inside attribute values; see [`escape_html`].
65pub fn escape_html_text(text: &str) -> String {
66    escape(text, false)
67}
68
69/// `text` safe both as HTML text content and inside a quoted attribute value.
70pub fn escape_html(text: &str) -> String {
71    escape(text, true)
72}
73
74fn escape(text: &str, escapes_quotes: bool) -> String {
75    let mut escaped = String::with_capacity(text.len());
76    for c in text.chars() {
77        match c {
78            '&' => escaped.push_str("&amp;"),
79            '<' => escaped.push_str("&lt;"),
80            '>' => escaped.push_str("&gt;"),
81            '"' if escapes_quotes => escaped.push_str("&quot;"),
82            '\'' if escapes_quotes => escaped.push_str("&#39;"),
83            _ => escaped.push(c),
84        }
85    }
86    escaped
87}
88
89/// A URL path segment made of a title: its letters and digits, of any script, in lowercase, with
90/// each run of other characters as one `-` and none at either end. Empty when the title has no
91/// letters or digits, so the caller picks a fallback.
92pub fn slugify(title: &str) -> String {
93    let mut slug = String::with_capacity(title.len());
94    let mut is_separator_pending = false;
95    for c in title.chars().flat_map(char::to_lowercase) {
96        if c.is_alphanumeric() {
97            if is_separator_pending && !slug.is_empty() {
98                slug.push('-');
99            }
100            is_separator_pending = false;
101            slug.push(c);
102        } else {
103            is_separator_pending = true;
104        }
105    }
106    slug
107}
108
109/// Trims the string and returns `None` if the trimmed result is empty.
110pub fn non_empty_trimmed(s: &str) -> Option<&str> {
111    let trimmed = s.trim();
112    (!trimmed.is_empty()).then_some(trimmed)
113}
114
115/// Truncates UTF-8 text to a max byte length at a valid char boundary.
116pub fn truncate_utf8_at_boundary(s: &str, max_bytes: usize) -> &str {
117    if s.len() <= max_bytes {
118        return s;
119    }
120    let mut idx = max_bytes;
121    while idx > 0 && !s.is_char_boundary(idx) {
122        idx -= 1;
123    }
124    &s[..idx]
125}
126
127#[cfg(test)]
128mod test {
129    use super::*;
130
131    #[test]
132    fn ietf_language_code_validation_works() {
133        // Invalid scenarios
134        assert!(!is_ietf_language_code_like(""));
135        assert!(!is_ietf_language_code_like("en_us"));
136        assert!(!is_ietf_language_code_like("en_US"));
137        assert!(!is_ietf_language_code_like("in-cans"));
138        assert!(!is_ietf_language_code_like("in-cans-ca"));
139
140        // Valid scenarios
141        assert!(is_ietf_language_code_like("en"));
142        assert!(is_ietf_language_code_like("eng"));
143        assert!(is_ietf_language_code_like("en-US"));
144        assert!(is_ietf_language_code_like("in-Cans-CA"));
145    }
146
147    #[test]
148    fn non_empty_trimmed_trims_and_rejects_blank() {
149        assert_eq!(non_empty_trimmed("  hello  "), Some("hello"));
150        assert_eq!(non_empty_trimmed(""), None);
151        assert_eq!(non_empty_trimmed("   "), None);
152    }
153
154    #[test]
155    fn strip_html_tags_removes_all_tags() {
156        assert_eq!(
157            strip_html_tags("<em>Intro</em> to <strong>X</strong>"),
158            "Intro to X"
159        );
160        assert_eq!(strip_html_tags(r#"<a href="/x">link</a>"#), "link");
161        assert_eq!(strip_html_tags("plain text"), "plain text");
162        assert_eq!(strip_html_tags("<br>"), "");
163    }
164
165    #[test]
166    fn truncate_utf8_at_boundary_returns_original_when_short() {
167        let input = "heillä";
168        let result = truncate_utf8_at_boundary(input, 255);
169        assert_eq!(result, input);
170    }
171
172    #[test]
173    fn truncate_utf8_at_boundary_handles_finnish_characters() {
174        let input = format!("{}äz", "a".repeat(254));
175        let result = truncate_utf8_at_boundary(&input, 255);
176        assert_eq!(result.len(), 254);
177        assert!(result.is_char_boundary(result.len()));
178        assert_eq!(result, "a".repeat(254));
179    }
180
181    #[test]
182    fn truncate_utf8_at_boundary_handles_emoji() {
183        let input = format!("{}😀z", "a".repeat(254));
184        let result = truncate_utf8_at_boundary(&input, 255);
185        assert_eq!(result.len(), 254);
186        assert!(result.is_char_boundary(result.len()));
187        assert_eq!(result, "a".repeat(254));
188    }
189}