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. File targets can also be directories,
6//! which are searched recursively for YAML files; an omitted target searches
7//! the current directory, while `-` reads standard input. [`run`] is public so
8//! integrations can supply their own argument and I/O streams.
9
10use std::cmp::Ordering as CmpOrdering;
11use std::collections::HashSet;
12use std::ffi::{OsStr, OsString};
13use std::fs::{self, File, OpenOptions};
14use std::io::{self, Read, Write};
15use std::path::{Path, PathBuf};
16use std::sync::atomic::{AtomicU64, Ordering};
17
18use clap::{Args, CommandFactory, Parser, Subcommand, error::ErrorKind};
19use yaml_rt_core::{JsonPointer, YamlDoc, YamlFragment, YamlPatch};
20use yaml_rt_rfc9535::{JsonPath, QueryMatches};
21
22mod query;
23
24use query::{query_matches, run_query};
25
26const FAILURE: i32 = 1;
27const USAGE: i32 = 2;
28static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
29
30/// Runs the command-line application against supplied streams.
31pub fn run<I, T>(
32    args: I,
33    stdin: &mut dyn Read,
34    stdout: &mut dyn Write,
35    stderr: &mut dyn Write,
36) -> i32
37where
38    I: IntoIterator<Item = T>,
39    T: Into<OsString> + Clone,
40{
41    let cli = match Cli::try_parse_from(args) {
42        Ok(cli) => cli,
43        Err(error) => {
44            let display_only = matches!(
45                error.kind(),
46                ErrorKind::DisplayHelp | ErrorKind::DisplayVersion
47            );
48            let write_result = if display_only {
49                write!(stdout, "{error}")
50            } else {
51                write!(stderr, "{error}")
52            };
53            if write_result.is_err() {
54                return FAILURE;
55            }
56            return if display_only { 0 } else { USAGE };
57        }
58    };
59    if let Err(message) = cli.operation.validate() {
60        let error = Cli::command().error(ErrorKind::ArgumentConflict, message);
61        if write!(stderr, "{error}").is_err() {
62            return FAILURE;
63        }
64        return USAGE;
65    }
66    match execute(&cli.operation, stdin, stdout) {
67        Ok(()) | Err(RunError::BrokenPipe) => 0,
68        Err(RunError::Usage(message)) => {
69            let error = Cli::command().error(ErrorKind::ArgumentConflict, message);
70            if write!(stderr, "{error}").is_err() {
71                return FAILURE;
72            }
73            USAGE
74        }
75        Err(RunError::Batch {
76            diagnostics,
77            summary,
78        }) => {
79            for diagnostic in diagnostics {
80                let _ = writeln!(stderr, "yaml-rt: {diagnostic}");
81            }
82            let _ = writeln!(stderr, "yaml-rt: {summary}");
83            FAILURE
84        }
85        Err(RunError::Message(message)) => {
86            let _ = writeln!(stderr, "yaml-rt: {message}");
87            FAILURE
88        }
89    }
90}
91
92#[derive(Parser)]
93#[command(
94    name = "yaml-rt",
95    version,
96    about = "Query and edit YAML while preserving presentation",
97    subcommand_required = true,
98    arg_required_else_help = true
99)]
100struct Cli {
101    #[command(subcommand)]
102    operation: Operation,
103}
104
105#[derive(Subcommand)]
106enum Operation {
107    /// Search a YAML document with RFC 9535 `JSONPath`.
108    Query(QueryArgs),
109    /// Print a selected YAML node.
110    Get(ReadArgs),
111    /// Add or replace a value.
112    Add(ValueMutationArgs),
113    /// Remove an existing value.
114    Remove(MutationArgs),
115    /// Replace an existing value.
116    Replace(ValueMutationArgs),
117    /// Rename one or more mapping keys.
118    RenameKey(RenameKeyArgs),
119    /// Move an existing value.
120    Move(FromMutationArgs),
121    /// Copy an existing value.
122    Copy(FromMutationArgs),
123    /// Test semantic equality at a path.
124    Test(ValueArgs),
125    /// Apply a transactional YAML or JSON patch document.
126    Patch(PatchArgs),
127}
128
129#[derive(Args)]
130struct TargetArgs {
131    /// Input YAML file or directory; defaults to the current directory. Use - for stdin.
132    #[arg(value_name = "FILE")]
133    file: Option<PathBuf>,
134    /// Zero-based YAML document index.
135    #[arg(long, value_name = "INDEX")]
136    doc: Option<usize>,
137}
138
139#[derive(Args)]
140struct PathArgs {
141    /// JSON Pointer, or the input file when `--query` is used.
142    #[arg(
143        value_name = "PATH_OR_FILE",
144        allow_hyphen_values = true,
145        required_unless_present = "query"
146    )]
147    path_or_file: Option<String>,
148    /// Select operation targets with an RFC 9535 `JSONPath` query.
149    #[arg(long, value_name = "QUERY")]
150    query: Option<String>,
151    #[command(flatten)]
152    target: TargetArgs,
153}
154
155#[derive(Args)]
156struct FromPathArgs {
157    #[arg(value_name = "FROM", allow_hyphen_values = true)]
158    from: String,
159    #[arg(value_name = "PATH", allow_hyphen_values = true)]
160    path: String,
161    #[command(flatten)]
162    target: TargetArgs,
163}
164
165#[derive(Args)]
166struct OutputArgs {
167    /// Write output to a file.
168    #[arg(short, long, value_name = "FILE")]
169    output: Option<PathBuf>,
170}
171
172#[derive(Args)]
173struct MutationOutputArgs {
174    #[command(flatten)]
175    output: OutputArgs,
176    /// Atomically replace the input file.
177    #[arg(short, long, conflicts_with = "output")]
178    in_place: bool,
179}
180
181#[derive(Args)]
182#[group(required = true, multiple = false)]
183struct ValueSourceArgs {
184    /// Complete YAML node.
185    #[arg(long, value_name = "YAML", allow_hyphen_values = true)]
186    value: Option<String>,
187    /// Read the YAML node from a file.
188    #[arg(long, value_name = "FILE")]
189    value_file: Option<PathBuf>,
190}
191
192#[derive(Args)]
193#[group(required = true, multiple = false)]
194struct PatchSourceArgs {
195    /// YAML or JSON patch document.
196    #[arg(long, value_name = "YAML", allow_hyphen_values = true)]
197    patch: Option<String>,
198    /// Read the YAML or JSON patch document from a file.
199    #[arg(long, value_name = "FILE")]
200    patch_file: Option<PathBuf>,
201}
202
203#[derive(Args)]
204struct ReadArgs {
205    #[command(flatten)]
206    path: PathArgs,
207    #[command(flatten)]
208    output: OutputArgs,
209}
210
211#[derive(Args)]
212struct QueryArgs {
213    /// RFC 9535 `JSONPath` query.
214    #[arg(value_name = "QUERY")]
215    query: String,
216    #[command(flatten)]
217    target: TargetArgs,
218    #[command(flatten)]
219    output: OutputArgs,
220}
221
222#[derive(Args)]
223struct MutationArgs {
224    #[command(flatten)]
225    path: PathArgs,
226    #[command(flatten)]
227    output: MutationOutputArgs,
228}
229
230#[derive(Args)]
231struct FromMutationArgs {
232    #[command(flatten)]
233    path: FromPathArgs,
234    #[command(flatten)]
235    output: MutationOutputArgs,
236}
237
238#[derive(Args)]
239struct ValueArgs {
240    #[command(flatten)]
241    path: PathArgs,
242    #[command(flatten)]
243    value: ValueSourceArgs,
244}
245
246#[derive(Args)]
247struct ValueMutationArgs {
248    #[command(flatten)]
249    value: ValueArgs,
250    #[command(flatten)]
251    output: MutationOutputArgs,
252}
253
254#[derive(Args)]
255struct RenameKeyArgs {
256    #[command(flatten)]
257    path: PathArgs,
258    /// Decoded destination key name.
259    #[arg(long, value_name = "KEY")]
260    to: String,
261    #[command(flatten)]
262    output: MutationOutputArgs,
263}
264
265#[derive(Args)]
266struct PatchArgs {
267    #[command(flatten)]
268    target: TargetArgs,
269    #[command(flatten)]
270    source: PatchSourceArgs,
271    #[command(flatten)]
272    output: MutationOutputArgs,
273}
274
275fn execute(
276    operation: &Operation,
277    stdin: &mut dyn Read,
278    stdout: &mut dyn Write,
279) -> Result<(), RunError> {
280    let targets = resolve_targets(operation.input_path())?;
281    if matches!(targets, InputTargets::Batch { .. })
282        && operation
283            .mutation_output()
284            .is_some_and(|output| !output.in_place)
285    {
286        return Err(RunError::usage(
287            "directory targets require --in-place for mutations",
288        ));
289    }
290    let target_uses_stdin = matches!(targets, InputTargets::Stdin);
291    if matches!(operation, Operation::Patch(arguments) if arguments.source.patch_file.as_deref() == Some(Path::new("-")))
292        && target_uses_stdin
293    {
294        return Err(RunError::message(
295            "target YAML and --patch-file cannot both read stdin",
296        ));
297    }
298    let prepared = prepare_operation(operation, target_uses_stdin, stdin)?;
299    match targets {
300        InputTargets::Stdin => {
301            let input = read_stream(stdin, "stdin")?;
302            execute_one(operation, &prepared, None, input, stdout, false)
303        }
304        InputTargets::File(path) => {
305            let input = read_target(&path)?;
306            execute_one(operation, &prepared, Some(&path), input, stdout, false)
307        }
308        InputTargets::Batch {
309            files,
310            discovery_failures,
311        } => execute_batch(operation, &prepared, &files, discovery_failures, stdout),
312    }
313}
314
315fn execute_one(
316    operation: &Operation,
317    prepared: &PreparedOperation,
318    input_path: Option<&Path>,
319    input: String,
320    stdout: &mut dyn Write,
321    batch_capture: bool,
322) -> Result<(), RunError> {
323    let target = operation.target();
324    let mut doc = YamlDoc::parse_owned(input).map_err(RunError::display)?;
325    let document = select_document(&doc, target.doc)?;
326
327    if let Operation::Query(arguments) = operation {
328        let output = run_query(
329            &doc,
330            document,
331            prepared
332                .query
333                .as_ref()
334                .expect("query operation is prepared"),
335        )
336        .map_err(RunError::display)?;
337        return write_result(
338            output.as_bytes(),
339            if batch_capture {
340                None
341            } else {
342                arguments.output.output.as_deref()
343            },
344            input_path,
345            stdout,
346        );
347    }
348
349    if let Operation::Patch(arguments) = operation {
350        doc.apply_patch(
351            document,
352            prepared
353                .patch
354                .as_ref()
355                .expect("patch operation is prepared"),
356        )
357        .map_err(RunError::display)?;
358        return write_mutation(&doc, &arguments.output, input_path, stdout);
359    }
360
361    if operation.selection_query().is_some() {
362        let matches = query_matches(
363            &doc,
364            document,
365            prepared
366                .query
367                .as_ref()
368                .expect("query-targeted operation is prepared"),
369        )
370        .map_err(RunError::display)?;
371        let mut output = CommandOutput {
372            input_path,
373            stdout,
374            batch_capture,
375        };
376        return execute_query_targeted(
377            operation,
378            &mut doc,
379            document,
380            &matches,
381            prepared.value.as_ref(),
382            &mut output,
383        );
384    }
385
386    let path = prepared
387        .path
388        .as_ref()
389        .expect("pointer operation is prepared");
390    let from = prepared.from.as_ref();
391    match operation {
392        Operation::Query(_) => unreachable!("query returned before pointer operations"),
393        Operation::Get(arguments) => {
394            let node = doc
395                .resolve_pointer(document, path)
396                .map_err(RunError::display)?;
397            let output = doc.extract_node(node).map_err(RunError::display)?;
398            write_result(
399                output.as_bytes(),
400                if batch_capture {
401                    None
402                } else {
403                    arguments.output.output.as_deref()
404                },
405                input_path,
406                stdout,
407            )
408        }
409        Operation::Test(_) => {
410            let equal = doc
411                .test_at(
412                    document,
413                    path,
414                    prepared.value.as_ref().expect("Clap requires a value"),
415                )
416                .map_err(RunError::display)?;
417            if equal {
418                Ok(())
419            } else {
420                Err(RunError::message(format!(
421                    "test failed at {:?}: values are not semantically equal",
422                    path.as_str()
423                )))
424            }
425        }
426        Operation::Add(arguments) => {
427            doc.add_at(
428                document,
429                path,
430                prepared.value.as_ref().expect("Clap requires a value"),
431            )
432            .map_err(RunError::display)?;
433            write_mutation(&doc, &arguments.output, input_path, stdout)
434        }
435        Operation::Remove(arguments) => {
436            doc.remove_at(document, path).map_err(RunError::display)?;
437            write_mutation(&doc, &arguments.output, input_path, stdout)
438        }
439        Operation::Replace(arguments) => {
440            doc.replace_at(
441                document,
442                path,
443                prepared.value.as_ref().expect("Clap requires a value"),
444            )
445            .map_err(RunError::display)?;
446            write_mutation(&doc, &arguments.output, input_path, stdout)
447        }
448        Operation::RenameKey(arguments) => {
449            doc.rename_key_at(document, path, &arguments.to)
450                .map_err(RunError::display)?;
451            write_mutation(&doc, &arguments.output, input_path, stdout)
452        }
453        Operation::Move(arguments) => {
454            doc.move_at(document, from.expect("Clap requires from"), path)
455                .map_err(RunError::display)?;
456            write_mutation(&doc, &arguments.output, input_path, stdout)
457        }
458        Operation::Copy(arguments) => {
459            doc.copy_at(document, from.expect("Clap requires from"), path)
460                .map_err(RunError::display)?;
461            write_mutation(&doc, &arguments.output, input_path, stdout)
462        }
463        Operation::Patch(_) => unreachable!("patch returned before pointer operations"),
464    }
465}
466
467struct PreparedOperation {
468    path: Option<JsonPointer>,
469    from: Option<JsonPointer>,
470    query: Option<JsonPath>,
471    value: Option<YamlFragment>,
472    patch: Option<YamlPatch>,
473}
474
475fn prepare_operation(
476    operation: &Operation,
477    target_uses_stdin: bool,
478    stdin: &mut dyn Read,
479) -> Result<PreparedOperation, RunError> {
480    let query = operation
481        .query_source()
482        .map(JsonPath::parse)
483        .transpose()
484        .map_err(RunError::display)?;
485    let path = if query.is_none() && !matches!(operation, Operation::Patch(_)) {
486        Some(JsonPointer::parse(operation.path()).map_err(RunError::display)?)
487    } else {
488        None
489    };
490    let from = operation
491        .from()
492        .map(JsonPointer::parse)
493        .transpose()
494        .map_err(RunError::display)?;
495    let value = read_value(operation.value(), target_uses_stdin, stdin)?;
496    let patch = match operation {
497        Operation::Patch(arguments) => Some(read_patch(&arguments.source, stdin)?),
498        _ => None,
499    };
500    Ok(PreparedOperation {
501        path,
502        from,
503        query,
504        value,
505        patch,
506    })
507}
508
509enum InputTargets {
510    Stdin,
511    File(PathBuf),
512    Batch {
513        files: Vec<BatchTarget>,
514        discovery_failures: Vec<DiscoveryFailure>,
515    },
516}
517
518struct BatchTarget {
519    path: PathBuf,
520    relative: PathBuf,
521}
522
523struct DiscoveryFailure {
524    relative: PathBuf,
525    message: String,
526}
527
528fn resolve_targets(path: Option<&Path>) -> Result<InputTargets, RunError> {
529    let path = match path {
530        None => std::env::current_dir().map_err(|error| {
531            RunError::message(format!("cannot determine current directory: {error}"))
532        })?,
533        Some(path) if path == Path::new("-") => return Ok(InputTargets::Stdin),
534        Some(path) => path.to_owned(),
535    };
536    if fs::symlink_metadata(&path).is_ok_and(|metadata| metadata.is_dir()) {
537        let (files, discovery_failures) = discover_yaml_files(&path);
538        Ok(InputTargets::Batch {
539            files,
540            discovery_failures,
541        })
542    } else {
543        Ok(InputTargets::File(path))
544    }
545}
546
547fn discover_yaml_files(root: &Path) -> (Vec<BatchTarget>, Vec<DiscoveryFailure>) {
548    let mut files = Vec::new();
549    let mut failures = Vec::new();
550    discover_directory(root, root, &mut files, &mut failures);
551    files.sort_by(|left, right| left.relative.cmp(&right.relative));
552    failures.sort_by(|left, right| left.relative.cmp(&right.relative));
553    (files, failures)
554}
555
556fn discover_directory(
557    root: &Path,
558    directory: &Path,
559    files: &mut Vec<BatchTarget>,
560    failures: &mut Vec<DiscoveryFailure>,
561) {
562    let entries = match fs::read_dir(directory) {
563        Ok(entries) => entries,
564        Err(error) => {
565            failures.push(DiscoveryFailure {
566                relative: relative_to(root, directory),
567                message: format!("cannot read directory: {error}"),
568            });
569            return;
570        }
571    };
572    let mut entries = entries
573        .filter_map(|entry| match entry {
574            Ok(entry) => Some(entry),
575            Err(error) => {
576                failures.push(DiscoveryFailure {
577                    relative: relative_to(root, directory),
578                    message: format!("cannot read directory entry: {error}"),
579                });
580                None
581            }
582        })
583        .collect::<Vec<_>>();
584    entries.sort_by_key(std::fs::DirEntry::file_name);
585    for entry in entries {
586        let path = entry.path();
587        let file_type = match entry.file_type() {
588            Ok(file_type) => file_type,
589            Err(error) => {
590                failures.push(DiscoveryFailure {
591                    relative: relative_to(root, &path),
592                    message: format!("cannot inspect path: {error}"),
593                });
594                continue;
595            }
596        };
597        if file_type.is_symlink() {
598            continue;
599        }
600        if file_type.is_dir() {
601            discover_directory(root, &path, files, failures);
602        } else if file_type.is_file() && has_yaml_extension(&path) {
603            files.push(BatchTarget {
604                relative: relative_to(root, &path),
605                path,
606            });
607        }
608    }
609}
610
611fn relative_to(root: &Path, path: &Path) -> PathBuf {
612    path.strip_prefix(root)
613        .ok()
614        .filter(|path| !path.as_os_str().is_empty())
615        .unwrap_or_else(|| Path::new("."))
616        .to_owned()
617}
618
619fn has_yaml_extension(path: &Path) -> bool {
620    path.extension()
621        .and_then(OsStr::to_str)
622        .is_some_and(|extension| {
623            extension.eq_ignore_ascii_case("yaml") || extension.eq_ignore_ascii_case("yml")
624        })
625}
626
627fn execute_batch(
628    operation: &Operation,
629    prepared: &PreparedOperation,
630    files: &[BatchTarget],
631    discovery_failures: Vec<DiscoveryFailure>,
632    stdout: &mut dyn Write,
633) -> Result<(), RunError> {
634    if let Some(output) = operation.read_output()
635        && let Some(input) = files
636            .iter()
637            .find(|input| paths_equivalent(&input.path, output))
638    {
639        return Err(RunError::message(format!(
640            "--output must not name input file {}",
641            render_batch_path(&input.relative)
642        )));
643    }
644
645    let mut diagnostics = discovery_failures
646        .iter()
647        .map(|failure| {
648            format!(
649                "{}: {}",
650                render_batch_path(&failure.relative),
651                failure.message
652            )
653        })
654        .collect::<Vec<_>>();
655    let mut succeeded = 0;
656    let mut failed = 0;
657    let mut combined_output = Vec::new();
658    for input in files {
659        let source = match read_target(&input.path) {
660            Ok(source) => source,
661            Err(RunError::Message(message)) => {
662                diagnostics.push(format!("{}: {message}", render_batch_path(&input.relative)));
663                failed += 1;
664                continue;
665            }
666            Err(error) => return Err(error),
667        };
668        let mut result = Vec::new();
669        match execute_one(
670            operation,
671            prepared,
672            Some(&input.path),
673            source,
674            &mut result,
675            true,
676        ) {
677            Ok(()) => {
678                succeeded += 1;
679                if operation.should_emit_batch_result(&result) {
680                    append_batch_result(&mut combined_output, &input.relative, &result);
681                }
682            }
683            Err(RunError::Message(message)) => {
684                diagnostics.push(format!("{}: {message}", render_batch_path(&input.relative)));
685                failed += 1;
686            }
687            Err(error) => return Err(error),
688        }
689    }
690
691    if operation.has_read_output() {
692        write_result(&combined_output, operation.read_output(), None, stdout)?;
693    }
694    if diagnostics.is_empty() {
695        Ok(())
696    } else {
697        Err(RunError::Batch {
698            diagnostics,
699            summary: format!(
700                "processed {} YAML files: {succeeded} succeeded, {failed} failed; {} traversal errors",
701                files.len(),
702                discovery_failures.len()
703            ),
704        })
705    }
706}
707
708fn append_batch_result(output: &mut Vec<u8>, path: &Path, result: &[u8]) {
709    if !output.is_empty() {
710        output.push(b'\n');
711    }
712    writeln!(output, "==> {} <==", render_batch_path(path)).expect("writing to a Vec cannot fail");
713    output.extend_from_slice(result);
714    if !result.is_empty() && !result.ends_with(b"\n") {
715        output.push(b'\n');
716    }
717}
718
719fn render_batch_path(path: &Path) -> String {
720    path.components()
721        .map(|component| component.as_os_str().to_string_lossy())
722        .collect::<Vec<_>>()
723        .join("/")
724}
725
726impl Operation {
727    fn validate(&self) -> Result<(), String> {
728        let path = match self {
729            Self::Get(args) => Some(&args.path),
730            Self::Add(args) | Self::Replace(args) => Some(&args.value.path),
731            Self::RenameKey(args) => Some(&args.path),
732            Self::Remove(args) => Some(&args.path),
733            Self::Test(args) => Some(&args.path),
734            _ => None,
735        };
736        if let Some(path) = path {
737            path.validate()?;
738        }
739        Ok(())
740    }
741
742    fn target(&self) -> &TargetArgs {
743        match self {
744            Self::Query(args) => &args.target,
745            Self::Get(args) => &args.path.target,
746            Self::Add(args) | Self::Replace(args) => &args.value.path.target,
747            Self::RenameKey(args) => &args.path.target,
748            Self::Remove(args) => &args.path.target,
749            Self::Move(args) | Self::Copy(args) => &args.path.target,
750            Self::Test(args) => &args.path.target,
751            Self::Patch(args) => &args.target,
752        }
753    }
754
755    fn path(&self) -> &str {
756        match self {
757            Self::Query(_) | Self::Patch(_) => {
758                unreachable!("operation does not use a JSON Pointer argument")
759            }
760            Self::Get(args) => args.path.pointer(),
761            Self::Add(args) | Self::Replace(args) => args.value.path.pointer(),
762            Self::RenameKey(args) => args.path.pointer(),
763            Self::Remove(args) => args.path.pointer(),
764            Self::Move(args) | Self::Copy(args) => &args.path.path,
765            Self::Test(args) => args.path.pointer(),
766        }
767    }
768
769    fn selection_query(&self) -> Option<&str> {
770        match self {
771            Self::Get(args) => args.path.query.as_deref(),
772            Self::Add(args) | Self::Replace(args) => args.value.path.query.as_deref(),
773            Self::RenameKey(args) => args.path.query.as_deref(),
774            Self::Remove(args) => args.path.query.as_deref(),
775            Self::Test(args) => args.path.query.as_deref(),
776            _ => None,
777        }
778    }
779
780    fn query_source(&self) -> Option<&str> {
781        match self {
782            Self::Query(args) => Some(&args.query),
783            _ => self.selection_query(),
784        }
785    }
786
787    fn mutation_output(&self) -> Option<&MutationOutputArgs> {
788        match self {
789            Self::Add(args) | Self::Replace(args) => Some(&args.output),
790            Self::RenameKey(args) => Some(&args.output),
791            Self::Remove(args) => Some(&args.output),
792            Self::Move(args) | Self::Copy(args) => Some(&args.output),
793            Self::Patch(args) => Some(&args.output),
794            Self::Query(_) | Self::Get(_) | Self::Test(_) => None,
795        }
796    }
797
798    fn read_output(&self) -> Option<&Path> {
799        match self {
800            Self::Query(args) => args.output.output.as_deref(),
801            Self::Get(args) => args.output.output.as_deref(),
802            _ => None,
803        }
804    }
805
806    fn has_read_output(&self) -> bool {
807        matches!(self, Self::Query(_) | Self::Get(_))
808    }
809
810    fn should_emit_batch_result(&self, result: &[u8]) -> bool {
811        match self {
812            Self::Query(_) => !result.is_empty(),
813            Self::Get(args) if args.path.query.is_some() => !result.is_empty(),
814            Self::Get(_) => true,
815            _ => false,
816        }
817    }
818
819    fn input_path(&self) -> Option<&Path> {
820        match self {
821            Self::Get(args) => args.path.input_path(),
822            Self::Add(args) | Self::Replace(args) => args.value.path.input_path(),
823            Self::RenameKey(args) => args.path.input_path(),
824            Self::Remove(args) => args.path.input_path(),
825            Self::Test(args) => args.path.input_path(),
826            _ => self.target().file.as_deref(),
827        }
828    }
829
830    fn from(&self) -> Option<&str> {
831        match self {
832            Self::Move(args) | Self::Copy(args) => Some(&args.path.from),
833            _ => None,
834        }
835    }
836
837    fn value(&self) -> Option<&ValueSourceArgs> {
838        match self {
839            Self::Add(args) | Self::Replace(args) => Some(&args.value.value),
840            Self::Test(args) => Some(&args.value),
841            _ => None,
842        }
843    }
844}
845
846impl PathArgs {
847    fn validate(&self) -> Result<(), String> {
848        if self.query.is_some() && self.target.file.is_some() {
849            return Err(
850                "a JSONPath-targeted command accepts at most one positional FILE argument"
851                    .to_owned(),
852            );
853        }
854        Ok(())
855    }
856
857    fn pointer(&self) -> &str {
858        self.path_or_file
859            .as_deref()
860            .expect("Clap requires a pointer when --query is absent")
861    }
862
863    fn input_path(&self) -> Option<&Path> {
864        if self.query.is_some() {
865            self.path_or_file.as_deref().map(Path::new)
866        } else {
867            self.target.file.as_deref()
868        }
869    }
870}
871
872struct CommandOutput<'a> {
873    input_path: Option<&'a Path>,
874    stdout: &'a mut dyn Write,
875    batch_capture: bool,
876}
877
878fn execute_query_targeted(
879    operation: &Operation,
880    doc: &mut YamlDoc,
881    document: usize,
882    matches: &QueryMatches,
883    value: Option<&YamlFragment>,
884    output: &mut CommandOutput<'_>,
885) -> Result<(), RunError> {
886    match operation {
887        Operation::Get(arguments) => {
888            let rendered = render_yaml_stream(doc, matches)?;
889            write_result(
890                rendered.as_bytes(),
891                if output.batch_capture {
892                    None
893                } else {
894                    arguments.output.output.as_deref()
895                },
896                output.input_path,
897                output.stdout,
898            )
899        }
900        Operation::Test(_) => test_query_matches(
901            doc,
902            document,
903            matches,
904            value.expect("Clap requires a value"),
905        ),
906        Operation::Add(arguments) => {
907            apply_query_mutation(
908                doc,
909                document,
910                matches,
911                QueryMutation::Add(value.expect("Clap requires a value")),
912            )?;
913            write_mutation(doc, &arguments.output, output.input_path, output.stdout)
914        }
915        Operation::Remove(arguments) => {
916            apply_query_mutation(doc, document, matches, QueryMutation::Remove)?;
917            write_mutation(doc, &arguments.output, output.input_path, output.stdout)
918        }
919        Operation::Replace(arguments) => {
920            apply_query_mutation(
921                doc,
922                document,
923                matches,
924                QueryMutation::Replace(value.expect("Clap requires a value")),
925            )?;
926            write_mutation(doc, &arguments.output, output.input_path, output.stdout)
927        }
928        Operation::RenameKey(arguments) => {
929            if matches.is_empty() {
930                return Err(RunError::message("query matched no nodes"));
931            }
932            let pointers = matches
933                .iter()
934                .map(|matched| matched.pointer().clone())
935                .collect::<Vec<_>>();
936            doc.rename_keys_at(document, &pointers, &arguments.to)
937                .map_err(RunError::display)?;
938            write_mutation(doc, &arguments.output, output.input_path, output.stdout)
939        }
940        _ => unreachable!("only single-path commands accept --query"),
941    }
942}
943
944fn render_yaml_stream(doc: &YamlDoc, matches: &QueryMatches) -> Result<String, RunError> {
945    let mut output = String::new();
946    for matched in matches {
947        output.push_str("---\n");
948        if let Some(node) = matched.node() {
949            let fragment = doc.extract_node(node).map_err(RunError::display)?;
950            output.push_str(&fragment);
951            if !fragment.ends_with(['\n', '\r']) {
952                output.push('\n');
953            }
954        }
955    }
956    Ok(output)
957}
958
959enum QueryMutation<'a> {
960    Add(&'a YamlFragment),
961    Remove,
962    Replace(&'a YamlFragment),
963}
964
965fn apply_query_mutation(
966    doc: &mut YamlDoc,
967    document: usize,
968    matches: &QueryMatches,
969    mutation: QueryMutation<'_>,
970) -> Result<(), RunError> {
971    if matches.is_empty() {
972        return Err(RunError::message("query matched no nodes"));
973    }
974    let mut targets = normalized_mutation_targets(matches);
975    if matches!(mutation, QueryMutation::Remove) {
976        targets.sort_by(removal_order);
977    }
978    let mut work = doc.clone();
979    for pointer in &targets {
980        match mutation {
981            QueryMutation::Add(value) => work.add_at(document, pointer, value),
982            QueryMutation::Remove => work.remove_at(document, pointer),
983            QueryMutation::Replace(value) => work.replace_at(document, pointer, value),
984        }
985        .map_err(RunError::display)?;
986    }
987    *doc = work;
988    Ok(())
989}
990
991fn normalized_mutation_targets(matches: &QueryMatches) -> Vec<JsonPointer> {
992    let mut seen = HashSet::new();
993    let unique = matches
994        .iter()
995        .filter_map(|matched| {
996            let pointer = matched.pointer();
997            seen.insert(pointer.as_str().to_owned())
998                .then(|| pointer.clone())
999        })
1000        .collect::<Vec<_>>();
1001    unique
1002        .iter()
1003        .filter(|pointer| {
1004            !unique
1005                .iter()
1006                .any(|candidate| candidate.is_proper_prefix_of(pointer))
1007        })
1008        .cloned()
1009        .collect()
1010}
1011
1012fn removal_order(left: &JsonPointer, right: &JsonPointer) -> CmpOrdering {
1013    right
1014        .tokens()
1015        .len()
1016        .cmp(&left.tokens().len())
1017        .then_with(|| {
1018            for (left, right) in left.tokens().iter().zip(right.tokens()) {
1019                let order = match (
1020                    left.as_str().parse::<usize>(),
1021                    right.as_str().parse::<usize>(),
1022                ) {
1023                    (Ok(left), Ok(right)) => right.cmp(&left),
1024                    _ => right.as_str().cmp(left.as_str()),
1025                };
1026                if order != CmpOrdering::Equal {
1027                    return order;
1028                }
1029            }
1030            CmpOrdering::Equal
1031        })
1032}
1033
1034fn test_query_matches(
1035    doc: &YamlDoc,
1036    document: usize,
1037    matches: &QueryMatches,
1038    value: &YamlFragment,
1039) -> Result<(), RunError> {
1040    if matches.is_empty() {
1041        return Err(RunError::message("query matched no nodes"));
1042    }
1043    for matched in matches {
1044        let pointer = matched.pointer();
1045        let equal = doc
1046            .test_at(document, pointer, value)
1047            .map_err(RunError::display)?;
1048        if !equal {
1049            return Err(RunError::message(format!(
1050                "test failed at {:?}: values are not semantically equal",
1051                pointer.as_str()
1052            )));
1053        }
1054    }
1055    Ok(())
1056}
1057
1058fn read_patch(arguments: &PatchSourceArgs, stdin: &mut dyn Read) -> Result<YamlPatch, RunError> {
1059    let input = if let Some(patch) = &arguments.patch {
1060        patch.clone()
1061    } else if let Some(path) = arguments.patch_file.as_deref() {
1062        if path == Path::new("-") {
1063            read_stream(stdin, "patch stdin")?
1064        } else {
1065            fs::read_to_string(path).map_err(|error| {
1066                RunError::message(format!(
1067                    "cannot read patch file {}: {error}",
1068                    path.display()
1069                ))
1070            })?
1071        }
1072    } else {
1073        unreachable!("Clap requires a patch source")
1074    };
1075    YamlPatch::parse_owned(input).map_err(RunError::display)
1076}
1077
1078fn read_target(path: &Path) -> Result<String, RunError> {
1079    fs::read_to_string(path)
1080        .map_err(|error| RunError::message(format!("cannot read {}: {error}", path.display())))
1081}
1082
1083fn read_value(
1084    arguments: Option<&ValueSourceArgs>,
1085    target_uses_stdin: bool,
1086    stdin: &mut dyn Read,
1087) -> Result<Option<YamlFragment>, RunError> {
1088    let input = if let Some(value) = arguments.and_then(|arguments| arguments.value.as_ref()) {
1089        Some(value.clone())
1090    } else if let Some(path) = arguments.and_then(|arguments| arguments.value_file.as_deref()) {
1091        if path == Path::new("-") {
1092            if target_uses_stdin {
1093                return Err(RunError::message(
1094                    "target YAML and --value-file cannot both read stdin",
1095                ));
1096            }
1097            Some(read_stream(stdin, "value stdin")?)
1098        } else {
1099            Some(fs::read_to_string(path).map_err(|error| {
1100                RunError::message(format!(
1101                    "cannot read value file {}: {error}",
1102                    path.display()
1103                ))
1104            })?)
1105        }
1106    } else {
1107        None
1108    };
1109    input
1110        .map(YamlFragment::parse_owned)
1111        .transpose()
1112        .map_err(RunError::display)
1113}
1114
1115fn read_stream(stream: &mut dyn Read, name: &str) -> Result<String, RunError> {
1116    let mut input = String::new();
1117    stream
1118        .read_to_string(&mut input)
1119        .map_err(|error| RunError::message(format!("cannot read {name}: {error}")))?;
1120    Ok(input)
1121}
1122
1123fn select_document(doc: &YamlDoc, selected: Option<usize>) -> Result<usize, RunError> {
1124    let count = doc.document_count();
1125    match selected {
1126        Some(index) if index < count => Ok(index),
1127        Some(index) => Err(RunError::message(format!(
1128            "document index {index} is out of range for {count} documents"
1129        ))),
1130        None if count == 1 => Ok(0),
1131        None if count == 0 => Err(RunError::message("YAML stream contains no documents")),
1132        None => Err(RunError::message(format!(
1133            "YAML stream contains {count} documents; select one with --doc"
1134        ))),
1135    }
1136}
1137
1138fn write_mutation(
1139    doc: &YamlDoc,
1140    arguments: &MutationOutputArgs,
1141    input: Option<&Path>,
1142    stdout: &mut dyn Write,
1143) -> Result<(), RunError> {
1144    if arguments.in_place {
1145        let input = input
1146            .filter(|path| *path != Path::new("-"))
1147            .ok_or_else(|| RunError::message("--in-place requires a real input filename"))?;
1148        atomic_replace(input, doc.as_source().as_bytes())
1149    } else {
1150        write_result(
1151            doc.as_source().as_bytes(),
1152            arguments.output.output.as_deref(),
1153            input,
1154            stdout,
1155        )
1156    }
1157}
1158
1159fn write_result(
1160    bytes: &[u8],
1161    output: Option<&Path>,
1162    input: Option<&Path>,
1163    stdout: &mut dyn Write,
1164) -> Result<(), RunError> {
1165    if let Some(output) = output {
1166        if input.is_some_and(|input| paths_equivalent(input, output)) {
1167            return Err(RunError::message(
1168                "--output must not name the input file; use --in-place",
1169            ));
1170        }
1171        fs::write(output, bytes).map_err(|error| {
1172            RunError::message(format!("cannot write {}: {error}", output.display()))
1173        })
1174    } else {
1175        stdout
1176            .write_all(bytes)
1177            .map_err(|error| RunError::io(&error))?;
1178        stdout.flush().map_err(|error| RunError::io(&error))
1179    }
1180}
1181
1182fn paths_equivalent(left: &Path, right: &Path) -> bool {
1183    match (fs::canonicalize(left), fs::canonicalize(right)) {
1184        (Ok(left), Ok(right)) => left == right,
1185        _ => absolute_path(left).ok() == absolute_path(right).ok(),
1186    }
1187}
1188
1189fn absolute_path(path: &Path) -> io::Result<PathBuf> {
1190    if path.is_absolute() {
1191        Ok(path.to_owned())
1192    } else {
1193        Ok(std::env::current_dir()?.join(path))
1194    }
1195}
1196
1197fn atomic_replace(path: &Path, bytes: &[u8]) -> Result<(), RunError> {
1198    let metadata = fs::symlink_metadata(path).map_err(|error| {
1199        RunError::message(format!("cannot inspect {}: {error}", path.display()))
1200    })?;
1201    if metadata.file_type().is_symlink() {
1202        return Err(RunError::message(
1203            "--in-place refuses to replace a symbolic link",
1204        ));
1205    }
1206    let parent = path.parent().unwrap_or_else(|| Path::new("."));
1207    let file_name = path
1208        .file_name()
1209        .ok_or_else(|| RunError::message("input path has no filename"))?;
1210    let (temporary, mut file) = create_sibling_temp(parent, file_name)?;
1211    let mut guard = TempGuard {
1212        path: temporary.clone(),
1213        armed: true,
1214    };
1215    file.set_permissions(metadata.permissions())
1216        .map_err(|error| {
1217            RunError::message(format!(
1218                "cannot preserve permissions for {}: {error}",
1219                path.display()
1220            ))
1221        })?;
1222    file.write_all(bytes)
1223        .map_err(|error| RunError::io(&error))?;
1224    file.flush().map_err(|error| RunError::io(&error))?;
1225    file.sync_all().map_err(|error| RunError::io(&error))?;
1226    drop(file);
1227    fs::rename(&temporary, path).map_err(|error| {
1228        RunError::message(format!(
1229            "cannot atomically replace {}: {error}",
1230            path.display()
1231        ))
1232    })?;
1233    guard.armed = false;
1234    Ok(())
1235}
1236
1237fn create_sibling_temp(parent: &Path, file_name: &OsStr) -> Result<(PathBuf, File), RunError> {
1238    for _ in 0..100 {
1239        let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
1240        let mut name = OsString::from(".");
1241        name.push(file_name);
1242        name.push(format!(".yaml-rt-{}-{counter}.tmp", std::process::id()));
1243        let path = parent.join(name);
1244        match OpenOptions::new().write(true).create_new(true).open(&path) {
1245            Ok(file) => return Ok((path, file)),
1246            Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {}
1247            Err(error) => {
1248                return Err(RunError::message(format!(
1249                    "cannot create temporary file in {}: {error}",
1250                    parent.display()
1251                )));
1252            }
1253        }
1254    }
1255    Err(RunError::message(
1256        "could not allocate a unique temporary filename",
1257    ))
1258}
1259
1260struct TempGuard {
1261    path: PathBuf,
1262    armed: bool,
1263}
1264
1265impl Drop for TempGuard {
1266    fn drop(&mut self) {
1267        if self.armed {
1268            let _ = fs::remove_file(&self.path);
1269        }
1270    }
1271}
1272
1273enum RunError {
1274    BrokenPipe,
1275    Message(String),
1276    Usage(String),
1277    Batch {
1278        diagnostics: Vec<String>,
1279        summary: String,
1280    },
1281}
1282
1283impl RunError {
1284    fn message(message: impl Into<String>) -> Self {
1285        Self::Message(message.into())
1286    }
1287
1288    fn usage(message: impl Into<String>) -> Self {
1289        Self::Usage(message.into())
1290    }
1291
1292    fn display(error: impl std::fmt::Display) -> Self {
1293        Self::Message(error.to_string())
1294    }
1295
1296    fn io(error: &io::Error) -> Self {
1297        if error.kind() == io::ErrorKind::BrokenPipe {
1298            Self::BrokenPipe
1299        } else {
1300            Self::Message(error.to_string())
1301        }
1302    }
1303}
1304
1305#[cfg(test)]
1306mod tests {
1307    use super::*;
1308
1309    fn invoke(args: &[&str], input: &str) -> (i32, String, String) {
1310        let mut stdin = input.as_bytes();
1311        let mut stdout = Vec::new();
1312        let mut stderr = Vec::new();
1313        let mut args = args.to_vec();
1314        args.push("-");
1315        let status = run(args, &mut stdin, &mut stdout, &mut stderr);
1316        (
1317            status,
1318            String::from_utf8(stdout).unwrap(),
1319            String::from_utf8(stderr).unwrap(),
1320        )
1321    }
1322
1323    #[test]
1324    fn get_and_replace_work_with_stdin() {
1325        let (status, stdout, stderr) = invoke(
1326            &["yaml-rt", "get", "/server/host"],
1327            "server:\n  host: localhost\n",
1328        );
1329        assert_eq!(status, 0, "{stderr}");
1330        assert_eq!(stdout, "localhost");
1331
1332        let (status, stdout, stderr) = invoke(
1333            &[
1334                "yaml-rt",
1335                "replace",
1336                "/server/host",
1337                "--value",
1338                "example.com",
1339            ],
1340            "server:\n  host: localhost\n",
1341        );
1342        assert_eq!(status, 0, "{stderr}");
1343        assert_eq!(stdout, "server:\n  host: example.com\n");
1344    }
1345
1346    #[test]
1347    fn rename_key_works_with_pointer_and_document_selection() {
1348        let input = "---\nold: first\n---\nold: second # keep\n";
1349        let (status, stdout, stderr) = invoke(
1350            &[
1351                "yaml-rt",
1352                "rename-key",
1353                "/old",
1354                "--to",
1355                "true",
1356                "--doc",
1357                "1",
1358            ],
1359            input,
1360        );
1361        assert_eq!(status, 0, "{stderr}");
1362        assert_eq!(stdout, "---\nold: first\n---\n\"true\": second # keep\n");
1363    }
1364
1365    #[test]
1366    fn query_targeted_rename_is_atomic_and_requires_mapping_members() {
1367        let input = "items: [{old: 1}, {old: 2}]\n";
1368        let (status, stdout, stderr) = invoke(
1369            &["yaml-rt", "rename-key", "--query", "$..old", "--to", "new"],
1370            input,
1371        );
1372        assert_eq!(status, 0, "{stderr}");
1373        assert_eq!(stdout, "items: [{new: 1}, {new: 2}]\n");
1374
1375        let (status, stdout, stderr) = invoke(
1376            &["yaml-rt", "rename-key", "--query", "$..*", "--to", "new"],
1377            input,
1378        );
1379        assert_eq!(status, FAILURE);
1380        assert!(stdout.is_empty());
1381        assert!(stderr.contains("does not select a mapping member"));
1382
1383        let (status, stdout, stderr) = invoke(
1384            &[
1385                "yaml-rt",
1386                "rename-key",
1387                "--query",
1388                "$.missing",
1389                "--to",
1390                "new",
1391            ],
1392            input,
1393        );
1394        assert_eq!(status, FAILURE);
1395        assert!(stdout.is_empty());
1396        assert!(stderr.contains("query matched no nodes"));
1397    }
1398
1399    #[test]
1400    fn query_targeted_rename_rolls_back_collisions() {
1401        let (status, stdout, stderr) = invoke(
1402            &[
1403                "yaml-rt",
1404                "rename-key",
1405                "--query",
1406                "$['a','b']",
1407                "--to",
1408                "x",
1409            ],
1410            "a: 1\nb: 2\n",
1411        );
1412        assert_eq!(status, FAILURE);
1413        assert!(stdout.is_empty());
1414        assert!(stderr.contains("duplicate key \"x\""));
1415    }
1416
1417    #[test]
1418    fn query_works_with_stdin_and_no_matches_succeed() {
1419        let input = "users:\n  - {name: Ada, active: true}\n  - {name: Linus, active: false}\n";
1420        let (status, stdout, stderr) = invoke(
1421            &["yaml-rt", "query", "$.users[?@.active == true].name"],
1422            input,
1423        );
1424        assert_eq!(status, 0, "{stderr}");
1425        assert_eq!(stdout, "\"/users/0/name\": \"Ada\"\n");
1426
1427        let (status, stdout, stderr) = invoke(&["yaml-rt", "query", "$.missing"], input);
1428        assert_eq!(status, 0, "{stderr}");
1429        assert!(stdout.is_empty());
1430    }
1431
1432    #[test]
1433    fn get_query_emits_a_yaml_document_stream() {
1434        let input = "users:\n  - {name: Ada}\n  - {name: Linus}\n";
1435        let (status, stdout, stderr) =
1436            invoke(&["yaml-rt", "get", "--query", "$.users[*].name"], input);
1437        assert_eq!(status, 0, "{stderr}");
1438        assert_eq!(stdout, "---\nAda\n---\nLinus\n");
1439
1440        let (status, stdout, stderr) = invoke(&["yaml-rt", "get", "--query", "$.missing"], input);
1441        assert_eq!(status, 0, "{stderr}");
1442        assert!(stdout.is_empty());
1443
1444        let (status, stdout, stderr) = invoke(&["yaml-rt", "get", "--query", "$"], "---\n");
1445        assert_eq!(status, 0, "{stderr}");
1446        assert_eq!(stdout, "---\n");
1447    }
1448
1449    #[test]
1450    fn query_targeted_value_mutations_are_atomic() {
1451        let input = "items: [{enabled: false}, {enabled: false}]\n";
1452        for operation in ["add", "replace"] {
1453            let (status, stdout, stderr) = invoke(
1454                &[
1455                    "yaml-rt",
1456                    operation,
1457                    "--query",
1458                    "$.items[*].enabled",
1459                    "--value",
1460                    "true",
1461                ],
1462                input,
1463            );
1464            assert_eq!(status, 0, "{stderr}");
1465            assert_eq!(stdout, "items: [{enabled: true}, {enabled: true}]\n");
1466        }
1467
1468        let (status, stdout, stderr) = invoke(
1469            &[
1470                "yaml-rt",
1471                "replace",
1472                "--query",
1473                "$.missing",
1474                "--value",
1475                "true",
1476            ],
1477            input,
1478        );
1479        assert_eq!(status, FAILURE);
1480        assert!(stdout.is_empty());
1481        assert!(stderr.contains("query matched no nodes"));
1482    }
1483
1484    #[test]
1485    fn query_targeted_remove_normalizes_and_orders_matches() {
1486        let (status, stdout, stderr) = invoke(
1487            &["yaml-rt", "remove", "--query", "$.items[0,2,0]"],
1488            "items: [a, b, c, d]\n",
1489        );
1490        assert_eq!(status, 0, "{stderr}");
1491        assert_eq!(stdout, "items: [b, d]\n");
1492
1493        let (status, stdout, stderr) = invoke(
1494            &["yaml-rt", "remove", "--query", "$..*"],
1495            "root: {child: x}\nuntouched: y\n",
1496        );
1497        assert_eq!(status, 0, "{stderr}");
1498        assert!(stdout.is_empty());
1499    }
1500
1501    #[test]
1502    fn query_targeted_test_requires_matches_and_tests_every_node() {
1503        let input = "values: [1, 1, 2]\n";
1504        let (status, stdout, stderr) = invoke(
1505            &[
1506                "yaml-rt",
1507                "test",
1508                "--query",
1509                "$.values[0,1]",
1510                "--value",
1511                "1",
1512            ],
1513            input,
1514        );
1515        assert_eq!(status, 0, "{stderr}");
1516        assert!(stdout.is_empty());
1517
1518        let (status, stdout, stderr) = invoke(
1519            &["yaml-rt", "test", "--query", "$.values[*]", "--value", "1"],
1520            input,
1521        );
1522        assert_eq!(status, FAILURE);
1523        assert!(stdout.is_empty());
1524        assert!(stderr.contains("/values/2"));
1525
1526        let (status, stdout, stderr) = invoke(
1527            &["yaml-rt", "test", "--query", "$.missing", "--value", "1"],
1528            input,
1529        );
1530        assert_eq!(status, FAILURE);
1531        assert!(stdout.is_empty());
1532        assert!(stderr.contains("query matched no nodes"));
1533    }
1534
1535    #[test]
1536    fn query_targeted_commands_reject_extra_positionals_as_usage_errors() {
1537        let (status, stdout, stderr) = invoke(
1538            &["yaml-rt", "get", "--query", "$.value", "first"],
1539            "value: 1\n",
1540        );
1541        assert_eq!(status, USAGE);
1542        assert!(stdout.is_empty());
1543        assert!(stderr.contains("at most one positional FILE"));
1544    }
1545
1546    #[test]
1547    fn query_targeted_commands_report_query_errors_before_output() {
1548        let (status, stdout, stderr) =
1549            invoke(&["yaml-rt", "get", "--query", "not-jsonpath"], "value: 1\n");
1550        assert_eq!(status, FAILURE);
1551        assert!(stdout.is_empty());
1552        assert!(stderr.contains("JSONPath"));
1553
1554        let (status, stdout, stderr) = invoke(
1555            &["yaml-rt", "remove", "--query", "$.*"],
1556            "? [complex, key]\n: value\n",
1557        );
1558        assert_eq!(status, FAILURE);
1559        assert!(stdout.is_empty());
1560        assert!(stderr.contains("non-string key"));
1561    }
1562
1563    #[test]
1564    fn test_failure_has_no_stdout() {
1565        let (status, stdout, stderr) =
1566            invoke(&["yaml-rt", "test", "/value", "--value", "2"], "value: 1\n");
1567        assert_eq!(status, FAILURE);
1568        assert!(stdout.is_empty());
1569        assert!(stderr.contains("test failed"));
1570    }
1571
1572    #[test]
1573    fn inline_patch_is_transactional() {
1574        let patch =
1575            "- {op: replace, path: /port, value: 9090}\n- {op: add, path: /debug, value: true}\n";
1576        let (status, stdout, stderr) = invoke(
1577            &["yaml-rt", "patch", "--patch", patch],
1578            "port: 8080 # keep\n",
1579        );
1580        assert_eq!(status, 0, "{stderr}");
1581        assert_eq!(stdout, "port: 9090 # keep\ndebug: true\n");
1582
1583        let failing =
1584            "- {op: replace, path: /port, value: 9090}\n- {op: test, path: /port, value: 8080}\n";
1585        let (status, stdout, stderr) =
1586            invoke(&["yaml-rt", "patch", "--patch", failing], "port: 8080\n");
1587        assert_eq!(status, FAILURE);
1588        assert!(stdout.is_empty());
1589        assert!(stderr.contains("patch operation[1]"));
1590    }
1591
1592    #[test]
1593    fn patch_source_is_required_and_exclusive() {
1594        let (status, _, stderr) = invoke(&["yaml-rt", "patch"], "{}\n");
1595        assert_eq!(status, USAGE);
1596        assert!(stderr.contains("required"));
1597
1598        let (status, _, stderr) = invoke(
1599            &[
1600                "yaml-rt",
1601                "patch",
1602                "--patch",
1603                "[]",
1604                "--patch-file",
1605                "changes.yaml",
1606            ],
1607            "{}\n",
1608        );
1609        assert_eq!(status, USAGE);
1610        assert!(stderr.contains("cannot be used with"));
1611    }
1612
1613    #[test]
1614    fn multiple_documents_require_selection() {
1615        let (status, _, stderr) = invoke(&["yaml-rt", "get", ""], "--- one\n--- two\n");
1616        assert_eq!(status, FAILURE);
1617        assert!(stderr.contains("--doc"));
1618    }
1619
1620    #[test]
1621    fn derive_arguments_enforce_value_and_output_conflicts() {
1622        let (status, stdout, stderr) = invoke(&["yaml-rt", "replace", "/value"], "value: 1\n");
1623        assert_eq!(status, USAGE);
1624        assert!(stdout.is_empty());
1625        assert!(stderr.contains("--value"));
1626
1627        let (status, stdout, stderr) = invoke(
1628            &[
1629                "yaml-rt",
1630                "replace",
1631                "/value",
1632                "--value",
1633                "1",
1634                "--value-file",
1635                "value.yaml",
1636            ],
1637            "value: 1\n",
1638        );
1639        assert_eq!(status, USAGE);
1640        assert!(stdout.is_empty());
1641        assert!(stderr.contains("cannot be used with"));
1642
1643        let (status, stdout, stderr) = invoke(
1644            &[
1645                "yaml-rt",
1646                "remove",
1647                "/value",
1648                "--output",
1649                "out.yaml",
1650                "--in-place",
1651            ],
1652            "value: 1\n",
1653        );
1654        assert_eq!(status, USAGE);
1655        assert!(stdout.is_empty());
1656        assert!(stderr.contains("cannot be used with"));
1657    }
1658
1659    #[test]
1660    fn hyphen_prefixed_inline_yaml_is_accepted() {
1661        let (status, stdout, stderr) = invoke(&["yaml-rt", "get", "-invalid"], "value: old\n");
1662        assert_eq!(status, FAILURE);
1663        assert!(stdout.is_empty());
1664        assert!(stderr.contains("JSON Pointer"));
1665
1666        let (status, stdout, stderr) = invoke(
1667            &["yaml-rt", "replace", "/value", "--value", "-1"],
1668            "value: old\n",
1669        );
1670        assert_eq!(status, 0, "{stderr}");
1671        assert_eq!(stdout, "value: -1\n");
1672    }
1673}