1use std::ffi::{OsStr, OsString};
8use std::fs::{self, File, OpenOptions};
9use std::io::{self, Read, Write};
10use std::path::{Path, PathBuf};
11use std::sync::atomic::{AtomicU64, Ordering};
12
13use clap::{Args, Parser, Subcommand, error::ErrorKind};
14use yaml_rt_core::{JsonPointer, YamlDoc, YamlFragment};
15
16mod query;
17
18use query::run_query;
19
20const FAILURE: i32 = 1;
21const USAGE: i32 = 2;
22static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
23
24pub fn run<I, T>(
26 args: I,
27 stdin: &mut dyn Read,
28 stdout: &mut dyn Write,
29 stderr: &mut dyn Write,
30) -> i32
31where
32 I: IntoIterator<Item = T>,
33 T: Into<OsString> + Clone,
34{
35 let cli = match Cli::try_parse_from(args) {
36 Ok(cli) => cli,
37 Err(error) => {
38 let display_only = matches!(
39 error.kind(),
40 ErrorKind::DisplayHelp | ErrorKind::DisplayVersion
41 );
42 let write_result = if display_only {
43 write!(stdout, "{error}")
44 } else {
45 write!(stderr, "{error}")
46 };
47 if write_result.is_err() {
48 return FAILURE;
49 }
50 return if display_only { 0 } else { USAGE };
51 }
52 };
53 match execute(&cli.operation, stdin, stdout) {
54 Ok(()) | Err(RunError::BrokenPipe) => 0,
55 Err(RunError::Message(message)) => {
56 let _ = writeln!(stderr, "yaml-rt: {message}");
57 FAILURE
58 }
59 }
60}
61
62#[derive(Parser)]
63#[command(
64 name = "yaml-rt",
65 version,
66 about = "Query and edit YAML while preserving presentation",
67 subcommand_required = true,
68 arg_required_else_help = true
69)]
70struct Cli {
71 #[command(subcommand)]
72 operation: Operation,
73}
74
75#[derive(Subcommand)]
76enum Operation {
77 Query(QueryArgs),
79 Get(ReadArgs),
81 Add(ValueMutationArgs),
83 Remove(MutationArgs),
85 Replace(ValueMutationArgs),
87 Move(FromMutationArgs),
89 Copy(FromMutationArgs),
91 Test(ValueArgs),
93}
94
95#[derive(Args)]
96struct TargetArgs {
97 #[arg(value_name = "FILE")]
99 file: Option<PathBuf>,
100 #[arg(long, value_name = "INDEX")]
102 doc: Option<usize>,
103}
104
105#[derive(Args)]
106struct PathArgs {
107 #[arg(value_name = "PATH", allow_hyphen_values = true)]
108 path: String,
109 #[command(flatten)]
110 target: TargetArgs,
111}
112
113#[derive(Args)]
114struct FromPathArgs {
115 #[arg(value_name = "FROM", allow_hyphen_values = true)]
116 from: String,
117 #[arg(value_name = "PATH", allow_hyphen_values = true)]
118 path: String,
119 #[command(flatten)]
120 target: TargetArgs,
121}
122
123#[derive(Args)]
124struct OutputArgs {
125 #[arg(short, long, value_name = "FILE")]
127 output: Option<PathBuf>,
128}
129
130#[derive(Args)]
131struct MutationOutputArgs {
132 #[command(flatten)]
133 output: OutputArgs,
134 #[arg(short, long, conflicts_with = "output")]
136 in_place: bool,
137}
138
139#[derive(Args)]
140#[group(required = true, multiple = false)]
141struct ValueSourceArgs {
142 #[arg(long, value_name = "YAML", allow_hyphen_values = true)]
144 value: Option<String>,
145 #[arg(long, value_name = "FILE")]
147 value_file: Option<PathBuf>,
148}
149
150#[derive(Args)]
151struct ReadArgs {
152 #[command(flatten)]
153 path: PathArgs,
154 #[command(flatten)]
155 output: OutputArgs,
156}
157
158#[derive(Args)]
159struct QueryArgs {
160 #[arg(value_name = "QUERY")]
162 query: String,
163 #[command(flatten)]
164 target: TargetArgs,
165 #[command(flatten)]
166 output: OutputArgs,
167}
168
169#[derive(Args)]
170struct MutationArgs {
171 #[command(flatten)]
172 path: PathArgs,
173 #[command(flatten)]
174 output: MutationOutputArgs,
175}
176
177#[derive(Args)]
178struct FromMutationArgs {
179 #[command(flatten)]
180 path: FromPathArgs,
181 #[command(flatten)]
182 output: MutationOutputArgs,
183}
184
185#[derive(Args)]
186struct ValueArgs {
187 #[command(flatten)]
188 path: PathArgs,
189 #[command(flatten)]
190 value: ValueSourceArgs,
191}
192
193#[derive(Args)]
194struct ValueMutationArgs {
195 #[command(flatten)]
196 value: ValueArgs,
197 #[command(flatten)]
198 output: MutationOutputArgs,
199}
200
201fn execute(
202 operation: &Operation,
203 stdin: &mut dyn Read,
204 stdout: &mut dyn Write,
205) -> Result<(), RunError> {
206 let target = operation.target();
207 let input_path = target.file.as_deref();
208 let target_uses_stdin = input_path.is_none_or(|path| path == Path::new("-"));
209 let input = read_target(input_path, stdin)?;
210 let mut doc = YamlDoc::parse_owned(input).map_err(RunError::display)?;
211 let document = select_document(&doc, target.doc)?;
212
213 if let Operation::Query(arguments) = operation {
214 let output = run_query(&doc, document, &arguments.query).map_err(RunError::display)?;
215 return write_result(
216 output.as_bytes(),
217 arguments.output.output.as_deref(),
218 input_path,
219 stdout,
220 );
221 }
222
223 let path = JsonPointer::parse(operation.path()).map_err(RunError::display)?;
224 let from = operation
225 .from()
226 .map(JsonPointer::parse)
227 .transpose()
228 .map_err(RunError::display)?;
229 let value = read_value(operation.value(), target_uses_stdin, stdin)?;
230
231 match operation {
232 Operation::Query(_) => unreachable!("query returned before pointer operations"),
233 Operation::Get(arguments) => {
234 let node = doc
235 .resolve_pointer(document, &path)
236 .map_err(RunError::display)?;
237 let output = doc.extract_node(node).map_err(RunError::display)?;
238 write_result(
239 output.as_bytes(),
240 arguments.output.output.as_deref(),
241 input_path,
242 stdout,
243 )
244 }
245 Operation::Test(_) => {
246 let equal = doc
247 .test_at(
248 document,
249 &path,
250 value.as_ref().expect("Clap requires a value"),
251 )
252 .map_err(RunError::display)?;
253 if equal {
254 Ok(())
255 } else {
256 Err(RunError::message(format!(
257 "test failed at {:?}: values are not semantically equal",
258 path.as_str()
259 )))
260 }
261 }
262 Operation::Add(arguments) => {
263 doc.add_at(
264 document,
265 &path,
266 value.as_ref().expect("Clap requires a value"),
267 )
268 .map_err(RunError::display)?;
269 write_mutation(&doc, &arguments.output, input_path, stdout)
270 }
271 Operation::Remove(arguments) => {
272 doc.remove_at(document, &path).map_err(RunError::display)?;
273 write_mutation(&doc, &arguments.output, input_path, stdout)
274 }
275 Operation::Replace(arguments) => {
276 doc.replace_at(
277 document,
278 &path,
279 value.as_ref().expect("Clap requires a value"),
280 )
281 .map_err(RunError::display)?;
282 write_mutation(&doc, &arguments.output, input_path, stdout)
283 }
284 Operation::Move(arguments) => {
285 doc.move_at(document, from.as_ref().expect("Clap requires from"), &path)
286 .map_err(RunError::display)?;
287 write_mutation(&doc, &arguments.output, input_path, stdout)
288 }
289 Operation::Copy(arguments) => {
290 doc.copy_at(document, from.as_ref().expect("Clap requires from"), &path)
291 .map_err(RunError::display)?;
292 write_mutation(&doc, &arguments.output, input_path, stdout)
293 }
294 }
295}
296
297impl Operation {
298 fn target(&self) -> &TargetArgs {
299 match self {
300 Self::Query(args) => &args.target,
301 Self::Get(args) => &args.path.target,
302 Self::Add(args) | Self::Replace(args) => &args.value.path.target,
303 Self::Remove(args) => &args.path.target,
304 Self::Move(args) | Self::Copy(args) => &args.path.target,
305 Self::Test(args) => &args.path.target,
306 }
307 }
308
309 fn path(&self) -> &str {
310 match self {
311 Self::Query(_) => unreachable!("query does not use a JSON Pointer argument"),
312 Self::Get(args) => &args.path.path,
313 Self::Add(args) | Self::Replace(args) => &args.value.path.path,
314 Self::Remove(args) => &args.path.path,
315 Self::Move(args) | Self::Copy(args) => &args.path.path,
316 Self::Test(args) => &args.path.path,
317 }
318 }
319
320 fn from(&self) -> Option<&str> {
321 match self {
322 Self::Move(args) | Self::Copy(args) => Some(&args.path.from),
323 _ => None,
324 }
325 }
326
327 fn value(&self) -> Option<&ValueSourceArgs> {
328 match self {
329 Self::Add(args) | Self::Replace(args) => Some(&args.value.value),
330 Self::Test(args) => Some(&args.value),
331 _ => None,
332 }
333 }
334}
335
336fn read_target(path: Option<&Path>, stdin: &mut dyn Read) -> Result<String, RunError> {
337 match path {
338 None => read_stream(stdin, "stdin"),
339 Some(path) if path == Path::new("-") => read_stream(stdin, "stdin"),
340 Some(path) => fs::read_to_string(path)
341 .map_err(|error| RunError::message(format!("cannot read {}: {error}", path.display()))),
342 }
343}
344
345fn read_value(
346 arguments: Option<&ValueSourceArgs>,
347 target_uses_stdin: bool,
348 stdin: &mut dyn Read,
349) -> Result<Option<YamlFragment>, RunError> {
350 let input = if let Some(value) = arguments.and_then(|arguments| arguments.value.as_ref()) {
351 Some(value.clone())
352 } else if let Some(path) = arguments.and_then(|arguments| arguments.value_file.as_deref()) {
353 if path == Path::new("-") {
354 if target_uses_stdin {
355 return Err(RunError::message(
356 "target YAML and --value-file cannot both read stdin",
357 ));
358 }
359 Some(read_stream(stdin, "value stdin")?)
360 } else {
361 Some(fs::read_to_string(path).map_err(|error| {
362 RunError::message(format!(
363 "cannot read value file {}: {error}",
364 path.display()
365 ))
366 })?)
367 }
368 } else {
369 None
370 };
371 input
372 .map(YamlFragment::parse_owned)
373 .transpose()
374 .map_err(RunError::display)
375}
376
377fn read_stream(stream: &mut dyn Read, name: &str) -> Result<String, RunError> {
378 let mut input = String::new();
379 stream
380 .read_to_string(&mut input)
381 .map_err(|error| RunError::message(format!("cannot read {name}: {error}")))?;
382 Ok(input)
383}
384
385fn select_document(doc: &YamlDoc, selected: Option<usize>) -> Result<usize, RunError> {
386 let count = doc.document_count();
387 match selected {
388 Some(index) if index < count => Ok(index),
389 Some(index) => Err(RunError::message(format!(
390 "document index {index} is out of range for {count} documents"
391 ))),
392 None if count == 1 => Ok(0),
393 None if count == 0 => Err(RunError::message("YAML stream contains no documents")),
394 None => Err(RunError::message(format!(
395 "YAML stream contains {count} documents; select one with --doc"
396 ))),
397 }
398}
399
400fn write_mutation(
401 doc: &YamlDoc,
402 arguments: &MutationOutputArgs,
403 input: Option<&Path>,
404 stdout: &mut dyn Write,
405) -> Result<(), RunError> {
406 if arguments.in_place {
407 let input = input
408 .filter(|path| *path != Path::new("-"))
409 .ok_or_else(|| RunError::message("--in-place requires a real input filename"))?;
410 atomic_replace(input, doc.as_source().as_bytes())
411 } else {
412 write_result(
413 doc.as_source().as_bytes(),
414 arguments.output.output.as_deref(),
415 input,
416 stdout,
417 )
418 }
419}
420
421fn write_result(
422 bytes: &[u8],
423 output: Option<&Path>,
424 input: Option<&Path>,
425 stdout: &mut dyn Write,
426) -> Result<(), RunError> {
427 if let Some(output) = output {
428 if input.is_some_and(|input| paths_equivalent(input, output)) {
429 return Err(RunError::message(
430 "--output must not name the input file; use --in-place",
431 ));
432 }
433 fs::write(output, bytes).map_err(|error| {
434 RunError::message(format!("cannot write {}: {error}", output.display()))
435 })
436 } else {
437 stdout
438 .write_all(bytes)
439 .map_err(|error| RunError::io(&error))?;
440 stdout.flush().map_err(|error| RunError::io(&error))
441 }
442}
443
444fn paths_equivalent(left: &Path, right: &Path) -> bool {
445 match (fs::canonicalize(left), fs::canonicalize(right)) {
446 (Ok(left), Ok(right)) => left == right,
447 _ => absolute_path(left).ok() == absolute_path(right).ok(),
448 }
449}
450
451fn absolute_path(path: &Path) -> io::Result<PathBuf> {
452 if path.is_absolute() {
453 Ok(path.to_owned())
454 } else {
455 Ok(std::env::current_dir()?.join(path))
456 }
457}
458
459fn atomic_replace(path: &Path, bytes: &[u8]) -> Result<(), RunError> {
460 let metadata = fs::symlink_metadata(path).map_err(|error| {
461 RunError::message(format!("cannot inspect {}: {error}", path.display()))
462 })?;
463 if metadata.file_type().is_symlink() {
464 return Err(RunError::message(
465 "--in-place refuses to replace a symbolic link",
466 ));
467 }
468 let parent = path.parent().unwrap_or_else(|| Path::new("."));
469 let file_name = path
470 .file_name()
471 .ok_or_else(|| RunError::message("input path has no filename"))?;
472 let (temporary, mut file) = create_sibling_temp(parent, file_name)?;
473 let mut guard = TempGuard {
474 path: temporary.clone(),
475 armed: true,
476 };
477 file.set_permissions(metadata.permissions())
478 .map_err(|error| {
479 RunError::message(format!(
480 "cannot preserve permissions for {}: {error}",
481 path.display()
482 ))
483 })?;
484 file.write_all(bytes)
485 .map_err(|error| RunError::io(&error))?;
486 file.flush().map_err(|error| RunError::io(&error))?;
487 file.sync_all().map_err(|error| RunError::io(&error))?;
488 drop(file);
489 fs::rename(&temporary, path).map_err(|error| {
490 RunError::message(format!(
491 "cannot atomically replace {}: {error}",
492 path.display()
493 ))
494 })?;
495 guard.armed = false;
496 Ok(())
497}
498
499fn create_sibling_temp(parent: &Path, file_name: &OsStr) -> Result<(PathBuf, File), RunError> {
500 for _ in 0..100 {
501 let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
502 let mut name = OsString::from(".");
503 name.push(file_name);
504 name.push(format!(".yaml-rt-{}-{counter}.tmp", std::process::id()));
505 let path = parent.join(name);
506 match OpenOptions::new().write(true).create_new(true).open(&path) {
507 Ok(file) => return Ok((path, file)),
508 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {}
509 Err(error) => {
510 return Err(RunError::message(format!(
511 "cannot create temporary file in {}: {error}",
512 parent.display()
513 )));
514 }
515 }
516 }
517 Err(RunError::message(
518 "could not allocate a unique temporary filename",
519 ))
520}
521
522struct TempGuard {
523 path: PathBuf,
524 armed: bool,
525}
526
527impl Drop for TempGuard {
528 fn drop(&mut self) {
529 if self.armed {
530 let _ = fs::remove_file(&self.path);
531 }
532 }
533}
534
535enum RunError {
536 BrokenPipe,
537 Message(String),
538}
539
540impl RunError {
541 fn message(message: impl Into<String>) -> Self {
542 Self::Message(message.into())
543 }
544
545 fn display(error: impl std::fmt::Display) -> Self {
546 Self::Message(error.to_string())
547 }
548
549 fn io(error: &io::Error) -> Self {
550 if error.kind() == io::ErrorKind::BrokenPipe {
551 Self::BrokenPipe
552 } else {
553 Self::Message(error.to_string())
554 }
555 }
556}
557
558#[cfg(test)]
559mod tests {
560 use super::*;
561
562 fn invoke(args: &[&str], input: &str) -> (i32, String, String) {
563 let mut stdin = input.as_bytes();
564 let mut stdout = Vec::new();
565 let mut stderr = Vec::new();
566 let status = run(args, &mut stdin, &mut stdout, &mut stderr);
567 (
568 status,
569 String::from_utf8(stdout).unwrap(),
570 String::from_utf8(stderr).unwrap(),
571 )
572 }
573
574 #[test]
575 fn get_and_replace_work_with_stdin() {
576 let (status, stdout, stderr) = invoke(
577 &["yaml-rt", "get", "/server/host"],
578 "server:\n host: localhost\n",
579 );
580 assert_eq!(status, 0, "{stderr}");
581 assert_eq!(stdout, "localhost");
582
583 let (status, stdout, stderr) = invoke(
584 &[
585 "yaml-rt",
586 "replace",
587 "/server/host",
588 "--value",
589 "example.com",
590 ],
591 "server:\n host: localhost\n",
592 );
593 assert_eq!(status, 0, "{stderr}");
594 assert_eq!(stdout, "server:\n host: example.com\n");
595 }
596
597 #[test]
598 fn query_works_with_stdin_and_no_matches_succeed() {
599 let input = "users:\n - {name: Ada, active: true}\n - {name: Linus, active: false}\n";
600 let (status, stdout, stderr) = invoke(
601 &["yaml-rt", "query", "$.users[?@.active == true].name"],
602 input,
603 );
604 assert_eq!(status, 0, "{stderr}");
605 assert_eq!(stdout, "\"/users/0/name\": \"Ada\"\n");
606
607 let (status, stdout, stderr) = invoke(&["yaml-rt", "query", "$.missing"], input);
608 assert_eq!(status, 0, "{stderr}");
609 assert!(stdout.is_empty());
610 }
611
612 #[test]
613 fn test_failure_has_no_stdout() {
614 let (status, stdout, stderr) =
615 invoke(&["yaml-rt", "test", "/value", "--value", "2"], "value: 1\n");
616 assert_eq!(status, FAILURE);
617 assert!(stdout.is_empty());
618 assert!(stderr.contains("test failed"));
619 }
620
621 #[test]
622 fn multiple_documents_require_selection() {
623 let (status, _, stderr) = invoke(&["yaml-rt", "get", ""], "--- one\n--- two\n");
624 assert_eq!(status, FAILURE);
625 assert!(stderr.contains("--doc"));
626 }
627
628 #[test]
629 fn derive_arguments_enforce_value_and_output_conflicts() {
630 let (status, stdout, stderr) = invoke(&["yaml-rt", "replace", "/value"], "value: 1\n");
631 assert_eq!(status, USAGE);
632 assert!(stdout.is_empty());
633 assert!(stderr.contains("--value"));
634
635 let (status, stdout, stderr) = invoke(
636 &[
637 "yaml-rt",
638 "replace",
639 "/value",
640 "--value",
641 "1",
642 "--value-file",
643 "value.yaml",
644 ],
645 "value: 1\n",
646 );
647 assert_eq!(status, USAGE);
648 assert!(stdout.is_empty());
649 assert!(stderr.contains("cannot be used with"));
650
651 let (status, stdout, stderr) = invoke(
652 &[
653 "yaml-rt",
654 "remove",
655 "/value",
656 "--output",
657 "out.yaml",
658 "--in-place",
659 ],
660 "value: 1\n",
661 );
662 assert_eq!(status, USAGE);
663 assert!(stdout.is_empty());
664 assert!(stderr.contains("cannot be used with"));
665 }
666
667 #[test]
668 fn hyphen_prefixed_inline_yaml_is_accepted() {
669 let (status, stdout, stderr) = invoke(&["yaml-rt", "get", "-invalid"], "value: old\n");
670 assert_eq!(status, FAILURE);
671 assert!(stdout.is_empty());
672 assert!(stderr.contains("JSON Pointer"));
673
674 let (status, stdout, stderr) = invoke(
675 &["yaml-rt", "replace", "/value", "--value", "-1"],
676 "value: old\n",
677 );
678 assert_eq!(status, 0, "{stderr}");
679 assert_eq!(stdout, "value: -1\n");
680 }
681}