pub(crate) fn apply_yaml_mapping_diff(
mapping: &yaml_edit::Mapping,
old: &serde_json::Value,
new: &serde_json::Value,
) -> anyhow::Result<bool> {
if old == new {
return Ok(true);
}
let (Some(old_map), Some(new_map)) = (old.as_object(), new.as_object()) else {
return Ok(true);
};
let mut all_applied = true;
let removed: Vec<String> = old_map
.keys()
.filter(|k| !new_map.contains_key(k.as_str()))
.cloned()
.collect();
for k in &removed {
mapping.remove(k.as_str());
}
for (key, new_val) in new_map {
if let Some(old_val) = old_map.get(key) {
if old_val == new_val {
continue;
}
match (old_val, new_val) {
(serde_json::Value::Object(_), serde_json::Value::Object(_)) => {
if let Some(child) = mapping.get_mapping(key.as_str()) {
if !apply_yaml_mapping_diff(&child, old_val, new_val)? {
all_applied = false;
}
} else {
mapping.set(key.as_str(), json_to_yaml_mapping(new_val)?);
}
}
(serde_json::Value::Array(old_arr), serde_json::Value::Array(new_arr)) => {
if let Some(seq) = mapping.get_sequence(key.as_str()) {
if old_arr.len() == new_arr.len() {
if !apply_yaml_sequence_diff(&seq, old_arr, new_arr)? {
all_applied = false;
}
} else if !apply_yaml_sequence_resize(
&seq,
old_arr,
new_arr,
mapping,
key.as_str(),
new_val,
) {
all_applied = false;
}
} else {
mapping.set(key.as_str(), json_to_yaml_node(new_val)?);
}
}
_ => {
if let Some(new_str) = new_val.as_str()
&& let Some(existing) = mapping.get(key.as_str())
&& let Some(scalar) = existing.as_scalar()
&& scalar.is_quoted()
{
set_quoted_scalar(scalar, new_str);
} else {
mapping.set(key.as_str(), json_to_yaml_node(new_val)?);
}
}
}
} else {
if new_val.is_object() {
let empty = yaml_edit::Mapping::new();
mapping.set(key.as_str(), &empty);
if let Some(nested) = mapping.get_mapping(key.as_str()) {
if let Some(obj) = new_val.as_object() {
for (k, v) in obj {
nested.set(k.as_str(), json_to_yaml_node(v)?);
}
}
} else {
mapping.set(key.as_str(), json_to_yaml_mapping(new_val)?);
}
} else {
mapping.set(key.as_str(), json_to_yaml_node(new_val)?);
}
}
}
Ok(all_applied)
}
pub(super) fn apply_yaml_sequence_diff(
seq: &yaml_edit::Sequence,
old_arr: &[serde_json::Value],
new_arr: &[serde_json::Value],
) -> anyhow::Result<bool> {
let mut all_applied = true;
for (i, (o, n)) in old_arr.iter().zip(new_arr.iter()).enumerate() {
if o == n {
continue;
}
match (o, n) {
(serde_json::Value::Object(_), serde_json::Value::Object(_)) => {
if let Some(node) = seq.get(i)
&& let Some(child_mapping) = node.as_mapping()
{
if !apply_yaml_mapping_diff(child_mapping, o, n)? {
all_applied = false;
}
continue;
}
if !try_sequence_set(seq, i, n)? {
all_applied = false;
}
}
_ => {
if !try_sequence_set(seq, i, n)? {
all_applied = false;
}
}
}
}
Ok(all_applied)
}
fn set_quoted_scalar(scalar: &yaml_edit::Scalar, new_str: &str) {
let raw = scalar.value();
let quote_char = raw.chars().next().unwrap_or('"');
let quoted = if quote_char == '\'' {
format!("'{}'", new_str.replace('\'', "''"))
} else {
format!("\"{}\"", new_str.replace('\\', "\\\\").replace('"', "\\\""))
};
scalar.set_value("ed);
}
fn try_sequence_set(
seq: &yaml_edit::Sequence,
index: usize,
new_val: &serde_json::Value,
) -> anyhow::Result<bool> {
if seq.get(index).is_some_and(|n| n.as_alias().is_some()) {
return Ok(false);
}
if let Some(new_str) = new_val.as_str()
&& let Some(node) = seq.get(index)
&& let Some(scalar) = node.as_scalar()
&& scalar.is_quoted()
{
set_quoted_scalar(scalar, new_str);
return Ok(true);
}
Ok(seq.set(index, json_to_yaml_node(new_val)?))
}
fn apply_yaml_sequence_resize(
seq: &yaml_edit::Sequence,
old_arr: &[serde_json::Value],
new_arr: &[serde_json::Value],
_mapping: &yaml_edit::Mapping,
_key: &str,
_new_val: &serde_json::Value,
) -> bool {
if new_arr.len() < old_arr.len() && try_remove_subsequence(seq, old_arr, new_arr) {
return true;
}
false
}
pub(super) fn try_remove_subsequence(
seq: &yaml_edit::Sequence,
old_arr: &[serde_json::Value],
new_arr: &[serde_json::Value],
) -> bool {
let new_len = new_arr.len();
let mut remove_indices = Vec::new();
let mut ni = 0;
for (oi, old_item) in old_arr.iter().enumerate() {
if ni < new_len && *old_item == new_arr[ni] {
ni += 1;
} else {
remove_indices.push(oi);
}
}
if ni != new_len {
return false;
}
for &idx in remove_indices.iter().rev() {
seq.remove(idx);
}
true
}
pub(crate) fn rewrite_yaml_alias_object_edits(
text: &str,
file: &yaml_edit::YamlFile,
old: &serde_json::Value,
new: &serde_json::Value,
) -> anyhow::Result<Option<String>> {
let Some(doc) = file.document() else {
return Ok(None);
};
let mut rewrites = Vec::new();
let mut seq_alias_seen = std::collections::HashMap::new();
let mut map_alias_seen = std::collections::HashMap::new();
if let Some(mapping) = doc.as_mapping() {
collect_mapping_alias_rewrites(
&mapping,
old,
new,
&mut seq_alias_seen,
&mut map_alias_seen,
&mut rewrites,
)?;
} else if let Some(seq) = doc.as_sequence()
&& let (Some(old_arr), Some(new_arr)) = (old.as_array(), new.as_array())
{
collect_sequence_alias_rewrites(
&seq,
old_arr,
new_arr,
&mut seq_alias_seen,
&mut map_alias_seen,
&mut rewrites,
)?;
}
if rewrites.is_empty() {
return Ok(None);
}
apply_alias_line_rewrites(text, &rewrites)
}
fn collect_mapping_alias_rewrites(
mapping: &yaml_edit::Mapping,
old: &serde_json::Value,
new: &serde_json::Value,
seq_alias_seen: &mut std::collections::HashMap<String, usize>,
map_alias_seen: &mut std::collections::HashMap<(String, String), usize>,
out: &mut Vec<AliasLineRewrite>,
) -> anyhow::Result<()> {
let (Some(old_map), Some(new_map)) = (old.as_object(), new.as_object()) else {
return Ok(());
};
for (key, new_val) in new_map {
let Some(old_val) = old_map.get(key) else {
continue;
};
if let (serde_json::Value::Array(old_arr), serde_json::Value::Array(new_arr)) =
(old_val, new_val)
&& let Some(seq) = mapping.get_sequence(key.as_str())
{
collect_sequence_alias_rewrites(
&seq,
old_arr,
new_arr,
seq_alias_seen,
map_alias_seen,
out,
)?;
}
if let (serde_json::Value::Object(_), serde_json::Value::Object(_)) = (old_val, new_val)
&& let Some(child) = mapping.get_mapping(key.as_str())
{
collect_mapping_alias_rewrites(
&child,
old_val,
new_val,
seq_alias_seen,
map_alias_seen,
out,
)?;
continue;
}
if let Some(node) = mapping.get(key.as_str())
&& let Some(alias) = node.as_alias()
{
let name = alias.name();
let occ = map_alias_seen
.entry((key.clone(), name.clone()))
.or_insert(0);
let this = *occ;
*occ += 1;
if old_val != new_val
&& let Some(rewrite) =
alias_object_rewrite(Some(node), Some(key), old_val, new_val, Some(this))?
{
out.push(rewrite);
}
}
}
Ok(())
}
fn collect_sequence_alias_rewrites(
seq: &yaml_edit::Sequence,
old_arr: &[serde_json::Value],
new_arr: &[serde_json::Value],
seq_alias_seen: &mut std::collections::HashMap<String, usize>,
map_alias_seen: &mut std::collections::HashMap<(String, String), usize>,
out: &mut Vec<AliasLineRewrite>,
) -> anyhow::Result<()> {
for (i, old_val) in old_arr.iter().enumerate() {
let Some(node) = seq.get(i) else {
continue;
};
if let Some(alias) = node.as_alias() {
let name = alias.name();
let occ = seq_alias_seen.entry(name.clone()).or_insert(0);
let this = *occ;
*occ += 1;
let Some(new_val) = new_arr.get(i) else {
continue;
};
if old_val == new_val {
continue;
}
if let Some(rewrite) =
alias_object_rewrite(Some(node), None, old_val, new_val, Some(this))?
{
out.push(rewrite);
}
} else if let Some(child) = node.as_mapping()
&& let (Some(new_val), serde_json::Value::Object(_)) = (new_arr.get(i), old_val)
&& new_val.is_object()
{
collect_mapping_alias_rewrites(
child,
old_val,
new_val,
seq_alias_seen,
map_alias_seen,
out,
)?;
}
}
Ok(())
}
fn alias_object_rewrite(
node: Option<yaml_edit::YamlNode>,
key: Option<&str>,
old: &serde_json::Value,
new: &serde_json::Value,
seq_occurrence: Option<usize>,
) -> anyhow::Result<Option<AliasLineRewrite>> {
let Some(alias) = node.as_ref().and_then(yaml_edit::YamlNode::as_alias) else {
return Ok(None);
};
let name = alias.name();
if !is_safe_yaml_plain_ident(&name) {
return Ok(None);
}
if let Some(key) = key
&& !is_safe_yaml_plain_ident(key)
{
return Ok(None);
}
let Some(new_map) = new.as_object() else {
return Ok(None);
};
let merge = old.as_object().is_some_and(|old_map| {
old_map.keys().all(|k| new_map.contains_key(k))
&& new_map.iter().any(|(k, v)| old_map.get(k) != Some(v))
});
let body = if merge {
let mut overrides = serde_json::Map::new();
if let Some(old_map) = old.as_object() {
for (k, v) in new_map {
if old_map.get(k) != Some(v) {
overrides.insert(k.clone(), v.clone());
}
}
}
format_alias_block_body(Some(&name), &overrides)?
} else {
format_alias_block_body(None, new_map)?
};
Ok(Some(AliasLineRewrite {
key: key.map(str::to_string),
alias: name,
body,
seq_occurrence,
}))
}
struct AliasLineRewrite {
key: Option<String>,
alias: String,
body: String,
seq_occurrence: Option<usize>,
}
fn is_safe_yaml_plain_ident(name: &str) -> bool {
let mut chars = name.chars();
matches!(chars.next(), Some(c) if c.is_ascii_alphanumeric() || c == '_')
&& chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
}
fn format_alias_block_body(
merge_alias: Option<&str>,
fields: &serde_json::Map<String, serde_json::Value>,
) -> anyhow::Result<String> {
let mut body = String::new();
if let Some(name) = merge_alias {
body.push_str("<<: *");
body.push_str(name);
body.push('\n');
}
if fields.is_empty() {
if merge_alias.is_none() {
body.push_str("{}\n");
}
return Ok(body);
}
let yaml =
serde_yaml_ng::to_string(&serde_json::Value::Object(fields.clone())).map_err(|e| {
anyhow::Error::new(crate::exit::InvalidInputError {
msg: format!("YAML serialization failed: {e}"),
})
})?;
for line in yaml.lines() {
let line = line.trim_end();
if line.is_empty() || line == "---" {
continue;
}
body.push_str(line);
body.push('\n');
}
Ok(body)
}
fn apply_alias_line_rewrites(
text: &str,
rewrites: &[AliasLineRewrite],
) -> anyhow::Result<Option<String>> {
let mut ordered: Vec<&AliasLineRewrite> = rewrites.iter().collect();
ordered.sort_by(|a, b| {
match (
a.key.as_deref(),
b.key.as_deref(),
a.seq_occurrence,
b.seq_occurrence,
) {
(Some(ka), Some(kb), Some(x), Some(y)) if ka == kb && a.alias == b.alias => y.cmp(&x),
(None, None, Some(x), Some(y)) if a.alias == b.alias => y.cmp(&x),
_ => std::cmp::Ordering::Equal,
}
});
let mut out = text.to_string();
for rewrite in ordered {
let Some(next) = replace_unique_alias_line(&out, rewrite) else {
return Ok(None);
};
out = next;
}
Ok(Some(out))
}
fn replace_unique_alias_line(text: &str, rewrite: &AliasLineRewrite) -> Option<String> {
let alias_re = regex::escape(&rewrite.alias);
if let Some(key) = rewrite.key.as_deref() {
let key_re = regex::escape(key);
let mapping_re = regex::Regex::new(&format!(
r"(?m)^([ \t]*){key_re}:[ \t]*\*{alias_re}([ \t]*(?:#.*)?)?[ \t]*\r?$"
))
.ok()?;
let hits: Vec<regex::Match<'_>> = mapping_re.find_iter(text).collect();
let idx = match rewrite.seq_occurrence {
Some(i) => i,
None if hits.len() == 1 => 0,
None => return None,
};
let hit = hits.get(idx)?;
let caps = mapping_re.captures(hit.as_str())?;
let indent = caps.get(1).map(|m| m.as_str()).unwrap_or("");
let comment = caps.get(2).map(|m| m.as_str()).unwrap_or("");
let replacement = with_file_eol(
text,
&format_block_replacement(indent, &format!("{key}:{comment}"), &rewrite.body),
);
return Some(splice_match(text, *hit, &replacement));
}
let seq_re = regex::Regex::new(&format!(
r"(?m)^([ \t]*)-[ \t]*\*{alias_re}([ \t]*(?:#.*)?)?[ \t]*\r?$"
))
.ok()?;
let hits: Vec<regex::Match<'_>> = seq_re.find_iter(text).collect();
let idx = rewrite.seq_occurrence?;
let hit = hits.get(idx)?;
let caps = seq_re.captures(hit.as_str())?;
let indent = caps.get(1).map(|m| m.as_str()).unwrap_or("");
let comment = caps.get(2).map(|m| m.as_str()).unwrap_or("");
let first = rewrite.body.lines().next().unwrap_or("");
let rest = rewrite
.body
.lines()
.skip(1)
.map(|line| format!("{indent} {line}"))
.collect::<Vec<_>>()
.join("\n");
let mut replacement = format!("{indent}- {first}{comment}");
if !rest.is_empty() {
replacement.push('\n');
replacement.push_str(&rest);
}
Some(splice_match(text, *hit, &with_file_eol(text, &replacement)))
}
fn with_file_eol(file: &str, block: &str) -> String {
if file.contains("\r\n") {
block.replace('\n', "\r\n")
} else {
block.to_string()
}
}
fn splice_match(text: &str, m: regex::Match<'_>, replacement: &str) -> String {
let mut out = String::with_capacity(text.len() + replacement.len());
out.push_str(&text[..m.start()]);
out.push_str(replacement);
out.push_str(&text[m.end()..]);
out
}
fn format_block_replacement(indent: &str, header: &str, body: &str) -> String {
let mut out = format!("{indent}{header}\n");
for line in body.lines() {
out.push_str(indent);
out.push_str(" ");
out.push_str(line);
out.push('\n');
}
out.pop();
out
}
fn json_to_yaml_node(val: &serde_json::Value) -> anyhow::Result<yaml_edit::YamlNode> {
use std::str::FromStr;
let wrapper = serde_json::json!({ "__v__": val });
let yaml_text = serde_yaml_ng::to_string(&wrapper).map_err(|e| {
anyhow::Error::new(crate::exit::InvalidInputError {
msg: format!("YAML serialization failed: {e}"),
})
})?;
let doc = yaml_edit::Document::from_str(&yaml_text).map_err(|e| {
anyhow::Error::new(crate::exit::ParseErrorError {
msg: format!("YAML CST re-parse failed: {e}"),
})
})?;
doc.as_mapping()
.and_then(|m| m.get("__v__"))
.ok_or_else(|| {
anyhow::Error::new(crate::exit::InvalidInputError {
msg: "YAML CST wrapper key missing".into(),
})
})
}
fn json_to_yaml_mapping(val: &serde_json::Value) -> anyhow::Result<yaml_edit::Mapping> {
let mapping = yaml_edit::Mapping::new();
if let Some(obj) = val.as_object() {
for (k, v) in obj {
mapping.set(k.as_str(), json_to_yaml_node(v)?);
}
}
Ok(mapping)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn parse_yaml(text: &str) -> yaml_edit::Document {
use std::str::FromStr;
yaml_edit::Document::from_str(text).unwrap()
}
fn apply_and_serialize(yaml: &str, old: &serde_json::Value, new: &serde_json::Value) -> String {
let doc = parse_yaml(yaml);
let mapping = doc.as_mapping().unwrap();
apply_yaml_mapping_diff(&mapping, old, new).unwrap();
doc.to_string()
}
fn set_and_render(key: &str, val: &serde_json::Value) -> String {
let doc = parse_yaml(&format!("{key}: placeholder\n"));
let mapping = doc.as_mapping().unwrap();
let node = json_to_yaml_node(val).unwrap();
mapping.set(key, node);
doc.to_string()
}
#[test]
fn json_to_yaml_node_scalar() {
let text = set_and_render("key", &json!("hello"));
assert!(text.contains("key: hello"), "expected 'key: hello': {text}");
}
#[test]
fn json_to_yaml_node_number() {
let text = set_and_render("val", &json!(42));
assert!(text.contains("val: 42"), "expected 'val: 42': {text}");
}
#[test]
fn json_to_yaml_node_boolean() {
let text = set_and_render("flag", &json!(true));
assert!(text.contains("flag: true"), "expected 'flag: true': {text}");
}
#[test]
fn json_to_yaml_node_array() {
let text = set_and_render("list", &json!(["a", "b", "c"]));
assert!(text.contains("- a"));
assert!(text.contains("- b"));
assert!(text.contains("- c"));
}
#[test]
fn json_to_yaml_node_nested_object() {
let text = set_and_render("outer", &json!({"inner": "value"}));
assert!(text.contains("inner: value"));
}
#[test]
fn json_to_yaml_mapping_basic() {
let val = json!({"alpha": 1, "beta": "two"});
let mapping_node = json_to_yaml_mapping(&val).unwrap();
let doc = parse_yaml("outer: placeholder\n");
let root = doc.as_mapping().unwrap();
root.set("outer", &mapping_node);
let text = doc.to_string();
assert!(text.contains("alpha"));
assert!(text.contains("beta"));
}
#[test]
fn json_to_yaml_mapping_non_object_is_empty() {
let val = json!("not an object");
let mapping = json_to_yaml_mapping(&val).unwrap();
assert!(mapping.get("anything").is_none());
}
#[test]
fn mapping_diff_no_change() {
let yaml = "key: value\n";
let old = json!({"key": "value"});
let new = json!({"key": "value"});
let result = apply_and_serialize(yaml, &old, &new);
assert_eq!(result, yaml);
}
#[test]
fn yaml_edit_set_on_pure_alias_does_not_become_merge() {
let yaml = "shared: &shared\n timeout: 30\n retries: 3\nservice_a: *shared\n";
let doc = parse_yaml(yaml);
let root = doc.as_mapping().unwrap();
let replacement = json_to_yaml_mapping(&json!({"timeout": 60, "retries": 3})).unwrap();
root.set("service_a", &replacement);
let result = doc.to_string();
assert_eq!(
result,
"shared: &shared\n timeout: 30\n retries: 3\nservice_a:\n timeout: 60\n retries: 3\n"
);
}
#[test]
fn yaml_edit_set_of_merge_mapping_keeps_alias() {
let yaml = "shared: &shared\n timeout: 30\n retries: 3\nservice_a: *shared\n";
let doc = parse_yaml(yaml);
let root = doc.as_mapping().unwrap();
let merge_src = parse_yaml("<<: *shared\ntimeout: 60\n");
let merge_map = merge_src.as_mapping().expect("merge snippet is a mapping");
root.set("service_a", &merge_map);
let result = doc.to_string();
assert_eq!(
result,
"shared: &shared\n timeout: 30\n retries: 3\nservice_a:\n <<: *shared\n timeout: 60\n"
);
}
#[test]
fn yaml_edit_get_mapping_on_alias_is_not_the_anchor() {
let yaml = "shared: &shared\n timeout: 30\n retries: 3\nservice_a: *shared\n";
let doc = parse_yaml(yaml);
let root = doc.as_mapping().unwrap();
let service_a = root.get("service_a").expect("service_a key");
assert!(
service_a.as_alias().is_some(),
"service_a must still be an alias node"
);
assert!(
root.get_mapping("service_a").is_none(),
"get_mapping on a pure alias must be None; a Some view would let apply_yaml_mapping_diff recurse into &shared"
);
}
#[test]
fn mapping_diff_on_alias_key_inlines_and_keeps_anchor() {
let yaml = "shared: &shared\n timeout: 30\n retries: 3\nservice_a: *shared\n";
let old = json!({"shared": {"timeout": 30, "retries": 3}, "service_a": {"timeout": 30, "retries": 3}});
let new = json!({"shared": {"timeout": 30, "retries": 3}, "service_a": {"timeout": 60, "retries": 3}});
let result = apply_and_serialize(yaml, &old, &new);
assert_eq!(
result,
"shared: &shared\n timeout: 30\n retries: 3\nservice_a:\n timeout: 60\n retries: 3\n"
);
}
#[test]
fn mapping_diff_pure_alias_override_to_merge() {
use std::str::FromStr;
let yaml = "shared: &shared\n timeout: 30\n retries: 3\nservice_a: *shared\n";
let old = json!({"shared": {"timeout": 30, "retries": 3}, "service_a": {"timeout": 30, "retries": 3}});
let new = json!({"shared": {"timeout": 30, "retries": 3}, "service_a": {"timeout": 60, "retries": 3}});
let file = yaml_edit::YamlFile::from_str(yaml).unwrap();
let result = rewrite_yaml_alias_object_edits(yaml, &file, &old, &new)
.unwrap()
.expect("alias line should be rewritten");
assert_eq!(
result,
"shared: &shared\n timeout: 30\n retries: 3\nservice_a:\n <<: *shared\n timeout: 60\n"
);
}
#[test]
fn mapping_diff_pure_alias_delete_inherited_expands() {
use std::str::FromStr;
let yaml = "shared: &shared\n timeout: 30\n retries: 3\nservice_a: *shared\nservice_b: *shared\n";
let old = json!({
"shared": {"timeout": 30, "retries": 3},
"service_a": {"timeout": 30, "retries": 3},
"service_b": {"timeout": 30, "retries": 3}
});
let new = json!({
"shared": {"timeout": 30, "retries": 3},
"service_a": {"timeout": 30},
"service_b": {"timeout": 30, "retries": 3}
});
let file = yaml_edit::YamlFile::from_str(yaml).unwrap();
let result = rewrite_yaml_alias_object_edits(yaml, &file, &old, &new)
.unwrap()
.expect("delete-inherited must splice, not return None");
assert_eq!(
result,
"shared: &shared\n timeout: 30\n retries: 3\nservice_a:\n timeout: 30\nservice_b: *shared\n"
);
}
#[test]
fn mapping_diff_pure_alias_non_superset_replace_expands() {
use std::str::FromStr;
let yaml = "shared: &shared\n timeout: 30\n retries: 3\nservice_a: *shared\n";
let old = json!({"shared": {"timeout": 30, "retries": 3}, "service_a": {"timeout": 30, "retries": 3}});
let new = json!({"shared": {"timeout": 30, "retries": 3}, "service_a": {"name": "api"}});
let file = yaml_edit::YamlFile::from_str(yaml).unwrap();
let result = rewrite_yaml_alias_object_edits(yaml, &file, &old, &new)
.unwrap()
.expect("non-superset replace must splice, not return None");
assert_eq!(
result,
"shared: &shared\n timeout: 30\n retries: 3\nservice_a:\n name: api\n"
);
}
#[test]
fn mapping_diff_scalar_update() {
let yaml = "name: old\n";
let old = json!({"name": "old"});
let new = json!({"name": "new"});
let result = apply_and_serialize(yaml, &old, &new);
assert!(result.contains("name: new"));
}
#[test]
fn mapping_diff_add_key() {
let yaml = "existing: yes\n";
let old = json!({"existing": "yes"});
let new = json!({"existing": "yes", "added": "here"});
let result = apply_and_serialize(yaml, &old, &new);
assert!(result.contains("existing: yes"));
assert!(result.contains("added: here"));
}
#[test]
fn mapping_diff_remove_key() {
let yaml = "keep: yes\nremove: me\n";
let old = json!({"keep": "yes", "remove": "me"});
let new = json!({"keep": "yes"});
let result = apply_and_serialize(yaml, &old, &new);
assert!(result.contains("keep: yes"));
assert!(!result.contains("remove"));
}
#[test]
fn mapping_diff_nested_object_update() {
let yaml = "parent:\n child: old\n";
let old = json!({"parent": {"child": "old"}});
let new = json!({"parent": {"child": "new"}});
let result = apply_and_serialize(yaml, &old, &new);
assert!(result.contains("child: new"));
}
#[test]
fn mapping_diff_type_change() {
let yaml = "key: scalar\n";
let old = json!({"key": "scalar"});
let new = json!({"key": ["array", "now"]});
let result = apply_and_serialize(yaml, &old, &new);
assert!(result.contains("- array"));
}
#[test]
fn try_remove_subsequence_simple_tail() {
let yaml = "items:\n - a\n - b\n - c\n";
let doc = parse_yaml(yaml);
let mapping = doc.as_mapping().unwrap();
let seq = mapping.get_sequence("items").unwrap();
let old = vec![json!("a"), json!("b"), json!("c")];
let new = vec![json!("a"), json!("b")];
assert!(try_remove_subsequence(&seq, &old, &new));
let result = doc.to_string();
assert!(result.contains("- a"));
assert!(result.contains("- b"));
assert!(!result.contains("- c"));
}
#[test]
fn try_remove_subsequence_middle() {
let yaml = "items:\n - x\n - y\n - z\n";
let doc = parse_yaml(yaml);
let mapping = doc.as_mapping().unwrap();
let seq = mapping.get_sequence("items").unwrap();
let old = vec![json!("x"), json!("y"), json!("z")];
let new = vec![json!("x"), json!("z")];
assert!(try_remove_subsequence(&seq, &old, &new));
let result = doc.to_string();
assert!(result.contains("- x"));
assert!(!result.contains("- y"));
assert!(result.contains("- z"));
}
#[test]
fn try_remove_subsequence_not_subsequence() {
let yaml = "items:\n - a\n - b\n";
let doc = parse_yaml(yaml);
let mapping = doc.as_mapping().unwrap();
let seq = mapping.get_sequence("items").unwrap();
let old = vec![json!("a"), json!("b")];
let new = vec![json!("c")]; assert!(!try_remove_subsequence(&seq, &old, &new));
}
#[test]
fn sequence_diff_scalar_update() {
let yaml = "list:\n - one\n - two\n";
let doc = parse_yaml(yaml);
let mapping = doc.as_mapping().unwrap();
let seq = mapping.get_sequence("list").unwrap();
let old = vec![json!("one"), json!("two")];
let new = vec![json!("ONE"), json!("two")];
assert!(apply_yaml_sequence_diff(&seq, &old, &new).unwrap());
let result = doc.to_string();
assert!(result.contains("ONE"));
assert!(result.contains("two"));
}
#[test]
fn sequence_diff_no_change() {
let yaml = "list:\n - a\n - b\n";
let doc = parse_yaml(yaml);
let mapping = doc.as_mapping().unwrap();
let seq = mapping.get_sequence("list").unwrap();
let old = vec![json!("a"), json!("b")];
let new = vec![json!("a"), json!("b")];
assert!(apply_yaml_sequence_diff(&seq, &old, &new).unwrap());
}
#[test]
fn sequence_diff_on_alias_item_is_not_applied() {
let yaml = "shared: &shared\n timeout: 30\n retries: 3\nitems:\n - *shared\n";
let doc = parse_yaml(yaml);
let mapping = doc.as_mapping().unwrap();
let seq = mapping.get_sequence("items").unwrap();
let old = vec![json!({"timeout": 30, "retries": 3})];
let new = vec![json!({"timeout": 60, "retries": 3})];
assert!(
!apply_yaml_sequence_diff(&seq, &old, &new).unwrap(),
"alias item set must not report applied"
);
assert_eq!(doc.to_string(), yaml);
}
#[test]
fn sequence_diff_on_scalar_alias_item_is_not_applied() {
let yaml = "shared: &shared hello\nitems:\n - *shared\n";
let doc = parse_yaml(yaml);
let mapping = doc.as_mapping().unwrap();
let seq = mapping.get_sequence("items").unwrap();
let old = vec![json!("hello")];
let new = vec![json!("world")];
assert!(
!apply_yaml_sequence_diff(&seq, &old, &new).unwrap(),
"scalar alias item set must not report applied"
);
assert_eq!(doc.to_string(), yaml);
}
#[test]
fn sequence_resize_shrink() {
let yaml = "items:\n - a\n - b\n - c\n";
let doc = parse_yaml(yaml);
let mapping = doc.as_mapping().unwrap();
let seq = mapping.get_sequence("items").unwrap();
let old = vec![json!("a"), json!("b"), json!("c")];
let new = vec![json!("a"), json!("c")];
let new_val = json!(["a", "c"]);
assert!(apply_yaml_sequence_resize(
&seq, &old, &new, &mapping, "items", &new_val
));
assert_eq!(doc.to_string(), "items:\n - a\n - c\n");
}
#[test]
fn sequence_resize_grow_returns_false() {
let yaml = "items:\n - a\n";
let doc = parse_yaml(yaml);
let mapping = doc.as_mapping().unwrap();
let seq = mapping.get_sequence("items").unwrap();
let old = vec![json!("a")];
let new = vec![json!("a"), json!("b")];
let new_val = json!(["a", "b"]);
assert!(!apply_yaml_sequence_resize(
&seq, &old, &new, &mapping, "items", &new_val
));
}
#[test]
fn mapping_diff_remove_first_nested_key() {
let yaml = "app:\n name: \"my-app\"\n version: \"1.0.0\"\n enabled: \"true\"\n port: \"8080\"\n";
let old = json!({"app": {"name": "my-app", "version": "1.0.0", "enabled": "true", "port": "8080"}});
let mut new = old.clone();
new.as_object_mut()
.unwrap()
.get_mut("app")
.unwrap()
.as_object_mut()
.unwrap()
.shift_remove("name");
let result = apply_and_serialize(yaml, &old, &new);
assert_eq!(
result,
"app:\n version: \"1.0.0\"\n enabled: \"true\"\n port: \"8080\"\n"
);
}
#[test]
fn mapping_diff_remove_middle_nested() {
let yaml = "app:\n name: \"my-app\"\n version: \"1.0.0\"\n port: \"8080\"\n";
let old = json!({"app": {"name": "my-app", "version": "1.0.0", "port": "8080"}});
let mut new = old.clone();
new.as_object_mut()
.unwrap()
.get_mut("app")
.unwrap()
.as_object_mut()
.unwrap()
.shift_remove("version");
let result = apply_and_serialize(yaml, &old, &new);
assert!(!result.contains("version"));
assert!(result.contains("\"my-app\""));
assert!(result.contains("\"8080\""));
}
#[test]
fn scalar_set_preserves_double_quote_style() {
let yaml = "name: \"John Doe\"\nage: 30\n";
let old = json!({"name": "John Doe", "age": 30});
let new = json!({"name": "Jane Doe", "age": 30});
let result = apply_and_serialize(yaml, &old, &new);
assert!(
result.contains("\"Jane Doe\""),
"double quotes not preserved: {result}"
);
}
#[test]
fn sequence_scalar_set_preserves_double_quote_style() {
let yaml = "items:\n - \"foo\"\n";
let old = json!({"items": ["foo"]});
let new = json!({"items": ["bar"]});
let result = apply_and_serialize(yaml, &old, &new);
assert!(
result.contains("- \"bar\""),
"double quotes not preserved on sequence item: {result}"
);
}
#[test]
fn scalar_set_preserves_single_quote_style() {
let yaml = "path: '/usr/local'\nname: plain\n";
let old = json!({"path": "/usr/local", "name": "plain"});
let new = json!({"path": "/opt/bin", "name": "plain"});
let result = apply_and_serialize(yaml, &old, &new);
assert!(
result.contains("'/opt/bin'"),
"single quotes not preserved: {result}"
);
}
#[test]
fn scalar_set_preserves_plain_style() {
let yaml = "name: Alice\nage: 30\n";
let old = json!({"name": "Alice", "age": 30});
let new = json!({"name": "Bob", "age": 30});
let result = apply_and_serialize(yaml, &old, &new);
assert!(
result.contains("name: Bob"),
"plain style should not add quotes: {result}"
);
assert!(
!result.contains("\"Bob\"") && !result.contains("'Bob'"),
"plain value should not get quoted: {result}"
);
}
}