Skip to main content

yaml_rt_cli/
lib.rs

1//! Command-line querying and editing operations for the `yaml-rt` binary.
2//!
3//! The binary searches YAML documents with `JSONPath` and applies JSON Pointer
4//! operations while retaining unrelated presentation. [`run`] is public so
5//! integrations can supply their 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::{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
24/// Runs the command-line application against supplied streams.
25pub 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    /// Search a YAML document with RFC 9535 `JSONPath`.
78    Query(QueryArgs),
79    /// Print a selected YAML node.
80    Get(ReadArgs),
81    /// Add or replace a value.
82    Add(ValueMutationArgs),
83    /// Remove an existing value.
84    Remove(MutationArgs),
85    /// Replace an existing value.
86    Replace(ValueMutationArgs),
87    /// Move an existing value.
88    Move(FromMutationArgs),
89    /// Copy an existing value.
90    Copy(FromMutationArgs),
91    /// Test semantic equality at a path.
92    Test(ValueArgs),
93}
94
95#[derive(Args)]
96struct TargetArgs {
97    /// Input YAML file; defaults to stdin.
98    #[arg(value_name = "FILE")]
99    file: Option<PathBuf>,
100    /// Zero-based YAML document index.
101    #[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    /// Write output to a file.
126    #[arg(short, long, value_name = "FILE")]
127    output: Option<PathBuf>,
128}
129
130#[derive(Args)]
131struct MutationOutputArgs {
132    #[command(flatten)]
133    output: OutputArgs,
134    /// Atomically replace the input file.
135    #[arg(short, long, conflicts_with = "output")]
136    in_place: bool,
137}
138
139#[derive(Args)]
140#[group(required = true, multiple = false)]
141struct ValueSourceArgs {
142    /// Complete YAML node.
143    #[arg(long, value_name = "YAML", allow_hyphen_values = true)]
144    value: Option<String>,
145    /// Read the YAML node from a file.
146    #[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    /// RFC 9535 `JSONPath` query.
161    #[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}