use crate::error::MetadataError;
use crate::extract_and_prepare_metadata;
use crate::metatags::MetaTagGroups;
use std::collections::HashMap;
use tokio::fs::File;
use tokio::io::AsyncReadExt;
pub fn escape_html(value: &str) -> String {
let mut out = String::with_capacity(value.len() + value.len() / 8);
for ch in value.chars() {
match ch {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
other => out.push(other),
}
}
out
}
pub fn unescape_html(value: &str) -> String {
const ENTITIES: [(&str, &str); 8] = [
("&", "&"),
("<", "<"),
(">", ">"),
(""", "\""),
("'", "'"),
("'", "'"),
("/", "/"),
("/", "/"),
];
let mut out = String::with_capacity(value.len());
let mut rest = value;
while let Some(amp) = rest.find('&') {
out.push_str(&rest[..amp]);
let tail = &rest[amp..];
match ENTITIES.iter().find(|(name, _)| tail.starts_with(name)) {
Some((name, decoded)) => {
out.push_str(decoded);
rest = &tail[name.len()..];
}
None => {
out.push('&');
rest = &tail[1..];
}
}
}
out.push_str(rest);
out
}
pub async fn async_extract_metadata_from_file(
file_path: &str,
) -> Result<
(HashMap<String, String>, Vec<String>, MetaTagGroups),
MetadataError,
> {
let mut file = File::open(file_path)
.await
.map_err(MetadataError::IoError)?;
let mut content = String::new();
file.read_to_string(&mut content)
.await
.map_err(MetadataError::IoError)?;
if content.trim().is_empty() {
return Ok((
HashMap::new(),
Vec::new(),
MetaTagGroups {
primary: String::new(),
apple: String::new(),
ms: String::new(),
og: String::new(),
twitter: String::new(),
},
));
}
extract_and_prepare_metadata(&content)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
use tokio::fs::File;
use tokio::io::AsyncWriteExt;
#[cfg_attr(
miri,
ignore = "touches the filesystem; Miri isolation forbids it"
)]
#[test]
fn test_escape_html() {
let input = "Hello, <world> & \"friends\"!";
let expected =
"Hello, <world> & "friends"!";
assert_eq!(escape_html(input), expected);
}
#[cfg_attr(
miri,
ignore = "touches the filesystem; Miri isolation forbids it"
)]
#[test]
fn test_escape_html_special_characters() {
let input = "It's <b>bold</b> & it's <i>italic</i>";
let expected = "It's <b>bold</b> & it's <i>italic</i>";
assert_eq!(escape_html(input), expected);
}
#[cfg_attr(
miri,
ignore = "touches the filesystem; Miri isolation forbids it"
)]
#[test]
fn test_unescape_html() {
let input = "Hello, <world> & "friends"!";
let expected = "Hello, <world> & \"friends\"!";
assert_eq!(unescape_html(input), expected);
}
#[cfg_attr(
miri,
ignore = "touches the filesystem; Miri isolation forbids it"
)]
#[test]
fn test_unescape_html_edge_cases() {
let input = "<&>"''/";
let expected = "<&>\"''/";
assert_eq!(unescape_html(input), expected);
}
#[cfg_attr(
miri,
ignore = "touches the filesystem; Miri isolation forbids it"
)]
#[test]
fn test_escape_unescape_roundtrip() {
let original = "Test <script>alert('XSS');</script> & other \"special\" chars";
let escaped = escape_html(original);
let unescaped = unescape_html(&escaped);
assert_eq!(original, unescaped);
}
#[cfg_attr(
miri,
ignore = "touches the filesystem; Miri isolation forbids it"
)]
#[tokio::test]
async fn test_async_extract_metadata_from_file() {
let temp_dir = tempdir().unwrap();
let file_path = temp_dir.path().join("test.md");
let content = r#"---
title: Test Page
description: A test page for metadata extraction
keywords: test, metadata, extraction
---
# Test Content
This is a test file for metadata extraction."#;
let mut file = File::create(&file_path).await.unwrap();
file.write_all(content.as_bytes()).await.unwrap();
file.flush().await.unwrap();
drop(file);
let result = async_extract_metadata_from_file(
file_path.to_str().unwrap(),
)
.await;
assert!(result.is_ok());
let (metadata, keywords, meta_tags) = result.unwrap();
assert_eq!(
metadata.get("title"),
Some(&"Test Page".to_string())
);
assert_eq!(
metadata.get("description"),
Some(&"A test page for metadata extraction".to_string())
);
assert_eq!(keywords, vec!["test", "metadata", "extraction"]);
assert!(!meta_tags.primary.is_empty());
}
#[cfg_attr(
miri,
ignore = "touches the filesystem; Miri isolation forbids it"
)]
#[tokio::test]
async fn test_async_extract_metadata_from_empty_file() {
let temp_dir = tempdir().unwrap();
let file_path = temp_dir.path().join("empty.md");
let mut file = File::create(&file_path).await.unwrap();
file.write_all(b"").await.unwrap();
file.flush().await.unwrap();
drop(file);
let result = async_extract_metadata_from_file(
file_path.to_str().unwrap(),
)
.await;
assert!(result.is_ok());
let (metadata, keywords, meta_tags) = result.unwrap();
assert!(metadata.is_empty());
assert!(keywords.is_empty());
assert!(meta_tags.primary.is_empty());
}
#[cfg_attr(
miri,
ignore = "touches the filesystem; Miri isolation forbids it"
)]
#[tokio::test]
async fn test_async_extract_metadata_from_nonexistent_file() {
let result =
async_extract_metadata_from_file("nonexistent_file.md")
.await;
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
MetadataError::IoError(_)
));
}
}
#[cfg(test)]
mod unescape_single_pass_tests {
use super::*;
#[test]
fn unescape_does_not_rescan_its_own_output() {
assert_eq!(unescape_html("&lt;"), "<");
assert_eq!(unescape_html("&amp;"), "&");
assert_eq!(unescape_html("&#39;"), "'");
}
#[test]
fn escape_then_unescape_is_the_identity() {
for s in [
"&<''",
"a < b && c > \"d\" 'e'",
"&",
"&&",
"plain",
] {
assert_eq!(unescape_html(&escape_html(s)), s, "{s:?}");
}
}
#[test]
fn unknown_and_unterminated_entities_pass_through() {
assert_eq!(unescape_html("&unknown;"), "&unknown;");
assert_eq!(unescape_html("&"), "&");
assert_eq!(unescape_html("& x"), "& x");
assert_eq!(unescape_html("//'"), "//'");
}
}
#[cfg(test)]
mod escape_single_pass_tests {
use super::*;
#[test]
fn escape_matches_the_replace_chain_it_replaced() {
let reference = |v: &str| {
v.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
};
for s in [
"",
"plain",
"a<b>c&d\"e'f",
"&&&",
"<<>>",
"ünïcödé <tag> & 'q'",
"&",
] {
assert_eq!(escape_html(s), reference(s), "{s:?}");
}
}
}