1use sha2::{Digest, Sha256};
15
16use crate::model::ContentFormat;
17
18const TEXT_WRAP_WIDTH: usize = 80;
21
22pub fn extract(html: &str, format: ContentFormat) -> (String, bool) {
28 match format {
29 ContentFormat::Markdown => match htmd::convert(html) {
30 Ok(md) => (md, false),
31 Err(_) => (strip_tags(html), true),
32 },
33 ContentFormat::Text => match html2text::from_read(html.as_bytes(), TEXT_WRAP_WIDTH) {
34 Ok(text) => (text, false),
35 Err(_) => (strip_tags(html), true),
36 },
37 ContentFormat::Html => (html.to_string(), false),
38 ContentFormat::None => (String::new(), false),
39 }
40}
41
42pub fn content_hash(text: &str) -> String {
46 let digest = Sha256::digest(text.as_bytes());
47 let mut hex = String::with_capacity(16);
48 for byte in digest.iter().take(8) {
49 hex.push_str(&format!("{byte:02x}"));
50 }
51 hex
52}
53
54pub fn estimate_tokens(text: &str) -> u32 {
58 text.chars().count().div_ceil(4) as u32
59}
60
61pub const TRUNCATION_MARKER: &str = " …[truncated]";
64
65pub fn truncate_to_chars(text: &str, max_chars: usize) -> (String, bool) {
73 if text.char_indices().nth(max_chars).is_none() {
75 return (text.to_string(), false); }
77 let kept: String = text.chars().take(max_chars).collect();
78 (format!("{kept}{TRUNCATION_MARKER}"), true)
79}
80
81fn strip_tags(html: &str) -> String {
84 let mut out = String::with_capacity(html.len());
85 let mut in_tag = false;
86 for ch in html.chars() {
87 match ch {
88 '<' => in_tag = true,
89 '>' => in_tag = false,
90 _ if !in_tag => out.push(ch),
91 _ => {}
92 }
93 }
94 out.split_whitespace().collect::<Vec<_>>().join(" ")
95}
96
97#[cfg(test)]
98mod tests {
99 use super::*;
100
101 #[test]
102 fn markdown_extraction_keeps_text() {
103 let (md, fell_back) = extract("<p>Hi <a href=x>link</a></p>", ContentFormat::Markdown);
104 assert!(!md.is_empty());
105 assert!(md.contains("Hi"), "markdown should retain text: {md:?}");
106 assert!(!fell_back, "a well-formed fragment should not fall back");
107 }
108
109 #[test]
110 fn text_extraction_keeps_text() {
111 let (text, fell_back) = extract("<p>Hi <a href=x>link</a></p>", ContentFormat::Text);
112 assert!(text.contains("Hi"), "text should retain content: {text:?}");
113 assert!(!fell_back);
114 }
115
116 #[test]
117 fn html_format_is_passthrough() {
118 let html = "<p>raw <b>html</b></p>";
119 assert_eq!(
120 extract(html, ContentFormat::Html),
121 (html.to_string(), false)
122 );
123 }
124
125 #[test]
126 fn none_format_is_empty() {
127 assert_eq!(
128 extract("<p>anything</p>", ContentFormat::None),
129 (String::new(), false)
130 );
131 }
132
133 #[test]
134 fn content_hash_is_stable_16_hex_and_change_sensitive() {
135 let a = content_hash("the body");
136 assert_eq!(a, content_hash("the body"), "hash is deterministic");
137 assert_eq!(a.len(), 16);
138 assert!(
139 a.chars()
140 .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
141 );
142 assert_ne!(
143 a,
144 content_hash("the body changed"),
145 "different content → different hash"
146 );
147 }
148
149 #[test]
150 fn token_estimate_is_ceil_div_four() {
151 assert_eq!(estimate_tokens("abcdefgh"), 2);
153 assert_eq!(estimate_tokens("abcdefghi"), 3);
154 assert_eq!(estimate_tokens(""), 0);
155 assert_eq!(estimate_tokens("héllo"), 2); }
158
159 #[test]
160 fn strip_tags_fallback_is_clean() {
161 assert_eq!(strip_tags("<p>Hi <b>there</b></p>"), "Hi there");
162 }
163
164 #[test]
165 fn truncate_under_limit_is_untouched() {
166 let (out, cut) = truncate_to_chars("hello", 10);
167 assert_eq!(out, "hello");
168 assert!(!cut);
169 let (out, cut) = truncate_to_chars("hello", 5);
171 assert_eq!(out, "hello");
172 assert!(!cut);
173 }
174
175 #[test]
176 fn truncate_over_limit_appends_marker() {
177 let (out, cut) = truncate_to_chars("hello world", 5);
178 assert!(cut);
179 assert!(out.starts_with("hello"));
180 assert!(out.ends_with(TRUNCATION_MARKER));
181 }
182
183 #[test]
184 fn truncate_respects_char_boundaries() {
185 let s = "héllo wörld"; let (out, cut) = truncate_to_chars(s, 4);
188 assert!(cut);
189 assert!(out.starts_with("héll"));
190 assert_eq!(out.chars().count(), 4 + TRUNCATION_MARKER.chars().count());
192 }
193
194 #[test]
195 fn truncate_to_zero_is_just_marker() {
196 let (out, cut) = truncate_to_chars("anything", 0);
197 assert!(cut);
198 assert_eq!(out, TRUNCATION_MARKER);
199 }
200}