use sha2::{Digest, Sha256};
use crate::model::ContentFormat;
const TEXT_WRAP_WIDTH: usize = 80;
pub fn extract(html: &str, format: ContentFormat) -> (String, bool) {
match format {
ContentFormat::Markdown => match htmd::convert(html) {
Ok(md) => (md, false),
Err(_) => (strip_tags(html), true),
},
ContentFormat::Text => match html2text::from_read(html.as_bytes(), TEXT_WRAP_WIDTH) {
Ok(text) => (text, false),
Err(_) => (strip_tags(html), true),
},
ContentFormat::Html => (html.to_string(), false),
ContentFormat::None => (String::new(), false),
}
}
pub fn content_hash(text: &str) -> String {
let digest = Sha256::digest(text.as_bytes());
let mut hex = String::with_capacity(16);
for byte in digest.iter().take(8) {
hex.push_str(&format!("{byte:02x}"));
}
hex
}
pub fn estimate_tokens(text: &str) -> u32 {
text.chars().count().div_ceil(4) as u32
}
pub const TRUNCATION_MARKER: &str = " …[truncated]";
pub fn truncate_to_chars(text: &str, max_chars: usize) -> (String, bool) {
if text.char_indices().nth(max_chars).is_none() {
return (text.to_string(), false); }
let kept: String = text.chars().take(max_chars).collect();
(format!("{kept}{TRUNCATION_MARKER}"), true)
}
fn strip_tags(html: &str) -> String {
let mut out = String::with_capacity(html.len());
let mut in_tag = false;
for ch in html.chars() {
match ch {
'<' => in_tag = true,
'>' => in_tag = false,
_ if !in_tag => out.push(ch),
_ => {}
}
}
out.split_whitespace().collect::<Vec<_>>().join(" ")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn markdown_extraction_keeps_text() {
let (md, fell_back) = extract("<p>Hi <a href=x>link</a></p>", ContentFormat::Markdown);
assert!(!md.is_empty());
assert!(md.contains("Hi"), "markdown should retain text: {md:?}");
assert!(!fell_back, "a well-formed fragment should not fall back");
}
#[test]
fn text_extraction_keeps_text() {
let (text, fell_back) = extract("<p>Hi <a href=x>link</a></p>", ContentFormat::Text);
assert!(text.contains("Hi"), "text should retain content: {text:?}");
assert!(!fell_back);
}
#[test]
fn html_format_is_passthrough() {
let html = "<p>raw <b>html</b></p>";
assert_eq!(
extract(html, ContentFormat::Html),
(html.to_string(), false)
);
}
#[test]
fn none_format_is_empty() {
assert_eq!(
extract("<p>anything</p>", ContentFormat::None),
(String::new(), false)
);
}
#[test]
fn content_hash_is_stable_16_hex_and_change_sensitive() {
let a = content_hash("the body");
assert_eq!(a, content_hash("the body"), "hash is deterministic");
assert_eq!(a.len(), 16);
assert!(
a.chars()
.all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
);
assert_ne!(
a,
content_hash("the body changed"),
"different content → different hash"
);
}
#[test]
fn token_estimate_is_ceil_div_four() {
assert_eq!(estimate_tokens("abcdefgh"), 2);
assert_eq!(estimate_tokens("abcdefghi"), 3);
assert_eq!(estimate_tokens(""), 0);
assert_eq!(estimate_tokens("héllo"), 2); }
#[test]
fn strip_tags_fallback_is_clean() {
assert_eq!(strip_tags("<p>Hi <b>there</b></p>"), "Hi there");
}
#[test]
fn truncate_under_limit_is_untouched() {
let (out, cut) = truncate_to_chars("hello", 10);
assert_eq!(out, "hello");
assert!(!cut);
let (out, cut) = truncate_to_chars("hello", 5);
assert_eq!(out, "hello");
assert!(!cut);
}
#[test]
fn truncate_over_limit_appends_marker() {
let (out, cut) = truncate_to_chars("hello world", 5);
assert!(cut);
assert!(out.starts_with("hello"));
assert!(out.ends_with(TRUNCATION_MARKER));
}
#[test]
fn truncate_respects_char_boundaries() {
let s = "héllo wörld"; let (out, cut) = truncate_to_chars(s, 4);
assert!(cut);
assert!(out.starts_with("héll"));
assert_eq!(out.chars().count(), 4 + TRUNCATION_MARKER.chars().count());
}
#[test]
fn truncate_to_zero_is_just_marker() {
let (out, cut) = truncate_to_chars("anything", 0);
assert!(cut);
assert_eq!(out, TRUNCATION_MARKER);
}
}