#[derive(Debug, Clone)]
pub struct DistillConfig {
pub drop_redundant_static_text: bool,
pub drop_empty_nodes: bool,
}
impl Default for DistillConfig {
fn default() -> Self {
Self {
drop_redundant_static_text: true,
drop_empty_nodes: true,
}
}
}
struct Node<'a> {
raw: &'a str,
indent: usize,
uid: Option<&'a str>,
role: Option<&'a str>,
name: Option<&'a str>,
}
fn parse_line(line: &str) -> Node<'_> {
let indent = line.len() - line.trim_start().len();
let body = line.trim_start();
let (uid, after_uid) = match body.strip_prefix("uid=") {
Some(rest) => {
let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
(Some(&rest[..end]), rest[end..].trim_start())
}
None => (None, body),
};
let role = after_uid
.split([' ', '"'])
.find(|t| !t.is_empty())
.filter(|_| !after_uid.starts_with('"'));
let name = after_uid.find('"').and_then(|start| {
let rest = &after_uid[start + 1..];
rest.find('"').map(|end| &rest[..end])
});
Node {
raw: line,
indent,
uid,
role,
name,
}
}
fn is_interactive(role: Option<&str>) -> bool {
matches!(
role,
Some(
"link"
| "button"
| "textbox"
| "checkbox"
| "radio"
| "combobox"
| "menuitem"
| "tab"
| "switch"
| "slider"
| "searchbox"
| "option"
)
)
}
pub fn distill_snapshot(raw: &str, cfg: &DistillConfig) -> String {
let nodes: Vec<Node> = raw.lines().map(parse_line).collect();
let mut keep: Vec<bool> = vec![true; nodes.len()];
for i in 0..nodes.len() {
let n = &nodes[i];
if cfg.drop_redundant_static_text
&& n.role == Some("StaticText")
&& !is_interactive(n.role)
&& let Some(text) = n.name
&& let Some(parent) = (0..i)
.rev()
.map(|j| &nodes[j])
.find(|p| p.indent < n.indent)
&& parent.name == Some(text)
&& is_interactive(parent.role)
{
keep[i] = false;
continue;
}
if cfg.drop_empty_nodes && n.uid.is_none() && n.raw.trim().is_empty() {
keep[i] = false;
}
}
let mut out = String::with_capacity(raw.len());
for (i, n) in nodes.iter().enumerate() {
if keep[i] {
out.push_str(n.raw);
out.push('\n');
}
}
if !raw.ends_with('\n') {
out.pop();
}
out
}
pub fn interactive_uids(snapshot: &str) -> Vec<String> {
snapshot
.lines()
.filter_map(|l| {
let n = parse_line(l);
match (n.uid, is_interactive(n.role)) {
(Some(u), true) => Some(u.to_string()),
_ => None,
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
const FIXTURE: &str = r#"uid=1_0 RootWebArea "Hacker News" url="https://news.ycombinator.com/"
uid=1_2 link "Hacker News" url="https://news.ycombinator.com/news"
uid=1_3 StaticText "Hacker News"
uid=1_4 link "new" url="https://news.ycombinator.com/newest"
uid=1_5 StaticText "new"
uid=1_6 StaticText " | "
uid=1_30 textbox "Search"
uid=1_31 StaticText "Plain caption with no interactive parent""#;
#[test]
fn preserves_every_interactive_uid() {
let before = interactive_uids(FIXTURE);
let out = distill_snapshot(FIXTURE, &DistillConfig::default());
let after = interactive_uids(&out);
assert_eq!(
before, after,
"distillation must preserve every interactive uid; before={before:?} after={after:?}"
);
assert_eq!(before, vec!["1_2", "1_4", "1_30"]);
}
#[test]
fn strictly_shrinks_by_dropping_echoed_static_text() {
let out = distill_snapshot(FIXTURE, &DistillConfig::default());
assert!(
out.len() < FIXTURE.len(),
"distilled output ({}) must be strictly smaller than input ({})",
out.len(),
FIXTURE.len()
);
assert!(!out.contains(r#"uid=1_3 StaticText "Hacker News""#));
assert!(!out.contains(r#"uid=1_5 StaticText "new""#));
assert!(out.contains(r#"uid=1_2 link "Hacker News""#));
assert!(out.contains(r#"uid=1_4 link "new""#));
}
#[test]
fn keeps_non_echoing_static_text_and_inputs() {
let out = distill_snapshot(FIXTURE, &DistillConfig::default());
assert!(out.contains(r#"uid=1_6 StaticText " | ""#));
assert!(out.contains("Plain caption with no interactive parent"));
assert!(out.contains(r#"uid=1_30 textbox "Search""#));
assert!(out.contains("https://news.ycombinator.com/news"));
}
#[test]
fn is_idempotent() {
let cfg = DistillConfig::default();
let once = distill_snapshot(FIXTURE, &cfg);
let twice = distill_snapshot(&once, &cfg);
assert_eq!(once, twice, "distillation must be idempotent");
}
#[test]
fn parse_line_extracts_uid_role_name() {
let n = parse_line(r#" uid=1_2 link "Hacker News" url="https://x""#);
assert_eq!(n.uid, Some("1_2"));
assert_eq!(n.role, Some("link"));
assert_eq!(n.name, Some("Hacker News"));
assert_eq!(n.indent, 2);
}
#[test]
fn distill_disabled_is_identity() {
let cfg = DistillConfig {
drop_redundant_static_text: false,
drop_empty_nodes: false,
};
assert_eq!(distill_snapshot(FIXTURE, &cfg), FIXTURE);
}
#[test]
fn interactive_uids_excludes_static_text_and_root() {
assert_eq!(interactive_uids(FIXTURE), vec!["1_2", "1_4", "1_30"]);
}
}