Skip to main content

rss_cli/
content.rs

1//! HTML → markdown/text extraction + token estimation. **Owner: `parser` agent.**
2//!
3//! ## Requirements
4//! - [`extract`] converts feed item HTML into the requested [`ContentFormat`]:
5//!   - `Markdown`: convert with `htmd` (HTML → Markdown).
6//!   - `Text`: render to plain text with `html2text`.
7//!   - `Html`: return the HTML as-is (feed-rs already emits sanitized content).
8//!   - `None`: callers should not call `extract`; return an empty string defensively.
9//! - On any converter error, fall back to a naive tag strip rather than panicking, and
10//!   report the fallback so callers can surface a `CONTENT_EXTRACTION_FALLBACK` warning.
11//! - [`estimate_tokens`] returns a cheap, dependency-free token estimate
12//!   (`ceil(chars / 4)`), used so agents can budget context.
13
14use sha2::{Digest, Sha256};
15
16use crate::model::ContentFormat;
17
18/// Wrap width passed to `html2text`'s plain-text renderer. Wide enough to avoid hard
19/// wrapping prose mid-sentence while still bounding pathological tables.
20const TEXT_WRAP_WIDTH: usize = 80;
21
22/// Convert `html` to the requested format. **Owner: `parser` agent.**
23///
24/// Returns `(content, fell_back)`. `fell_back` is `true` when the Markdown/Text converter
25/// errored and we degraded to a naive tag strip (lower fidelity) — the caller turns that
26/// into a non-fatal warning. It is always `false` for `Html`/`None` (no conversion).
27pub 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
42/// Stable 16-hex (64-bit) SHA-256 prefix of already-extracted content, for cheap
43/// change-detection across runs. Mirrors the item-id construction (lowercase hex, first 8
44/// bytes); 64 bits is ample to flag "this body changed".
45pub 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
54/// Rough token estimate for already-extracted text. **Owner: `parser` agent.**
55///
56/// `ceil(chars / 4)` — i.e. `(n + 3) / 4`, expressed via `div_ceil`.
57pub fn estimate_tokens(text: &str) -> u32 {
58    text.chars().count().div_ceil(4) as u32
59}
60
61/// Marker appended to truncated content. The boolean flag is authoritative; this is a
62/// human/agent-visible hint that the body was cut.
63pub const TRUNCATION_MARKER: &str = " …[truncated]";
64
65/// Truncate `text` to at most `max_chars` *characters* (not bytes), appending
66/// [`TRUNCATION_MARKER`] when anything was cut. Returns `(text, was_truncated)`.
67///
68/// Counts and slices by Unicode scalar values, so it never panics on a multi-byte boundary
69/// the way `&text[..n]` would. Note: when applied to rendered Markdown this may cut through
70/// markup (mid-link, mid-emphasis) — accepted as the simple, predictable behavior; callers
71/// wanting intact markup should fetch the item without a cap.
72pub fn truncate_to_chars(text: &str, max_chars: usize) -> (String, bool) {
73    // Avoid counting all chars on long strings unless needed: peek at char index `max_chars`.
74    if text.char_indices().nth(max_chars).is_none() {
75        return (text.to_string(), false); // `text` has <= max_chars characters.
76    }
77    let kept: String = text.chars().take(max_chars).collect();
78    (format!("{kept}{TRUNCATION_MARKER}"), true)
79}
80
81/// Last-resort tag stripper used when a converter errors out. Drops everything between
82/// `<` and `>` and collapses runs of whitespace, so we always return *something* readable.
83fn 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        // 8 chars -> 2 tokens; 9 chars -> ceil(9/4) = 3.
152        assert_eq!(estimate_tokens("abcdefgh"), 2);
153        assert_eq!(estimate_tokens("abcdefghi"), 3);
154        assert_eq!(estimate_tokens(""), 0);
155        // Counts Unicode scalar values, not bytes.
156        assert_eq!(estimate_tokens("héllo"), 2); // 5 chars -> ceil(5/4) = 2
157    }
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        // Exactly at the limit is not truncated.
170        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        // Multi-byte characters: slicing by byte index would panic; by char it must not.
186        let s = "héllo wörld"; // 'é' and 'ö' are 2 bytes each
187        let (out, cut) = truncate_to_chars(s, 4);
188        assert!(cut);
189        assert!(out.starts_with("héll"));
190        // 4 kept chars + the marker.
191        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}