webfetch_core/
compress.rs1use once_cell::sync::Lazy;
2use regex::Regex;
3
4static WHITESPACE_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"\s+").unwrap());
5static DECORATIVE_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"[▶→←▼▲•·◆◇◊✓✗✔✘‣⁃◦]").unwrap());
6
7pub fn compress_text(text: &str) -> String {
14 let clean = DECORATIVE_RE.replace_all(text, "");
15 let collapsed = WHITESPACE_RE.replace_all(&clean, " ");
16 collapsed.trim().to_string()
17}
18
19pub fn compress_block(text: &str) -> String {
22 let mut lines: Vec<String> = Vec::new();
23 let mut prev_blank = false;
24 for raw in text.lines() {
25 let line = compress_text(raw);
26 let blank = line.is_empty();
27 if blank && prev_blank {
28 continue;
29 }
30 lines.push(line);
31 prev_blank = blank;
32 }
33 lines.join("\n").trim().to_string()
34}
35
36fn is_url_punct(b: u8) -> bool {
39 matches!(
40 b,
41 b'/' | b':' | b'.' | b'?' | b'#' | b'&' | b'=' | b'%' | b'~'
42 )
43}
44
45fn cost_quarters(text: &str) -> usize {
51 text.len() + 2 * text.bytes().filter(|b| is_url_punct(*b)).count()
52}
53
54pub fn estimate_tokens(text: &str) -> usize {
65 cost_quarters(text) / 4
66}
67
68pub const TRUNCATION_MARKER: &str = "\n…[truncated]";
70
71pub fn truncate_to_tokens(text: &str, max_tokens: usize) -> String {
79 if estimate_tokens(text) <= max_tokens {
80 return text.to_string();
81 }
82 let budget = (max_tokens * 4).saturating_sub(cost_quarters(TRUNCATION_MARKER));
84
85 let mut spent = 0usize;
86 let mut end = 0usize;
87 for (i, b) in text.bytes().enumerate() {
88 let next = spent + if is_url_punct(b) { 3 } else { 1 };
89 if next > budget {
90 break;
91 }
92 spent = next;
93 end = i + 1;
94 }
95 while end > 0 && !text.is_char_boundary(end) {
96 end -= 1;
97 }
98 format!("{}{}", &text[..end], TRUNCATION_MARKER)
99}
100
101#[cfg(test)]
102mod tests {
103 use super::*;
104
105 #[test]
106 fn estimate_unchanged_for_plain_prose() {
107 assert_eq!(estimate_tokens(&"a".repeat(100)), 25);
108 }
109
110 #[test]
111 fn url_heavy_text_estimates_higher_than_prose_of_same_length() {
112 let urls = "[1] https://example.com/a/b?c=d#e\n\
114 [2] https://example.org/x/y/z?q=1\n\
115 [3] https://sub.example.net/path/to/thing\n\
116 [4] https://example.io/foo/bar/baz?k=v";
117 let prose = "x".repeat(urls.len());
119 assert_eq!(urls.len(), prose.len());
120 assert!(
121 estimate_tokens(urls) > estimate_tokens(&prose),
122 "urls={} prose={}",
123 estimate_tokens(urls),
124 estimate_tokens(&prose)
125 );
126 }
127
128 #[test]
129 fn truncate_respects_the_budget_it_reports() {
130 let text = "a".repeat(1000);
131 let out = truncate_to_tokens(&text, 20);
132 assert!(out.ends_with(TRUNCATION_MARKER));
133 assert!(
134 estimate_tokens(&out) <= 20,
135 "estimate {}",
136 estimate_tokens(&out)
137 );
138 }
139
140 #[test]
143 fn truncate_respects_budget_on_url_heavy_text() {
144 let urls = "[1] https://example.com/a/b?c=d#e\n".repeat(200);
145 let out = truncate_to_tokens(&urls, 50);
146 assert!(
147 estimate_tokens(&out) <= 50,
148 "estimate {}",
149 estimate_tokens(&out)
150 );
151 }
152
153 #[test]
154 fn truncate_is_a_noop_within_budget() {
155 let text = "short enough";
156 assert_eq!(truncate_to_tokens(text, 1000), text);
157 }
158
159 #[test]
160 fn truncate_never_splits_a_utf8_char() {
161 let text = "é".repeat(500);
162 let out = truncate_to_tokens(&text, 10);
163 assert!(out.starts_with('é'));
166 assert!(estimate_tokens(&out) <= 10);
167 }
168}