1use std::cmp::Ordering as CmpOrdering;
9use std::collections::HashSet;
10use std::ffi::{OsStr, OsString};
11use std::fs::{self, File, OpenOptions};
12use std::io::{self, Read, Write};
13use std::path::{Path, PathBuf};
14use std::sync::atomic::{AtomicU64, Ordering};
15
16use clap::{Args, CommandFactory, Parser, Subcommand, error::ErrorKind};
17use yaml_rt_core::{JsonPointer, YamlDoc, YamlFragment, YamlPatch};
18use yaml_rt_rfc9535::QueryMatches;
19
20mod query;
21
22use query::{query_matches, run_query};
23
24const FAILURE: i32 = 1;
25const USAGE: i32 = 2;
26static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
27
28pub fn run<I, T>(
30 args: I,
31 stdin: &mut dyn Read,
32 stdout: &mut dyn Write,
33 stderr: &mut dyn Write,
34) -> i32
35where
36 I: IntoIterator<Item = T>,
37 T: Into<OsString> + Clone,
38{
39 let cli = match Cli::try_parse_from(args) {
40 Ok(cli) => cli,
41 Err(error) => {
42 let display_only = matches!(
43 error.kind(),
44 ErrorKind::DisplayHelp | ErrorKind::DisplayVersion
45 );
46 let write_result = if display_only {
47 write!(stdout, "{error}")
48 } else {
49 write!(stderr, "{error}")
50 };
51 if write_result.is_err() {
52 return FAILURE;
53 }
54 return if display_only { 0 } else { USAGE };
55 }
56 };
57 if let Err(message) = cli.operation.validate() {
58 let error = Cli::command().error(ErrorKind::ArgumentConflict, message);
59 if write!(stderr, "{error}").is_err() {
60 return FAILURE;
61 }
62 return USAGE;
63 }
64 match execute(&cli.operation, stdin, stdout) {
65 Ok(()) | Err(RunError::BrokenPipe) => 0,
66 Err(RunError::Message(message)) => {
67 let _ = writeln!(stderr, "yaml-rt: {message}");
68 FAILURE
69 }
70 }
71}
72
73#[derive(Parser)]
74#[command(
75 name = "yaml-rt",
76 version,
77 about = "Query and edit YAML while preserving presentation",
78 subcommand_required = true,
79 arg_required_else_help = true
80)]
81struct Cli {
82 #[command(subcommand)]
83 operation: Operation,
84}
85
86#[derive(Subcommand)]
87enum Operation {
88 Query(QueryArgs),
90 Get(ReadArgs),
92 Add(ValueMutationArgs),
94 Remove(MutationArgs),
96 Replace(ValueMutationArgs),
98 Move(FromMutationArgs),
100 Copy(FromMutationArgs),
102 Test(ValueArgs),
104 Patch(PatchArgs),
106}
107
108#[derive(Args)]
109struct TargetArgs {
110 #[arg(value_name = "FILE")]
112 file: Option<PathBuf>,
113 #[arg(long, value_name = "INDEX")]
115 doc: Option<usize>,
116}
117
118#[derive(Args)]
119struct PathArgs {
120 #[arg(
122 value_name = "PATH_OR_FILE",
123 allow_hyphen_values = true,
124 required_unless_present = "query"
125 )]
126 path_or_file: Option<String>,
127 #[arg(long, value_name = "QUERY")]
129 query: Option<String>,
130 #[command(flatten)]
131 target: TargetArgs,
132}
133
134#[derive(Args)]
135struct FromPathArgs {
136 #[arg(value_name = "FROM", allow_hyphen_values = true)]
137 from: String,
138 #[arg(value_name = "PATH", allow_hyphen_values = true)]
139 path: String,
140 #[command(flatten)]
141 target: TargetArgs,
142}
143
144#[derive(Args)]
145struct OutputArgs {
146 #[arg(short, long, value_name = "FILE")]
148 output: Option<PathBuf>,
149}
150
151#[derive(Args)]
152struct MutationOutputArgs {
153 #[command(flatten)]
154 output: OutputArgs,
155 #[arg(short, long, conflicts_with = "output")]
157 in_place: bool,
158}
159
160#[derive(Args)]
161#[group(required = true, multiple = false)]
162struct ValueSourceArgs {
163 #[arg(long, value_name = "YAML", allow_hyphen_values = true)]
165 value: Option<String>,
166 #[arg(long, value_name = "FILE")]
168 value_file: Option<PathBuf>,
169}
170
171#[derive(Args)]
172#[group(required = true, multiple = false)]
173struct PatchSourceArgs {
174 #[arg(long, value_name = "YAML", allow_hyphen_values = true)]
176 patch: Option<String>,
177 #[arg(long, value_name = "FILE")]
179 patch_file: Option<PathBuf>,
180}
181
182#[derive(Args)]
183struct ReadArgs {
184 #[command(flatten)]
185 path: PathArgs,
186 #[command(flatten)]
187 output: OutputArgs,
188}
189
190#[derive(Args)]
191struct QueryArgs {
192 #[arg(value_name = "QUERY")]
194 query: String,
195 #[command(flatten)]
196 target: TargetArgs,
197 #[command(flatten)]
198 output: OutputArgs,
199}
200
201#[derive(Args)]
202struct MutationArgs {
203 #[command(flatten)]
204 path: PathArgs,
205 #[command(flatten)]
206 output: MutationOutputArgs,
207}
208
209#[derive(Args)]
210struct FromMutationArgs {
211 #[command(flatten)]
212 path: FromPathArgs,
213 #[command(flatten)]
214 output: MutationOutputArgs,
215}
216
217#[derive(Args)]
218struct ValueArgs {
219 #[command(flatten)]
220 path: PathArgs,
221 #[command(flatten)]
222 value: ValueSourceArgs,
223}
224
225#[derive(Args)]
226struct ValueMutationArgs {
227 #[command(flatten)]
228 value: ValueArgs,
229 #[command(flatten)]
230 output: MutationOutputArgs,
231}
232
233#[derive(Args)]
234struct PatchArgs {
235 #[command(flatten)]
236 target: TargetArgs,
237 #[command(flatten)]
238 source: PatchSourceArgs,
239 #[command(flatten)]
240 output: MutationOutputArgs,
241}
242
243fn execute(
244 operation: &Operation,
245 stdin: &mut dyn Read,
246 stdout: &mut dyn Write,
247) -> Result<(), RunError> {
248 let target = operation.target();
249 let input_path = operation.input_path();
250 let target_uses_stdin = input_path.is_none_or(|path| path == Path::new("-"));
251 if matches!(operation, Operation::Patch(arguments) if arguments.source.patch_file.as_deref() == Some(Path::new("-")))
252 && target_uses_stdin
253 {
254 return Err(RunError::message(
255 "target YAML and --patch-file cannot both read stdin",
256 ));
257 }
258 let input = read_target(input_path, stdin)?;
259 let mut doc = YamlDoc::parse_owned(input).map_err(RunError::display)?;
260 let document = select_document(&doc, target.doc)?;
261
262 if let Operation::Query(arguments) = operation {
263 let output = run_query(&doc, document, &arguments.query).map_err(RunError::display)?;
264 return write_result(
265 output.as_bytes(),
266 arguments.output.output.as_deref(),
267 input_path,
268 stdout,
269 );
270 }
271
272 if let Operation::Patch(arguments) = operation {
273 let patch = read_patch(&arguments.source, stdin)?;
274 doc.apply_patch(document, &patch)
275 .map_err(RunError::display)?;
276 return write_mutation(&doc, &arguments.output, input_path, stdout);
277 }
278
279 let value = read_value(operation.value(), target_uses_stdin, stdin)?;
280 if let Some(source) = operation.selection_query() {
281 let matches = query_matches(&doc, document, source).map_err(RunError::display)?;
282 return execute_query_targeted(
283 operation,
284 &mut doc,
285 document,
286 &matches,
287 value.as_ref(),
288 input_path,
289 stdout,
290 );
291 }
292
293 let path = JsonPointer::parse(operation.path()).map_err(RunError::display)?;
294 let from = operation
295 .from()
296 .map(JsonPointer::parse)
297 .transpose()
298 .map_err(RunError::display)?;
299 match operation {
300 Operation::Query(_) => unreachable!("query returned before pointer operations"),
301 Operation::Get(arguments) => {
302 let node = doc
303 .resolve_pointer(document, &path)
304 .map_err(RunError::display)?;
305 let output = doc.extract_node(node).map_err(RunError::display)?;
306 write_result(
307 output.as_bytes(),
308 arguments.output.output.as_deref(),
309 input_path,
310 stdout,
311 )
312 }
313 Operation::Test(_) => {
314 let equal = doc
315 .test_at(
316 document,
317 &path,
318 value.as_ref().expect("Clap requires a value"),
319 )
320 .map_err(RunError::display)?;
321 if equal {
322 Ok(())
323 } else {
324 Err(RunError::message(format!(
325 "test failed at {:?}: values are not semantically equal",
326 path.as_str()
327 )))
328 }
329 }
330 Operation::Add(arguments) => {
331 doc.add_at(
332 document,
333 &path,
334 value.as_ref().expect("Clap requires a value"),
335 )
336 .map_err(RunError::display)?;
337 write_mutation(&doc, &arguments.output, input_path, stdout)
338 }
339 Operation::Remove(arguments) => {
340 doc.remove_at(document, &path).map_err(RunError::display)?;
341 write_mutation(&doc, &arguments.output, input_path, stdout)
342 }
343 Operation::Replace(arguments) => {
344 doc.replace_at(
345 document,
346 &path,
347 value.as_ref().expect("Clap requires a value"),
348 )
349 .map_err(RunError::display)?;
350 write_mutation(&doc, &arguments.output, input_path, stdout)
351 }
352 Operation::Move(arguments) => {
353 doc.move_at(document, from.as_ref().expect("Clap requires from"), &path)
354 .map_err(RunError::display)?;
355 write_mutation(&doc, &arguments.output, input_path, stdout)
356 }
357 Operation::Copy(arguments) => {
358 doc.copy_at(document, from.as_ref().expect("Clap requires from"), &path)
359 .map_err(RunError::display)?;
360 write_mutation(&doc, &arguments.output, input_path, stdout)
361 }
362 Operation::Patch(_) => unreachable!("patch returned before pointer operations"),
363 }
364}
365
366impl Operation {
367 fn validate(&self) -> Result<(), String> {
368 let path = match self {
369 Self::Get(args) => Some(&args.path),
370 Self::Add(args) | Self::Replace(args) => Some(&args.value.path),
371 Self::Remove(args) => Some(&args.path),
372 Self::Test(args) => Some(&args.path),
373 _ => None,
374 };
375 if let Some(path) = path {
376 path.validate()?;
377 }
378 Ok(())
379 }
380
381 fn target(&self) -> &TargetArgs {
382 match self {
383 Self::Query(args) => &args.target,
384 Self::Get(args) => &args.path.target,
385 Self::Add(args) | Self::Replace(args) => &args.value.path.target,
386 Self::Remove(args) => &args.path.target,
387 Self::Move(args) | Self::Copy(args) => &args.path.target,
388 Self::Test(args) => &args.path.target,
389 Self::Patch(args) => &args.target,
390 }
391 }
392
393 fn path(&self) -> &str {
394 match self {
395 Self::Query(_) | Self::Patch(_) => {
396 unreachable!("operation does not use a JSON Pointer argument")
397 }
398 Self::Get(args) => args.path.pointer(),
399 Self::Add(args) | Self::Replace(args) => args.value.path.pointer(),
400 Self::Remove(args) => args.path.pointer(),
401 Self::Move(args) | Self::Copy(args) => &args.path.path,
402 Self::Test(args) => args.path.pointer(),
403 }
404 }
405
406 fn selection_query(&self) -> Option<&str> {
407 match self {
408 Self::Get(args) => args.path.query.as_deref(),
409 Self::Add(args) | Self::Replace(args) => args.value.path.query.as_deref(),
410 Self::Remove(args) => args.path.query.as_deref(),
411 Self::Test(args) => args.path.query.as_deref(),
412 _ => None,
413 }
414 }
415
416 fn input_path(&self) -> Option<&Path> {
417 match self {
418 Self::Get(args) => args.path.input_path(),
419 Self::Add(args) | Self::Replace(args) => args.value.path.input_path(),
420 Self::Remove(args) => args.path.input_path(),
421 Self::Test(args) => args.path.input_path(),
422 _ => self.target().file.as_deref(),
423 }
424 }
425
426 fn from(&self) -> Option<&str> {
427 match self {
428 Self::Move(args) | Self::Copy(args) => Some(&args.path.from),
429 _ => None,
430 }
431 }
432
433 fn value(&self) -> Option<&ValueSourceArgs> {
434 match self {
435 Self::Add(args) | Self::Replace(args) => Some(&args.value.value),
436 Self::Test(args) => Some(&args.value),
437 _ => None,
438 }
439 }
440}
441
442impl PathArgs {
443 fn validate(&self) -> Result<(), String> {
444 if self.query.is_some() && self.target.file.is_some() {
445 return Err(
446 "a JSONPath-targeted command accepts at most one positional FILE argument"
447 .to_owned(),
448 );
449 }
450 Ok(())
451 }
452
453 fn pointer(&self) -> &str {
454 self.path_or_file
455 .as_deref()
456 .expect("Clap requires a pointer when --query is absent")
457 }
458
459 fn input_path(&self) -> Option<&Path> {
460 if self.query.is_some() {
461 self.path_or_file.as_deref().map(Path::new)
462 } else {
463 self.target.file.as_deref()
464 }
465 }
466}
467
468fn execute_query_targeted(
469 operation: &Operation,
470 doc: &mut YamlDoc,
471 document: usize,
472 matches: &QueryMatches,
473 value: Option<&YamlFragment>,
474 input_path: Option<&Path>,
475 stdout: &mut dyn Write,
476) -> Result<(), RunError> {
477 match operation {
478 Operation::Get(arguments) => {
479 let output = render_yaml_stream(doc, matches)?;
480 write_result(
481 output.as_bytes(),
482 arguments.output.output.as_deref(),
483 input_path,
484 stdout,
485 )
486 }
487 Operation::Test(_) => test_query_matches(
488 doc,
489 document,
490 matches,
491 value.expect("Clap requires a value"),
492 ),
493 Operation::Add(arguments) => {
494 apply_query_mutation(
495 doc,
496 document,
497 matches,
498 QueryMutation::Add(value.expect("Clap requires a value")),
499 )?;
500 write_mutation(doc, &arguments.output, input_path, stdout)
501 }
502 Operation::Remove(arguments) => {
503 apply_query_mutation(doc, document, matches, QueryMutation::Remove)?;
504 write_mutation(doc, &arguments.output, input_path, stdout)
505 }
506 Operation::Replace(arguments) => {
507 apply_query_mutation(
508 doc,
509 document,
510 matches,
511 QueryMutation::Replace(value.expect("Clap requires a value")),
512 )?;
513 write_mutation(doc, &arguments.output, input_path, stdout)
514 }
515 _ => unreachable!("only single-path commands accept --query"),
516 }
517}
518
519fn render_yaml_stream(doc: &YamlDoc, matches: &QueryMatches) -> Result<String, RunError> {
520 let mut output = String::new();
521 for matched in matches {
522 output.push_str("---\n");
523 if let Some(node) = matched.node() {
524 let fragment = doc.extract_node(node).map_err(RunError::display)?;
525 output.push_str(&fragment);
526 if !fragment.ends_with(['\n', '\r']) {
527 output.push('\n');
528 }
529 }
530 }
531 Ok(output)
532}
533
534enum QueryMutation<'a> {
535 Add(&'a YamlFragment),
536 Remove,
537 Replace(&'a YamlFragment),
538}
539
540fn apply_query_mutation(
541 doc: &mut YamlDoc,
542 document: usize,
543 matches: &QueryMatches,
544 mutation: QueryMutation<'_>,
545) -> Result<(), RunError> {
546 if matches.is_empty() {
547 return Err(RunError::message("query matched no nodes"));
548 }
549 let mut targets = normalized_mutation_targets(matches);
550 if matches!(mutation, QueryMutation::Remove) {
551 targets.sort_by(removal_order);
552 }
553 let mut work = doc.clone();
554 for pointer in &targets {
555 match mutation {
556 QueryMutation::Add(value) => work.add_at(document, pointer, value),
557 QueryMutation::Remove => work.remove_at(document, pointer),
558 QueryMutation::Replace(value) => work.replace_at(document, pointer, value),
559 }
560 .map_err(RunError::display)?;
561 }
562 *doc = work;
563 Ok(())
564}
565
566fn normalized_mutation_targets(matches: &QueryMatches) -> Vec<JsonPointer> {
567 let mut seen = HashSet::new();
568 let unique = matches
569 .iter()
570 .filter_map(|matched| {
571 let pointer = matched.pointer();
572 seen.insert(pointer.as_str().to_owned())
573 .then(|| pointer.clone())
574 })
575 .collect::<Vec<_>>();
576 unique
577 .iter()
578 .filter(|pointer| {
579 !unique
580 .iter()
581 .any(|candidate| candidate.is_proper_prefix_of(pointer))
582 })
583 .cloned()
584 .collect()
585}
586
587fn removal_order(left: &JsonPointer, right: &JsonPointer) -> CmpOrdering {
588 right
589 .tokens()
590 .len()
591 .cmp(&left.tokens().len())
592 .then_with(|| {
593 for (left, right) in left.tokens().iter().zip(right.tokens()) {
594 let order = match (
595 left.as_str().parse::<usize>(),
596 right.as_str().parse::<usize>(),
597 ) {
598 (Ok(left), Ok(right)) => right.cmp(&left),
599 _ => right.as_str().cmp(left.as_str()),
600 };
601 if order != CmpOrdering::Equal {
602 return order;
603 }
604 }
605 CmpOrdering::Equal
606 })
607}
608
609fn test_query_matches(
610 doc: &YamlDoc,
611 document: usize,
612 matches: &QueryMatches,
613 value: &YamlFragment,
614) -> Result<(), RunError> {
615 if matches.is_empty() {
616 return Err(RunError::message("query matched no nodes"));
617 }
618 for matched in matches {
619 let pointer = matched.pointer();
620 let equal = doc
621 .test_at(document, pointer, value)
622 .map_err(RunError::display)?;
623 if !equal {
624 return Err(RunError::message(format!(
625 "test failed at {:?}: values are not semantically equal",
626 pointer.as_str()
627 )));
628 }
629 }
630 Ok(())
631}
632
633fn read_patch(arguments: &PatchSourceArgs, stdin: &mut dyn Read) -> Result<YamlPatch, RunError> {
634 let input = if let Some(patch) = &arguments.patch {
635 patch.clone()
636 } else if let Some(path) = arguments.patch_file.as_deref() {
637 if path == Path::new("-") {
638 read_stream(stdin, "patch stdin")?
639 } else {
640 fs::read_to_string(path).map_err(|error| {
641 RunError::message(format!(
642 "cannot read patch file {}: {error}",
643 path.display()
644 ))
645 })?
646 }
647 } else {
648 unreachable!("Clap requires a patch source")
649 };
650 YamlPatch::parse_owned(input).map_err(RunError::display)
651}
652
653fn read_target(path: Option<&Path>, stdin: &mut dyn Read) -> Result<String, RunError> {
654 match path {
655 None => read_stream(stdin, "stdin"),
656 Some(path) if path == Path::new("-") => read_stream(stdin, "stdin"),
657 Some(path) => fs::read_to_string(path)
658 .map_err(|error| RunError::message(format!("cannot read {}: {error}", path.display()))),
659 }
660}
661
662fn read_value(
663 arguments: Option<&ValueSourceArgs>,
664 target_uses_stdin: bool,
665 stdin: &mut dyn Read,
666) -> Result<Option<YamlFragment>, RunError> {
667 let input = if let Some(value) = arguments.and_then(|arguments| arguments.value.as_ref()) {
668 Some(value.clone())
669 } else if let Some(path) = arguments.and_then(|arguments| arguments.value_file.as_deref()) {
670 if path == Path::new("-") {
671 if target_uses_stdin {
672 return Err(RunError::message(
673 "target YAML and --value-file cannot both read stdin",
674 ));
675 }
676 Some(read_stream(stdin, "value stdin")?)
677 } else {
678 Some(fs::read_to_string(path).map_err(|error| {
679 RunError::message(format!(
680 "cannot read value file {}: {error}",
681 path.display()
682 ))
683 })?)
684 }
685 } else {
686 None
687 };
688 input
689 .map(YamlFragment::parse_owned)
690 .transpose()
691 .map_err(RunError::display)
692}
693
694fn read_stream(stream: &mut dyn Read, name: &str) -> Result<String, RunError> {
695 let mut input = String::new();
696 stream
697 .read_to_string(&mut input)
698 .map_err(|error| RunError::message(format!("cannot read {name}: {error}")))?;
699 Ok(input)
700}
701
702fn select_document(doc: &YamlDoc, selected: Option<usize>) -> Result<usize, RunError> {
703 let count = doc.document_count();
704 match selected {
705 Some(index) if index < count => Ok(index),
706 Some(index) => Err(RunError::message(format!(
707 "document index {index} is out of range for {count} documents"
708 ))),
709 None if count == 1 => Ok(0),
710 None if count == 0 => Err(RunError::message("YAML stream contains no documents")),
711 None => Err(RunError::message(format!(
712 "YAML stream contains {count} documents; select one with --doc"
713 ))),
714 }
715}
716
717fn write_mutation(
718 doc: &YamlDoc,
719 arguments: &MutationOutputArgs,
720 input: Option<&Path>,
721 stdout: &mut dyn Write,
722) -> Result<(), RunError> {
723 if arguments.in_place {
724 let input = input
725 .filter(|path| *path != Path::new("-"))
726 .ok_or_else(|| RunError::message("--in-place requires a real input filename"))?;
727 atomic_replace(input, doc.as_source().as_bytes())
728 } else {
729 write_result(
730 doc.as_source().as_bytes(),
731 arguments.output.output.as_deref(),
732 input,
733 stdout,
734 )
735 }
736}
737
738fn write_result(
739 bytes: &[u8],
740 output: Option<&Path>,
741 input: Option<&Path>,
742 stdout: &mut dyn Write,
743) -> Result<(), RunError> {
744 if let Some(output) = output {
745 if input.is_some_and(|input| paths_equivalent(input, output)) {
746 return Err(RunError::message(
747 "--output must not name the input file; use --in-place",
748 ));
749 }
750 fs::write(output, bytes).map_err(|error| {
751 RunError::message(format!("cannot write {}: {error}", output.display()))
752 })
753 } else {
754 stdout
755 .write_all(bytes)
756 .map_err(|error| RunError::io(&error))?;
757 stdout.flush().map_err(|error| RunError::io(&error))
758 }
759}
760
761fn paths_equivalent(left: &Path, right: &Path) -> bool {
762 match (fs::canonicalize(left), fs::canonicalize(right)) {
763 (Ok(left), Ok(right)) => left == right,
764 _ => absolute_path(left).ok() == absolute_path(right).ok(),
765 }
766}
767
768fn absolute_path(path: &Path) -> io::Result<PathBuf> {
769 if path.is_absolute() {
770 Ok(path.to_owned())
771 } else {
772 Ok(std::env::current_dir()?.join(path))
773 }
774}
775
776fn atomic_replace(path: &Path, bytes: &[u8]) -> Result<(), RunError> {
777 let metadata = fs::symlink_metadata(path).map_err(|error| {
778 RunError::message(format!("cannot inspect {}: {error}", path.display()))
779 })?;
780 if metadata.file_type().is_symlink() {
781 return Err(RunError::message(
782 "--in-place refuses to replace a symbolic link",
783 ));
784 }
785 let parent = path.parent().unwrap_or_else(|| Path::new("."));
786 let file_name = path
787 .file_name()
788 .ok_or_else(|| RunError::message("input path has no filename"))?;
789 let (temporary, mut file) = create_sibling_temp(parent, file_name)?;
790 let mut guard = TempGuard {
791 path: temporary.clone(),
792 armed: true,
793 };
794 file.set_permissions(metadata.permissions())
795 .map_err(|error| {
796 RunError::message(format!(
797 "cannot preserve permissions for {}: {error}",
798 path.display()
799 ))
800 })?;
801 file.write_all(bytes)
802 .map_err(|error| RunError::io(&error))?;
803 file.flush().map_err(|error| RunError::io(&error))?;
804 file.sync_all().map_err(|error| RunError::io(&error))?;
805 drop(file);
806 fs::rename(&temporary, path).map_err(|error| {
807 RunError::message(format!(
808 "cannot atomically replace {}: {error}",
809 path.display()
810 ))
811 })?;
812 guard.armed = false;
813 Ok(())
814}
815
816fn create_sibling_temp(parent: &Path, file_name: &OsStr) -> Result<(PathBuf, File), RunError> {
817 for _ in 0..100 {
818 let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
819 let mut name = OsString::from(".");
820 name.push(file_name);
821 name.push(format!(".yaml-rt-{}-{counter}.tmp", std::process::id()));
822 let path = parent.join(name);
823 match OpenOptions::new().write(true).create_new(true).open(&path) {
824 Ok(file) => return Ok((path, file)),
825 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {}
826 Err(error) => {
827 return Err(RunError::message(format!(
828 "cannot create temporary file in {}: {error}",
829 parent.display()
830 )));
831 }
832 }
833 }
834 Err(RunError::message(
835 "could not allocate a unique temporary filename",
836 ))
837}
838
839struct TempGuard {
840 path: PathBuf,
841 armed: bool,
842}
843
844impl Drop for TempGuard {
845 fn drop(&mut self) {
846 if self.armed {
847 let _ = fs::remove_file(&self.path);
848 }
849 }
850}
851
852enum RunError {
853 BrokenPipe,
854 Message(String),
855}
856
857impl RunError {
858 fn message(message: impl Into<String>) -> Self {
859 Self::Message(message.into())
860 }
861
862 fn display(error: impl std::fmt::Display) -> Self {
863 Self::Message(error.to_string())
864 }
865
866 fn io(error: &io::Error) -> Self {
867 if error.kind() == io::ErrorKind::BrokenPipe {
868 Self::BrokenPipe
869 } else {
870 Self::Message(error.to_string())
871 }
872 }
873}
874
875#[cfg(test)]
876mod tests {
877 use super::*;
878
879 fn invoke(args: &[&str], input: &str) -> (i32, String, String) {
880 let mut stdin = input.as_bytes();
881 let mut stdout = Vec::new();
882 let mut stderr = Vec::new();
883 let status = run(args, &mut stdin, &mut stdout, &mut stderr);
884 (
885 status,
886 String::from_utf8(stdout).unwrap(),
887 String::from_utf8(stderr).unwrap(),
888 )
889 }
890
891 #[test]
892 fn get_and_replace_work_with_stdin() {
893 let (status, stdout, stderr) = invoke(
894 &["yaml-rt", "get", "/server/host"],
895 "server:\n host: localhost\n",
896 );
897 assert_eq!(status, 0, "{stderr}");
898 assert_eq!(stdout, "localhost");
899
900 let (status, stdout, stderr) = invoke(
901 &[
902 "yaml-rt",
903 "replace",
904 "/server/host",
905 "--value",
906 "example.com",
907 ],
908 "server:\n host: localhost\n",
909 );
910 assert_eq!(status, 0, "{stderr}");
911 assert_eq!(stdout, "server:\n host: example.com\n");
912 }
913
914 #[test]
915 fn query_works_with_stdin_and_no_matches_succeed() {
916 let input = "users:\n - {name: Ada, active: true}\n - {name: Linus, active: false}\n";
917 let (status, stdout, stderr) = invoke(
918 &["yaml-rt", "query", "$.users[?@.active == true].name"],
919 input,
920 );
921 assert_eq!(status, 0, "{stderr}");
922 assert_eq!(stdout, "\"/users/0/name\": \"Ada\"\n");
923
924 let (status, stdout, stderr) = invoke(&["yaml-rt", "query", "$.missing"], input);
925 assert_eq!(status, 0, "{stderr}");
926 assert!(stdout.is_empty());
927 }
928
929 #[test]
930 fn get_query_emits_a_yaml_document_stream() {
931 let input = "users:\n - {name: Ada}\n - {name: Linus}\n";
932 let (status, stdout, stderr) =
933 invoke(&["yaml-rt", "get", "--query", "$.users[*].name"], input);
934 assert_eq!(status, 0, "{stderr}");
935 assert_eq!(stdout, "---\nAda\n---\nLinus\n");
936
937 let (status, stdout, stderr) = invoke(&["yaml-rt", "get", "--query", "$.missing"], input);
938 assert_eq!(status, 0, "{stderr}");
939 assert!(stdout.is_empty());
940
941 let (status, stdout, stderr) = invoke(&["yaml-rt", "get", "--query", "$"], "---\n");
942 assert_eq!(status, 0, "{stderr}");
943 assert_eq!(stdout, "---\n");
944 }
945
946 #[test]
947 fn query_targeted_value_mutations_are_atomic() {
948 let input = "items: [{enabled: false}, {enabled: false}]\n";
949 for operation in ["add", "replace"] {
950 let (status, stdout, stderr) = invoke(
951 &[
952 "yaml-rt",
953 operation,
954 "--query",
955 "$.items[*].enabled",
956 "--value",
957 "true",
958 ],
959 input,
960 );
961 assert_eq!(status, 0, "{stderr}");
962 assert_eq!(stdout, "items: [{enabled: true}, {enabled: true}]\n");
963 }
964
965 let (status, stdout, stderr) = invoke(
966 &[
967 "yaml-rt",
968 "replace",
969 "--query",
970 "$.missing",
971 "--value",
972 "true",
973 ],
974 input,
975 );
976 assert_eq!(status, FAILURE);
977 assert!(stdout.is_empty());
978 assert!(stderr.contains("query matched no nodes"));
979 }
980
981 #[test]
982 fn query_targeted_remove_normalizes_and_orders_matches() {
983 let (status, stdout, stderr) = invoke(
984 &["yaml-rt", "remove", "--query", "$.items[0,2,0]"],
985 "items: [a, b, c, d]\n",
986 );
987 assert_eq!(status, 0, "{stderr}");
988 assert_eq!(stdout, "items: [b, d]\n");
989
990 let (status, stdout, stderr) = invoke(
991 &["yaml-rt", "remove", "--query", "$..*"],
992 "root: {child: x}\nuntouched: y\n",
993 );
994 assert_eq!(status, 0, "{stderr}");
995 assert!(stdout.is_empty());
996 }
997
998 #[test]
999 fn query_targeted_test_requires_matches_and_tests_every_node() {
1000 let input = "values: [1, 1, 2]\n";
1001 let (status, stdout, stderr) = invoke(
1002 &[
1003 "yaml-rt",
1004 "test",
1005 "--query",
1006 "$.values[0,1]",
1007 "--value",
1008 "1",
1009 ],
1010 input,
1011 );
1012 assert_eq!(status, 0, "{stderr}");
1013 assert!(stdout.is_empty());
1014
1015 let (status, stdout, stderr) = invoke(
1016 &["yaml-rt", "test", "--query", "$.values[*]", "--value", "1"],
1017 input,
1018 );
1019 assert_eq!(status, FAILURE);
1020 assert!(stdout.is_empty());
1021 assert!(stderr.contains("/values/2"));
1022
1023 let (status, stdout, stderr) = invoke(
1024 &["yaml-rt", "test", "--query", "$.missing", "--value", "1"],
1025 input,
1026 );
1027 assert_eq!(status, FAILURE);
1028 assert!(stdout.is_empty());
1029 assert!(stderr.contains("query matched no nodes"));
1030 }
1031
1032 #[test]
1033 fn query_targeted_commands_reject_extra_positionals_as_usage_errors() {
1034 let (status, stdout, stderr) = invoke(
1035 &["yaml-rt", "get", "--query", "$.value", "first", "second"],
1036 "value: 1\n",
1037 );
1038 assert_eq!(status, USAGE);
1039 assert!(stdout.is_empty());
1040 assert!(stderr.contains("at most one positional FILE"));
1041 }
1042
1043 #[test]
1044 fn query_targeted_commands_report_query_errors_before_output() {
1045 let (status, stdout, stderr) =
1046 invoke(&["yaml-rt", "get", "--query", "not-jsonpath"], "value: 1\n");
1047 assert_eq!(status, FAILURE);
1048 assert!(stdout.is_empty());
1049 assert!(stderr.contains("JSONPath"));
1050
1051 let (status, stdout, stderr) = invoke(
1052 &["yaml-rt", "remove", "--query", "$.*"],
1053 "? [complex, key]\n: value\n",
1054 );
1055 assert_eq!(status, FAILURE);
1056 assert!(stdout.is_empty());
1057 assert!(stderr.contains("non-string key"));
1058 }
1059
1060 #[test]
1061 fn test_failure_has_no_stdout() {
1062 let (status, stdout, stderr) =
1063 invoke(&["yaml-rt", "test", "/value", "--value", "2"], "value: 1\n");
1064 assert_eq!(status, FAILURE);
1065 assert!(stdout.is_empty());
1066 assert!(stderr.contains("test failed"));
1067 }
1068
1069 #[test]
1070 fn inline_patch_is_transactional() {
1071 let patch =
1072 "- {op: replace, path: /port, value: 9090}\n- {op: add, path: /debug, value: true}\n";
1073 let (status, stdout, stderr) = invoke(
1074 &["yaml-rt", "patch", "--patch", patch],
1075 "port: 8080 # keep\n",
1076 );
1077 assert_eq!(status, 0, "{stderr}");
1078 assert_eq!(stdout, "port: 9090 # keep\ndebug: true\n");
1079
1080 let failing =
1081 "- {op: replace, path: /port, value: 9090}\n- {op: test, path: /port, value: 8080}\n";
1082 let (status, stdout, stderr) =
1083 invoke(&["yaml-rt", "patch", "--patch", failing], "port: 8080\n");
1084 assert_eq!(status, FAILURE);
1085 assert!(stdout.is_empty());
1086 assert!(stderr.contains("patch operation[1]"));
1087 }
1088
1089 #[test]
1090 fn patch_source_is_required_and_exclusive() {
1091 let (status, _, stderr) = invoke(&["yaml-rt", "patch"], "{}\n");
1092 assert_eq!(status, USAGE);
1093 assert!(stderr.contains("required"));
1094
1095 let (status, _, stderr) = invoke(
1096 &[
1097 "yaml-rt",
1098 "patch",
1099 "--patch",
1100 "[]",
1101 "--patch-file",
1102 "changes.yaml",
1103 ],
1104 "{}\n",
1105 );
1106 assert_eq!(status, USAGE);
1107 assert!(stderr.contains("cannot be used with"));
1108 }
1109
1110 #[test]
1111 fn multiple_documents_require_selection() {
1112 let (status, _, stderr) = invoke(&["yaml-rt", "get", ""], "--- one\n--- two\n");
1113 assert_eq!(status, FAILURE);
1114 assert!(stderr.contains("--doc"));
1115 }
1116
1117 #[test]
1118 fn derive_arguments_enforce_value_and_output_conflicts() {
1119 let (status, stdout, stderr) = invoke(&["yaml-rt", "replace", "/value"], "value: 1\n");
1120 assert_eq!(status, USAGE);
1121 assert!(stdout.is_empty());
1122 assert!(stderr.contains("--value"));
1123
1124 let (status, stdout, stderr) = invoke(
1125 &[
1126 "yaml-rt",
1127 "replace",
1128 "/value",
1129 "--value",
1130 "1",
1131 "--value-file",
1132 "value.yaml",
1133 ],
1134 "value: 1\n",
1135 );
1136 assert_eq!(status, USAGE);
1137 assert!(stdout.is_empty());
1138 assert!(stderr.contains("cannot be used with"));
1139
1140 let (status, stdout, stderr) = invoke(
1141 &[
1142 "yaml-rt",
1143 "remove",
1144 "/value",
1145 "--output",
1146 "out.yaml",
1147 "--in-place",
1148 ],
1149 "value: 1\n",
1150 );
1151 assert_eq!(status, USAGE);
1152 assert!(stdout.is_empty());
1153 assert!(stderr.contains("cannot be used with"));
1154 }
1155
1156 #[test]
1157 fn hyphen_prefixed_inline_yaml_is_accepted() {
1158 let (status, stdout, stderr) = invoke(&["yaml-rt", "get", "-invalid"], "value: old\n");
1159 assert_eq!(status, FAILURE);
1160 assert!(stdout.is_empty());
1161 assert!(stderr.contains("JSON Pointer"));
1162
1163 let (status, stdout, stderr) = invoke(
1164 &["yaml-rt", "replace", "/value", "--value", "-1"],
1165 "value: old\n",
1166 );
1167 assert_eq!(status, 0, "{stderr}");
1168 assert_eq!(stdout, "value: -1\n");
1169 }
1170}