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 => {
249            // Check if block-style
250            if !is_in_flow_collection(node) {
251                if let Some(last_token) = node.last_token() {
252                    if last_token.kind() != SyntaxKind::NEWLINE {
253                        return Err(format!(
254                            "Block-style SEQUENCE_ENTRY should end with NEWLINE, ends with {:?}",
255                            last_token.kind()
256                        ));
257                    }
258                }
259            }
260        }
261        _ => {}
262    }
263
264    // Recursively validate children
265    for child in node.children() {
266        validate_node(&child)?;
267    }
268
269    Ok(())
270}
271
272fn is_in_flow_collection(node: &SyntaxNode) -> bool {
273    // Look at the parent MAPPING or SEQUENCE node
274    let mut current = node.parent();
275    while let Some(parent) = current {
276        match parent.kind() {
277            SyntaxKind::MAPPING => {
278                // Flow mapping has LEFT_BRACE and RIGHT_BRACE tokens
279                for child in parent.children_with_tokens() {
280                    if let Some(token) = child.as_token() {
281                        if token.kind() == SyntaxKind::LEFT_BRACE {
282                            return true;
283                        }
284                    }
285                }
286                return false;
287            }
288            SyntaxKind::SEQUENCE => {
289                // Flow sequence has LEFT_BRACKET and RIGHT_BRACKET tokens
290                for child in parent.children_with_tokens() {
291                    if let Some(token) = child.as_token() {
292                        if token.kind() == SyntaxKind::LEFT_BRACKET {
293                            return true;
294                        }
295                    }
296                }
297                return false;
298            }
299            _ => current = parent.parent(),
300        }
301    }
302    false
303}
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308    use crate::yaml::YamlFile;
309    use rowan::ast::AstNode;
310    use std::str::FromStr;
311
312    #[test]
313    fn test_print_tree() {
314        let yaml = YamlFile::from_str("name: Alice").unwrap();
315        // Should not panic
316        print_tree(yaml.syntax());
317    }
318
319    #[test]
320    fn test_tree_to_string() {
321        let yaml = YamlFile::from_str("name: Alice").unwrap();
322        let s = tree_to_string(yaml.syntax());
323        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";
324        assert_eq!(s, expected);
325    }
326
327    #[test]
328    fn test_tree_to_string_sequence() {
329        let yaml = YamlFile::from_str("- item1\n- item2\n").unwrap();
330        let s = tree_to_string(yaml.syntax());
331        assert_eq!(
332            s,
333            concat!(
334                "DOCUMENT\n",
335                "  SEQUENCE\n",
336                "    SEQUENCE_ENTRY\n",
337                "      DASH: \"-\"\n",
338                "      WHITESPACE: \" \"\n",
339                "      SCALAR\n",
340                "        STRING: \"item1\"\n",
341                "      NEWLINE: \"\\\\n\"\n",
342                "    SEQUENCE_ENTRY\n",
343                "      DASH: \"-\"\n",
344                "      WHITESPACE: \" \"\n",
345                "      SCALAR\n",
346                "        STRING: \"item2\"\n",
347                "      NEWLINE: \"\\\\n\"\n",
348            )
349        );
350    }
351
352    #[test]
353    fn test_print_stats() {
354        let yaml = YamlFile::from_str("name: Alice\nage: 30\n").unwrap();
355        // Should not panic
356        print_stats(yaml.syntax());
357    }
358
359    #[test]
360    fn test_validate_tree() {
361        let yaml = YamlFile::from_str("name: Alice\nage: 30\n").unwrap();
362        validate_tree(yaml.syntax()).expect("Tree should be valid");
363    }
364}
365
366/// Pretty-print a YAML document showing its structure and values.
367///
368/// This provides a human-readable view of the document structure,
369/// showing both the logical YAML structure and the actual values.
370///
371/// # Example
372///
373/// ```
374/// use yaml_edit::{Document, debug::PrettyDebug};
375/// use std::str::FromStr;
376///
377/// let doc = Document::from_str("name: Alice\nage: 30").unwrap();
378/// println!("{}", PrettyDebug(&doc));
379/// ```
380pub struct PrettyDebug<'a, T>(pub &'a T);
381
382impl<'a> fmt::Display for PrettyDebug<'a, Document> {
383    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
384        writeln!(f, "Document {{")?;
385
386        if let Some(mapping) = self.0.as_mapping() {
387            write_indented(f, &format!("{}", PrettyDebug(&mapping)), 1)?;
388        } else if let Some(sequence) = self.0.as_sequence() {
389            write_indented(f, &format!("{}", PrettyDebug(&sequence)), 1)?;
390        } else if let Some(scalar) = self.0.as_scalar() {
391            writeln!(f, "  {}", PrettyDebug(&scalar))?;
392        }
393
394        writeln!(f, "}}")
395    }
396}
397
398impl<'a> fmt::Display for PrettyDebug<'a, Mapping> {
399    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
400        writeln!(f, "Mapping [")?;
401
402        for (key, value) in self.0.iter() {
403            write!(f, "  {:?}: ", key)?;
404            format_node(f, &value, 1)?;
405            writeln!(f)?;
406        }
407
408        write!(f, "]")
409    }
410}
411
412impl<'a> fmt::Display for PrettyDebug<'a, Sequence> {
413    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
414        writeln!(f, "Sequence [")?;
415
416        for (i, value) in self.0.values().enumerate() {
417            write!(f, "  [{}]: ", i)?;
418            format_node(f, &value, 1)?;
419            writeln!(f)?;
420        }
421
422        write!(f, "]")
423    }
424}
425
426impl<'a> fmt::Display for PrettyDebug<'a, Scalar> {
427    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
428        write!(f, "Scalar({:?})", self.0.value())
429    }
430}
431
432fn write_indented(f: &mut fmt::Formatter<'_>, text: &str, indent: usize) -> fmt::Result {
433    let indent_str = "  ".repeat(indent);
434    for line in text.lines() {
435        writeln!(f, "{}{}", indent_str, line)?;
436    }
437    Ok(())
438}
439
440fn format_node(f: &mut fmt::Formatter<'_>, node: &YamlNode, indent: usize) -> fmt::Result {
441    let indent_str = "  ".repeat(indent);
442
443    match node {
444        YamlNode::Scalar(s) => {
445            let sv = crate::scalar::ScalarValue::from_scalar(s);
446            write!(f, "{:?} (type: {:?})", sv.value(), sv.scalar_type())?;
447        }
448        YamlNode::Mapping(m) => {
449            writeln!(f, "Mapping {{")?;
450            for (k, v) in m.iter() {
451                write!(f, "{}  ", indent_str)?;
452                format_node(f, &k, 0)?;
453                write!(f, ": ")?;
454                format_node(f, &v, indent + 1)?;
455                writeln!(f)?;
456            }
457            write!(f, "{}}}", indent_str)?;
458        }
459        YamlNode::Sequence(s) => {
460            writeln!(f, "Sequence [")?;
461            for (i, v) in s.values().enumerate() {
462                write!(f, "{}  [{}]: ", indent_str, i)?;
463                format_node(f, &v, indent + 1)?;
464                writeln!(f)?;
465            }
466            write!(f, "{}]", indent_str)?;
467        }
468        YamlNode::Alias(a) => {
469            write!(f, "Alias(*{})", a.name())?;
470        }
471        YamlNode::TaggedNode(t) => {
472            write!(f, "Tagged({:?})", t.tag().unwrap_or_default())?;
473        }
474    }
475
476    Ok(())
477}
478
479/// Value inspector for deep type analysis.
480///
481/// Provides detailed information about a YAML value including:
482/// - Parsed type
483/// - Raw text representation
484/// - Coercion possibilities
485///
486/// # Example
487///
488/// ```
489/// use yaml_edit::{YamlFile, debug::ValueInspector};
490/// use std::str::FromStr;
491///
492/// let yaml = YamlFile::from_str("port: 8080").unwrap();
493/// let doc = yaml.document().unwrap();
494/// let mapping = doc.as_mapping().unwrap();
495/// let value = mapping.get("port").unwrap();
496///
497/// println!("{}", ValueInspector(&value));
498/// ```
499pub struct ValueInspector<'a>(pub &'a crate::as_yaml::YamlNode);
500
501impl<'a> fmt::Display for ValueInspector<'a> {
502    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
503        writeln!(f, "Value Inspector:")?;
504        writeln!(f, "===============")?;
505
506        match self.0 {
507            crate::as_yaml::YamlNode::Scalar(s) => {
508                writeln!(f, "Type: Scalar")?;
509                writeln!(f, "  Value: {:?}", s.as_string())?;
510
511                writeln!(f, "\nCoercion Capabilities:")?;
512                writeln!(f, "  to_i64: {:?}", self.0.to_i64())?;
513                writeln!(f, "  to_f64: {:?}", self.0.to_f64())?;
514                writeln!(f, "  to_bool: {:?}", self.0.to_bool())?;
515            }
516            crate::as_yaml::YamlNode::Mapping(m) => {
517                writeln!(f, "Type: Mapping")?;
518                writeln!(f, "  Size: {} entries", m.len())?;
519            }
520            crate::as_yaml::YamlNode::Sequence(s) => {
521                writeln!(f, "Type: Sequence")?;
522                writeln!(f, "  Length: {} items", s.len())?;
523            }
524            crate::as_yaml::YamlNode::Alias(a) => {
525                writeln!(f, "Type: Alias")?;
526                writeln!(f, "  Anchor name: {}", a.name())?;
527            }
528            crate::as_yaml::YamlNode::TaggedNode(_) => {
529                writeln!(f, "Type: TaggedNode")?;
530            }
531        }
532
533        Ok(())
534    }
535}
536
537/// Visual diff between two YAML documents.
538///
539/// Shows additions, deletions, and modifications between two versions.
540///
541/// # Example
542///
543/// ```
544/// use yaml_edit::{Document, debug::VisualDiff};
545/// use std::str::FromStr;
546///
547/// let before = Document::from_str("name: Alice\nage: 30").unwrap();
548/// let after = Document::from_str("name: Bob\nage: 30").unwrap();
549///
550/// println!("{}", VisualDiff {
551///     before: &before,
552///     after: &after,
553/// });
554/// ```
555pub struct VisualDiff<'a> {
556    /// The original document
557    pub before: &'a Document,
558    /// The modified document
559    pub after: &'a Document,
560}
561
562impl<'a> fmt::Display for VisualDiff<'a> {
563    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
564        writeln!(f, "Visual Diff:")?;
565        writeln!(f, "============")?;
566
567        writeln!(f, "\nBefore:")?;
568        writeln!(f, "-------")?;
569        for line in self.before.to_string().lines() {
570            writeln!(f, "  {}", line)?;
571        }
572
573        writeln!(f, "\nAfter:")?;
574        writeln!(f, "------")?;
575        for line in self.after.to_string().lines() {
576            writeln!(f, "  {}", line)?;
577        }
578
579        writeln!(f, "\nChanges:")?;
580        writeln!(f, "--------")?;
581
582        // Simple line-based diff
583        let before_str = self.before.to_string();
584        let after_str = self.after.to_string();
585        let before_lines: Vec<_> = before_str.lines().collect();
586        let after_lines: Vec<_> = after_str.lines().collect();
587
588        for (i, (b, a)) in before_lines.iter().zip(after_lines.iter()).enumerate() {
589            if b != a {
590                writeln!(f, "Line {}: {:?} -> {:?}", i + 1, b, a)?;
591            }
592        }
593
594        if before_lines.len() > after_lines.len() {
595            writeln!(
596                f,
597                "Removed {} lines",
598                before_lines.len() - after_lines.len()
599            )?;
600        } else if after_lines.len() > before_lines.len() {
601            writeln!(f, "Added {} lines", after_lines.len() - before_lines.len())?;
602        }
603
604        Ok(())
605    }
606}
607
608/// Generate GraphViz DOT format visualization of the CST.
609///
610/// This creates a visual graph representation suitable for rendering
611/// with GraphViz tools like `dot`.
612///
613/// # Example
614///
615/// ```
616/// use yaml_edit::{YamlFile, debug::graphviz_dot};
617/// use rowan::ast::AstNode;
618/// use std::str::FromStr;
619///
620/// let yaml = YamlFile::from_str("name: Alice").unwrap();
621/// let dot = graphviz_dot(yaml.syntax());
622/// // Save to file and render with: dot -Tpng output.dot -o output.png
623/// ```
624pub fn graphviz_dot(node: &SyntaxNode) -> String {
625    let mut result = String::from("digraph CST {\n");
626    result.push_str("  node [shape=box, fontname=\"Courier\"];\n");
627    result.push_str("  edge [fontsize=10];\n\n");
628
629    let mut counter = 0;
630    graphviz_node(&mut result, node, None, &mut counter);
631
632    result.push_str("}\n");
633    result
634}
635
636fn graphviz_node(
637    result: &mut String,
638    node: &SyntaxNode,
639    parent_id: Option<usize>,
640    counter: &mut usize,
641) {
642    let node_id = *counter;
643    *counter += 1;
644
645    // Create node
646    let label = format!("{:?}", node.kind());
647    result.push_str(&format!("  n{} [label=\"{}\"];\n", node_id, label));
648
649    // Link to parent
650    if let Some(pid) = parent_id {
651        result.push_str(&format!("  n{} -> n{};\n", pid, node_id));
652    }
653
654    // Process children
655    for child in node.children_with_tokens() {
656        match child {
657            rowan::NodeOrToken::Node(n) => {
658                graphviz_node(result, &n, Some(node_id), counter);
659            }
660            rowan::NodeOrToken::Token(t) => {
661                let token_id = *counter;
662                *counter += 1;
663
664                let text = t
665                    .text()
666                    .replace('\\', "\\\\")
667                    .replace('"', "\\\"")
668                    .replace('\n', "\\\\n")
669                    .replace('\r', "\\\\r");
670                let label = format!("{:?}\\n{:?}", t.kind(), text);
671                result.push_str(&format!(
672                    "  n{} [label=\"{}\", shape=ellipse, style=filled, fillcolor=lightgray];\n",
673                    token_id, label
674                ));
675                result.push_str(&format!("  n{} -> n{};\n", node_id, token_id));
676            }
677        }
678    }
679}
680
681#[cfg(test)]
682mod new_debug_tests {
683    use super::*;
684    use crate::yaml::YamlFile;
685    use rowan::ast::AstNode;
686    use std::str::FromStr;
687
688    #[test]
689    fn test_pretty_debug_document() {
690        let doc = Document::from_str("name: Alice\nage: 30").unwrap();
691        let output = format!("{}", PrettyDebug(&doc));
692
693        // Check structure exists (exact formatting may vary)
694        assert_eq!(output.lines().next(), Some("Document {"));
695        assert_eq!(output.lines().last(), Some("}"));
696    }
697
698    #[test]
699    fn test_value_inspector_scalar() {
700        let yaml = YamlFile::from_str("port: 8080").unwrap();
701        let doc = yaml.document().unwrap();
702        let mapping = doc.as_mapping().unwrap();
703        let value = mapping.get("port").unwrap();
704
705        let output = format!("{}", ValueInspector(&value));
706
707        assert_eq!(output.lines().next(), Some("Value Inspector:"));
708        assert_eq!(output.lines().nth(1), Some("==============="));
709        assert_eq!(output.lines().nth(2), Some("Type: Scalar"));
710    }
711
712    #[test]
713    fn test_visual_diff_simple() {
714        let before = Document::from_str("name: Alice").unwrap();
715        let after = Document::from_str("name: Bob").unwrap();
716
717        let diff = VisualDiff {
718            before: &before,
719            after: &after,
720        };
721
722        let output = format!("{}", diff);
723
724        assert_eq!(output.lines().next(), Some("Visual Diff:"));
725        assert_eq!(output.lines().nth(1), Some("============"));
726    }
727
728    #[test]
729    fn test_graphviz_dot_structure() {
730        let yaml = YamlFile::from_str("name: Alice").unwrap();
731        let dot = graphviz_dot(yaml.syntax());
732
733        assert_eq!(dot.lines().next(), Some("digraph CST {"));
734        assert_eq!(dot.lines().last(), Some("}"));
735    }
736
737    #[test]
738    fn test_graphviz_dot_contains_nodes() {
739        let yaml = YamlFile::from_str("key: value").unwrap();
740        let dot = graphviz_dot(yaml.syntax());
741
742        // Should have node declarations (at least one "node" line in the DOT output)
743        let node_count = dot.lines().filter(|l| l.trim().starts_with("node")).count();
744        assert!(
745            node_count >= 1,
746            "expected at least one node declaration in dot output"
747        );
748    }
749}