use crate::selector;
use serde::Deserialize;
use std::path::Path;
mod navigate;
mod preserve;
pub mod query;
mod toml_preserve;
mod yaml_cst;
mod yaml_splice;
pub use navigate::{
deep_merge, delete_at_selector, delete_where, move_at_path, navigate_mut, set_at_path,
update_matching,
};
use toml_preserve::apply_value_diff;
use yaml_cst::{apply_yaml_mapping_diff, apply_yaml_sequence_diff, try_remove_subsequence};
pub use yaml_splice::needs_yaml_quoting;
#[derive(Debug, Clone, Copy)]
pub enum FileFormat {
Json,
Yaml,
Toml,
}
pub fn detect_format(path: &str) -> anyhow::Result<FileFormat> {
match Path::new(path).extension().and_then(|e| e.to_str()) {
Some("json") => Ok(FileFormat::Json),
Some("yaml" | "yml") => Ok(FileFormat::Yaml),
Some("toml") => Ok(FileFormat::Toml),
Some(ext) => anyhow::bail!(
"unsupported file extension: .{ext} (supported: .json, .yaml, .yml, .toml)"
),
None => anyhow::bail!(
"file has no extension; doc commands require .json, .yaml, .yml, or .toml"
),
}
}
pub fn serialize_value(value: &serde_json::Value, format: &FileFormat) -> anyhow::Result<String> {
match format {
FileFormat::Json => {
let mut s = serde_json::to_string_pretty(value)?;
s.push('\n');
Ok(s)
}
FileFormat::Yaml => Ok(serde_yaml_ng::to_string(value)?),
FileFormat::Toml => {
let s = toml_edit::ser::to_string_pretty(value)
.map_err(|e| anyhow::anyhow!("TOML serialization error: {e}"))?;
Ok(s)
}
}
}
pub fn serialize_value_preserving(
original_content: &str,
old_value: &serde_json::Value,
new_value: &serde_json::Value,
format: &FileFormat,
) -> anyhow::Result<String> {
match format {
FileFormat::Toml => {
let mut doc: toml_edit::DocumentMut = original_content
.parse()
.map_err(|e| anyhow::anyhow!("TOML re-parse for comment preservation: {e}"))?;
apply_value_diff(doc.as_item_mut(), old_value, new_value);
Ok(doc.to_string())
}
FileFormat::Yaml => {
if let Some(result) = try_preserve_yaml(original_content, old_value, new_value)? {
return Ok(result);
}
if old_value == new_value {
Ok(original_content.to_string())
} else {
let body = serialize_value(new_value, format)?;
Ok(preserve::hoist_comments(original_content, &body))
}
}
_ => {
if old_value == new_value {
Ok(original_content.to_string())
} else {
serialize_value(new_value, format)
}
}
}
}
fn cleanup_yaml_cst_whitespace(text: &str) -> String {
let mut result: String = text
.lines()
.map(|line| line.trim_end())
.collect::<Vec<_>>()
.join("\n");
if !result.ends_with('\n') {
result.push('\n');
}
result
}
fn fix_yaml_block_indentation(text: &str) -> String {
let lines: Vec<&str> = text.lines().collect();
let mut result: Vec<String> = Vec::with_capacity(lines.len());
for i in 0..lines.len() {
let line = lines[i];
let trimmed = line.trim_start();
if trimmed.is_empty() {
result.push(line.to_string());
continue;
}
if trimmed.starts_with('#') {
let comment_indent = line.len() - trimmed.len();
if comment_indent > 0
&& let Some(next_indent) = next_entry_indent(&lines, i)
&& next_indent < comment_indent
{
result.push(format!("{}{}", " ".repeat(next_indent), trimmed));
continue;
}
result.push(line.to_string());
continue;
}
let indent = line.len() - trimmed.len();
let is_mapping_entry = indent > 0 && (trimmed.contains(": ") || trimmed.ends_with(':'));
if is_mapping_entry
&& let Some(expected) = expected_sibling_indent(&lines, i, indent)
&& expected < indent
{
result.push(format!("{}{}", " ".repeat(expected), trimmed));
continue;
}
result.push(line.to_string());
}
let mut out = result.join("\n");
if text.ends_with('\n') && !out.ends_with('\n') {
out.push('\n');
}
out
}
fn next_entry_indent(lines: &[&str], i: usize) -> Option<usize> {
for line in &lines[i + 1..] {
let t = line.trim();
if !t.is_empty() && !t.starts_with('#') {
return Some(line.len() - t.len());
}
}
None
}
fn expected_sibling_indent(lines: &[&str], i: usize, current_indent: usize) -> Option<usize> {
let is_significant = |l: &&str| {
let t = l.trim();
!t.is_empty() && !t.starts_with('#')
};
if let Some(next_line) = lines[i + 1..].iter().find(|l| is_significant(l)) {
let nt = next_line.trim_start();
let ni = next_line.len() - nt.len();
let next_is_entry = nt.contains(": ") || nt.ends_with(':');
if ni < current_indent && ni > 0 && next_is_entry {
let prev_ok = lines[..i]
.iter()
.rev()
.find(|l| is_significant(l))
.is_some_and(|l| {
let pi = l.len() - l.trim_start().len();
(pi < ni && l.trim_end().ends_with(':')) || pi == ni
});
if prev_ok {
return Some(ni);
}
}
}
if let Some(prev_line) = lines[..i].iter().rev().find(|l| is_significant(l)) {
let pt = prev_line.trim_start();
let pi = prev_line.len() - pt.len();
if pi < current_indent && pi > 0 && pt.contains(": ") && !is_yaml_parent_line(pt) {
return Some(pi);
}
}
None
}
fn is_yaml_parent_line(trimmed: &str) -> bool {
if trimmed.ends_with(':') {
return true;
}
if let Some(pos) = trimmed.find(": ") {
let after = trimmed[pos + 2..].trim();
return after.is_empty() || after.starts_with('#');
}
false
}
fn try_preserve_yaml(
original_content: &str,
old_value: &serde_json::Value,
new_value: &serde_json::Value,
) -> anyhow::Result<Option<String>> {
use std::str::FromStr;
let file = yaml_edit::YamlFile::from_str(original_content)
.map_err(|e| anyhow::anyhow!("YAML re-parse for comment preservation: {e}"))?;
if let Some(doc) = file.document() {
if let Some(mapping) = doc.as_mapping() {
if old_value.is_object() && new_value.is_object() {
return try_preserve_yaml_object(&file, &mapping, old_value, new_value);
}
} else if let Some(seq) = doc.as_sequence()
&& let (Some(old_arr), Some(new_arr)) = (old_value.as_array(), new_value.as_array())
{
return try_preserve_yaml_array(
&file,
&seq,
original_content,
old_arr,
new_arr,
new_value,
);
}
}
Ok(None)
}
fn try_preserve_yaml_object(
file: &yaml_edit::YamlFile,
mapping: &yaml_edit::Mapping,
old_value: &serde_json::Value,
new_value: &serde_json::Value,
) -> anyhow::Result<Option<String>> {
let has_array_growth = yaml_splice::has_array_growth_diffs(old_value, new_value);
let all_cst_applied = apply_yaml_mapping_diff(mapping, old_value, new_value)?;
let result = fix_yaml_block_indentation(&cleanup_yaml_cst_whitespace(&file.to_string()));
if let Ok(reparsed) = serde_yaml_ng::from_str::<serde_json::Value>(&result)
&& reparsed.is_object()
&& reparsed == *new_value
&& !has_array_growth
&& all_cst_applied
{
return Ok(Some(result));
}
if let Ok(reparsed) = serde_yaml_ng::from_str::<serde_json::Value>(&result)
&& let Some(spliced) = yaml_splice::splice_yaml_array_diffs(&result, &reparsed, new_value)?
{
return Ok(Some(spliced));
}
Ok(None)
}
fn try_preserve_yaml_array(
file: &yaml_edit::YamlFile,
seq: &yaml_edit::Sequence,
original_content: &str,
old_arr: &[serde_json::Value],
new_arr: &[serde_json::Value],
new_value: &serde_json::Value,
) -> anyhow::Result<Option<String>> {
let applied = if old_arr.len() == new_arr.len() {
apply_yaml_sequence_diff(seq, old_arr, new_arr)?
} else if new_arr.len() < old_arr.len() {
try_remove_subsequence(seq, old_arr, new_arr)
} else {
false
};
if applied {
let result = cleanup_yaml_cst_whitespace(&file.to_string());
if serde_yaml_ng::from_str::<serde_json::Value>(&result).is_ok_and(|v| v == *new_value) {
return Ok(Some(result));
}
}
if new_arr.len() > old_arr.len()
&& let Some(spliced) =
yaml_splice::splice_yaml_root_sequence(original_content, old_arr, new_arr)?
&& serde_yaml_ng::from_str::<serde_json::Value>(&spliced).is_ok_and(|v| v == *new_value)
&& spliced.parse::<yaml_edit::YamlFile>().is_ok()
{
return Ok(Some(spliced));
}
Ok(None)
}
pub fn parse_doc(content: &str, format: &FileFormat) -> anyhow::Result<serde_json::Value> {
match format {
FileFormat::Json => Ok(serde_json::from_str(content)?),
FileFormat::Yaml => {
if is_multi_document_yaml(content) {
parse_multi_document_yaml(content)
} else {
let mut val: serde_json::Value = serde_yaml_ng::from_str(content)?;
resolve_yaml_merge_keys(&mut val);
Ok(val)
}
}
FileFormat::Toml => Ok(toml_edit::de::from_str(content)?),
}
}
pub(crate) fn is_multi_document_yaml(content: &str) -> bool {
let rest = content.strip_prefix("---").map_or(content, |after| {
if is_after_yaml_marker(after) {
skip_to_next_line(after)
} else {
content
}
});
if rest.starts_with("---") && is_after_yaml_marker(&rest[3..]) {
return true;
}
for (i, _) in rest.match_indices("\n---") {
let after_marker = &rest[i + 4..];
if is_after_yaml_marker(after_marker) {
return true;
}
}
false
}
fn is_after_yaml_marker(after: &str) -> bool {
if after.is_empty() {
return true;
}
let b = after.as_bytes()[0];
if b == b'\n' || b == b'\r' {
return true;
}
if b == b' ' || b == b'\t' || b == b'#' {
let rest = after
.as_bytes()
.iter()
.skip_while(|&&c| c == b' ' || c == b'\t')
.copied()
.next();
return rest.is_none() || rest == Some(b'\n') || rest == Some(b'\r') || rest == Some(b'#');
}
false
}
fn skip_to_next_line(s: &str) -> &str {
match s.find('\n') {
Some(pos) => &s[pos + 1..],
None => "",
}
}
fn parse_multi_document_yaml(content: &str) -> anyhow::Result<serde_json::Value> {
let mut docs = Vec::new();
for de in serde_yaml_ng::Deserializer::from_str(content) {
let mut val: serde_json::Value = serde_json::Value::deserialize(de)?;
resolve_yaml_merge_keys(&mut val);
docs.push(val);
}
debug_assert!(!docs.is_empty(), "multi-doc YAML produced zero documents");
Ok(serde_json::Value::Array(docs))
}
pub fn parse_value(s: &str) -> serde_json::Value {
if s.starts_with('"')
&& s.ends_with('"')
&& let Ok(v) = serde_json::from_str::<serde_json::Value>(s)
{
return v;
}
if (s.starts_with('{') || s.starts_with('['))
&& let Ok(v) = serde_json::from_str::<serde_json::Value>(s)
{
return v;
}
if s == "true" {
return serde_json::Value::Bool(true);
}
if s == "false" {
return serde_json::Value::Bool(false);
}
if s == "null" {
return serde_json::Value::Null;
}
if let Ok(n) = s.parse::<i64>() {
return serde_json::Value::Number(n.into());
}
if let Ok(n) = s.parse::<f64>()
&& let Some(num) = serde_json::Number::from_f64(n)
{
return serde_json::Value::Number(num);
}
serde_json::Value::String(s.to_string())
}
fn push_key_quoted(buf: &mut String, k: &str) {
if k.contains('.') || k.contains('[') || k.contains(']') || k.contains('"') {
buf.push('"');
buf.push_str(&k.replace('"', "\\\""));
buf.push('"');
} else {
buf.push_str(k);
}
}
pub fn flatten_value<'a>(
value: &'a serde_json::Value,
buf: &mut String,
out: &mut Vec<(String, &'a serde_json::Value)>,
) {
match value {
serde_json::Value::Object(map) if !map.is_empty() => {
for (k, v) in map {
let restore = buf.len();
if !buf.is_empty() {
buf.push('.');
}
push_key_quoted(buf, k);
flatten_value(v, buf, out);
buf.truncate(restore);
}
}
serde_json::Value::Array(arr) if !arr.is_empty() => {
for (i, v) in arr.iter().enumerate() {
let restore = buf.len();
buf.push('[');
let _ = std::fmt::Write::write_fmt(buf, format_args!("{i}"));
buf.push(']');
flatten_value(v, buf, out);
buf.truncate(restore);
}
}
_ => {
out.push((buf.clone(), value));
}
}
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct DiffEntry {
pub path: String,
pub kind: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
pub old_value: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub new_value: Option<serde_json::Value>,
}
pub fn diff_values(
a: &serde_json::Value,
b: &serde_json::Value,
buf: &mut String,
out: &mut Vec<DiffEntry>,
) {
match (a, b) {
(serde_json::Value::Object(ma), serde_json::Value::Object(mb)) => {
for (k, va) in ma {
let restore = buf.len();
if !buf.is_empty() {
buf.push('.');
}
push_key_quoted(buf, k);
if let Some(vb) = mb.get(k) {
diff_values(va, vb, buf, out);
} else {
out.push(DiffEntry {
path: buf.clone(),
kind: "removed",
old_value: Some(va.clone()),
new_value: None,
});
}
buf.truncate(restore);
}
for (k, vb) in mb {
if !ma.contains_key(k) {
let restore = buf.len();
if !buf.is_empty() {
buf.push('.');
}
push_key_quoted(buf, k);
out.push(DiffEntry {
path: buf.clone(),
kind: "added",
old_value: None,
new_value: Some(vb.clone()),
});
buf.truncate(restore);
}
}
}
(serde_json::Value::Array(aa), serde_json::Value::Array(ab)) => {
let max_len = aa.len().max(ab.len());
for i in 0..max_len {
let restore = buf.len();
buf.push('[');
let _ = std::fmt::Write::write_fmt(buf, format_args!("{i}"));
buf.push(']');
match (aa.get(i), ab.get(i)) {
(Some(va), Some(vb)) => diff_values(va, vb, buf, out),
(Some(va), None) => out.push(DiffEntry {
path: buf.clone(),
kind: "removed",
old_value: Some(va.clone()),
new_value: None,
}),
(None, Some(vb)) => out.push(DiffEntry {
path: buf.clone(),
kind: "added",
old_value: None,
new_value: Some(vb.clone()),
}),
(None, None) => {}
}
buf.truncate(restore);
}
}
_ => {
if a != b {
out.push(DiffEntry {
path: buf.clone(),
kind: "changed",
old_value: Some(a.clone()),
new_value: Some(b.clone()),
});
}
}
}
}
fn resolve_yaml_merge_keys(value: &mut serde_json::Value) {
match value {
serde_json::Value::Object(map) => {
for v in map.values_mut() {
resolve_yaml_merge_keys(v);
}
if let Some(merge_val) = map.remove("<<") {
match merge_val {
serde_json::Value::Object(merged) => {
for (k, v) in merged {
map.entry(k).or_insert(v);
}
}
serde_json::Value::Array(arr) => {
for item in arr {
if let serde_json::Value::Object(merged) = item {
for (k, v) in merged {
map.entry(k).or_insert(v);
}
}
}
}
_ => {
map.insert("<<".to_string(), merge_val);
}
}
}
}
serde_json::Value::Array(arr) => {
for v in arr {
resolve_yaml_merge_keys(v);
}
}
_ => {}
}
}
#[derive(Debug)]
pub enum DocMutation {
Set {
selector: String,
value: serde_json::Value,
},
Delete {
selector: String,
},
Merge {
value: serde_json::Value,
},
Append {
selector: String,
value: serde_json::Value,
},
Prepend {
selector: String,
value: serde_json::Value,
},
Update {
selector: String,
value: serde_json::Value,
},
Move {
from: String,
to: String,
},
Ensure {
selector: String,
value: serde_json::Value,
},
DeleteWhere {
selector: String,
predicate: String,
},
}
#[derive(Debug)]
pub enum MutationResult {
Applied,
Removed(usize),
NoMatch,
AlreadyExists,
TypeError(String),
}
pub fn apply_doc_mutation(
root: &mut serde_json::Value,
mutation: DocMutation,
) -> anyhow::Result<MutationResult> {
match mutation {
DocMutation::Set { selector, value } => {
let sel = selector::parse_anyhow(&selector)?;
set_at_path(root, &sel, value)?;
Ok(MutationResult::Applied)
}
DocMutation::Delete { selector } => {
let sel = selector::parse_anyhow(&selector)?;
if delete_at_selector(root, &sel)? {
Ok(MutationResult::Removed(1))
} else {
Ok(MutationResult::NoMatch)
}
}
DocMutation::Merge { value } => {
deep_merge(root, &value);
Ok(MutationResult::Applied)
}
DocMutation::Append { selector, value } => {
let sel = selector::parse_anyhow(&selector)?;
let target = navigate_mut(root, &sel, false)?;
match target.as_array_mut() {
Some(arr) => {
arr.push(value);
Ok(MutationResult::Applied)
}
None => Ok(MutationResult::TypeError(format!(
"doc append: target at '{selector}' is not an array"
))),
}
}
DocMutation::Prepend { selector, value } => {
let sel = selector::parse_anyhow(&selector)?;
let target = navigate_mut(root, &sel, false)?;
match target.as_array_mut() {
Some(arr) => {
arr.insert(0, value);
Ok(MutationResult::Applied)
}
None => Ok(MutationResult::TypeError(format!(
"doc prepend: target at '{selector}' is not an array"
))),
}
}
DocMutation::Update { selector, value } => {
let sel = selector::parse_anyhow(&selector)?;
if update_matching(root, &sel, &value) == 0 {
Ok(MutationResult::NoMatch)
} else {
Ok(MutationResult::Applied)
}
}
DocMutation::Move { from, to } => {
let from_sel = selector::parse_anyhow(&from)?;
let to_sel = selector::parse_anyhow(&to)?;
move_at_path(root, &from_sel, &to_sel)?;
Ok(MutationResult::Applied)
}
DocMutation::Ensure { selector, value } => {
let sel = selector::parse_anyhow(&selector)?;
if !selector::eval(root, &sel).is_empty() {
Ok(MutationResult::AlreadyExists)
} else {
set_at_path(root, &sel, value)?;
Ok(MutationResult::Applied)
}
}
DocMutation::DeleteWhere {
selector,
predicate,
} => {
let sel = selector::parse_anyhow(&selector)?;
let removed = delete_where(root, &sel, &predicate)?;
if removed == 0 {
Ok(MutationResult::NoMatch)
} else {
Ok(MutationResult::Removed(removed))
}
}
}
}
#[cfg(test)]
mod tests;