1use crate::error::MetadataError;
7use crate::extract_and_prepare_metadata;
8use crate::metatags::MetaTagGroups;
9use std::collections::HashMap;
10use tokio::fs::File;
11use tokio::io::AsyncReadExt;
12
13pub fn escape_html(value: &str) -> String {
47 let mut out = String::with_capacity(value.len() + value.len() / 8);
51 for ch in value.chars() {
52 match ch {
53 '&' => out.push_str("&"),
54 '<' => out.push_str("<"),
55 '>' => out.push_str(">"),
56 '"' => out.push_str("""),
57 '\'' => out.push_str("'"),
58 other => out.push(other),
59 }
60 }
61 out
62}
63
64pub fn unescape_html(value: &str) -> String {
99 const ENTITIES: [(&str, &str); 8] = [
104 ("&", "&"),
105 ("<", "<"),
106 (">", ">"),
107 (""", "\""),
108 ("'", "'"),
109 ("'", "'"),
110 ("/", "/"),
111 ("/", "/"),
112 ];
113 let mut out = String::with_capacity(value.len());
114 let mut rest = value;
115 while let Some(amp) = rest.find('&') {
116 out.push_str(&rest[..amp]);
117 let tail = &rest[amp..];
118 match ENTITIES.iter().find(|(name, _)| tail.starts_with(name)) {
119 Some((name, decoded)) => {
120 out.push_str(decoded);
121 rest = &tail[name.len()..];
122 }
123 None => {
124 out.push('&');
125 rest = &tail[1..];
126 }
127 }
128 }
129 out.push_str(rest);
130 out
131}
132
133pub async fn async_extract_metadata_from_file(
176 file_path: &str,
177) -> Result<
178 (HashMap<String, String>, Vec<String>, MetaTagGroups),
179 MetadataError,
180> {
181 let mut file = File::open(file_path)
182 .await
183 .map_err(MetadataError::IoError)?;
184
185 let mut content = String::new();
186 file.read_to_string(&mut content)
187 .await
188 .map_err(MetadataError::IoError)?;
189
190 if content.trim().is_empty() {
191 return Ok((
193 HashMap::new(),
194 Vec::new(),
195 MetaTagGroups {
196 primary: String::new(),
197 apple: String::new(),
198 ms: String::new(),
199 og: String::new(),
200 twitter: String::new(),
201 },
202 ));
203 }
204
205 extract_and_prepare_metadata(&content)
206}
207
208#[cfg(test)]
209mod tests {
210 use super::*;
211 use tempfile::tempdir;
212 use tokio::fs::File;
213 use tokio::io::AsyncWriteExt;
214
215 #[cfg_attr(
216 miri,
217 ignore = "touches the filesystem; Miri isolation forbids it"
218 )]
219 #[test]
220 fn test_escape_html() {
221 let input = "Hello, <world> & \"friends\"!";
222 let expected =
223 "Hello, <world> & "friends"!";
224 assert_eq!(escape_html(input), expected);
225 }
226
227 #[cfg_attr(
228 miri,
229 ignore = "touches the filesystem; Miri isolation forbids it"
230 )]
231 #[test]
232 fn test_escape_html_special_characters() {
233 let input = "It's <b>bold</b> & it's <i>italic</i>";
234 let expected = "It's <b>bold</b> & it's <i>italic</i>";
235 assert_eq!(escape_html(input), expected);
236 }
237
238 #[cfg_attr(
239 miri,
240 ignore = "touches the filesystem; Miri isolation forbids it"
241 )]
242 #[test]
243 fn test_unescape_html() {
244 let input = "Hello, <world> & "friends"!";
245 let expected = "Hello, <world> & \"friends\"!";
246 assert_eq!(unescape_html(input), expected);
247 }
248
249 #[cfg_attr(
250 miri,
251 ignore = "touches the filesystem; Miri isolation forbids it"
252 )]
253 #[test]
254 fn test_unescape_html_edge_cases() {
255 let input = "<&>"''/";
256 let expected = "<&>\"''/";
257 assert_eq!(unescape_html(input), expected);
258 }
259
260 #[cfg_attr(
261 miri,
262 ignore = "touches the filesystem; Miri isolation forbids it"
263 )]
264 #[test]
265 fn test_escape_unescape_roundtrip() {
266 let original = "Test <script>alert('XSS');</script> & other \"special\" chars";
267 let escaped = escape_html(original);
268 let unescaped = unescape_html(&escaped);
269 assert_eq!(original, unescaped);
270 }
271
272 #[cfg_attr(
273 miri,
274 ignore = "touches the filesystem; Miri isolation forbids it"
275 )]
276 #[tokio::test]
277 async fn test_async_extract_metadata_from_file() {
278 let temp_dir = tempdir().unwrap();
280 let file_path = temp_dir.path().join("test.md");
281
282 let content = r#"---
284title: Test Page
285description: A test page for metadata extraction
286keywords: test, metadata, extraction
287---
288# Test Content
289This is a test file for metadata extraction."#;
290
291 let mut file = File::create(&file_path).await.unwrap();
292 file.write_all(content.as_bytes()).await.unwrap();
293 file.flush().await.unwrap();
294 drop(file);
295
296 let result = async_extract_metadata_from_file(
298 file_path.to_str().unwrap(),
299 )
300 .await;
301 assert!(result.is_ok());
302
303 let (metadata, keywords, meta_tags) = result.unwrap();
304 assert_eq!(
305 metadata.get("title"),
306 Some(&"Test Page".to_string())
307 );
308 assert_eq!(
309 metadata.get("description"),
310 Some(&"A test page for metadata extraction".to_string())
311 );
312 assert_eq!(keywords, vec!["test", "metadata", "extraction"]);
313 assert!(!meta_tags.primary.is_empty());
314 }
315
316 #[cfg_attr(
317 miri,
318 ignore = "touches the filesystem; Miri isolation forbids it"
319 )]
320 #[tokio::test]
321 async fn test_async_extract_metadata_from_empty_file() {
322 let temp_dir = tempdir().unwrap();
323 let file_path = temp_dir.path().join("empty.md");
324
325 let mut file = File::create(&file_path).await.unwrap();
327 file.write_all(b"").await.unwrap();
328 file.flush().await.unwrap();
329 drop(file);
330
331 let result = async_extract_metadata_from_file(
332 file_path.to_str().unwrap(),
333 )
334 .await;
335
336 assert!(result.is_ok());
338 let (metadata, keywords, meta_tags) = result.unwrap();
339 assert!(metadata.is_empty());
340 assert!(keywords.is_empty());
341 assert!(meta_tags.primary.is_empty());
342 }
343
344 #[cfg_attr(
345 miri,
346 ignore = "touches the filesystem; Miri isolation forbids it"
347 )]
348 #[tokio::test]
349 async fn test_async_extract_metadata_from_nonexistent_file() {
350 let result =
351 async_extract_metadata_from_file("nonexistent_file.md")
352 .await;
353 assert!(result.is_err());
354 assert!(matches!(
355 result.unwrap_err(),
356 MetadataError::IoError(_)
357 ));
358 }
359}
360
361#[cfg(test)]
362mod unescape_single_pass_tests {
363 use super::*;
370
371 #[test]
372 fn unescape_does_not_rescan_its_own_output() {
373 assert_eq!(unescape_html("&lt;"), "<");
374 assert_eq!(unescape_html("&amp;"), "&");
375 assert_eq!(unescape_html("&#39;"), "'");
376 }
377
378 #[test]
379 fn escape_then_unescape_is_the_identity() {
380 for s in [
381 "&<''",
382 "a < b && c > \"d\" 'e'",
383 "&",
384 "&&",
385 "plain",
386 ] {
387 assert_eq!(unescape_html(&escape_html(s)), s, "{s:?}");
388 }
389 }
390
391 #[test]
392 fn unknown_and_unterminated_entities_pass_through() {
393 assert_eq!(unescape_html("&unknown;"), "&unknown;");
394 assert_eq!(unescape_html("&"), "&");
395 assert_eq!(unescape_html("& x"), "& x");
396 assert_eq!(unescape_html("//'"), "//'");
397 }
398}
399
400#[cfg(test)]
401mod escape_single_pass_tests {
402 use super::*;
403
404 #[test]
405 fn escape_matches_the_replace_chain_it_replaced() {
406 let reference = |v: &str| {
407 v.replace('&', "&")
408 .replace('<', "<")
409 .replace('>', ">")
410 .replace('"', """)
411 .replace('\'', "'")
412 };
413 for s in [
414 "",
415 "plain",
416 "a<b>c&d\"e'f",
417 "&&&",
418 "<<>>",
419 "ünïcödé <tag> & 'q'",
420 "&",
421 ] {
422 assert_eq!(escape_html(s), reference(s), "{s:?}");
423 }
424 }
425}