headless_lms_utils/
strings.rs1use 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
12pub 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(|c| c != &b'l' && c != &b'1' && c != &b'o' && c != &b'0')
30 .take(length)
31 .map(char::from)
32 .collect()
33}
34
35pub fn is_ietf_language_code_like(string: &str) -> bool {
37 IETF_LANGUAGE_CODE_REGEX.is_match(string)
38}
39
40pub fn strip_html_tags(input: &str) -> Cow<'_, str> {
43 HTML_TAG_REGEX.replace_all(input, "")
44}
45
46pub fn decode_html_entities(text: &str) -> Cow<'_, str> {
50 if !text.contains('&') {
51 return Cow::Borrowed(text);
52 }
53 Cow::Owned(
54 text.replace(" ", " ")
55 .replace("<", "<")
56 .replace(">", ">")
57 .replace(""", "\"")
58 .replace("'", "'")
59 .replace("&", "&"),
60 )
61}
62
63pub fn escape_html_text(text: &str) -> String {
66 escape(text, false)
67}
68
69pub 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("&"),
79 '<' => escaped.push_str("<"),
80 '>' => escaped.push_str(">"),
81 '"' if escapes_quotes => escaped.push_str("""),
82 '\'' if escapes_quotes => escaped.push_str("'"),
83 _ => escaped.push(c),
84 }
85 }
86 escaped
87}
88
89pub 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
109pub fn non_empty_trimmed(s: &str) -> Option<&str> {
111 let trimmed = s.trim();
112 (!trimmed.is_empty()).then_some(trimmed)
113}
114
115pub 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 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 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}