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