1use crate::selector;
8use anyhow::Context;
9use serde::Deserialize;
10use std::borrow::Cow;
11use std::collections::HashSet;
12use std::path::Path;
13
14mod navigate;
15mod preserve;
16pub mod query;
17mod toml_preserve;
18mod yaml_cst;
19mod yaml_splice;
20
21use navigate::reject_blank_merge_overlay;
22pub use navigate::{
23 deep_merge, delete_at_selector, delete_where, move_at_path, navigate_mut, set_at_path,
24 update_matching,
25};
26use toml_preserve::apply_value_diff;
27use yaml_cst::{apply_yaml_mapping_diff, apply_yaml_sequence_diff, try_remove_subsequence};
28
29pub use yaml_splice::needs_yaml_quoting;
30
31#[derive(Debug, Clone, Copy)]
32pub enum FileFormat {
33 Json,
34 Yaml,
35 Toml,
36}
37
38pub fn detect_format(path: &str) -> anyhow::Result<FileFormat> {
39 match Path::new(path).extension().and_then(|e| e.to_str()) {
40 Some("json") => Ok(FileFormat::Json),
41 Some("yaml" | "yml") => Ok(FileFormat::Yaml),
42 Some("toml") => Ok(FileFormat::Toml),
43 Some(ext) => Err(crate::exit::InvalidInputError {
44 msg: format!(
45 "unsupported file extension: .{ext} (supported: .json, .yaml, .yml, .toml)"
46 ),
47 }
48 .into()),
49 None => Err(crate::exit::InvalidInputError {
50 msg: "file has no extension; doc commands require .json, .yaml, .yml, or .toml".into(),
51 }
52 .into()),
53 }
54}
55
56pub fn serialize_value(value: &serde_json::Value, format: &FileFormat) -> anyhow::Result<String> {
57 match format {
58 FileFormat::Json => {
59 let mut s = serde_json::to_string_pretty(value)?;
60 s.push('\n');
61 Ok(s)
62 }
63 FileFormat::Yaml => Ok(serde_yaml_ng::to_string(value)?),
64 FileFormat::Toml => {
65 let s = toml_edit::ser::to_string_pretty(value).map_err(|e| {
66 anyhow::Error::new(crate::exit::InvalidInputError {
67 msg: format!("TOML serialization error: {e}"),
68 })
69 })?;
70 Ok(s)
71 }
72 }
73}
74
75pub fn presentation_style_changed(original: &str, new_text: &str, format: &FileFormat) -> bool {
93 if original == new_text {
94 return false;
95 }
96 match format {
97 FileFormat::Yaml => {
98 yaml_block_sequence_style_marks(original) != yaml_block_sequence_style_marks(new_text)
99 || yaml_alias_identity_counts(original) != yaml_alias_identity_counts(new_text)
100 }
101 FileFormat::Json | FileFormat::Toml => false,
104 }
105}
106
107pub fn style_changed_for_path(path: &str, original: &str, new_text: &str) -> bool {
112 let Ok(fmt) = detect_format(path) else {
113 return false;
114 };
115 presentation_style_changed(original, new_text, &fmt)
116}
117
118fn yaml_alias_identity_counts(text: &str) -> (usize, usize, usize) {
121 let mut anchors = 0usize;
122 let mut aliases = 0usize;
123 let mut merges = 0usize;
124 for line in text.lines() {
125 let code = yaml_line_without_comment(line);
126 anchors += count_yaml_prefixed_idents(code, '&');
127 aliases += count_yaml_prefixed_idents(code, '*');
128 merges += code.matches("<<:").count();
129 }
130 (anchors, aliases, merges)
131}
132
133fn yaml_line_without_comment(line: &str) -> &str {
134 let trimmed = line.trim_start();
135 if trimmed.starts_with('#') {
136 return "";
137 }
138 match line.find(" #") {
139 Some(i) => &line[..i],
140 None => line,
141 }
142}
143
144fn count_yaml_prefixed_idents(s: &str, prefix: char) -> usize {
145 let chars: Vec<char> = s.chars().collect();
146 let mut n = 0usize;
147 let mut i = 0usize;
148 while i < chars.len() {
149 if chars[i] == prefix {
150 let next_ok = chars
151 .get(i + 1)
152 .is_some_and(|c| c.is_ascii_alphanumeric() || *c == '_');
153 if next_ok {
154 n += 1;
155 i += 2;
156 while i < chars.len()
157 && (chars[i].is_ascii_alphanumeric() || chars[i] == '_' || chars[i] == '-')
158 {
159 i += 1;
160 }
161 continue;
162 }
163 }
164 i += 1;
165 }
166 n
167}
168
169fn yaml_block_sequence_style_marks(text: &str) -> std::collections::BTreeSet<(usize, usize)> {
172 text.lines()
173 .filter_map(|line| {
174 let trimmed = line.trim_start();
175 let dash_indent = line.len() - trimmed.len();
176 if trimmed == "-" {
177 return Some((dash_indent, 0));
178 }
179 let rest = trimmed.strip_prefix('-')?;
180 if rest.starts_with(' ') || rest.starts_with('\t') {
181 let pad = rest.len() - rest.trim_start().len();
182 Some((dash_indent, pad))
183 } else {
184 None
185 }
186 })
187 .collect()
188}
189
190pub fn serialize_value_preserving(
191 original_content: &str,
192 old_value: &serde_json::Value,
193 new_value: &serde_json::Value,
194 format: &FileFormat,
195) -> anyhow::Result<String> {
196 match format {
197 FileFormat::Toml => {
198 let mut doc: toml_edit::DocumentMut = toml_source_for_parse(original_content)
199 .parse()
200 .map_err(|e| {
201 anyhow::Error::new(crate::exit::ParseErrorError {
202 msg: format!("TOML re-parse for comment preservation: {e}"),
203 })
204 })?;
205 apply_value_diff(doc.as_item_mut(), old_value, new_value);
206 Ok(restore_toml_file_eol(original_content, doc.to_string()))
207 }
208 FileFormat::Yaml => {
209 if is_multi_document_yaml(original_content) {
213 return serialize_multi_document_yaml(original_content, old_value, new_value);
214 }
215 if let Some(result) = try_preserve_yaml(original_content, old_value, new_value)? {
216 return Ok(result);
217 }
218 if old_value == new_value {
220 Ok(original_content.to_string())
221 } else {
222 let body = serialize_value(new_value, format)?;
223 Ok(preserve::hoist_comments(original_content, &body))
224 }
225 }
226 _ => {
229 if old_value == new_value {
230 Ok(original_content.to_string())
231 } else {
232 serialize_value(new_value, format)
233 }
234 }
235 }
236}
237
238fn cleanup_yaml_cst_whitespace(text: &str) -> String {
247 let eol = crate::write::detect_eol(text);
248 let mut result: String = text
249 .lines()
250 .map(|line| line.trim_end())
251 .collect::<Vec<_>>()
252 .join(eol);
253 if result.is_empty() || !result.ends_with('\n') {
255 result.push_str(eol);
256 }
257 result
258}
259
260#[cfg(test)]
293fn fix_yaml_block_indentation(text: &str) -> String {
294 fix_yaml_block_indentation_with_original(None, text)
295}
296
297fn fix_yaml_block_indentation_with_original(original: Option<&str>, text: &str) -> String {
298 let lines: Vec<&str> = text.lines().collect();
299 let mut result: Vec<String> = Vec::with_capacity(lines.len());
300 let original_trim_ends: Option<HashSet<&str>> =
302 original.map(|orig| orig.lines().map(str::trim_end).collect());
303
304 for i in 0..lines.len() {
305 let line = lines[i];
306 let trimmed = line.trim_start();
307
308 if trimmed.is_empty() {
310 result.push(line.to_string());
311 continue;
312 }
313
314 if trimmed.starts_with('#') {
318 let comment_indent = line.len() - trimmed.len();
319 if comment_indent > 0
320 && let Some(next_indent) = next_entry_indent(&lines, i)
321 && next_indent < comment_indent
322 {
323 result.push(format!("{}{}", " ".repeat(next_indent), trimmed));
324 continue;
325 }
326 result.push(line.to_string());
327 continue;
328 }
329
330 let indent = line.len() - trimmed.len();
331 if is_yaml_sequence_item(trimmed)
335 && let Some(rest) = trimmed.strip_prefix('-')
336 {
337 let rest_trim = rest.trim_start();
338 if rest.len() - rest_trim.len() > 1
339 && (rest_trim.contains(": ") || rest_trim.ends_with(':'))
340 && !is_yaml_sequence_item(rest_trim)
341 && original_trim_ends
342 .as_ref()
343 .is_some_and(|orig| !orig.contains(line.trim_end()))
344 {
345 result.push(format!("{}- {rest_trim}", " ".repeat(indent)));
346 continue;
347 }
348 }
349 let is_mapping_entry = indent > 0
351 && !is_yaml_sequence_item(trimmed)
352 && (trimmed.contains(": ") || trimmed.ends_with(':'));
353
354 if is_mapping_entry
355 && let Some(expected) = expected_sibling_indent(&lines, i, indent)
356 && expected < indent
357 {
358 result.push(format!("{}{}", " ".repeat(expected), trimmed));
359 continue;
360 }
361
362 result.push(line.to_string());
363 }
364
365 let eol = crate::write::detect_eol(text);
366 let mut out = result.join(eol);
367 if text.ends_with('\n') && !out.ends_with('\n') {
368 out.push_str(eol);
369 }
370 out
371}
372
373fn repair_glued_block_sequence_items(text: &str) -> String {
378 let eol = crate::write::detect_eol(text);
379 let mut lines: Vec<String> = Vec::new();
380 for line in text.lines() {
381 let mut remaining = line.to_string();
382 loop {
383 match split_glued_block_sequence_line(&remaining) {
384 Some((first, second)) => {
385 lines.push(first);
386 remaining = second;
387 }
388 None => {
389 lines.push(remaining);
390 break;
391 }
392 }
393 }
394 }
395 let mut out = lines.join(eol);
396 if text.ends_with('\n') && !out.ends_with('\n') {
397 out.push_str(eol);
398 }
399 out
400}
401
402fn split_glued_block_sequence_line(line: &str) -> Option<(String, String)> {
403 let indent_len = line.len() - line.trim_start().len();
404 let trimmed = &line[indent_len..];
405 let after_dash = trimmed.strip_prefix('-')?;
406 let pad_len = after_dash.len() - after_dash.trim_start().len();
407 if pad_len == 0 {
408 return None;
409 }
410 let after_pad = after_dash.trim_start();
411 let first_item = if after_pad.starts_with("{}") {
412 "{}"
413 } else if after_pad.starts_with("[]") {
414 "[]"
415 } else {
416 return None;
417 };
418 let rest = &after_pad[first_item.len()..];
419 let pad = &after_dash[..pad_len];
420 let first = format!("{}-{pad}{first_item}", &line[..indent_len]);
421 let rest_trim = rest.trim_start();
422 if is_yaml_sequence_item(rest_trim) {
425 let between = &rest[..rest.len() - rest_trim.len()];
426 if between.chars().all(|c| c == ' ' || c == '\t') {
427 let second = if between.is_empty() {
428 format!("{}{rest_trim}", &line[..indent_len])
429 } else {
430 format!("{between}{rest_trim}")
431 };
432 return Some((first, second));
433 }
434 }
435 if !rest.is_empty()
439 && !rest_trim.starts_with('#')
440 && !is_yaml_sequence_item(rest_trim)
441 && (rest_trim.contains(": ") || rest_trim.ends_with(':'))
442 {
443 return Some((first, rest.to_string()));
444 }
445 None
446}
447
448fn next_entry_indent(lines: &[&str], i: usize) -> Option<usize> {
451 for line in &lines[i + 1..] {
452 let t = line.trim();
453 if !t.is_empty() && !t.starts_with('#') {
454 return Some(line.len() - t.len());
455 }
456 }
457 None
458}
459
460fn expected_sibling_indent(lines: &[&str], i: usize, current_indent: usize) -> Option<usize> {
464 let is_significant = |l: &&str| {
465 let t = l.trim();
466 !t.is_empty() && !t.starts_with('#')
467 };
468
469 if let Some(next_line) = lines[i + 1..].iter().find(|l| is_significant(l)) {
471 let nt = next_line.trim_start();
472 let ni = next_line.len() - nt.len();
473 let next_is_entry = !is_yaml_sequence_item(nt) && (nt.contains(": ") || nt.ends_with(':'));
474
475 if ni < current_indent && ni > 0 && next_is_entry {
476 let prev_ok = lines[..i]
479 .iter()
480 .rev()
481 .find(|l| is_significant(l))
482 .is_some_and(|l| {
483 let t = l.trim_start();
484 if is_yaml_sequence_item(t) {
485 return false;
486 }
487 let pi = l.len() - t.len();
488 (pi < ni && l.trim_end().ends_with(':')) || pi == ni
489 });
490 if prev_ok {
491 return Some(ni);
492 }
493 }
494 }
495
496 if let Some(prev_line) = lines[..i].iter().rev().find(|l| is_significant(l)) {
499 let pt = prev_line.trim_start();
500 let pi = prev_line.len() - pt.len();
501
502 if pi < current_indent
507 && pi > 0
508 && pt.contains(": ")
509 && !is_yaml_parent_line(pt)
510 && !is_yaml_sequence_item(pt)
511 {
512 return Some(pi);
513 }
514 }
515
516 None
517}
518
519fn is_yaml_sequence_item(trimmed: &str) -> bool {
521 trimmed == "-" || trimmed.starts_with("- ")
522}
523
524fn is_yaml_parent_line(trimmed: &str) -> bool {
533 if trimmed.ends_with(':') {
534 return true;
535 }
536 if let Some(pos) = trimmed.find(": ") {
537 let after = trimmed[pos + 2..].trim();
538 return after.is_empty() || after.starts_with('#');
539 }
540 false
541}
542
543fn try_preserve_yaml(
546 original_content: &str,
547 old_value: &serde_json::Value,
548 new_value: &serde_json::Value,
549) -> anyhow::Result<Option<String>> {
550 use std::str::FromStr;
551
552 let file = yaml_edit::YamlFile::from_str(original_content).map_err(|e| {
553 anyhow::Error::new(crate::exit::ParseErrorError {
554 msg: format!("YAML re-parse for comment preservation: {e}"),
555 })
556 })?;
557 let promoted =
558 yaml_cst::rewrite_yaml_alias_object_edits(original_content, &file, old_value, new_value)?;
559 if let Some(spliced) = promoted.as_deref()
560 && yaml_semantic_eq(spliced, new_value)
561 {
562 return Ok(Some(cleanup_yaml_cst_whitespace(spliced)));
563 }
564 if let Some(spliced) = promoted.as_deref()
568 && let Some(reparsed) = parse_yaml_semantic(spliced)
569 && let Some(grown) = yaml_splice::splice_yaml_array_diffs(spliced, &reparsed, new_value)?
570 {
571 return Ok(Some(grown));
572 }
573 let (file, cst_old) = if let Some(spliced) = promoted.as_deref() {
574 match yaml_file_after_partial_alias_splice(spliced) {
575 Some(pair) => pair,
576 None => return Ok(None),
577 }
578 } else {
579 (file, old_value.clone())
580 };
581
582 if let Some(doc) = file.document() {
583 if let Some(mapping) = doc.as_mapping() {
584 if cst_old.is_object() && new_value.is_object() {
585 return try_preserve_yaml_object(
586 promoted.as_deref().unwrap_or(original_content),
587 &file,
588 &mapping,
589 &cst_old,
590 new_value,
591 );
592 }
593 } else if let Some(seq) = doc.as_sequence()
594 && let (Some(old_arr), Some(new_arr)) = (cst_old.as_array(), new_value.as_array())
595 {
596 return try_preserve_yaml_array(
597 &file,
598 &seq,
599 promoted.as_deref().unwrap_or(original_content),
600 old_arr,
601 new_arr,
602 new_value,
603 );
604 }
605 }
606 Ok(None)
607}
608
609fn yaml_file_after_partial_alias_splice(
612 spliced: &str,
613) -> Option<(yaml_edit::YamlFile, serde_json::Value)> {
614 use std::str::FromStr;
615
616 let reparsed = yaml_edit::YamlFile::from_str(spliced).ok()?;
617 let cst_old = parse_yaml_semantic(spliced)?;
618 Some((reparsed, cst_old))
619}
620
621fn try_preserve_yaml_object(
622 original: &str,
623 file: &yaml_edit::YamlFile,
624 mapping: &yaml_edit::Mapping,
625 old_value: &serde_json::Value,
626 new_value: &serde_json::Value,
627) -> anyhow::Result<Option<String>> {
628 let all_cst_applied = apply_yaml_mapping_diff(mapping, old_value, new_value)?;
629 let result = finalize_yaml_cst_text(original, &file.to_string());
637
638 if yaml_semantic_eq(&result, new_value) {
645 return Ok(Some(result));
646 }
647
648 if !all_cst_applied
651 && let Some(finished) = retry_yaml_cst_empties(original, &result, new_value)?
652 {
653 return Ok(Some(finished));
654 }
655
656 if let Some(reparsed) = parse_yaml_semantic(&result)
665 && let Some(spliced) = yaml_splice::splice_yaml_array_diffs(&result, &reparsed, new_value)?
666 {
667 return Ok(Some(spliced));
668 }
669 Ok(None)
670}
671
672fn finalize_yaml_cst_text(original: &str, cst: &str) -> String {
673 fix_yaml_block_indentation_with_original(
674 Some(original),
675 &repair_glued_empty_flow_after_colon(&repair_glued_block_sequence_items(
676 &cleanup_yaml_cst_whitespace(cst),
677 )),
678 )
679}
680
681fn repair_glued_empty_flow_after_colon(text: &str) -> String {
685 let eol = crate::write::detect_eol(text);
686 let mut lines: Vec<String> = Vec::new();
687 for line in text.lines() {
688 lines.push(repair_glued_empty_flow_line(line));
689 }
690 let mut out = lines.join(eol);
691 if text.ends_with('\n') && !out.ends_with('\n') {
692 out.push_str(eol);
693 }
694 out
695}
696
697fn repair_glued_empty_flow_line(line: &str) -> String {
698 let indent_len = line.len() - line.trim_start().len();
699 let trimmed = &line[indent_len..];
700 if is_yaml_sequence_item(trimmed) {
701 return line.to_string();
702 }
703 let Some((key, rest)) = trimmed.split_once(':') else {
704 return line.to_string();
705 };
706 if key.is_empty() || key.contains('#') {
707 return line.to_string();
708 }
709 if rest.starts_with("{}") || rest.starts_with("[]") {
710 return format!("{}{key}: {rest}", &line[..indent_len]);
711 }
712 line.to_string()
713}
714
715fn retry_yaml_cst_empties(
716 original: &str,
717 start: &str,
718 new_value: &serde_json::Value,
719) -> anyhow::Result<Option<String>> {
720 use std::str::FromStr;
721
722 let mut text = start.to_string();
723 for _ in 0..32 {
724 let Some(current) = parse_yaml_semantic(&text) else {
725 return Ok(None);
726 };
727 if current == *new_value {
728 return Ok(Some(text));
729 }
730 let Ok(file) = yaml_edit::YamlFile::from_str(&text) else {
731 return Ok(None);
732 };
733 let Some(doc) = file.document() else {
734 return Ok(None);
735 };
736 if let Some(mapping) = doc.as_mapping() {
737 apply_yaml_mapping_diff(&mapping, ¤t, new_value)?;
738 } else if let Some(seq) = doc.as_sequence()
739 && let (Some(old_arr), Some(new_arr)) = (current.as_array(), new_value.as_array())
740 {
741 apply_yaml_sequence_diff(&seq, old_arr, new_arr)?;
742 } else {
743 return Ok(None);
744 }
745 let next = finalize_yaml_cst_text(original, &file.to_string());
746 if yaml_semantic_eq(&next, new_value) {
747 return Ok(Some(next));
748 }
749 if next == text {
750 return Ok(None);
751 }
752 text = next;
753 }
754 Ok(None)
755}
756
757fn try_preserve_yaml_array(
758 file: &yaml_edit::YamlFile,
759 seq: &yaml_edit::Sequence,
760 original_content: &str,
761 old_arr: &[serde_json::Value],
762 new_arr: &[serde_json::Value],
763 new_value: &serde_json::Value,
764) -> anyhow::Result<Option<String>> {
765 let applied = if old_arr.len() == new_arr.len() {
766 apply_yaml_sequence_diff(seq, old_arr, new_arr)?
767 } else if new_arr.len() < old_arr.len() {
768 try_remove_subsequence(seq, old_arr, new_arr)
769 } else {
770 false
771 };
772 if old_arr.len() == new_arr.len() || applied {
775 let result = finalize_yaml_cst_text(original_content, &file.to_string());
776 if yaml_semantic_eq(&result, new_value) {
777 return Ok(Some(result));
778 }
779 if !applied
780 && let Some(finished) = retry_yaml_cst_empties(original_content, &result, new_value)?
781 {
782 return Ok(Some(finished));
783 }
784 }
785
786 if new_arr.len() > old_arr.len()
788 && let Some(spliced) =
789 yaml_splice::splice_yaml_root_sequence(original_content, old_arr, new_arr)?
790 && yaml_semantic_eq(&spliced, new_value)
791 && spliced.parse::<yaml_edit::YamlFile>().is_ok()
792 {
793 return Ok(Some(spliced));
794 }
795 Ok(None)
796}
797
798fn parse_yaml_semantic(text: &str) -> Option<serde_json::Value> {
801 let mut value: serde_json::Value = serde_yaml_ng::from_str(text).ok()?;
802 resolve_yaml_merge_keys(&mut value);
803 Some(value)
804}
805
806pub(super) fn yaml_semantic_eq(text: &str, expected: &serde_json::Value) -> bool {
810 parse_yaml_semantic(text).is_some_and(|v| v == *expected)
811}
812
813fn toml_source_for_parse(content: &str) -> Cow<'_, str> {
816 let bytes = content.as_bytes();
817 let mut i = 0;
818 let mut lone = false;
819 while i < bytes.len() {
820 if bytes[i] == b'\r' {
821 if i + 1 < bytes.len() && bytes[i + 1] == b'\n' {
822 i += 2;
823 } else {
824 lone = true;
825 break;
826 }
827 } else {
828 i += 1;
829 }
830 }
831 if !lone {
832 return Cow::Borrowed(content);
833 }
834 let mut out = Vec::with_capacity(bytes.len());
835 i = 0;
836 while i < bytes.len() {
837 if bytes[i] == b'\r' {
838 if i + 1 < bytes.len() && bytes[i + 1] == b'\n' {
839 out.extend_from_slice(b"\r\n");
840 i += 2;
841 } else {
842 out.push(b'\n');
843 i += 1;
844 }
845 } else {
846 out.push(bytes[i]);
847 i += 1;
848 }
849 }
850 Cow::Owned(String::from_utf8(out).expect("input was UTF-8"))
851}
852
853fn restore_toml_file_eol(original: &str, rendered: String) -> String {
855 if crate::write::detect_eol(original) == "\r" {
856 crate::write::normalize_eol(&rendered, crate::write::EolMode::Cr).into_owned()
857 } else {
858 rendered
859 }
860}
861
862pub fn parse_doc(content: &str, format: &FileFormat) -> anyhow::Result<serde_json::Value> {
863 let content = crate::ops::file::strip_utf8_bom(content);
866 match format {
867 FileFormat::Json => {
871 if content.trim().is_empty() {
872 Ok(serde_json::json!({}))
873 } else {
874 serde_json::from_str(content).map_err(|e| {
875 anyhow::Error::new(crate::exit::ParseErrorError { msg: e.to_string() })
876 })
877 }
878 }
879 FileFormat::Yaml => {
880 if is_multi_document_yaml(content) {
881 parse_multi_document_yaml(content).map_err(|e| {
882 if crate::exit::is_parse_error(&e) {
884 e
885 } else {
886 anyhow::Error::new(crate::exit::ParseErrorError { msg: e.to_string() })
887 }
888 })
889 } else {
890 let mut val: serde_json::Value = serde_yaml_ng::from_str(content).map_err(|e| {
891 anyhow::Error::new(crate::exit::ParseErrorError { msg: e.to_string() })
892 })?;
893 resolve_yaml_merge_keys(&mut val);
894 Ok(val)
895 }
896 }
897 FileFormat::Toml => toml_edit::de::from_str(&toml_source_for_parse(content))
898 .map_err(|e| anyhow::Error::new(crate::exit::ParseErrorError { msg: e.to_string() })),
899 }
900}
901
902pub fn parse_doc_for_query(
908 content: &str,
909 format: &FileFormat,
910) -> anyhow::Result<serde_json::Value> {
911 if matches!(format, FileFormat::Yaml) && crate::containment::is_blank_text(content) {
914 return Ok(serde_json::json!({}));
915 }
916 parse_doc(content, format)
917}
918
919pub fn load_for_query(path: &Path) -> anyhow::Result<serde_json::Value> {
925 let display = path.to_string_lossy();
926 let content = crate::files::load_text_strict(path, &display)?;
927 let format = detect_format(&display)?;
928 parse_doc_for_query(&content, &format).with_context(|| format!("parsing {display}"))
929}
930
931pub(crate) fn is_multi_document_yaml(content: &str) -> bool {
937 let rest = content.strip_prefix("---").map_or(content, |after| {
939 if is_after_yaml_marker(after) {
942 skip_to_next_line(after)
943 } else {
944 content
945 }
946 });
947
948 if rest.starts_with("---") && is_after_yaml_marker(&rest[3..]) {
950 return true;
951 }
952
953 for (i, _) in rest.match_indices("\n---") {
955 let after_marker = &rest[i + 4..];
956 if is_after_yaml_marker(after_marker) {
957 return true;
958 }
959 }
960 false
961}
962
963fn is_after_yaml_marker(after: &str) -> bool {
966 if after.is_empty() {
967 return true;
968 }
969 let b = after.as_bytes()[0];
970 if b == b'\n' || b == b'\r' {
972 return true;
973 }
974 if b == b' ' || b == b'\t' || b == b'#' {
976 let rest = after
977 .as_bytes()
978 .iter()
979 .skip_while(|&&c| c == b' ' || c == b'\t')
980 .copied()
981 .next();
982 return rest.is_none() || rest == Some(b'\n') || rest == Some(b'\r') || rest == Some(b'#');
983 }
984 false
985}
986
987fn skip_to_next_line(s: &str) -> &str {
989 match s.find('\n') {
990 Some(pos) => &s[pos + 1..],
991 None => "",
992 }
993}
994
995fn parse_multi_document_yaml(content: &str) -> anyhow::Result<serde_json::Value> {
997 let mut docs = Vec::new();
998 for de in serde_yaml_ng::Deserializer::from_str(content) {
999 let mut val: serde_json::Value = serde_json::Value::deserialize(de)?;
1000 resolve_yaml_merge_keys(&mut val);
1001 docs.push(val);
1002 }
1003 debug_assert!(!docs.is_empty(), "multi-doc YAML produced zero documents");
1006 Ok(serde_json::Value::Array(docs))
1007}
1008
1009fn is_yaml_document_separator_line(line: &str) -> bool {
1011 line.strip_prefix("---").is_some_and(is_after_yaml_marker)
1012}
1013
1014pub(crate) fn split_multi_document_yaml(content: &str) -> (bool, Vec<String>) {
1019 let mut bodies: Vec<String> = Vec::new();
1020 let mut current = String::new();
1021 let mut leading_marker = false;
1022 let mut first_line = true;
1023 let mut saw_body = false;
1024
1025 for line in content.split_inclusive('\n') {
1026 let without_nl = line.trim_end_matches(['\n', '\r']);
1027 if is_yaml_document_separator_line(without_nl) {
1028 if first_line && !saw_body {
1029 leading_marker = true;
1031 first_line = false;
1032 continue;
1033 }
1034 bodies.push(std::mem::take(&mut current));
1035 first_line = false;
1036 continue;
1037 }
1038 first_line = false;
1039 saw_body = true;
1040 current.push_str(line);
1041 }
1042 bodies.push(current);
1043 (leading_marker, bodies)
1044}
1045
1046fn join_multi_document_yaml(leading_marker: bool, docs: &[String], eol: &str) -> String {
1051 let mut out = String::new();
1052 if leading_marker {
1053 out.push_str("---");
1054 out.push_str(eol);
1055 }
1056 for (i, doc) in docs.iter().enumerate() {
1057 if i > 0 {
1058 if !out.ends_with('\n') {
1059 out.push_str(eol);
1060 }
1061 out.push_str("---");
1062 out.push_str(eol);
1063 }
1064 let body = doc.trim_end_matches(['\n', '\r']);
1065 if !body.is_empty() {
1066 out.push_str(body);
1067 out.push_str(eol);
1068 }
1069 }
1070 if out.is_empty() {
1071 out.push_str(eol);
1072 }
1073 out
1074}
1075
1076fn serialize_single_yaml_document(
1078 original_body: &str,
1079 old_value: &serde_json::Value,
1080 new_value: &serde_json::Value,
1081) -> anyhow::Result<String> {
1082 if old_value == new_value && !original_body.is_empty() {
1083 return Ok(original_body.to_string());
1084 }
1085 if !original_body.trim().is_empty()
1086 && let Some(result) = try_preserve_yaml(original_body, old_value, new_value)?
1087 {
1088 return Ok(result);
1089 }
1090 let body = serialize_value(new_value, &FileFormat::Yaml)?;
1091 if original_body.is_empty() {
1092 Ok(body)
1093 } else {
1094 Ok(preserve::hoist_comments(original_body, &body))
1095 }
1096}
1097
1098fn serialize_multi_document_yaml(
1105 original_content: &str,
1106 old_value: &serde_json::Value,
1107 new_value: &serde_json::Value,
1108) -> anyhow::Result<String> {
1109 if old_value == new_value {
1110 return Ok(original_content.to_string());
1111 }
1112
1113 let (leading_marker, bodies) = split_multi_document_yaml(original_content);
1114
1115 let Some(new_docs) = new_value.as_array() else {
1116 let body = serialize_value(new_value, &FileFormat::Yaml)?;
1118 return Ok(preserve::hoist_comments(original_content, &body));
1119 };
1120
1121 let old_docs = old_value.as_array().map(|a| a.as_slice()).unwrap_or(&[]);
1122 let mut out_docs: Vec<String> = Vec::with_capacity(new_docs.len());
1123
1124 if old_docs.len() == new_docs.len() {
1129 for (i, new_doc) in new_docs.iter().enumerate() {
1130 let orig_body = bodies.get(i).map(String::as_str).unwrap_or("");
1131 let old_doc = old_docs.get(i);
1132 match old_doc {
1133 Some(old_doc) if old_doc == new_doc && !orig_body.is_empty() => {
1134 out_docs.push(orig_body.to_string());
1135 }
1136 Some(old_doc) => {
1137 out_docs.push(serialize_single_yaml_document(orig_body, old_doc, new_doc)?);
1138 }
1139 None => {
1140 out_docs.push(serialize_value(new_doc, &FileFormat::Yaml)?);
1141 }
1142 }
1143 }
1144 } else {
1145 let mut used = vec![false; old_docs.len()];
1146 for new_doc in new_docs {
1147 let match_j = old_docs.iter().enumerate().find_map(|(j, old_doc)| {
1148 if !used[j] && old_doc == new_doc {
1149 Some(j)
1150 } else {
1151 None
1152 }
1153 });
1154 match match_j {
1155 Some(j) => {
1156 used[j] = true;
1157 let orig_body = bodies.get(j).map(String::as_str).unwrap_or("");
1158 if !orig_body.is_empty() {
1159 out_docs.push(orig_body.to_string());
1160 } else {
1161 out_docs.push(serialize_value(new_doc, &FileFormat::Yaml)?);
1162 }
1163 }
1164 None => {
1165 out_docs.push(serialize_value(new_doc, &FileFormat::Yaml)?);
1167 }
1168 }
1169 }
1170 }
1171
1172 let eol = crate::write::detect_eol(original_content);
1173 Ok(join_multi_document_yaml(leading_marker, &out_docs, eol))
1174}
1175
1176pub fn parse_value(s: &str) -> serde_json::Value {
1189 if s.starts_with('"')
1191 && s.ends_with('"')
1192 && let Ok(v) = serde_json::from_str::<serde_json::Value>(s)
1193 {
1194 return v;
1195 }
1196 if (s.starts_with('{') || s.starts_with('['))
1198 && let Ok(v) = serde_json::from_str::<serde_json::Value>(s)
1199 {
1200 return v;
1201 }
1202 if s == "true" {
1204 return serde_json::Value::Bool(true);
1205 }
1206 if s == "false" {
1207 return serde_json::Value::Bool(false);
1208 }
1209 if s == "null" {
1211 return serde_json::Value::Null;
1212 }
1213 if let Ok(n) = s.parse::<i64>() {
1215 return serde_json::Value::Number(n.into());
1216 }
1217 if let Ok(n) = s.parse::<f64>()
1219 && let Some(num) = serde_json::Number::from_f64(n)
1220 {
1221 return serde_json::Value::Number(num);
1222 }
1223 serde_json::Value::String(s.to_string())
1225}
1226
1227fn push_key_quoted(buf: &mut String, k: &str) {
1236 if k.contains('.') || k.contains('[') || k.contains(']') || k.contains('"') {
1237 buf.push('"');
1238 buf.push_str(&k.replace('"', "\\\""));
1239 buf.push('"');
1240 } else {
1241 buf.push_str(k);
1242 }
1243}
1244
1245pub fn flatten_value<'a>(
1246 value: &'a serde_json::Value,
1247 buf: &mut String,
1248 out: &mut Vec<(String, &'a serde_json::Value)>,
1249) {
1250 match value {
1251 serde_json::Value::Object(map) if !map.is_empty() => {
1252 for (k, v) in map {
1253 let restore = buf.len();
1254 if !buf.is_empty() {
1255 buf.push('.');
1256 }
1257 push_key_quoted(buf, k);
1258 flatten_value(v, buf, out);
1259 buf.truncate(restore);
1260 }
1261 }
1262 serde_json::Value::Array(arr) if !arr.is_empty() => {
1263 for (i, v) in arr.iter().enumerate() {
1264 let restore = buf.len();
1265 buf.push('[');
1266 let _ = std::fmt::Write::write_fmt(buf, format_args!("{i}"));
1267 buf.push(']');
1268 flatten_value(v, buf, out);
1269 buf.truncate(restore);
1270 }
1271 }
1272 _ => {
1273 out.push((buf.clone(), value));
1274 }
1275 }
1276}
1277
1278#[derive(Debug, Clone, serde::Serialize)]
1280pub struct DiffEntry {
1281 pub path: String,
1282 pub kind: &'static str,
1283 #[serde(skip_serializing_if = "Option::is_none")]
1284 pub old_value: Option<serde_json::Value>,
1285 #[serde(skip_serializing_if = "Option::is_none")]
1286 pub new_value: Option<serde_json::Value>,
1287}
1288
1289pub fn diff_values(
1294 a: &serde_json::Value,
1295 b: &serde_json::Value,
1296 buf: &mut String,
1297 out: &mut Vec<DiffEntry>,
1298) {
1299 match (a, b) {
1300 (serde_json::Value::Object(ma), serde_json::Value::Object(mb)) => {
1301 for (k, va) in ma {
1302 let restore = buf.len();
1303 if !buf.is_empty() {
1304 buf.push('.');
1305 }
1306 push_key_quoted(buf, k);
1307 if let Some(vb) = mb.get(k) {
1308 diff_values(va, vb, buf, out);
1309 } else {
1310 out.push(DiffEntry {
1311 path: buf.clone(),
1312 kind: "removed",
1313 old_value: Some(va.clone()),
1314 new_value: None,
1315 });
1316 }
1317 buf.truncate(restore);
1318 }
1319 for (k, vb) in mb {
1320 if !ma.contains_key(k) {
1321 let restore = buf.len();
1322 if !buf.is_empty() {
1323 buf.push('.');
1324 }
1325 push_key_quoted(buf, k);
1326 out.push(DiffEntry {
1327 path: buf.clone(),
1328 kind: "added",
1329 old_value: None,
1330 new_value: Some(vb.clone()),
1331 });
1332 buf.truncate(restore);
1333 }
1334 }
1335 }
1336 (serde_json::Value::Array(aa), serde_json::Value::Array(ab)) => {
1337 let max_len = aa.len().max(ab.len());
1338 for i in 0..max_len {
1339 let restore = buf.len();
1340 buf.push('[');
1341 let _ = std::fmt::Write::write_fmt(buf, format_args!("{i}"));
1342 buf.push(']');
1343 match (aa.get(i), ab.get(i)) {
1344 (Some(va), Some(vb)) => diff_values(va, vb, buf, out),
1345 (Some(va), None) => out.push(DiffEntry {
1346 path: buf.clone(),
1347 kind: "removed",
1348 old_value: Some(va.clone()),
1349 new_value: None,
1350 }),
1351 (None, Some(vb)) => out.push(DiffEntry {
1352 path: buf.clone(),
1353 kind: "added",
1354 old_value: None,
1355 new_value: Some(vb.clone()),
1356 }),
1357 (None, None) => {}
1358 }
1359 buf.truncate(restore);
1360 }
1361 }
1362 _ => {
1363 if a != b {
1364 out.push(DiffEntry {
1365 path: buf.clone(),
1366 kind: "changed",
1367 old_value: Some(a.clone()),
1368 new_value: Some(b.clone()),
1369 });
1370 }
1371 }
1372 }
1373}
1374
1375fn resolve_yaml_merge_keys(value: &mut serde_json::Value) {
1384 resolve_yaml_merge_keys_inner(value, 0);
1385}
1386
1387fn resolve_yaml_merge_keys_inner(value: &mut serde_json::Value, depth: usize) {
1388 if depth >= navigate::MAX_MERGE_DEPTH {
1389 return;
1390 }
1391 match value {
1392 serde_json::Value::Object(map) => {
1393 for v in map.values_mut() {
1395 resolve_yaml_merge_keys_inner(v, depth + 1);
1396 }
1397
1398 if let Some(merge_val) = map.remove("<<") {
1400 match merge_val {
1401 serde_json::Value::Object(merged) => {
1402 for (k, v) in merged {
1403 map.entry(k).or_insert(v);
1404 }
1405 }
1406 serde_json::Value::Array(arr) => {
1407 for item in arr {
1409 if let serde_json::Value::Object(merged) = item {
1410 for (k, v) in merged {
1411 map.entry(k).or_insert(v);
1412 }
1413 }
1414 }
1415 }
1416 _ => {
1417 map.insert("<<".to_string(), merge_val);
1419 }
1420 }
1421 }
1422 }
1423 serde_json::Value::Array(arr) => {
1424 for v in arr {
1425 resolve_yaml_merge_keys_inner(v, depth + 1);
1426 }
1427 }
1428 _ => {}
1429 }
1430}
1431
1432#[derive(Debug)]
1442pub enum DocMutation {
1443 Set {
1444 selector: String,
1445 value: serde_json::Value,
1446 },
1447 Delete {
1448 selector: String,
1449 },
1450 Merge {
1451 selector: Option<String>,
1453 value: serde_json::Value,
1454 },
1455 Append {
1456 selector: String,
1457 value: serde_json::Value,
1458 },
1459 Prepend {
1460 selector: String,
1461 value: serde_json::Value,
1462 },
1463 Update {
1464 selector: String,
1465 value: serde_json::Value,
1466 },
1467 Move {
1468 from: String,
1469 to: String,
1470 },
1471 Ensure {
1472 selector: String,
1473 value: serde_json::Value,
1474 },
1475 DeleteWhere {
1476 selector: String,
1477 predicate: String,
1478 },
1479}
1480
1481#[derive(Debug)]
1483pub enum MutationResult {
1484 Applied,
1486 Removed(usize),
1492 NoMatch,
1494 AlreadyExists,
1496 TypeError(String),
1500}
1501
1502pub fn apply_doc_mutation(
1509 root: &mut serde_json::Value,
1510 mutation: DocMutation,
1511) -> anyhow::Result<MutationResult> {
1512 match mutation {
1513 DocMutation::Set { selector, value } => {
1514 let sel = selector::parse_anyhow(&selector)?;
1515 set_at_path(root, &sel, value)?;
1516 Ok(MutationResult::Applied)
1517 }
1518 DocMutation::Delete { selector } => {
1519 let sel = selector::parse_anyhow(&selector)?;
1520 if delete_at_selector(root, &sel)? {
1522 Ok(MutationResult::Removed(1))
1523 } else {
1524 Ok(MutationResult::NoMatch)
1525 }
1526 }
1527 DocMutation::Merge { selector, value } => {
1528 let target = if let Some(sel) = selector.as_deref().filter(|s| !s.is_empty()) {
1534 let parsed = selector::parse_anyhow(sel)?;
1535 navigate_mut(root, &parsed, false, "doc.merge")?
1536 } else {
1537 root
1538 };
1539 if target.is_array() {
1540 return Ok(MutationResult::TypeError(
1541 "doc merge: target is a top-level array (multi-document YAML or JSON \
1542 array); deep-merge would replace the whole stream with the overlay. \
1543 Pass a selector to an object document (e.g. `--selector 0` / plan \
1544 `\"selector\": \"0\"`) or use doc.set under `0.` / `[0].`"
1545 .into(),
1546 ));
1547 }
1548 if !target.is_object() && !value.is_object() {
1549 }
1552 reject_blank_merge_overlay(&value)?;
1553 deep_merge(target, &value);
1554 Ok(MutationResult::Applied)
1555 }
1556 DocMutation::Append { selector, value } => {
1557 let sel = selector::parse_anyhow(&selector)?;
1558 let target = navigate_mut(root, &sel, false, "doc.append")?;
1559 match target.as_array_mut() {
1560 Some(arr) => {
1561 arr.push(value);
1562 Ok(MutationResult::Applied)
1563 }
1564 None => Ok(MutationResult::TypeError(format!(
1565 "doc append: target at '{selector}' is not an array"
1566 ))),
1567 }
1568 }
1569 DocMutation::Prepend { selector, value } => {
1570 let sel = selector::parse_anyhow(&selector)?;
1571 let target = navigate_mut(root, &sel, false, "doc.prepend")?;
1572 match target.as_array_mut() {
1573 Some(arr) => {
1574 arr.insert(0, value);
1575 Ok(MutationResult::Applied)
1576 }
1577 None => Ok(MutationResult::TypeError(format!(
1578 "doc prepend: target at '{selector}' is not an array"
1579 ))),
1580 }
1581 }
1582 DocMutation::Update { selector, value } => {
1583 let sel = selector::parse_anyhow(&selector)?;
1584 if update_matching(root, &sel, &value)? == 0 {
1585 if let Some(hint) = query::array_root_bare_key_hint(root, &sel) {
1587 Ok(MutationResult::TypeError(hint))
1588 } else {
1589 Ok(MutationResult::NoMatch)
1590 }
1591 } else {
1592 Ok(MutationResult::Applied)
1593 }
1594 }
1595 DocMutation::Move { from, to } => {
1596 let from_sel = selector::parse_anyhow(&from)?;
1597 let to_sel = selector::parse_anyhow(&to)?;
1598 move_at_path(root, &from_sel, &to_sel)?;
1599 Ok(MutationResult::Applied)
1600 }
1601 DocMutation::Ensure { selector, value } => {
1602 let sel = selector::parse_anyhow(&selector)?;
1603 if !selector::eval_result(root, &sel)?.is_empty() {
1604 Ok(MutationResult::AlreadyExists)
1605 } else {
1606 set_at_path(root, &sel, value)?;
1607 Ok(MutationResult::Applied)
1608 }
1609 }
1610 DocMutation::DeleteWhere {
1611 selector,
1612 predicate,
1613 } => {
1614 let sel = selector::parse_anyhow(&selector)?;
1615 let removed = delete_where(root, &sel, &predicate)?;
1616 if removed == 0 {
1617 Ok(MutationResult::NoMatch)
1618 } else {
1619 Ok(MutationResult::Removed(removed))
1620 }
1621 }
1622 }
1623}
1624
1625#[cfg(test)]
1626mod tests;