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