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`, applies JSON Pointer
4//! operations, and executes transactional YAML or JSON patch documents while
5//! retaining unrelated presentation. [`run`] is public so integrations can
6//! supply their own argument and I/O streams.
7
8use 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
28/// Runs the command-line application against supplied streams.
29pub 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    /// Search a YAML document with RFC 9535 `JSONPath`.
89    Query(QueryArgs),
90    /// Print a selected YAML node.
91    Get(ReadArgs),
92    /// Add or replace a value.
93    Add(ValueMutationArgs),
94    /// Remove an existing value.
95    Remove(MutationArgs),
96    /// Replace an existing value.
97    Replace(ValueMutationArgs),
98    /// Move an existing value.
99    Move(FromMutationArgs),
100    /// Copy an existing value.
101    Copy(FromMutationArgs),
102    /// Test semantic equality at a path.
103    Test(ValueArgs),
104    /// Apply a transactional YAML or JSON patch document.
105    Patch(PatchArgs),
106}
107
108#[derive(Args)]
109struct TargetArgs {
110    /// Input YAML file; defaults to stdin.
111    #[arg(value_name = "FILE")]
112    file: Option<PathBuf>,
113    /// Zero-based YAML document index.
114    #[arg(long, value_name = "INDEX")]
115    doc: Option<usize>,
116}
117
118#[derive(Args)]
119struct PathArgs {
120    /// JSON Pointer, or the input file when `--query` is used.
121    #[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    /// Select operation targets with an RFC 9535 `JSONPath` query.
128    #[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    /// Write output to a file.
147    #[arg(short, long, value_name = "FILE")]
148    output: Option<PathBuf>,
149}
150
151#[derive(Args)]
152struct MutationOutputArgs {
153    #[command(flatten)]
154    output: OutputArgs,
155    /// Atomically replace the input file.
156    #[arg(short, long, conflicts_with = "output")]
157    in_place: bool,
158}
159
160#[derive(Args)]
161#[group(required = true, multiple = false)]
162struct ValueSourceArgs {
163    /// Complete YAML node.
164    #[arg(long, value_name = "YAML", allow_hyphen_values = true)]
165    value: Option<String>,
166    /// Read the YAML node from a file.
167    #[arg(long, value_name = "FILE")]
168    value_file: Option<PathBuf>,
169}
170
171#[derive(Args)]
172#[group(required = true, multiple = false)]
173struct PatchSourceArgs {
174    /// YAML or JSON patch document.
175    #[arg(long, value_name = "YAML", allow_hyphen_values = true)]
176    patch: Option<String>,
177    /// Read the YAML or JSON patch document from a file.
178    #[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    /// RFC 9535 `JSONPath` query.
193    #[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}