1use crate::dom::{AriaChild, AriaNode, yaml_escape_key_if_needed, yaml_escape_value_if_needed};
2use crate::error::Result;
3use crate::tools::{Tool, ToolContext, ToolResult};
4use schemars::JsonSchema;
5use serde::{Deserialize, Serialize};
6
7#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
9pub struct SnapshotParams {
10 #[serde(default)]
12 pub incremental: bool,
13}
14
15#[derive(Default)]
17pub struct SnapshotTool;
18
19impl Tool for SnapshotTool {
20 type Params = SnapshotParams;
21
22 fn name(&self) -> &str {
23 "snapshot"
24 }
25
26 fn execute_typed(
27 &self,
28 params: SnapshotParams,
29 context: &mut ToolContext,
30 ) -> Result<ToolResult> {
31 let dom = context.get_dom()?;
33
34 let yaml_snapshot = render_aria_tree(&dom.root, RenderMode::Ai, None);
36
37 let interactive_count = dom.count_interactive();
39
40 let result = if params.incremental {
41 serde_json::json!({
43 "full": yaml_snapshot,
44 "interactive_count": interactive_count,
45 })
46 } else {
47 serde_json::json!({
48 "snapshot": yaml_snapshot,
49 "interactive_count": interactive_count,
50 })
51 };
52
53 Ok(ToolResult::success_with(result))
54 }
55}
56
57#[derive(Debug, Clone, Copy)]
59pub enum RenderMode {
60 Ai,
62 Expect,
64}
65
66pub fn render_aria_tree(root: &AriaNode, mode: RenderMode, previous: Option<&AriaNode>) -> String {
69 let mut lines = Vec::new();
70
71 let render_cursor_pointer = matches!(mode, RenderMode::Ai);
72 let render_active = matches!(mode, RenderMode::Ai);
73
74 let nodes_to_render = if root.role == "fragment" {
76 &root.children
77 } else {
78 return render_single_node(root, mode, previous);
80 };
81
82 for node in nodes_to_render {
83 match node {
84 AriaChild::Text(text) => {
85 visit_text(text, "", &mut lines);
86 }
87 AriaChild::Node(node) => {
88 visit(
89 node,
90 "",
91 render_cursor_pointer,
92 render_active,
93 &mut lines,
94 previous,
95 );
96 }
97 }
98 }
99
100 lines.join("\n")
101}
102
103fn render_single_node(root: &AriaNode, mode: RenderMode, previous: Option<&AriaNode>) -> String {
104 let mut lines = Vec::new();
105 let render_cursor_pointer = matches!(mode, RenderMode::Ai);
106 let render_active = matches!(mode, RenderMode::Ai);
107
108 visit(
109 root,
110 "",
111 render_cursor_pointer,
112 render_active,
113 &mut lines,
114 previous,
115 );
116
117 lines.join("\n")
118}
119
120fn visit_text(text: &str, indent: &str, lines: &mut Vec<String>) {
121 let escaped = yaml_escape_value_if_needed(text);
122 if !escaped.is_empty() {
123 lines.push(format!("{}- text: {}", indent, escaped));
124 }
125}
126
127fn visit(
128 aria_node: &AriaNode,
129 indent: &str,
130 render_cursor_pointer: bool,
131 render_active: bool,
132 lines: &mut Vec<String>,
133 _previous: Option<&AriaNode>,
134) {
135 let key = create_key(aria_node, render_cursor_pointer, render_active);
137 let escaped_key = format!("{}- {}", indent, yaml_escape_key_if_needed(&key));
138
139 let single_text_child = get_single_inlined_text_child(aria_node);
141
142 if aria_node.children.is_empty() && aria_node.props.is_empty() {
143 lines.push(escaped_key);
145 } else if let Some(text) = single_text_child {
146 lines.push(format!(
148 "{}: {}",
149 escaped_key,
150 yaml_escape_value_if_needed(&text)
151 ));
152 } else {
153 lines.push(format!("{}:", escaped_key));
155
156 for (name, value) in &aria_node.props {
158 lines.push(format!(
159 "{} - /{}: {}",
160 indent,
161 name,
162 yaml_escape_value_if_needed(value)
163 ));
164 }
165
166 let child_indent = format!("{} ", indent);
168 let in_cursor_pointer =
169 aria_node.index.is_some() && render_cursor_pointer && aria_node.has_pointer_cursor();
170
171 for child in &aria_node.children {
172 match child {
173 AriaChild::Text(text) => {
174 visit_text(text, &child_indent, lines);
175 }
176 AriaChild::Node(child_node) => {
177 visit(
178 child_node,
179 &child_indent,
180 render_cursor_pointer && !in_cursor_pointer,
181 render_active,
182 lines,
183 None,
184 );
185 }
186 }
187 }
188 }
189}
190
191fn create_key(aria_node: &AriaNode, render_cursor_pointer: bool, render_active: bool) -> String {
192 let mut key = aria_node.role.clone();
193
194 if !aria_node.name.is_empty() && aria_node.name.len() <= 900 {
196 let name = &aria_node.name;
198 key.push(' ');
200 key.push_str(&format!("{:?}", name)); }
202
203 if let Some(checked) = &aria_node.checked {
205 match checked {
206 crate::dom::element::AriaChecked::Bool(true) => key.push_str(" [checked]"),
207 crate::dom::element::AriaChecked::Bool(false) => {}
208 crate::dom::element::AriaChecked::Mixed(_) => key.push_str(" [checked=mixed]"),
209 }
210 }
211
212 if aria_node.disabled == Some(true) {
213 key.push_str(" [disabled]");
214 }
215
216 if aria_node.expanded == Some(true) {
217 key.push_str(" [expanded]");
218 }
219
220 if render_active && aria_node.active == Some(true) {
221 key.push_str(" [active]");
222 }
223
224 if let Some(level) = aria_node.level {
225 key.push_str(&format!(" [level={}]", level));
226 }
227
228 if let Some(pressed) = &aria_node.pressed {
229 match pressed {
230 crate::dom::element::AriaPressed::Bool(true) => key.push_str(" [pressed]"),
231 crate::dom::element::AriaPressed::Bool(false) => {}
232 crate::dom::element::AriaPressed::Mixed(_) => key.push_str(" [pressed=mixed]"),
233 }
234 }
235
236 if aria_node.selected == Some(true) {
237 key.push_str(" [selected]");
238 }
239
240 if let Some(index) = aria_node.index {
242 key.push_str(&format!(" [index={}]", index));
243
244 if render_cursor_pointer && aria_node.has_pointer_cursor() {
245 key.push_str(" [cursor=pointer]");
246 }
247 }
248
249 key
250}
251
252fn get_single_inlined_text_child(aria_node: &AriaNode) -> Option<String> {
253 if aria_node.children.len() == 1 && aria_node.props.is_empty() {
254 if let AriaChild::Text(text) = &aria_node.children[0] {
255 return Some(text.clone());
256 }
257 }
258 None
259}
260
261#[cfg(test)]
262mod tests {
263 use super::*;
264
265 #[test]
266 fn test_render_simple_tree() {
267 let mut root = AriaNode::fragment();
268 root.children.push(AriaChild::Node(Box::new(
269 AriaNode::new("button", "Click me")
270 .with_index(0)
271 .with_box(true, Some("pointer".to_string())),
272 )));
273
274 let yaml = render_aria_tree(&root, RenderMode::Ai, None);
275 assert!(yaml.contains("button"));
276 assert!(yaml.contains("Click me"));
277 assert!(yaml.contains("[index=0]"));
278 assert!(yaml.contains("[cursor=pointer]"));
279 }
280
281 #[test]
282 fn test_render_tree_with_text() {
283 let mut root = AriaNode::fragment();
284 root.children
285 .push(AriaChild::Text("Hello world".to_string()));
286
287 let yaml = render_aria_tree(&root, RenderMode::Ai, None);
288 eprintln!("YAML output:\n{}", yaml);
289 assert!(yaml.contains("text:"));
290 assert!(yaml.contains("Hello world"));
291 }
292
293 #[test]
294 fn test_render_nested_tree() {
295 let mut root = AriaNode::fragment();
296 let mut div = AriaNode::new("generic", "");
297 div.children
298 .push(AriaChild::Text("Parent text".to_string()));
299 div.children.push(AriaChild::Node(Box::new(
300 AriaNode::new("button", "Child button").with_index(0),
301 )));
302
303 root.children.push(AriaChild::Node(Box::new(div)));
304
305 let yaml = render_aria_tree(&root, RenderMode::Ai, None);
306 assert!(yaml.contains("generic"));
307 assert!(yaml.contains("Parent text"));
308 assert!(yaml.contains("button"));
309 assert!(yaml.contains("Child button"));
310 }
311
312 #[test]
313 fn test_render_with_props() {
314 let mut root = AriaNode::fragment();
315 root.children.push(AriaChild::Node(Box::new(
316 AriaNode::new("link", "Go to page")
317 .with_index(0)
318 .with_prop("url", "https://example.com"),
319 )));
320
321 let yaml = render_aria_tree(&root, RenderMode::Ai, None);
322 eprintln!("YAML output:\n{}", yaml);
323 assert!(yaml.contains("link"));
324 assert!(yaml.contains("[index=0]"));
325 assert!(yaml.contains("/url:"));
326 assert!(yaml.contains("https://example.com"));
327 }
328
329 #[test]
330 fn test_render_with_aria_states() {
331 let mut root = AriaNode::fragment();
332 root.children.push(AriaChild::Node(Box::new(
333 AriaNode::new("checkbox", "Accept terms")
334 .with_index(0)
335 .with_checked(true)
336 .with_disabled(false),
337 )));
338
339 let yaml = render_aria_tree(&root, RenderMode::Ai, None);
340 assert!(yaml.contains("checkbox"));
341 assert!(yaml.contains("[checked]"));
342 assert!(!yaml.contains("[disabled]"));
344 }
345
346 #[test]
347 fn test_render_heading_with_level() {
348 let mut root = AriaNode::fragment();
349 root.children.push(AriaChild::Node(Box::new(
350 AriaNode::new("heading", "Page Title").with_level(1),
351 )));
352
353 let yaml = render_aria_tree(&root, RenderMode::Ai, None);
354 assert!(yaml.contains("heading"));
355 assert!(yaml.contains("Page Title"));
356 assert!(yaml.contains("[level=1]"));
357 }
358
359 #[test]
360 fn test_empty_snapshot() {
361 let root = AriaNode::fragment();
362 let yaml = render_aria_tree(&root, RenderMode::Ai, None);
363 assert_eq!(yaml.trim(), "");
364 }
365}