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