heartbit_core/browser/
distill.rs1#[derive(Debug, Clone)]
24pub struct DistillConfig {
25 pub drop_redundant_static_text: bool,
29 pub drop_empty_nodes: bool,
31}
32
33impl Default for DistillConfig {
34 fn default() -> Self {
35 Self {
36 drop_redundant_static_text: true,
37 drop_empty_nodes: true,
38 }
39 }
40}
41
42struct Node<'a> {
44 raw: &'a str,
45 indent: usize,
46 uid: Option<&'a str>,
48 role: Option<&'a str>,
50 name: Option<&'a str>,
52}
53
54fn parse_line(line: &str) -> Node<'_> {
55 let indent = line.len() - line.trim_start().len();
56 let body = line.trim_start();
57
58 let (uid, after_uid) = match body.strip_prefix("uid=") {
59 Some(rest) => {
60 let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
61 (Some(&rest[..end]), rest[end..].trim_start())
62 }
63 None => (None, body),
64 };
65
66 let role = after_uid
68 .split([' ', '"'])
69 .find(|t| !t.is_empty())
70 .filter(|_| !after_uid.starts_with('"'));
71
72 let name = after_uid.find('"').and_then(|start| {
74 let rest = &after_uid[start + 1..];
75 rest.find('"').map(|end| &rest[..end])
76 });
77
78 Node {
79 raw: line,
80 indent,
81 uid,
82 role,
83 name,
84 }
85}
86
87fn is_interactive(role: Option<&str>) -> bool {
90 matches!(
91 role,
92 Some(
93 "link"
94 | "button"
95 | "textbox"
96 | "checkbox"
97 | "radio"
98 | "combobox"
99 | "menuitem"
100 | "tab"
101 | "switch"
102 | "slider"
103 | "searchbox"
104 | "option"
105 )
106 )
107}
108
109pub fn distill_snapshot(raw: &str, cfg: &DistillConfig) -> String {
119 let nodes: Vec<Node> = raw.lines().map(parse_line).collect();
120 let mut keep: Vec<bool> = vec![true; nodes.len()];
121
122 for i in 0..nodes.len() {
123 let n = &nodes[i];
124
125 if cfg.drop_redundant_static_text
130 && n.role == Some("StaticText")
131 && !is_interactive(n.role)
132 && let Some(text) = n.name
133 && let Some(parent) = (0..i)
134 .rev()
135 .map(|j| &nodes[j])
136 .find(|p| p.indent < n.indent)
137 && parent.name == Some(text)
138 && is_interactive(parent.role)
139 {
140 keep[i] = false;
141 continue;
142 }
143
144 if cfg.drop_empty_nodes && n.uid.is_none() && n.raw.trim().is_empty() {
146 keep[i] = false;
147 }
148 }
149
150 let mut out = String::with_capacity(raw.len());
151 for (i, n) in nodes.iter().enumerate() {
152 if keep[i] {
153 out.push_str(n.raw);
154 out.push('\n');
155 }
156 }
157 if !raw.ends_with('\n') {
159 out.pop();
160 }
161 out
162}
163
164pub fn interactive_uids(snapshot: &str) -> Vec<String> {
166 snapshot
167 .lines()
168 .filter_map(|l| {
169 let n = parse_line(l);
170 match (n.uid, is_interactive(n.role)) {
171 (Some(u), true) => Some(u.to_string()),
172 _ => None,
173 }
174 })
175 .collect()
176}
177
178#[cfg(test)]
179mod tests {
180 use super::*;
181
182 const FIXTURE: &str = r#"uid=1_0 RootWebArea "Hacker News" url="https://news.ycombinator.com/"
188 uid=1_2 link "Hacker News" url="https://news.ycombinator.com/news"
189 uid=1_3 StaticText "Hacker News"
190 uid=1_4 link "new" url="https://news.ycombinator.com/newest"
191 uid=1_5 StaticText "new"
192 uid=1_6 StaticText " | "
193 uid=1_30 textbox "Search"
194 uid=1_31 StaticText "Plain caption with no interactive parent""#;
195
196 #[test]
197 fn preserves_every_interactive_uid() {
198 let before = interactive_uids(FIXTURE);
199 let out = distill_snapshot(FIXTURE, &DistillConfig::default());
200 let after = interactive_uids(&out);
201 assert_eq!(
202 before, after,
203 "distillation must preserve every interactive uid; before={before:?} after={after:?}"
204 );
205 assert_eq!(before, vec!["1_2", "1_4", "1_30"]);
207 }
208
209 #[test]
210 fn strictly_shrinks_by_dropping_echoed_static_text() {
211 let out = distill_snapshot(FIXTURE, &DistillConfig::default());
212 assert!(
213 out.len() < FIXTURE.len(),
214 "distilled output ({}) must be strictly smaller than input ({})",
215 out.len(),
216 FIXTURE.len()
217 );
218 assert!(!out.contains(r#"uid=1_3 StaticText "Hacker News""#));
220 assert!(!out.contains(r#"uid=1_5 StaticText "new""#));
221 assert!(out.contains(r#"uid=1_2 link "Hacker News""#));
223 assert!(out.contains(r#"uid=1_4 link "new""#));
224 }
225
226 #[test]
227 fn keeps_non_echoing_static_text_and_inputs() {
228 let out = distill_snapshot(FIXTURE, &DistillConfig::default());
229 assert!(out.contains(r#"uid=1_6 StaticText " | ""#));
231 assert!(out.contains("Plain caption with no interactive parent"));
232 assert!(out.contains(r#"uid=1_30 textbox "Search""#));
234 assert!(out.contains("https://news.ycombinator.com/news"));
236 }
237
238 #[test]
239 fn is_idempotent() {
240 let cfg = DistillConfig::default();
241 let once = distill_snapshot(FIXTURE, &cfg);
242 let twice = distill_snapshot(&once, &cfg);
243 assert_eq!(once, twice, "distillation must be idempotent");
244 }
245
246 #[test]
247 fn parse_line_extracts_uid_role_name() {
248 let n = parse_line(r#" uid=1_2 link "Hacker News" url="https://x""#);
249 assert_eq!(n.uid, Some("1_2"));
250 assert_eq!(n.role, Some("link"));
251 assert_eq!(n.name, Some("Hacker News"));
252 assert_eq!(n.indent, 2);
253 }
254
255 #[test]
256 fn distill_disabled_is_identity() {
257 let cfg = DistillConfig {
258 drop_redundant_static_text: false,
259 drop_empty_nodes: false,
260 };
261 assert_eq!(distill_snapshot(FIXTURE, &cfg), FIXTURE);
262 }
263
264 #[test]
265 fn interactive_uids_excludes_static_text_and_root() {
266 assert_eq!(interactive_uids(FIXTURE), vec!["1_2", "1_4", "1_30"]);
268 }
269}