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