Skip to main content

yaml_edit/
debug.rs

1//! Debug utilities for inspecting and understanding YAML structures.
2//!
3//! This module provides debugging tools including:
4//! - CST tree visualization
5//! - Pretty Debug formatting showing actual values
6//! - Visual diffs between documents
7//! - Deep value inspection
8//! - AST visualization (GraphViz output)
9//!
10//! These functions are useful for both contributors working on yaml-edit
11//! and users debugging their YAML documents.
12
13use crate::as_yaml::YamlNode;
14use crate::lex::SyntaxKind;
15use crate::yaml::{Document, Mapping, Scalar, Sequence, SyntaxNode};
16use std::fmt;
17
18/// Prints the CST (Concrete Syntax Tree) structure to stdout.
19///
20/// This is invaluable for understanding how YAML is parsed into the tree structure,
21/// debugging formatting issues, and verifying that mutations produce the expected tree.
22///
23/// # Example
24///
25/// ```
26/// use yaml_edit::{YamlFile, debug};
27/// use rowan::ast::AstNode;
28/// use std::str::FromStr;
29///
30/// let yaml = YamlFile::from_str("team:\n  - Alice\n  - Bob").unwrap();
31/// debug::print_tree(yaml.syntax());
32/// ```
33///
34/// Output:
35/// ```text
36/// DOCUMENT
37///   MAPPING
38///     MAPPING_ENTRY
39///       KEY
40///         SCALAR
41///           STRING: "team"
42///       COLON: ":"
43///       VALUE
44///         NEWLINE: "\n"
45///         INDENT: "  "
46///         SEQUENCE
47///           SEQUENCE_ENTRY
48///             DASH: "-"
49///             WHITESPACE: " "
50///             SCALAR
51///               STRING: "Alice"
52///             NEWLINE: "\n"
53/// ...
54/// ```
55pub fn print_tree(node: &SyntaxNode) {
56    print_tree_indent(node, 0);
57}
58
59/// Prints the CST structure with a custom starting indentation.
60pub fn print_tree_indent(node: &SyntaxNode, indent: usize) {
61    for child in node.children_with_tokens() {
62        let prefix = "  ".repeat(indent);
63        match child {
64            rowan::NodeOrToken::Node(n) => {
65                println!("{}{:?}", prefix, n.kind());
66                print_tree_indent(&n, indent + 1);
67            }
68            rowan::NodeOrToken::Token(t) => {
69                let text = t.text().replace('\n', "\\n").replace('\r', "\\r");
70                println!("{}{:?}: {:?}", prefix, t.kind(), text);
71            }
72        }
73    }
74}
75
76/// Returns a string representation of the CST structure.
77///
78/// Like `print_tree` but returns a string instead of printing to stdout.
79///
80/// # Example
81///
82/// ```
83/// use yaml_edit::{YamlFile, debug};
84/// use rowan::ast::AstNode;
85/// use std::str::FromStr;
86///
87/// let yaml = YamlFile::from_str("name: Alice").unwrap();
88/// let tree_str = debug::tree_to_string(yaml.syntax());
89///
90/// let expected = "DOCUMENT\n  MAPPING\n    MAPPING_ENTRY\n      KEY\n        SCALAR\n          STRING: \"name\"\n      COLON: \":\"\n      WHITESPACE: \" \"\n      VALUE\n        SCALAR\n          STRING: \"Alice\"\n";
91/// assert_eq!(tree_str, expected);
92/// ```
93pub fn tree_to_string(node: &SyntaxNode) -> String {
94    let mut result = String::new();
95    tree_to_string_indent(node, 0, &mut result);
96    result
97}
98
99fn tree_to_string_indent(node: &SyntaxNode, indent: usize, result: &mut String) {
100    for child in node.children_with_tokens() {
101        let prefix = "  ".repeat(indent);
102        match child {
103            rowan::NodeOrToken::Node(n) => {
104                result.push_str(&format!("{}{:?}\n", prefix, n.kind()));
105                tree_to_string_indent(&n, indent + 1, result);
106            }
107            rowan::NodeOrToken::Token(t) => {
108                let text = t.text().replace('\n', "\\n").replace('\r', "\\r");
109                result.push_str(&format!("{}{:?}: {:?}\n", prefix, t.kind(), text));
110            }
111        }
112    }
113}
114
115/// Prints statistics about a YAML document's CST.
116///
117/// Shows counts of different node and token types, which can be useful
118/// for understanding document complexity and debugging issues.
119///
120/// # Example
121///
122/// ```
123/// use yaml_edit::{YamlFile, debug};
124/// use rowan::ast::AstNode;
125/// use std::str::FromStr;
126///
127/// let yaml = YamlFile::from_str("team:\n  - Alice\n  - Bob").unwrap();
128/// debug::print_stats(yaml.syntax());
129/// ```
130pub fn print_stats(node: &SyntaxNode) {
131    let mut node_counts = std::collections::HashMap::new();
132    let mut token_counts = std::collections::HashMap::new();
133
134    count_nodes(node, &mut node_counts, &mut token_counts);
135
136    println!("=== Node Counts ===");
137    let mut node_vec: Vec<_> = node_counts.iter().collect();
138    node_vec.sort_by_key(|(_, count)| std::cmp::Reverse(**count));
139    for (kind, count) in node_vec {
140        println!("  {:?}: {}", kind, count);
141    }
142
143    println!("\n=== Token Counts ===");
144    let mut token_vec: Vec<_> = token_counts.iter().collect();
145    token_vec.sort_by_key(|(_, count)| std::cmp::Reverse(**count));
146    for (kind, count) in token_vec {
147        println!("  {:?}: {}", kind, count);
148    }
149}
150
151fn count_nodes(
152    node: &SyntaxNode,
153    node_counts: &mut std::collections::HashMap<SyntaxKind, usize>,
154    token_counts: &mut std::collections::HashMap<SyntaxKind, usize>,
155) {
156    *node_counts.entry(node.kind()).or_insert(0) += 1;
157
158    for child in node.children_with_tokens() {
159        match child {
160            rowan::NodeOrToken::Node(n) => {
161                count_nodes(&n, node_counts, token_counts);
162            }
163            rowan::NodeOrToken::Token(t) => {
164                *token_counts.entry(t.kind()).or_insert(0) += 1;
165            }
166        }
167    }
168}
169
170/// Validates that the CST follows expected structural invariants.
171///
172/// This is useful for testing that mutations don't break tree structure.
173/// Returns `Ok(())` if the tree is valid, or an error message if issues are found.
174///
175/// Current checks:
176/// - Every MAPPING_ENTRY has exactly one KEY
177/// - Every MAPPING_ENTRY has exactly one COLON
178/// - Every MAPPING_ENTRY has at most one VALUE
179/// - Block-style MAPPING_ENTRY and SEQUENCE_ENTRY nodes end with NEWLINE
180///
181/// # Example
182///
183/// ```
184/// use yaml_edit::{YamlFile, debug};
185/// use rowan::ast::AstNode;
186/// use std::str::FromStr;
187///
188/// let yaml = YamlFile::from_str("name: Alice\n").unwrap();
189/// debug::validate_tree(yaml.syntax()).expect("Tree should be valid");
190/// ```
191pub fn validate_tree(node: &SyntaxNode) -> Result<(), String> {
192    validate_node(node)
193}
194
195fn validate_node(node: &SyntaxNode) -> Result<(), String> {
196    match node.kind() {
197        SyntaxKind::MAPPING_ENTRY => {
198            let keys: Vec<_> = node
199                .children()
200                .filter(|c| c.kind() == SyntaxKind::KEY)
201                .collect();
202            if keys.len() != 1 {
203                return Err(format!(
204                    "MAPPING_ENTRY should have exactly 1 KEY, found {}",
205                    keys.len()
206                ));
207            }
208
209            let colons: Vec<_> = node
210                .children_with_tokens()
211                .filter(|c| {
212                    c.as_token()
213                        .map(|t| t.kind() == SyntaxKind::COLON)
214                        .unwrap_or(false)
215                })
216                .collect();
217            if colons.len() != 1 {
218                return Err(format!(
219                    "MAPPING_ENTRY should have exactly 1 COLON, found {}",
220                    colons.len()
221                ));
222            }
223
224            let values: Vec<_> = node
225                .children()
226                .filter(|c| c.kind() == SyntaxKind::VALUE)
227                .collect();
228            if values.len() > 1 {
229                return Err(format!(
230                    "MAPPING_ENTRY should have at most 1 VALUE, found {}",
231                    values.len()
232                ));
233            }
234
235            // Check if block-style (not in flow collection)
236            // Block entries should end with NEWLINE
237            if !is_in_flow_collection(node) {
238                if let Some(last_token) = node.last_token() {
239                    if last_token.kind() != SyntaxKind::NEWLINE {
240                        return Err(format!(
241                            "Block-style MAPPING_ENTRY should end with NEWLINE, ends with {:?}",
242                            last_token.kind()
243                        ));
244                    }
245                }
246            }
247        }
248        SyntaxKind::SEQUENCE_ENTRY if !is_in_flow_collection(node) => {
249            // Check if block-style
250            if let Some(last_token) = node.last_token() {
251                if last_token.kind() != SyntaxKind::NEWLINE {
252                    return Err(format!(
253                        "Block-style SEQUENCE_ENTRY should end with NEWLINE, ends with {:?}",
254                        last_token.kind()
255                    ));
256                }
257            }
258        }
259        _ => {}
260    }
261
262    // Recursively validate children
263    for child in node.children() {
264        validate_node(&child)?;
265    }
266
267    Ok(())
268}
269
270fn is_in_flow_collection(node: &SyntaxNode) -> bool {
271    // Look at the parent MAPPING or SEQUENCE node
272    let mut current = node.parent();
273    while let Some(parent) = current {
274        match parent.kind() {
275            SyntaxKind::MAPPING => {
276                // Flow mapping has LEFT_BRACE and RIGHT_BRACE tokens
277                for child in parent.children_with_tokens() {
278                    if let Some(token) = child.as_token() {
279                        if token.kind() == SyntaxKind::LEFT_BRACE {
280                            return true;
281                        }
282                    }
283                }
284                return false;
285            }
286            SyntaxKind::SEQUENCE => {
287                // Flow sequence has LEFT_BRACKET and RIGHT_BRACKET tokens
288                for child in parent.children_with_tokens() {
289                    if let Some(token) = child.as_token() {
290                        if token.kind() == SyntaxKind::LEFT_BRACKET {
291                            return true;
292                        }
293                    }
294                }
295                return false;
296            }
297            _ => current = parent.parent(),
298        }
299    }
300    false
301}
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306    use crate::yaml::YamlFile;
307    use rowan::ast::AstNode;
308    use std::str::FromStr;
309
310    #[test]
311    fn test_print_tree() {
312        let yaml = YamlFile::from_str("name: Alice").unwrap();
313        // Should not panic
314        print_tree(yaml.syntax());
315    }
316
317    #[test]
318    fn test_tree_to_string() {
319        let yaml = YamlFile::from_str("name: Alice").unwrap();
320        let s = tree_to_string(yaml.syntax());
321        let expected = "DOCUMENT\n  MAPPING\n    MAPPING_ENTRY\n      KEY\n        SCALAR\n          STRING: \"name\"\n      COLON: \":\"\n      WHITESPACE: \" \"\n      VALUE\n        SCALAR\n          STRING: \"Alice\"\n";
322        assert_eq!(s, expected);
323    }
324
325    #[test]
326    fn test_tree_to_string_sequence() {
327        let yaml = YamlFile::from_str("- item1\n- item2\n").unwrap();
328        let s = tree_to_string(yaml.syntax());
329        assert_eq!(
330            s,
331            concat!(
332                "DOCUMENT\n",
333                "  SEQUENCE\n",
334                "    SEQUENCE_ENTRY\n",
335                "      DASH: \"-\"\n",
336                "      WHITESPACE: \" \"\n",
337                "      SCALAR\n",
338                "        STRING: \"item1\"\n",
339                "      NEWLINE: \"\\\\n\"\n",
340                "    SEQUENCE_ENTRY\n",
341                "      DASH: \"-\"\n",
342                "      WHITESPACE: \" \"\n",
343                "      SCALAR\n",
344                "        STRING: \"item2\"\n",
345                "      NEWLINE: \"\\\\n\"\n",
346            )
347        );
348    }
349
350    #[test]
351    fn test_print_stats() {
352        let yaml = YamlFile::from_str("name: Alice\nage: 30\n").unwrap();
353        // Should not panic
354        print_stats(yaml.syntax());
355    }
356
357    #[test]
358    fn test_validate_tree() {
359        let yaml = YamlFile::from_str("name: Alice\nage: 30\n").unwrap();
360        validate_tree(yaml.syntax()).expect("Tree should be valid");
361    }
362}
363
364/// Pretty-print a YAML document showing its structure and values.
365///
366/// This provides a human-readable view of the document structure,
367/// showing both the logical YAML structure and the actual values.
368///
369/// # Example
370///
371/// ```
372/// use yaml_edit::{Document, debug::PrettyDebug};
373/// use std::str::FromStr;
374///
375/// let doc = Document::from_str("name: Alice\nage: 30").unwrap();
376/// println!("{}", PrettyDebug(&doc));
377/// ```
378pub struct PrettyDebug<'a, T>(pub &'a T);
379
380impl<'a> fmt::Display for PrettyDebug<'a, Document> {
381    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
382        writeln!(f, "Document {{")?;
383
384        if let Some(mapping) = self.0.as_mapping() {
385            write_indented(f, &format!("{}", PrettyDebug(&mapping)), 1)?;
386        } else if let Some(sequence) = self.0.as_sequence() {
387            write_indented(f, &format!("{}", PrettyDebug(&sequence)), 1)?;
388        } else if let Some(scalar) = self.0.as_scalar() {
389            writeln!(f, "  {}", PrettyDebug(&scalar))?;
390        }
391
392        writeln!(f, "}}")
393    }
394}
395
396impl<'a> fmt::Display for PrettyDebug<'a, Mapping> {
397    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
398        writeln!(f, "Mapping [")?;
399
400        for (key, value) in self.0.iter() {
401            write!(f, "  {:?}: ", key)?;
402            format_node(f, &value, 1)?;
403            writeln!(f)?;
404        }
405
406        write!(f, "]")
407    }
408}
409
410impl<'a> fmt::Display for PrettyDebug<'a, Sequence> {
411    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
412        writeln!(f, "Sequence [")?;
413
414        for (i, value) in self.0.values().enumerate() {
415            write!(f, "  [{}]: ", i)?;
416            format_node(f, &value, 1)?;
417            writeln!(f)?;
418        }
419
420        write!(f, "]")
421    }
422}
423
424impl<'a> fmt::Display for PrettyDebug<'a, Scalar> {
425    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
426        write!(f, "Scalar({:?})", self.0.value())
427    }
428}
429
430fn write_indented(f: &mut fmt::Formatter<'_>, text: &str, indent: usize) -> fmt::Result {
431    let indent_str = "  ".repeat(indent);
432    for line in text.lines() {
433        writeln!(f, "{}{}", indent_str, line)?;
434    }
435    Ok(())
436}
437
438fn format_node(f: &mut fmt::Formatter<'_>, node: &YamlNode, indent: usize) -> fmt::Result {
439    let indent_str = "  ".repeat(indent);
440
441    match node {
442        YamlNode::Scalar(s) => {
443            let sv = crate::scalar::ScalarValue::from_scalar(s);
444            write!(f, "{:?} (type: {:?})", sv.value(), sv.scalar_type())?;
445        }
446        YamlNode::Mapping(m) => {
447            writeln!(f, "Mapping {{")?;
448            for (k, v) in m.iter() {
449                write!(f, "{}  ", indent_str)?;
450                format_node(f, &k, 0)?;
451                write!(f, ": ")?;
452                format_node(f, &v, indent + 1)?;
453                writeln!(f)?;
454            }
455            write!(f, "{}}}", indent_str)?;
456        }
457        YamlNode::Sequence(s) => {
458            writeln!(f, "Sequence [")?;
459            for (i, v) in s.values().enumerate() {
460                write!(f, "{}  [{}]: ", indent_str, i)?;
461                format_node(f, &v, indent + 1)?;
462                writeln!(f)?;
463            }
464            write!(f, "{}]", indent_str)?;
465        }
466        YamlNode::Alias(a) => {
467            write!(f, "Alias(*{})", a.name())?;
468        }
469        YamlNode::TaggedNode(t) => {
470            write!(f, "Tagged({:?})", t.tag().unwrap_or_default())?;
471        }
472    }
473
474    Ok(())
475}
476
477/// Value inspector for deep type analysis.
478///
479/// Provides detailed information about a YAML value including:
480/// - Parsed type
481/// - Raw text representation
482/// - Coercion possibilities
483///
484/// # Example
485///
486/// ```
487/// use yaml_edit::{YamlFile, debug::ValueInspector};
488/// use std::str::FromStr;
489///
490/// let yaml = YamlFile::from_str("port: 8080").unwrap();
491/// let doc = yaml.document().unwrap();
492/// let mapping = doc.as_mapping().unwrap();
493/// let value = mapping.get("port").unwrap();
494///
495/// println!("{}", ValueInspector(&value));
496/// ```
497pub struct ValueInspector<'a>(pub &'a crate::as_yaml::YamlNode);
498
499impl<'a> fmt::Display for ValueInspector<'a> {
500    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
501        writeln!(f, "Value Inspector:")?;
502        writeln!(f, "===============")?;
503
504        match self.0 {
505            crate::as_yaml::YamlNode::Scalar(s) => {
506                writeln!(f, "Type: Scalar")?;
507                writeln!(f, "  Value: {:?}", s.as_string())?;
508
509                writeln!(f, "\nCoercion Capabilities:")?;
510                writeln!(f, "  to_i64: {:?}", self.0.to_i64())?;
511                writeln!(f, "  to_f64: {:?}", self.0.to_f64())?;
512                writeln!(f, "  to_bool: {:?}", self.0.to_bool())?;
513            }
514            crate::as_yaml::YamlNode::Mapping(m) => {
515                writeln!(f, "Type: Mapping")?;
516                writeln!(f, "  Size: {} entries", m.len())?;
517            }
518            crate::as_yaml::YamlNode::Sequence(s) => {
519                writeln!(f, "Type: Sequence")?;
520                writeln!(f, "  Length: {} items", s.len())?;
521            }
522            crate::as_yaml::YamlNode::Alias(a) => {
523                writeln!(f, "Type: Alias")?;
524                writeln!(f, "  Anchor name: {}", a.name())?;
525            }
526            crate::as_yaml::YamlNode::TaggedNode(_) => {
527                writeln!(f, "Type: TaggedNode")?;
528            }
529        }
530
531        Ok(())
532    }
533}
534
535/// Visual diff between two YAML documents.
536///
537/// Shows additions, deletions, and modifications between two versions.
538///
539/// # Example
540///
541/// ```
542/// use yaml_edit::{Document, debug::VisualDiff};
543/// use std::str::FromStr;
544///
545/// let before = Document::from_str("name: Alice\nage: 30").unwrap();
546/// let after = Document::from_str("name: Bob\nage: 30").unwrap();
547///
548/// println!("{}", VisualDiff {
549///     before: &before,
550///     after: &after,
551/// });
552/// ```
553pub struct VisualDiff<'a> {
554    /// The original document
555    pub before: &'a Document,
556    /// The modified document
557    pub after: &'a Document,
558}
559
560impl<'a> fmt::Display for VisualDiff<'a> {
561    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
562        writeln!(f, "Visual Diff:")?;
563        writeln!(f, "============")?;
564
565        writeln!(f, "\nBefore:")?;
566        writeln!(f, "-------")?;
567        for line in self.before.to_string().lines() {
568            writeln!(f, "  {}", line)?;
569        }
570
571        writeln!(f, "\nAfter:")?;
572        writeln!(f, "------")?;
573        for line in self.after.to_string().lines() {
574            writeln!(f, "  {}", line)?;
575        }
576
577        writeln!(f, "\nChanges:")?;
578        writeln!(f, "--------")?;
579
580        // Simple line-based diff
581        let before_str = self.before.to_string();
582        let after_str = self.after.to_string();
583        let before_lines: Vec<_> = before_str.lines().collect();
584        let after_lines: Vec<_> = after_str.lines().collect();
585
586        for (i, (b, a)) in before_lines.iter().zip(after_lines.iter()).enumerate() {
587            if b != a {
588                writeln!(f, "Line {}: {:?} -> {:?}", i + 1, b, a)?;
589            }
590        }
591
592        if before_lines.len() > after_lines.len() {
593            writeln!(
594                f,
595                "Removed {} lines",
596                before_lines.len() - after_lines.len()
597            )?;
598        } else if after_lines.len() > before_lines.len() {
599            writeln!(f, "Added {} lines", after_lines.len() - before_lines.len())?;
600        }
601
602        Ok(())
603    }
604}
605
606/// Generate GraphViz DOT format visualization of the CST.
607///
608/// This creates a visual graph representation suitable for rendering
609/// with GraphViz tools like `dot`.
610///
611/// # Example
612///
613/// ```
614/// use yaml_edit::{YamlFile, debug::graphviz_dot};
615/// use rowan::ast::AstNode;
616/// use std::str::FromStr;
617///
618/// let yaml = YamlFile::from_str("name: Alice").unwrap();
619/// let dot = graphviz_dot(yaml.syntax());
620/// // Save to file and render with: dot -Tpng output.dot -o output.png
621/// ```
622pub fn graphviz_dot(node: &SyntaxNode) -> String {
623    let mut result = String::from("digraph CST {\n");
624    result.push_str("  node [shape=box, fontname=\"Courier\"];\n");
625    result.push_str("  edge [fontsize=10];\n\n");
626
627    let mut counter = 0;
628    graphviz_node(&mut result, node, None, &mut counter);
629
630    result.push_str("}\n");
631    result
632}
633
634fn graphviz_node(
635    result: &mut String,
636    node: &SyntaxNode,
637    parent_id: Option<usize>,
638    counter: &mut usize,
639) {
640    let node_id = *counter;
641    *counter += 1;
642
643    // Create node
644    let label = format!("{:?}", node.kind());
645    result.push_str(&format!("  n{} [label=\"{}\"];\n", node_id, label));
646
647    // Link to parent
648    if let Some(pid) = parent_id {
649        result.push_str(&format!("  n{} -> n{};\n", pid, node_id));
650    }
651
652    // Process children
653    for child in node.children_with_tokens() {
654        match child {
655            rowan::NodeOrToken::Node(n) => {
656                graphviz_node(result, &n, Some(node_id), counter);
657            }
658            rowan::NodeOrToken::Token(t) => {
659                let token_id = *counter;
660                *counter += 1;
661
662                let text = t
663                    .text()
664                    .replace('\\', "\\\\")
665                    .replace('"', "\\\"")
666                    .replace('\n', "\\\\n")
667                    .replace('\r', "\\\\r");
668                let label = format!("{:?}\\n{:?}", t.kind(), text);
669                result.push_str(&format!(
670                    "  n{} [label=\"{}\", shape=ellipse, style=filled, fillcolor=lightgray];\n",
671                    token_id, label
672                ));
673                result.push_str(&format!("  n{} -> n{};\n", node_id, token_id));
674            }
675        }
676    }
677}
678
679#[cfg(test)]
680mod new_debug_tests {
681    use super::*;
682    use crate::yaml::YamlFile;
683    use rowan::ast::AstNode;
684    use std::str::FromStr;
685
686    #[test]
687    fn test_pretty_debug_document() {
688        let doc = Document::from_str("name: Alice\nage: 30").unwrap();
689        let output = format!("{}", PrettyDebug(&doc));
690
691        // Check structure exists (exact formatting may vary)
692        assert_eq!(output.lines().next(), Some("Document {"));
693        assert_eq!(output.lines().last(), Some("}"));
694    }
695
696    #[test]
697    fn test_value_inspector_scalar() {
698        let yaml = YamlFile::from_str("port: 8080").unwrap();
699        let doc = yaml.document().unwrap();
700        let mapping = doc.as_mapping().unwrap();
701        let value = mapping.get("port").unwrap();
702
703        let output = format!("{}", ValueInspector(&value));
704
705        assert_eq!(output.lines().next(), Some("Value Inspector:"));
706        assert_eq!(output.lines().nth(1), Some("==============="));
707        assert_eq!(output.lines().nth(2), Some("Type: Scalar"));
708    }
709
710    #[test]
711    fn test_visual_diff_simple() {
712        let before = Document::from_str("name: Alice").unwrap();
713        let after = Document::from_str("name: Bob").unwrap();
714
715        let diff = VisualDiff {
716            before: &before,
717            after: &after,
718        };
719
720        let output = format!("{}", diff);
721
722        assert_eq!(output.lines().next(), Some("Visual Diff:"));
723        assert_eq!(output.lines().nth(1), Some("============"));
724    }
725
726    #[test]
727    fn test_graphviz_dot_structure() {
728        let yaml = YamlFile::from_str("name: Alice").unwrap();
729        let dot = graphviz_dot(yaml.syntax());
730
731        assert_eq!(dot.lines().next(), Some("digraph CST {"));
732        assert_eq!(dot.lines().last(), Some("}"));
733    }
734
735    #[test]
736    fn test_graphviz_dot_contains_nodes() {
737        let yaml = YamlFile::from_str("key: value").unwrap();
738        let dot = graphviz_dot(yaml.syntax());
739
740        // Should have node declarations (at least one "node" line in the DOT output)
741        let node_count = dot.lines().filter(|l| l.trim().starts_with("node")).count();
742        assert!(
743            node_count >= 1,
744            "expected at least one node declaration in dot output"
745        );
746    }
747}