Skip to main content

cool_diff/render/
yaml.rs

1use serde_json::Value;
2
3use crate::model::{ChildKind, DiffKind, DiffNode, DiffTree, PathSegment};
4use crate::render::{DiffRenderer, indicator};
5
6/// Controls whether the renderer emits ANSI colour codes.
7#[cfg(feature = "color")]
8#[derive(Debug, Clone, Copy, Default)]
9pub enum ColorMode {
10    /// Automatically detect terminal support.
11    ///
12    /// Uses `supports-color` to check if stdout supports ANSI colours.
13    #[default]
14    Auto,
15
16    /// Always emit ANSI colour codes.
17    Always,
18
19    /// Never emit ANSI colour codes.
20    Never,
21}
22
23/// Renders a `DiffTree` as YAML-like diff output.
24///
25/// Output uses unified diff conventions:
26/// - ` ` prefix for context lines (unchanged values, comments, structural markers)
27/// - `-` prefix for expected (what we wanted but didn't get)
28/// - `+` prefix for actual (what we got instead)
29pub struct YamlRenderer {
30    /// Maximum lines to render per side for large values.
31    ///
32    /// `None` means no truncation.
33    max_lines_per_side: Option<u32>,
34
35    /// Number of spaces per indentation level.
36    indent_width: u16,
37
38    /// Controls ANSI colour output.
39    #[cfg(feature = "color")]
40    color_mode: ColorMode,
41}
42
43impl YamlRenderer {
44    /// Default maximum lines to render per side.
45    pub const DEFAULT_MAX_LINES_PER_SIDE: u32 = 20;
46
47    /// Default number of spaces per indentation level.
48    pub const DEFAULT_INDENT_WIDTH: u16 = 2;
49
50    /// Creates a new renderer with default settings.
51    pub fn new() -> Self {
52        Self {
53            max_lines_per_side: Some(Self::DEFAULT_MAX_LINES_PER_SIDE),
54            indent_width: Self::DEFAULT_INDENT_WIDTH,
55            #[cfg(feature = "color")]
56            color_mode: ColorMode::default(),
57        }
58    }
59
60    /// Sets the maximum lines to render per side.
61    pub fn with_max_lines_per_side(mut self, max: Option<u32>) -> Self {
62        self.max_lines_per_side = max;
63        self
64    }
65
66    /// Sets the number of spaces per indentation level.
67    pub fn with_indent_width(mut self, width: u16) -> Self {
68        self.indent_width = width;
69        self
70    }
71
72    /// Sets the colour mode for ANSI output.
73    #[cfg(feature = "color")]
74    pub fn with_color_mode(mut self, mode: ColorMode) -> Self {
75        self.color_mode = mode;
76        self
77    }
78
79    /// Renders a single diff node at the given indentation depth.
80    fn render_node(&self, node: &DiffNode, indent: u16, output: &mut String) {
81        match node {
82            DiffNode::Container {
83                segment,
84                child_kind,
85                omitted_count,
86                children,
87            } => {
88                // Render the segment as a context line.
89                // Key segments need a trailing colon (e.g. `spec:`), but
90                // array segments already include their content (e.g. `- name: FOO`).
91                // Index segments include the index as a comment (e.g. `- # index 0`).
92                let label = format_segment_label(segment);
93                let suffix = if matches!(segment, PathSegment::Key(_)) {
94                    ":"
95                } else {
96                    ""
97                };
98                push_line(
99                    output,
100                    indicator::CONTEXT,
101                    indent,
102                    &format!("{label}{suffix}"),
103                );
104
105                let child_indent = indent + self.indent_width;
106
107                if *omitted_count > 0 {
108                    let unit = omitted_unit(child_kind, *omitted_count);
109                    push_line(
110                        output,
111                        indicator::CONTEXT,
112                        child_indent,
113                        &format!("# {omitted_count} {unit} omitted"),
114                    );
115                }
116
117                for child in children {
118                    render_child(self, child, indent, output);
119                }
120            }
121
122            DiffNode::Leaf { segment, kind } => {
123                render_leaf(self, segment, kind, indent, output);
124            }
125        }
126    }
127}
128
129impl Default for YamlRenderer {
130    fn default() -> Self {
131        Self::new()
132    }
133}
134
135impl DiffRenderer for YamlRenderer {
136    fn render(&self, tree: &DiffTree) -> String {
137        let mut output = String::new();
138        for node in &tree.roots {
139            // Root nodes start at indent level 0 (no leading spaces)
140            self.render_node(node, 0, &mut output);
141        }
142
143        #[cfg(feature = "color")]
144        if self.should_colorize() {
145            return self.colorize(&output);
146        }
147
148        output
149    }
150}
151
152#[cfg(feature = "color")]
153impl YamlRenderer {
154    /// Returns true if output should be colorized based on the colour mode.
155    fn should_colorize(&self) -> bool {
156        match self.color_mode {
157            ColorMode::Always => true,
158            ColorMode::Never => false,
159            ColorMode::Auto => {
160                supports_color::on(supports_color::Stream::Stdout)
161                    .is_some_and(|level| level.has_basic)
162            }
163        }
164    }
165
166    /// Colorizes each line of the plain output based on its prefix character.
167    fn colorize(&self, plain: &str) -> String {
168        use owo_colors::OwoColorize;
169
170        let mut output = String::with_capacity(plain.len());
171        for line in plain.lines() {
172            let first = line.chars().next();
173            let colored = match first {
174                Some(indicator::EXPECTED) => {
175                    format!("{colored_text}", colored_text = line.red())
176                }
177                Some(indicator::ACTUAL) => {
178                    format!("{colored_text}", colored_text = line.green())
179                }
180                _ if line.trim_start().starts_with('#') => {
181                    format!("{colored_text}", colored_text = line.bright_black())
182                }
183                _ => line.to_owned(),
184            };
185            output.push_str(&colored);
186            output.push('\n');
187        }
188        output
189    }
190}
191
192/// Renders a child node, increasing indent by the configured width.
193///
194/// Array element segments (NamedElement, Index, Unmatched) get special
195/// handling: the `- ` prefix is rendered on the context line, and children
196/// are indented from there.
197fn render_child(renderer: &YamlRenderer, node: &DiffNode, parent_indent: u16, output: &mut String) {
198    let child_indent = parent_indent + renderer.indent_width;
199    renderer.render_node(node, child_indent, output);
200}
201
202/// Renders a leaf node (a single difference).
203fn render_leaf(
204    renderer: &YamlRenderer,
205    segment: &PathSegment,
206    kind: &DiffKind,
207    indent: u16,
208    output: &mut String,
209) {
210    match kind {
211        // Changed values are always scalars (compound types produce
212        // Container nodes, not Leaf nodes). Safe to call format_scalar.
213        DiffKind::Changed { actual, expected } => {
214            // Emit an index comment for position-matched array elements
215            if let Some(comment) = index_comment(segment) {
216                push_line(output, indicator::CONTEXT, indent, &comment);
217            }
218
219            if segment.is_array() {
220                // Array element. Render as a YAML list item.
221                push_line(
222                    output,
223                    indicator::EXPECTED,
224                    indent,
225                    &format!("- {val}", val = format_scalar(expected)),
226                );
227                push_line(
228                    output,
229                    indicator::ACTUAL,
230                    indent,
231                    &format!("- {val}", val = format_scalar(actual)),
232                );
233            } else {
234                let label = format_segment_label(segment);
235                push_line(
236                    output,
237                    indicator::EXPECTED,
238                    indent,
239                    &format!("{label}: {val}", val = format_scalar(expected)),
240                );
241                push_line(
242                    output,
243                    indicator::ACTUAL,
244                    indent,
245                    &format!("{label}: {val}", val = format_scalar(actual)),
246                );
247            }
248        }
249
250        DiffKind::Missing { expected } => {
251            // Emit an index comment for position-matched array elements
252            if let Some(comment) = index_comment(segment) {
253                push_line(output, indicator::CONTEXT, indent, &comment);
254            }
255
256            if segment.is_array() {
257                // Missing array element. Render as a YAML list item.
258                render_missing_array_element(
259                    output,
260                    indicator::EXPECTED,
261                    indent,
262                    renderer.indent_width,
263                    expected,
264                    renderer.max_lines_per_side,
265                );
266            } else {
267                let label = format_segment_label(segment);
268                if is_scalar(expected) {
269                    // Scalar missing value, safe to call format_scalar
270                    push_line(
271                        output,
272                        indicator::EXPECTED,
273                        indent,
274                        &format!("{label}: {val}", val = format_scalar(expected)),
275                    );
276                } else {
277                    // Compound missing value. Render the key, then the full
278                    // expected value as `-` prefixed YAML lines.
279                    push_line(output, indicator::EXPECTED, indent, &format!("{label}:"));
280                    render_value_truncated(
281                        output,
282                        indicator::EXPECTED,
283                        indent + renderer.indent_width,
284                        renderer.indent_width,
285                        expected,
286                        renderer.max_lines_per_side,
287                    );
288                }
289            }
290        }
291
292        DiffKind::TypeMismatch {
293            actual,
294            actual_type,
295            expected,
296            expected_type,
297        } => {
298            let label = format_segment_label(segment);
299
300            // Build the content portion of each header line (before the comment)
301            let expected_header = if is_scalar(expected) {
302                format!("{label}: {val}", val = format_scalar(expected))
303            } else {
304                format!("{label}:")
305            };
306            let actual_header = if is_scalar(actual) {
307                format!("{label}: {val}", val = format_scalar(actual))
308            } else {
309                format!("{label}:")
310            };
311
312            // Pad the shorter header so the type comments align
313            let max_len = expected_header.len().max(actual_header.len());
314
315            // Render expected side
316            push_line(
317                output,
318                indicator::EXPECTED,
319                indent,
320                &format!(
321                    "{expected_header:<width$} # expected: {expected_type}",
322                    width = max_len
323                ),
324            );
325            if !is_scalar(expected) {
326                render_value_truncated(
327                    output,
328                    indicator::EXPECTED,
329                    indent + renderer.indent_width,
330                    renderer.indent_width,
331                    expected,
332                    renderer.max_lines_per_side,
333                );
334            }
335
336            // Render actual side
337            push_line(
338                output,
339                indicator::ACTUAL,
340                indent,
341                &format!(
342                    "{actual_header:<width$} # actual: {actual_type}",
343                    width = max_len
344                ),
345            );
346            if !is_scalar(actual) {
347                render_value_truncated(
348                    output,
349                    indicator::ACTUAL,
350                    indent + renderer.indent_width,
351                    renderer.indent_width,
352                    actual,
353                    renderer.max_lines_per_side,
354                );
355            }
356        }
357    }
358}
359
360/// Returns a trailing comment for Index segments (e.g. `# index 1`).
361///
362/// Returns `None` for non-Index segments.
363fn index_comment(segment: &PathSegment) -> Option<String> {
364    match segment {
365        PathSegment::Index(i) => Some(format!("# index {i}")),
366        _ => None,
367    }
368}
369
370/// Renders a missing array element as a YAML list item.
371///
372/// Scalars render as `- value`. Objects render with the first key on
373/// the same line as `- `. Applies truncation for compound values.
374fn render_missing_array_element(
375    output: &mut String,
376    prefix: char,
377    indent: u16,
378    indent_width: u16,
379    value: &Value,
380    max_lines: Option<u32>,
381) {
382    if is_scalar(value) {
383        push_line(
384            output,
385            prefix,
386            indent,
387            &format!("- {val}", val = format_scalar(value)),
388        );
389    } else {
390        let mut buf = String::new();
391        render_array_element(&mut buf, prefix, indent, indent_width, value);
392        match max_lines {
393            Some(max) => {
394                let lines: Vec<&str> = buf.lines().collect();
395                let total = lines.len() as u32;
396                if total <= max {
397                    output.push_str(&buf);
398                } else {
399                    for line in &lines[..max as usize] {
400                        output.push_str(line);
401                        output.push('\n');
402                    }
403                    let remaining = total - max;
404                    push_line(
405                        output,
406                        prefix,
407                        indent + indent_width,
408                        &format!("# {remaining} more lines"),
409                    );
410                }
411            }
412            None => {
413                output.push_str(&buf);
414            }
415        }
416    }
417}
418
419/// Returns the appropriate unit word for omitted count based on the segment
420/// type and count.
421///
422/// Object keys use "field"/"fields", array segments use "item"/"items".
423fn omitted_unit(child_kind: &ChildKind, count: u16) -> &'static str {
424    match (child_kind, count) {
425        (ChildKind::Fields, 1) => "field",
426        (ChildKind::Fields, _) => "fields",
427        (ChildKind::Items, 1) => "item",
428        (ChildKind::Items, _) => "items",
429    }
430}
431
432/// Formats a path segment as a label for rendering.
433fn format_segment_label(segment: &PathSegment) -> String {
434    match segment {
435        PathSegment::Key(key) => key.clone(),
436        PathSegment::NamedElement {
437            match_key,
438            match_value,
439        } => format!("- {match_key}: {match_value}"),
440        PathSegment::Index(i) => format!("- # index {i}"),
441        PathSegment::Unmatched => "-".to_owned(),
442    }
443}
444
445/// Renders a compound value with optional line truncation.
446///
447/// Renders into a temporary buffer, then appends to `output`. If the
448/// rendered output exceeds `max_lines`, only the first `max_lines` lines
449/// are kept and a `# N more lines` marker is appended.
450fn render_value_truncated(
451    output: &mut String,
452    prefix: char,
453    indent: u16,
454    indent_width: u16,
455    value: &Value,
456    max_lines: Option<u32>,
457) {
458    let mut buf = String::new();
459    render_value(&mut buf, prefix, indent, indent_width, value);
460
461    match max_lines {
462        Some(max) => {
463            let lines: Vec<&str> = buf.lines().collect();
464            let total = lines.len() as u32;
465
466            if total <= max {
467                output.push_str(&buf);
468            } else {
469                // Append the first max_lines lines
470                for line in &lines[..max as usize] {
471                    output.push_str(line);
472                    output.push('\n');
473                }
474                // Append the truncation marker with the same prefix
475                let remaining = total - max;
476                push_line(output, prefix, indent, &format!("# {remaining} more lines"));
477            }
478        }
479        None => {
480            output.push_str(&buf);
481        }
482    }
483}
484
485/// Renders a single key-value pair as YAML.
486///
487/// Scalars render as `key: value` on one line. Objects and arrays render
488/// `key:` followed by the recursively rendered value on subsequent lines.
489/// Also used for array element first keys via `render_array_element`,
490/// where `key` is prefixed with `- ` (e.g. `- name`).
491fn render_key_value(
492    output: &mut String,
493    prefix: char,
494    indent: u16,
495    indent_width: u16,
496    key: &str,
497    value: &Value,
498) {
499    if is_scalar(value) {
500        push_line(
501            output,
502            prefix,
503            indent,
504            &format!("{key}: {val}", val = format_scalar(value)),
505        );
506    } else {
507        push_line(output, prefix, indent, &format!("{key}:"));
508        render_value(output, prefix, indent + indent_width, indent_width, value);
509    }
510}
511
512/// Recursively renders a JSON value as YAML lines with the given prefix.
513///
514/// Used for rendering compound values in Missing and TypeMismatch diffs.
515/// Each line is prefixed with the indicator character (e.g. `-` for expected).
516fn render_value(output: &mut String, prefix: char, indent: u16, indent_width: u16, value: &Value) {
517    match value {
518        // Scalars render as a single value (caller handles the key)
519        Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {
520            push_line(output, prefix, indent, &format_scalar(value));
521        }
522
523        Value::Object(map) => {
524            for (key, val) in map {
525                render_key_value(output, prefix, indent, indent_width, key, val);
526            }
527        }
528
529        Value::Array(arr) => {
530            for elem in arr {
531                if is_scalar(elem) {
532                    push_line(
533                        output,
534                        prefix,
535                        indent,
536                        &format!("- {val}", val = format_scalar(elem)),
537                    );
538                } else {
539                    // Render first key on the same line as `- `, rest indented
540                    render_array_element(output, prefix, indent, indent_width, elem);
541                }
542            }
543        }
544    }
545}
546
547/// Renders a compound array element, placing the first object key on the
548/// same line as the `- ` marker for natural YAML formatting.
549fn render_array_element(
550    output: &mut String,
551    prefix: char,
552    indent: u16,
553    indent_width: u16,
554    value: &Value,
555) {
556    match value {
557        Value::Object(map) => {
558            let mut first = true;
559            for (key, val) in map {
560                if first {
561                    // First key goes on the `- ` line
562                    render_key_value(
563                        output,
564                        prefix,
565                        indent,
566                        indent_width,
567                        &format!("- {key}"),
568                        val,
569                    );
570                    first = false;
571                } else {
572                    // Subsequent keys are indented past the `- `
573                    render_key_value(
574                        output,
575                        prefix,
576                        indent + indent_width,
577                        indent_width,
578                        key,
579                        val,
580                    );
581                }
582            }
583        }
584
585        // Non-object array elements (nested arrays)
586        _ => {
587            push_line(output, prefix, indent, "-");
588            render_value(output, prefix, indent + indent_width, indent_width, value);
589        }
590    }
591}
592
593/// Builds a line string from prefix, indentation, and content.
594fn build_line(prefix: char, indent: u16, content: &str) -> String {
595    let mut line = String::with_capacity(1 + indent as usize + content.len());
596    line.push(prefix);
597    for _ in 0..indent {
598        line.push(' ');
599    }
600    line.push_str(content);
601    line
602}
603
604/// Pushes a single line to the output with the given prefix and indentation.
605fn push_line(output: &mut String, prefix: char, indent: u16, content: &str) {
606    let line = build_line(prefix, indent, content);
607    output.push_str(&line);
608    output.push('\n');
609}
610
611
612/// Formats a JSON value as a YAML scalar.
613fn format_scalar(value: &Value) -> String {
614    match value {
615        Value::Null => "null".to_owned(),
616        Value::Bool(b) => b.to_string(),
617        Value::Number(n) => n.to_string(),
618        Value::String(s) => {
619            if needs_yaml_quoting(s) {
620                let escaped = s.replace('\\', "\\\\").replace('"', "\\\"");
621                format!("\"{escaped}\"")
622            } else {
623                s.clone()
624            }
625        }
626        Value::Array(_) | Value::Object(_) => {
627            unreachable!("format_scalar called with compound value")
628        }
629    }
630}
631
632/// Returns true if a string needs quoting in YAML.
633fn needs_yaml_quoting(s: &str) -> bool {
634    if s.is_empty() {
635        return true;
636    }
637
638    // Values that YAML would interpret as non-strings
639    const SPECIAL: &[&str] = &[
640        "true", "false", "null", "yes", "no", "on", "off", "True", "False", "Null", "Yes", "No",
641        "On", "Off", "TRUE", "FALSE", "NULL", "YES", "NO", "ON", "OFF",
642    ];
643    if SPECIAL.contains(&s) {
644        return true;
645    }
646
647    // Strings that look like numbers
648    if s.parse::<f64>().is_ok() {
649        return true;
650    }
651
652    // Strings with special YAML characters
653    s.contains(':')
654        || s.contains('#')
655        || s.contains('\n')
656        || s.starts_with(' ')
657        || s.ends_with(' ')
658        || s.starts_with('{')
659        || s.starts_with('[')
660        || s.starts_with('*')
661        || s.starts_with('&')
662        || s.starts_with('!')
663        || s.starts_with('|')
664        || s.starts_with('>')
665}
666
667/// Returns true if a value is a scalar (not an object or array).
668fn is_scalar(value: &Value) -> bool {
669    matches!(
670        value,
671        Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_)
672    )
673}
674
675#[cfg(test)]
676mod tests {
677    use super::*;
678    use crate::{DiffConfig, diff};
679    use indoc::indoc;
680    use serde_json::json;
681
682    fn render(actual: &Value, expected: &Value) -> String {
683        let config = DiffConfig::default();
684        let tree = diff(actual, expected, &config).expect("diff with valid inputs");
685        YamlRenderer::new().render(&tree)
686    }
687
688    #[test]
689    fn scalar_changed_renders_minus_plus() {
690        let output = render(
691            &json!({"name": "actual_value"}),
692            &json!({"name": "expected_value"}),
693        );
694        assert_eq!(
695            output,
696            indoc! {"
697                -name: expected_value
698                +name: actual_value
699            "}
700        );
701    }
702
703    #[test]
704    fn nested_scalar_changed() {
705        let output = render(
706            &json!({"a": {"b": "actual"}}),
707            &json!({"a": {"b": "expected"}}),
708        );
709        assert_eq!(
710            output,
711            indoc! {"
712                 a:
713                -  b: expected
714                +  b: actual
715            "}
716        );
717    }
718
719    #[test]
720    fn missing_scalar_key() {
721        let output = render(&json!({"a": 1}), &json!({"a": 1, "b": 2}));
722        assert_eq!(
723            output,
724            indoc! {"
725                -b: 2
726            "}
727        );
728    }
729
730    #[test]
731    fn equal_values_render_empty() {
732        let output = render(&json!({"a": 1}), &json!({"a": 1}));
733        assert_eq!(output, "");
734    }
735
736    #[test]
737    #[allow(clippy::approx_constant)]
738    fn type_mismatch_scalar() {
739        // actual has number 42, expected has string "42".
740        // "42" is quoted because it looks like a number in YAML.
741        // Comments are aligned by padding the shorter line.
742        let output = render(&json!({"a": 42}), &json!({"a": "42"}));
743        assert_eq!(
744            output,
745            indoc! {r#"
746                -a: "42" # expected: string
747                +a: 42   # actual: number
748            "#}
749        );
750    }
751
752    #[test]
753    fn type_mismatch_null_vs_object() {
754        let output = render(&json!({"a": null}), &json!({"a": {"b": 1}}));
755        assert_eq!(
756            output,
757            indoc! {"
758                -a:      # expected: object
759                -  b: 1
760                +a: null # actual: null
761            "}
762        );
763    }
764
765    #[test]
766    fn missing_object_subtree() {
767        let output = render(&json!({"a": 1}), &json!({"a": 1, "b": {"x": 1, "y": 2}}));
768        assert_eq!(
769            output,
770            indoc! {"
771                -b:
772                -  x: 1
773                -  y: 2
774            "}
775        );
776    }
777
778    #[test]
779    fn missing_array_subtree() {
780        let output = render(&json!({"a": 1}), &json!({"a": 1, "items": [1, 2, 3]}));
781        assert_eq!(
782            output,
783            indoc! {"
784                -items:
785                -  - 1
786                -  - 2
787                -  - 3
788            "}
789        );
790    }
791
792    #[test]
793    fn missing_nested_object_in_array() {
794        let output = render(
795            &json!({"a": 1}),
796            &json!({"a": 1, "items": [{"name": "foo", "value": "bar"}]}),
797        );
798        assert_eq!(
799            output,
800            indoc! {"
801                -items:
802                -  - name: foo
803                -    value: bar
804            "}
805        );
806    }
807
808    #[test]
809    fn missing_subtree_truncated() {
810        // Use a renderer with max 2 lines per side.
811        // Keys are alphabetically ordered (serde_json uses BTreeMap).
812        let config = DiffConfig::default();
813        let actual = json!({"a": 1});
814        let expected = json!({"a": 1, "b": {"p": 1, "q": 2, "r": 3, "s": 4}});
815        let tree = diff(&actual, &expected, &config).expect("diff with valid inputs");
816        let output = YamlRenderer::new()
817            .with_max_lines_per_side(Some(2))
818            .render(&tree);
819        assert_eq!(
820            output,
821            indoc! {"
822                -b:
823                -  p: 1
824                -  q: 2
825                -  # 2 more lines
826            "}
827        );
828    }
829
830    #[test]
831    fn truncation_disabled_renders_all_lines() {
832        let config = DiffConfig::default();
833        let actual = json!({"a": 1});
834        let expected = json!({"a": 1, "b": {"x": 1, "y": 2, "z": 3}});
835        let tree = diff(&actual, &expected, &config).expect("diff with valid inputs");
836        let output = YamlRenderer::new()
837            .with_max_lines_per_side(None)
838            .render(&tree);
839        assert_eq!(
840            output,
841            indoc! {"
842                -b:
843                -  x: 1
844                -  y: 2
845                -  z: 3
846            "}
847        );
848    }
849
850    #[test]
851    fn omitted_fields_comment() {
852        // inner object has 3 keys, expected checks 1 that differs. 2 fields omitted.
853        let output = render(
854            &json!({"outer": {"a": 1, "b": 2, "c": 3}}),
855            &json!({"outer": {"a": 99}}),
856        );
857        assert_eq!(
858            output,
859            indoc! {"
860                 outer:
861                   # 2 fields omitted
862                -  a: 99
863                +  a: 1
864            "}
865        );
866    }
867}