Skip to main content

yaml_rt_cli/
lib.rs

1//! Command-line editing operations for the `yaml-rt` binary.
2//!
3//! The binary applies JSON Pointer operations to YAML documents while retaining
4//! unrelated presentation. [`run`] is public so integrations can supply their
5//! own argument and I/O streams.
6
7use 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
20/// Runs the command-line application against supplied streams.
21pub 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}