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::{Arg, ArgAction, ArgGroup, ArgMatches, Command, error::ErrorKind};
14use yaml_rt_core::{JsonPointer, YamlDoc, YamlFragment};
15
16const FAILURE: i32 = 1;
17const USAGE: i32 = 2;
18static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
19
20pub fn run<I, T>(
22 args: I,
23 stdin: &mut dyn Read,
24 stdout: &mut dyn Write,
25 stderr: &mut dyn Write,
26) -> i32
27where
28 I: IntoIterator<Item = T>,
29 T: Into<OsString> + Clone,
30{
31 let matches = match command().try_get_matches_from(args) {
32 Ok(matches) => matches,
33 Err(error) => {
34 let display_only = matches!(
35 error.kind(),
36 ErrorKind::DisplayHelp | ErrorKind::DisplayVersion
37 );
38 let write_result = if display_only {
39 write!(stdout, "{error}")
40 } else {
41 write!(stderr, "{error}")
42 };
43 if write_result.is_err() {
44 return FAILURE;
45 }
46 return if display_only { 0 } else { USAGE };
47 }
48 };
49 match execute(&matches, stdin, stdout) {
50 Ok(()) => 0,
51 Err(RunError::BrokenPipe) => 0,
52 Err(RunError::Message(message)) => {
53 let _ = writeln!(stderr, "yaml-rt: {message}");
54 FAILURE
55 }
56 }
57}
58
59fn command() -> Command {
60 Command::new("yaml-rt")
61 .version(env!("CARGO_PKG_VERSION"))
62 .about("Edit YAML through JSON Pointers while preserving presentation")
63 .subcommand_required(true)
64 .arg_required_else_help(true)
65 .subcommand(read_command("get", "Print a selected YAML node", true))
66 .subcommand(value_command("add", "Add or replace a value", true))
67 .subcommand(mutation_command(
68 "remove",
69 "Remove an existing value",
70 OperationArgs::Path,
71 ))
72 .subcommand(value_command("replace", "Replace an existing value", true))
73 .subcommand(mutation_command(
74 "move",
75 "Move an existing value",
76 OperationArgs::FromPath,
77 ))
78 .subcommand(mutation_command(
79 "copy",
80 "Copy an existing value",
81 OperationArgs::FromPath,
82 ))
83 .subcommand(value_command(
84 "test",
85 "Test semantic equality at a path",
86 false,
87 ))
88}
89
90#[derive(Clone, Copy)]
91enum OperationArgs {
92 Path,
93 FromPath,
94}
95
96fn base_command(name: &'static str, about: &'static str, args: OperationArgs) -> Command {
97 let mut command = Command::new(name).about(about);
98 match args {
99 OperationArgs::Path => {
100 command = command.arg(path_arg("path", 1));
101 }
102 OperationArgs::FromPath => {
103 command = command.arg(path_arg("from", 1)).arg(path_arg("path", 2));
104 }
105 }
106 command
107 .arg(
108 Arg::new("file")
109 .value_name("FILE")
110 .index(match args {
111 OperationArgs::Path => 2,
112 OperationArgs::FromPath => 3,
113 })
114 .help("Input YAML file; defaults to stdin"),
115 )
116 .arg(
117 Arg::new("doc")
118 .long("doc")
119 .value_name("INDEX")
120 .value_parser(clap::value_parser!(usize))
121 .help("Zero-based YAML document index"),
122 )
123}
124
125fn path_arg(name: &'static str, index: usize) -> Arg {
126 Arg::new(name)
127 .value_name(if name == "from" { "FROM" } else { "PATH" })
128 .index(index)
129 .required(true)
130 .allow_hyphen_values(true)
131}
132
133fn output_arg() -> Arg {
134 Arg::new("output")
135 .short('o')
136 .long("output")
137 .value_name("FILE")
138 .help("Write output to a file")
139}
140
141fn read_command(name: &'static str, about: &'static str, output: bool) -> Command {
142 let command = base_command(name, about, OperationArgs::Path);
143 if output {
144 command.arg(output_arg())
145 } else {
146 command
147 }
148}
149
150fn mutation_command(name: &'static str, about: &'static str, args: OperationArgs) -> Command {
151 base_command(name, about, args).arg(output_arg()).arg(
152 Arg::new("in-place")
153 .short('i')
154 .long("in-place")
155 .action(ArgAction::SetTrue)
156 .conflicts_with("output")
157 .help("Atomically replace the input file"),
158 )
159}
160
161fn value_command(name: &'static str, about: &'static str, mutation: bool) -> Command {
162 let command = base_command(name, about, OperationArgs::Path)
163 .arg(
164 Arg::new("value")
165 .long("value")
166 .value_name("YAML")
167 .allow_hyphen_values(true)
168 .help("Complete YAML node"),
169 )
170 .arg(
171 Arg::new("value-file")
172 .long("value-file")
173 .value_name("FILE")
174 .help("Read the YAML node from a file"),
175 )
176 .group(
177 ArgGroup::new("value-source")
178 .args(["value", "value-file"])
179 .required(true)
180 .multiple(false),
181 );
182 if mutation {
183 command.arg(output_arg()).arg(
184 Arg::new("in-place")
185 .short('i')
186 .long("in-place")
187 .action(ArgAction::SetTrue)
188 .conflicts_with("output")
189 .help("Atomically replace the input file"),
190 )
191 } else {
192 command
193 }
194}
195
196fn execute(
197 matches: &ArgMatches,
198 stdin: &mut dyn Read,
199 stdout: &mut dyn Write,
200) -> Result<(), RunError> {
201 let (operation, arguments) = matches
202 .subcommand()
203 .ok_or_else(|| RunError::message("missing subcommand"))?;
204 let input_path = arguments.get_one::<String>("file").map(PathBuf::from);
205 let target_uses_stdin = input_path
206 .as_deref()
207 .is_none_or(|path| path == Path::new("-"));
208 let input = read_target(input_path.as_deref(), stdin)?;
209 let mut doc = YamlDoc::parse_owned(input).map_err(RunError::display)?;
210 let document = select_document(&doc, arguments.get_one::<usize>("doc").copied())?;
211
212 let path = arguments
213 .get_one::<String>("path")
214 .map(|value| JsonPointer::parse(value))
215 .transpose()
216 .map_err(RunError::display)?;
217 let from = arguments
218 .try_get_one::<String>("from")
219 .ok()
220 .flatten()
221 .map(|value| JsonPointer::parse(value))
222 .transpose()
223 .map_err(RunError::display)?;
224 let value = read_value(arguments, target_uses_stdin, stdin)?;
225
226 match operation {
227 "get" => {
228 let pointer = path.as_ref().expect("Clap requires path");
229 let node = doc
230 .resolve_pointer(document, pointer)
231 .map_err(RunError::display)?;
232 let output = doc.extract_node(node).map_err(RunError::display)?;
233 write_result(
234 output.as_bytes(),
235 arguments.get_one::<String>("output").map(Path::new),
236 input_path.as_deref(),
237 stdout,
238 )
239 }
240 "test" => {
241 let equal = doc
242 .test_at(
243 document,
244 path.as_ref().expect("Clap requires path"),
245 value.as_ref().expect("Clap requires a value"),
246 )
247 .map_err(RunError::display)?;
248 if equal {
249 Ok(())
250 } else {
251 Err(RunError::message(format!(
252 "test failed at {:?}: values are not semantically equal",
253 path.as_ref().map_or("", JsonPointer::as_str)
254 )))
255 }
256 }
257 "add" => {
258 doc.add_at(
259 document,
260 path.as_ref().expect("Clap requires path"),
261 value.as_ref().expect("Clap requires a value"),
262 )
263 .map_err(RunError::display)?;
264 write_mutation(&doc, arguments, input_path.as_deref(), stdout)
265 }
266 "remove" => {
267 doc.remove_at(document, path.as_ref().expect("Clap requires path"))
268 .map_err(RunError::display)?;
269 write_mutation(&doc, arguments, input_path.as_deref(), stdout)
270 }
271 "replace" => {
272 doc.replace_at(
273 document,
274 path.as_ref().expect("Clap requires path"),
275 value.as_ref().expect("Clap requires a value"),
276 )
277 .map_err(RunError::display)?;
278 write_mutation(&doc, arguments, input_path.as_deref(), stdout)
279 }
280 "move" => {
281 doc.move_at(
282 document,
283 from.as_ref().expect("Clap requires from"),
284 path.as_ref().expect("Clap requires path"),
285 )
286 .map_err(RunError::display)?;
287 write_mutation(&doc, arguments, input_path.as_deref(), stdout)
288 }
289 "copy" => {
290 doc.copy_at(
291 document,
292 from.as_ref().expect("Clap requires from"),
293 path.as_ref().expect("Clap requires path"),
294 )
295 .map_err(RunError::display)?;
296 write_mutation(&doc, arguments, input_path.as_deref(), stdout)
297 }
298 _ => Err(RunError::message("unknown subcommand")),
299 }
300}
301
302fn read_target(path: Option<&Path>, stdin: &mut dyn Read) -> Result<String, RunError> {
303 match path {
304 None => read_stream(stdin, "stdin"),
305 Some(path) if path == Path::new("-") => read_stream(stdin, "stdin"),
306 Some(path) => fs::read_to_string(path)
307 .map_err(|error| RunError::message(format!("cannot read {}: {error}", path.display()))),
308 }
309}
310
311fn read_value(
312 arguments: &ArgMatches,
313 target_uses_stdin: bool,
314 stdin: &mut dyn Read,
315) -> Result<Option<YamlFragment>, RunError> {
316 let input = if let Some(value) = arguments.try_get_one::<String>("value").ok().flatten() {
317 Some(value.clone())
318 } else if let Some(path) = arguments.try_get_one::<String>("value-file").ok().flatten() {
319 if path == "-" {
320 if target_uses_stdin {
321 return Err(RunError::message(
322 "target YAML and --value-file cannot both read stdin",
323 ));
324 }
325 Some(read_stream(stdin, "value stdin")?)
326 } else {
327 Some(fs::read_to_string(path).map_err(|error| {
328 RunError::message(format!("cannot read value file {path}: {error}"))
329 })?)
330 }
331 } else {
332 None
333 };
334 input
335 .map(YamlFragment::parse_owned)
336 .transpose()
337 .map_err(RunError::display)
338}
339
340fn read_stream(stream: &mut dyn Read, name: &str) -> Result<String, RunError> {
341 let mut input = String::new();
342 stream
343 .read_to_string(&mut input)
344 .map_err(|error| RunError::message(format!("cannot read {name}: {error}")))?;
345 Ok(input)
346}
347
348fn select_document(doc: &YamlDoc, selected: Option<usize>) -> Result<usize, RunError> {
349 let count = doc.document_count();
350 match selected {
351 Some(index) if index < count => Ok(index),
352 Some(index) => Err(RunError::message(format!(
353 "document index {index} is out of range for {count} documents"
354 ))),
355 None if count == 1 => Ok(0),
356 None if count == 0 => Err(RunError::message("YAML stream contains no documents")),
357 None => Err(RunError::message(format!(
358 "YAML stream contains {count} documents; select one with --doc"
359 ))),
360 }
361}
362
363fn write_mutation(
364 doc: &YamlDoc,
365 arguments: &ArgMatches,
366 input: Option<&Path>,
367 stdout: &mut dyn Write,
368) -> Result<(), RunError> {
369 if arguments.get_flag("in-place") {
370 let input = input
371 .filter(|path| *path != Path::new("-"))
372 .ok_or_else(|| RunError::message("--in-place requires a real input filename"))?;
373 atomic_replace(input, doc.as_source().as_bytes())
374 } else {
375 write_result(
376 doc.as_source().as_bytes(),
377 arguments.get_one::<String>("output").map(Path::new),
378 input,
379 stdout,
380 )
381 }
382}
383
384fn write_result(
385 bytes: &[u8],
386 output: Option<&Path>,
387 input: Option<&Path>,
388 stdout: &mut dyn Write,
389) -> Result<(), RunError> {
390 if let Some(output) = output {
391 if input.is_some_and(|input| paths_equivalent(input, output)) {
392 return Err(RunError::message(
393 "--output must not name the input file; use --in-place",
394 ));
395 }
396 fs::write(output, bytes).map_err(|error| {
397 RunError::message(format!("cannot write {}: {error}", output.display()))
398 })
399 } else {
400 stdout.write_all(bytes).map_err(RunError::io)?;
401 stdout.flush().map_err(RunError::io)
402 }
403}
404
405fn paths_equivalent(left: &Path, right: &Path) -> bool {
406 match (fs::canonicalize(left), fs::canonicalize(right)) {
407 (Ok(left), Ok(right)) => left == right,
408 _ => absolute_path(left).ok() == absolute_path(right).ok(),
409 }
410}
411
412fn absolute_path(path: &Path) -> io::Result<PathBuf> {
413 if path.is_absolute() {
414 Ok(path.to_owned())
415 } else {
416 Ok(std::env::current_dir()?.join(path))
417 }
418}
419
420fn atomic_replace(path: &Path, bytes: &[u8]) -> Result<(), RunError> {
421 let metadata = fs::symlink_metadata(path).map_err(|error| {
422 RunError::message(format!("cannot inspect {}: {error}", path.display()))
423 })?;
424 if metadata.file_type().is_symlink() {
425 return Err(RunError::message(
426 "--in-place refuses to replace a symbolic link",
427 ));
428 }
429 let parent = path.parent().unwrap_or_else(|| Path::new("."));
430 let file_name = path
431 .file_name()
432 .ok_or_else(|| RunError::message("input path has no filename"))?;
433 let (temporary, mut file) = create_sibling_temp(parent, file_name)?;
434 let mut guard = TempGuard {
435 path: temporary.clone(),
436 armed: true,
437 };
438 file.set_permissions(metadata.permissions())
439 .map_err(|error| {
440 RunError::message(format!(
441 "cannot preserve permissions for {}: {error}",
442 path.display()
443 ))
444 })?;
445 file.write_all(bytes).map_err(RunError::io)?;
446 file.flush().map_err(RunError::io)?;
447 file.sync_all().map_err(RunError::io)?;
448 drop(file);
449 fs::rename(&temporary, path).map_err(|error| {
450 RunError::message(format!(
451 "cannot atomically replace {}: {error}",
452 path.display()
453 ))
454 })?;
455 guard.armed = false;
456 Ok(())
457}
458
459fn create_sibling_temp(parent: &Path, file_name: &OsStr) -> Result<(PathBuf, File), RunError> {
460 for _ in 0..100 {
461 let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
462 let mut name = OsString::from(".");
463 name.push(file_name);
464 name.push(format!(".yaml-rt-{}-{counter}.tmp", std::process::id()));
465 let path = parent.join(name);
466 match OpenOptions::new().write(true).create_new(true).open(&path) {
467 Ok(file) => return Ok((path, file)),
468 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {}
469 Err(error) => {
470 return Err(RunError::message(format!(
471 "cannot create temporary file in {}: {error}",
472 parent.display()
473 )));
474 }
475 }
476 }
477 Err(RunError::message(
478 "could not allocate a unique temporary filename",
479 ))
480}
481
482struct TempGuard {
483 path: PathBuf,
484 armed: bool,
485}
486
487impl Drop for TempGuard {
488 fn drop(&mut self) {
489 if self.armed {
490 let _ = fs::remove_file(&self.path);
491 }
492 }
493}
494
495enum RunError {
496 BrokenPipe,
497 Message(String),
498}
499
500impl RunError {
501 fn message(message: impl Into<String>) -> Self {
502 Self::Message(message.into())
503 }
504
505 fn display(error: impl std::fmt::Display) -> Self {
506 Self::Message(error.to_string())
507 }
508
509 fn io(error: io::Error) -> Self {
510 if error.kind() == io::ErrorKind::BrokenPipe {
511 Self::BrokenPipe
512 } else {
513 Self::Message(error.to_string())
514 }
515 }
516}
517
518#[cfg(test)]
519mod tests {
520 use super::*;
521
522 fn invoke(args: &[&str], input: &str) -> (i32, String, String) {
523 let mut stdin = input.as_bytes();
524 let mut stdout = Vec::new();
525 let mut stderr = Vec::new();
526 let status = run(args, &mut stdin, &mut stdout, &mut stderr);
527 (
528 status,
529 String::from_utf8(stdout).unwrap(),
530 String::from_utf8(stderr).unwrap(),
531 )
532 }
533
534 #[test]
535 fn get_and_replace_work_with_stdin() {
536 let (status, stdout, stderr) = invoke(
537 &["yaml-rt", "get", "/server/host"],
538 "server:\n host: localhost\n",
539 );
540 assert_eq!(status, 0, "{stderr}");
541 assert_eq!(stdout, "localhost");
542
543 let (status, stdout, stderr) = invoke(
544 &[
545 "yaml-rt",
546 "replace",
547 "/server/host",
548 "--value",
549 "example.com",
550 ],
551 "server:\n host: localhost\n",
552 );
553 assert_eq!(status, 0, "{stderr}");
554 assert_eq!(stdout, "server:\n host: example.com\n");
555 }
556
557 #[test]
558 fn test_failure_has_no_stdout() {
559 let (status, stdout, stderr) =
560 invoke(&["yaml-rt", "test", "/value", "--value", "2"], "value: 1\n");
561 assert_eq!(status, FAILURE);
562 assert!(stdout.is_empty());
563 assert!(stderr.contains("test failed"));
564 }
565
566 #[test]
567 fn multiple_documents_require_selection() {
568 let (status, _, stderr) = invoke(&["yaml-rt", "get", ""], "--- one\n--- two\n");
569 assert_eq!(status, FAILURE);
570 assert!(stderr.contains("--doc"));
571 }
572}