1use std::cmp::Ordering as CmpOrdering;
11use std::collections::HashSet;
12use std::ffi::{OsStr, OsString};
13use std::fs::{self, File, OpenOptions};
14use std::io::{self, Read, Write};
15use std::path::{Path, PathBuf};
16use std::sync::atomic::{AtomicU64, Ordering};
17
18use clap::{Args, CommandFactory, Parser, Subcommand, error::ErrorKind};
19use yaml_rt_core::{DiagnosticColor, JsonPointer, YamlDoc, YamlError, YamlFragment, YamlPatch};
20use yaml_rt_rfc9535::{JsonPath, QueryMatches};
21
22mod query;
23
24use query::{query_matches, run_query};
25
26const FAILURE: i32 = 1;
27const USAGE: i32 = 2;
28static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
29
30pub fn run<I, T>(
32 args: I,
33 stdin: &mut dyn Read,
34 stdout: &mut dyn Write,
35 stderr: &mut dyn Write,
36) -> i32
37where
38 I: IntoIterator<Item = T>,
39 T: Into<OsString> + Clone,
40{
41 run_with_options(args, stdin, stdout, stderr, RunOptions::default())
42}
43
44#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
46pub struct RunOptions {
47 pub color: bool,
49}
50
51pub fn run_with_options<I, T>(
53 args: I,
54 stdin: &mut dyn Read,
55 stdout: &mut dyn Write,
56 stderr: &mut dyn Write,
57 options: RunOptions,
58) -> i32
59where
60 I: IntoIterator<Item = T>,
61 T: Into<OsString> + Clone,
62{
63 let cli = match Cli::try_parse_from(args) {
64 Ok(cli) => cli,
65 Err(error) => {
66 let display_only = matches!(
67 error.kind(),
68 ErrorKind::DisplayHelp | ErrorKind::DisplayVersion
69 );
70 let write_result = if display_only {
71 write!(stdout, "{error}")
72 } else {
73 write!(stderr, "{error}")
74 };
75 if write_result.is_err() {
76 return FAILURE;
77 }
78 return if display_only { 0 } else { USAGE };
79 }
80 };
81 if let Err(message) = cli.operation.validate() {
82 let error = Cli::command().error(ErrorKind::ArgumentConflict, message);
83 if write!(stderr, "{error}").is_err() {
84 return FAILURE;
85 }
86 return USAGE;
87 }
88 match execute(&cli.operation, stdin, stdout, options) {
89 Ok(()) | Err(RunError::BrokenPipe) => 0,
90 Err(RunError::Usage(message)) => {
91 let error = Cli::command().error(ErrorKind::ArgumentConflict, message);
92 if write!(stderr, "{error}").is_err() {
93 return FAILURE;
94 }
95 USAGE
96 }
97 Err(RunError::Batch {
98 diagnostics,
99 summary,
100 }) => {
101 for diagnostic in diagnostics {
102 let _ = writeln!(stderr, "yaml-rt: {diagnostic}");
103 }
104 let _ = writeln!(stderr, "yaml-rt: {summary}");
105 FAILURE
106 }
107 Err(RunError::Message(message)) => {
108 let _ = writeln!(stderr, "yaml-rt: {message}");
109 FAILURE
110 }
111 Err(RunError::Diagnostic(diagnostic)) => {
112 let _ = writeln!(stderr, "{diagnostic}");
113 FAILURE
114 }
115 }
116}
117
118#[derive(Parser)]
119#[command(
120 name = "yaml-rt",
121 version,
122 about = "Query and edit YAML while preserving presentation",
123 subcommand_required = true,
124 arg_required_else_help = true
125)]
126struct Cli {
127 #[command(subcommand)]
128 operation: Operation,
129}
130
131#[derive(Subcommand)]
132enum Operation {
133 #[command(visible_alias = "v")]
135 Validate(ValidateArgs),
136 #[command(visible_alias = "q")]
138 Query(QueryArgs),
139 #[command(visible_alias = "g")]
141 Get(ReadArgs),
142 #[command(visible_alias = "a")]
144 Add(ValueMutationArgs),
145 #[command(visible_alias = "d")]
147 Remove(MutationArgs),
148 #[command(visible_alias = "r")]
150 Replace(ValueMutationArgs),
151 #[command(visible_alias = "k")]
153 RenameKey(RenameKeyArgs),
154 #[command(visible_alias = "m")]
156 Move(FromMutationArgs),
157 #[command(visible_alias = "c")]
159 Copy(FromMutationArgs),
160 #[command(visible_alias = "t")]
162 Test(ValueArgs),
163 #[command(visible_alias = "p")]
165 Patch(PatchArgs),
166}
167
168#[derive(Args)]
169struct ValidateArgs {
170 #[arg(value_name = "FILE")]
172 file: Option<PathBuf>,
173}
174
175#[derive(Args)]
176struct TargetArgs {
177 #[arg(value_name = "FILE")]
179 file: Option<PathBuf>,
180 #[arg(long, value_name = "INDEX")]
182 doc: Option<usize>,
183}
184
185#[derive(Args)]
186struct PathArgs {
187 #[arg(
189 value_name = "PATH_OR_FILE",
190 allow_hyphen_values = true,
191 required_unless_present = "query"
192 )]
193 path_or_file: Option<String>,
194 #[arg(long, value_name = "QUERY")]
196 query: Option<String>,
197 #[command(flatten)]
198 target: TargetArgs,
199}
200
201#[derive(Args)]
202struct FromPathArgs {
203 #[arg(value_name = "FROM", allow_hyphen_values = true)]
204 from: String,
205 #[arg(value_name = "PATH", allow_hyphen_values = true)]
206 path: String,
207 #[command(flatten)]
208 target: TargetArgs,
209}
210
211#[derive(Args)]
212struct OutputArgs {
213 #[arg(short, long, value_name = "FILE")]
215 output: Option<PathBuf>,
216}
217
218#[derive(Args)]
219struct MutationOutputArgs {
220 #[command(flatten)]
221 output: OutputArgs,
222 #[arg(short, long, conflicts_with = "output")]
224 in_place: bool,
225}
226
227#[derive(Args)]
228#[group(required = true, multiple = false)]
229struct ValueSourceArgs {
230 #[arg(long, value_name = "YAML", allow_hyphen_values = true)]
232 value: Option<String>,
233 #[arg(long, value_name = "FILE")]
235 value_file: Option<PathBuf>,
236}
237
238#[derive(Args)]
239#[group(required = true, multiple = false)]
240struct PatchSourceArgs {
241 #[arg(long, value_name = "YAML", allow_hyphen_values = true)]
243 patch: Option<String>,
244 #[arg(long, value_name = "FILE")]
246 patch_file: Option<PathBuf>,
247}
248
249#[derive(Args)]
250struct ReadArgs {
251 #[command(flatten)]
252 path: PathArgs,
253 #[command(flatten)]
254 output: OutputArgs,
255}
256
257#[derive(Args)]
258struct QueryArgs {
259 #[arg(value_name = "QUERY")]
261 query: String,
262 #[command(flatten)]
263 target: TargetArgs,
264 #[command(flatten)]
265 output: OutputArgs,
266}
267
268#[derive(Args)]
269struct MutationArgs {
270 #[command(flatten)]
271 path: PathArgs,
272 #[command(flatten)]
273 output: MutationOutputArgs,
274}
275
276#[derive(Args)]
277struct FromMutationArgs {
278 #[command(flatten)]
279 path: FromPathArgs,
280 #[command(flatten)]
281 output: MutationOutputArgs,
282}
283
284#[derive(Args)]
285struct ValueArgs {
286 #[command(flatten)]
287 path: PathArgs,
288 #[command(flatten)]
289 value: ValueSourceArgs,
290}
291
292#[derive(Args)]
293struct ValueMutationArgs {
294 #[command(flatten)]
295 value: ValueArgs,
296 #[command(flatten)]
297 output: MutationOutputArgs,
298}
299
300#[derive(Args)]
301struct RenameKeyArgs {
302 #[command(flatten)]
303 path: PathArgs,
304 #[arg(long, value_name = "KEY")]
306 to: String,
307 #[command(flatten)]
308 output: MutationOutputArgs,
309}
310
311#[derive(Args)]
312struct PatchArgs {
313 #[command(flatten)]
314 target: TargetArgs,
315 #[command(flatten)]
316 source: PatchSourceArgs,
317 #[command(flatten)]
318 output: MutationOutputArgs,
319}
320
321fn execute(
322 operation: &Operation,
323 stdin: &mut dyn Read,
324 stdout: &mut dyn Write,
325 options: RunOptions,
326) -> Result<(), RunError> {
327 let targets = resolve_targets(operation.input_path())?;
328 if matches!(targets, InputTargets::Batch { .. })
329 && operation
330 .mutation_output()
331 .is_some_and(|output| !output.in_place)
332 {
333 return Err(RunError::usage(
334 "directory targets require --in-place for mutations",
335 ));
336 }
337 let target_uses_stdin = matches!(targets, InputTargets::Stdin);
338 if matches!(operation, Operation::Patch(arguments) if arguments.source.patch_file.as_deref() == Some(Path::new("-")))
339 && target_uses_stdin
340 {
341 return Err(RunError::message(
342 "target YAML and --patch-file cannot both read stdin",
343 ));
344 }
345 let prepared = prepare_operation(operation, target_uses_stdin, stdin)?;
346 match targets {
347 InputTargets::Stdin => {
348 let input = read_stream(stdin, "stdin")?;
349 execute_one(operation, &prepared, None, input, stdout, false, options)
350 }
351 InputTargets::File(path) => {
352 let input = read_target(&path)?;
353 execute_one(
354 operation,
355 &prepared,
356 Some(&path),
357 input,
358 stdout,
359 false,
360 options,
361 )
362 }
363 InputTargets::Batch {
364 files,
365 discovery_failures,
366 } => execute_batch(
367 operation,
368 &prepared,
369 &files,
370 discovery_failures,
371 stdout,
372 options,
373 ),
374 }
375}
376
377fn execute_one(
378 operation: &Operation,
379 prepared: &PreparedOperation,
380 input_path: Option<&Path>,
381 input: String,
382 stdout: &mut dyn Write,
383 batch_capture: bool,
384 options: RunOptions,
385) -> Result<(), RunError> {
386 let source_name = input_path
387 .map(|path| path.display().to_string())
388 .unwrap_or_else(|| "<stdin>".to_owned());
389 let mut doc = YamlDoc::parse(&input)
390 .map_err(|error| RunError::yaml_diagnostic(error, &input, &source_name, options.color))?;
391 if matches!(operation, Operation::Validate(_)) {
392 return Ok(());
393 }
394 let target = operation.target();
395 let document = select_document(&doc, target.doc)?;
396
397 if let Operation::Query(arguments) = operation {
398 let output = run_query(
399 &doc,
400 document,
401 prepared
402 .query
403 .as_ref()
404 .expect("query operation is prepared"),
405 )
406 .map_err(RunError::display)?;
407 return write_result(
408 output.as_bytes(),
409 if batch_capture {
410 None
411 } else {
412 arguments.output.output.as_deref()
413 },
414 input_path,
415 stdout,
416 );
417 }
418
419 if let Operation::Patch(arguments) = operation {
420 doc.apply_patch(
421 document,
422 prepared
423 .patch
424 .as_ref()
425 .expect("patch operation is prepared"),
426 )
427 .map_err(RunError::display)?;
428 return write_mutation(&doc, &arguments.output, input_path, stdout);
429 }
430
431 if operation.selection_query().is_some() {
432 let matches = query_matches(
433 &doc,
434 document,
435 prepared
436 .query
437 .as_ref()
438 .expect("query-targeted operation is prepared"),
439 )
440 .map_err(RunError::display)?;
441 let mut output = CommandOutput {
442 input_path,
443 stdout,
444 batch_capture,
445 };
446 return execute_query_targeted(
447 operation,
448 &mut doc,
449 document,
450 &matches,
451 prepared.value.as_ref(),
452 &mut output,
453 );
454 }
455
456 let path = prepared
457 .path
458 .as_ref()
459 .expect("pointer operation is prepared");
460 let from = prepared.from.as_ref();
461 match operation {
462 Operation::Validate(_) => unreachable!("validate returned after parsing"),
463 Operation::Query(_) => unreachable!("query returned before pointer operations"),
464 Operation::Get(arguments) => {
465 let node = doc
466 .resolve_pointer(document, path)
467 .map_err(RunError::display)?;
468 let output = doc.extract_node(node).map_err(|error| {
469 RunError::yaml_diagnostic(error, doc.as_source(), &source_name, options.color)
470 })?;
471 write_result(
472 output.as_bytes(),
473 if batch_capture {
474 None
475 } else {
476 arguments.output.output.as_deref()
477 },
478 input_path,
479 stdout,
480 )
481 }
482 Operation::Test(_) => {
483 let equal = doc
484 .test_at(
485 document,
486 path,
487 prepared.value.as_ref().expect("Clap requires a value"),
488 )
489 .map_err(RunError::display)?;
490 if equal {
491 Ok(())
492 } else {
493 Err(RunError::message(format!(
494 "test failed at {:?}: values are not semantically equal",
495 path.as_str()
496 )))
497 }
498 }
499 Operation::Add(arguments) => {
500 doc.add_at(
501 document,
502 path,
503 prepared.value.as_ref().expect("Clap requires a value"),
504 )
505 .map_err(RunError::display)?;
506 write_mutation(&doc, &arguments.output, input_path, stdout)
507 }
508 Operation::Remove(arguments) => {
509 doc.remove_at(document, path).map_err(RunError::display)?;
510 write_mutation(&doc, &arguments.output, input_path, stdout)
511 }
512 Operation::Replace(arguments) => {
513 doc.replace_at(
514 document,
515 path,
516 prepared.value.as_ref().expect("Clap requires a value"),
517 )
518 .map_err(RunError::display)?;
519 write_mutation(&doc, &arguments.output, input_path, stdout)
520 }
521 Operation::RenameKey(arguments) => {
522 doc.rename_key_at(document, path, &arguments.to)
523 .map_err(RunError::display)?;
524 write_mutation(&doc, &arguments.output, input_path, stdout)
525 }
526 Operation::Move(arguments) => {
527 doc.move_at(document, from.expect("Clap requires from"), path)
528 .map_err(RunError::display)?;
529 write_mutation(&doc, &arguments.output, input_path, stdout)
530 }
531 Operation::Copy(arguments) => {
532 doc.copy_at(document, from.expect("Clap requires from"), path)
533 .map_err(RunError::display)?;
534 write_mutation(&doc, &arguments.output, input_path, stdout)
535 }
536 Operation::Patch(_) => unreachable!("patch returned before pointer operations"),
537 }
538}
539
540struct PreparedOperation {
541 path: Option<JsonPointer>,
542 from: Option<JsonPointer>,
543 query: Option<JsonPath>,
544 value: Option<YamlFragment>,
545 patch: Option<YamlPatch>,
546}
547
548fn prepare_operation(
549 operation: &Operation,
550 target_uses_stdin: bool,
551 stdin: &mut dyn Read,
552) -> Result<PreparedOperation, RunError> {
553 let query = operation
554 .query_source()
555 .map(JsonPath::parse)
556 .transpose()
557 .map_err(RunError::display)?;
558 let path =
559 if query.is_none() && !matches!(operation, Operation::Patch(_) | Operation::Validate(_)) {
560 Some(JsonPointer::parse(operation.path()).map_err(RunError::display)?)
561 } else {
562 None
563 };
564 let from = operation
565 .from()
566 .map(JsonPointer::parse)
567 .transpose()
568 .map_err(RunError::display)?;
569 let value = read_value(operation.value(), target_uses_stdin, stdin)?;
570 let patch = match operation {
571 Operation::Patch(arguments) => Some(read_patch(&arguments.source, stdin)?),
572 _ => None,
573 };
574 Ok(PreparedOperation {
575 path,
576 from,
577 query,
578 value,
579 patch,
580 })
581}
582
583enum InputTargets {
584 Stdin,
585 File(PathBuf),
586 Batch {
587 files: Vec<BatchTarget>,
588 discovery_failures: Vec<DiscoveryFailure>,
589 },
590}
591
592struct BatchTarget {
593 path: PathBuf,
594 relative: PathBuf,
595}
596
597struct DiscoveryFailure {
598 relative: PathBuf,
599 message: String,
600}
601
602fn resolve_targets(path: Option<&Path>) -> Result<InputTargets, RunError> {
603 let path = match path {
604 None => std::env::current_dir().map_err(|error| {
605 RunError::message(format!("cannot determine current directory: {error}"))
606 })?,
607 Some(path) if path == Path::new("-") => return Ok(InputTargets::Stdin),
608 Some(path) => path.to_owned(),
609 };
610 if fs::symlink_metadata(&path).is_ok_and(|metadata| metadata.is_dir()) {
611 let (files, discovery_failures) = discover_yaml_files(&path);
612 Ok(InputTargets::Batch {
613 files,
614 discovery_failures,
615 })
616 } else {
617 Ok(InputTargets::File(path))
618 }
619}
620
621fn discover_yaml_files(root: &Path) -> (Vec<BatchTarget>, Vec<DiscoveryFailure>) {
622 let mut files = Vec::new();
623 let mut failures = Vec::new();
624 discover_directory(root, root, &mut files, &mut failures);
625 files.sort_by(|left, right| left.relative.cmp(&right.relative));
626 failures.sort_by(|left, right| left.relative.cmp(&right.relative));
627 (files, failures)
628}
629
630fn discover_directory(
631 root: &Path,
632 directory: &Path,
633 files: &mut Vec<BatchTarget>,
634 failures: &mut Vec<DiscoveryFailure>,
635) {
636 let entries = match fs::read_dir(directory) {
637 Ok(entries) => entries,
638 Err(error) => {
639 failures.push(DiscoveryFailure {
640 relative: relative_to(root, directory),
641 message: format!("cannot read directory: {error}"),
642 });
643 return;
644 }
645 };
646 let mut entries = entries
647 .filter_map(|entry| match entry {
648 Ok(entry) => Some(entry),
649 Err(error) => {
650 failures.push(DiscoveryFailure {
651 relative: relative_to(root, directory),
652 message: format!("cannot read directory entry: {error}"),
653 });
654 None
655 }
656 })
657 .collect::<Vec<_>>();
658 entries.sort_by_key(std::fs::DirEntry::file_name);
659 for entry in entries {
660 let path = entry.path();
661 let file_type = match entry.file_type() {
662 Ok(file_type) => file_type,
663 Err(error) => {
664 failures.push(DiscoveryFailure {
665 relative: relative_to(root, &path),
666 message: format!("cannot inspect path: {error}"),
667 });
668 continue;
669 }
670 };
671 if file_type.is_symlink() {
672 continue;
673 }
674 if file_type.is_dir() {
675 discover_directory(root, &path, files, failures);
676 } else if file_type.is_file() && has_yaml_extension(&path) {
677 files.push(BatchTarget {
678 relative: relative_to(root, &path),
679 path,
680 });
681 }
682 }
683}
684
685fn relative_to(root: &Path, path: &Path) -> PathBuf {
686 path.strip_prefix(root)
687 .ok()
688 .filter(|path| !path.as_os_str().is_empty())
689 .unwrap_or_else(|| Path::new("."))
690 .to_owned()
691}
692
693fn has_yaml_extension(path: &Path) -> bool {
694 path.extension()
695 .and_then(OsStr::to_str)
696 .is_some_and(|extension| {
697 extension.eq_ignore_ascii_case("yaml") || extension.eq_ignore_ascii_case("yml")
698 })
699}
700
701fn execute_batch(
702 operation: &Operation,
703 prepared: &PreparedOperation,
704 files: &[BatchTarget],
705 discovery_failures: Vec<DiscoveryFailure>,
706 stdout: &mut dyn Write,
707 options: RunOptions,
708) -> Result<(), RunError> {
709 if let Some(output) = operation.read_output()
710 && let Some(input) = files
711 .iter()
712 .find(|input| paths_equivalent(&input.path, output))
713 {
714 return Err(RunError::message(format!(
715 "--output must not name input file {}",
716 render_batch_path(&input.relative)
717 )));
718 }
719
720 let mut diagnostics = discovery_failures
721 .iter()
722 .map(|failure| {
723 format!(
724 "{}: {}",
725 render_batch_path(&failure.relative),
726 failure.message
727 )
728 })
729 .collect::<Vec<_>>();
730 let mut succeeded = 0;
731 let mut failed = 0;
732 let mut combined_output = Vec::new();
733 for input in files {
734 let source = match read_target(&input.path) {
735 Ok(source) => source,
736 Err(RunError::Message(message)) => {
737 diagnostics.push(format!("{}: {message}", render_batch_path(&input.relative)));
738 failed += 1;
739 continue;
740 }
741 Err(error) => return Err(error),
742 };
743 let mut result = Vec::new();
744 match execute_one(
745 operation,
746 prepared,
747 Some(&input.path),
748 source,
749 &mut result,
750 true,
751 options,
752 ) {
753 Ok(()) => {
754 succeeded += 1;
755 if operation.should_emit_batch_result(&result) {
756 append_batch_result(&mut combined_output, &input.relative, &result);
757 }
758 }
759 Err(RunError::Message(message)) => {
760 diagnostics.push(format!("{}: {message}", render_batch_path(&input.relative)));
761 failed += 1;
762 }
763 Err(RunError::Diagnostic(diagnostic)) => {
764 diagnostics.push(diagnostic);
765 failed += 1;
766 }
767 Err(error) => return Err(error),
768 }
769 }
770
771 if operation.has_read_output() {
772 write_result(&combined_output, operation.read_output(), None, stdout)?;
773 }
774 if diagnostics.is_empty() {
775 Ok(())
776 } else {
777 Err(RunError::Batch {
778 diagnostics,
779 summary: format!(
780 "processed {} YAML files: {succeeded} succeeded, {failed} failed; {} traversal errors",
781 files.len(),
782 discovery_failures.len()
783 ),
784 })
785 }
786}
787
788fn append_batch_result(output: &mut Vec<u8>, path: &Path, result: &[u8]) {
789 if !output.is_empty() {
790 output.push(b'\n');
791 }
792 writeln!(output, "==> {} <==", render_batch_path(path)).expect("writing to a Vec cannot fail");
793 output.extend_from_slice(result);
794 if !result.is_empty() && !result.ends_with(b"\n") {
795 output.push(b'\n');
796 }
797}
798
799fn render_batch_path(path: &Path) -> String {
800 path.components()
801 .map(|component| component.as_os_str().to_string_lossy())
802 .collect::<Vec<_>>()
803 .join("/")
804}
805
806impl Operation {
807 fn validate(&self) -> Result<(), String> {
808 let path = match self {
809 Self::Get(args) => Some(&args.path),
810 Self::Add(args) | Self::Replace(args) => Some(&args.value.path),
811 Self::RenameKey(args) => Some(&args.path),
812 Self::Remove(args) => Some(&args.path),
813 Self::Test(args) => Some(&args.path),
814 _ => None,
815 };
816 if let Some(path) = path {
817 path.validate()?;
818 }
819 Ok(())
820 }
821
822 fn target(&self) -> &TargetArgs {
823 match self {
824 Self::Validate(_) => unreachable!("validate does not select a document"),
825 Self::Query(args) => &args.target,
826 Self::Get(args) => &args.path.target,
827 Self::Add(args) | Self::Replace(args) => &args.value.path.target,
828 Self::RenameKey(args) => &args.path.target,
829 Self::Remove(args) => &args.path.target,
830 Self::Move(args) | Self::Copy(args) => &args.path.target,
831 Self::Test(args) => &args.path.target,
832 Self::Patch(args) => &args.target,
833 }
834 }
835
836 fn path(&self) -> &str {
837 match self {
838 Self::Validate(_) | Self::Query(_) | Self::Patch(_) => {
839 unreachable!("operation does not use a JSON Pointer argument")
840 }
841 Self::Get(args) => args.path.pointer(),
842 Self::Add(args) | Self::Replace(args) => args.value.path.pointer(),
843 Self::RenameKey(args) => args.path.pointer(),
844 Self::Remove(args) => args.path.pointer(),
845 Self::Move(args) | Self::Copy(args) => &args.path.path,
846 Self::Test(args) => args.path.pointer(),
847 }
848 }
849
850 fn selection_query(&self) -> Option<&str> {
851 match self {
852 Self::Get(args) => args.path.query.as_deref(),
853 Self::Add(args) | Self::Replace(args) => args.value.path.query.as_deref(),
854 Self::RenameKey(args) => args.path.query.as_deref(),
855 Self::Remove(args) => args.path.query.as_deref(),
856 Self::Test(args) => args.path.query.as_deref(),
857 _ => None,
858 }
859 }
860
861 fn query_source(&self) -> Option<&str> {
862 match self {
863 Self::Query(args) => Some(&args.query),
864 _ => self.selection_query(),
865 }
866 }
867
868 fn mutation_output(&self) -> Option<&MutationOutputArgs> {
869 match self {
870 Self::Add(args) | Self::Replace(args) => Some(&args.output),
871 Self::RenameKey(args) => Some(&args.output),
872 Self::Remove(args) => Some(&args.output),
873 Self::Move(args) | Self::Copy(args) => Some(&args.output),
874 Self::Patch(args) => Some(&args.output),
875 Self::Validate(_) | Self::Query(_) | Self::Get(_) | Self::Test(_) => None,
876 }
877 }
878
879 fn read_output(&self) -> Option<&Path> {
880 match self {
881 Self::Query(args) => args.output.output.as_deref(),
882 Self::Get(args) => args.output.output.as_deref(),
883 _ => None,
884 }
885 }
886
887 fn has_read_output(&self) -> bool {
888 matches!(self, Self::Query(_) | Self::Get(_))
889 }
890
891 fn should_emit_batch_result(&self, result: &[u8]) -> bool {
892 match self {
893 Self::Query(_) => !result.is_empty(),
894 Self::Get(args) if args.path.query.is_some() => !result.is_empty(),
895 Self::Get(_) => true,
896 _ => false,
897 }
898 }
899
900 fn input_path(&self) -> Option<&Path> {
901 match self {
902 Self::Validate(args) => args.file.as_deref(),
903 Self::Get(args) => args.path.input_path(),
904 Self::Add(args) | Self::Replace(args) => args.value.path.input_path(),
905 Self::RenameKey(args) => args.path.input_path(),
906 Self::Remove(args) => args.path.input_path(),
907 Self::Test(args) => args.path.input_path(),
908 _ => self.target().file.as_deref(),
909 }
910 }
911
912 fn from(&self) -> Option<&str> {
913 match self {
914 Self::Move(args) | Self::Copy(args) => Some(&args.path.from),
915 _ => None,
916 }
917 }
918
919 fn value(&self) -> Option<&ValueSourceArgs> {
920 match self {
921 Self::Add(args) | Self::Replace(args) => Some(&args.value.value),
922 Self::Test(args) => Some(&args.value),
923 _ => None,
924 }
925 }
926}
927
928impl PathArgs {
929 fn validate(&self) -> Result<(), String> {
930 if self.query.is_some() && self.target.file.is_some() {
931 return Err(
932 "a JSONPath-targeted command accepts at most one positional FILE argument"
933 .to_owned(),
934 );
935 }
936 Ok(())
937 }
938
939 fn pointer(&self) -> &str {
940 self.path_or_file
941 .as_deref()
942 .expect("Clap requires a pointer when --query is absent")
943 }
944
945 fn input_path(&self) -> Option<&Path> {
946 if self.query.is_some() {
947 self.path_or_file.as_deref().map(Path::new)
948 } else {
949 self.target.file.as_deref()
950 }
951 }
952}
953
954struct CommandOutput<'a> {
955 input_path: Option<&'a Path>,
956 stdout: &'a mut dyn Write,
957 batch_capture: bool,
958}
959
960fn execute_query_targeted(
961 operation: &Operation,
962 doc: &mut YamlDoc,
963 document: usize,
964 matches: &QueryMatches,
965 value: Option<&YamlFragment>,
966 output: &mut CommandOutput<'_>,
967) -> Result<(), RunError> {
968 match operation {
969 Operation::Get(arguments) => {
970 let rendered = render_yaml_stream(doc, matches)?;
971 write_result(
972 rendered.as_bytes(),
973 if output.batch_capture {
974 None
975 } else {
976 arguments.output.output.as_deref()
977 },
978 output.input_path,
979 output.stdout,
980 )
981 }
982 Operation::Test(_) => test_query_matches(
983 doc,
984 document,
985 matches,
986 value.expect("Clap requires a value"),
987 ),
988 Operation::Add(arguments) => {
989 apply_query_mutation(
990 doc,
991 document,
992 matches,
993 QueryMutation::Add(value.expect("Clap requires a value")),
994 )?;
995 write_mutation(doc, &arguments.output, output.input_path, output.stdout)
996 }
997 Operation::Remove(arguments) => {
998 apply_query_mutation(doc, document, matches, QueryMutation::Remove)?;
999 write_mutation(doc, &arguments.output, output.input_path, output.stdout)
1000 }
1001 Operation::Replace(arguments) => {
1002 apply_query_mutation(
1003 doc,
1004 document,
1005 matches,
1006 QueryMutation::Replace(value.expect("Clap requires a value")),
1007 )?;
1008 write_mutation(doc, &arguments.output, output.input_path, output.stdout)
1009 }
1010 Operation::RenameKey(arguments) => {
1011 if matches.is_empty() {
1012 return Err(RunError::message("query matched no nodes"));
1013 }
1014 let pointers = matches
1015 .iter()
1016 .map(|matched| matched.pointer().clone())
1017 .collect::<Vec<_>>();
1018 doc.rename_keys_at(document, &pointers, &arguments.to)
1019 .map_err(RunError::display)?;
1020 write_mutation(doc, &arguments.output, output.input_path, output.stdout)
1021 }
1022 _ => unreachable!("only single-path commands accept --query"),
1023 }
1024}
1025
1026fn render_yaml_stream(doc: &YamlDoc, matches: &QueryMatches) -> Result<String, RunError> {
1027 let mut output = String::new();
1028 for matched in matches {
1029 output.push_str("---\n");
1030 if let Some(node) = matched.node() {
1031 let fragment = doc.extract_node(node).map_err(RunError::display)?;
1032 output.push_str(&fragment);
1033 if !fragment.ends_with(['\n', '\r']) {
1034 output.push('\n');
1035 }
1036 }
1037 }
1038 Ok(output)
1039}
1040
1041enum QueryMutation<'a> {
1042 Add(&'a YamlFragment),
1043 Remove,
1044 Replace(&'a YamlFragment),
1045}
1046
1047fn apply_query_mutation(
1048 doc: &mut YamlDoc,
1049 document: usize,
1050 matches: &QueryMatches,
1051 mutation: QueryMutation<'_>,
1052) -> Result<(), RunError> {
1053 if matches.is_empty() {
1054 return Err(RunError::message("query matched no nodes"));
1055 }
1056 let mut targets = normalized_mutation_targets(matches);
1057 if matches!(mutation, QueryMutation::Remove) {
1058 targets.sort_by(removal_order);
1059 }
1060 let mut work = doc.clone();
1061 for pointer in &targets {
1062 match mutation {
1063 QueryMutation::Add(value) => work.add_at(document, pointer, value),
1064 QueryMutation::Remove => work.remove_at(document, pointer),
1065 QueryMutation::Replace(value) => work.replace_at(document, pointer, value),
1066 }
1067 .map_err(RunError::display)?;
1068 }
1069 *doc = work;
1070 Ok(())
1071}
1072
1073fn normalized_mutation_targets(matches: &QueryMatches) -> Vec<JsonPointer> {
1074 let mut seen = HashSet::new();
1075 let unique = matches
1076 .iter()
1077 .filter_map(|matched| {
1078 let pointer = matched.pointer();
1079 seen.insert(pointer.as_str().to_owned())
1080 .then(|| pointer.clone())
1081 })
1082 .collect::<Vec<_>>();
1083 unique
1084 .iter()
1085 .filter(|pointer| {
1086 !unique
1087 .iter()
1088 .any(|candidate| candidate.is_proper_prefix_of(pointer))
1089 })
1090 .cloned()
1091 .collect()
1092}
1093
1094fn removal_order(left: &JsonPointer, right: &JsonPointer) -> CmpOrdering {
1095 right
1096 .tokens()
1097 .len()
1098 .cmp(&left.tokens().len())
1099 .then_with(|| {
1100 for (left, right) in left.tokens().iter().zip(right.tokens()) {
1101 let order = match (
1102 left.as_str().parse::<usize>(),
1103 right.as_str().parse::<usize>(),
1104 ) {
1105 (Ok(left), Ok(right)) => right.cmp(&left),
1106 _ => right.as_str().cmp(left.as_str()),
1107 };
1108 if order != CmpOrdering::Equal {
1109 return order;
1110 }
1111 }
1112 CmpOrdering::Equal
1113 })
1114}
1115
1116fn test_query_matches(
1117 doc: &YamlDoc,
1118 document: usize,
1119 matches: &QueryMatches,
1120 value: &YamlFragment,
1121) -> Result<(), RunError> {
1122 if matches.is_empty() {
1123 return Err(RunError::message("query matched no nodes"));
1124 }
1125 for matched in matches {
1126 let pointer = matched.pointer();
1127 let equal = doc
1128 .test_at(document, pointer, value)
1129 .map_err(RunError::display)?;
1130 if !equal {
1131 return Err(RunError::message(format!(
1132 "test failed at {:?}: values are not semantically equal",
1133 pointer.as_str()
1134 )));
1135 }
1136 }
1137 Ok(())
1138}
1139
1140fn read_patch(arguments: &PatchSourceArgs, stdin: &mut dyn Read) -> Result<YamlPatch, RunError> {
1141 let input = if let Some(patch) = &arguments.patch {
1142 patch.clone()
1143 } else if let Some(path) = arguments.patch_file.as_deref() {
1144 if path == Path::new("-") {
1145 read_stream(stdin, "patch stdin")?
1146 } else {
1147 fs::read_to_string(path).map_err(|error| {
1148 RunError::message(format!(
1149 "cannot read patch file {}: {error}",
1150 path.display()
1151 ))
1152 })?
1153 }
1154 } else {
1155 unreachable!("Clap requires a patch source")
1156 };
1157 YamlPatch::parse_owned(input).map_err(RunError::display)
1158}
1159
1160fn read_target(path: &Path) -> Result<String, RunError> {
1161 fs::read_to_string(path)
1162 .map_err(|error| RunError::message(format!("cannot read {}: {error}", path.display())))
1163}
1164
1165fn read_value(
1166 arguments: Option<&ValueSourceArgs>,
1167 target_uses_stdin: bool,
1168 stdin: &mut dyn Read,
1169) -> Result<Option<YamlFragment>, RunError> {
1170 let input = if let Some(value) = arguments.and_then(|arguments| arguments.value.as_ref()) {
1171 Some(value.clone())
1172 } else if let Some(path) = arguments.and_then(|arguments| arguments.value_file.as_deref()) {
1173 if path == Path::new("-") {
1174 if target_uses_stdin {
1175 return Err(RunError::message(
1176 "target YAML and --value-file cannot both read stdin",
1177 ));
1178 }
1179 Some(read_stream(stdin, "value stdin")?)
1180 } else {
1181 Some(fs::read_to_string(path).map_err(|error| {
1182 RunError::message(format!(
1183 "cannot read value file {}: {error}",
1184 path.display()
1185 ))
1186 })?)
1187 }
1188 } else {
1189 None
1190 };
1191 input
1192 .map(YamlFragment::parse_owned)
1193 .transpose()
1194 .map_err(RunError::display)
1195}
1196
1197fn read_stream(stream: &mut dyn Read, name: &str) -> Result<String, RunError> {
1198 let mut input = String::new();
1199 stream
1200 .read_to_string(&mut input)
1201 .map_err(|error| RunError::message(format!("cannot read {name}: {error}")))?;
1202 Ok(input)
1203}
1204
1205fn select_document(doc: &YamlDoc, selected: Option<usize>) -> Result<usize, RunError> {
1206 let count = doc.document_count();
1207 match selected {
1208 Some(index) if index < count => Ok(index),
1209 Some(index) => Err(RunError::message(format!(
1210 "document index {index} is out of range for {count} documents"
1211 ))),
1212 None if count == 1 => Ok(0),
1213 None if count == 0 => Err(RunError::message("YAML stream contains no documents")),
1214 None => Err(RunError::message(format!(
1215 "YAML stream contains {count} documents; select one with --doc"
1216 ))),
1217 }
1218}
1219
1220fn write_mutation(
1221 doc: &YamlDoc,
1222 arguments: &MutationOutputArgs,
1223 input: Option<&Path>,
1224 stdout: &mut dyn Write,
1225) -> Result<(), RunError> {
1226 if arguments.in_place {
1227 let input = input
1228 .filter(|path| *path != Path::new("-"))
1229 .ok_or_else(|| RunError::message("--in-place requires a real input filename"))?;
1230 atomic_replace(input, doc.as_source().as_bytes())
1231 } else {
1232 write_result(
1233 doc.as_source().as_bytes(),
1234 arguments.output.output.as_deref(),
1235 input,
1236 stdout,
1237 )
1238 }
1239}
1240
1241fn write_result(
1242 bytes: &[u8],
1243 output: Option<&Path>,
1244 input: Option<&Path>,
1245 stdout: &mut dyn Write,
1246) -> Result<(), RunError> {
1247 if let Some(output) = output {
1248 if input.is_some_and(|input| paths_equivalent(input, output)) {
1249 return Err(RunError::message(
1250 "--output must not name the input file; use --in-place",
1251 ));
1252 }
1253 fs::write(output, bytes).map_err(|error| {
1254 RunError::message(format!("cannot write {}: {error}", output.display()))
1255 })
1256 } else {
1257 stdout
1258 .write_all(bytes)
1259 .map_err(|error| RunError::io(&error))?;
1260 stdout.flush().map_err(|error| RunError::io(&error))
1261 }
1262}
1263
1264fn paths_equivalent(left: &Path, right: &Path) -> bool {
1265 match (fs::canonicalize(left), fs::canonicalize(right)) {
1266 (Ok(left), Ok(right)) => left == right,
1267 _ => absolute_path(left).ok() == absolute_path(right).ok(),
1268 }
1269}
1270
1271fn absolute_path(path: &Path) -> io::Result<PathBuf> {
1272 if path.is_absolute() {
1273 Ok(path.to_owned())
1274 } else {
1275 Ok(std::env::current_dir()?.join(path))
1276 }
1277}
1278
1279fn atomic_replace(path: &Path, bytes: &[u8]) -> Result<(), RunError> {
1280 let metadata = fs::symlink_metadata(path).map_err(|error| {
1281 RunError::message(format!("cannot inspect {}: {error}", path.display()))
1282 })?;
1283 if metadata.file_type().is_symlink() {
1284 return Err(RunError::message(
1285 "--in-place refuses to replace a symbolic link",
1286 ));
1287 }
1288 let parent = path.parent().unwrap_or_else(|| Path::new("."));
1289 let file_name = path
1290 .file_name()
1291 .ok_or_else(|| RunError::message("input path has no filename"))?;
1292 let (temporary, mut file) = create_sibling_temp(parent, file_name)?;
1293 let mut guard = TempGuard {
1294 path: temporary.clone(),
1295 armed: true,
1296 };
1297 file.set_permissions(metadata.permissions())
1298 .map_err(|error| {
1299 RunError::message(format!(
1300 "cannot preserve permissions for {}: {error}",
1301 path.display()
1302 ))
1303 })?;
1304 file.write_all(bytes)
1305 .map_err(|error| RunError::io(&error))?;
1306 file.flush().map_err(|error| RunError::io(&error))?;
1307 file.sync_all().map_err(|error| RunError::io(&error))?;
1308 drop(file);
1309 fs::rename(&temporary, path).map_err(|error| {
1310 RunError::message(format!(
1311 "cannot atomically replace {}: {error}",
1312 path.display()
1313 ))
1314 })?;
1315 guard.armed = false;
1316 Ok(())
1317}
1318
1319fn create_sibling_temp(parent: &Path, file_name: &OsStr) -> Result<(PathBuf, File), RunError> {
1320 for _ in 0..100 {
1321 let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
1322 let mut name = OsString::from(".");
1323 name.push(file_name);
1324 name.push(format!(".yaml-rt-{}-{counter}.tmp", std::process::id()));
1325 let path = parent.join(name);
1326 match OpenOptions::new().write(true).create_new(true).open(&path) {
1327 Ok(file) => return Ok((path, file)),
1328 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {}
1329 Err(error) => {
1330 return Err(RunError::message(format!(
1331 "cannot create temporary file in {}: {error}",
1332 parent.display()
1333 )));
1334 }
1335 }
1336 }
1337 Err(RunError::message(
1338 "could not allocate a unique temporary filename",
1339 ))
1340}
1341
1342struct TempGuard {
1343 path: PathBuf,
1344 armed: bool,
1345}
1346
1347impl Drop for TempGuard {
1348 fn drop(&mut self) {
1349 if self.armed {
1350 let _ = fs::remove_file(&self.path);
1351 }
1352 }
1353}
1354
1355enum RunError {
1356 BrokenPipe,
1357 Message(String),
1358 Diagnostic(String),
1359 Usage(String),
1360 Batch {
1361 diagnostics: Vec<String>,
1362 summary: String,
1363 },
1364}
1365
1366impl RunError {
1367 fn message(message: impl Into<String>) -> Self {
1368 Self::Message(message.into())
1369 }
1370
1371 fn usage(message: impl Into<String>) -> Self {
1372 Self::Usage(message.into())
1373 }
1374
1375 fn display(error: impl std::fmt::Display) -> Self {
1376 Self::Message(error.to_string())
1377 }
1378
1379 fn yaml_diagnostic(error: YamlError, source: &str, source_name: &str, color: bool) -> Self {
1380 let color = if color {
1381 DiagnosticColor::Always
1382 } else {
1383 DiagnosticColor::Never
1384 };
1385 Self::Diagnostic(
1386 error
1387 .render(source)
1388 .with_source_name(source_name)
1389 .with_color(color)
1390 .to_string(),
1391 )
1392 }
1393
1394 fn io(error: &io::Error) -> Self {
1395 if error.kind() == io::ErrorKind::BrokenPipe {
1396 Self::BrokenPipe
1397 } else {
1398 Self::Message(error.to_string())
1399 }
1400 }
1401}
1402
1403#[cfg(test)]
1404mod tests {
1405 use super::*;
1406
1407 fn invoke(args: &[&str], input: &str) -> (i32, String, String) {
1408 let mut stdin = input.as_bytes();
1409 let mut stdout = Vec::new();
1410 let mut stderr = Vec::new();
1411 let mut args = args.to_vec();
1412 args.push("-");
1413 let status = run(args, &mut stdin, &mut stdout, &mut stderr);
1414 (
1415 status,
1416 String::from_utf8(stdout).unwrap(),
1417 String::from_utf8(stderr).unwrap(),
1418 )
1419 }
1420
1421 #[test]
1422 fn validation_errors_render_source_aware_diagnostics() {
1423 let input = "enabled: true\nitems: [a, , b]\n";
1424 let mut stdin = input.as_bytes();
1425 let mut stdout = Vec::new();
1426 let mut stderr = Vec::new();
1427 let status = run(
1428 ["yaml-rt", "validate", "-"],
1429 &mut stdin,
1430 &mut stdout,
1431 &mut stderr,
1432 );
1433 let stderr = String::from_utf8(stderr).unwrap();
1434 assert_eq!(status, 1, "{stderr}");
1435 assert!(stdout.is_empty());
1436 assert!(stderr.contains("error[parser]:"), "{stderr}");
1437 assert!(stderr.contains(" --> <stdin>:2:"), "{stderr}");
1438 assert!(stderr.contains("2 | items: [a, , b]"), "{stderr}");
1439 assert!(stderr.contains('^'), "{stderr}");
1440 assert!(!stderr.contains("\x1b["), "{stderr:?}");
1441 }
1442
1443 #[test]
1444 fn explicit_cli_color_uses_standard_ansi_colors() {
1445 let mut stdin = "[\n".as_bytes();
1446 let mut stdout = Vec::new();
1447 let mut stderr = Vec::new();
1448 let status = run_with_options(
1449 ["yaml-rt", "validate", "-"],
1450 &mut stdin,
1451 &mut stdout,
1452 &mut stderr,
1453 RunOptions { color: true },
1454 );
1455 let stderr = String::from_utf8(stderr).unwrap();
1456 assert_eq!(status, 1, "{stderr}");
1457 assert!(stderr.contains("\x1b[1;31merror\x1b[0m"), "{stderr:?}");
1458 assert!(stderr.contains("\x1b[1;34m-->\x1b[0m"), "{stderr:?}");
1459 }
1460
1461 #[test]
1462 fn get_and_replace_work_with_stdin() {
1463 let (status, stdout, stderr) = invoke(
1464 &["yaml-rt", "get", "/server/host"],
1465 "server:\n host: localhost\n",
1466 );
1467 assert_eq!(status, 0, "{stderr}");
1468 assert_eq!(stdout, "localhost");
1469
1470 let (status, stdout, stderr) = invoke(
1471 &[
1472 "yaml-rt",
1473 "replace",
1474 "/server/host",
1475 "--value",
1476 "example.com",
1477 ],
1478 "server:\n host: localhost\n",
1479 );
1480 assert_eq!(status, 0, "{stderr}");
1481 assert_eq!(stdout, "server:\n host: example.com\n");
1482 }
1483
1484 #[test]
1485 fn rename_key_works_with_pointer_and_document_selection() {
1486 let input = "---\nold: first\n---\nold: second # keep\n";
1487 let (status, stdout, stderr) = invoke(
1488 &[
1489 "yaml-rt",
1490 "rename-key",
1491 "/old",
1492 "--to",
1493 "true",
1494 "--doc",
1495 "1",
1496 ],
1497 input,
1498 );
1499 assert_eq!(status, 0, "{stderr}");
1500 assert_eq!(stdout, "---\nold: first\n---\n\"true\": second # keep\n");
1501 }
1502
1503 #[test]
1504 fn query_targeted_rename_is_atomic_and_requires_mapping_members() {
1505 let input = "items: [{old: 1}, {old: 2}]\n";
1506 let (status, stdout, stderr) = invoke(
1507 &["yaml-rt", "rename-key", "--query", "$..old", "--to", "new"],
1508 input,
1509 );
1510 assert_eq!(status, 0, "{stderr}");
1511 assert_eq!(stdout, "items: [{new: 1}, {new: 2}]\n");
1512
1513 let (status, stdout, stderr) = invoke(
1514 &["yaml-rt", "rename-key", "--query", "$..*", "--to", "new"],
1515 input,
1516 );
1517 assert_eq!(status, FAILURE);
1518 assert!(stdout.is_empty());
1519 assert!(stderr.contains("does not select a mapping member"));
1520
1521 let (status, stdout, stderr) = invoke(
1522 &[
1523 "yaml-rt",
1524 "rename-key",
1525 "--query",
1526 "$.missing",
1527 "--to",
1528 "new",
1529 ],
1530 input,
1531 );
1532 assert_eq!(status, FAILURE);
1533 assert!(stdout.is_empty());
1534 assert!(stderr.contains("query matched no nodes"));
1535 }
1536
1537 #[test]
1538 fn query_targeted_rename_rolls_back_collisions() {
1539 let (status, stdout, stderr) = invoke(
1540 &[
1541 "yaml-rt",
1542 "rename-key",
1543 "--query",
1544 "$['a','b']",
1545 "--to",
1546 "x",
1547 ],
1548 "a: 1\nb: 2\n",
1549 );
1550 assert_eq!(status, FAILURE);
1551 assert!(stdout.is_empty());
1552 assert!(stderr.contains("duplicate key \"x\""));
1553 }
1554
1555 #[test]
1556 fn query_works_with_stdin_and_no_matches_succeed() {
1557 let input = "users:\n - {name: Ada, active: true}\n - {name: Linus, active: false}\n";
1558 let (status, stdout, stderr) = invoke(
1559 &["yaml-rt", "query", "$.users[?@.active == true].name"],
1560 input,
1561 );
1562 assert_eq!(status, 0, "{stderr}");
1563 assert_eq!(stdout, "\"/users/0/name\": \"Ada\"\n");
1564
1565 let (status, stdout, stderr) = invoke(&["yaml-rt", "query", "$.missing"], input);
1566 assert_eq!(status, 0, "{stderr}");
1567 assert!(stdout.is_empty());
1568 }
1569
1570 #[test]
1571 fn get_query_emits_a_yaml_document_stream() {
1572 let input = "users:\n - {name: Ada}\n - {name: Linus}\n";
1573 let (status, stdout, stderr) =
1574 invoke(&["yaml-rt", "get", "--query", "$.users[*].name"], input);
1575 assert_eq!(status, 0, "{stderr}");
1576 assert_eq!(stdout, "---\nAda\n---\nLinus\n");
1577
1578 let (status, stdout, stderr) = invoke(&["yaml-rt", "get", "--query", "$.missing"], input);
1579 assert_eq!(status, 0, "{stderr}");
1580 assert!(stdout.is_empty());
1581
1582 let (status, stdout, stderr) = invoke(&["yaml-rt", "get", "--query", "$"], "---\n");
1583 assert_eq!(status, 0, "{stderr}");
1584 assert_eq!(stdout, "---\n");
1585 }
1586
1587 #[test]
1588 fn query_targeted_value_mutations_are_atomic() {
1589 let input = "items: [{enabled: false}, {enabled: false}]\n";
1590 for operation in ["add", "replace"] {
1591 let (status, stdout, stderr) = invoke(
1592 &[
1593 "yaml-rt",
1594 operation,
1595 "--query",
1596 "$.items[*].enabled",
1597 "--value",
1598 "true",
1599 ],
1600 input,
1601 );
1602 assert_eq!(status, 0, "{stderr}");
1603 assert_eq!(stdout, "items: [{enabled: true}, {enabled: true}]\n");
1604 }
1605
1606 let (status, stdout, stderr) = invoke(
1607 &[
1608 "yaml-rt",
1609 "replace",
1610 "--query",
1611 "$.missing",
1612 "--value",
1613 "true",
1614 ],
1615 input,
1616 );
1617 assert_eq!(status, FAILURE);
1618 assert!(stdout.is_empty());
1619 assert!(stderr.contains("query matched no nodes"));
1620 }
1621
1622 #[test]
1623 fn query_targeted_remove_normalizes_and_orders_matches() {
1624 let (status, stdout, stderr) = invoke(
1625 &["yaml-rt", "remove", "--query", "$.items[0,2,0]"],
1626 "items: [a, b, c, d]\n",
1627 );
1628 assert_eq!(status, 0, "{stderr}");
1629 assert_eq!(stdout, "items: [b, d]\n");
1630
1631 let (status, stdout, stderr) = invoke(
1632 &["yaml-rt", "remove", "--query", "$..*"],
1633 "root: {child: x}\nuntouched: y\n",
1634 );
1635 assert_eq!(status, 0, "{stderr}");
1636 assert_eq!(stdout, "{}\n");
1637 }
1638
1639 #[test]
1640 fn query_targeted_test_requires_matches_and_tests_every_node() {
1641 let input = "values: [1, 1, 2]\n";
1642 let (status, stdout, stderr) = invoke(
1643 &[
1644 "yaml-rt",
1645 "test",
1646 "--query",
1647 "$.values[0,1]",
1648 "--value",
1649 "1",
1650 ],
1651 input,
1652 );
1653 assert_eq!(status, 0, "{stderr}");
1654 assert!(stdout.is_empty());
1655
1656 let (status, stdout, stderr) = invoke(
1657 &["yaml-rt", "test", "--query", "$.values[*]", "--value", "1"],
1658 input,
1659 );
1660 assert_eq!(status, FAILURE);
1661 assert!(stdout.is_empty());
1662 assert!(stderr.contains("/values/2"));
1663
1664 let (status, stdout, stderr) = invoke(
1665 &["yaml-rt", "test", "--query", "$.missing", "--value", "1"],
1666 input,
1667 );
1668 assert_eq!(status, FAILURE);
1669 assert!(stdout.is_empty());
1670 assert!(stderr.contains("query matched no nodes"));
1671 }
1672
1673 #[test]
1674 fn query_targeted_commands_reject_extra_positionals_as_usage_errors() {
1675 let (status, stdout, stderr) = invoke(
1676 &["yaml-rt", "get", "--query", "$.value", "first"],
1677 "value: 1\n",
1678 );
1679 assert_eq!(status, USAGE);
1680 assert!(stdout.is_empty());
1681 assert!(stderr.contains("at most one positional FILE"));
1682 }
1683
1684 #[test]
1685 fn query_targeted_commands_report_query_errors_before_output() {
1686 let (status, stdout, stderr) =
1687 invoke(&["yaml-rt", "get", "--query", "not-jsonpath"], "value: 1\n");
1688 assert_eq!(status, FAILURE);
1689 assert!(stdout.is_empty());
1690 assert!(stderr.contains("JSONPath"));
1691
1692 let (status, stdout, stderr) = invoke(
1693 &["yaml-rt", "remove", "--query", "$.*"],
1694 "? [complex, key]\n: value\n",
1695 );
1696 assert_eq!(status, FAILURE);
1697 assert!(stdout.is_empty());
1698 assert!(stderr.contains("non-string key"));
1699 }
1700
1701 #[test]
1702 fn test_failure_has_no_stdout() {
1703 let (status, stdout, stderr) =
1704 invoke(&["yaml-rt", "test", "/value", "--value", "2"], "value: 1\n");
1705 assert_eq!(status, FAILURE);
1706 assert!(stdout.is_empty());
1707 assert!(stderr.contains("test failed"));
1708 }
1709
1710 #[test]
1711 fn inline_patch_is_transactional() {
1712 let patch =
1713 "- {op: replace, path: /port, value: 9090}\n- {op: add, path: /debug, value: true}\n";
1714 let (status, stdout, stderr) = invoke(
1715 &["yaml-rt", "patch", "--patch", patch],
1716 "port: 8080 # keep\n",
1717 );
1718 assert_eq!(status, 0, "{stderr}");
1719 assert_eq!(stdout, "port: 9090 # keep\ndebug: true\n");
1720
1721 let failing =
1722 "- {op: replace, path: /port, value: 9090}\n- {op: test, path: /port, value: 8080}\n";
1723 let (status, stdout, stderr) =
1724 invoke(&["yaml-rt", "patch", "--patch", failing], "port: 8080\n");
1725 assert_eq!(status, FAILURE);
1726 assert!(stdout.is_empty());
1727 assert!(stderr.contains("patch operation[1]"));
1728 }
1729
1730 #[test]
1731 fn patch_source_is_required_and_exclusive() {
1732 let (status, _, stderr) = invoke(&["yaml-rt", "patch"], "{}\n");
1733 assert_eq!(status, USAGE);
1734 assert!(stderr.contains("required"));
1735
1736 let (status, _, stderr) = invoke(
1737 &[
1738 "yaml-rt",
1739 "patch",
1740 "--patch",
1741 "[]",
1742 "--patch-file",
1743 "changes.yaml",
1744 ],
1745 "{}\n",
1746 );
1747 assert_eq!(status, USAGE);
1748 assert!(stderr.contains("cannot be used with"));
1749 }
1750
1751 #[test]
1752 fn multiple_documents_require_selection() {
1753 let (status, _, stderr) = invoke(&["yaml-rt", "get", ""], "--- one\n--- two\n");
1754 assert_eq!(status, FAILURE);
1755 assert!(stderr.contains("--doc"));
1756 }
1757
1758 #[test]
1759 fn derive_arguments_enforce_value_and_output_conflicts() {
1760 let (status, stdout, stderr) = invoke(&["yaml-rt", "replace", "/value"], "value: 1\n");
1761 assert_eq!(status, USAGE);
1762 assert!(stdout.is_empty());
1763 assert!(stderr.contains("--value"));
1764
1765 let (status, stdout, stderr) = invoke(
1766 &[
1767 "yaml-rt",
1768 "replace",
1769 "/value",
1770 "--value",
1771 "1",
1772 "--value-file",
1773 "value.yaml",
1774 ],
1775 "value: 1\n",
1776 );
1777 assert_eq!(status, USAGE);
1778 assert!(stdout.is_empty());
1779 assert!(stderr.contains("cannot be used with"));
1780
1781 let (status, stdout, stderr) = invoke(
1782 &[
1783 "yaml-rt",
1784 "remove",
1785 "/value",
1786 "--output",
1787 "out.yaml",
1788 "--in-place",
1789 ],
1790 "value: 1\n",
1791 );
1792 assert_eq!(status, USAGE);
1793 assert!(stdout.is_empty());
1794 assert!(stderr.contains("cannot be used with"));
1795 }
1796
1797 #[test]
1798 fn hyphen_prefixed_inline_yaml_is_accepted() {
1799 let (status, stdout, stderr) = invoke(&["yaml-rt", "get", "-invalid"], "value: old\n");
1800 assert_eq!(status, FAILURE);
1801 assert!(stdout.is_empty());
1802 assert!(stderr.contains("JSON Pointer"));
1803
1804 let (status, stdout, stderr) = invoke(
1805 &["yaml-rt", "replace", "/value", "--value", "-1"],
1806 "value: old\n",
1807 );
1808 assert_eq!(status, 0, "{stderr}");
1809 assert_eq!(stdout, "value: -1\n");
1810 }
1811}