use serde_json::Value;
use crate::model::{ChildKind, DiffKind, DiffNode, DiffTree, PathSegment};
use crate::render::{DiffRenderer, indicator};
#[cfg(feature = "color")]
#[derive(Debug, Clone, Copy, Default)]
pub enum ColorMode {
#[default]
Auto,
Always,
Never,
}
pub struct YamlRenderer {
max_lines_per_side: Option<u32>,
indent_width: u16,
#[cfg(feature = "color")]
color_mode: ColorMode,
}
impl YamlRenderer {
pub const DEFAULT_MAX_LINES_PER_SIDE: u32 = 20;
pub const DEFAULT_INDENT_WIDTH: u16 = 2;
pub fn new() -> Self {
Self {
max_lines_per_side: Some(Self::DEFAULT_MAX_LINES_PER_SIDE),
indent_width: Self::DEFAULT_INDENT_WIDTH,
#[cfg(feature = "color")]
color_mode: ColorMode::default(),
}
}
pub fn with_max_lines_per_side(mut self, max: Option<u32>) -> Self {
self.max_lines_per_side = max;
self
}
pub fn with_indent_width(mut self, width: u16) -> Self {
self.indent_width = width;
self
}
#[cfg(feature = "color")]
pub fn with_color_mode(mut self, mode: ColorMode) -> Self {
self.color_mode = mode;
self
}
fn render_node(&self, node: &DiffNode, indent: u16, output: &mut String) {
match node {
DiffNode::Container {
segment,
child_kind,
omitted_count,
children,
} => {
let label = format_segment_label(segment);
let suffix = if matches!(segment, PathSegment::Key(_)) {
":"
} else {
""
};
push_line(
output,
indicator::CONTEXT,
indent,
&format!("{label}{suffix}"),
);
let child_indent = indent + self.indent_width;
if *omitted_count > 0 {
let unit = omitted_unit(child_kind, *omitted_count);
push_line(
output,
indicator::CONTEXT,
child_indent,
&format!("# {omitted_count} {unit} omitted"),
);
}
for child in children {
render_child(self, child, indent, output);
}
}
DiffNode::Leaf { segment, kind } => {
render_leaf(self, segment, kind, indent, output);
}
}
}
}
impl Default for YamlRenderer {
fn default() -> Self {
Self::new()
}
}
impl DiffRenderer for YamlRenderer {
fn render(&self, tree: &DiffTree) -> String {
let mut output = String::new();
for node in &tree.roots {
self.render_node(node, 0, &mut output);
}
#[cfg(feature = "color")]
if self.should_colorize() {
return self.colorize(&output);
}
output
}
}
#[cfg(feature = "color")]
impl YamlRenderer {
fn should_colorize(&self) -> bool {
match self.color_mode {
ColorMode::Always => true,
ColorMode::Never => false,
ColorMode::Auto => {
supports_color::on(supports_color::Stream::Stdout)
.is_some_and(|level| level.has_basic)
}
}
}
fn colorize(&self, plain: &str) -> String {
use owo_colors::OwoColorize;
let mut output = String::with_capacity(plain.len());
for line in plain.lines() {
let first = line.chars().next();
let colored = match first {
Some(indicator::EXPECTED) => {
format!("{colored_text}", colored_text = line.red())
}
Some(indicator::ACTUAL) => {
format!("{colored_text}", colored_text = line.green())
}
_ if line.trim_start().starts_with('#') => {
format!("{colored_text}", colored_text = line.bright_black())
}
_ => line.to_owned(),
};
output.push_str(&colored);
output.push('\n');
}
output
}
}
fn render_child(renderer: &YamlRenderer, node: &DiffNode, parent_indent: u16, output: &mut String) {
let child_indent = parent_indent + renderer.indent_width;
renderer.render_node(node, child_indent, output);
}
fn render_leaf(
renderer: &YamlRenderer,
segment: &PathSegment,
kind: &DiffKind,
indent: u16,
output: &mut String,
) {
match kind {
DiffKind::Changed { actual, expected } => {
if let Some(comment) = index_comment(segment) {
push_line(output, indicator::CONTEXT, indent, &comment);
}
if segment.is_array() {
push_line(
output,
indicator::EXPECTED,
indent,
&format!("- {val}", val = format_scalar(expected)),
);
push_line(
output,
indicator::ACTUAL,
indent,
&format!("- {val}", val = format_scalar(actual)),
);
} else {
let label = format_segment_label(segment);
push_line(
output,
indicator::EXPECTED,
indent,
&format!("{label}: {val}", val = format_scalar(expected)),
);
push_line(
output,
indicator::ACTUAL,
indent,
&format!("{label}: {val}", val = format_scalar(actual)),
);
}
}
DiffKind::Missing { expected } => {
if let Some(comment) = index_comment(segment) {
push_line(output, indicator::CONTEXT, indent, &comment);
}
if segment.is_array() {
render_missing_array_element(
output,
indicator::EXPECTED,
indent,
renderer.indent_width,
expected,
renderer.max_lines_per_side,
);
} else {
let label = format_segment_label(segment);
if is_scalar(expected) {
push_line(
output,
indicator::EXPECTED,
indent,
&format!("{label}: {val}", val = format_scalar(expected)),
);
} else {
push_line(output, indicator::EXPECTED, indent, &format!("{label}:"));
render_value_truncated(
output,
indicator::EXPECTED,
indent + renderer.indent_width,
renderer.indent_width,
expected,
renderer.max_lines_per_side,
);
}
}
}
DiffKind::TypeMismatch {
actual,
actual_type,
expected,
expected_type,
} => {
let label = format_segment_label(segment);
let expected_header = if is_scalar(expected) {
format!("{label}: {val}", val = format_scalar(expected))
} else {
format!("{label}:")
};
let actual_header = if is_scalar(actual) {
format!("{label}: {val}", val = format_scalar(actual))
} else {
format!("{label}:")
};
let max_len = expected_header.len().max(actual_header.len());
push_line(
output,
indicator::EXPECTED,
indent,
&format!(
"{expected_header:<width$} # expected: {expected_type}",
width = max_len
),
);
if !is_scalar(expected) {
render_value_truncated(
output,
indicator::EXPECTED,
indent + renderer.indent_width,
renderer.indent_width,
expected,
renderer.max_lines_per_side,
);
}
push_line(
output,
indicator::ACTUAL,
indent,
&format!(
"{actual_header:<width$} # actual: {actual_type}",
width = max_len
),
);
if !is_scalar(actual) {
render_value_truncated(
output,
indicator::ACTUAL,
indent + renderer.indent_width,
renderer.indent_width,
actual,
renderer.max_lines_per_side,
);
}
}
}
}
fn index_comment(segment: &PathSegment) -> Option<String> {
match segment {
PathSegment::Index(i) => Some(format!("# index {i}")),
_ => None,
}
}
fn render_missing_array_element(
output: &mut String,
prefix: char,
indent: u16,
indent_width: u16,
value: &Value,
max_lines: Option<u32>,
) {
if is_scalar(value) {
push_line(
output,
prefix,
indent,
&format!("- {val}", val = format_scalar(value)),
);
} else {
let mut buf = String::new();
render_array_element(&mut buf, prefix, indent, indent_width, value);
match max_lines {
Some(max) => {
let lines: Vec<&str> = buf.lines().collect();
let total = lines.len() as u32;
if total <= max {
output.push_str(&buf);
} else {
for line in &lines[..max as usize] {
output.push_str(line);
output.push('\n');
}
let remaining = total - max;
push_line(
output,
prefix,
indent + indent_width,
&format!("# {remaining} more lines"),
);
}
}
None => {
output.push_str(&buf);
}
}
}
}
fn omitted_unit(child_kind: &ChildKind, count: u16) -> &'static str {
match (child_kind, count) {
(ChildKind::Fields, 1) => "field",
(ChildKind::Fields, _) => "fields",
(ChildKind::Items, 1) => "item",
(ChildKind::Items, _) => "items",
}
}
fn format_segment_label(segment: &PathSegment) -> String {
match segment {
PathSegment::Key(key) => key.clone(),
PathSegment::NamedElement {
match_key,
match_value,
} => format!("- {match_key}: {match_value}"),
PathSegment::Index(i) => format!("- # index {i}"),
PathSegment::Unmatched => "-".to_owned(),
}
}
fn render_value_truncated(
output: &mut String,
prefix: char,
indent: u16,
indent_width: u16,
value: &Value,
max_lines: Option<u32>,
) {
let mut buf = String::new();
render_value(&mut buf, prefix, indent, indent_width, value);
match max_lines {
Some(max) => {
let lines: Vec<&str> = buf.lines().collect();
let total = lines.len() as u32;
if total <= max {
output.push_str(&buf);
} else {
for line in &lines[..max as usize] {
output.push_str(line);
output.push('\n');
}
let remaining = total - max;
push_line(output, prefix, indent, &format!("# {remaining} more lines"));
}
}
None => {
output.push_str(&buf);
}
}
}
fn render_key_value(
output: &mut String,
prefix: char,
indent: u16,
indent_width: u16,
key: &str,
value: &Value,
) {
if is_scalar(value) {
push_line(
output,
prefix,
indent,
&format!("{key}: {val}", val = format_scalar(value)),
);
} else {
push_line(output, prefix, indent, &format!("{key}:"));
render_value(output, prefix, indent + indent_width, indent_width, value);
}
}
fn render_value(output: &mut String, prefix: char, indent: u16, indent_width: u16, value: &Value) {
match value {
Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {
push_line(output, prefix, indent, &format_scalar(value));
}
Value::Object(map) => {
for (key, val) in map {
render_key_value(output, prefix, indent, indent_width, key, val);
}
}
Value::Array(arr) => {
for elem in arr {
if is_scalar(elem) {
push_line(
output,
prefix,
indent,
&format!("- {val}", val = format_scalar(elem)),
);
} else {
render_array_element(output, prefix, indent, indent_width, elem);
}
}
}
}
}
fn render_array_element(
output: &mut String,
prefix: char,
indent: u16,
indent_width: u16,
value: &Value,
) {
match value {
Value::Object(map) => {
let mut first = true;
for (key, val) in map {
if first {
render_key_value(
output,
prefix,
indent,
indent_width,
&format!("- {key}"),
val,
);
first = false;
} else {
render_key_value(
output,
prefix,
indent + indent_width,
indent_width,
key,
val,
);
}
}
}
_ => {
push_line(output, prefix, indent, "-");
render_value(output, prefix, indent + indent_width, indent_width, value);
}
}
}
fn build_line(prefix: char, indent: u16, content: &str) -> String {
let mut line = String::with_capacity(1 + indent as usize + content.len());
line.push(prefix);
for _ in 0..indent {
line.push(' ');
}
line.push_str(content);
line
}
fn push_line(output: &mut String, prefix: char, indent: u16, content: &str) {
let line = build_line(prefix, indent, content);
output.push_str(&line);
output.push('\n');
}
fn format_scalar(value: &Value) -> String {
match value {
Value::Null => "null".to_owned(),
Value::Bool(b) => b.to_string(),
Value::Number(n) => n.to_string(),
Value::String(s) => {
if needs_yaml_quoting(s) {
let escaped = s.replace('\\', "\\\\").replace('"', "\\\"");
format!("\"{escaped}\"")
} else {
s.clone()
}
}
Value::Array(_) | Value::Object(_) => {
unreachable!("format_scalar called with compound value")
}
}
}
fn needs_yaml_quoting(s: &str) -> bool {
if s.is_empty() {
return true;
}
const SPECIAL: &[&str] = &[
"true", "false", "null", "yes", "no", "on", "off", "True", "False", "Null", "Yes", "No",
"On", "Off", "TRUE", "FALSE", "NULL", "YES", "NO", "ON", "OFF",
];
if SPECIAL.contains(&s) {
return true;
}
if s.parse::<f64>().is_ok() {
return true;
}
s.contains(':')
|| s.contains('#')
|| s.contains('\n')
|| s.starts_with(' ')
|| s.ends_with(' ')
|| s.starts_with('{')
|| s.starts_with('[')
|| s.starts_with('*')
|| s.starts_with('&')
|| s.starts_with('!')
|| s.starts_with('|')
|| s.starts_with('>')
}
fn is_scalar(value: &Value) -> bool {
matches!(
value,
Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_)
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{DiffConfig, diff};
use indoc::indoc;
use serde_json::json;
fn render(actual: &Value, expected: &Value) -> String {
let config = DiffConfig::default();
let tree = diff(actual, expected, &config).expect("diff with valid inputs");
YamlRenderer::new().render(&tree)
}
#[test]
fn scalar_changed_renders_minus_plus() {
let output = render(
&json!({"name": "actual_value"}),
&json!({"name": "expected_value"}),
);
assert_eq!(
output,
indoc! {"
-name: expected_value
+name: actual_value
"}
);
}
#[test]
fn nested_scalar_changed() {
let output = render(
&json!({"a": {"b": "actual"}}),
&json!({"a": {"b": "expected"}}),
);
assert_eq!(
output,
indoc! {"
a:
- b: expected
+ b: actual
"}
);
}
#[test]
fn missing_scalar_key() {
let output = render(&json!({"a": 1}), &json!({"a": 1, "b": 2}));
assert_eq!(
output,
indoc! {"
-b: 2
"}
);
}
#[test]
fn equal_values_render_empty() {
let output = render(&json!({"a": 1}), &json!({"a": 1}));
assert_eq!(output, "");
}
#[test]
#[allow(clippy::approx_constant)]
fn type_mismatch_scalar() {
let output = render(&json!({"a": 42}), &json!({"a": "42"}));
assert_eq!(
output,
indoc! {r#"
-a: "42" # expected: string
+a: 42 # actual: number
"#}
);
}
#[test]
fn type_mismatch_null_vs_object() {
let output = render(&json!({"a": null}), &json!({"a": {"b": 1}}));
assert_eq!(
output,
indoc! {"
-a: # expected: object
- b: 1
+a: null # actual: null
"}
);
}
#[test]
fn missing_object_subtree() {
let output = render(&json!({"a": 1}), &json!({"a": 1, "b": {"x": 1, "y": 2}}));
assert_eq!(
output,
indoc! {"
-b:
- x: 1
- y: 2
"}
);
}
#[test]
fn missing_array_subtree() {
let output = render(&json!({"a": 1}), &json!({"a": 1, "items": [1, 2, 3]}));
assert_eq!(
output,
indoc! {"
-items:
- - 1
- - 2
- - 3
"}
);
}
#[test]
fn missing_nested_object_in_array() {
let output = render(
&json!({"a": 1}),
&json!({"a": 1, "items": [{"name": "foo", "value": "bar"}]}),
);
assert_eq!(
output,
indoc! {"
-items:
- - name: foo
- value: bar
"}
);
}
#[test]
fn missing_subtree_truncated() {
let config = DiffConfig::default();
let actual = json!({"a": 1});
let expected = json!({"a": 1, "b": {"p": 1, "q": 2, "r": 3, "s": 4}});
let tree = diff(&actual, &expected, &config).expect("diff with valid inputs");
let output = YamlRenderer::new()
.with_max_lines_per_side(Some(2))
.render(&tree);
assert_eq!(
output,
indoc! {"
-b:
- p: 1
- q: 2
- # 2 more lines
"}
);
}
#[test]
fn truncation_disabled_renders_all_lines() {
let config = DiffConfig::default();
let actual = json!({"a": 1});
let expected = json!({"a": 1, "b": {"x": 1, "y": 2, "z": 3}});
let tree = diff(&actual, &expected, &config).expect("diff with valid inputs");
let output = YamlRenderer::new()
.with_max_lines_per_side(None)
.render(&tree);
assert_eq!(
output,
indoc! {"
-b:
- x: 1
- y: 2
- z: 3
"}
);
}
#[test]
fn omitted_fields_comment() {
let output = render(
&json!({"outer": {"a": 1, "b": 2, "c": 3}}),
&json!({"outer": {"a": 99}}),
);
assert_eq!(
output,
indoc! {"
outer:
# 2 fields omitted
- a: 99
+ a: 1
"}
);
}
}