1use serde_json::Value;
2
3use crate::model::{ChildKind, DiffKind, DiffNode, DiffTree, PathSegment};
4use crate::render::{DiffRenderer, indicator};
5
6#[cfg(feature = "color")]
8#[derive(Debug, Clone, Copy, Default)]
9pub enum ColorMode {
10 #[default]
14 Auto,
15
16 Always,
18
19 Never,
21}
22
23pub struct YamlRenderer {
30 max_lines_per_side: Option<u32>,
34
35 indent_width: u16,
37
38 #[cfg(feature = "color")]
40 color_mode: ColorMode,
41}
42
43impl YamlRenderer {
44 pub const DEFAULT_MAX_LINES_PER_SIDE: u32 = 20;
46
47 pub const DEFAULT_INDENT_WIDTH: u16 = 2;
49
50 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 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 pub fn with_indent_width(mut self, width: u16) -> Self {
68 self.indent_width = width;
69 self
70 }
71
72 #[cfg(feature = "color")]
74 pub fn with_color_mode(mut self, mode: ColorMode) -> Self {
75 self.color_mode = mode;
76 self
77 }
78
79 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 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 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 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 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
192fn 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
202fn render_leaf(
204 renderer: &YamlRenderer,
205 segment: &PathSegment,
206 kind: &DiffKind,
207 indent: u16,
208 output: &mut String,
209) {
210 match kind {
211 DiffKind::Changed { actual, expected } => {
214 if let Some(comment) = index_comment(segment) {
216 push_line(output, indicator::CONTEXT, indent, &comment);
217 }
218
219 if segment.is_array() {
220 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 if let Some(comment) = index_comment(segment) {
253 push_line(output, indicator::CONTEXT, indent, &comment);
254 }
255
256 if segment.is_array() {
257 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 push_line(
271 output,
272 indicator::EXPECTED,
273 indent,
274 &format!("{label}: {val}", val = format_scalar(expected)),
275 );
276 } else {
277 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 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 let max_len = expected_header.len().max(actual_header.len());
314
315 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 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
360fn index_comment(segment: &PathSegment) -> Option<String> {
364 match segment {
365 PathSegment::Index(i) => Some(format!("# index {i}")),
366 _ => None,
367 }
368}
369
370fn 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
419fn 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
432fn 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
445fn 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 for line in &lines[..max as usize] {
471 output.push_str(line);
472 output.push('\n');
473 }
474 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
485fn 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
512fn render_value(output: &mut String, prefix: char, indent: u16, indent_width: u16, value: &Value) {
517 match value {
518 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_array_element(output, prefix, indent, indent_width, elem);
541 }
542 }
543 }
544 }
545}
546
547fn 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 render_key_value(
563 output,
564 prefix,
565 indent,
566 indent_width,
567 &format!("- {key}"),
568 val,
569 );
570 first = false;
571 } else {
572 render_key_value(
574 output,
575 prefix,
576 indent + indent_width,
577 indent_width,
578 key,
579 val,
580 );
581 }
582 }
583 }
584
585 _ => {
587 push_line(output, prefix, indent, "-");
588 render_value(output, prefix, indent + indent_width, indent_width, value);
589 }
590 }
591}
592
593fn 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
604fn 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
612fn 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
632fn needs_yaml_quoting(s: &str) -> bool {
634 if s.is_empty() {
635 return true;
636 }
637
638 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 if s.parse::<f64>().is_ok() {
649 return true;
650 }
651
652 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
667fn 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 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 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 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}