Skip to main content

mq_run/
cli.rs

1use clap::{CommandFactory, Parser, Subcommand};
2use colored::Colorize;
3use miette::IntoDiagnostic;
4use miette::WrapErr;
5use miette::miette;
6use mq_lang::DefaultEngine;
7use mq_lang::DictMap;
8use mq_lang::Shared;
9use rayon::prelude::*;
10use std::collections::BTreeSet;
11use std::ffi::OsString;
12use std::fmt::Write as _;
13use std::io::BufRead;
14use std::io::IsTerminal;
15use std::io::{self, BufWriter, Read, Write};
16use std::path::Path;
17use std::process::Command;
18use std::str::FromStr;
19use std::sync::atomic::{AtomicBool, Ordering};
20use std::sync::mpsc;
21use std::time::Duration;
22use std::{fs, path::PathBuf};
23use which::which;
24
25// Tracks whether any non-falsy value has been printed during a run.
26// Used by --exit-status / -e to decide the process exit code. A plain
27// AtomicBool (rather than a thread_local) is required because batch
28// processing can fan out across rayon worker threads.
29static HAD_TRUTHY_OUTPUT: AtomicBool = AtomicBool::new(false);
30
31// Tracks whether --diff found any changed input, for the exit-code-1 CI check below.
32static HAD_DIFF: AtomicBool = AtomicBool::new(false);
33
34use crate::atomic_output::{AtomicOutput, ClobberMode, OutputSink};
35use crate::grep;
36use mq_help as help;
37
38#[cfg(feature = "debug-trace")]
39mod bytecode;
40mod mqc;
41
42/// A file's query prefix and, for a `.mqc` program, its input format.
43type ProgramKey = (Option<String>, Option<String>);
44
45/// Programs prepared for one engine, reused across its inputs.
46#[derive(Default)]
47struct ProgramCache {
48    /// One program per [`ProgramKey`]; a run usually has one or two.
49    programs: Vec<(ProgramKey, mq_lang::CompiledProgram)>,
50    separator: Option<mq_lang::CompiledProgram>,
51    /// Whether a file's `__FILE__` globals are installed and need resetting for stdin.
52    file_vars_set: bool,
53}
54
55fn parse_timeout(value: &str) -> Result<Duration, String> {
56    let secs = value
57        .parse::<f64>()
58        .map_err(|_| "expected a number of seconds".to_string())?;
59    if !secs.is_finite() || secs <= 0.0 {
60        return Err("must be a finite number greater than 0".to_string());
61    }
62    let timeout = Duration::try_from_secs_f64(secs).map_err(|_| "timeout is too large".to_string())?;
63    if std::time::Instant::now().checked_add(timeout).is_none() {
64        return Err("timeout is too large".to_string());
65    }
66    Ok(timeout)
67}
68
69#[derive(Parser, Debug, Default)]
70#[command(name = "mq")]
71#[command(author = env!("CARGO_PKG_AUTHORS"))]
72#[command(version = env!("CARGO_PKG_VERSION"))]
73#[command(after_help = "# Examples\n\n\
74    mq 'query' file.md\n\n\
75    mq compile -f query.mq\n\
76    mq query.mqc file.md\n\n\
77    Compiled .mqc programs are experimental. Recompile them after upgrading mq.\n\n\
78    Run `mq help examples` for more usage examples, or `mq help <name>` for\n\
79    function/selector/module docs.\n")]
80#[command(
81    about = "mq is a markdown processor that can filter markdown nodes by using jq-like syntax.",
82    long_about = None,
83    disable_help_subcommand = true
84)]
85pub struct Cli {
86    /// Suppress normal query output while preserving diagnostics, exit status, and query side effects.
87    #[arg(short = 'q', long, global = true, default_value_t = false)]
88    quiet: bool,
89
90    #[clap(flatten)]
91    input: InputArgs,
92
93    #[clap(flatten)]
94    output: OutputArgs,
95
96    /// Set both input and output format at once (shorthand for `-I FORMAT -F FORMAT`).
97    /// An explicit `-I`/`-F` overrides this for that side. Only accepts formats valid
98    /// on both sides; e.g. `-I mdx` or `-F table` still require the dedicated flag.
99    #[arg(short = 'T', long = "format", value_enum)]
100    format: Option<IoFormat>,
101
102    #[clap(subcommand)]
103    commands: Option<Commands>,
104
105    /// List all available subcommands (built-in and external)
106    #[arg(long)]
107    list: bool,
108
109    /// Number of files to process before switching to parallel processing
110    #[arg(short = 'P', long, default_value_t = 10)]
111    parallel_threshold: usize,
112
113    #[arg(value_name = "QUERY OR FILE")]
114    query: Option<String>,
115    files: Option<Vec<PathBuf>>,
116
117    /// Positional string arguments, available as ARGS."positional" in queries.
118    #[arg(long = "argv", num_args = 0..)]
119    argv: Option<Vec<String>>,
120
121    /// Maximum time in seconds allowed for query evaluation before aborting (e.g. 0.5, 5).
122    /// Must be a finite positive number. No timeout by default.
123    #[arg(long, value_name = "SECONDS", value_parser = parse_timeout, allow_negative_numbers = true)]
124    timeout: Option<Duration>,
125
126    /// Include VM frames in uncaught runtime errors.
127    #[arg(long, default_value_t = false)]
128    stack_trace: bool,
129
130    /// Format for the final uncaught error. Combines with `--stack-trace`.
131    #[arg(long, value_enum, default_value_t = ErrorFormat::Human)]
132    error_format: ErrorFormat,
133
134    /// Enter the interactive debugger when an uncaught error occurs (mq-dbg only).
135    #[cfg(feature = "debugger")]
136    #[arg(long = "stop-on-error", default_value_t = false)]
137    stop_on_error: bool,
138
139    /// Print the Tarn VM operand stack whenever the debugger stops (mq-dbg `debug-trace` build only).
140    #[cfg(feature = "debug-trace")]
141    #[arg(long = "dump-stack", default_value_t = false)]
142    dump_stack: bool,
143
144    /// Print Tarn VM instruction execution counts to stderr (mq-dbg profile builds only).
145    #[cfg(feature = "vm-profile")]
146    #[arg(long = "vm-profile", default_value_t = false)]
147    vm_profile: bool,
148
149    /// Print the Tarn VM bytecode to stdout and exit without running the program or reading
150    /// input. Pass a format as `--dump-bytecode=json` (mq-dbg `debug-trace` build only).
151    #[cfg(feature = "debug-trace")]
152    #[arg(
153        long = "dump-bytecode",
154        value_name = "FORMAT",
155        value_enum,
156        num_args = 0..=1,
157        require_equals = true,
158        default_missing_value = "text"
159    )]
160    dump_bytecode: Option<bytecode::BytecodeFormat>,
161
162    /// The `.mqc` program, which replaces the query.
163    #[arg(skip)]
164    bytecode: std::sync::OnceLock<mq_lang::Mqc>,
165}
166
167#[cfg(unix)]
168const UNIX_EXECUTABLE_BITS: u32 = 0o111;
169
170/// Represents the input format for processing.
171///
172/// Native formats (no module import):
173/// - Markdown: Standard Markdown parsing.
174/// - Mdx: MDX parsing.
175/// - Html: HTML parsing.
176/// - Text: Treats input as plain text.
177/// - Null: No input.
178/// - Raw: Treats all input as a single string, without parsing.
179/// - Bytes: Reads input as raw bytes (`RuntimeValue::Bytes`), without any parsing.
180///
181/// Module-backed formats (auto-import and parse, sorted alphabetically):
182/// - Cbor: Reads input as raw bytes and parses via the `cbor` module.
183/// - Csv/Gron/Json/Psv/Toml/Toon/Tsv/Xml/Yaml: Auto-import the matching module and parse.
184#[derive(Clone, Debug, Default, clap::ValueEnum, PartialEq)]
185enum InputFormat {
186    #[default]
187    Markdown,
188    Mdx,
189    Html,
190    Text,
191    Null,
192    Raw,
193    Bytes,
194    Cbor,
195    Csv,
196    Gron,
197    Json,
198    Psv,
199    Toml,
200    Toon,
201    Tsv,
202    Xml,
203    Yaml,
204}
205
206impl InputFormat {
207    fn from_extension(ext: &str) -> Self {
208        match ext.to_lowercase().as_str() {
209            "md" | "markdown" => Self::Markdown,
210            "mdx" => Self::Mdx,
211            "html" | "htm" => Self::Html,
212            "txt" | "log" => Self::Raw,
213            "jsonl" | "ndjson" => Self::Text,
214            "cbor" => Self::Cbor,
215            "csv" => Self::Csv,
216            "gron" => Self::Gron,
217            "json" => Self::Json,
218            "psv" => Self::Psv,
219            "toml" => Self::Toml,
220            "toon" => Self::Toon,
221            "tsv" => Self::Tsv,
222            "xml" => Self::Xml,
223            "yaml" | "yml" => Self::Yaml,
224            _ => Self::Markdown,
225        }
226    }
227
228    fn is_gzip_path(path: &Path) -> bool {
229        path.extension()
230            .and_then(|e| e.to_str())
231            .is_some_and(|e| e.eq_ignore_ascii_case("gz"))
232    }
233
234    fn from_path(path: &Path) -> Self {
235        if Self::is_gzip_path(path) {
236            let inner_ext = Path::new(path.file_stem().unwrap_or_default())
237                .extension()
238                .and_then(|e| e.to_str())
239                .unwrap_or_default();
240            Self::from_extension(inner_ext)
241        } else {
242            let ext = path.extension().and_then(|e| e.to_str()).unwrap_or_default();
243            Self::from_extension(ext)
244        }
245    }
246
247    fn needs_binary_read(&self) -> bool {
248        matches!(self, Self::Bytes | Self::Cbor)
249    }
250
251    fn module_query_prefix(&self) -> Option<&'static str> {
252        match self {
253            // Module-backed formats (alphabetical order)
254            Self::Cbor => Some(r#"import "cbor" | cbor::cbor_parse()"#),
255            Self::Csv => Some(r#"import "csv" | csv::csv_parse(true)"#),
256            Self::Gron => Some(r#"import "gron" | gron::gron_parse()"#),
257            Self::Json => Some(r#"import "json" | json::json_parse()"#),
258            Self::Psv => Some(r#"import "csv" | csv::psv_parse(true)"#),
259            Self::Toml => Some(r#"import "toml" | toml::toml_parse()"#),
260            Self::Toon => Some(r#"import "toon" | toon::toon_parse()"#),
261            Self::Tsv => Some(r#"import "csv" | csv::tsv_parse(true)"#),
262            Self::Xml => Some(r#"import "xml" | xml::xml_parse()"#),
263            Self::Yaml => Some(r#"import "yaml" | yaml::yaml_parse()"#),
264            _ => None,
265        }
266    }
267}
268
269/// Holds file/stdin content as either UTF-8 text or raw bytes.
270enum ContentData {
271    Text(String),
272    Bytes(Vec<u8>),
273}
274
275impl ContentData {
276    fn empty() -> Self {
277        ContentData::Text(String::new())
278    }
279
280    fn as_str(&self) -> Option<&str> {
281        match self {
282            ContentData::Text(s) => Some(s),
283            ContentData::Bytes(_) => None,
284        }
285    }
286
287    fn as_bytes(&self) -> &[u8] {
288        match self {
289            ContentData::Text(s) => s.as_bytes(),
290            ContentData::Bytes(b) => b,
291        }
292    }
293}
294
295impl From<String> for ContentData {
296    fn from(s: String) -> Self {
297        ContentData::Text(s)
298    }
299}
300
301impl From<Vec<u8>> for ContentData {
302    fn from(b: Vec<u8>) -> Self {
303        ContentData::Bytes(b)
304    }
305}
306
307/// How the final uncaught error is rendered.
308#[derive(Clone, Copy, Debug, Default, PartialEq, clap::ValueEnum)]
309pub enum ErrorFormat {
310    /// Colored, human-readable diagnostic (default).
311    #[default]
312    Human,
313    /// Single-line JSON object via `miette::JSONReportHandler`, for CI/tooling.
314    Json,
315}
316
317/// Preset combination of `--allow-*` flags, set via `--sandbox`.
318#[derive(Clone, Debug, clap::ValueEnum, PartialEq)]
319enum SandboxProfile {
320    /// Deny everything (same as no flags).
321    Strict,
322    /// Unrestricted filesystem reads only.
323    ReadOnly,
324    /// Unrestricted network access (`http()` and HTTP imports) only.
325    Networked,
326    /// Everything, equivalent to `--allow-all`.
327    Unsafe,
328}
329
330#[derive(Clone, Debug, Default, clap::ValueEnum, PartialEq)]
331enum OutputFormat {
332    #[default]
333    Markdown,
334    Html,
335    Text,
336    Json,
337    Table,
338    Grep,
339    Gron,
340    Raw,
341    Csv,
342    Toml,
343    Toon,
344    Xml,
345    Yaml,
346    Shell,
347    None,
348}
349
350impl OutputFormat {
351    fn from_extension(ext: &str) -> Self {
352        match ext.to_lowercase().as_str() {
353            "html" | "htm" => Self::Html,
354            "txt" | "log" => Self::Raw,
355            "json" => Self::Json,
356            "csv" => Self::Csv,
357            "toml" => Self::Toml,
358            "toon" => Self::Toon,
359            "xml" => Self::Xml,
360            "yaml" | "yml" => Self::Yaml,
361            "gron" => Self::Gron,
362            _ => Self::Markdown,
363        }
364    }
365
366    fn from_path(path: &Path) -> Self {
367        let ext = path.extension().and_then(|e| e.to_str()).unwrap_or_default();
368        Self::from_extension(ext)
369    }
370}
371
372#[derive(Clone, Debug, clap::ValueEnum, PartialEq)]
373enum IoFormat {
374    Markdown,
375    Html,
376    Text,
377    Json,
378    Gron,
379    Raw,
380    Csv,
381    Toml,
382    Toon,
383    Xml,
384    Yaml,
385}
386
387impl From<IoFormat> for InputFormat {
388    fn from(fmt: IoFormat) -> Self {
389        match fmt {
390            IoFormat::Markdown => Self::Markdown,
391            IoFormat::Html => Self::Html,
392            IoFormat::Text => Self::Text,
393            IoFormat::Json => Self::Json,
394            IoFormat::Gron => Self::Gron,
395            IoFormat::Raw => Self::Raw,
396            IoFormat::Csv => Self::Csv,
397            IoFormat::Toml => Self::Toml,
398            IoFormat::Toon => Self::Toon,
399            IoFormat::Xml => Self::Xml,
400            IoFormat::Yaml => Self::Yaml,
401        }
402    }
403}
404
405impl From<IoFormat> for OutputFormat {
406    fn from(fmt: IoFormat) -> Self {
407        match fmt {
408            IoFormat::Markdown => Self::Markdown,
409            IoFormat::Html => Self::Html,
410            IoFormat::Text => Self::Text,
411            IoFormat::Json => Self::Json,
412            IoFormat::Gron => Self::Gron,
413            IoFormat::Raw => Self::Raw,
414            IoFormat::Csv => Self::Csv,
415            IoFormat::Toml => Self::Toml,
416            IoFormat::Toon => Self::Toon,
417            IoFormat::Xml => Self::Xml,
418            IoFormat::Yaml => Self::Yaml,
419        }
420    }
421}
422
423#[derive(Debug, Clone, Default, clap::ValueEnum)]
424pub enum ListStyle {
425    #[default]
426    Dash,
427    Plus,
428    Star,
429}
430
431#[derive(Debug, Clone, PartialEq, Default, clap::ValueEnum)]
432pub enum LinkTitleStyle {
433    #[default]
434    Double,
435    Single,
436    Paren,
437}
438
439#[derive(Debug, Clone, PartialEq, Default, clap::ValueEnum)]
440pub enum LinkUrlStyle {
441    #[default]
442    None,
443    Angle,
444}
445
446/// Flags that shape the compiled program or its permissions, shared by queries and `mq compile`.
447#[derive(Clone, Debug, clap::Args, Default, PartialEq)]
448struct ProgramArgs {
449    /// Aggregate all input files/content into a single array
450    #[arg(short = 'A', long, default_value_t = false)]
451    aggregate: bool,
452
453    /// Set input format
454    #[arg(short = 'I', long, value_enum)]
455    input_format: Option<InputFormat>,
456
457    /// Custom delimiter for `-I csv` input (a single ASCII character). Has no effect on
458    /// `-I tsv`/`-I psv`, which use a fixed tab/pipe delimiter by design; pass `-I csv`
459    /// with this flag instead if you need a different delimiter (e.g. `;`)
460    #[arg(long = "csv-delimiter", value_name = "CHAR")]
461    csv_delimiter: Option<char>,
462
463    /// Treat csv/tsv/psv input as headerless: each row becomes an array of values
464    /// instead of a dict keyed by header names. Applies to `-I csv`, `-I tsv`, and
465    /// `-I psv`
466    #[arg(long = "no-header", default_value_t = false)]
467    no_header: bool,
468
469    /// Search modules from the directory
470    #[arg(short = 'L', long = "directory")]
471    module_directories: Option<Vec<PathBuf>>,
472
473    /// Load additional modules from specified files
474    #[arg(short = 'M', long)]
475    module_names: Option<Vec<String>>,
476
477    /// Import modules by name, making them available as `name::fn()` in queries
478    #[arg(short = 'm', long)]
479    import_module_names: Option<Vec<String>>,
480
481    /// Allow `import`/`include` to fetch modules over HTTP(S). Disabled by default.
482    /// `--allow-http-import=DOMAIN` (repeat the flag, or comma-separate) also allows those
483    /// domains beyond the default. Use `github.com/{user}/{repo}` for one repository
484    /// (expanded automatically), or a plain domain like `example.com` for any path under
485    /// that host. The `=` is required so a bare domain isn't taken as a query/file positional.
486    #[cfg(feature = "http-import")]
487    #[arg(long = "allow-http-import", num_args = 0.., require_equals = true, value_delimiter = ',', value_name = "DOMAIN")]
488    allow_http_import: Option<Vec<String>>,
489
490    /// Same as `--allow-http-import=DOMAIN`, except it does not enable HTTP imports by itself.
491    #[cfg(feature = "http-import")]
492    #[arg(long = "allowed-domain", hide = true)]
493    allowed_domains: Option<Vec<String>>,
494
495    /// Force re-fetch of mutable-ref (HEAD/branch) HTTP-imported modules, ignoring the local cache.
496    /// Versioned (tagged) modules are never re-fetched regardless of this flag.
497    #[cfg(feature = "http-import")]
498    #[arg(long = "refresh-modules", default_value_t = false)]
499    refresh_modules: bool,
500
501    /// Remove all HTTP module cache including versioned (tagged) modules and lock files.
502    /// Use this to fully reset the cache when something goes wrong.
503    #[cfg(feature = "http-import")]
504    #[arg(long = "clear-cache", default_value_t = false)]
505    clear_cache: bool,
506
507    /// Disable the mq.lock integrity check for HTTP imports.
508    /// By default a fetched URL's content is checked against mq.lock, and a mismatch is
509    /// rejected unless --refresh-modules is also passed.
510    #[cfg(feature = "http-import")]
511    #[arg(long = "no-lockfile", default_value_t = false, conflicts_with_all = ["lockfile_path", "frozen"])]
512    no_lockfile: bool,
513
514    /// Fail instead of recording a new mq.lock entry.
515    /// Use in CI so a new module's content is only trusted during a reviewable local run whose mq.lock diff gets committed.
516    #[cfg(feature = "http-import")]
517    #[arg(long = "frozen", default_value_t = false)]
518    frozen: bool,
519
520    /// Path to the mq.lock file used for HTTP import integrity checks.
521    /// Defaults to ./mq.lock (relative to the current directory).
522    #[cfg(feature = "http-import")]
523    #[arg(long = "lockfile", value_name = "PATH")]
524    lockfile_path: Option<PathBuf>,
525
526    /// Allow the `http` function to make outbound HTTPS requests. Disabled by default;
527    /// requests are HTTPS-only and blocked from reaching loopback/private/link-local
528    /// addresses regardless of this flag. Pass with no value to allow any domain, or
529    /// `--allow-net=DOMAIN` (repeat the flag, or comma-separate, to add more) to restrict
530    /// requests to just those domains (and any path under them). The `=` is required so a
531    /// bare domain after the flag isn't swallowed as a query/file positional instead.
532    #[cfg(feature = "http-import")]
533    #[arg(short = 'N', long = "allow-net", num_args = 0.., require_equals = true, value_delimiter = ',', value_name = "DOMAIN")]
534    allow_net: Option<Vec<String>>,
535
536    /// Allow the `read_file`/`read_file_bytes`/`open_file`/`collection`/`walk_files`/`file_exists`/`file_info`/`embed_images`
537    /// functions to read from the filesystem. Disabled by default. Pass with no value to
538    /// allow reading anywhere, or `--allow-read=PATH` (files or directories; repeat the
539    /// flag, or comma-separate, to add more) to restrict reads to just those paths and
540    /// their descendants. The `=` is required so a bare path after the flag isn't
541    /// swallowed as a query/file positional instead.
542    #[arg(short = 'R', long = "allow-read", num_args = 0.., require_equals = true, value_delimiter = ',', value_name = "PATH")]
543    allow_read: Option<Vec<PathBuf>>,
544
545    /// Allow the `write_file`/`extract_images` functions to write to the filesystem.
546    /// Under `--sandbox`, this also gates `-o`/`--output` (and `--append`, `--no-clobber`,
547    /// `--atomic-output`), which are rejected unless the target is allowed. Without
548    /// `--sandbox`, `-o` is not restricted. Disabled by default. Pass with no value to allow writing anywhere, or
549    /// `--allow-write=PATH` (files or directories; repeat the flag, or comma-separate, to
550    /// add more) to restrict writes to just those paths and their descendants. The `=` is
551    /// required so a bare path after the flag isn't swallowed as a query/file positional
552    /// instead.
553    #[arg(short = 'W', long = "allow-write", num_args = 0.., require_equals = true, value_delimiter = ',', value_name = "PATH")]
554    allow_write: Option<Vec<PathBuf>>,
555
556    /// Allow the `system` function to execute external commands. Disabled by default.
557    /// Commands run directly (never through a shell), so shell metacharacters in arguments
558    /// are never interpreted. Pass with no value to allow any command, or
559    /// `--allow-run=COMMAND` (repeat the flag, or comma-separate, to add more) to restrict
560    /// execution to just those commands. The `=` is required so a bare command after the
561    /// flag isn't swallowed as a query/file positional instead.
562    #[arg(long = "allow-run", num_args = 0.., require_equals = true, value_delimiter = ',', value_name = "COMMAND")]
563    allow_run: Option<Vec<String>>,
564
565    /// Allow `$VAR`/`${$VAR}` interpolation and debugger logpoints to read environment
566    /// variables. Disabled by default. Pass with no value to allow reading any variable, or
567    /// `--allow-env=NAME` (repeat the flag, or comma-separate, to add more) to restrict
568    /// access to just those names. The `=` is required so a bare name after the flag isn't
569    /// swallowed as a query/file positional instead.
570    #[arg(short = 'E', long = "allow-env", num_args = 0.., require_equals = true, value_delimiter = ',', value_name = "NAME")]
571    allow_env: Option<Vec<String>>,
572
573    /// Grant every sandboxed permission at once (read/write/net/run/env), and also enable
574    /// HTTP module imports as if --allow-http-import were passed. Disabled by default.
575    /// Cannot be combined with the individual --allow-* flags above.
576    #[arg(
577        short = 'a',
578        long = "allow-all",
579        default_value_t = false,
580        conflicts_with_all = ["allow_net", "allow_read", "allow_write", "allow_run", "allow_env", "allow_http_import"]
581    )]
582    allow_all: bool,
583
584    /// Named preset of sandboxed capabilities. Cannot be combined with --allow-* flags,
585    /// except --allow-write, which adds write access (including `-o`/`--output`) on top of
586    /// the preset. Under a preset, `-o`/`--output` is denied unless write access is granted.
587    #[arg(
588        long = "sandbox",
589        value_enum,
590        conflicts_with_all = ["allow_net", "allow_read", "allow_run", "allow_env", "allow_http_import", "allow_all"]
591    )]
592    sandbox: Option<SandboxProfile>,
593}
594
595#[derive(Clone, Debug, clap::Args, Default)]
596struct InputArgs {
597    #[clap(flatten)]
598    program: ProgramArgs,
599
600    /// load filter from the file
601    #[arg(short, long, default_value_t = false)]
602    from_file: bool,
603
604    /// Sets a named string argument. NAME is accessible directly in queries, and also
605    /// via ARGS."named" when --args or --argv is given.
606    #[arg(long, num_args = 2, value_names = ["NAME", "VALUE"], aliases = ["arg", "define"])]
607    args: Option<Vec<String>>,
608
609    /// Sets a named JSON argument. NAME is accessible directly in queries
610    #[arg(long, num_args = 2, value_names = ["NAME", "JSON_VALUE"])]
611    argjson: Option<Vec<String>>,
612
613    /// Sets file contents that can be referenced at runtime
614    #[arg(long="rawfile", num_args = 2, value_names = ["NAME", "FILE"])]
615    raw_file: Option<Vec<String>>,
616
617    /// Sets a named argument from a JSON file. NAME is bound to an array of every JSON
618    /// value found in FILE (jq --slurpfile compatible), so a file containing a single
619    /// JSON value becomes a one-element array.
620    #[arg(long = "slurpfile", num_args = 2, value_names = ["NAME", "FILE"])]
621    slurp_file: Option<Vec<String>>,
622
623    /// Enable streaming mode for processing large files line by line
624    #[arg(long, default_value_t = false)]
625    stream: bool,
626
627    /// Watch the input file(s) for changes and automatically re-run the query whenever
628    /// they change. Requires at least one input file (stdin cannot be watched). With
629    /// --from-file, the query file is watched too. Runs until interrupted (Ctrl-C); a
630    /// query error is printed to stderr and watching continues rather than exiting.
631    #[cfg(feature = "watch")]
632    #[arg(long, default_value_t = false)]
633    watch: bool,
634
635    /// Evaluate the query once against all input files combined (like yq's `eval-all`),
636    /// instead of once per file. Enables cross-file aggregation in a single query.
637    #[arg(
638        long = "eval-all",
639        default_value_t = false,
640        conflicts_with_all = ["update", "count", "stream", "separator"]
641    )]
642    eval_all: bool,
643}
644
645impl ProgramArgs {
646    /// Builds sandboxed IO permissions from `--sandbox` or the individual `--allow-*` flags.
647    fn build_sandboxed_io(&self) -> mq_lang::SandboxedIo {
648        let sandboxed_io = mq_lang::SandboxedIo::new(mq_lang::NativeIo::default());
649        match &self.sandbox {
650            Some(SandboxProfile::Strict) => sandboxed_io.allow_write(self.allow_write.clone()),
651            Some(SandboxProfile::ReadOnly) => sandboxed_io.allow_read(true).allow_write(self.allow_write.clone()),
652            Some(SandboxProfile::Networked) => sandboxed_io.allow_net(true).allow_write(self.allow_write.clone()),
653            Some(SandboxProfile::Unsafe) => sandboxed_io.allow_all(),
654            None if self.allow_all => sandboxed_io.allow_all(),
655            None => sandboxed_io
656                .allow_read(self.allow_read.clone())
657                .allow_write(self.allow_write.clone())
658                .allow_net(self.allow_net.clone())
659                .allow_run(self.allow_run.clone())
660                .allow_env(self.allow_env.clone()),
661        }
662    }
663
664    /// Whether `--sandbox` enables HTTP module imports.
665    #[cfg(feature = "http-import")]
666    fn sandbox_enables_http_import(&self) -> bool {
667        matches!(
668            self.sandbox,
669            Some(SandboxProfile::Networked) | Some(SandboxProfile::Unsafe)
670        )
671    }
672
673    /// Whether HTTP module imports are enabled by any of `--allow-http-import`, `--allow-all` or `--sandbox`.
674    #[cfg(feature = "http-import")]
675    fn http_import_enabled(&self) -> bool {
676        self.allow_http_import.is_some() || self.allow_all || self.sandbox_enables_http_import()
677    }
678
679    /// Extra domains allowed for HTTP imports, from `--allow-http-import=DOMAIN` and `--allowed-domain`.
680    #[cfg(feature = "http-import")]
681    fn http_import_domains(&self) -> Vec<String> {
682        self.allow_http_import
683            .iter()
684            .chain(&self.allowed_domains)
685            .flatten()
686            .cloned()
687            .collect()
688    }
689}
690
691#[derive(Clone, Debug, clap::Args, Default)]
692struct OutputArgs {
693    /// Set output format. When omitted, inferred from the `-o`/`--output` file
694    /// extension if given (e.g. `.json` -> json, `.csv` -> csv), else defaults to
695    /// markdown.
696    #[arg(short = 'F', long, value_enum, conflicts_with = "quiet")]
697    output_format: Option<OutputFormat>,
698
699    /// Update matching Markdown nodes and write the result to stdout
700    #[arg(short = 'U', long = "update", default_value_t = false)]
701    update: bool,
702
703    /// With --update, print a unified diff instead of the transformed content;
704    /// nothing is written. Multiple files are diffed one at a time with their path
705    /// in the headers; stdin is labeled `<stdin>`. Exits 1 if anything would change.
706    #[arg(long = "diff", default_value_t = false, requires = "update")]
707    diff: bool,
708
709    /// Unbuffered output
710    #[clap(long, default_value_t = false, conflicts_with = "quiet")]
711    unbuffered: bool,
712
713    /// Set the list style for markdown output
714    #[clap(long, value_enum, default_value_t = ListStyle::Dash, conflicts_with = "quiet")]
715    list_style: ListStyle,
716
717    /// Set the link title surround style for markdown output
718    #[clap(long, value_enum, default_value_t = LinkTitleStyle::Double, conflicts_with = "quiet")]
719    link_title_style: LinkTitleStyle,
720
721    /// Set the link URL surround style for markdown links
722    #[clap(long, value_enum, default_value_t = LinkUrlStyle::None, conflicts_with = "quiet")]
723    link_url_style: LinkUrlStyle,
724
725    /// Specify a query to insert between files as a separator
726    #[clap(short = 'S', long, value_name = "QUERY", conflicts_with = "quiet")]
727    separator: Option<String>,
728
729    /// Output to the specified file
730    #[clap(short = 'o', long = "output", value_name = "FILE", conflicts_with = "quiet")]
731    output_file: Option<PathBuf>,
732
733    /// Write `-o`/`--output` atomically via a same-directory temp file + fsync
734    /// + rename, so a crash or full disk mid-write can't truncate the target.
735    #[clap(long, value_enum, default_value_t = AtomicOutput::Auto, requires = "output_file", conflicts_with = "quiet")]
736    atomic_output: AtomicOutput,
737
738    /// Fail instead of overwriting `-o`/`--output` if the target already exists.
739    #[clap(long, default_value_t = false, requires = "output_file", conflicts_with_all = ["append", "quiet"])]
740    no_clobber: bool,
741
742    /// Add to `-o`/`--output` instead of replacing it, creating it if missing.
743    /// Use `--atomic-output never` for concurrent-safe appends across processes.
744    #[clap(
745        long,
746        default_value_t = false,
747        requires = "output_file",
748        conflicts_with_all = ["no_clobber", "quiet"]
749    )]
750    append: bool,
751
752    /// Whether this process has already opened `output_file` once.
753    #[clap(skip)]
754    output_claimed: std::sync::Arc<AtomicBool>,
755
756    /// Colorize markdown output
757    #[arg(
758        short = 'C',
759        long = "color-output",
760        default_value_t = false,
761        conflicts_with = "quiet"
762    )]
763    color_output: bool,
764
765    /// Show NUM nodes before each match. Only effective with -F grep.
766    #[clap(short = 'B', long, value_name = "NUM", conflicts_with = "quiet")]
767    before_context: Option<usize>,
768
769    /// Show NUM nodes after each match. Only effective with -F grep.
770    #[clap(long, value_name = "NUM", conflicts_with = "quiet")]
771    after_context: Option<usize>,
772
773    /// Show NUM nodes before and after each match. Only effective with -F grep.
774    #[clap(long, value_name = "NUM", conflicts_with = "quiet")]
775    context: Option<usize>,
776
777    /// Exit with code 1 if the last output value is false, null, or the output
778    /// is empty. Mirrors jq's --exit-status / -e flag.
779    #[arg(short = 'e', long = "exit-status", default_value_t = false)]
780    exit_status: bool,
781
782    /// Output only the count of matching (non-None) results. Mirrors grep -c.
783    /// With multiple files, prints "filename: N" per file and "total: N" at the end.
784    #[arg(short = 'c', long = "count", default_value_t = false, conflicts_with_all = ["update", "stream", "quiet"])]
785    count: bool,
786
787    /// Skip the first N matching results before outputting.
788    #[arg(long, value_name = "N", conflicts_with_all = ["update", "quiet"])]
789    skip: Option<usize>,
790
791    /// Limit output to at most N results.
792    #[arg(long, value_name = "N", conflicts_with_all = ["update", "quiet"])]
793    limit: Option<usize>,
794
795    /// Omit Markdown node position information from structured output
796    /// (json, table, gron, csv, toml, toon, xml, yaml). Reduces output size
797    /// when source line/column spans aren't needed.
798    #[arg(long, default_value_t = false, conflicts_with = "quiet")]
799    no_position: bool,
800
801    /// Print JSON on a single line, without pretty-printing. Only valid with -F json.
802    #[arg(long, default_value_t = false, conflicts_with = "quiet")]
803    compact: bool,
804
805    /// Number of spaces per indent level in pretty-printed output (0-7), jq's
806    /// `--indent`. Only valid with -F json or -F xml; default is 2.
807    #[arg(
808        long,
809        value_name = "N",
810        value_parser = clap::value_parser!(u8).range(0..=7),
811        conflicts_with_all = ["tab", "compact", "quiet"]
812    )]
813    indent: Option<u8>,
814
815    /// Indent pretty-printed output with tabs instead of spaces, jq's `--tab`.
816    /// Only valid with -F json or -F xml.
817    #[arg(long, default_value_t = false, conflicts_with_all = ["indent", "compact", "quiet"])]
818    tab: bool,
819}
820
821impl OutputArgs {
822    /// Returns `(before, after)` node counts for grep context expansion.
823    /// `--context N` sets both sides; `--before-context` / `--after-context` override each side.
824    fn context_counts(&self) -> (usize, usize) {
825        let base = self.context.unwrap_or(0);
826        let before = self.before_context.unwrap_or(base);
827        let after = self.after_context.unwrap_or(base);
828        (before, after)
829    }
830
831    /// Applies skip/limit pagination to a vector of values.
832    ///
833    /// Call this on compact (non-empty) values so that N refers to visible results.
834    fn paginate<T>(&self, values: Vec<T>) -> Vec<T> {
835        let skip = self.skip.unwrap_or(0);
836        let values: Vec<T> = values.into_iter().skip(skip).collect();
837        if let Some(limit) = self.limit {
838            values.into_iter().take(limit).collect()
839        } else {
840            values
841        }
842    }
843
844    /// The per-level indent string for pretty-printed JSON output, honoring `--tab`/`--indent`.
845    fn json_indent_unit(&self) -> String {
846        if self.tab {
847            "\t".to_string()
848        } else {
849            " ".repeat(self.indent.unwrap_or(2) as usize)
850        }
851    }
852
853    /// The `(indent_char, indent_size)` pair for pretty-printed XML output, honoring `--tab`/`--indent`.
854    fn xml_indent(&self) -> (u8, usize) {
855        if self.tab {
856            (b'\t', 1)
857        } else {
858            (b' ', self.indent.unwrap_or(2) as usize)
859        }
860    }
861}
862
863#[derive(Debug, Subcommand)]
864enum Commands {
865    /// Start a REPL session for interactive query execution. Optional FILES are
866    /// combined as the initial input (same file/format handling as `mq QUERY FILES...`).
867    Repl {
868        /// Markdown (or other supported format) files to load as the REPL's initial input
869        files: Option<Vec<PathBuf>>,
870    },
871    /// Start a debug adapter for mq
872    #[cfg(feature = "debugger")]
873    Dap,
874    /// Generate a shell completion script and print it to stdout
875    Completion {
876        /// Shell to generate the completion script for
877        #[arg(value_enum)]
878        shell: CompletionShell,
879    },
880    /// Show documentation for a builtin function, selector, standard module, standard-module
881    /// function, or the `examples` topic.
882    Help {
883        /// Name of a function, selector, module, or `examples`, e.g. `map`, `.h1`, `csv_parse`,
884        /// `section`, `examples`
885        name: Option<String>,
886        /// Print machine-readable JSON instead of formatted text
887        #[arg(long, conflicts_with = "markdown")]
888        json: bool,
889        /// Print Markdown instead of formatted text — e.g. queryable with mq itself:
890        /// `mq help section --markdown | mq 'select(.code.lang == "mq")'`
891        #[arg(long)]
892        markdown: bool,
893    },
894    /// Compile a query to experimental `.mqc` bytecode.
895    #[command(
896        long_about = "Compile a query to a .mqc program. Run it with `mq PROGRAM.mqc [FILES]...`.\n\nThe .mqc format and CLI are experimental and may change. Recompile programs after upgrading mq."
897    )]
898    Compile {
899        /// Query to compile, or a query file with -f
900        #[arg(value_name = "QUERY OR FILE")]
901        query: String,
902        /// Load the query from the file
903        #[arg(short, long)]
904        from_file: bool,
905        /// Output file [default: the query file with a `.mqc` extension; required for a query string]
906        #[arg(short, long)]
907        output: Option<PathBuf>,
908        #[clap(flatten)]
909        program: Box<ProgramArgs>,
910    },
911}
912
913/// Shell targets supported by the `completion` subcommand.
914#[derive(Clone, Debug, clap::ValueEnum)]
915enum CompletionShell {
916    Bash,
917    Elvish,
918    Fish,
919    Nushell,
920    #[value(name = "powershell")]
921    PowerShell,
922    Zsh,
923}
924
925/// Output mode for the `help` subcommand.
926#[derive(Clone, Copy)]
927enum HelpFormat {
928    Human,
929    Json,
930    Markdown,
931}
932
933impl Cli {
934    /// Reserved `mq help` topic name for general CLI usage examples (not a function,
935    /// selector, or module).
936    const EXAMPLES_TOPIC: &'static str = "examples";
937
938    /// Get the path to the external commands directory (~/.local/bin)
939    fn get_external_commands_dir() -> Option<PathBuf> {
940        let home_dir = dirs::home_dir()?;
941        let mq_bin_dir = home_dir.join(".local").join("bin");
942        if mq_bin_dir.exists() && mq_bin_dir.is_dir() {
943            Some(mq_bin_dir)
944        } else {
945            None
946        }
947    }
948
949    /// Find all external commands (mq-* files in ~/.local/bin and PATH)
950    fn find_external_commands() -> Vec<String> {
951        let mut seen = std::collections::HashSet::new();
952
953        // Search ~/.local/bin first
954        if let Some(bin_dir) = Self::get_external_commands_dir() {
955            Self::collect_mq_commands_from_dir(&bin_dir, &mut seen);
956        }
957
958        // Search PATH directories
959        if let Ok(path_var) = std::env::var("PATH") {
960            for dir in std::env::split_paths(&path_var) {
961                Self::collect_mq_commands_from_dir(&dir, &mut seen);
962            }
963        }
964
965        let mut commands: Vec<String> = seen.into_iter().collect();
966        commands.sort();
967        commands
968    }
969
970    /// Collect mq-* command names from a directory.
971    fn collect_mq_commands_from_dir(dir: &Path, seen: &mut std::collections::HashSet<String>) {
972        if let Ok(entries) = fs::read_dir(dir) {
973            for entry in entries.flatten() {
974                if let Ok(file_name) = entry.file_name().into_string()
975                    && file_name.starts_with("mq-")
976                    && Self::is_executable_file(&entry)
977                    && let Some(subcommand) = file_name.strip_prefix("mq-")
978                {
979                    let subcommand = Self::strip_executable_extension(subcommand);
980                    if !subcommand.is_empty() {
981                        seen.insert(subcommand);
982                    }
983                }
984            }
985        }
986    }
987
988    /// Check if a directory entry is an executable file.
989    /// On Windows, checks for executable extensions (.exe, .cmd, .bat, .com).
990    /// On Unix, checks for the executable bit in file permissions.
991    fn is_executable_file(entry: &fs::DirEntry) -> bool {
992        #[cfg(unix)]
993        {
994            use std::os::unix::fs::PermissionsExt;
995            entry
996                .metadata()
997                .map(|m| m.is_file() && m.permissions().mode() & UNIX_EXECUTABLE_BITS != 0)
998                .unwrap_or(false)
999        }
1000        #[cfg(windows)]
1001        {
1002            let path = entry.path();
1003            let is_file = entry.metadata().map(|m| m.is_file()).unwrap_or(false);
1004            is_file
1005                && path.extension().and_then(|e| e.to_str()).is_some_and(|ext| {
1006                    ext.eq_ignore_ascii_case("exe")
1007                        || ext.eq_ignore_ascii_case("cmd")
1008                        || ext.eq_ignore_ascii_case("bat")
1009                        || ext.eq_ignore_ascii_case("com")
1010                })
1011        }
1012        #[cfg(not(any(unix, windows)))]
1013        {
1014            entry.metadata().map(|m| m.is_file()).unwrap_or(false)
1015        }
1016    }
1017
1018    /// Strip known executable extensions on Windows. On Unix, returns the name as-is.
1019    fn strip_executable_extension(name: &str) -> String {
1020        if cfg!(windows) {
1021            let path = Path::new(name);
1022            match path.extension().and_then(|e| e.to_str()) {
1023                Some("exe" | "cmd" | "bat" | "com") => {
1024                    path.file_stem().unwrap_or_default().to_string_lossy().to_string()
1025                }
1026                _ => name.to_string(),
1027            }
1028        } else {
1029            name.to_string()
1030        }
1031    }
1032
1033    /// Execute an external subcommand
1034    fn execute_external_command(&self, command_path: PathBuf, args: &[String]) -> miette::Result<()> {
1035        if args.is_empty() {
1036            return Err(miette!("No subcommand specified"));
1037        }
1038
1039        let subcommand = &args[0];
1040
1041        // Check if the file is executable
1042        #[cfg(unix)]
1043        {
1044            use std::os::unix::fs::PermissionsExt;
1045            let metadata = fs::metadata(&command_path).into_diagnostic()?;
1046            let permissions = metadata.permissions();
1047            if permissions.mode() & 0o111 == 0 {
1048                return Err(miette!(
1049                    "External subcommand 'mq-{}' is not executable. Run: chmod +x {}",
1050                    subcommand,
1051                    command_path.display()
1052                ));
1053            }
1054        }
1055
1056        // Execute the external command with remaining arguments
1057        let status = Command::new(&command_path).args(&args[1..]).status().map_err(|e| {
1058            miette!(
1059                "Failed to execute external subcommand 'mq-{}' at {}: {}",
1060                subcommand,
1061                command_path.display(),
1062                e
1063            )
1064        })?;
1065
1066        if !status.success() {
1067            let code = status.code().unwrap_or(1);
1068            std::process::exit(code);
1069        }
1070
1071        Ok(())
1072    }
1073
1074    /// List all available subcommands (built-in and external)
1075    fn list_commands(&self) -> miette::Result<()> {
1076        let mut output = vec![
1077            format!("{}", "Built-in subcommands:".bold().cyan()),
1078            format!(
1079                "  {} - Start a REPL session for interactive query execution",
1080                "repl".green()
1081            ),
1082            format!(
1083                "  {} - Generate a shell completion script and print it to stdout",
1084                "completion".green()
1085            ),
1086            format!(
1087                "  {} - Compile a query to .mqc bytecode (experimental)",
1088                "compile".green()
1089            ),
1090        ];
1091
1092        #[cfg(feature = "debugger")]
1093        output.push(format!("  {} - Start a debug adapter for mq", "dap".green()));
1094
1095        let external_commands = Self::find_external_commands();
1096        if !external_commands.is_empty() {
1097            output.push("".to_string());
1098            output.push(format!(
1099                "{}",
1100                "External subcommands (from ~/.local/bin and PATH):".bold().yellow()
1101            ));
1102            for cmd in external_commands {
1103                output.push(format!("  {}", cmd.bright_yellow()));
1104            }
1105        }
1106
1107        println!("{}", output.join("\n"));
1108        Ok(())
1109    }
1110
1111    /// Generate a shell completion script for the given shell and print it to stdout.
1112    fn new_repl(engine: mq_lang::DefaultEngine, input: Vec<mq_lang::RuntimeValue>) -> mq_repl::Repl {
1113        let repl = mq_repl::Repl::with_engine(engine, input);
1114        if cfg!(feature = "debugger") {
1115            repl.with_name("mq-dbg")
1116        } else {
1117            repl
1118        }
1119    }
1120
1121    fn generate_completion(shell: &CompletionShell) -> miette::Result<()> {
1122        let mut command = Cli::command();
1123        let name = command.get_name().to_string();
1124
1125        match shell {
1126            CompletionShell::Bash => {
1127                clap_complete::generate(clap_complete::Shell::Bash, &mut command, name, &mut io::stdout())
1128            }
1129            CompletionShell::Elvish => {
1130                clap_complete::generate(clap_complete::Shell::Elvish, &mut command, name, &mut io::stdout())
1131            }
1132            CompletionShell::Fish => {
1133                clap_complete::generate(clap_complete::Shell::Fish, &mut command, name, &mut io::stdout())
1134            }
1135            CompletionShell::Nushell => {
1136                clap_complete::generate(clap_complete_nushell::Nushell, &mut command, name, &mut io::stdout())
1137            }
1138            CompletionShell::PowerShell => {
1139                clap_complete::generate(clap_complete::Shell::PowerShell, &mut command, name, &mut io::stdout())
1140            }
1141            CompletionShell::Zsh => {
1142                clap_complete::generate(clap_complete::Shell::Zsh, &mut command, name, &mut io::stdout())
1143            }
1144        }
1145
1146        Ok(())
1147    }
1148
1149    /// Shows documentation for a single function/selector/module, or lists everything known
1150    /// when `name` is `None`. Writes the whole output in one call, not one `println!` per
1151    /// line — hundreds of lines otherwise means hundreds of flushed syscalls.
1152    fn run_help(name: Option<&str>, json: bool, markdown: bool) -> miette::Result<()> {
1153        let format = if json {
1154            HelpFormat::Json
1155        } else if markdown {
1156            HelpFormat::Markdown
1157        } else {
1158            HelpFormat::Human
1159        };
1160
1161        let stdout = io::stdout();
1162        let mut handle = BufWriter::new(stdout.lock());
1163
1164        let Some(name) = name else {
1165            let out = match format {
1166                HelpFormat::Json => serde_json::to_string_pretty(&help::all_names()).into_diagnostic()?,
1167                HelpFormat::Markdown => Self::help_index_markdown(),
1168                HelpFormat::Human => Self::help_index_text(),
1169            };
1170            Self::write_ignore_pipe(&mut handle, out.as_bytes())?;
1171            Self::write_ignore_pipe(&mut handle, b"\n")?;
1172            return handle.flush().into_diagnostic();
1173        };
1174
1175        // `examples` is a reserved topic name (not a function/selector/module): general CLI
1176        // usage that clap's own `--help`/`after_help` intentionally keeps short.
1177        if name == Self::EXAMPLES_TOPIC {
1178            let out = match format {
1179                HelpFormat::Json => serde_json::to_string_pretty(&serde_json::json!({
1180                    "topic": Self::EXAMPLES_TOPIC,
1181                    "content": Self::examples_topic_markdown(),
1182                }))
1183                .into_diagnostic()?,
1184                HelpFormat::Markdown => Self::examples_topic_markdown(),
1185                HelpFormat::Human => Self::examples_topic_text(),
1186            };
1187            Self::write_ignore_pipe(&mut handle, out.as_bytes())?;
1188            Self::write_ignore_pipe(&mut handle, b"\n")?;
1189            return handle.flush().into_diagnostic();
1190        }
1191
1192        // A bare name that's both a module and a same-named function within it (e.g.
1193        // `section`) resolves to the module overview; `module::function` disambiguates.
1194        if !name.contains("::")
1195            && let Some(module) = help::lookup_module(name)
1196        {
1197            // A selector can also share a module's bare name (e.g. `.table`/`table`,
1198            // `.toml`/`toml`, `.yaml`/`yaml`); append it after the module overview instead
1199            // of leaving it unreachable without the leading dot. JSON keeps the module
1200            // alone for output stability — pass the leading dot (`.table`) for the
1201            // selector's own JSON.
1202            let selector_entries: Vec<_> = help::lookup(name)
1203                .into_iter()
1204                .filter(|e| e.kind == "selector")
1205                .collect();
1206
1207            let out = match format {
1208                HelpFormat::Json => serde_json::to_string_pretty(&module).into_diagnostic()?,
1209                HelpFormat::Markdown => {
1210                    let mut s = help::render_module_markdown(&module);
1211                    for entry in &selector_entries {
1212                        s.push('\n');
1213                        s.push_str(&help::render_markdown(entry));
1214                    }
1215                    s
1216                }
1217                HelpFormat::Human => {
1218                    let mut s = help::render_module_human(&module);
1219                    for entry in &selector_entries {
1220                        s.push('\n');
1221                        s.push_str(&help::render_human(entry));
1222                    }
1223                    s
1224                }
1225            };
1226            Self::write_ignore_pipe(&mut handle, out.as_bytes())?;
1227            Self::write_ignore_pipe(&mut handle, b"\n")?;
1228            return handle.flush().into_diagnostic();
1229        }
1230
1231        let entries = help::lookup(name);
1232        if entries.is_empty() {
1233            return Err(match help::suggest(name) {
1234                Some(suggestion) => {
1235                    miette!("no function, selector, or module named `{name}` — did you mean `{suggestion}`?")
1236                }
1237                None => miette!("no function, selector, or module named `{name}`"),
1238            });
1239        }
1240
1241        let out = match format {
1242            HelpFormat::Json => serde_json::to_string_pretty(&entries).into_diagnostic()?,
1243            HelpFormat::Markdown => entries.iter().map(help::render_markdown).collect::<Vec<_>>().join("\n"),
1244            HelpFormat::Human => entries.iter().map(help::render_human).collect::<Vec<_>>().join("\n"),
1245        };
1246        Self::write_ignore_pipe(&mut handle, out.as_bytes())?;
1247        Self::write_ignore_pipe(&mut handle, b"\n")?;
1248        handle.flush().into_diagnostic()
1249    }
1250
1251    /// Sorted, deduped selector, top-level function, and keyword names for the `mq help`
1252    /// index. Cheap: uses `top_level_entries`, which never parses standard-module sources.
1253    fn help_index_names() -> (Vec<String>, Vec<String>, Vec<String>) {
1254        let entries = help::top_level_entries();
1255
1256        let mut selectors: Vec<String> = entries
1257            .iter()
1258            .filter(|e| e.kind == "selector")
1259            .map(|e| e.name.clone())
1260            .collect();
1261        selectors.sort_unstable();
1262        selectors.dedup();
1263
1264        let mut functions: Vec<String> = entries
1265            .iter()
1266            .filter(|e| e.kind == "function")
1267            .map(|e| e.name.clone())
1268            .collect();
1269        functions.sort_unstable();
1270        functions.dedup();
1271
1272        let mut keywords: Vec<String> = entries
1273            .iter()
1274            .filter(|e| e.kind == "keyword")
1275            .map(|e| e.name.clone())
1276            .collect();
1277        keywords.sort_unstable();
1278        keywords.dedup();
1279
1280        (selectors, functions, keywords)
1281    }
1282
1283    /// Builds the grouped `mq help` index text: selectors, top-level functions, and modules
1284    /// (each with a one-line summary), for a name-less human-readable lookup.
1285    fn help_index_text() -> String {
1286        let (selectors, functions, keywords) = Self::help_index_names();
1287        let mut out = String::new();
1288
1289        let _ = writeln!(out, "{}", "Selectors:".bold().cyan());
1290        for s in selectors {
1291            let _ = writeln!(out, "  {s}");
1292        }
1293
1294        let _ = writeln!(out, "\n{}", "Functions:".bold().cyan());
1295        for f in functions {
1296            let _ = writeln!(out, "  {f}");
1297        }
1298
1299        let _ = writeln!(out, "\n{}", "Keywords:".bold().cyan());
1300        for k in keywords {
1301            let _ = writeln!(out, "  {k}");
1302        }
1303
1304        let _ = writeln!(out, "\n{}", "Modules:".bold().cyan());
1305        for module in help::all_modules() {
1306            let summary = module.description.split(". ").next().unwrap_or_default();
1307            let padded_name = format!("{:<10}", module.name);
1308            let _ = writeln!(out, "  {} {}", padded_name.green(), summary);
1309        }
1310
1311        let _ = write!(
1312            out,
1313            "\nRun `mq help <name>` for a function or selector, `mq help <module>` for a module \
1314            overview, `mq help examples` for CLI usage examples."
1315        );
1316
1317        out
1318    }
1319
1320    /// Same content as [`Self::help_index_text`], as Markdown — queryable with mq itself.
1321    fn help_index_markdown() -> String {
1322        let (selectors, functions, keywords) = Self::help_index_names();
1323        let mut out = String::new();
1324
1325        let _ = writeln!(out, "# mq help");
1326
1327        let _ = writeln!(out, "\n## Selectors\n");
1328        for s in selectors {
1329            let _ = writeln!(out, "- `{s}`");
1330        }
1331
1332        let _ = writeln!(out, "\n## Functions\n");
1333        for f in functions {
1334            let _ = writeln!(out, "- `{f}`");
1335        }
1336
1337        let _ = writeln!(out, "\n## Keywords\n");
1338        for k in keywords {
1339            let _ = writeln!(out, "- `{k}`");
1340        }
1341
1342        let _ = writeln!(out, "\n## Modules\n");
1343        for module in help::all_modules() {
1344            let summary = module.description.split(". ").next().unwrap_or_default();
1345            if summary.is_empty() {
1346                let _ = writeln!(out, "- `{}`", module.name);
1347            } else {
1348                let _ = writeln!(out, "- `{}` — {}", module.name, summary);
1349            }
1350        }
1351
1352        let _ = write!(
1353            out,
1354            "\nRun `mq help <name>` for a function or selector, `mq help <module>` for a module \
1355            overview, `mq help examples` for CLI usage examples."
1356        );
1357
1358        out
1359    }
1360
1361    /// Text for the `mq help examples` topic — kept out of clap's own `after_help` so
1362    /// `--help` stays short; this is the fuller reference it points to.
1363    fn examples_topic_text() -> String {
1364        let mut out = String::new();
1365
1366        let _ = writeln!(out, "{}", "Basic usage:".bold().cyan());
1367        let _ = writeln!(out, "  mq 'query' file.md");
1368        let _ = writeln!(out, "  mq -f 'file' file.md        # read query from file");
1369        let _ = writeln!(out, "  mq repl                     # start a REPL session");
1370
1371        let _ = writeln!(out, "\n{}", "Auto-parsing by file extension or -I flag:".bold().cyan());
1372        let _ = writeln!(
1373            out,
1374            "  mq automatically imports the matching module based on the file extension."
1375        );
1376        let _ = writeln!(out, "  Use -I <format> to force a specific format:\n");
1377        let _ = writeln!(
1378            out,
1379            "  .cbor / -I cbor  import \"cbor\" | cbor::cbor_parse()  (reads as bytes)"
1380        );
1381        let _ = writeln!(out, "  .csv  / -I csv   import \"csv\"  | csv::csv_parse(true)");
1382        let _ = writeln!(out, "  .gron / -I gron  import \"gron\" | gron::gron_parse()");
1383        let _ = writeln!(out, "  .json / -I json  import \"json\" | json::json_parse()");
1384        let _ = writeln!(out, "  .psv  / -I psv   import \"csv\"  | csv::psv_parse(true)");
1385        let _ = writeln!(out, "  .toml / -I toml  import \"toml\" | toml::toml_parse()");
1386        let _ = writeln!(out, "  .toon / -I toon  import \"toon\" | toon::toon_parse()");
1387        let _ = writeln!(out, "  .tsv  / -I tsv   import \"csv\"  | csv::tsv_parse(true)");
1388        let _ = writeln!(out, "  .xml  / -I xml   import \"xml\"  | xml::xml_parse()");
1389        let _ = writeln!(out, "  .yaml / -I yaml  import \"yaml\" | yaml::yaml_parse()\n");
1390        let _ = writeln!(
1391            out,
1392            "  Use -I raw   to disable auto-parsing and receive the raw string."
1393        );
1394        let _ = writeln!(out, "  Use -I bytes to read input as raw bytes without parsing.");
1395
1396        let _ = writeln!(out, "\n{}", "Output formats (-F):".bold().cyan());
1397        let _ = writeln!(out, "  mq -F json '.h' file.md          # headings as JSON nodes");
1398        let _ = writeln!(
1399            out,
1400            "  mq -F csv 'to_text()' file.md    # every node's text, one per CSV row"
1401        );
1402        let _ = writeln!(
1403            out,
1404            "  mq -o out.json 'self' file.md    # -F inferred from -o's extension"
1405        );
1406        let _ = writeln!(
1407            out,
1408            "  mq -T json 'self' file.txt       # sets both -I and -F at once (-I/-F still override)"
1409        );
1410
1411        let _ = writeln!(out, "\n{}", "Updating markdown (-U):".bold().cyan());
1412        let _ = writeln!(
1413            out,
1414            "  mq -U '.h1 | update(\"New title\")' file.md         # whole document to stdout; the file is not touched"
1415        );
1416        let _ = writeln!(
1417            out,
1418            "  mq -U --diff '.h1 | update(\"New title\")' file.md  # preview the change as a unified diff"
1419        );
1420        let _ = writeln!(out, "  (-U never edits in place; unmatched content is reformatted too)");
1421
1422        let _ = writeln!(out, "\n{}", "Filtering with context (-F grep):".bold().cyan());
1423        let _ = writeln!(
1424            out,
1425            "  mq -F grep --context 1 '.h' file.md  # matches with 1 node of context on each side"
1426        );
1427        let _ = writeln!(out, "  (also: -B/--before-context, --after-context)");
1428
1429        let _ = writeln!(out, "\n{}", "Passing arguments to queries (ARGS):".bold().cyan());
1430        let _ = writeln!(
1431            out,
1432            "  When --args or --argv is given, ARGS = {{\"positional\": [...], \"named\": {{...}}}}\n"
1433        );
1434        let _ = writeln!(out, "  mq -I null 'name' --args name Alice");
1435        let _ = writeln!(out, "  mq -I null 'ARGS | .\"named\"' --args name Alice");
1436        let _ = writeln!(out, "  # => {{\"name\": \"Alice\"}}\n");
1437        let _ = writeln!(
1438            out,
1439            "  mq -I null 'ARGS | .\"positional\"' --argv x y z  # must come after query and files"
1440        );
1441        let _ = writeln!(out, "  # => [\"x\", \"y\", \"z\"]\n");
1442        let _ = writeln!(out, "  mq -I null 'ARGS' file.md --args name Alice --argv x y z");
1443        let _ = writeln!(
1444            out,
1445            "  # => {{\"positional\": [\"x\",\"y\",\"z\"], \"named\": {{\"name\": \"Alice\"}}}}"
1446        );
1447
1448        let _ = writeln!(
1449            out,
1450            "\n{}",
1451            "Sandboxed capabilities (--allow-read/write/net/run/env):".bold().cyan()
1452        );
1453        let _ = writeln!(
1454            out,
1455            "  Disabled by default; each takes an optional allowlist (comma-separated)."
1456        );
1457        let _ = writeln!(out, "  mq --allow-read=. -I null 'read_file(\"notes.md\")'");
1458        let _ = writeln!(out, "  mq --allow-run=echo -I null 'system(\"echo\", [\"hello\"])'");
1459        let _ = writeln!(out, "  mq --allow-env=MY_VAR -I null '$MY_VAR'");
1460        let _ = writeln!(
1461            out,
1462            "  mq --allow-net=api.example.com 'http(\"get\", \"https://api.example.com/data\")' file.md"
1463        );
1464        let _ = writeln!(
1465            out,
1466            "  mq --sandbox=read-only -I null 'read_file(\"notes.md\")'  # strict/read-only/networked/unsafe presets"
1467        );
1468
1469        #[cfg(feature = "http-import")]
1470        {
1471            let _ = writeln!(out, "\n{}", "HTTP module imports (--allow-http-import):".bold().cyan());
1472            let _ = writeln!(
1473                out,
1474                "  Separate from --allow-net above; disabled by default regardless of it."
1475            );
1476            let _ = writeln!(
1477                out,
1478                "  mq --allow-http-import 'import \"github.com/harehare/kdl.mq\"' file.md"
1479            );
1480        }
1481
1482        let _ = writeln!(out, "\n{}", "Working with multiple files:".bold().cyan());
1483        let _ = writeln!(
1484            out,
1485            "  mq --eval-all '.h1' a.md b.md          # one query over all files combined"
1486        );
1487        let _ = writeln!(
1488            out,
1489            "  mq -S '\"---\"' '.h1' a.md b.md          # insert a separator between files"
1490        );
1491
1492        let _ = writeln!(out, "\n{}", "Streaming large files (--stream):".bold().cyan());
1493        let _ = write!(out, "  mq --stream -I text 'select(contains(\"ERROR\"))' huge.log");
1494
1495        out
1496    }
1497
1498    /// Same content as [`Self::examples_topic_text`], as Markdown — queryable with mq itself
1499    /// via `mq help examples --markdown | mq '...'`.
1500    fn examples_topic_markdown() -> String {
1501        let mut out = String::new();
1502
1503        let _ = writeln!(out, "# mq help examples");
1504
1505        let _ = writeln!(out, "\n## Basic usage\n");
1506        let _ = writeln!(
1507            out,
1508            "```sh\nmq 'query' file.md\nmq -f 'file' file.md        # read query from file\nmq repl                     # start a REPL session\n```"
1509        );
1510
1511        let _ = writeln!(out, "\n## Auto-parsing by file extension or -I flag\n");
1512        let _ = writeln!(
1513            out,
1514            "mq automatically imports the matching module based on the file extension. \
1515            Use `-I <format>` to force a specific format:\n"
1516        );
1517        let _ = writeln!(out, "| Extension / `-I` | Query prefix |");
1518        let _ = writeln!(out, "|---|---|");
1519        let _ = writeln!(
1520            out,
1521            "| `.cbor` / `-I cbor` | `import \"cbor\" \\| cbor::cbor_parse()` (reads as bytes) |"
1522        );
1523        let _ = writeln!(out, "| `.csv` / `-I csv` | `import \"csv\" \\| csv::csv_parse(true)` |");
1524        let _ = writeln!(
1525            out,
1526            "| `.gron` / `-I gron` | `import \"gron\" \\| gron::gron_parse()` |"
1527        );
1528        let _ = writeln!(
1529            out,
1530            "| `.json` / `-I json` | `import \"json\" \\| json::json_parse()` |"
1531        );
1532        let _ = writeln!(out, "| `.psv` / `-I psv` | `import \"csv\" \\| csv::psv_parse(true)` |");
1533        let _ = writeln!(
1534            out,
1535            "| `.toml` / `-I toml` | `import \"toml\" \\| toml::toml_parse()` |"
1536        );
1537        let _ = writeln!(
1538            out,
1539            "| `.toon` / `-I toon` | `import \"toon\" \\| toon::toon_parse()` |"
1540        );
1541        let _ = writeln!(out, "| `.tsv` / `-I tsv` | `import \"csv\" \\| csv::tsv_parse(true)` |");
1542        let _ = writeln!(out, "| `.xml` / `-I xml` | `import \"xml\" \\| xml::xml_parse()` |");
1543        let _ = writeln!(
1544            out,
1545            "| `.yaml` / `-I yaml` | `import \"yaml\" \\| yaml::yaml_parse()` |"
1546        );
1547        let _ = writeln!(
1548            out,
1549            "\nUse `-I raw` to disable auto-parsing and receive the raw string. Use `-I bytes` to \
1550            read input as raw bytes without parsing."
1551        );
1552
1553        let _ = writeln!(out, "\n## Output formats (-F)\n");
1554        let _ = writeln!(
1555            out,
1556            "```sh\nmq -F json '.h' file.md          # headings as JSON nodes\nmq -F csv 'to_text()' file.md    # every node's text, one per CSV row\n\
1557            mq -o out.json 'self' file.md    # -F inferred from -o's extension\n\
1558            mq -T json 'self' file.txt       # sets both -I and -F at once (-I/-F still override)\n```"
1559        );
1560
1561        let _ = writeln!(out, "\n## Updating markdown (-U)\n");
1562        let _ = writeln!(
1563            out,
1564            "```sh\nmq -U '.h1 | update(\"New title\")' file.md         # whole document to stdout; the file is not touched\n\
1565            mq -U --diff '.h1 | update(\"New title\")' file.md  # preview the change as a unified diff\n```\n\n\
1566            `-U` never edits in place; unmatched content is reformatted too."
1567        );
1568
1569        let _ = writeln!(out, "\n## Filtering with context (-F grep)\n");
1570        let _ = writeln!(
1571            out,
1572            "```sh\nmq -F grep --context 1 '.h' file.md  # matches with 1 node of context on each side\n```\n\n\
1573            (also: `-B`/`--before-context`, `--after-context`)"
1574        );
1575
1576        let _ = writeln!(out, "\n## Passing arguments to queries (ARGS)\n");
1577        let _ = writeln!(
1578            out,
1579            "When `--args` or `--argv` is given, `ARGS = {{\"positional\": [...], \"named\": {{...}}}}`\n"
1580        );
1581        let _ = writeln!(
1582            out,
1583            "```sh\nmq -I null 'name' --args name Alice\nmq -I null 'ARGS | .\"named\"' --args name Alice\n\
1584            # => {{\"name\": \"Alice\"}}\n\n\
1585            mq -I null 'ARGS | .\"positional\"' --argv x y z  # must come after query and files\n\
1586            # => [\"x\", \"y\", \"z\"]\n\n\
1587            mq -I null 'ARGS' file.md --args name Alice --argv x y z\n\
1588            # => {{\"positional\": [\"x\",\"y\",\"z\"], \"named\": {{\"name\": \"Alice\"}}}}\n```"
1589        );
1590
1591        let _ = writeln!(out, "\n## Sandboxed capabilities (--allow-read/write/net/run/env)\n");
1592        let _ = writeln!(
1593            out,
1594            "Disabled by default; each takes an optional allowlist (comma-separated).\n"
1595        );
1596        let _ = writeln!(
1597            out,
1598            "```sh\nmq --allow-read=. -I null 'read_file(\"notes.md\")'\n\
1599            mq --allow-run=echo -I null 'system(\"echo\", [\"hello\"])'\n\
1600            mq --allow-env=MY_VAR -I null '$MY_VAR'\n\
1601            mq --allow-net=api.example.com 'http(\"get\", \"https://api.example.com/data\")' file.md\n\
1602            mq --sandbox=read-only -I null 'read_file(\"notes.md\")'  # strict/read-only/networked/unsafe presets\n```"
1603        );
1604
1605        #[cfg(feature = "http-import")]
1606        {
1607            let _ = writeln!(out, "\n## HTTP module imports (--allow-http-import)\n");
1608            let _ = writeln!(
1609                out,
1610                "Separate from --allow-net above; disabled by default regardless of it.\n"
1611            );
1612            let _ = writeln!(
1613                out,
1614                "```sh\nmq --allow-http-import 'import \"github.com/harehare/kdl.mq\"' file.md\n```"
1615            );
1616        }
1617
1618        let _ = writeln!(out, "\n## Working with multiple files\n");
1619        let _ = writeln!(
1620            out,
1621            "```sh\nmq --eval-all '.h1' a.md b.md          # one query over all files combined\n\
1622            mq -S '\"---\"' '.h1' a.md b.md          # insert a separator between files\n```"
1623        );
1624
1625        let _ = writeln!(out, "\n## Streaming large files (--stream)\n");
1626        let _ = write!(
1627            out,
1628            "```sh\nmq --stream -I text 'select(contains(\"ERROR\"))' huge.log\n```"
1629        );
1630
1631        out
1632    }
1633
1634    /// Prints an uncaught error to stderr per `--error-format`.
1635    /// The exit code requested by `halt(code)`, if `err` came from it.
1636    pub fn halt_exit_code(err: &miette::Report) -> Option<i32> {
1637        err.downcast_ref::<mq_lang::Error>().and_then(mq_lang::Error::exit_code)
1638    }
1639
1640    pub fn report_error(&self, err: &miette::Report) {
1641        match self.error_format {
1642            ErrorFormat::Human => {
1643                eprintln!("Error: {err:?}");
1644                if let Some(hint) = Self::capability_hint(&err.to_string()) {
1645                    eprintln!("  hint: {hint}");
1646                }
1647            }
1648            ErrorFormat::Json => match Self::render_error_json(err) {
1649                Some(json) => eprintln!("{json}"),
1650                None => eprintln!("Error: {err:?}"),
1651            },
1652        }
1653    }
1654
1655    #[cold]
1656    fn capability_hint(message: &str) -> Option<String> {
1657        fn between<'a>(text: &'a str, start: &str, end: &str) -> Option<&'a str> {
1658            let rest = text.split_once(start)?.1;
1659            Some(rest.split_once(end)?.0.trim())
1660        }
1661        let broad = |flag: &str, scoped: &str| {
1662            Some(format!(
1663                "the sandbox blocked this. Pass `{flag}` (or `{scoped}` to limit it), or `--allow-all`."
1664            ))
1665        };
1666        let scoped = |flag: &str, value: &str| {
1667            Some(format!(
1668                "the sandbox blocked this. Add it with `{flag}={value}`, or pass `--allow-all`."
1669            ))
1670        };
1671
1672        if message.contains("network access is disabled") {
1673            return broad("--allow-net", "--allow-net=DOMAIN");
1674        }
1675        if message.contains("filesystem reads are disabled") {
1676            return broad("--allow-read", "--allow-read=PATH");
1677        }
1678        if message.contains("filesystem writes are disabled") {
1679            return broad("--allow-write", "--allow-write=PATH");
1680        }
1681        if message.contains("environment variable access is disabled") {
1682            return broad("--allow-env", "--allow-env=NAME");
1683        }
1684        if message.contains("process execution is disabled") {
1685            return broad("--allow-run", "--allow-run=COMMAND");
1686        }
1687        if let Some(url) = between(
1688            message,
1689            "network access to ",
1690            " is not allowed (outside the allowed domains)",
1691        ) {
1692            let host = url.split("://").last().unwrap_or(url).split('/').next().unwrap_or(url);
1693            return scoped("--allow-net", host);
1694        }
1695        if let Some(path) = between(message, " of ", " is not allowed (outside the allowed paths)") {
1696            let action = message.split(" of ").next()?.split_whitespace().last()?;
1697            let flag = if action == "write" {
1698                "--allow-write"
1699            } else {
1700                "--allow-read"
1701            };
1702            return scoped(flag, path);
1703        }
1704        if let Some(name) = between(message, "environment variable `", "` is not allowed") {
1705            return scoped("--allow-env", name);
1706        }
1707        if let Some(command) = between(
1708            message,
1709            "execution of ",
1710            " is not allowed (outside the allowed commands)",
1711        ) {
1712            return scoped("--allow-run", command);
1713        }
1714        None
1715    }
1716
1717    /// Renders `err` as a single-line JSON diagnostic object (`None` on a render failure).
1718    fn render_error_json(err: &miette::Report) -> Option<String> {
1719        let mut json = String::new();
1720        miette::JSONReportHandler::new().render_report(&mut json, &**err).ok()?;
1721        Some(json)
1722    }
1723
1724    pub fn run(&self) -> miette::Result<()> {
1725        if self.list {
1726            return self.list_commands();
1727        }
1728
1729        if (self.output.before_context.is_some()
1730            || self.output.after_context.is_some()
1731            || self.output.context.is_some())
1732            && !matches!(self.resolved_output_format(), OutputFormat::Grep)
1733        {
1734            return Err(miette!(
1735                "--before-context, --after-context, and --context are only valid with -F grep"
1736            ));
1737        }
1738
1739        if let Some(Commands::Compile {
1740            query,
1741            from_file,
1742            output,
1743            program,
1744        }) = &self.commands
1745        {
1746            if self.format.is_some() {
1747                return Err(miette!(
1748                    "-T/--format has no effect on `mq compile`; pass -I/--input-format after `compile` instead"
1749                ));
1750            }
1751            if self.input.program != ProgramArgs::default() || self.input.from_file {
1752                return Err(miette!(
1753                    "Pass compile options after `compile`: mq compile [OPTIONS] QUERY"
1754                ));
1755            }
1756            return Self::compile_bytecode(query, *from_file, output.as_deref(), program);
1757        }
1758        self.load_bytecode()?;
1759
1760        // Check if query is actually an external subcommand
1761        // This handles the case where clap parses "mq test arg1" as query="test", files=["arg1"]
1762        if !self.input.from_file
1763            && self.commands.is_none()
1764            && self.bytecode.get().is_none()
1765            && let Some(query_value) = &self.query
1766        {
1767            // Only treat as external command if query_value is a valid file name
1768            if query_value
1769                .chars()
1770                .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
1771            {
1772                let command_path = {
1773                    let command_bin = format!("mq-{}", query_value);
1774                    let command_path = Self::get_external_commands_dir().unwrap_or_default().join(&command_bin);
1775
1776                    if !command_path.exists() {
1777                        which(&command_bin).ok()
1778                    } else {
1779                        Some(command_path)
1780                    }
1781                };
1782
1783                if let Some(command_path) = command_path {
1784                    let mut args = vec![query_value.clone()];
1785                    if let Some(files) = &self.files {
1786                        args.extend(files.iter().map(|p| p.to_string_lossy().to_string()));
1787                    }
1788                    return self.execute_external_command(command_path, &args);
1789                }
1790            }
1791        }
1792
1793        if !matches!(self.explicit_input_format(), Some(InputFormat::Markdown) | None) && self.output.update {
1794            return Err(miette!("The output format is not supported for the update option"));
1795        }
1796
1797        if self.output.diff && matches!(self.resolved_output_format(), OutputFormat::Grep) {
1798            return Err(miette!("--diff is not supported with -F grep"));
1799        }
1800
1801        if self.output.compact && !matches!(self.resolved_output_format(), OutputFormat::Json) {
1802            return Err(miette!("--compact is only valid with -F json"));
1803        }
1804
1805        if (self.output.indent.is_some() || self.output.tab)
1806            && !matches!(self.resolved_output_format(), OutputFormat::Json | OutputFormat::Xml)
1807        {
1808            return Err(miette!("--indent/--tab are only valid with -F json or -F xml"));
1809        }
1810
1811        self.validate_csv_options()?;
1812        self.check_output_permitted()?;
1813
1814        match &self.commands {
1815            Some(Commands::Repl { files }) => {
1816                let engine = self.create_engine()?;
1817                let input = match files {
1818                    Some(files) if !files.is_empty() => {
1819                        let files = Self::expand_glob_patterns(files)?;
1820                        if files.iter().any(|f| Self::is_stdin_path(f)) {
1821                            return Err(miette!("`-` (stdin) is not supported by the repl command"));
1822                        }
1823                        let contents = self.read_files_content(&files)?;
1824                        let mut combined = Vec::new();
1825                        for (file, content) in &contents {
1826                            combined.extend(self.resolve_input(file, content)?);
1827                        }
1828                        combined
1829                    }
1830                    _ => vec![mq_lang::RuntimeValue::String(Shared::new("".to_string()))],
1831                };
1832                Self::new_repl(engine, input).run()
1833            }
1834            None if self.query.is_none() => {
1835                let engine = self.create_engine()?;
1836                Self::new_repl(engine, vec![mq_lang::RuntimeValue::String(Shared::new("".to_string()))]).run()
1837            }
1838            #[cfg(feature = "debugger")]
1839            Some(Commands::Dap) => mq_dap::start().map_err(|e| miette!(e.to_string())),
1840            Some(Commands::Completion { shell }) => Self::generate_completion(shell),
1841            Some(Commands::Help { name, json, markdown }) => Self::run_help(name.as_deref(), *json, *markdown),
1842            Some(Commands::Compile { .. }) => unreachable!("handled before input validation"),
1843            #[cfg(feature = "watch")]
1844            None if self.input.watch => self.run_watch(),
1845            None => {
1846                let result = self.execute_once();
1847
1848                // --exit-status / -e: exit with code 1 if no truthy value was
1849                // produced. Mirrors jq's behaviour: false and null are falsy;
1850                // everything else (including empty string, 0, [], {}) is truthy.
1851                if self.output.exit_status {
1852                    let had_truthy = HAD_TRUTHY_OUTPUT.load(Ordering::Relaxed);
1853                    if !had_truthy {
1854                        result?;
1855                        std::process::exit(1);
1856                    }
1857                }
1858
1859                // --diff: exit 1 if anything would change, for CI gating.
1860                if self.output.diff && HAD_DIFF.load(Ordering::Relaxed) {
1861                    result?;
1862                    std::process::exit(1);
1863                }
1864
1865                result
1866            }
1867        }
1868    }
1869
1870    fn create_engine(&self) -> miette::Result<DefaultEngine> {
1871        let sandboxed_io = self.input.program.build_sandboxed_io();
1872        let mut engine = mq_lang::DefaultEngine::default();
1873        engine.set_io(Shared::new(sandboxed_io));
1874        engine.load_builtin_module();
1875        // Empty defaults for stdin; `--args` etc. below may override them.
1876        Self::define_file_vars(&engine, None);
1877
1878        if self.input.program.aggregate {
1879            engine.import_module("section").map_err(|e| *e)?;
1880        }
1881
1882        if let Some(dirs) = &self.input.program.module_directories {
1883            engine.set_search_paths(dirs.clone());
1884        }
1885
1886        if let Some(modules) = &self.input.program.module_names {
1887            for module_name in modules {
1888                engine.load_module(module_name).map_err(|e| *e)?;
1889            }
1890        }
1891
1892        if let Some(modules) = &self.input.program.import_module_names {
1893            for module_name in modules {
1894                engine.import_module(module_name).map_err(|e| *e)?;
1895            }
1896        }
1897
1898        if self.input.args.is_some()
1899            || self.argv.is_some()
1900            || self.input.argjson.is_some()
1901            || self.input.slurp_file.is_some()
1902        {
1903            let mut named: DictMap = DictMap::default();
1904            if let Some(args) = &self.input.args {
1905                for v in args.chunks(2) {
1906                    engine.define_string_value(&v[0], &v[1]);
1907                    named.insert(
1908                        mq_lang::Ident::new(&v[0]),
1909                        mq_lang::RuntimeValue::String(Shared::new(v[1].clone())),
1910                    );
1911                }
1912            }
1913
1914            if let Some(argjson) = &self.input.argjson {
1915                for v in argjson.chunks(2) {
1916                    let json_value: serde_json::Value = serde_json::from_str(&v[1]).into_diagnostic()?;
1917                    let runtime_value: mq_lang::RuntimeValue = json_value.into();
1918                    engine.define_value(&v[0], runtime_value.clone()).into_diagnostic()?;
1919                    named.insert(mq_lang::Ident::new(&v[0]), runtime_value);
1920                }
1921            }
1922
1923            if let Some(slurp_file) = &self.input.slurp_file {
1924                for v in slurp_file.chunks(2) {
1925                    let path = PathBuf::from_str(&v[1]).into_diagnostic()?;
1926
1927                    if !path.exists() {
1928                        return Err(miette!("File not found: {}", path.display()));
1929                    }
1930
1931                    let content = fs::read_to_string(&path).into_diagnostic()?;
1932                    let json_values: Vec<serde_json::Value> = serde_json::Deserializer::from_str(&content)
1933                        .into_iter::<serde_json::Value>()
1934                        .collect::<Result<_, _>>()
1935                        .into_diagnostic()?;
1936                    let runtime_value = mq_lang::RuntimeValue::Array(mq_lang::Shared::new(
1937                        json_values.into_iter().map(Into::into).collect(),
1938                    ));
1939                    engine.define_value(&v[0], runtime_value.clone()).into_diagnostic()?;
1940                    named.insert(mq_lang::Ident::new(&v[0]), runtime_value);
1941                }
1942            }
1943
1944            let positional: Vec<mq_lang::RuntimeValue> = self
1945                .argv
1946                .as_deref()
1947                .unwrap_or(&[])
1948                .iter()
1949                .map(|s| mq_lang::RuntimeValue::String(Shared::new(s.clone())))
1950                .collect();
1951            let args_map: DictMap = [
1952                (
1953                    mq_lang::Ident::new("positional"),
1954                    mq_lang::RuntimeValue::Array(Shared::new(positional)),
1955                ),
1956                (
1957                    mq_lang::Ident::new("named"),
1958                    mq_lang::RuntimeValue::Dict(Shared::new(named)),
1959                ),
1960            ]
1961            .into_iter()
1962            .collect();
1963            engine
1964                .define_value("ARGS", mq_lang::RuntimeValue::Dict(Shared::new(args_map)))
1965                .into_diagnostic()?;
1966        }
1967
1968        if let Some(raw_file) = &self.input.raw_file {
1969            for v in raw_file.chunks(2) {
1970                let path = PathBuf::from_str(&v[1]).into_diagnostic()?;
1971
1972                if !path.exists() {
1973                    return Err(miette!("File not found: {}", path.display()));
1974                }
1975
1976                let content = fs::read_to_string(&path).into_diagnostic()?;
1977                engine.define_string_value(&v[0], &content);
1978            }
1979        }
1980
1981        #[cfg(feature = "http-import")]
1982        {
1983            engine.set_http_import_enabled(self.input.program.http_import_enabled());
1984            let domains = self.input.program.http_import_domains();
1985            if !domains.is_empty() {
1986                engine.set_http_allowed_domains(domains);
1987            }
1988            if self.input.program.no_lockfile {
1989                engine.set_lockfile_enabled(false);
1990            }
1991            if self.input.program.frozen {
1992                engine.set_lockfile_frozen(true);
1993            }
1994            if let Some(path) = &self.input.program.lockfile_path {
1995                engine.set_lockfile_path(path.clone());
1996            }
1997            if self.input.program.clear_cache {
1998                engine.clear_http_cache_all().map_err(|e| miette!(e.to_string()))?;
1999            } else if self.input.program.refresh_modules {
2000                engine.clear_http_cache().map_err(|e| miette!(e.to_string()))?;
2001            }
2002        }
2003
2004        if let Some(timeout) = self.timeout {
2005            engine.set_timeout(timeout);
2006        }
2007        engine.set_capture_stack_trace(self.stack_trace);
2008
2009        #[cfg(feature = "debugger")]
2010        {
2011            use crate::debugger::DebuggerHandler;
2012            #[cfg(feature = "debug-trace")]
2013            let handler = {
2014                let mut handler = DebuggerHandler::new(engine.clone(), self.stop_on_error);
2015                handler.set_dump_stack(self.dump_stack);
2016                handler.set_color_output(self.output.color_output && !Self::is_no_color());
2017                handler
2018            };
2019            #[cfg(not(feature = "debug-trace"))]
2020            let handler = DebuggerHandler::new(engine.clone(), self.stop_on_error);
2021            engine.set_debugger_handler(Box::new(handler));
2022            engine.debugger().write().unwrap().activate();
2023        }
2024
2025        Ok(engine)
2026    }
2027
2028    fn get_query(&self) -> miette::Result<String> {
2029        if self.bytecode.get().is_some() {
2030            return Ok(String::new());
2031        }
2032        let query = match self.query.as_ref() {
2033            Some(q) if self.input.from_file => {
2034                let path = PathBuf::from_str(q).into_diagnostic()?;
2035                fs::read_to_string(&path)
2036                    .into_diagnostic()
2037                    .wrap_err_with(|| format!("failed to read `{}`", path.display()))?
2038            }
2039            Some(q) => q.clone(),
2040            None => return Err(miette!("Query is required")),
2041        };
2042
2043        let aggregate = self.input.program.aggregate.then_some("nodes");
2044        Ok(aggregate.map(|agg| format!("{} | {}", agg, query)).unwrap_or(query))
2045    }
2046
2047    fn validate_csv_options(&self) -> miette::Result<()> {
2048        if (self.input.program.csv_delimiter.is_some() || self.input.program.no_header)
2049            && matches!(self.explicit_input_format(), Some(fmt) if !matches!(fmt, InputFormat::Csv | InputFormat::Tsv | InputFormat::Psv))
2050        {
2051            return Err(miette!(
2052                "--csv-delimiter/--no-header only apply to -I csv, -I tsv, or -I psv"
2053            ));
2054        }
2055        Ok(())
2056    }
2057
2058    fn explicit_input_format(&self) -> Option<InputFormat> {
2059        self.input
2060            .program
2061            .input_format
2062            .clone()
2063            .or_else(|| self.format.clone().map(InputFormat::from))
2064    }
2065
2066    fn resolved_output_format(&self) -> OutputFormat {
2067        self.output
2068            .output_format
2069            .clone()
2070            .or_else(|| self.format.clone().map(OutputFormat::from))
2071            .unwrap_or_else(|| {
2072                self.output
2073                    .output_file
2074                    .as_deref()
2075                    .map(OutputFormat::from_path)
2076                    .unwrap_or_default()
2077            })
2078    }
2079
2080    /// Under `--sandbox`, rejects `-o`/`--output` (and so `--append`/`--no-clobber`) unless
2081    /// `--allow-write` permits the target. Without `--sandbox`, output is unrestricted.
2082    fn check_output_permitted(&self) -> miette::Result<()> {
2083        let (Some(_), Some(path)) = (&self.input.program.sandbox, &self.output.output_file) else {
2084            return Ok(());
2085        };
2086        self.input
2087            .program
2088            .build_sandboxed_io()
2089            .check_write(path)
2090            .map_err(|e| miette!("cannot write output file: {e} (use --allow-write to permit it)"))
2091    }
2092
2093    fn clobber_mode(&self) -> ClobberMode {
2094        if self.output.append {
2095            ClobberMode::Append
2096        } else if self.output.no_clobber {
2097            ClobberMode::NoClobber
2098        } else {
2099            ClobberMode::Overwrite
2100        }
2101    }
2102
2103    fn auto_query_prefix(&self, file: &Option<PathBuf>) -> Option<String> {
2104        let fmt = match self.explicit_input_format() {
2105            Some(fmt) => fmt,
2106            None => InputFormat::from_path(file.as_ref()?),
2107        };
2108        self.tabular_query_prefix(&fmt)
2109            .or_else(|| fmt.module_query_prefix().map(str::to_string))
2110    }
2111
2112    /// `--csv-delimiter`/`--no-header`-aware prefix for csv/tsv/psv; `None` otherwise.
2113    fn tabular_query_prefix(&self, fmt: &InputFormat) -> Option<String> {
2114        let has_header = !self.input.program.no_header;
2115        match fmt {
2116            InputFormat::Csv => Some(match self.input.program.csv_delimiter {
2117                Some(delimiter) => format!(
2118                    r#"import "csv" | csv::csv_parse_with_delimiter({:?}, {has_header})"#,
2119                    delimiter.to_string()
2120                ),
2121                None => format!(r#"import "csv" | csv::csv_parse({has_header})"#),
2122            }),
2123            InputFormat::Tsv => Some(format!(r#"import "csv" | csv::tsv_parse({has_header})"#)),
2124            InputFormat::Psv => Some(format!(r#"import "csv" | csv::psv_parse({has_header})"#)),
2125            _ => None,
2126        }
2127    }
2128
2129    /// Sets the `__FILE__` globals; `None` (stdin) resets them only after a file set them,
2130    /// so a preceding file's values don't leak and user-defined globals survive.
2131    fn set_file_vars(&self, engine: &mut mq_lang::DefaultEngine, cache: &mut ProgramCache, file: Option<&Path>) {
2132        if file.is_none() && !cache.file_vars_set {
2133            return;
2134        }
2135        cache.file_vars_set = file.is_some();
2136        Self::define_file_vars(engine, file);
2137    }
2138
2139    fn define_file_vars(engine: &mq_lang::DefaultEngine, file: Option<&Path>) {
2140        let file = file.unwrap_or_else(|| Path::new(""));
2141        let path = file.to_string_lossy();
2142        let name = file.file_name().unwrap_or_default().to_string_lossy();
2143        let stem = file.file_stem().unwrap_or_default().to_string_lossy();
2144        engine.define_string_values(&[("__FILE__", &path), ("__FILE_NAME__", &name), ("__FILE_STEM__", &stem)]);
2145    }
2146
2147    fn resolve_input(
2148        &self,
2149        file: &Option<PathBuf>,
2150        content: &ContentData,
2151    ) -> miette::Result<Vec<mq_lang::RuntimeValue>> {
2152        let text = content.as_str().unwrap_or("");
2153        Ok(
2154            match self.explicit_input_format().unwrap_or_else(|| {
2155                if let Some(file) = file {
2156                    InputFormat::from_path(file)
2157                } else if text.is_empty() && io::stdin().is_terminal() {
2158                    InputFormat::Null
2159                } else {
2160                    InputFormat::Markdown
2161                }
2162            }) {
2163                // Native formats
2164                InputFormat::Markdown => mq_lang::parse_markdown_input(text)?,
2165                InputFormat::Mdx => mq_lang::parse_mdx_input(text)?,
2166                InputFormat::Html => mq_lang::parse_html_input(text)?,
2167                InputFormat::Text => mq_lang::parse_text_input(text)?,
2168                InputFormat::Null => mq_lang::null_input(),
2169                InputFormat::Raw => mq_lang::raw_input(text),
2170                // Bytes: pass raw binary content as RuntimeValue::Bytes with no further parsing.
2171                InputFormat::Bytes => mq_lang::bytes_input(content.as_bytes()),
2172                // Module-backed binary format: pass raw bytes; the cbor module handles parsing.
2173                InputFormat::Cbor => mq_lang::bytes_input(content.as_bytes()),
2174                // Module-backed text formats (alphabetical): pass raw string; the module handles parsing.
2175                InputFormat::Csv
2176                | InputFormat::Gron
2177                | InputFormat::Json
2178                | InputFormat::Psv
2179                | InputFormat::Toml
2180                | InputFormat::Toon
2181                | InputFormat::Tsv
2182                | InputFormat::Xml
2183                | InputFormat::Yaml => mq_lang::raw_input(text),
2184            },
2185        )
2186    }
2187
2188    fn apply_update(
2189        &self,
2190        input: Vec<mq_lang::RuntimeValue>,
2191        results: mq_lang::RuntimeValues,
2192    ) -> miette::Result<mq_lang::RuntimeValues> {
2193        let current_values: mq_lang::RuntimeValues = input.into();
2194        if current_values.len() != results.len() {
2195            return Err(miette!("The number of input and output values do not match"));
2196        }
2197        Ok(current_values.update_with(results))
2198    }
2199
2200    fn emit_results(
2201        &self,
2202        runtime_values: mq_lang::RuntimeValues,
2203        grep_input: Option<Vec<mq_lang::RuntimeValue>>,
2204        file: &Option<PathBuf>,
2205    ) -> miette::Result<()> {
2206        let runtime_values = if self.output.skip.is_some() || self.output.limit.is_some() {
2207            let compact = runtime_values.compact();
2208            let had_empties = compact.len() < runtime_values.len();
2209            let paginated: Vec<mq_lang::RuntimeValue> = self
2210                .output
2211                .paginate(compact)
2212                .into_iter()
2213                .map(|v| {
2214                    if had_empties {
2215                        let mut v = v;
2216                        v.set_position(None);
2217                        v
2218                    } else {
2219                        v
2220                    }
2221                })
2222                .collect();
2223            paginated.into()
2224        } else {
2225            runtime_values
2226        };
2227
2228        if self.quiet {
2229            return self.print(runtime_values);
2230        }
2231
2232        if let Some(input) = grep_input {
2233            let (before, after) = self.output.context_counts();
2234            let handle = OutputSink::open(
2235                &self.output.output_file,
2236                self.output.atomic_output,
2237                self.output.unbuffered,
2238                self.clobber_mode(),
2239                &self.output.output_claimed,
2240            )?;
2241            grep::print_grep(runtime_values, &input, file, handle, before, after)
2242        } else {
2243            self.print(runtime_values)
2244        }
2245    }
2246
2247    fn execute(
2248        &self,
2249        engine: &mut mq_lang::DefaultEngine,
2250        cache: &mut ProgramCache,
2251        query: &str,
2252        file: &Option<PathBuf>,
2253        content: &ContentData,
2254    ) -> miette::Result<()> {
2255        self.set_file_vars(engine, cache, file.as_deref());
2256        let index = self.program_index(engine, cache, query, file)?;
2257        if cache.separator.is_none()
2258            && let Some(separator) = &self.output.separator
2259        {
2260            cache.separator = Some(engine.compile(separator).map_err(|error| *error)?);
2261        }
2262        self.run_program(
2263            engine,
2264            &cache.programs[index].1,
2265            cache.separator.as_ref(),
2266            file,
2267            content,
2268        )
2269    }
2270
2271    /// Returns the effective query string combining any auto-prefix with the base query.
2272    fn effective_query(&self, query: &str, file: &Option<PathBuf>) -> String {
2273        match self.auto_query_prefix(file) {
2274            Some(prefix) => format!("{} | {}", prefix, query),
2275            None => query.to_string(),
2276        }
2277    }
2278
2279    /// Compiles the query for `file`, or loads the `.mqc` program.
2280    fn prepare_program(
2281        &self,
2282        engine: &mut mq_lang::DefaultEngine,
2283        query: &str,
2284        file: &Option<PathBuf>,
2285    ) -> miette::Result<mq_lang::CompiledProgram> {
2286        match self.bytecode.get() {
2287            Some(mqc) => self.load_mqc_program(engine, mqc, file),
2288            None => engine
2289                .compile(&self.effective_query(query, file))
2290                .map_err(|error| miette::Report::new(*error)),
2291        }
2292    }
2293
2294    /// Returns the index in `cache` of `file`'s program, preparing it once per [`ProgramKey`].
2295    fn program_index(
2296        &self,
2297        engine: &mut mq_lang::DefaultEngine,
2298        cache: &mut ProgramCache,
2299        query: &str,
2300        file: &Option<PathBuf>,
2301    ) -> miette::Result<usize> {
2302        let key = self.program_key(file);
2303        if let Some(index) = cache.programs.iter().position(|(cached, _)| *cached == key) {
2304            return Ok(index);
2305        }
2306        let program = self.prepare_program(engine, query, file)?;
2307        self.dump_compiled_bytecode(engine, &program)?;
2308        cache.programs.push((key, program));
2309        Ok(cache.programs.len() - 1)
2310    }
2311
2312    #[cfg(feature = "debug-trace")]
2313    fn dump_compiled_bytecode(
2314        &self,
2315        engine: &mut mq_lang::DefaultEngine,
2316        program: &mq_lang::CompiledProgram,
2317    ) -> miette::Result<()> {
2318        if let Some(format) = self.dump_bytecode {
2319            let dump = engine.dump_bytecode(program).map_err(|error| *error)?;
2320            let color = self.output.color_output && !Self::is_no_color();
2321            io::stdout()
2322                .write_all(format.render(&dump, color).as_bytes())
2323                .into_diagnostic()?;
2324        }
2325        Ok(())
2326    }
2327
2328    /// Dumps the bytecode of each distinct program without reading input or running it.
2329    #[cfg(feature = "debug-trace")]
2330    fn dump_bytecode_only(&self) -> miette::Result<()> {
2331        let query = self.get_query()?;
2332        let files = match self.resolved_files()? {
2333            Some(files) if !files.is_empty() => files.into_iter().map(Some).collect(),
2334            _ => vec![None],
2335        };
2336        let mut engine = self.create_engine()?;
2337        let mut cache = ProgramCache::default();
2338        for file in &files {
2339            self.set_file_vars(&mut engine, &mut cache, file.as_deref());
2340            self.program_index(&mut engine, &mut cache, &query, file)?;
2341        }
2342        Ok(())
2343    }
2344
2345    #[cfg(not(feature = "debug-trace"))]
2346    fn dump_compiled_bytecode(
2347        &self,
2348        _engine: &mut mq_lang::DefaultEngine,
2349        _program: &mq_lang::CompiledProgram,
2350    ) -> miette::Result<()> {
2351        Ok(())
2352    }
2353
2354    /// What the prepared program for `file` depends on.
2355    fn program_key(&self, file: &Option<PathBuf>) -> ProgramKey {
2356        (self.auto_query_prefix(file), self.mqc_input_format(file))
2357    }
2358
2359    /// Returns true if one prepared program is valid for every file.
2360    fn all_files_share_program(&self, files: &[(Option<PathBuf>, ContentData)]) -> bool {
2361        if files.is_empty() {
2362            return true;
2363        }
2364        let first = self.program_key(&files[0].0);
2365        files[1..].iter().all(|(f, _)| self.program_key(f) == first)
2366    }
2367
2368    fn execute_once(&self) -> miette::Result<()> {
2369        #[cfg(feature = "debug-trace")]
2370        if self.dump_bytecode.is_some() {
2371            return self.dump_bytecode_only();
2372        }
2373        if self.input.stream {
2374            self.process_streaming()
2375        } else {
2376            self.process_batch()
2377        }
2378    }
2379
2380    #[cfg(feature = "watch")]
2381    fn watch_targets(&self) -> miette::Result<Vec<PathBuf>> {
2382        let mut targets = self.resolved_files()?.unwrap_or_default();
2383
2384        if targets.is_empty() || targets.iter().any(|t| Self::is_stdin_path(t)) {
2385            return Err(miette!(
2386                "--watch requires at least one input file; stdin cannot be watched"
2387            ));
2388        }
2389
2390        if self.input.from_file
2391            && let Some(query_path) = &self.query
2392        {
2393            targets.push(PathBuf::from(query_path));
2394        }
2395
2396        Ok(targets)
2397    }
2398
2399    #[cfg(feature = "watch")]
2400    fn canonical_parent_dir(path: &Path) -> PathBuf {
2401        let dir = path
2402            .parent()
2403            .filter(|p| !p.as_os_str().is_empty())
2404            .unwrap_or_else(|| Path::new("."));
2405        fs::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf())
2406    }
2407
2408    /// Errors go to stderr instead of ending the loop, so one bad query doesn't stop watching.
2409    #[cfg(feature = "watch")]
2410    fn run_once_watch(&self) {
2411        if let Err(err) = self.execute_once() {
2412            if let Some(code) = Self::halt_exit_code(&err) {
2413                std::process::exit(code);
2414            }
2415            self.report_error(&err);
2416        }
2417    }
2418
2419    /// A `[filename]` tag for status lines, so they're never mistaken for query output
2420    /// interleaved on the same terminal. Colorized when possible, plain text otherwise.
2421    #[cfg(feature = "watch")]
2422    fn watch_badge(watch_paths: &[PathBuf]) -> String {
2423        let names: Vec<String> = watch_paths
2424            .iter()
2425            .map(|p| {
2426                p.file_name()
2427                    .map_or_else(|| p.display().to_string(), |n| n.to_string_lossy().to_string())
2428            })
2429            .collect();
2430        let label = if names.len() <= 3 {
2431            names.join(",")
2432        } else {
2433            format!("{} files", names.len())
2434        };
2435        let tag = format!("[{label}]");
2436
2437        if io::stderr().is_terminal() && !Self::is_no_color() {
2438            tag.cyan().bold().to_string()
2439        } else {
2440            tag
2441        }
2442    }
2443
2444    #[cfg(feature = "watch")]
2445    fn watch_divider() -> String {
2446        let line = "─".repeat(40);
2447        if io::stderr().is_terminal() && !Self::is_no_color() {
2448            line.dimmed().to_string()
2449        } else {
2450            line
2451        }
2452    }
2453
2454    /// Watches each target's parent dir (not the file itself) and filters by filename,
2455    /// since editors that save via delete-and-rename would break a watch on the file's inode.
2456    #[cfg(feature = "watch")]
2457    fn run_watch(&self) -> miette::Result<()> {
2458        use notify::Watcher as _;
2459
2460        self.ensure_watch_supported()?;
2461
2462        let watch_paths = self.watch_targets()?;
2463        let targets: rustc_hash::FxHashSet<(PathBuf, OsString)> = watch_paths
2464            .iter()
2465            .filter_map(|p| {
2466                p.file_name()
2467                    .map(|name| (Self::canonical_parent_dir(p), name.to_os_string()))
2468            })
2469            .collect();
2470
2471        eprintln!(
2472            "{} Watching for changes. Press Ctrl-C to stop.",
2473            Self::watch_badge(&watch_paths)
2474        );
2475        self.run_once_watch();
2476
2477        let (tx, rx) = mpsc::channel::<PathBuf>();
2478        let mut watcher = notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
2479            if let Ok(event) = res {
2480                for path in event.paths {
2481                    let _ = tx.send(path);
2482                }
2483            }
2484        })
2485        .map_err(|e| miette!("Failed to start file watcher: {e}"))?;
2486
2487        let mut watched_dirs = BTreeSet::new();
2488        for path in &watch_paths {
2489            let dir = Self::canonical_parent_dir(path);
2490            if watched_dirs.insert(dir.clone()) {
2491                watcher
2492                    .watch(&dir, notify::RecursiveMode::NonRecursive)
2493                    .map_err(|e| miette!("Failed to watch {}: {e}", dir.display()))?;
2494            }
2495        }
2496
2497        loop {
2498            let Ok(first) = rx.recv() else {
2499                return Ok(());
2500            };
2501
2502            // Debounce: a single save often fires several fs events in quick
2503            // succession (write + rename, etc.); coalesce them into one re-run.
2504            let mut changed = vec![first];
2505            while let Ok(path) = rx.recv_timeout(Duration::from_millis(150)) {
2506                changed.push(path);
2507            }
2508
2509            let relevant = changed.iter().any(|p| {
2510                p.file_name()
2511                    .is_some_and(|name| targets.contains(&(Self::canonical_parent_dir(p), name.to_os_string())))
2512            });
2513
2514            if relevant {
2515                eprintln!(
2516                    "\n{}\n{} Changed, re-running...",
2517                    Self::watch_divider(),
2518                    Self::watch_badge(&watch_paths)
2519                );
2520                self.run_once_watch();
2521            }
2522        }
2523    }
2524
2525    fn process_batch(&self) -> Result<(), miette::Error> {
2526        let query = self.get_query()?;
2527        let files = self.read_contents()?;
2528
2529        if self.output.count {
2530            return self.process_batch_count(&query, &files);
2531        }
2532
2533        if self.input.eval_all {
2534            return self.execute_eval_all(&query, &files);
2535        }
2536
2537        // Keep --append sequential: parallel files racing the same read-then-rename
2538        // append could clobber each other.
2539        if files.len() > self.parallel_threshold && !self.output.append {
2540            // `CompiledProgram` uses `Rc`, so each worker keeps its own engine and programs.
2541            // `init` runs once per rayon split, not per worker; coarse splits keep it to a few per worker.
2542            let min_len = files.len().div_ceil(rayon::current_num_threads() * 4);
2543            files.par_iter().with_min_len(min_len).try_for_each_init(
2544                || Ok::<_, miette::Error>((self.create_engine()?, ProgramCache::default())),
2545                |worker, (file, content)| {
2546                    let (engine, cache) = worker
2547                        .as_mut()
2548                        .map_err(|error| miette!("Failed to prepare parallel query worker: {error}"))?;
2549                    self.execute(engine, cache, &query, file, content)
2550                },
2551            )?;
2552        } else {
2553            let mut engine = self.create_engine()?;
2554            let mut cache = ProgramCache::default();
2555            for (file, content) in &files {
2556                self.execute(&mut engine, &mut cache, &query, file, content)?;
2557            }
2558        }
2559
2560        Ok(())
2561    }
2562
2563    /// `__FILE__`-family vars aren't set here: no single file is "current" once combined.
2564    fn execute_eval_all(&self, query: &str, files: &[(Option<PathBuf>, ContentData)]) -> miette::Result<()> {
2565        if !self.all_files_share_program(files) {
2566            return Err(miette!(
2567                "--eval-all requires all input files to use the same input format"
2568            ));
2569        }
2570
2571        let first_file = files.first().map(|(f, _)| f).unwrap_or(&None);
2572        let mut engine = self.create_engine()?;
2573
2574        let mut combined_input = Vec::new();
2575        for (file, content) in files {
2576            combined_input.extend(self.resolve_input(file, content)?);
2577        }
2578
2579        let is_grep = matches!(self.resolved_output_format(), OutputFormat::Grep);
2580        let grep_input: Option<Vec<mq_lang::RuntimeValue>> = is_grep.then(|| combined_input.clone());
2581
2582        #[cfg(feature = "vm-profile")]
2583        let vm_profile = self.vm_profile.then(mq_lang::vm_profile::VmProfileScope::start);
2584        let mut cache = ProgramCache::default();
2585        let index = self.program_index(&mut engine, &mut cache, query, first_file)?;
2586        let runtime_values = engine
2587            .eval_compiled(&cache.programs[index].1, combined_input.into_iter())
2588            .map_err(|error| *error)?;
2589
2590        #[cfg(feature = "vm-profile")]
2591        self.emit_vm_profile(vm_profile, &None);
2592
2593        self.emit_results(runtime_values, grep_input, &None)
2594    }
2595
2596    /// Runs `program` on one input, with any file-scoped globals already installed.
2597    fn run_program(
2598        &self,
2599        engine: &mut mq_lang::DefaultEngine,
2600        program: &mq_lang::CompiledProgram,
2601        separator: Option<&mq_lang::CompiledProgram>,
2602        file: &Option<PathBuf>,
2603        content: &ContentData,
2604    ) -> miette::Result<()> {
2605        let input = self.resolve_input(file, content)?;
2606        let is_grep = matches!(self.resolved_output_format(), OutputFormat::Grep);
2607        let grep_input: Option<Vec<mq_lang::RuntimeValue>> = is_grep.then(|| input.clone());
2608
2609        #[cfg(feature = "vm-profile")]
2610        let vm_profile = self.vm_profile.then(mq_lang::vm_profile::VmProfileScope::start);
2611
2612        let runtime_values = if self.output.update {
2613            let results = engine
2614                .eval_compiled(program, input.clone().into_iter())
2615                .map_err(|e| *e)?;
2616            self.apply_update(input, results)?
2617        } else {
2618            engine.eval_compiled(program, input.into_iter()).map_err(|e| *e)?
2619        };
2620
2621        #[cfg(feature = "vm-profile")]
2622        self.emit_vm_profile(vm_profile, file);
2623
2624        if self.output.update && self.output.diff {
2625            return self.emit_diff(&runtime_values, file, content);
2626        }
2627
2628        if let Some(separator) = separator {
2629            let separator = engine
2630                .eval_compiled(
2631                    separator,
2632                    vec![mq_lang::RuntimeValue::String(Shared::new("".to_string()))].into_iter(),
2633                )
2634                .map_err(|e| *e)?;
2635            self.print(separator)?;
2636        }
2637
2638        self.emit_results(runtime_values, grep_input, file)
2639    }
2640
2641    fn count_file(
2642        &self,
2643        engine: &mut mq_lang::DefaultEngine,
2644        cache: &mut ProgramCache,
2645        query: &str,
2646        file: &Option<PathBuf>,
2647        content: &ContentData,
2648    ) -> miette::Result<usize> {
2649        self.set_file_vars(engine, cache, file.as_deref());
2650        let index = self.program_index(engine, cache, query, file)?;
2651        #[cfg(feature = "vm-profile")]
2652        let vm_profile = self.vm_profile.then(mq_lang::vm_profile::VmProfileScope::start);
2653        let input = self.resolve_input(file, content)?;
2654        let runtime_values = engine
2655            .eval_compiled(&cache.programs[index].1, input.into_iter())
2656            .map_err(|error| *error)?;
2657        #[cfg(feature = "vm-profile")]
2658        self.emit_vm_profile(vm_profile, file);
2659        Ok(self.output.paginate(runtime_values.compact()).len())
2660    }
2661
2662    #[cfg(feature = "vm-profile")]
2663    fn emit_vm_profile(&self, scope: Option<mq_lang::vm_profile::VmProfileScope>, file: &Option<PathBuf>) {
2664        let Some(scope) = scope else {
2665            return;
2666        };
2667        let target = file
2668            .as_ref()
2669            .map_or_else(|| "stdin".to_string(), |path| path.display().to_string());
2670        eprintln!("Tarn VM profile ({target})\n{}", scope.finish());
2671    }
2672
2673    fn process_batch_count(&self, query: &str, files: &[(Option<PathBuf>, ContentData)]) -> miette::Result<()> {
2674        let multiple_files = files.len() > 1;
2675        let mut total = 0usize;
2676        let mut engine = self.create_engine()?;
2677        let mut cache = ProgramCache::default();
2678
2679        if self.quiet {
2680            for (file, content) in files {
2681                self.count_file(&mut engine, &mut cache, query, file, content)?;
2682            }
2683            return Ok(());
2684        }
2685
2686        let mut handle = OutputSink::open(
2687            &self.output.output_file,
2688            self.output.atomic_output,
2689            self.output.unbuffered,
2690            self.clobber_mode(),
2691            &self.output.output_claimed,
2692        )?;
2693
2694        for (file, content) in files {
2695            let count = self.count_file(&mut engine, &mut cache, query, file, content)?;
2696            total += count;
2697            if multiple_files {
2698                let name = file
2699                    .as_ref()
2700                    .map(|p| p.display().to_string())
2701                    .unwrap_or_else(|| "(stdin)".to_string());
2702                Self::write_ignore_pipe(&mut handle, format!("{}: {}\n", name, count).as_bytes())?;
2703            }
2704        }
2705
2706        if multiple_files {
2707            Self::write_ignore_pipe(&mut handle, format!("total: {}\n", total).as_bytes())?;
2708        } else {
2709            Self::write_ignore_pipe(&mut handle, format!("{}\n", total).as_bytes())?;
2710        }
2711
2712        handle.finish()?;
2713
2714        Ok(())
2715    }
2716
2717    fn process_streaming(&self) -> miette::Result<()> {
2718        if self.is_binary_format() {
2719            return Err(miette!(
2720                "Streaming mode is not supported for binary input formats (bytes, cbor)"
2721            ));
2722        }
2723        let query = self.get_query()?;
2724        let mut engine = self.create_engine()?;
2725        // A stream contains many independent inputs but normally only one effective query and
2726        // one set of file globals per file. Keep both pieces of state outside the per-line hot
2727        // path so short queries do not spend their time allocating query strings or recreating
2728        // `__FILE__` values.
2729        let mut cache = ProgramCache::default();
2730        let mut program_index = 0;
2731        let mut active_file: Option<Option<PathBuf>> = None;
2732
2733        self.process_lines(|file, line| {
2734            let file_changed = active_file
2735                .as_ref()
2736                .is_none_or(|current_file| current_file.as_ref() != file);
2737            if file_changed {
2738                active_file = Some(file.cloned());
2739                let current_file = active_file
2740                    .as_ref()
2741                    .ok_or_else(|| miette!("streaming input did not retain its active file"))?;
2742                self.set_file_vars(&mut engine, &mut cache, current_file.as_deref());
2743
2744                program_index = self.program_index(&mut engine, &mut cache, &query, current_file)?;
2745            }
2746            let current_file = active_file
2747                .as_ref()
2748                .ok_or_else(|| miette!("streaming input did not select an active file"))?;
2749            self.run_program(
2750                &mut engine,
2751                &cache.programs[program_index].1,
2752                None,
2753                current_file,
2754                &line.into(),
2755            )
2756        })
2757    }
2758
2759    fn process_lines<F>(&self, mut process: F) -> miette::Result<()>
2760    where
2761        F: FnMut(Option<&PathBuf>, String) -> miette::Result<()>,
2762    {
2763        // If files are specified, process each file line by line
2764        if let Some(files) = self.resolved_files()? {
2765            for file in &files {
2766                if Self::is_stdin_path(file) {
2767                    for line_result in io::BufReader::new(io::stdin().lock()).lines() {
2768                        process(None, line_result.into_diagnostic()?)?;
2769                    }
2770                    continue;
2771                }
2772                let file_handle = fs::File::open(file)
2773                    .into_diagnostic()
2774                    .wrap_err_with(|| format!("failed to read `{}`", file.display()))?;
2775                let reader = io::BufReader::new(file_handle);
2776                for line_result in reader.lines() {
2777                    let line = line_result.into_diagnostic()?;
2778                    process(Some(file), line)?;
2779                }
2780            }
2781        } else {
2782            // Otherwise, process stdin line by line
2783            let stdin = io::stdin();
2784            let reader = io::BufReader::new(stdin.lock());
2785            for line_result in reader.lines() {
2786                let line = line_result.into_diagnostic()?;
2787                process(None, line)?;
2788            }
2789        }
2790        Ok(())
2791    }
2792
2793    fn is_binary_format(&self) -> bool {
2794        matches!(
2795            self.explicit_input_format(),
2796            Some(InputFormat::Bytes) | Some(InputFormat::Cbor)
2797        )
2798    }
2799
2800    fn needs_binary_read_for_file(&self, file: &Path) -> bool {
2801        self.explicit_input_format()
2802            .map(|fmt| fmt.needs_binary_read())
2803            .unwrap_or_else(|| {
2804                let ext = file.extension().unwrap_or_default().to_string_lossy().to_lowercase();
2805                InputFormat::from_extension(&ext).needs_binary_read()
2806            })
2807    }
2808
2809    /// Expands glob patterns (`*`, `?`, `[`) that don't exist as literal paths, so `mq`
2810    /// behaves the same on shells that don't expand globs themselves (e.g. Windows `cmd.exe`).
2811    fn expand_glob_patterns(files: &[PathBuf]) -> miette::Result<Vec<PathBuf>> {
2812        let mut expanded = Vec::with_capacity(files.len());
2813
2814        for file in files {
2815            let pattern = file.to_string_lossy();
2816
2817            if file.exists() || !pattern.contains(['*', '?', '[']) {
2818                expanded.push(file.clone());
2819                continue;
2820            }
2821
2822            let matches = glob::glob(&pattern)
2823                .map_err(|e| miette!("invalid glob pattern `{}`: {}", pattern, e))?
2824                .filter_map(Result::ok)
2825                .collect::<Vec<_>>();
2826
2827            if matches.is_empty() {
2828                expanded.push(file.clone());
2829            } else {
2830                expanded.extend(matches);
2831            }
2832        }
2833
2834        Ok(expanded)
2835    }
2836
2837    fn resolved_files(&self) -> miette::Result<Option<Vec<PathBuf>>> {
2838        let files = self.files.as_deref().map(Self::expand_glob_patterns).transpose()?;
2839        if files
2840            .as_ref()
2841            .is_some_and(|f| f.iter().filter(|p| Self::is_stdin_path(p)).count() > 1)
2842        {
2843            return Err(miette!("`-` (stdin) can be specified only once"));
2844        }
2845        Ok(files)
2846    }
2847
2848    /// Returns `true` if `path` is `-`, which refers to stdin.
2849    fn is_stdin_path(path: &Path) -> bool {
2850        path == Path::new("-")
2851    }
2852
2853    fn read_stdin(&self) -> miette::Result<ContentData> {
2854        if self.is_binary_format() {
2855            let mut buf = Vec::new();
2856            io::stdin().read_to_end(&mut buf).into_diagnostic()?;
2857            Ok(buf.into())
2858        } else {
2859            let mut input = String::new();
2860            io::stdin().read_to_string(&mut input).into_diagnostic()?;
2861            Ok(input.into())
2862        }
2863    }
2864
2865    fn read_files_content(&self, files: &[PathBuf]) -> miette::Result<Vec<(Option<PathBuf>, ContentData)>> {
2866        files
2867            .iter()
2868            .map(|file| {
2869                if Self::is_stdin_path(file) {
2870                    return Ok((None, self.read_stdin()?));
2871                }
2872                let content = if InputFormat::is_gzip_path(file) {
2873                    self.read_gzip_file(file)?
2874                } else {
2875                    let read = if self.needs_binary_read_for_file(file) {
2876                        fs::read(file).map(ContentData::from)
2877                    } else {
2878                        fs::read_to_string(file).map(ContentData::from)
2879                    };
2880                    read.into_diagnostic()
2881                        .wrap_err_with(|| format!("failed to read `{}`", file.display()))?
2882                };
2883                Ok((Some(file.clone()), content))
2884            })
2885            .collect()
2886    }
2887
2888    fn read_gzip_file(&self, file: &Path) -> miette::Result<ContentData> {
2889        let raw = fs::read(file)
2890            .into_diagnostic()
2891            .wrap_err_with(|| format!("failed to read `{}`", file.display()))?;
2892        let mut decompressed = Vec::new();
2893        flate2::read::GzDecoder::new(&raw[..])
2894            .read_to_end(&mut decompressed)
2895            .into_diagnostic()?;
2896
2897        let fmt = self
2898            .explicit_input_format()
2899            .unwrap_or_else(|| InputFormat::from_path(file));
2900        if fmt.needs_binary_read() {
2901            Ok(ContentData::Bytes(decompressed))
2902        } else {
2903            String::from_utf8(decompressed).map(ContentData::Text).map_err(|e| {
2904                miette!(
2905                    "{} does not contain valid UTF-8 after decompression: {}",
2906                    file.display(),
2907                    e
2908                )
2909            })
2910        }
2911    }
2912
2913    fn read_contents(&self) -> miette::Result<Vec<(Option<PathBuf>, ContentData)>> {
2914        if matches!(self.explicit_input_format(), Some(InputFormat::Null)) {
2915            return Ok(vec![(None, ContentData::empty())]);
2916        }
2917
2918        self.resolved_files()?
2919            .map(|files| self.read_files_content(&files))
2920            .unwrap_or_else(|| {
2921                if io::stdin().is_terminal() {
2922                    return Ok(vec![(None, ContentData::empty())]);
2923                }
2924
2925                Ok(vec![(None, self.read_stdin()?)])
2926            })
2927    }
2928
2929    /// Returns `true` if the `NO_COLOR` environment variable is set and non-empty.
2930    fn is_no_color() -> bool {
2931        std::env::var("NO_COLOR").is_ok_and(|v| !v.is_empty())
2932    }
2933
2934    #[inline(always)]
2935    fn write_ignore_pipe<W: Write>(handle: &mut W, data: &[u8]) -> miette::Result<()> {
2936        match handle.write_all(data) {
2937            Ok(()) => Ok(()),
2938            Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => Ok(()),
2939            Err(e) => Err(miette!(e)),
2940        }
2941    }
2942
2943    /// Recursively collects Markdown nodes from a `RuntimeValue`.
2944    fn collect_markdown_nodes(value: &mq_lang::RuntimeValue, nodes: &mut Vec<mq_markdown::Node>) {
2945        match value {
2946            mq_lang::RuntimeValue::Markdown(node, _) => nodes.push((**node).clone()),
2947            mq_lang::RuntimeValue::Array(items) => {
2948                for item in items.iter() {
2949                    Self::collect_markdown_nodes(item, nodes);
2950                }
2951            }
2952            _ => {}
2953        }
2954    }
2955
2956    /// Returns `true` if the dict is a known expandable typed dict (has `type: :symbol`).
2957    fn is_typed_dict(map: &DictMap) -> bool {
2958        let type_key = mq_lang::Ident::new("type");
2959        matches!(
2960            map.get(&type_key),
2961            Some(mq_lang::RuntimeValue::Symbol(s)) if s.resolve_with(|s| matches!(s, "section" | "table"))
2962        )
2963    }
2964
2965    /// Expands a typed dict (one with `type: :symbol`) into Markdown nodes.
2966    ///
2967    /// Returns `None` if the dict is not a known expandable type.
2968    /// To add support for a new type, add a match arm for the type name.
2969    fn expand_typed_dict(map: &DictMap) -> Option<Vec<mq_markdown::Node>> {
2970        let type_key = mq_lang::Ident::new("type");
2971        match map.get(&type_key) {
2972            Some(mq_lang::RuntimeValue::Symbol(s)) => match s.to_string().as_str() {
2973                "section" => {
2974                    let mut nodes = Vec::new();
2975                    if let Some(header) = map.get(&mq_lang::Ident::new("header")) {
2976                        Self::collect_markdown_nodes(header, &mut nodes);
2977                    }
2978                    if let Some(children) = map.get(&mq_lang::Ident::new("children")) {
2979                        Self::collect_markdown_nodes(children, &mut nodes);
2980                    }
2981                    Some(nodes)
2982                }
2983                "table" => {
2984                    // Reconstruct table nodes in the same order as table::to_markdown():
2985                    // header cells + align row + flattened data rows
2986                    let mut nodes = Vec::new();
2987                    if let Some(header) = map.get(&mq_lang::Ident::new("header")) {
2988                        Self::collect_markdown_nodes(header, &mut nodes);
2989                    }
2990                    if let Some(align) = map.get(&mq_lang::Ident::new("align")) {
2991                        Self::collect_markdown_nodes(align, &mut nodes);
2992                    }
2993                    if let Some(rows) = map.get(&mq_lang::Ident::new("rows")) {
2994                        Self::collect_markdown_nodes(rows, &mut nodes);
2995                    }
2996                    Some(nodes)
2997                }
2998                // To add a new expandable type: add a match arm here.
2999                _ => None,
3000            },
3001            _ => None,
3002        }
3003    }
3004
3005    /// Returns true if a RuntimeValue is considered "falsy" for --exit-status.
3006    /// Mirrors jq's definition (false and null are falsy) but also treats
3007    /// empty values as falsy: `select()` on markdown input doesn't drop
3008    /// non-matching nodes, it replaces them with an empty `Markdown` value,
3009    /// so a plain `None`/`Boolean(false)` check alone would never observe
3010    /// the common "no match" case.
3011    fn is_falsy(value: &mq_lang::RuntimeValue) -> bool {
3012        matches!(value, mq_lang::RuntimeValue::Boolean(false)) || value.is_empty()
3013    }
3014
3015    /// Converts a `RuntimeValue` into a list of Markdown nodes.
3016    fn runtime_value_to_nodes(runtime_value: &mq_lang::RuntimeValue) -> Vec<mq_markdown::Node> {
3017        match runtime_value {
3018            mq_lang::RuntimeValue::Markdown(node, _) => vec![(**node).clone()],
3019            mq_lang::RuntimeValue::Dict(map) => {
3020                Self::expand_typed_dict(map).unwrap_or_else(|| vec![runtime_value.to_string().into()])
3021            }
3022            mq_lang::RuntimeValue::Array(items) => {
3023                let has_expandable = items.iter().any(|v| match v {
3024                    mq_lang::RuntimeValue::Markdown(_, _) => true,
3025                    mq_lang::RuntimeValue::Dict(m) => Self::is_typed_dict(m),
3026                    _ => false,
3027                });
3028                if has_expandable {
3029                    items.iter().flat_map(Self::runtime_value_to_nodes).collect()
3030                } else if items.is_empty() {
3031                    vec![]
3032                } else {
3033                    vec![runtime_value.to_string().into()]
3034                }
3035            }
3036            _ => vec![runtime_value.to_string().into()],
3037        }
3038    }
3039
3040    /// Clears position information from a Markdown value's node (recursively);
3041    /// other value kinds are passed through unchanged. Used by `--no-position`.
3042    fn strip_markdown_position(mut value: mq_lang::RuntimeValue) -> mq_lang::RuntimeValue {
3043        value.strip_positions();
3044        value
3045    }
3046
3047    fn build_markdown(&self, runtime_values: &[mq_lang::RuntimeValue]) -> mq_markdown::Markdown {
3048        let mut markdown =
3049            mq_markdown::Markdown::new(runtime_values.iter().flat_map(Self::runtime_value_to_nodes).collect());
3050        markdown.set_options(mq_markdown::RenderOptions {
3051            list_style: match self.output.list_style.clone() {
3052                ListStyle::Dash => mq_markdown::ListStyle::Dash,
3053                ListStyle::Plus => mq_markdown::ListStyle::Plus,
3054                ListStyle::Star => mq_markdown::ListStyle::Star,
3055            },
3056            link_title_style: match self.output.link_title_style.clone() {
3057                LinkTitleStyle::Double => mq_markdown::TitleSurroundStyle::Double,
3058                LinkTitleStyle::Single => mq_markdown::TitleSurroundStyle::Single,
3059                LinkTitleStyle::Paren => mq_markdown::TitleSurroundStyle::Paren,
3060            },
3061            link_url_style: match self.output.link_url_style.clone() {
3062                LinkUrlStyle::None => mq_markdown::UrlSurroundStyle::None,
3063                LinkUrlStyle::Angle => mq_markdown::UrlSurroundStyle::Angle,
3064            },
3065        });
3066        markdown
3067    }
3068
3069    /// Renders `runtime_values` to a byte buffer without writing anywhere.
3070    /// `emit_diff` always passes `colorize: false` — it needs plain text to diff
3071    /// against the uncolored original, and colors the diff lines itself.
3072    fn render(&self, runtime_values: &[mq_lang::RuntimeValue], colorize: bool) -> miette::Result<Vec<u8>> {
3073        let mut buf = Vec::new();
3074
3075        match self.resolved_output_format() {
3076            OutputFormat::Raw => {
3077                for value in runtime_values {
3078                    match value {
3079                        mq_lang::RuntimeValue::Bytes(b) => buf.extend_from_slice(b),
3080                        _ => buf.extend_from_slice(value.to_string().as_bytes()),
3081                    }
3082                }
3083            }
3084            OutputFormat::Json => {
3085                let theme = colorize.then(mq_markdown::ColorTheme::from_env);
3086                let json_str = crate::output::json::runtime_values_to_json(
3087                    runtime_values,
3088                    theme.as_ref(),
3089                    self.output.compact,
3090                    &self.output.json_indent_unit(),
3091                )?;
3092                buf.extend_from_slice(json_str.as_bytes());
3093                if !json_str.ends_with('\n') {
3094                    buf.push(b'\n');
3095                }
3096            }
3097            OutputFormat::Html => {
3098                let markdown = self.build_markdown(runtime_values);
3099                buf.extend_from_slice(markdown.to_html().as_bytes());
3100            }
3101            OutputFormat::Text => {
3102                let markdown = self.build_markdown(runtime_values);
3103                buf.extend_from_slice(markdown.to_text().as_bytes());
3104            }
3105            OutputFormat::Markdown if colorize => {
3106                let markdown = self.build_markdown(runtime_values);
3107                let theme = mq_markdown::ColorTheme::from_env();
3108                buf.extend_from_slice(markdown.to_colored_string_with_theme(&theme).as_bytes());
3109            }
3110            OutputFormat::Markdown => {
3111                let markdown = self.build_markdown(runtime_values);
3112                buf.extend_from_slice(markdown.to_string().as_bytes());
3113            }
3114            OutputFormat::Table => {
3115                let theme = colorize.then(mq_markdown::ColorTheme::from_env);
3116                let table = crate::output::table::runtime_values_to_table(runtime_values, theme.as_ref());
3117                buf.extend_from_slice(format!("{}\n", table).as_bytes());
3118            }
3119            OutputFormat::Grep => {
3120                let markdown = self.build_markdown(runtime_values);
3121                buf.extend_from_slice(markdown.to_string().as_bytes());
3122            }
3123            OutputFormat::Gron => {
3124                let gron_str = crate::output::gron::runtime_values_to_gron(runtime_values);
3125                buf.extend_from_slice(gron_str.as_bytes());
3126            }
3127            OutputFormat::Csv => {
3128                let theme = colorize.then(mq_markdown::ColorTheme::from_env);
3129                let csv_str = crate::output::csv::runtime_values_to_csv(runtime_values, theme.as_ref())?;
3130                buf.extend_from_slice(csv_str.as_bytes());
3131            }
3132            OutputFormat::Toml => {
3133                let toml_str = crate::output::toml::runtime_values_to_toml(runtime_values)?;
3134                buf.extend_from_slice(toml_str.as_bytes());
3135            }
3136            OutputFormat::Toon => {
3137                let toon_str = crate::output::toon::runtime_values_to_toon(runtime_values)?;
3138                buf.extend_from_slice(toon_str.as_bytes());
3139            }
3140            OutputFormat::Xml => {
3141                let (indent_char, indent_size) = self.output.xml_indent();
3142                let xml_str = crate::output::xml::runtime_values_to_xml(runtime_values, indent_char, indent_size)?;
3143                buf.extend_from_slice(xml_str.as_bytes());
3144            }
3145            OutputFormat::Yaml => {
3146                let yaml_str = crate::output::yaml::runtime_values_to_yaml(runtime_values)?;
3147                buf.extend_from_slice(yaml_str.as_bytes());
3148            }
3149            OutputFormat::Shell => {
3150                let shell_str = crate::output::shell::runtime_values_to_shell(runtime_values);
3151                buf.extend_from_slice(shell_str.as_bytes());
3152            }
3153            OutputFormat::None => {}
3154        }
3155
3156        Ok(buf)
3157    }
3158
3159    fn print(&self, runtime_values: mq_lang::RuntimeValues) -> miette::Result<()> {
3160        let stripped_values: Option<Vec<mq_lang::RuntimeValue>> = self.output.no_position.then(|| {
3161            runtime_values
3162                .values()
3163                .iter()
3164                .cloned()
3165                .map(Self::strip_markdown_position)
3166                .collect()
3167        });
3168        let runtime_values: &[mq_lang::RuntimeValue] = stripped_values.as_deref().unwrap_or(runtime_values.values());
3169
3170        // Track truthy output for --exit-status.
3171        if self.output.exit_status && runtime_values.iter().any(|v| !Self::is_falsy(v)) {
3172            HAD_TRUTHY_OUTPUT.store(true, Ordering::Relaxed);
3173        }
3174
3175        if self.quiet {
3176            return Ok(());
3177        }
3178
3179        let mut handle = OutputSink::open(
3180            &self.output.output_file,
3181            self.output.atomic_output,
3182            self.output.unbuffered,
3183            self.clobber_mode(),
3184            &self.output.output_claimed,
3185        )?;
3186
3187        let colorize = self.output.color_output && !Self::is_no_color();
3188        let buf = self.render(runtime_values, colorize)?;
3189        Self::write_ignore_pipe(&mut handle, &buf)?;
3190        handle.finish()?;
3191
3192        Ok(())
3193    }
3194
3195    /// Renders what `--update` would print and diffs it against the original input.
3196    fn emit_diff(
3197        &self,
3198        runtime_values: &mq_lang::RuntimeValues,
3199        file: &Option<PathBuf>,
3200        content: &ContentData,
3201    ) -> miette::Result<()> {
3202        let original = content.as_str().unwrap_or("");
3203        let rendered = self.render(runtime_values.values(), false)?;
3204        let rendered = String::from_utf8_lossy(&rendered);
3205
3206        if original != rendered {
3207            HAD_DIFF.store(true, Ordering::Relaxed);
3208            self.print_unified_diff(original, &rendered, file)?;
3209        }
3210
3211        Ok(())
3212    }
3213
3214    /// Prints a unified diff of `original` vs `rendered` to stdout (or `-o`), headed
3215    /// by the file path (or `<stdin>` when there is none). Colorizes `+`/`-`/`@@`
3216    /// lines when `-C`/`--color-output` is set and `NO_COLOR` isn't.
3217    fn print_unified_diff(&self, original: &str, rendered: &str, file: &Option<PathBuf>) -> miette::Result<()> {
3218        if self.quiet {
3219            return Ok(());
3220        }
3221
3222        let mut handle = OutputSink::open(
3223            &self.output.output_file,
3224            self.output.atomic_output,
3225            self.output.unbuffered,
3226            self.clobber_mode(),
3227            &self.output.output_claimed,
3228        )?;
3229
3230        let label = file
3231            .as_ref()
3232            .map(|f| f.display().to_string())
3233            .unwrap_or_else(|| "<stdin>".to_string());
3234
3235        let diff_text = similar::TextDiff::from_lines(original, rendered)
3236            .unified_diff()
3237            .header(&label, &label)
3238            .to_string();
3239
3240        // Raw ANSI, not `colored::Colorize` — it auto-disables on non-tty stdout, but -C should force color.
3241        let colorize = self.output.color_output && !Self::is_no_color();
3242        let mut out = String::with_capacity(diff_text.len());
3243        for line in diff_text.lines() {
3244            if colorize && line.starts_with('+') && !line.starts_with("+++") {
3245                out.push_str("\x1b[32m");
3246                out.push_str(line);
3247                out.push_str("\x1b[0m");
3248            } else if colorize && line.starts_with('-') && !line.starts_with("---") {
3249                out.push_str("\x1b[31m");
3250                out.push_str(line);
3251                out.push_str("\x1b[0m");
3252            } else if colorize && line.starts_with("@@") {
3253                out.push_str("\x1b[36m");
3254                out.push_str(line);
3255                out.push_str("\x1b[0m");
3256            } else {
3257                out.push_str(line);
3258            }
3259            out.push('\n');
3260        }
3261
3262        Self::write_ignore_pipe(&mut handle, out.as_bytes())?;
3263        handle.finish()?;
3264
3265        Ok(())
3266    }
3267}
3268
3269#[cfg(test)]
3270mod tests {
3271    #[rstest]
3272    #[case("Runtime error: http: network access is disabled", Some("`--allow-net`"))]
3273    #[case(
3274        "http: network access to https://api.example.invalid/x is not allowed (outside the allowed domains)",
3275        Some("`--allow-net=api.example.invalid`")
3276    )]
3277    #[case("read_file: filesystem reads are disabled", Some("`--allow-read`"))]
3278    #[case(
3279        "read of /tmp/a.md is not allowed (outside the allowed paths)",
3280        Some("`--allow-read=/tmp/a.md`")
3281    )]
3282    #[case(
3283        "write of /tmp/a.md is not allowed (outside the allowed paths)",
3284        Some("`--allow-write=/tmp/a.md`")
3285    )]
3286    #[case("filesystem writes are disabled", Some("`--allow-write`"))]
3287    #[case("environment variable access is disabled", Some("`--allow-env`"))]
3288    #[case("access to environment variable `HOME` is not allowed", Some("`--allow-env=HOME`"))]
3289    #[case("process execution is disabled", Some("`--allow-run`"))]
3290    #[case(
3291        "execution of git is not allowed (outside the allowed commands)",
3292        Some("`--allow-run=git`")
3293    )]
3294    #[case("Unexpected token `x`", None)]
3295    fn test_capability_hint(#[case] message: &str, #[case] expected: Option<&str>) {
3296        let hint = Cli::capability_hint(message);
3297        match expected {
3298            Some(flag) => {
3299                let hint = hint.expect("a hint");
3300                assert!(hint.contains(flag), "{hint} should contain {flag}");
3301                assert!(hint.contains("--allow-all"));
3302            }
3303            None => assert!(hint.is_none()),
3304        }
3305    }
3306
3307    use rstest::rstest;
3308    use scopeguard::defer;
3309    use std::io::Write;
3310    use std::{fs::File, path::PathBuf};
3311
3312    use super::*;
3313
3314    fn create_file(name: &str, content: &str) -> (PathBuf, PathBuf) {
3315        let temp_dir = std::env::temp_dir();
3316        let temp_file_path = temp_dir.join(name);
3317        let mut file = File::create(&temp_file_path).expect("Failed to create temp file");
3318        file.write_all(content.as_bytes())
3319            .expect("Failed to write to temp file");
3320
3321        (temp_dir, temp_file_path)
3322    }
3323
3324    #[test]
3325    fn test_cli_null_input() {
3326        let cli = Cli {
3327            input: InputArgs {
3328                program: ProgramArgs {
3329                    input_format: Some(InputFormat::Null),
3330                    ..Default::default()
3331                },
3332                ..Default::default()
3333            },
3334            output: OutputArgs::default(),
3335            commands: None,
3336            query: Some("self".to_string()),
3337            files: None,
3338            ..Cli::default()
3339        };
3340
3341        assert!(cli.run().is_ok());
3342    }
3343
3344    #[test]
3345    fn test_cli_does_not_auto_collect_final_coroutine_output() {
3346        let (_, output_file) = create_file("test_cli_does_not_auto_collect_final_coroutine_output.md", "");
3347        let output_file_cleanup = output_file.clone();
3348        defer! {
3349            if output_file_cleanup.exists() {
3350                std::fs::remove_file(&output_file_cleanup).expect("Failed to delete temporary output file");
3351            }
3352        }
3353
3354        let cli = Cli {
3355            input: InputArgs {
3356                program: ProgramArgs {
3357                    input_format: Some(InputFormat::Null),
3358                    ..Default::default()
3359                },
3360                ..Default::default()
3361            },
3362            output: OutputArgs {
3363                output_file: Some(output_file.clone()),
3364                ..Default::default()
3365            },
3366            commands: None,
3367            query: Some("def source(): yield: 1 | yield: 2 | yield: 3; | source() | map(fn(x): x * 10;)".to_string()),
3368            files: None,
3369            ..Cli::default()
3370        };
3371
3372        cli.run().unwrap();
3373        assert_eq!(std::fs::read_to_string(output_file).unwrap().trim(), "coroutine");
3374    }
3375
3376    #[test]
3377    fn test_cli_explicit_collect_still_materializes_coroutine_output() {
3378        let (_, output_file) = create_file("test_cli_explicit_collect_still_materializes_coroutine_output.md", "");
3379        let output_file_cleanup = output_file.clone();
3380        defer! {
3381            if output_file_cleanup.exists() {
3382                std::fs::remove_file(&output_file_cleanup).expect("Failed to delete temporary output file");
3383            }
3384        }
3385
3386        let cli = Cli {
3387            input: InputArgs {
3388                program: ProgramArgs {
3389                    input_format: Some(InputFormat::Null),
3390                    ..Default::default()
3391                },
3392                ..Default::default()
3393            },
3394            output: OutputArgs {
3395                output_file: Some(output_file.clone()),
3396                ..Default::default()
3397            },
3398            commands: None,
3399            query: Some(
3400                "def source(): yield: 1 | yield: 2 | yield: 3; | source() | map(fn(x): x * 10;) | collect()"
3401                    .to_string(),
3402            ),
3403            files: None,
3404            ..Cli::default()
3405        };
3406
3407        cli.run().unwrap();
3408        assert_eq!(std::fs::read_to_string(output_file).unwrap().trim(), "[10, 20, 30]");
3409    }
3410
3411    #[test]
3412    fn test_cli_limit_does_not_hang_on_infinite_generator() {
3413        // Regression test: previously `mq-run` collected a leftover coroutine before applying
3414        // `--limit`, so an infinite generator hung forever even with `--limit` set. The query
3415        // now returns immediately because the coroutine is never auto-collected.
3416        let (_, output_file) = create_file("test_cli_limit_does_not_hang_on_infinite_generator.md", "");
3417        let output_file_cleanup = output_file.clone();
3418        defer! {
3419            if output_file_cleanup.exists() {
3420                std::fs::remove_file(&output_file_cleanup).expect("Failed to delete temporary output file");
3421            }
3422        }
3423
3424        let cli = Cli {
3425            input: InputArgs {
3426                program: ProgramArgs {
3427                    input_format: Some(InputFormat::Null),
3428                    ..Default::default()
3429                },
3430                ..Default::default()
3431            },
3432            output: OutputArgs {
3433                output_file: Some(output_file.clone()),
3434                limit: Some(3),
3435                ..Default::default()
3436            },
3437            commands: None,
3438            query: Some("def nat(): var i = 0 | while (true): yield: i | i += 1;; | nat()".to_string()),
3439            files: None,
3440            ..Cli::default()
3441        };
3442
3443        cli.run().unwrap();
3444        assert_eq!(std::fs::read_to_string(output_file).unwrap().trim(), "coroutine");
3445    }
3446
3447    #[test]
3448    fn test_cli_does_not_auto_collect_nested_coroutine_output() {
3449        let (_, output_file) = create_file("test_cli_does_not_auto_collect_nested_coroutine_output.md", "");
3450        let output_file_cleanup = output_file.clone();
3451        defer! {
3452            if output_file_cleanup.exists() {
3453                std::fs::remove_file(&output_file_cleanup).expect("Failed to delete temporary output file");
3454            }
3455        }
3456
3457        let cli = Cli {
3458            input: InputArgs {
3459                program: ProgramArgs {
3460                    input_format: Some(InputFormat::Null),
3461                    ..Default::default()
3462                },
3463                ..Default::default()
3464            },
3465            output: OutputArgs {
3466                output_file: Some(output_file.clone()),
3467                ..Default::default()
3468            },
3469            commands: None,
3470            query: Some("def g(): yield: 1 | yield: 2; | [g()]".to_string()),
3471            files: None,
3472            ..Cli::default()
3473        };
3474
3475        cli.run().unwrap();
3476        assert_eq!(std::fs::read_to_string(output_file).unwrap().trim(), "[coroutine]");
3477    }
3478
3479    #[test]
3480    fn test_allow_read_flag_gates_read_file() {
3481        let (_, temp_file_path) = create_file("test_allow_read.md", "hello");
3482        let temp_file_path_clone = temp_file_path.clone();
3483
3484        defer! {
3485            if temp_file_path_clone.exists() {
3486                std::fs::remove_file(&temp_file_path_clone).expect("Failed to delete temp file");
3487            }
3488        }
3489
3490        let query = format!("read_file(\"{}\")", temp_file_path.to_string_lossy());
3491
3492        let blocked_cli = Cli {
3493            input: InputArgs {
3494                program: ProgramArgs {
3495                    input_format: Some(InputFormat::Null),
3496                    ..Default::default()
3497                },
3498                ..Default::default()
3499            },
3500            output: OutputArgs::default(),
3501            commands: None,
3502            query: Some(query.clone()),
3503            files: None,
3504            ..Cli::default()
3505        };
3506        assert!(
3507            blocked_cli.run().is_err(),
3508            "read_file should be blocked without --allow-read"
3509        );
3510
3511        let allowed_cli = Cli {
3512            input: InputArgs {
3513                program: ProgramArgs {
3514                    allow_read: Some(vec![]),
3515                    input_format: Some(InputFormat::Null),
3516                    ..Default::default()
3517                },
3518                ..Default::default()
3519            },
3520            output: OutputArgs::default(),
3521            commands: None,
3522            query: Some(query),
3523            files: None,
3524            ..Cli::default()
3525        };
3526        assert!(allowed_cli.run().is_ok(), "read_file should succeed with --allow-read");
3527    }
3528
3529    #[test]
3530    fn test_allow_run_flag_gates_system() {
3531        let query = "system(\"echo\", [\"hello\"])";
3532
3533        let blocked_cli = Cli {
3534            input: InputArgs {
3535                program: ProgramArgs {
3536                    input_format: Some(InputFormat::Null),
3537                    ..Default::default()
3538                },
3539                ..Default::default()
3540            },
3541            output: OutputArgs::default(),
3542            commands: None,
3543            query: Some(query.to_string()),
3544            files: None,
3545            ..Cli::default()
3546        };
3547        assert!(
3548            blocked_cli.run().is_err(),
3549            "system should be blocked without --allow-run"
3550        );
3551
3552        let allowed_cli = Cli {
3553            input: InputArgs {
3554                program: ProgramArgs {
3555                    allow_run: Some(vec![]),
3556                    input_format: Some(InputFormat::Null),
3557                    ..Default::default()
3558                },
3559                ..Default::default()
3560            },
3561            output: OutputArgs::default(),
3562            commands: None,
3563            query: Some(query.to_string()),
3564            files: None,
3565            ..Cli::default()
3566        };
3567        assert!(allowed_cli.run().is_ok(), "system should succeed with --allow-run");
3568
3569        let restricted_cli = Cli {
3570            input: InputArgs {
3571                program: ProgramArgs {
3572                    allow_run: Some(vec!["ls".to_string()]),
3573                    input_format: Some(InputFormat::Null),
3574                    ..Default::default()
3575                },
3576                ..Default::default()
3577            },
3578            output: OutputArgs::default(),
3579            commands: None,
3580            query: Some(query.to_string()),
3581            files: None,
3582            ..Cli::default()
3583        };
3584        assert!(
3585            restricted_cli.run().is_err(),
3586            "system should be blocked when --allow-run only names other commands"
3587        );
3588    }
3589
3590    #[test]
3591    fn test_allow_env_flag_gates_var_interpolation() {
3592        // SAFETY: no other threads read/write this env var concurrently in this test.
3593        unsafe { std::env::set_var("MQ_ALLOW_ENV_TEST_VAR", "test_value") };
3594        defer! {
3595            unsafe { std::env::remove_var("MQ_ALLOW_ENV_TEST_VAR") };
3596        }
3597
3598        let query = "$MQ_ALLOW_ENV_TEST_VAR";
3599
3600        let blocked_cli = Cli {
3601            input: InputArgs {
3602                program: ProgramArgs {
3603                    input_format: Some(InputFormat::Null),
3604                    ..Default::default()
3605                },
3606                ..Default::default()
3607            },
3608            output: OutputArgs::default(),
3609            commands: None,
3610            query: Some(query.to_string()),
3611            files: None,
3612            ..Cli::default()
3613        };
3614        assert!(blocked_cli.run().is_err(), "$VAR should be blocked without --allow-env");
3615
3616        let allowed_cli = Cli {
3617            input: InputArgs {
3618                program: ProgramArgs {
3619                    allow_env: Some(vec![]),
3620                    input_format: Some(InputFormat::Null),
3621                    ..Default::default()
3622                },
3623                ..Default::default()
3624            },
3625            output: OutputArgs::default(),
3626            commands: None,
3627            query: Some(query.to_string()),
3628            files: None,
3629            ..Cli::default()
3630        };
3631        assert!(allowed_cli.run().is_ok(), "$VAR should succeed with --allow-env");
3632
3633        let restricted_cli = Cli {
3634            input: InputArgs {
3635                program: ProgramArgs {
3636                    allow_env: Some(vec!["MQ_OTHER_VAR".to_string()]),
3637                    input_format: Some(InputFormat::Null),
3638                    ..Default::default()
3639                },
3640                ..Default::default()
3641            },
3642            output: OutputArgs::default(),
3643            commands: None,
3644            query: Some(query.to_string()),
3645            files: None,
3646            ..Cli::default()
3647        };
3648        assert!(
3649            restricted_cli.run().is_err(),
3650            "$VAR should be blocked when --allow-env only names other variables"
3651        );
3652    }
3653
3654    #[test]
3655    fn test_allow_all_flag_grants_every_capability() {
3656        // SAFETY: no other threads read/write this env var concurrently in this test.
3657        unsafe { std::env::set_var("MQ_ALLOW_ALL_TEST_VAR", "test_value") };
3658        defer! {
3659            unsafe { std::env::remove_var("MQ_ALLOW_ALL_TEST_VAR") };
3660        }
3661
3662        let base_cli = |query: &str, allow_all: bool| Cli {
3663            input: InputArgs {
3664                program: ProgramArgs {
3665                    allow_all,
3666                    input_format: Some(InputFormat::Null),
3667                    ..Default::default()
3668                },
3669                ..Default::default()
3670            },
3671            output: OutputArgs::default(),
3672            commands: None,
3673            query: Some(query.to_string()),
3674            files: None,
3675            ..Cli::default()
3676        };
3677
3678        assert!(
3679            base_cli("$MQ_ALLOW_ALL_TEST_VAR", false).run().is_err(),
3680            "$VAR should be blocked without --allow-all"
3681        );
3682        assert!(
3683            base_cli("$MQ_ALLOW_ALL_TEST_VAR", true).run().is_ok(),
3684            "$VAR should succeed with --allow-all"
3685        );
3686
3687        let system_query = "system(\"echo\", [\"hi\"])";
3688        assert!(
3689            base_cli(system_query, false).run().is_err(),
3690            "system should be blocked without --allow-all"
3691        );
3692        assert!(
3693            base_cli(system_query, true).run().is_ok(),
3694            "system should succeed with --allow-all"
3695        );
3696    }
3697
3698    #[rstest]
3699    #[case(&["mq", "--allow-all", "--allow-read=/tmp", "self"])]
3700    #[case(&["mq", "--allow-all", "--allow-write=/tmp", "self"])]
3701    #[case(&["mq", "--allow-all", "--allow-net=example.com", "self"])]
3702    #[case(&["mq", "--allow-all", "--allow-run", "self"])]
3703    #[case(&["mq", "--allow-all", "--allow-env", "self"])]
3704    #[case(&["mq", "--allow-all", "--allow-http-import", "self"])]
3705    fn test_allow_all_conflicts_with_individual_allow_flags(#[case] args: &[&str]) {
3706        assert!(
3707            Cli::try_parse_from(args).is_err(),
3708            "--allow-all should conflict with individual --allow-* flags"
3709        );
3710    }
3711
3712    #[rstest]
3713    #[case(&["mq", "--sandbox=strict", "--allow-read=/tmp", "self"])]
3714    #[case(&["mq", "--sandbox=networked", "--allow-net=example.com", "self"])]
3715    #[case(&["mq", "--sandbox=unsafe", "--allow-run", "self"])]
3716    #[case(&["mq", "--sandbox=unsafe", "--allow-env", "self"])]
3717    #[case(&["mq", "--sandbox=unsafe", "--allow-http-import", "self"])]
3718    #[case(&["mq", "--sandbox=unsafe", "--allow-all", "self"])]
3719    fn test_sandbox_conflicts_with_individual_allow_flags(#[case] args: &[&str]) {
3720        assert!(
3721            Cli::try_parse_from(args).is_err(),
3722            "--sandbox should conflict with --allow-* flags (including --allow-all)"
3723        );
3724    }
3725
3726    #[rstest]
3727    #[case(Some(SandboxProfile::Strict), None, "out.md", false)]
3728    #[case(Some(SandboxProfile::ReadOnly), None, "out.md", false)]
3729    #[case(Some(SandboxProfile::Networked), None, "out.md", false)]
3730    #[case(Some(SandboxProfile::Strict), Some(vec![]), "out.md", true)]
3731    #[case(Some(SandboxProfile::ReadOnly), Some(vec![PathBuf::from("/allowed")]), "/allowed/out.md", true)]
3732    #[case(Some(SandboxProfile::ReadOnly), Some(vec![PathBuf::from("/allowed")]), "/other/out.md", false)]
3733    #[case(Some(SandboxProfile::Unsafe), None, "out.md", true)]
3734    #[case(None, None, "out.md", true)]
3735    fn test_output_file_gated_by_allow_write_under_sandbox(
3736        #[case] sandbox: Option<SandboxProfile>,
3737        #[case] allow_write: Option<Vec<PathBuf>>,
3738        #[case] output: &str,
3739        #[case] expected_ok: bool,
3740    ) {
3741        let mut cli = Cli::default();
3742        cli.input.program.sandbox = sandbox;
3743        cli.input.program.allow_write = allow_write;
3744        cli.output.output_file = Some(PathBuf::from(output));
3745        assert_eq!(cli.check_output_permitted().is_ok(), expected_ok);
3746    }
3747
3748    #[test]
3749    fn test_sandbox_strict_denies_everything() {
3750        let io = ProgramArgs {
3751            sandbox: Some(SandboxProfile::Strict),
3752            ..Default::default()
3753        }
3754        .build_sandboxed_io();
3755
3756        assert!(!io.is_read_allowed());
3757        assert!(!io.is_write_allowed());
3758        assert!(!io.is_net_allowed());
3759        assert!(!io.is_run_allowed());
3760        assert!(!io.is_env_allowed());
3761    }
3762
3763    #[test]
3764    fn test_sandbox_read_only_grants_read_only() {
3765        let io = ProgramArgs {
3766            sandbox: Some(SandboxProfile::ReadOnly),
3767            ..Default::default()
3768        }
3769        .build_sandboxed_io();
3770
3771        assert!(io.is_read_allowed());
3772        assert!(!io.is_write_allowed());
3773        assert!(!io.is_net_allowed());
3774        assert!(!io.is_run_allowed());
3775        assert!(!io.is_env_allowed());
3776    }
3777
3778    #[test]
3779    fn test_sandbox_networked_grants_net_only() {
3780        let io = ProgramArgs {
3781            sandbox: Some(SandboxProfile::Networked),
3782            ..Default::default()
3783        }
3784        .build_sandboxed_io();
3785
3786        assert!(!io.is_read_allowed());
3787        assert!(!io.is_write_allowed());
3788        assert!(io.is_net_allowed());
3789        assert!(!io.is_run_allowed());
3790        assert!(!io.is_env_allowed());
3791    }
3792
3793    #[test]
3794    fn test_sandbox_unsafe_grants_every_capability() {
3795        let io = ProgramArgs {
3796            sandbox: Some(SandboxProfile::Unsafe),
3797            ..Default::default()
3798        }
3799        .build_sandboxed_io();
3800
3801        assert!(io.is_read_allowed());
3802        assert!(io.is_write_allowed());
3803        assert!(io.is_net_allowed());
3804        assert!(io.is_run_allowed());
3805        assert!(io.is_env_allowed());
3806    }
3807
3808    #[cfg(feature = "http-import")]
3809    #[test]
3810    fn test_sandbox_http_import_enablement() {
3811        assert!(!ProgramArgs::default().sandbox_enables_http_import());
3812        for (sandbox, expected) in [
3813            (SandboxProfile::ReadOnly, false),
3814            (SandboxProfile::Networked, true),
3815            (SandboxProfile::Unsafe, true),
3816        ] {
3817            let args = ProgramArgs {
3818                sandbox: Some(sandbox),
3819                ..Default::default()
3820            };
3821            assert_eq!(args.sandbox_enables_http_import(), expected);
3822        }
3823    }
3824
3825    #[cfg(feature = "http-import")]
3826    #[rstest]
3827    #[case(&["mq", "--allow-http-import", "self"], true, &[])]
3828    #[case(&["mq", "--allow-http-import=a.com,github.com/u/r", "self"], true, &["a.com", "github.com/u/r"])]
3829    #[case(&["mq", "--allow-http-import=a.com", "--allow-http-import=b.com", "self"], true, &["a.com", "b.com"])]
3830    #[case(&["mq", "--allowed-domain", "a.com", "self"], false, &["a.com"])]
3831    #[case(&["mq", "--allow-http-import=a.com", "--allowed-domain", "b.com", "self"], true, &["a.com", "b.com"])]
3832    #[case(&["mq", "--allow-all", "--allowed-domain", "a.com", "self"], true, &["a.com"])]
3833    #[case(&["mq", "self"], false, &[])]
3834    fn test_http_import_flags(#[case] args: &[&str], #[case] enabled: bool, #[case] domains: &[&str]) {
3835        let cli = Cli::try_parse_from(args).unwrap();
3836        assert_eq!(cli.input.program.http_import_enabled(), enabled);
3837        assert_eq!(cli.input.program.http_import_domains(), domains);
3838    }
3839
3840    #[cfg(feature = "http-import")]
3841    #[test]
3842    fn test_allow_http_import_conflicts_with_allow_all() {
3843        assert!(Cli::try_parse_from(["mq", "--allow-all", "--allow-http-import=a.com", "self"]).is_err());
3844    }
3845
3846    #[cfg(feature = "http-import")]
3847    #[test]
3848    fn test_http_import_disabled_by_default() {
3849        // No network call is made: resolution fails on the enabled-check before any
3850        // request, so this stays fast and deterministic regardless of connectivity.
3851        let cli = Cli {
3852            input: InputArgs {
3853                program: ProgramArgs {
3854                    input_format: Some(InputFormat::Null),
3855                    ..Default::default()
3856                },
3857                ..Default::default()
3858            },
3859            output: OutputArgs::default(),
3860            commands: None,
3861            query: Some(r#"import "github.com/harehare/lisp""#.to_string()),
3862            files: None,
3863            ..Cli::default()
3864        };
3865
3866        let err = cli.run().unwrap_err();
3867        assert!(
3868            err.to_string().contains("HTTP module imports are disabled"),
3869            "error was: {err}"
3870        );
3871    }
3872
3873    #[cfg(feature = "http-import")]
3874    #[test]
3875    fn test_allowed_domain_alone_does_not_enable_http_import() {
3876        let cli = Cli {
3877            input: InputArgs {
3878                program: ProgramArgs {
3879                    input_format: Some(InputFormat::Null),
3880                    allowed_domains: Some(vec!["example.com".to_string()]),
3881                    ..Default::default()
3882                },
3883                ..Default::default()
3884            },
3885            output: OutputArgs::default(),
3886            commands: None,
3887            query: Some(r#"import "https://example.com/mod.mq""#.to_string()),
3888            files: None,
3889            ..Cli::default()
3890        };
3891
3892        let err = cli.run().unwrap_err();
3893        assert!(
3894            err.to_string().contains("HTTP module imports are disabled"),
3895            "error was: {err}"
3896        );
3897    }
3898
3899    #[cfg(feature = "http-import")]
3900    #[test]
3901    fn test_no_lockfile_conflicts_with_frozen() {
3902        assert!(
3903            Cli::try_parse_from(["mq", "--no-lockfile", "--frozen", "self"]).is_err(),
3904            "--no-lockfile should conflict with --frozen"
3905        );
3906    }
3907
3908    #[rstest]
3909    #[case("0")]
3910    #[case("-1")]
3911    #[case("NaN")]
3912    #[case("inf")]
3913    #[case("1e100")]
3914    #[case("1e19")]
3915    fn test_timeout_rejects_invalid_values(#[case] secs: &str) {
3916        let error = Cli::try_parse_from(["mq", "--timeout", secs, "self"]).unwrap_err();
3917        assert_eq!(error.kind(), clap::error::ErrorKind::ValueValidation);
3918        assert!(error.to_string().contains("--timeout"), "{error}");
3919    }
3920
3921    #[test]
3922    fn test_timeout_parses_fractional_seconds() {
3923        let cli = Cli::try_parse_from(["mq", "--timeout", "0.5", "self"]).unwrap();
3924        assert_eq!(cli.timeout, Some(Duration::from_millis(500)));
3925    }
3926
3927    #[test]
3928    fn test_timeout_aborts_infinite_loop() {
3929        let cli = Cli {
3930            input: InputArgs {
3931                program: ProgramArgs {
3932                    input_format: Some(InputFormat::Null),
3933                    ..Default::default()
3934                },
3935                ..Default::default()
3936            },
3937            output: OutputArgs::default(),
3938            commands: None,
3939            query: Some("while(true): 1;".to_string()),
3940            files: None,
3941            timeout: Some(Duration::from_millis(1)),
3942            ..Cli::default()
3943        };
3944
3945        assert!(cli.run().is_err());
3946    }
3947
3948    #[test]
3949    fn test_timeout_allows_normal_query() {
3950        let cli = Cli {
3951            input: InputArgs {
3952                program: ProgramArgs {
3953                    input_format: Some(InputFormat::Null),
3954                    ..Default::default()
3955                },
3956                ..Default::default()
3957            },
3958            output: OutputArgs::default(),
3959            commands: None,
3960            query: Some("1 + 1".to_string()),
3961            files: None,
3962            timeout: Some(Duration::from_secs(5)),
3963            ..Cli::default()
3964        };
3965
3966        assert!(cli.run().is_ok());
3967    }
3968
3969    #[rstest]
3970    #[case::default(false)]
3971    #[case::enabled(true)]
3972    fn test_stack_trace_flag(#[case] enabled: bool) {
3973        let cli = if enabled {
3974            Cli::try_parse_from(["mq", "--stack-trace", "self"]).unwrap()
3975        } else {
3976            Cli::try_parse_from(["mq", "self"]).unwrap()
3977        };
3978        let error = cli
3979            .create_engine()
3980            .unwrap()
3981            .eval(
3982                "def inner(): 1 / 0; def outer(): inner(); outer()",
3983                std::iter::once(mq_lang::RuntimeValue::None),
3984            )
3985            .unwrap_err()
3986            .to_string();
3987
3988        assert_eq!(error.contains("stack trace:"), enabled);
3989        if enabled {
3990            assert!(error.contains("at inner (1:"), "{error}");
3991        }
3992    }
3993
3994    #[rstest]
3995    #[case::default_is_human(&["mq", "self"], ErrorFormat::Human)]
3996    #[case::explicit_human(&["mq", "--error-format", "human", "self"], ErrorFormat::Human)]
3997    #[case::json(&["mq", "--error-format", "json", "self"], ErrorFormat::Json)]
3998    fn test_error_format_flag(#[case] args: &[&str], #[case] expected: ErrorFormat) {
3999        let cli = Cli::try_parse_from(args).unwrap();
4000        assert_eq!(cli.error_format, expected);
4001    }
4002
4003    #[rstest]
4004    #[case::without_stack_trace(false)]
4005    #[case::with_stack_trace(true)]
4006    fn test_render_error_json(#[case] stack_trace: bool) {
4007        let args: &[&str] = if stack_trace {
4008            &["mq", "--stack-trace", "self"]
4009        } else {
4010            &["mq", "self"]
4011        };
4012        let cli = Cli::try_parse_from(args).unwrap();
4013        let err: mq_lang::Error = *cli
4014            .create_engine()
4015            .unwrap()
4016            .eval("1 / 0", std::iter::once(mq_lang::RuntimeValue::None))
4017            .unwrap_err();
4018        let json = Cli::render_error_json(&err.into()).unwrap();
4019
4020        let value: serde_json::Value = serde_json::from_str(&json).unwrap();
4021        let message = value["message"].as_str().unwrap();
4022        assert!(message.contains("Division by zero"), "{message}");
4023        assert_eq!(message.contains("stack trace:"), stack_trace);
4024    }
4025
4026    #[test]
4027    fn test_cli_raw_input() {
4028        let (_, temp_file_path) = create_file("test1.md", "# test");
4029        let temp_file_path_clone = temp_file_path.clone();
4030
4031        defer! {
4032            if temp_file_path_clone.exists() {
4033                std::fs::remove_file(&temp_file_path_clone).expect("Failed to delete temp file");
4034            }
4035        }
4036
4037        let cli = Cli {
4038            input: InputArgs {
4039                program: ProgramArgs {
4040                    input_format: Some(InputFormat::Text),
4041                    ..Default::default()
4042                },
4043                ..Default::default()
4044            },
4045            output: OutputArgs::default(),
4046            commands: None,
4047            query: Some("self".to_string()),
4048            files: Some(vec![temp_file_path]),
4049            ..Cli::default()
4050        };
4051
4052        assert!(cli.run().is_ok());
4053    }
4054
4055    #[test]
4056    fn test_cli_output_formats() {
4057        let (_, temp_file_path) = create_file("test2.md", "# test");
4058        let temp_file_path_clone = temp_file_path.clone();
4059
4060        defer! {
4061            if temp_file_path_clone.exists() {
4062                std::fs::remove_file(&temp_file_path_clone).expect("Failed to delete temp file");
4063            }
4064        }
4065
4066        for format in [
4067            OutputFormat::Markdown,
4068            OutputFormat::Html,
4069            OutputFormat::Text,
4070            OutputFormat::Table,
4071            OutputFormat::Grep,
4072            OutputFormat::Gron,
4073            OutputFormat::Csv,
4074            OutputFormat::Xml,
4075            OutputFormat::Yaml,
4076            OutputFormat::Toon,
4077            OutputFormat::Shell,
4078        ] {
4079            let cli = Cli {
4080                input: InputArgs::default(),
4081                output: OutputArgs {
4082                    output_format: Some(format.clone()),
4083                    ..Default::default()
4084                },
4085                commands: None,
4086                query: Some("self".to_string()),
4087                files: Some(vec![temp_file_path.clone()]),
4088                ..Cli::default()
4089            };
4090
4091            assert!(cli.run().is_ok());
4092        }
4093    }
4094
4095    #[test]
4096    fn test_cli_list_styles() {
4097        let (_, temp_file_path) = create_file("test3.md", "# test");
4098        let temp_file_path_clone = temp_file_path.clone();
4099
4100        defer! {
4101            if temp_file_path_clone.exists() {
4102                std::fs::remove_file(&temp_file_path_clone).expect("Failed to delete temp file");
4103            }
4104        }
4105
4106        for style in [ListStyle::Dash, ListStyle::Plus, ListStyle::Star] {
4107            let cli = Cli {
4108                input: InputArgs::default(),
4109                output: OutputArgs {
4110                    list_style: style.clone(),
4111                    ..Default::default()
4112                },
4113                commands: None,
4114                query: Some("self".to_string()),
4115                files: Some(vec![temp_file_path.clone()]),
4116                ..Cli::default()
4117            };
4118
4119            assert!(cli.run().is_ok());
4120        }
4121    }
4122
4123    #[test]
4124    fn test_cli_color_output() {
4125        let (_, temp_file_path) = create_file("test_color.md", "# test");
4126        let temp_file_path_clone = temp_file_path.clone();
4127
4128        defer! {
4129            if temp_file_path_clone.exists() {
4130                std::fs::remove_file(&temp_file_path_clone).expect("Failed to delete temp file");
4131            }
4132        }
4133
4134        let cli = Cli {
4135            input: InputArgs::default(),
4136            output: OutputArgs {
4137                color_output: true,
4138                ..Default::default()
4139            },
4140            commands: None,
4141            query: Some("self".to_string()),
4142            files: Some(vec![temp_file_path.clone()]),
4143            ..Cli::default()
4144        };
4145
4146        assert!(cli.run().is_ok());
4147    }
4148
4149    #[test]
4150    fn test_cli_update_flag() {
4151        let (_, temp_file_path) = create_file("test4.md", "# test");
4152        let temp_file_path_clone = temp_file_path.clone();
4153
4154        defer! {
4155            if temp_file_path_clone.exists() {
4156                std::fs::remove_file(&temp_file_path_clone).expect("Failed to delete temp file");
4157            }
4158        }
4159
4160        let cli = Cli {
4161            input: InputArgs::default(),
4162            output: OutputArgs {
4163                update: true,
4164                ..Default::default()
4165            },
4166            commands: None,
4167            query: Some("self".to_string()),
4168            files: Some(vec![temp_file_path]),
4169            ..Cli::default()
4170        };
4171
4172        assert!(cli.run().is_ok());
4173    }
4174
4175    #[test]
4176    fn test_cli_with_module_names() {
4177        let (temp_dir, temp_file_path) = create_file("math.mq", "def math(): 42;");
4178        let (_, temp_md_file_path) = create_file("test.md", "# test");
4179        let temp_md_file_path_clone = temp_md_file_path.clone();
4180
4181        defer! {
4182            if temp_file_path.exists() {
4183                std::fs::remove_file(&temp_file_path).expect("Failed to delete temp file");
4184            }
4185
4186            if temp_md_file_path_clone.exists() {
4187                std::fs::remove_file(&temp_md_file_path_clone).expect("Failed to delete temp file");
4188            }
4189        }
4190
4191        let cli = Cli {
4192            input: InputArgs {
4193                program: ProgramArgs {
4194                    module_names: Some(vec!["math".to_string()]),
4195                    module_directories: Some(vec![temp_dir.clone()]),
4196                    ..Default::default()
4197                },
4198                ..Default::default()
4199            },
4200            output: OutputArgs::default(),
4201            commands: None,
4202            query: Some("math".to_owned()),
4203            files: Some(vec![temp_md_file_path]),
4204            ..Cli::default()
4205        };
4206
4207        assert!(cli.run().is_ok());
4208    }
4209
4210    #[test]
4211    fn test_find_external_commands() {
4212        // find_external_commands searches ~/.local/bin and PATH for mq-* files
4213        let commands = Cli::find_external_commands();
4214        // We can't assert specific commands, but we can check the function works
4215        assert!(commands.iter().all(|cmd| !cmd.is_empty()));
4216    }
4217
4218    #[test]
4219    fn test_get_external_commands_dir() {
4220        // This test checks if the function returns a valid path or None
4221        let dir = Cli::get_external_commands_dir();
4222        if let Some(path) = dir {
4223            assert!(path.ends_with(".local/bin") || path.ends_with(".local\\bin"));
4224        }
4225    }
4226
4227    #[test]
4228    #[cfg(unix)]
4229    fn test_collect_mq_commands_from_dir() {
4230        let temp_dir = std::env::temp_dir().join("mq-collect-test");
4231        fs::create_dir_all(&temp_dir).expect("Failed to create test directory");
4232
4233        defer! {
4234            if temp_dir.exists() {
4235                std::fs::remove_dir_all(&temp_dir).ok();
4236            }
4237        }
4238
4239        // Create test files: mq-foo, mq-bar, a non-mq file, and a non-executable mq file
4240        fs::write(temp_dir.join("mq-foo"), "").expect("Failed to write file");
4241        fs::write(temp_dir.join("mq-bar"), "").expect("Failed to write file");
4242        fs::write(temp_dir.join("other-cmd"), "").expect("Failed to write file");
4243        fs::write(temp_dir.join("mq-noexec"), "").expect("Failed to write file");
4244
4245        #[cfg(unix)]
4246        {
4247            use std::os::unix::fs::PermissionsExt;
4248            // Set executable bit on mq-foo and mq-bar, but not mq-noexec
4249            fs::set_permissions(temp_dir.join("mq-foo"), fs::Permissions::from_mode(0o755))
4250                .expect("Failed to set permissions");
4251            fs::set_permissions(temp_dir.join("mq-bar"), fs::Permissions::from_mode(0o755))
4252                .expect("Failed to set permissions");
4253        }
4254
4255        let mut seen = std::collections::HashSet::new();
4256        Cli::collect_mq_commands_from_dir(&temp_dir, &mut seen);
4257
4258        assert_eq!(seen.len(), 2);
4259        assert!(seen.contains("foo"));
4260        assert!(seen.contains("bar"));
4261        assert!(!seen.contains("other-cmd"));
4262        assert!(!seen.contains("noexec"));
4263    }
4264
4265    #[test]
4266    #[cfg(unix)]
4267    fn test_collect_mq_commands_from_dir_deduplicates() {
4268        let dir1 = std::env::temp_dir().join("mq-dedup-test-1");
4269        let dir2 = std::env::temp_dir().join("mq-dedup-test-2");
4270        fs::create_dir_all(&dir1).expect("Failed to create test directory");
4271        fs::create_dir_all(&dir2).expect("Failed to create test directory");
4272
4273        defer! {
4274            if dir1.exists() {
4275                std::fs::remove_dir_all(&dir1).ok();
4276            }
4277            if dir2.exists() {
4278                std::fs::remove_dir_all(&dir2).ok();
4279            }
4280        }
4281
4282        // Same command in both directories
4283        fs::write(dir1.join("mq-dup"), "").expect("Failed to write file");
4284        fs::write(dir2.join("mq-dup"), "").expect("Failed to write file");
4285        fs::write(dir2.join("mq-unique"), "").expect("Failed to write file");
4286
4287        #[cfg(unix)]
4288        {
4289            use std::os::unix::fs::PermissionsExt;
4290            fs::set_permissions(dir1.join("mq-dup"), fs::Permissions::from_mode(0o755))
4291                .expect("Failed to set permissions");
4292            fs::set_permissions(dir2.join("mq-dup"), fs::Permissions::from_mode(0o755))
4293                .expect("Failed to set permissions");
4294            fs::set_permissions(dir2.join("mq-unique"), fs::Permissions::from_mode(0o755))
4295                .expect("Failed to set permissions");
4296        }
4297
4298        let mut seen = std::collections::HashSet::new();
4299        Cli::collect_mq_commands_from_dir(&dir1, &mut seen);
4300        Cli::collect_mq_commands_from_dir(&dir2, &mut seen);
4301
4302        assert_eq!(seen.len(), 2);
4303        assert!(seen.contains("dup"));
4304        assert!(seen.contains("unique"));
4305    }
4306
4307    #[test]
4308    fn test_collect_mq_commands_from_nonexistent_dir() {
4309        let nonexistent = std::env::temp_dir().join("mq-nonexistent-dir");
4310        let mut seen = std::collections::HashSet::new();
4311        // Should not panic on nonexistent directory
4312        Cli::collect_mq_commands_from_dir(&nonexistent, &mut seen);
4313        assert!(seen.is_empty());
4314    }
4315
4316    #[rstest]
4317    #[case("foo", "foo")]
4318    #[case("foo.exe", "foo.exe")]
4319    #[case("foo.cmd", "foo.cmd")]
4320    #[case("foo.bat", "foo.bat")]
4321    #[case("foo.sh", "foo.sh")]
4322    #[cfg(not(windows))]
4323    fn test_strip_executable_extension_unix(#[case] input: &str, #[case] expected: &str) {
4324        assert_eq!(Cli::strip_executable_extension(input), expected);
4325    }
4326
4327    #[rstest]
4328    #[case("foo.exe", "foo")]
4329    #[case("foo.cmd", "foo")]
4330    #[case("foo.bat", "foo")]
4331    #[case("foo.com", "foo")]
4332    #[case("foo", "foo")]
4333    #[case("foo.sh", "foo.sh")]
4334    #[case("foo.txt", "foo.txt")]
4335    #[cfg(windows)]
4336    fn test_strip_executable_extension_windows(#[case] input: &str, #[case] expected: &str) {
4337        assert_eq!(Cli::strip_executable_extension(input), expected);
4338    }
4339
4340    #[test]
4341    fn test_external_command_execution() {
4342        // Create a temporary directory for testing
4343        let temp_dir = std::env::temp_dir().join("mq-run-test");
4344        let bin_dir = temp_dir.join(".mq").join("bin");
4345        fs::create_dir_all(&bin_dir).expect("Failed to create test directory");
4346
4347        defer! {
4348            if temp_dir.exists() {
4349                std::fs::remove_dir_all(&temp_dir).ok();
4350            }
4351        }
4352
4353        // Create a test external command
4354        let test_cmd_path = bin_dir.join("mq-testcmd");
4355        #[cfg(unix)]
4356        {
4357            use std::os::unix::fs::PermissionsExt;
4358            fs::write(&test_cmd_path, "#!/bin/sh\necho 'test output'").expect("Failed to write test command");
4359            let mut perms = fs::metadata(&test_cmd_path)
4360                .expect("Failed to get metadata")
4361                .permissions();
4362            perms.set_mode(0o755);
4363            fs::set_permissions(&test_cmd_path, perms).expect("Failed to set permissions");
4364        }
4365        #[cfg(not(unix))]
4366        {
4367            fs::write(&test_cmd_path, "@echo off\necho test output").expect("Failed to write test command");
4368        }
4369
4370        // Note: We can't easily test execute_external_command without modifying HOME
4371        // This test just verifies the command file was created correctly
4372        assert!(test_cmd_path.exists());
4373    }
4374
4375    #[test]
4376    fn test_input_format_mdx() {
4377        let (_, temp_file_path) = create_file("test_mdx.mdx", "# MDX test");
4378        let (_, output_file) = create_file("test_mdx_output.md", "");
4379        let temp_file_path_clone = temp_file_path.clone();
4380        let output_file_clone = output_file.clone();
4381
4382        defer! {
4383            if temp_file_path_clone.exists() {
4384                std::fs::remove_file(&temp_file_path_clone).ok();
4385            }
4386            if output_file_clone.exists() {
4387                std::fs::remove_file(&output_file_clone).ok();
4388            }
4389        }
4390
4391        let cli = Cli {
4392            input: InputArgs {
4393                program: ProgramArgs {
4394                    input_format: Some(InputFormat::Mdx),
4395                    ..Default::default()
4396                },
4397                ..Default::default()
4398            },
4399            output: OutputArgs {
4400                output_file: Some(output_file.clone()),
4401                ..Default::default()
4402            },
4403            commands: None,
4404            query: Some("self".to_string()),
4405            files: Some(vec![temp_file_path]),
4406            ..Cli::default()
4407        };
4408
4409        assert!(cli.run().is_ok());
4410        let output_content = fs::read_to_string(&output_file).expect("Failed to read output");
4411        assert!(output_content.contains("# MDX test"), "Output should contain heading");
4412    }
4413
4414    #[test]
4415    fn test_input_format_html() {
4416        let (_, temp_file_path) = create_file("test_html.html", "<h1>HTML test</h1>");
4417        let (_, output_file) = create_file("test_html_output.md", "");
4418        let temp_file_path_clone = temp_file_path.clone();
4419        let output_file_clone = output_file.clone();
4420
4421        defer! {
4422            if temp_file_path_clone.exists() {
4423                std::fs::remove_file(&temp_file_path_clone).ok();
4424            }
4425            if output_file_clone.exists() {
4426                std::fs::remove_file(&output_file_clone).ok();
4427            }
4428        }
4429
4430        let cli = Cli {
4431            input: InputArgs {
4432                program: ProgramArgs {
4433                    input_format: Some(InputFormat::Html),
4434                    ..Default::default()
4435                },
4436                ..Default::default()
4437            },
4438            output: OutputArgs {
4439                output_file: Some(output_file.clone()),
4440                ..Default::default()
4441            },
4442            commands: None,
4443            query: Some("self".to_string()),
4444            files: Some(vec![temp_file_path]),
4445            ..Cli::default()
4446        };
4447
4448        assert!(cli.run().is_ok());
4449        let output_content = fs::read_to_string(&output_file).expect("Failed to read output");
4450        assert!(
4451            output_content.contains("# HTML test"),
4452            "Output should contain converted heading"
4453        );
4454    }
4455
4456    #[test]
4457    fn test_output_format_json_markdown_input() {
4458        let (_, temp_file_path) = create_file("test_json_md_input.md", "# Test");
4459        let (_, output_file) = create_file("test_json_md_output.json", "");
4460        let temp_file_path_clone = temp_file_path.clone();
4461        let output_file_clone = output_file.clone();
4462
4463        defer! {
4464            if temp_file_path_clone.exists() {
4465                std::fs::remove_file(&temp_file_path_clone).ok();
4466            }
4467            if output_file_clone.exists() {
4468                std::fs::remove_file(&output_file_clone).ok();
4469            }
4470        }
4471
4472        let cli = Cli {
4473            input: InputArgs::default(),
4474            output: OutputArgs {
4475                output_format: Some(OutputFormat::Json),
4476                output_file: Some(output_file.clone()),
4477                ..Default::default()
4478            },
4479            commands: None,
4480            query: Some("self".to_string()),
4481            files: Some(vec![temp_file_path]),
4482            ..Cli::default()
4483        };
4484
4485        assert!(cli.run().is_ok());
4486        let output_content = fs::read_to_string(&output_file).expect("Failed to read output");
4487        let parsed: serde_json::Value = serde_json::from_str(&output_content).expect("Output should be valid JSON");
4488        assert!(parsed.is_array(), "Markdown JSON output should be an array");
4489        let nodes = parsed.as_array().unwrap();
4490        assert_eq!(nodes.len(), 1);
4491        assert_eq!(nodes[0]["type"], "Heading", "Markdown heading should have type=Heading");
4492        assert!(
4493            nodes[0].get("position").is_some(),
4494            "position should be present by default"
4495        );
4496    }
4497
4498    #[test]
4499    fn test_output_format_json_markdown_input_no_position() {
4500        let (_, temp_file_path) = create_file("test_json_md_input_no_position.md", "# Test");
4501        let (_, output_file) = create_file("test_json_md_output_no_position.json", "");
4502        let temp_file_path_clone = temp_file_path.clone();
4503        let output_file_clone = output_file.clone();
4504
4505        defer! {
4506            if temp_file_path_clone.exists() {
4507                std::fs::remove_file(&temp_file_path_clone).ok();
4508            }
4509            if output_file_clone.exists() {
4510                std::fs::remove_file(&output_file_clone).ok();
4511            }
4512        }
4513
4514        let cli = Cli {
4515            input: InputArgs::default(),
4516            output: OutputArgs {
4517                output_format: Some(OutputFormat::Json),
4518                output_file: Some(output_file.clone()),
4519                no_position: true,
4520                ..Default::default()
4521            },
4522            commands: None,
4523            query: Some("self".to_string()),
4524            files: Some(vec![temp_file_path]),
4525            ..Cli::default()
4526        };
4527
4528        assert!(cli.run().is_ok());
4529        let output_content = fs::read_to_string(&output_file).expect("Failed to read output");
4530        let parsed: serde_json::Value = serde_json::from_str(&output_content).expect("Output should be valid JSON");
4531        let nodes = parsed.as_array().unwrap();
4532        assert_eq!(nodes.len(), 1);
4533        assert_eq!(nodes[0]["type"], "Heading");
4534        assert!(
4535            nodes[0].get("position").is_none(),
4536            "--no-position should omit position from the top-level node"
4537        );
4538        let children = nodes[0]["values"].as_array().expect("heading should have values");
4539        assert!(
4540            children.iter().all(|c| c.get("position").is_none()),
4541            "--no-position should omit position from child nodes too"
4542        );
4543    }
4544
4545    #[test]
4546    fn test_output_format_json_with_json_object_input() {
4547        let (_, temp_file_path) = create_file("test_json_obj_input.json", r#"{"id": 1, "name": "Alice"}"#);
4548        let (_, output_file) = create_file("test_json_obj_output.json", "");
4549        let temp_file_path_clone = temp_file_path.clone();
4550        let output_file_clone = output_file.clone();
4551
4552        defer! {
4553            if temp_file_path_clone.exists() {
4554                std::fs::remove_file(&temp_file_path_clone).ok();
4555            }
4556            if output_file_clone.exists() {
4557                std::fs::remove_file(&output_file_clone).ok();
4558            }
4559        }
4560
4561        let cli = Cli {
4562            input: InputArgs::default(),
4563            output: OutputArgs {
4564                output_format: Some(OutputFormat::Json),
4565                output_file: Some(output_file.clone()),
4566                ..Default::default()
4567            },
4568            commands: None,
4569            query: Some("self".to_string()),
4570            files: Some(vec![temp_file_path]),
4571            ..Cli::default()
4572        };
4573
4574        assert!(cli.run().is_ok());
4575        let output_content = fs::read_to_string(&output_file).expect("Failed to read output");
4576        let parsed: serde_json::Value = serde_json::from_str(&output_content).expect("Output should be valid JSON");
4577        assert!(parsed.is_object(), "JSON object input should output a JSON object");
4578        assert_eq!(parsed["id"], 1.0, "id field should be preserved");
4579        assert_eq!(parsed["name"], "Alice", "name field should be preserved");
4580        assert!(
4581            parsed.get("type").is_none(),
4582            "Output should not contain Markdown AST 'type' field"
4583        );
4584    }
4585
4586    #[test]
4587    fn test_output_format_json_with_json_array_input() {
4588        let (_, temp_file_path) = create_file(
4589            "test_json_arr_input.json",
4590            r#"[{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}]"#,
4591        );
4592        let (_, output_file) = create_file("test_json_arr_output.json", "");
4593        let temp_file_path_clone = temp_file_path.clone();
4594        let output_file_clone = output_file.clone();
4595
4596        defer! {
4597            if temp_file_path_clone.exists() {
4598                std::fs::remove_file(&temp_file_path_clone).ok();
4599            }
4600            if output_file_clone.exists() {
4601                std::fs::remove_file(&output_file_clone).ok();
4602            }
4603        }
4604
4605        let cli = Cli {
4606            input: InputArgs::default(),
4607            output: OutputArgs {
4608                output_format: Some(OutputFormat::Json),
4609                output_file: Some(output_file.clone()),
4610                ..Default::default()
4611            },
4612            commands: None,
4613            query: Some("self".to_string()),
4614            files: Some(vec![temp_file_path]),
4615            ..Cli::default()
4616        };
4617
4618        assert!(cli.run().is_ok());
4619        let output_content = fs::read_to_string(&output_file).expect("Failed to read output");
4620        let parsed: serde_json::Value = serde_json::from_str(&output_content).expect("Output should be valid JSON");
4621        assert!(parsed.is_array(), "JSON array input should output a JSON array");
4622        let arr = parsed.as_array().unwrap();
4623        assert_eq!(arr.len(), 2, "Array should have 2 elements");
4624        assert_eq!(arr[0]["id"], 1.0);
4625        assert_eq!(arr[0]["name"], "Alice");
4626        assert_eq!(arr[1]["id"], 2.0);
4627        assert_eq!(arr[1]["name"], "Bob");
4628        assert!(
4629            arr[0].get("type").is_none(),
4630            "Output should not contain Markdown AST fields"
4631        );
4632    }
4633
4634    #[test]
4635    fn test_output_format_gron() {
4636        let (_, temp_file_path) = create_file("test_gron_output.md", "# Title\n");
4637        let (_, output_file) = create_file("test_gron_output.gron", "");
4638        let temp_file_path_clone = temp_file_path.clone();
4639        let output_file_clone = output_file.clone();
4640
4641        defer! {
4642            if temp_file_path_clone.exists() {
4643                std::fs::remove_file(&temp_file_path_clone).ok();
4644            }
4645            if output_file_clone.exists() {
4646                std::fs::remove_file(&output_file_clone).ok();
4647            }
4648        }
4649
4650        let cli = Cli {
4651            input: InputArgs::default(),
4652            output: OutputArgs {
4653                output_format: Some(OutputFormat::Gron),
4654                output_file: Some(output_file.clone()),
4655                ..Default::default()
4656            },
4657            commands: None,
4658            query: Some("self".to_string()),
4659            files: Some(vec![temp_file_path]),
4660            ..Cli::default()
4661        };
4662
4663        assert!(cli.run().is_ok());
4664        let output_content = fs::read_to_string(&output_file).expect("Failed to read output");
4665        assert!(output_content.contains("json[0].type = \"Heading\";\n"));
4666        assert!(output_content.contains("json[0].depth = 1;\n"));
4667    }
4668
4669    #[test]
4670    fn test_input_format_gron_reconstructs_data() {
4671        let (_, temp_file_path) = create_file(
4672            "test_gron_input.gron",
4673            "json = {};\njson.a = {};\njson.a.b = \"deep\";\njson.arr = [];\njson.arr[0] = 1;\njson.arr[1] = 2;\n",
4674        );
4675        let (_, output_file) = create_file("test_gron_input_output.json", "");
4676        let temp_file_path_clone = temp_file_path.clone();
4677        let output_file_clone = output_file.clone();
4678
4679        defer! {
4680            if temp_file_path_clone.exists() {
4681                std::fs::remove_file(&temp_file_path_clone).ok();
4682            }
4683            if output_file_clone.exists() {
4684                std::fs::remove_file(&output_file_clone).ok();
4685            }
4686        }
4687
4688        let cli = Cli {
4689            input: InputArgs::default(),
4690            output: OutputArgs {
4691                output_format: Some(OutputFormat::Json),
4692                output_file: Some(output_file.clone()),
4693                ..Default::default()
4694            },
4695            commands: None,
4696            query: Some("self".to_string()),
4697            files: Some(vec![temp_file_path]),
4698            ..Cli::default()
4699        };
4700
4701        assert!(cli.run().is_ok());
4702        let output_content = fs::read_to_string(&output_file).expect("Failed to read output");
4703        let parsed: serde_json::Value = serde_json::from_str(&output_content).expect("Output should be valid JSON");
4704        assert_eq!(parsed["a"]["b"], "deep");
4705        assert_eq!(parsed["arr"][0], 1.0);
4706        assert_eq!(parsed["arr"][1], 2.0);
4707    }
4708
4709    #[test]
4710    fn test_output_format_gron_then_input_format_gron_round_trip() {
4711        let (_, md_file) = create_file("test_gron_roundtrip.md", "# Title\n\nSome *text* here.\n");
4712        let (_, gron_file) = create_file("test_gron_roundtrip.gron", "");
4713        let (_, json_file) = create_file("test_gron_roundtrip.json", "");
4714        let md_file_clone = md_file.clone();
4715        let gron_file_clone = gron_file.clone();
4716        let json_file_clone = json_file.clone();
4717
4718        defer! {
4719            if md_file_clone.exists() {
4720                std::fs::remove_file(&md_file_clone).ok();
4721            }
4722            if gron_file_clone.exists() {
4723                std::fs::remove_file(&gron_file_clone).ok();
4724            }
4725            if json_file_clone.exists() {
4726                std::fs::remove_file(&json_file_clone).ok();
4727            }
4728        }
4729
4730        let to_gron = Cli {
4731            input: InputArgs::default(),
4732            output: OutputArgs {
4733                output_format: Some(OutputFormat::Gron),
4734                output_file: Some(gron_file.clone()),
4735                ..Default::default()
4736            },
4737            commands: None,
4738            query: Some("self".to_string()),
4739            files: Some(vec![md_file.clone()]),
4740            ..Cli::default()
4741        };
4742        assert!(to_gron.run().is_ok());
4743
4744        let from_gron = Cli {
4745            input: InputArgs::default(),
4746            output: OutputArgs {
4747                output_format: Some(OutputFormat::Json),
4748                output_file: Some(json_file.clone()),
4749                ..Default::default()
4750            },
4751            commands: None,
4752            query: Some("self".to_string()),
4753            files: Some(vec![gron_file]),
4754            ..Cli::default()
4755        };
4756        assert!(from_gron.run().is_ok());
4757
4758        let direct_json = Cli {
4759            input: InputArgs::default(),
4760            output: OutputArgs {
4761                output_format: Some(OutputFormat::Json),
4762                output_file: Some(json_file.with_extension("direct.json")),
4763                ..Default::default()
4764            },
4765            commands: None,
4766            query: Some("self".to_string()),
4767            files: Some(vec![md_file]),
4768            ..Cli::default()
4769        };
4770        assert!(direct_json.run().is_ok());
4771
4772        let roundtrip_content = fs::read_to_string(&json_file).expect("Failed to read roundtrip output");
4773        let direct_content =
4774            fs::read_to_string(json_file.with_extension("direct.json")).expect("Failed to read direct output");
4775        fs::remove_file(json_file.with_extension("direct.json")).ok();
4776
4777        let roundtrip_value: serde_json::Value =
4778            serde_json::from_str(&roundtrip_content).expect("Roundtrip output should be valid JSON");
4779        let direct_value: serde_json::Value =
4780            serde_json::from_str(&direct_content).expect("Direct output should be valid JSON");
4781        assert_eq!(roundtrip_value, direct_value);
4782    }
4783
4784    #[test]
4785    fn test_output_format_raw() {
4786        let (_, output_file) = create_file("test_raw_output.bin", "");
4787        let output_file_clone = output_file.clone();
4788
4789        defer! {
4790            if output_file_clone.exists() {
4791                std::fs::remove_file(&output_file_clone).ok();
4792            }
4793        }
4794
4795        let cli = Cli {
4796            input: InputArgs {
4797                program: ProgramArgs {
4798                    input_format: Some(InputFormat::Null),
4799                    ..Default::default()
4800                },
4801                ..Default::default()
4802            },
4803            output: OutputArgs {
4804                output_format: Some(OutputFormat::Raw),
4805                output_file: Some(output_file.clone()),
4806                ..Default::default()
4807            },
4808            commands: None,
4809            query: Some(r#"to_bytes("hello")"#.to_string()),
4810            files: None,
4811            ..Cli::default()
4812        };
4813
4814        assert!(cli.run().is_ok());
4815        let output_bytes = fs::read(&output_file).expect("Failed to read output");
4816        assert_eq!(output_bytes, b"hello");
4817    }
4818
4819    #[rstest]
4820    #[case::from_string("raw_from_string", r#"to_bytes("hello")"#, b"hello" as &[u8])]
4821    #[case::from_number_array("raw_from_array", "to_bytes([104, 101, 108, 108, 111])", b"hello")]
4822    #[case::binary_data("raw_binary", "to_bytes([0, 255, 128, 1])", &[0u8, 255, 128, 1])]
4823    #[case::non_bytes_string("raw_string_value", r#""hello""#, b"hello")]
4824    #[case::utf8("raw_utf8", r#"to_bytes("あ")"#, &[0xe3u8, 0x81, 0x82])]
4825    #[case::empty("raw_empty", "to_bytes([])", b"")]
4826    fn test_output_format_raw_bytes(#[case] suffix: &str, #[case] query: &str, #[case] expected: &[u8]) {
4827        let (_, output_file) = create_file(&format!("test_{}.bin", suffix), "");
4828        let output_file_clone = output_file.clone();
4829
4830        defer! {
4831            if output_file_clone.exists() {
4832                std::fs::remove_file(&output_file_clone).ok();
4833            }
4834        }
4835
4836        let cli = Cli {
4837            input: InputArgs {
4838                program: ProgramArgs {
4839                    input_format: Some(InputFormat::Null),
4840                    ..Default::default()
4841                },
4842                ..Default::default()
4843            },
4844            output: OutputArgs {
4845                output_format: Some(OutputFormat::Raw),
4846                output_file: Some(output_file.clone()),
4847                ..Default::default()
4848            },
4849            commands: None,
4850            query: Some(query.to_string()),
4851            files: None,
4852            ..Cli::default()
4853        };
4854
4855        assert!(cli.run().is_ok());
4856        let output_bytes = fs::read(&output_file).expect("Failed to read output");
4857        assert_eq!(output_bytes, expected);
4858    }
4859
4860    #[test]
4861    fn test_output_format_none() {
4862        let (_, temp_file_path) = create_file("test_none.md", "# Test");
4863        let temp_file_path_clone = temp_file_path.clone();
4864
4865        defer! {
4866            if temp_file_path_clone.exists() {
4867                std::fs::remove_file(&temp_file_path_clone).ok();
4868            }
4869        }
4870
4871        let cli = Cli {
4872            input: InputArgs::default(),
4873            output: OutputArgs {
4874                output_format: Some(OutputFormat::None),
4875                ..Default::default()
4876            },
4877            commands: None,
4878            query: Some("self".to_string()),
4879            files: Some(vec![temp_file_path]),
4880            ..Cli::default()
4881        };
4882
4883        assert!(cli.run().is_ok());
4884    }
4885
4886    #[test]
4887    fn test_output_format_table_single_column() {
4888        let (_, temp_file_path) = create_file("test_table.md", "# Test\n\nContent");
4889        let (_, output_file) = create_file("test_table_output.md", "");
4890        let temp_file_path_clone = temp_file_path.clone();
4891        let output_file_clone = output_file.clone();
4892
4893        defer! {
4894            if temp_file_path_clone.exists() {
4895                std::fs::remove_file(&temp_file_path_clone).ok();
4896            }
4897            if output_file_clone.exists() {
4898                std::fs::remove_file(&output_file_clone).ok();
4899            }
4900        }
4901
4902        let cli = Cli {
4903            input: InputArgs::default(),
4904            output: OutputArgs {
4905                output_format: Some(OutputFormat::Table),
4906                output_file: Some(output_file.clone()),
4907                ..Default::default()
4908            },
4909            commands: None,
4910            query: Some("self".to_string()),
4911            files: Some(vec![temp_file_path]),
4912            ..Cli::default()
4913        };
4914
4915        assert!(cli.run().is_ok());
4916        let output_content = fs::read_to_string(&output_file).expect("Failed to read output");
4917        assert!(output_content.contains("value"), "Table should have value header");
4918        assert!(output_content.contains("Test"), "Table should contain node text");
4919    }
4920
4921    #[test]
4922    fn test_output_format_table_dict() {
4923        let (_, output_file) = create_file("test_table_dict_output.md", "");
4924        let output_file_clone = output_file.clone();
4925
4926        defer! {
4927            if output_file_clone.exists() {
4928                std::fs::remove_file(&output_file_clone).ok();
4929            }
4930        }
4931
4932        let cli = Cli {
4933            input: InputArgs {
4934                program: ProgramArgs {
4935                    input_format: Some(InputFormat::Null),
4936                    ..Default::default()
4937                },
4938                ..Default::default()
4939            },
4940            output: OutputArgs {
4941                output_format: Some(OutputFormat::Table),
4942                output_file: Some(output_file.clone()),
4943                ..Default::default()
4944            },
4945            commands: None,
4946            query: Some(r#"{name: "Alice", age: "30"}"#.to_string()),
4947            files: None,
4948            ..Cli::default()
4949        };
4950
4951        assert!(cli.run().is_ok());
4952        let output_content = fs::read_to_string(&output_file).expect("Failed to read output");
4953        assert!(output_content.contains("name"), "Table should contain name column");
4954        assert!(output_content.contains("age"), "Table should contain age column");
4955        assert!(output_content.contains("Alice"), "Table should contain Alice");
4956        assert!(output_content.contains("30"), "Table should contain 30");
4957    }
4958
4959    #[test]
4960    fn test_output_format_table_nested_dict() {
4961        let (_, output_file) = create_file("test_table_nested_dict_output.md", "");
4962        let output_file_clone = output_file.clone();
4963
4964        defer! {
4965            if output_file_clone.exists() {
4966                std::fs::remove_file(&output_file_clone).ok();
4967            }
4968        }
4969
4970        let cli = Cli {
4971            input: InputArgs {
4972                program: ProgramArgs {
4973                    input_format: Some(InputFormat::Null),
4974                    ..Default::default()
4975                },
4976                ..Default::default()
4977            },
4978            output: OutputArgs {
4979                output_format: Some(OutputFormat::Table),
4980                output_file: Some(output_file.clone()),
4981                ..Default::default()
4982            },
4983            commands: None,
4984            query: Some(r#"{name: "Alice", addr: {city: "Tokyo", zip: "100"}}"#.to_string()),
4985            files: None,
4986            ..Cli::default()
4987        };
4988
4989        assert!(cli.run().is_ok());
4990        let output_content = fs::read_to_string(&output_file).expect("Failed to read output");
4991        assert!(output_content.contains("addr"), "Table should contain addr column");
4992        assert!(output_content.contains("name"), "Table should contain name column");
4993        assert!(output_content.contains("Alice"), "Table should contain Alice");
4994        assert!(output_content.contains("city"), "Nested table should contain city key");
4995        assert!(output_content.contains("Tokyo"), "Nested table should contain Tokyo");
4996        assert!(output_content.contains("zip"), "Nested table should contain zip key");
4997        assert!(output_content.contains("100"), "Nested table should contain 100");
4998        assert!(!output_content.contains("addr.city"), "Dot notation must not appear");
4999    }
5000
5001    #[test]
5002    fn test_output_format_table_array_value() {
5003        let (_, output_file) = create_file("test_table_array_value_output.md", "");
5004        let output_file_clone = output_file.clone();
5005
5006        defer! {
5007            if output_file_clone.exists() {
5008                std::fs::remove_file(&output_file_clone).ok();
5009            }
5010        }
5011
5012        let cli = Cli {
5013            input: InputArgs {
5014                program: ProgramArgs {
5015                    input_format: Some(InputFormat::Null),
5016                    ..Default::default()
5017                },
5018                ..Default::default()
5019            },
5020            output: OutputArgs {
5021                output_format: Some(OutputFormat::Table),
5022                output_file: Some(output_file.clone()),
5023                ..Default::default()
5024            },
5025            commands: None,
5026            query: Some(r#"{name: "Alice", tags: ["a", "b"]}"#.to_string()),
5027            files: None,
5028            ..Cli::default()
5029        };
5030
5031        assert!(cli.run().is_ok());
5032        let output_content = fs::read_to_string(&output_file).expect("Failed to read output");
5033        assert!(output_content.contains("tags"), "Table should contain tags column");
5034        assert!(output_content.contains('a'), "Nested table should contain a");
5035        assert!(output_content.contains('b'), "Nested table should contain b");
5036        assert!(output_content.contains("Alice"), "Table should contain Alice");
5037        assert!(!output_content.contains(r#"["a""#), "Raw array repr must not appear");
5038    }
5039
5040    #[test]
5041    fn test_output_format_table_array_input() {
5042        let (_, output_file) = create_file("test_table_array_input_output.md", "");
5043        let output_file_clone = output_file.clone();
5044
5045        defer! {
5046            if output_file_clone.exists() {
5047                std::fs::remove_file(&output_file_clone).ok();
5048            }
5049        }
5050
5051        let cli = Cli {
5052            input: InputArgs {
5053                program: ProgramArgs {
5054                    input_format: Some(InputFormat::Null),
5055                    ..Default::default()
5056                },
5057                ..Default::default()
5058            },
5059            output: OutputArgs {
5060                output_format: Some(OutputFormat::Table),
5061                output_file: Some(output_file.clone()),
5062                ..Default::default()
5063            },
5064            commands: None,
5065            query: Some(r#"[{a: "1"}, {a: "2"}]"#.to_string()),
5066            files: None,
5067            ..Cli::default()
5068        };
5069
5070        assert!(cli.run().is_ok());
5071        let output_content = fs::read_to_string(&output_file).expect("Failed to read output");
5072        assert!(output_content.contains('a'), "Table should have column 'a'");
5073        assert!(output_content.contains('1'), "Row 1 value should appear");
5074        assert!(output_content.contains('2'), "Row 2 value should appear");
5075        assert!(
5076            !output_content.contains("value"),
5077            "Should not fall back to 'value' column"
5078        );
5079    }
5080
5081    #[test]
5082    fn test_link_title_styles() {
5083        let (_, temp_file_path) = create_file("test_link_title.md", "[link](url \"title\")");
5084        let temp_file_path_clone = temp_file_path.clone();
5085
5086        defer! {
5087            if temp_file_path_clone.exists() {
5088                std::fs::remove_file(&temp_file_path_clone).ok();
5089            }
5090        }
5091
5092        for (style, expected_char) in [
5093            (LinkTitleStyle::Double, '"'),
5094            (LinkTitleStyle::Single, '\''),
5095            (LinkTitleStyle::Paren, '('),
5096        ] {
5097            let (_, output_file) = create_file(&format!("test_link_title_{:?}.md", style), "");
5098            let output_file_clone = output_file.clone();
5099
5100            defer! {
5101                if output_file_clone.exists() {
5102                    std::fs::remove_file(&output_file_clone).ok();
5103                }
5104            }
5105
5106            let cli = Cli {
5107                input: InputArgs::default(),
5108                output: OutputArgs {
5109                    link_title_style: style.clone(),
5110                    output_file: Some(output_file.clone()),
5111                    ..Default::default()
5112                },
5113                commands: None,
5114                query: Some("self".to_string()),
5115                files: Some(vec![temp_file_path.clone()]),
5116                ..Cli::default()
5117            };
5118
5119            assert!(cli.run().is_ok());
5120            let output_content = fs::read_to_string(&output_file).expect("Failed to read output");
5121            if style == LinkTitleStyle::Paren {
5122                assert!(
5123                    output_content.contains("(title)"),
5124                    "Paren style should wrap title with parens"
5125                );
5126            } else {
5127                assert!(
5128                    output_content.contains(expected_char),
5129                    "Link title should use {:?} style",
5130                    style
5131                );
5132            }
5133        }
5134    }
5135
5136    #[test]
5137    fn test_link_url_styles() {
5138        let (_, temp_file_path) = create_file("test_link_url.md", "[link](https://example.com)");
5139        let temp_file_path_clone = temp_file_path.clone();
5140
5141        defer! {
5142            if temp_file_path_clone.exists() {
5143                std::fs::remove_file(&temp_file_path_clone).ok();
5144            }
5145        }
5146
5147        for style in [LinkUrlStyle::None, LinkUrlStyle::Angle] {
5148            let (_, output_file) = create_file(&format!("test_link_url_{:?}.md", style), "");
5149            let output_file_clone = output_file.clone();
5150
5151            defer! {
5152                if output_file_clone.exists() {
5153                    std::fs::remove_file(&output_file_clone).ok();
5154                }
5155            }
5156
5157            let cli = Cli {
5158                input: InputArgs::default(),
5159                output: OutputArgs {
5160                    link_url_style: style.clone(),
5161                    output_file: Some(output_file.clone()),
5162                    ..Default::default()
5163                },
5164                commands: None,
5165                query: Some("self".to_string()),
5166                files: Some(vec![temp_file_path.clone()]),
5167                ..Cli::default()
5168            };
5169
5170            assert!(cli.run().is_ok());
5171            let output_content = fs::read_to_string(&output_file).expect("Failed to read output");
5172            if style == LinkUrlStyle::Angle {
5173                assert!(
5174                    output_content.contains("<https://example.com>"),
5175                    "Angle style should wrap URL with angle brackets"
5176                );
5177            } else {
5178                assert!(
5179                    output_content.contains("(https://example.com)"),
5180                    "None style should not wrap URL"
5181                );
5182            }
5183        }
5184    }
5185
5186    #[test]
5187    fn test_aggregate_flag() {
5188        let (_, temp_file1) = create_file("test_agg1.md", "# Test 1");
5189        let (_, temp_file2) = create_file("test_agg2.md", "# Test 2");
5190        let (_, output_file) = create_file("test_agg_output.md", "");
5191        let temp_file1_clone = temp_file1.clone();
5192        let temp_file2_clone = temp_file2.clone();
5193        let output_file_clone = output_file.clone();
5194
5195        defer! {
5196            if temp_file1_clone.exists() {
5197                std::fs::remove_file(&temp_file1_clone).ok();
5198            }
5199            if temp_file2_clone.exists() {
5200                std::fs::remove_file(&temp_file2_clone).ok();
5201            }
5202            if output_file_clone.exists() {
5203                std::fs::remove_file(&output_file_clone).ok();
5204            }
5205        }
5206
5207        let cli = Cli {
5208            input: InputArgs {
5209                program: ProgramArgs {
5210                    aggregate: true,
5211                    ..Default::default()
5212                },
5213                ..Default::default()
5214            },
5215            output: OutputArgs {
5216                output_file: Some(output_file.clone()),
5217                output_format: Some(OutputFormat::Text),
5218                ..Default::default()
5219            },
5220            commands: None,
5221            query: Some("len()".to_string()),
5222            files: Some(vec![temp_file1, temp_file2]),
5223            ..Cli::default()
5224        };
5225
5226        assert!(cli.run().is_ok());
5227        let output_content = fs::read_to_string(&output_file).expect("Failed to read output");
5228        assert!(!output_content.is_empty(), "Aggregated output should not be empty");
5229    }
5230
5231    #[test]
5232    fn test_eval_all_combines_nodes_across_files() {
5233        let (_, temp_file1) = create_file("test_eval_all1.md", "# File One\n\n## Sub A");
5234        let (_, temp_file2) = create_file("test_eval_all2.md", "# File Two\n\n## Sub B");
5235        let (_, output_file) = create_file("test_eval_all_output.md", "");
5236        let temp_file1_clone = temp_file1.clone();
5237        let temp_file2_clone = temp_file2.clone();
5238        let output_file_clone = output_file.clone();
5239
5240        defer! {
5241            if temp_file1_clone.exists() {
5242                std::fs::remove_file(&temp_file1_clone).ok();
5243            }
5244            if temp_file2_clone.exists() {
5245                std::fs::remove_file(&temp_file2_clone).ok();
5246            }
5247            if output_file_clone.exists() {
5248                std::fs::remove_file(&output_file_clone).ok();
5249            }
5250        }
5251
5252        // -A alone aggregates per file (2 separate counts); --eval-all -A must combine
5253        // both files into a single collection before "nodes" runs, giving one total.
5254        let cli = Cli {
5255            input: InputArgs {
5256                program: ProgramArgs {
5257                    aggregate: true,
5258                    ..Default::default()
5259                },
5260                eval_all: true,
5261                ..Default::default()
5262            },
5263            output: OutputArgs {
5264                output_file: Some(output_file.clone()),
5265                output_format: Some(OutputFormat::Text),
5266                ..Default::default()
5267            },
5268            commands: None,
5269            query: Some(".h | len".to_string()),
5270            files: Some(vec![temp_file1, temp_file2]),
5271            ..Cli::default()
5272        };
5273
5274        assert!(cli.run().is_ok());
5275        let output_content = fs::read_to_string(&output_file).expect("Failed to read output");
5276        assert_eq!(
5277            output_content.trim(),
5278            "4",
5279            "should count headings across both files as one"
5280        );
5281    }
5282
5283    #[test]
5284    fn test_eval_all_rejects_mixed_input_formats() {
5285        let (_, temp_md) = create_file("test_eval_all_mixed.md", "# Test");
5286        let (_, temp_json) = create_file("test_eval_all_mixed.json", r#"{"a":1}"#);
5287        let temp_md_clone = temp_md.clone();
5288        let temp_json_clone = temp_json.clone();
5289
5290        defer! {
5291            if temp_md_clone.exists() {
5292                std::fs::remove_file(&temp_md_clone).ok();
5293            }
5294            if temp_json_clone.exists() {
5295                std::fs::remove_file(&temp_json_clone).ok();
5296            }
5297        }
5298
5299        let cli = Cli {
5300            input: InputArgs {
5301                eval_all: true,
5302                ..Default::default()
5303            },
5304            output: OutputArgs::default(),
5305            commands: None,
5306            query: Some("self".to_string()),
5307            files: Some(vec![temp_md, temp_json]),
5308            ..Cli::default()
5309        };
5310
5311        let err = cli.run().unwrap_err();
5312        assert!(err.to_string().contains("--eval-all requires"));
5313    }
5314
5315    #[test]
5316    fn test_from_file_flag() {
5317        let (_, query_file) = create_file("test_query.mq", "self");
5318        let (_, input_file) = create_file("test_from_file.md", "# Test");
5319        let query_file_clone = query_file.clone();
5320        let input_file_clone = input_file.clone();
5321
5322        defer! {
5323            if query_file_clone.exists() {
5324                std::fs::remove_file(&query_file_clone).ok();
5325            }
5326            if input_file_clone.exists() {
5327                std::fs::remove_file(&input_file_clone).ok();
5328            }
5329        }
5330
5331        let cli = Cli {
5332            input: InputArgs {
5333                from_file: true,
5334                ..Default::default()
5335            },
5336            output: OutputArgs::default(),
5337            commands: None,
5338            query: Some(query_file.to_string_lossy().to_string()),
5339            files: Some(vec![input_file]),
5340            ..Cli::default()
5341        };
5342
5343        assert!(cli.run().is_ok());
5344    }
5345
5346    #[test]
5347    fn test_separator_flag() {
5348        let (_, temp_file1) = create_file("test_sep1.md", "# Test 1");
5349        let (_, temp_file2) = create_file("test_sep2.md", "# Test 2");
5350        let (_, output_file) = create_file("test_sep_output.md", "");
5351        let temp_file1_clone = temp_file1.clone();
5352        let temp_file2_clone = temp_file2.clone();
5353        let output_file_clone = output_file.clone();
5354
5355        defer! {
5356            if temp_file1_clone.exists() {
5357                std::fs::remove_file(&temp_file1_clone).ok();
5358            }
5359            if temp_file2_clone.exists() {
5360                std::fs::remove_file(&temp_file2_clone).ok();
5361            }
5362            if output_file_clone.exists() {
5363                std::fs::remove_file(&output_file_clone).ok();
5364            }
5365        }
5366
5367        let cli = Cli {
5368            input: InputArgs::default(),
5369            output: OutputArgs {
5370                separator: Some("\"---\"".to_string()),
5371                output_file: Some(output_file.clone()),
5372                ..Default::default()
5373            },
5374            commands: None,
5375            query: Some("self".to_string()),
5376            files: Some(vec![temp_file1, temp_file2]),
5377            ..Cli::default()
5378        };
5379
5380        assert!(cli.run().is_ok());
5381        let output_content = fs::read_to_string(&output_file).expect("Failed to read output");
5382        assert!(!output_content.is_empty(), "Output should not be empty");
5383        assert!(output_content.contains("# Test"), "File content should be present");
5384    }
5385
5386    #[test]
5387    fn test_output_file_flag() {
5388        let (_, temp_input) = create_file("test_input_out.md", "# Test Output");
5389        let temp_output = std::env::temp_dir().join("test_output_file.md");
5390        let temp_input_clone = temp_input.clone();
5391        let temp_output_clone = temp_output.clone();
5392
5393        defer! {
5394            if temp_input_clone.exists() {
5395                std::fs::remove_file(&temp_input_clone).ok();
5396            }
5397            if temp_output_clone.exists() {
5398                std::fs::remove_file(&temp_output_clone).ok();
5399            }
5400        }
5401
5402        let cli = Cli {
5403            input: InputArgs::default(),
5404            output: OutputArgs {
5405                output_file: Some(temp_output.clone()),
5406                ..Default::default()
5407            },
5408            commands: None,
5409            query: Some("self".to_string()),
5410            files: Some(vec![temp_input]),
5411            ..Cli::default()
5412        };
5413
5414        assert!(cli.run().is_ok());
5415        assert!(temp_output.exists(), "Output file should exist");
5416        let output_content = fs::read_to_string(&temp_output).expect("Failed to read output");
5417        assert!(
5418            output_content.contains("# Test Output"),
5419            "Output content should match input"
5420        );
5421    }
5422
5423    #[test]
5424    fn test_unbuffered_output() {
5425        let (_, temp_file) = create_file("test_unbuf.md", "# Test");
5426        let temp_file_clone = temp_file.clone();
5427
5428        defer! {
5429            if temp_file_clone.exists() {
5430                std::fs::remove_file(&temp_file_clone).ok();
5431            }
5432        }
5433
5434        let cli = Cli {
5435            input: InputArgs::default(),
5436            output: OutputArgs {
5437                unbuffered: true,
5438                ..Default::default()
5439            },
5440            commands: None,
5441            query: Some("self".to_string()),
5442            files: Some(vec![temp_file]),
5443            ..Cli::default()
5444        };
5445
5446        assert!(cli.run().is_ok());
5447    }
5448
5449    #[test]
5450    fn test_update_with_non_markdown_input() {
5451        let cli = Cli {
5452            input: InputArgs {
5453                program: ProgramArgs {
5454                    input_format: Some(InputFormat::Html),
5455                    ..Default::default()
5456                },
5457                ..Default::default()
5458            },
5459            output: OutputArgs {
5460                update: true,
5461                ..Default::default()
5462            },
5463            commands: None,
5464            query: Some("self".to_string()),
5465            files: None,
5466            ..Cli::default()
5467        };
5468
5469        assert!(cli.run().is_err());
5470    }
5471
5472    #[test]
5473    fn test_list_commands() {
5474        let cli = Cli {
5475            list: true,
5476            ..Cli::default()
5477        };
5478
5479        assert!(cli.run().is_ok());
5480    }
5481
5482    #[test]
5483    fn test_parallel_threshold() {
5484        let files: Vec<PathBuf> = (0..15)
5485            .map(|i| {
5486                let (_, path) = create_file(&format!("test_parallel_{}.md", i), "# Test");
5487                path
5488            })
5489            .collect();
5490
5491        let files_clone = files.clone();
5492        defer! {
5493            for file in &files_clone {
5494                if file.exists() {
5495                    std::fs::remove_file(file).ok();
5496                }
5497            }
5498        }
5499
5500        let cli = Cli {
5501            input: InputArgs::default(),
5502            output: OutputArgs::default(),
5503            commands: None,
5504            query: Some("self".to_string()),
5505            files: Some(files),
5506            parallel_threshold: 10,
5507            ..Cli::default()
5508        };
5509
5510        assert!(cli.run().is_ok());
5511    }
5512
5513    #[rstest]
5514    #[case("mq-exec-owner", 0o700, true)]
5515    #[case("mq-exec-group", 0o010, true)]
5516    #[case("mq-exec-other", 0o001, true)]
5517    #[case("mq-exec-all", 0o755, true)]
5518    #[case("mq-noexec-rw", 0o644, false)]
5519    #[case("mq-noexec-ro", 0o444, false)]
5520    #[cfg(unix)]
5521    fn test_is_executable_file_unix(#[case] filename: &str, #[case] mode: u32, #[case] expected: bool) {
5522        use std::os::unix::fs::PermissionsExt;
5523
5524        let temp_dir = std::env::temp_dir().join(format!("mq-exec-test-{filename}"));
5525        fs::create_dir_all(&temp_dir).expect("Failed to create test directory");
5526
5527        defer! {
5528            if temp_dir.exists() {
5529                std::fs::remove_dir_all(&temp_dir).ok();
5530            }
5531        }
5532
5533        let file_path = temp_dir.join(filename);
5534        fs::write(&file_path, "#!/bin/sh\necho test").expect("Failed to write file");
5535        fs::set_permissions(&file_path, fs::Permissions::from_mode(mode)).expect("Failed to set permissions");
5536
5537        let entry = fs::read_dir(&temp_dir)
5538            .expect("Failed to read dir")
5539            .find(|e| e.as_ref().unwrap().file_name().to_str() == Some(filename))
5540            .unwrap()
5541            .unwrap();
5542
5543        assert_eq!(
5544            Cli::is_executable_file(&entry),
5545            expected,
5546            "File with mode {mode:#o} should return {expected}"
5547        );
5548    }
5549
5550    #[test]
5551    #[cfg(unix)]
5552    fn test_is_executable_file_unix_directory() {
5553        use std::os::unix::fs::PermissionsExt;
5554
5555        let temp_dir = std::env::temp_dir().join("mq-dir-test-unix");
5556        let sub_dir = temp_dir.join("mq-subdir");
5557        fs::create_dir_all(&sub_dir).expect("Failed to create test directory");
5558
5559        defer! {
5560            if temp_dir.exists() {
5561                std::fs::remove_dir_all(&temp_dir).ok();
5562            }
5563        }
5564
5565        fs::set_permissions(&sub_dir, fs::Permissions::from_mode(0o755)).expect("Failed to set permissions");
5566
5567        let entry = fs::read_dir(&temp_dir)
5568            .expect("Failed to read dir")
5569            .find(|e| e.as_ref().unwrap().file_name() == "mq-subdir")
5570            .unwrap()
5571            .unwrap();
5572
5573        assert!(!Cli::is_executable_file(&entry), "Directory should return false");
5574    }
5575
5576    #[rstest]
5577    #[case("mq-test.exe", true)]
5578    #[case("mq-test.cmd", true)]
5579    #[case("mq-test.bat", true)]
5580    #[case("mq-test.com", true)]
5581    #[case("mq-test.EXE", true)]
5582    #[case("mq-test.Bat", true)]
5583    #[case("mq-test.txt", false)]
5584    #[case("mq-test.sh", false)]
5585    #[case("mq-test", false)]
5586    #[cfg(windows)]
5587    fn test_is_executable_file_windows(#[case] filename: &str, #[case] expected: bool) {
5588        let temp_dir = std::env::temp_dir().join(format!("mq-exec-test-win-{}", filename.replace('.', "-")));
5589        fs::create_dir_all(&temp_dir).expect("Failed to create test directory");
5590
5591        defer! {
5592            if temp_dir.exists() {
5593                std::fs::remove_dir_all(&temp_dir).ok();
5594            }
5595        }
5596
5597        let file_path = temp_dir.join(filename);
5598        fs::write(&file_path, "test").expect("Failed to write file");
5599
5600        let entry = fs::read_dir(&temp_dir)
5601            .expect("Failed to read dir")
5602            .find(|e| e.as_ref().unwrap().file_name().to_str() == Some(filename))
5603            .unwrap()
5604            .unwrap();
5605
5606        assert_eq!(
5607            Cli::is_executable_file(&entry),
5608            expected,
5609            "File '{filename}' should return {expected}"
5610        );
5611    }
5612
5613    #[test]
5614    #[cfg(windows)]
5615    fn test_is_executable_file_windows_directory() {
5616        let temp_dir = std::env::temp_dir().join("mq-dir-test-windows");
5617        let sub_dir = temp_dir.join("mq-subdir");
5618        fs::create_dir_all(&sub_dir).expect("Failed to create test directory");
5619
5620        defer! {
5621            if temp_dir.exists() {
5622                std::fs::remove_dir_all(&temp_dir).ok();
5623            }
5624        }
5625
5626        let entry = fs::read_dir(&temp_dir)
5627            .expect("Failed to read dir")
5628            .find(|e| e.as_ref().unwrap().file_name() == "mq-subdir")
5629            .unwrap()
5630            .unwrap();
5631
5632        assert!(!Cli::is_executable_file(&entry), "Directory should return false");
5633    }
5634
5635    #[test]
5636    #[cfg(not(any(unix, windows)))]
5637    fn test_is_executable_file_other_os() {
5638        let temp_dir = std::env::temp_dir().join("mq-other-test");
5639        fs::create_dir_all(&temp_dir).expect("Failed to create test directory");
5640
5641        defer! {
5642            if temp_dir.exists() {
5643                std::fs::remove_dir_all(&temp_dir).ok();
5644            }
5645        }
5646
5647        let file = temp_dir.join("mq-test");
5648        fs::write(&file, "test").expect("Failed to write file");
5649
5650        let entry = fs::read_dir(&temp_dir)
5651            .expect("Failed to read dir")
5652            .find(|e| e.as_ref().unwrap().file_name() == "mq-test")
5653            .unwrap()
5654            .unwrap();
5655
5656        assert!(
5657            Cli::is_executable_file(&entry),
5658            "Regular file should return true on other OS"
5659        );
5660    }
5661
5662    #[test]
5663    #[cfg(not(any(unix, windows)))]
5664    fn test_is_executable_file_other_os_directory() {
5665        let temp_dir = std::env::temp_dir().join("mq-dir-other-test");
5666        let sub_dir = temp_dir.join("mq-subdir");
5667        fs::create_dir_all(&sub_dir).expect("Failed to create test directory");
5668
5669        defer! {
5670            if temp_dir.exists() {
5671                std::fs::remove_dir_all(&temp_dir).ok();
5672            }
5673        }
5674
5675        let entry = fs::read_dir(&temp_dir)
5676            .expect("Failed to read dir")
5677            .find(|e| e.as_ref().unwrap().file_name() == "mq-subdir")
5678            .unwrap()
5679            .unwrap();
5680
5681        assert!(
5682            !Cli::is_executable_file(&entry),
5683            "Directory should return false on other OS"
5684        );
5685    }
5686
5687    /// Test that Windows deduplicates commands with different executable extensions.
5688    /// e.g., mq-foo.bat and mq-foo.exe in the same directory should produce only "foo".
5689    #[test]
5690    #[cfg(windows)]
5691    fn test_collect_mq_commands_deduplicates_windows_extensions() {
5692        let temp_dir = std::env::temp_dir().join("mq-win-dedup-ext-test");
5693        fs::create_dir_all(&temp_dir).expect("Failed to create test directory");
5694
5695        defer! {
5696            if temp_dir.exists() {
5697                std::fs::remove_dir_all(&temp_dir).ok();
5698            }
5699        }
5700
5701        // Create the same subcommand with multiple Windows executable extensions
5702        fs::write(temp_dir.join("mq-foo.exe"), "test").expect("Failed to write file");
5703        fs::write(temp_dir.join("mq-foo.bat"), "@echo test").expect("Failed to write file");
5704        fs::write(temp_dir.join("mq-foo.cmd"), "@echo test").expect("Failed to write file");
5705        fs::write(temp_dir.join("mq-bar.exe"), "test").expect("Failed to write file");
5706
5707        let mut seen = std::collections::HashSet::new();
5708        Cli::collect_mq_commands_from_dir(&temp_dir, &mut seen);
5709
5710        assert_eq!(seen.len(), 2, "Should have exactly 2 unique commands");
5711        assert!(seen.contains("foo"), "Should contain 'foo'");
5712        assert!(seen.contains("bar"), "Should contain 'bar'");
5713    }
5714
5715    /// Test that Windows deduplicates commands with different extensions across directories.
5716    /// e.g., mq-foo.bat in dir1 and mq-foo.exe in dir2 should produce only "foo".
5717    #[test]
5718    #[cfg(windows)]
5719    fn test_collect_mq_commands_deduplicates_across_dirs_windows() {
5720        let dir1 = std::env::temp_dir().join("mq-win-cross-dedup-1");
5721        let dir2 = std::env::temp_dir().join("mq-win-cross-dedup-2");
5722        fs::create_dir_all(&dir1).expect("Failed to create test directory");
5723        fs::create_dir_all(&dir2).expect("Failed to create test directory");
5724
5725        defer! {
5726            if dir1.exists() {
5727                std::fs::remove_dir_all(&dir1).ok();
5728            }
5729            if dir2.exists() {
5730                std::fs::remove_dir_all(&dir2).ok();
5731            }
5732        }
5733
5734        fs::write(dir1.join("mq-foo.bat"), "@echo test").expect("Failed to write file");
5735        fs::write(dir2.join("mq-foo.exe"), "test").expect("Failed to write file");
5736        fs::write(dir2.join("mq-unique.cmd"), "@echo test").expect("Failed to write file");
5737
5738        let mut seen = std::collections::HashSet::new();
5739        Cli::collect_mq_commands_from_dir(&dir1, &mut seen);
5740        Cli::collect_mq_commands_from_dir(&dir2, &mut seen);
5741
5742        assert_eq!(seen.len(), 2, "Should have exactly 2 unique commands");
5743        assert!(seen.contains("foo"), "Should contain 'foo'");
5744        assert!(seen.contains("unique"), "Should contain 'unique'");
5745    }
5746
5747    /// Test that collect_mq_commands_from_dir handles an empty directory correctly.
5748    #[test]
5749    fn test_collect_mq_commands_from_empty_dir() {
5750        let temp_dir = std::env::temp_dir().join("mq-empty-dir-test");
5751        fs::create_dir_all(&temp_dir).expect("Failed to create test directory");
5752
5753        defer! {
5754            if temp_dir.exists() {
5755                std::fs::remove_dir_all(&temp_dir).ok();
5756            }
5757        }
5758
5759        let mut seen = std::collections::HashSet::new();
5760        Cli::collect_mq_commands_from_dir(&temp_dir, &mut seen);
5761        assert!(seen.is_empty(), "Empty directory should yield no commands");
5762    }
5763
5764    /// Test that files without the mq- prefix are ignored even if executable.
5765    #[test]
5766    fn test_collect_mq_commands_ignores_non_mq_prefix() {
5767        let temp_dir = std::env::temp_dir().join("mq-prefix-test");
5768        fs::create_dir_all(&temp_dir).expect("Failed to create test directory");
5769
5770        defer! {
5771            if temp_dir.exists() {
5772                std::fs::remove_dir_all(&temp_dir).ok();
5773            }
5774        }
5775
5776        // Create files without mq- prefix
5777        fs::write(temp_dir.join("foo"), "test").expect("Failed to write file");
5778        fs::write(temp_dir.join("bar-mq"), "test").expect("Failed to write file");
5779        fs::write(temp_dir.join("mqfoo"), "test").expect("Failed to write file");
5780
5781        #[cfg(unix)]
5782        {
5783            use std::os::unix::fs::PermissionsExt;
5784            for name in &["foo", "bar-mq", "mqfoo"] {
5785                fs::set_permissions(temp_dir.join(name), fs::Permissions::from_mode(0o755))
5786                    .expect("Failed to set permissions");
5787            }
5788        }
5789
5790        let mut seen = std::collections::HashSet::new();
5791        Cli::collect_mq_commands_from_dir(&temp_dir, &mut seen);
5792        assert!(seen.is_empty(), "Files without mq- prefix should be ignored");
5793    }
5794
5795    #[rstest]
5796    #[case("md", InputFormat::Markdown)]
5797    #[case("MD", InputFormat::Markdown)]
5798    #[case("markdown", InputFormat::Markdown)]
5799    #[case("mdx", InputFormat::Mdx)]
5800    #[case("html", InputFormat::Html)]
5801    #[case("htm", InputFormat::Html)]
5802    #[case("txt", InputFormat::Raw)]
5803    #[case("log", InputFormat::Raw)]
5804    #[case("csv", InputFormat::Csv)]
5805    #[case("gron", InputFormat::Gron)]
5806    #[case("psv", InputFormat::Psv)]
5807    #[case("tsv", InputFormat::Tsv)]
5808    #[case("json", InputFormat::Json)]
5809    #[case("toml", InputFormat::Toml)]
5810    #[case("yaml", InputFormat::Yaml)]
5811    #[case("yml", InputFormat::Yaml)]
5812    #[case("xml", InputFormat::Xml)]
5813    #[case("jsonl", InputFormat::Text)]
5814    #[case("ndjson", InputFormat::Text)]
5815    #[case("cbor", InputFormat::Cbor)]
5816    #[case("unknown", InputFormat::Markdown)] // default fallback
5817    fn test_from_extension(#[case] ext: &str, #[case] expected: InputFormat) {
5818        assert_eq!(InputFormat::from_extension(ext), expected);
5819    }
5820
5821    #[rstest]
5822    #[case("file.csv", InputFormat::Csv)]
5823    #[case("file.csv.gz", InputFormat::Csv)]
5824    #[case("file.json.gz", InputFormat::Json)]
5825    #[case("file.tar.gz", InputFormat::Markdown)] // "tar" isn't a recognized inner extension
5826    #[case("file.gz", InputFormat::Markdown)] // no inner extension at all
5827    #[case("file.md", InputFormat::Markdown)]
5828    fn test_input_format_from_path(#[case] filename: &str, #[case] expected: InputFormat) {
5829        assert_eq!(InputFormat::from_path(&PathBuf::from(filename)), expected);
5830    }
5831
5832    #[rstest]
5833    #[case("html", OutputFormat::Html)]
5834    #[case("htm", OutputFormat::Html)]
5835    #[case("txt", OutputFormat::Raw)]
5836    #[case("log", OutputFormat::Raw)]
5837    #[case("json", OutputFormat::Json)]
5838    #[case("csv", OutputFormat::Csv)]
5839    #[case("toml", OutputFormat::Toml)]
5840    #[case("toon", OutputFormat::Toon)]
5841    #[case("xml", OutputFormat::Xml)]
5842    #[case("yaml", OutputFormat::Yaml)]
5843    #[case("yml", OutputFormat::Yaml)]
5844    #[case("gron", OutputFormat::Gron)]
5845    #[case("unknown", OutputFormat::Markdown)] // default fallback
5846    #[case("md", OutputFormat::Markdown)]
5847    fn test_output_format_from_extension(#[case] ext: &str, #[case] expected: OutputFormat) {
5848        assert_eq!(OutputFormat::from_extension(ext), expected);
5849    }
5850
5851    #[rstest]
5852    #[case("file.json", OutputFormat::Json)]
5853    #[case("file.csv", OutputFormat::Csv)]
5854    #[case("file.yaml", OutputFormat::Yaml)]
5855    #[case("file.md", OutputFormat::Markdown)]
5856    #[case("file", OutputFormat::Markdown)] // no extension at all
5857    fn test_output_format_from_path(#[case] filename: &str, #[case] expected: OutputFormat) {
5858        assert_eq!(OutputFormat::from_path(&PathBuf::from(filename)), expected);
5859    }
5860
5861    #[rstest]
5862    #[case("file.json", Some(r#"import "json" | json::json_parse()"#))]
5863    #[case("file.gron", Some(r#"import "gron" | gron::gron_parse()"#))]
5864    #[case("file.yaml", Some(r#"import "yaml" | yaml::yaml_parse()"#))]
5865    #[case("file.yml", Some(r#"import "yaml" | yaml::yaml_parse()"#))]
5866    #[case("file.toml", Some(r#"import "toml" | toml::toml_parse()"#))]
5867    #[case("file.xml", Some(r#"import "xml" | xml::xml_parse()"#))]
5868    #[case("file.toon", Some(r#"import "toon" | toon::toon_parse()"#))]
5869    #[case("file.csv", Some(r#"import "csv" | csv::csv_parse(true)"#))]
5870    #[case("file.tsv", Some(r#"import "csv" | csv::tsv_parse(true)"#))]
5871    #[case("file.psv", Some(r#"import "csv" | csv::psv_parse(true)"#))]
5872    #[case("file.cbor", Some(r#"import "cbor" | cbor::cbor_parse()"#))]
5873    #[case("file.csv.gz", Some(r#"import "csv" | csv::csv_parse(true)"#))]
5874    #[case("file.json.gz", Some(r#"import "json" | json::json_parse()"#))]
5875    #[case("file.md", None)]
5876    #[case("file.txt", None)]
5877    fn test_auto_query_prefix(#[case] filename: &str, #[case] expected: Option<&str>) {
5878        let cli = Cli {
5879            input: InputArgs::default(),
5880            ..Cli::default()
5881        };
5882        let file = Some(PathBuf::from(filename));
5883        assert_eq!(cli.auto_query_prefix(&file).as_deref(), expected);
5884    }
5885
5886    #[test]
5887    fn test_auto_query_prefix_disabled_when_input_format_set() {
5888        let cli = Cli {
5889            input: InputArgs {
5890                program: ProgramArgs {
5891                    input_format: Some(InputFormat::Raw),
5892                    ..Default::default()
5893                },
5894                ..Default::default()
5895            },
5896            ..Cli::default()
5897        };
5898        let file = Some(PathBuf::from("file.json"));
5899        assert_eq!(cli.auto_query_prefix(&file), None);
5900    }
5901
5902    #[test]
5903    fn test_auto_query_prefix_none_for_no_file() {
5904        let cli = Cli {
5905            input: InputArgs::default(),
5906            ..Cli::default()
5907        };
5908        assert_eq!(cli.auto_query_prefix(&None), None);
5909    }
5910
5911    #[rstest]
5912    #[case(InputFormat::Csv, None, false, r#"import "csv" | csv::csv_parse(true)"#)]
5913    #[case(InputFormat::Csv, None, true, r#"import "csv" | csv::csv_parse(false)"#)]
5914    #[case(
5915        InputFormat::Csv,
5916        Some(';'),
5917        false,
5918        r#"import "csv" | csv::csv_parse_with_delimiter(";", true)"#
5919    )]
5920    #[case(InputFormat::Tsv, None, true, r#"import "csv" | csv::tsv_parse(false)"#)]
5921    #[case(InputFormat::Psv, None, true, r#"import "csv" | csv::psv_parse(false)"#)]
5922    fn test_tabular_query_prefix(
5923        #[case] fmt: InputFormat,
5924        #[case] csv_delimiter: Option<char>,
5925        #[case] no_header: bool,
5926        #[case] expected: &str,
5927    ) {
5928        let cli = Cli {
5929            input: InputArgs {
5930                program: ProgramArgs {
5931                    input_format: Some(fmt.clone()),
5932                    csv_delimiter,
5933                    no_header,
5934                    ..Default::default()
5935                },
5936                ..Default::default()
5937            },
5938            ..Cli::default()
5939        };
5940        assert_eq!(cli.tabular_query_prefix(&fmt).as_deref(), Some(expected));
5941    }
5942
5943    #[test]
5944    fn test_tabular_query_prefix_none_for_non_tabular_format() {
5945        let cli = Cli {
5946            input: InputArgs::default(),
5947            ..Cli::default()
5948        };
5949        assert_eq!(cli.tabular_query_prefix(&InputFormat::Json), None);
5950    }
5951
5952    #[test]
5953    fn test_json_auto_parse() {
5954        let (_, temp_file_path) = create_file("auto_parse_test.json", r#"{"key": "value"}"#);
5955        let temp_file_path_clone = temp_file_path.clone();
5956        let (_, output_file) = create_file("auto_parse_output.md", "");
5957        let output_file_clone = output_file.clone();
5958
5959        defer! {
5960            if temp_file_path_clone.exists() {
5961                std::fs::remove_file(&temp_file_path_clone).ok();
5962            }
5963            if output_file_clone.exists() {
5964                std::fs::remove_file(&output_file_clone).ok();
5965            }
5966        }
5967
5968        let cli = Cli {
5969            input: InputArgs::default(),
5970            output: OutputArgs {
5971                output_format: Some(OutputFormat::Raw),
5972                output_file: Some(output_file.clone()),
5973                ..Default::default()
5974            },
5975            commands: None,
5976            query: Some("self".to_string()),
5977            files: Some(vec![temp_file_path]),
5978            ..Cli::default()
5979        };
5980
5981        assert!(cli.run().is_ok());
5982        let content = fs::read_to_string(&output_file).expect("Failed to read output");
5983        assert!(content.contains("value"), "JSON should be parsed automatically");
5984    }
5985
5986    #[test]
5987    fn test_csv_auto_parse() {
5988        let (_, temp_file_path) = create_file("auto_parse_test.csv", "name,age\nAlice,30\n");
5989        let temp_file_path_clone = temp_file_path.clone();
5990        let (_, output_file) = create_file("auto_parse_csv_output.md", "");
5991        let output_file_clone = output_file.clone();
5992
5993        defer! {
5994            if temp_file_path_clone.exists() {
5995                std::fs::remove_file(&temp_file_path_clone).ok();
5996            }
5997            if output_file_clone.exists() {
5998                std::fs::remove_file(&output_file_clone).ok();
5999            }
6000        }
6001
6002        let cli = Cli {
6003            input: InputArgs::default(),
6004            output: OutputArgs {
6005                output_format: Some(OutputFormat::Raw),
6006                output_file: Some(output_file.clone()),
6007                ..Default::default()
6008            },
6009            commands: None,
6010            query: Some("self".to_string()),
6011            files: Some(vec![temp_file_path]),
6012            ..Cli::default()
6013        };
6014
6015        assert!(cli.run().is_ok());
6016        let content = fs::read_to_string(&output_file).expect("Failed to read output");
6017        assert!(content.contains("Alice"), "CSV should be parsed automatically");
6018        assert!(content.contains("name"), "CSV header should be parsed");
6019    }
6020
6021    #[test]
6022    fn test_output_format_auto_detected_from_output_file_extension() {
6023        let (_, temp_file_path) = create_file("out_fmt_auto_input.md", "# Test");
6024        let (_, output_file) = create_file("out_fmt_auto_output.json", "");
6025        let temp_file_path_clone = temp_file_path.clone();
6026        let output_file_clone = output_file.clone();
6027
6028        defer! {
6029            if temp_file_path_clone.exists() {
6030                std::fs::remove_file(&temp_file_path_clone).ok();
6031            }
6032            if output_file_clone.exists() {
6033                std::fs::remove_file(&output_file_clone).ok();
6034            }
6035        }
6036
6037        // No -F given; the `.json` extension on -o should drive the output format.
6038        let cli = Cli {
6039            input: InputArgs::default(),
6040            output: OutputArgs {
6041                output_file: Some(output_file.clone()),
6042                ..Default::default()
6043            },
6044            commands: None,
6045            query: Some("self".to_string()),
6046            files: Some(vec![temp_file_path]),
6047            ..Cli::default()
6048        };
6049
6050        assert!(cli.run().is_ok());
6051        let output_content = fs::read_to_string(&output_file).expect("Failed to read output");
6052        let parsed: serde_json::Value = serde_json::from_str(&output_content).expect("Output should be valid JSON");
6053        assert!(parsed.is_array(), "extension-inferred JSON output should be an array");
6054        assert_eq!(parsed[0]["type"], "Heading");
6055    }
6056
6057    #[test]
6058    fn test_output_format_explicit_flag_overrides_extension() {
6059        let (_, temp_file_path) = create_file("out_fmt_override_input.md", "# Test");
6060        let (_, output_file) = create_file("out_fmt_override_output.json", "");
6061        let temp_file_path_clone = temp_file_path.clone();
6062        let output_file_clone = output_file.clone();
6063
6064        defer! {
6065            if temp_file_path_clone.exists() {
6066                std::fs::remove_file(&temp_file_path_clone).ok();
6067            }
6068            if output_file_clone.exists() {
6069                std::fs::remove_file(&output_file_clone).ok();
6070            }
6071        }
6072
6073        // -F yaml should win over the `.json` extension on -o.
6074        let cli = Cli {
6075            input: InputArgs::default(),
6076            output: OutputArgs {
6077                output_format: Some(OutputFormat::Yaml),
6078                output_file: Some(output_file.clone()),
6079                ..Default::default()
6080            },
6081            commands: None,
6082            query: Some("self".to_string()),
6083            files: Some(vec![temp_file_path]),
6084            ..Cli::default()
6085        };
6086
6087        assert!(cli.run().is_ok());
6088        let output_content = fs::read_to_string(&output_file).expect("Failed to read output");
6089        assert!(
6090            serde_json::from_str::<serde_json::Value>(&output_content).is_err(),
6091            "output should be YAML, not JSON"
6092        );
6093        assert!(output_content.contains("type: Heading") || output_content.contains("type: heading"));
6094    }
6095
6096    #[test]
6097    fn test_format_flag_sets_both_input_and_output() {
6098        // ".dat" isn't a recognized extension on either side, so without -T this
6099        // would parse as markdown and render as markdown.
6100        let (_, temp_file_path) = create_file("format_flag_input.dat", r#"{"key": "value"}"#);
6101        let (_, output_file) = create_file("format_flag_output.dat", "");
6102        let temp_file_path_clone = temp_file_path.clone();
6103        let output_file_clone = output_file.clone();
6104
6105        defer! {
6106            if temp_file_path_clone.exists() {
6107                std::fs::remove_file(&temp_file_path_clone).ok();
6108            }
6109            if output_file_clone.exists() {
6110                std::fs::remove_file(&output_file_clone).ok();
6111            }
6112        }
6113
6114        let cli = Cli {
6115            input: InputArgs::default(),
6116            output: OutputArgs {
6117                output_file: Some(output_file.clone()),
6118                ..Default::default()
6119            },
6120            format: Some(IoFormat::Json),
6121            commands: None,
6122            query: Some("self".to_string()),
6123            files: Some(vec![temp_file_path]),
6124            ..Cli::default()
6125        };
6126
6127        assert!(cli.run().is_ok());
6128        let output_content = fs::read_to_string(&output_file).expect("Failed to read output");
6129        let parsed: serde_json::Value = serde_json::from_str(&output_content).expect("Output should be valid JSON");
6130        assert_eq!(parsed["key"], "value");
6131    }
6132
6133    #[test]
6134    fn test_format_flag_overridden_by_explicit_input_and_output_format() {
6135        let (_, temp_file_path) = create_file("format_flag_override_input.dat", "# Test");
6136        let (_, output_file) = create_file("format_flag_override_output.dat", "");
6137        let temp_file_path_clone = temp_file_path.clone();
6138        let output_file_clone = output_file.clone();
6139
6140        defer! {
6141            if temp_file_path_clone.exists() {
6142                std::fs::remove_file(&temp_file_path_clone).ok();
6143            }
6144            if output_file_clone.exists() {
6145                std::fs::remove_file(&output_file_clone).ok();
6146            }
6147        }
6148
6149        let cli = Cli {
6150            input: InputArgs {
6151                program: ProgramArgs {
6152                    input_format: Some(InputFormat::Markdown),
6153                    ..Default::default()
6154                },
6155                ..Default::default()
6156            },
6157            output: OutputArgs {
6158                output_format: Some(OutputFormat::Table),
6159                output_file: Some(output_file.clone()),
6160                ..Default::default()
6161            },
6162            format: Some(IoFormat::Json),
6163            commands: None,
6164            query: Some("self".to_string()),
6165            files: Some(vec![temp_file_path]),
6166            ..Cli::default()
6167        };
6168
6169        assert!(cli.run().is_ok());
6170        let output_content = fs::read_to_string(&output_file).expect("Failed to read output");
6171        assert!(
6172            serde_json::from_str::<serde_json::Value>(&output_content).is_err(),
6173            "explicit -F table should win over -T json"
6174        );
6175    }
6176
6177    #[test]
6178    fn test_csv_delimiter_and_no_header_flags() {
6179        let (_, temp_file_path) = create_file("custom_delim_test.csv", "Alice;30\nBob;25\n");
6180        let temp_file_path_clone = temp_file_path.clone();
6181        let (_, output_file) = create_file("custom_delim_output.json", "");
6182        let output_file_clone = output_file.clone();
6183
6184        defer! {
6185            if temp_file_path_clone.exists() {
6186                std::fs::remove_file(&temp_file_path_clone).ok();
6187            }
6188            if output_file_clone.exists() {
6189                std::fs::remove_file(&output_file_clone).ok();
6190            }
6191        }
6192
6193        let cli = Cli {
6194            input: InputArgs {
6195                program: ProgramArgs {
6196                    input_format: Some(InputFormat::Csv),
6197                    csv_delimiter: Some(';'),
6198                    no_header: true,
6199                    ..Default::default()
6200                },
6201                ..Default::default()
6202            },
6203            output: OutputArgs {
6204                output_format: Some(OutputFormat::Json),
6205                output_file: Some(output_file.clone()),
6206                ..Default::default()
6207            },
6208            commands: None,
6209            query: Some("self".to_string()),
6210            files: Some(vec![temp_file_path]),
6211            ..Cli::default()
6212        };
6213
6214        assert!(cli.run().is_ok());
6215        let content = fs::read_to_string(&output_file).expect("Failed to read output");
6216        let json: serde_json::Value = serde_json::from_str(&content).expect("output should be valid JSON");
6217        // Headerless: every row is an array of values, not a dict keyed by a first-row header.
6218        assert_eq!(json, serde_json::json!([["Alice", "30"], ["Bob", "25"]]));
6219    }
6220
6221    fn create_binary_file(name: &str, content: &[u8]) -> (PathBuf, PathBuf) {
6222        let temp_dir = std::env::temp_dir();
6223        let temp_file_path = temp_dir.join(name);
6224        let mut file = File::create(&temp_file_path).expect("Failed to create temp file");
6225        file.write_all(content).expect("Failed to write to temp file");
6226        (temp_dir, temp_file_path)
6227    }
6228
6229    #[test]
6230    fn test_content_data_empty() {
6231        let c = ContentData::empty();
6232        assert_eq!(c.as_str(), Some(""));
6233        assert_eq!(c.as_bytes(), b"");
6234    }
6235
6236    #[rstest]
6237    #[case(ContentData::Text("hello".to_string()), Some("hello"))]
6238    #[case(ContentData::Text("".to_string()), Some(""))]
6239    #[case(ContentData::Bytes(vec![0xde, 0xad]), None)]
6240    #[case(ContentData::Bytes(vec![]), None)]
6241    fn test_content_data_as_str(#[case] input: ContentData, #[case] expected: Option<&str>) {
6242        assert_eq!(input.as_str(), expected);
6243    }
6244
6245    #[rstest]
6246    #[case(ContentData::Text("abc".to_string()), b"abc".as_ref())]
6247    #[case(ContentData::Text("".to_string()), b"".as_ref())]
6248    #[case(ContentData::Bytes(vec![0xde, 0xad, 0xbe, 0xef]), &[0xde, 0xad, 0xbe, 0xef])]
6249    #[case(ContentData::Bytes(vec![]), b"".as_ref())]
6250    fn test_content_data_as_bytes(#[case] input: ContentData, #[case] expected: &[u8]) {
6251        assert_eq!(input.as_bytes(), expected);
6252    }
6253
6254    #[rstest]
6255    #[case("hello".to_string(), Some("hello"))]
6256    #[case("".to_string(), Some(""))]
6257    fn test_content_data_from_string(#[case] s: String, #[case] expected_str: Option<&str>) {
6258        let c: ContentData = s.into();
6259        assert_eq!(c.as_str(), expected_str);
6260    }
6261
6262    #[rstest]
6263    #[case(vec![0x01, 0x02, 0x03])]
6264    #[case(vec![])]
6265    fn test_content_data_from_vec_u8(#[case] bytes: Vec<u8>) {
6266        let expected = bytes.clone();
6267        let c: ContentData = bytes.into();
6268        assert_eq!(c.as_str(), None);
6269        assert_eq!(c.as_bytes(), expected.as_slice());
6270    }
6271
6272    #[rstest]
6273    #[case(Some(InputFormat::Bytes), true)]
6274    #[case(Some(InputFormat::Cbor), true)]
6275    #[case(Some(InputFormat::Json), false)]
6276    #[case(Some(InputFormat::Yaml), false)]
6277    #[case(Some(InputFormat::Toml), false)]
6278    #[case(Some(InputFormat::Markdown), false)]
6279    #[case(Some(InputFormat::Raw), false)]
6280    #[case(Some(InputFormat::Text), false)]
6281    #[case(Some(InputFormat::Null), false)]
6282    #[case(None, false)]
6283    fn test_is_binary_format(#[case] fmt: Option<InputFormat>, #[case] expected: bool) {
6284        let cli = Cli {
6285            input: InputArgs {
6286                program: ProgramArgs {
6287                    input_format: fmt,
6288                    ..Default::default()
6289                },
6290                ..Default::default()
6291            },
6292            ..Cli::default()
6293        };
6294        assert_eq!(cli.is_binary_format(), expected);
6295    }
6296
6297    #[rstest]
6298    #[case(InputFormat::Bytes, None)]
6299    #[case(InputFormat::Cbor, Some(r#"import "cbor" | cbor::cbor_parse()"#))]
6300    #[case(InputFormat::Gron, Some(r#"import "gron" | gron::gron_parse()"#))]
6301    #[case(InputFormat::Json, Some(r#"import "json" | json::json_parse()"#))]
6302    #[case(InputFormat::Markdown, None)]
6303    #[case(InputFormat::Raw, None)]
6304    fn test_module_query_prefix(#[case] fmt: InputFormat, #[case] expected: Option<&str>) {
6305        assert_eq!(fmt.module_query_prefix(), expected);
6306    }
6307
6308    #[rstest]
6309    #[case(InputFormat::Bytes)]
6310    #[case(InputFormat::Cbor)]
6311    fn test_binary_format_streaming_returns_error(#[case] fmt: InputFormat) {
6312        let cli = Cli {
6313            input: InputArgs {
6314                program: ProgramArgs {
6315                    input_format: Some(fmt),
6316                    ..Default::default()
6317                },
6318                stream: true,
6319                ..Default::default()
6320            },
6321            query: Some("self".to_string()),
6322            ..Cli::default()
6323        };
6324        assert!(cli.run().is_err());
6325    }
6326
6327    #[rstest]
6328    #[case(&[0x01, 0x02, 0x03, 0xff], "self", "bytes_self")]
6329    #[case(&[0xca, 0xfe, 0xba, 0xbe], "self", "bytes_self2")]
6330    fn test_bytes_input_self_roundtrip(#[case] data: &[u8], #[case] query: &str, #[case] suffix: &str) {
6331        let (_, temp_file_path) = create_binary_file(&format!("test_{suffix}.bin"), data);
6332        let temp_file_path_clone = temp_file_path.clone();
6333        let (_, output_file) = create_file(&format!("test_{suffix}_out.md"), "");
6334        let output_file_clone = output_file.clone();
6335
6336        defer! {
6337            if temp_file_path_clone.exists() { std::fs::remove_file(&temp_file_path_clone).ok(); }
6338            if output_file_clone.exists() { std::fs::remove_file(&output_file_clone).ok(); }
6339        }
6340
6341        let cli = Cli {
6342            input: InputArgs {
6343                program: ProgramArgs {
6344                    input_format: Some(InputFormat::Bytes),
6345                    ..Default::default()
6346                },
6347                ..Default::default()
6348            },
6349            output: OutputArgs {
6350                output_format: Some(OutputFormat::Raw),
6351                output_file: Some(output_file.clone()),
6352                ..Default::default()
6353            },
6354            query: Some(query.to_string()),
6355            files: Some(vec![temp_file_path]),
6356            ..Cli::default()
6357        };
6358
6359        assert!(cli.run().is_ok());
6360        let result = fs::read(&output_file).expect("Failed to read output");
6361        assert_eq!(result, data);
6362    }
6363
6364    #[rstest]
6365    #[case(&[0xca, 0xfe, 0xba, 0xbe], "4")]
6366    #[case(&[0x01], "1")]
6367    #[case(&[], "0")]
6368    fn test_bytes_input_len(#[case] data: &[u8], #[case] expected_len: &str) {
6369        let suffix = format!("bytes_len_{}", data.len());
6370        let (_, temp_file_path) = create_binary_file(&format!("test_{suffix}.bin"), data);
6371        let temp_file_path_clone = temp_file_path.clone();
6372        let (_, output_file) = create_file(&format!("test_{suffix}_out.md"), "");
6373        let output_file_clone = output_file.clone();
6374
6375        defer! {
6376            if temp_file_path_clone.exists() { std::fs::remove_file(&temp_file_path_clone).ok(); }
6377            if output_file_clone.exists() { std::fs::remove_file(&output_file_clone).ok(); }
6378        }
6379
6380        let cli = Cli {
6381            input: InputArgs {
6382                program: ProgramArgs {
6383                    input_format: Some(InputFormat::Bytes),
6384                    ..Default::default()
6385                },
6386                ..Default::default()
6387            },
6388            output: OutputArgs {
6389                output_format: Some(OutputFormat::Raw),
6390                output_file: Some(output_file.clone()),
6391                ..Default::default()
6392            },
6393            query: Some("len()".to_string()),
6394            files: Some(vec![temp_file_path]),
6395            ..Cli::default()
6396        };
6397
6398        assert!(cli.run().is_ok());
6399        let result = fs::read_to_string(&output_file).expect("Failed to read output");
6400        assert_eq!(result.trim(), expected_len);
6401    }
6402
6403    #[rstest]
6404    // CBOR text string "hello": major 3 (text), len 5
6405    #[case(&[0x65, 0x68, 0x65, 0x6c, 0x6c, 0x6f], None, "hello", "cbor_auto_hello")]
6406    // CBOR integer 42: 0x18 0x2a
6407    #[case(&[0x18, 0x2a], Some(InputFormat::Cbor), "42", "cbor_explicit_42")]
6408    // CBOR integer 0: 0x00
6409    #[case(&[0x00], Some(InputFormat::Cbor), "0", "cbor_explicit_0")]
6410    fn test_cbor_parse(
6411        #[case] cbor_bytes: &[u8],
6412        #[case] fmt: Option<InputFormat>,
6413        #[case] expected: &str,
6414        #[case] suffix: &str,
6415    ) {
6416        let ext = if fmt.is_none() { "cbor" } else { "bin" };
6417        let (_, temp_file_path) = create_binary_file(&format!("test_{suffix}.{ext}"), cbor_bytes);
6418        let temp_file_path_clone = temp_file_path.clone();
6419        let (_, output_file) = create_file(&format!("test_{suffix}_out.md"), "");
6420        let output_file_clone = output_file.clone();
6421
6422        defer! {
6423            if temp_file_path_clone.exists() { std::fs::remove_file(&temp_file_path_clone).ok(); }
6424            if output_file_clone.exists() { std::fs::remove_file(&output_file_clone).ok(); }
6425        }
6426
6427        let cli = Cli {
6428            input: InputArgs {
6429                program: ProgramArgs {
6430                    input_format: fmt,
6431                    ..Default::default()
6432                },
6433                ..Default::default()
6434            },
6435            output: OutputArgs {
6436                output_format: Some(OutputFormat::Raw),
6437                output_file: Some(output_file.clone()),
6438                ..Default::default()
6439            },
6440            query: Some("self".to_string()),
6441            files: Some(vec![temp_file_path]),
6442            ..Cli::default()
6443        };
6444
6445        assert!(cli.run().is_ok());
6446        let result = fs::read_to_string(&output_file).expect("Failed to read output");
6447        assert!(
6448            result.trim().contains(expected),
6449            "expected '{}' in output, got '{}'",
6450            expected,
6451            result.trim()
6452        );
6453    }
6454
6455    #[rstest]
6456    #[case::data_only(
6457        "data_only",
6458        None,
6459        Some(vec!["x".to_string(), "y".to_string(), "z".to_string()]),
6460        "ARGS",
6461        r#"{"positional": ["x", "y", "z"], "named": {}}"#
6462    )]
6463    #[case::args_only(
6464        "args_only",
6465        Some(vec!["name".to_string(), "Alice".to_string()]),
6466        None,
6467        "ARGS",
6468        r#"{"positional": [], "named": {"name": "Alice"}}"#
6469    )]
6470    #[case::args_and_data(
6471        "args_and_data",
6472        Some(vec!["name".to_string(), "Alice".to_string()]),
6473        Some(vec!["x".to_string(), "y".to_string()]),
6474        "ARGS",
6475        r#"{"positional": ["x", "y"], "named": {"name": "Alice"}}"#
6476    )]
6477    #[case::positional_access(
6478        "positional_access",
6479        None,
6480        Some(vec!["a".to_string(), "b".to_string()]),
6481        r#"ARGS | ."positional""#,
6482        r#"["a", "b"]"#
6483    )]
6484    #[case::named_access(
6485        "named_access",
6486        Some(vec!["key".to_string(), "val".to_string()]),
6487        None,
6488        r#"ARGS | ."named""#,
6489        r#"{"key": "val"}"#
6490    )]
6491    #[case::named_individual_var(
6492        "named_individual_var",
6493        Some(vec!["greeting".to_string(), "hello".to_string()]),
6494        None,
6495        "greeting",
6496        "hello"
6497    )]
6498    fn test_args_and_data(
6499        #[case] suffix: &str,
6500        #[case] args: Option<Vec<String>>,
6501        #[case] argv: Option<Vec<String>>,
6502        #[case] query: &str,
6503        #[case] expected: &str,
6504    ) {
6505        let (_, output_file) = create_file(&format!("test_args_data_{suffix}.md"), "");
6506        let output_file_clone = output_file.clone();
6507
6508        defer! {
6509            if output_file_clone.exists() {
6510                std::fs::remove_file(&output_file_clone).ok();
6511            }
6512        }
6513
6514        let cli = Cli {
6515            input: InputArgs {
6516                program: ProgramArgs {
6517                    input_format: Some(InputFormat::Null),
6518                    ..Default::default()
6519                },
6520                args,
6521                ..Default::default()
6522            },
6523            output: OutputArgs {
6524                output_format: Some(OutputFormat::Raw),
6525                output_file: Some(output_file.clone()),
6526                ..Default::default()
6527            },
6528            query: Some(query.to_string()),
6529            argv,
6530            ..Cli::default()
6531        };
6532
6533        assert!(cli.run().is_ok());
6534        let result = fs::read_to_string(&output_file).expect("Failed to read output");
6535        assert_eq!(result.trim(), expected, "query: {}", query);
6536    }
6537
6538    #[rstest]
6539    #[case::string_value(
6540        "string_value",
6541        vec!["name".to_string(), r#""Alice""#.to_string()],
6542        "name",
6543        "Alice"
6544    )]
6545    #[case::number_value(
6546        "number_value",
6547        vec!["count".to_string(), "42".to_string()],
6548        "count",
6549        "42"
6550    )]
6551    #[case::bool_value(
6552        "bool_value",
6553        vec!["flag".to_string(), "true".to_string()],
6554        "flag",
6555        "true"
6556    )]
6557    #[case::null_value(
6558        "null_value",
6559        vec!["n".to_string(), "null".to_string()],
6560        "n",
6561        ""
6562    )]
6563    #[case::array_value(
6564        "array_value",
6565        vec!["list".to_string(), "[1,2,3]".to_string()],
6566        "list",
6567        r#"[1, 2, 3]"#
6568    )]
6569    #[case::object_value(
6570        "object_value",
6571        vec!["obj".to_string(), r#"{"a":1}"#.to_string()],
6572        "obj",
6573        r#"{"a": 1}"#
6574    )]
6575    #[case::args_named_access(
6576        "args_named_access",
6577        vec!["count".to_string(), "42".to_string()],
6578        r#"ARGS | ."named""#,
6579        r#"{"count": 42}"#
6580    )]
6581    fn test_argjson(#[case] suffix: &str, #[case] argjson: Vec<String>, #[case] query: &str, #[case] expected: &str) {
6582        let (_, output_file) = create_file(&format!("test_argjson_{suffix}.md"), "");
6583        let output_file_clone = output_file.clone();
6584
6585        defer! {
6586            if output_file_clone.exists() {
6587                std::fs::remove_file(&output_file_clone).ok();
6588            }
6589        }
6590
6591        let cli = Cli {
6592            input: InputArgs {
6593                program: ProgramArgs {
6594                    input_format: Some(InputFormat::Null),
6595                    ..Default::default()
6596                },
6597                argjson: Some(argjson),
6598                ..Default::default()
6599            },
6600            output: OutputArgs {
6601                output_format: Some(OutputFormat::Raw),
6602                output_file: Some(output_file.clone()),
6603                ..Default::default()
6604            },
6605            query: Some(query.to_string()),
6606            ..Cli::default()
6607        };
6608
6609        assert!(cli.run().is_ok());
6610        let result = fs::read_to_string(&output_file).expect("Failed to read output");
6611        assert_eq!(result.trim(), expected, "query: {}", query);
6612    }
6613
6614    #[test]
6615    fn test_argjson_invalid_json_errors() {
6616        let cli = Cli {
6617            input: InputArgs {
6618                program: ProgramArgs {
6619                    input_format: Some(InputFormat::Null),
6620                    ..Default::default()
6621                },
6622                argjson: Some(vec!["name".to_string(), "not valid json".to_string()]),
6623                ..Default::default()
6624            },
6625            query: Some("name".to_string()),
6626            ..Cli::default()
6627        };
6628
6629        assert!(
6630            cli.run().is_err(),
6631            "--argjson with malformed JSON should return an error"
6632        );
6633    }
6634
6635    #[test]
6636    fn test_argjson_combined_with_args_and_argv() {
6637        let (_, output_file) = create_file("test_argjson_combined.md", "");
6638        let output_file_clone = output_file.clone();
6639
6640        defer! {
6641            if output_file_clone.exists() {
6642                std::fs::remove_file(&output_file_clone).ok();
6643            }
6644        }
6645
6646        let cli = Cli {
6647            input: InputArgs {
6648                program: ProgramArgs {
6649                    input_format: Some(InputFormat::Null),
6650                    ..Default::default()
6651                },
6652                args: Some(vec!["name".to_string(), "Alice".to_string()]),
6653                argjson: Some(vec!["count".to_string(), "42".to_string()]),
6654                ..Default::default()
6655            },
6656            output: OutputArgs {
6657                output_format: Some(OutputFormat::Raw),
6658                output_file: Some(output_file.clone()),
6659                ..Default::default()
6660            },
6661            query: Some(r#"ARGS | ."named""#.to_string()),
6662            argv: Some(vec!["x".to_string()]),
6663            ..Cli::default()
6664        };
6665
6666        assert!(cli.run().is_ok());
6667        let result = fs::read_to_string(&output_file).expect("Failed to read output");
6668        // Compare parsed JSON values (rather than the raw string) so key order doesn't matter.
6669        let actual: serde_json::Value = serde_json::from_str(result.trim()).expect("output should be valid JSON");
6670        let expected: serde_json::Value = serde_json::from_str(r#"{"count": 42, "name": "Alice"}"#).unwrap();
6671        assert_eq!(actual, expected);
6672    }
6673
6674    #[rstest]
6675    #[case::single_object("single_object", r#"{"a": 1}"#, "data", r#"[{"a": 1}]"#)]
6676    #[case::single_array("single_array", "[1, 2, 3]", "data", r#"[[1, 2, 3]]"#)]
6677    #[case::multiple_concatenated_values("multiple_values", "1 2 3", "data", "[1, 2, 3]")]
6678    fn test_slurpfile(#[case] suffix: &str, #[case] file_content: &str, #[case] query: &str, #[case] expected: &str) {
6679        let (_, data_file) = create_file(&format!("test_slurpfile_{suffix}.json"), file_content);
6680        let (_, output_file) = create_file(&format!("test_slurpfile_{suffix}_out.md"), "");
6681        let data_file_clone = data_file.clone();
6682        let output_file_clone = output_file.clone();
6683
6684        defer! {
6685            std::fs::remove_file(&data_file_clone).ok();
6686            std::fs::remove_file(&output_file_clone).ok();
6687        }
6688
6689        let cli = Cli {
6690            input: InputArgs {
6691                program: ProgramArgs {
6692                    input_format: Some(InputFormat::Null),
6693                    ..Default::default()
6694                },
6695                slurp_file: Some(vec!["data".to_string(), data_file.to_string_lossy().to_string()]),
6696                ..Default::default()
6697            },
6698            output: OutputArgs {
6699                output_format: Some(OutputFormat::Raw),
6700                output_file: Some(output_file.clone()),
6701                ..Default::default()
6702            },
6703            query: Some(query.to_string()),
6704            ..Cli::default()
6705        };
6706
6707        assert!(cli.run().is_ok());
6708        let result = fs::read_to_string(&output_file).expect("Failed to read output");
6709        assert_eq!(result.trim(), expected, "query: {}", query);
6710    }
6711
6712    #[test]
6713    fn test_slurpfile_invalid_json_errors() {
6714        let (_, data_file) = create_file("test_slurpfile_invalid.json", "not valid json");
6715        let data_file_clone = data_file.clone();
6716
6717        defer! {
6718            std::fs::remove_file(&data_file_clone).ok();
6719        }
6720
6721        let cli = Cli {
6722            input: InputArgs {
6723                program: ProgramArgs {
6724                    input_format: Some(InputFormat::Null),
6725                    ..Default::default()
6726                },
6727                slurp_file: Some(vec!["data".to_string(), data_file.to_string_lossy().to_string()]),
6728                ..Default::default()
6729            },
6730            query: Some("data".to_string()),
6731            ..Cli::default()
6732        };
6733
6734        assert!(
6735            cli.run().is_err(),
6736            "--slurpfile with malformed JSON should return an error"
6737        );
6738    }
6739
6740    #[test]
6741    fn test_slurpfile_missing_file_errors() {
6742        let cli = Cli {
6743            input: InputArgs {
6744                program: ProgramArgs {
6745                    input_format: Some(InputFormat::Null),
6746                    ..Default::default()
6747                },
6748                slurp_file: Some(vec!["data".to_string(), "/nonexistent/path/to/file.json".to_string()]),
6749                ..Default::default()
6750            },
6751            query: Some("data".to_string()),
6752            ..Cli::default()
6753        };
6754
6755        assert!(
6756            cli.run().is_err(),
6757            "--slurpfile with a missing file should return an error"
6758        );
6759    }
6760
6761    #[test]
6762    fn test_missing_input_file_error_contains_path() {
6763        let cli = Cli {
6764            input: InputArgs::default(),
6765            query: Some(".h".to_string()),
6766            files: Some(vec![PathBuf::from("no_such_dir/missing_input.md")]),
6767            ..Cli::default()
6768        };
6769
6770        let err = cli.run().expect_err("missing input file should return an error");
6771        assert!(
6772            err.to_string().contains("no_such_dir/missing_input.md"),
6773            "error should contain the file path, got: {err}"
6774        );
6775    }
6776
6777    #[test]
6778    fn test_duplicate_stdin_argument_errors() {
6779        let cli = Cli {
6780            query: Some(".h".to_string()),
6781            files: Some(vec![PathBuf::from("-"), PathBuf::from("-")]),
6782            ..Cli::default()
6783        };
6784
6785        let err = cli.run().expect_err("`-` specified twice should return an error");
6786        assert!(err.to_string().contains("only once"), "unexpected error: {err}");
6787    }
6788
6789    #[test]
6790    fn test_repl_rejects_stdin_argument() {
6791        let cli = Cli {
6792            commands: Some(Commands::Repl {
6793                files: Some(vec![PathBuf::from("-")]),
6794            }),
6795            ..Cli::default()
6796        };
6797
6798        let err = cli.run().expect_err("repl with `-` should return an error");
6799        assert!(err.to_string().contains("repl"), "unexpected error: {err}");
6800    }
6801
6802    fn eval_file_vars(engine: &mut mq_lang::DefaultEngine) -> String {
6803        let compiled = engine.compile("__FILE__").unwrap();
6804        let result = engine
6805            .eval_compiled(&compiled, mq_lang::null_input().into_iter())
6806            .unwrap();
6807        result[0].to_string()
6808    }
6809
6810    #[test]
6811    fn test_set_file_vars_resets_for_stdin() {
6812        let cli = Cli::default();
6813        let mut engine = cli.create_engine().unwrap();
6814        let mut cache = ProgramCache::default();
6815        cli.set_file_vars(&mut engine, &mut cache, Some(Path::new("dir/a.md")));
6816        assert_eq!(eval_file_vars(&mut engine), "dir/a.md");
6817        cli.set_file_vars(&mut engine, &mut cache, None);
6818        assert_eq!(eval_file_vars(&mut engine), "");
6819    }
6820
6821    #[test]
6822    fn test_file_vars_default_to_empty_for_stdin() {
6823        let cli = Cli::default();
6824        let mut engine = cli.create_engine().unwrap();
6825        let mut cache = ProgramCache::default();
6826        cli.set_file_vars(&mut engine, &mut cache, None);
6827        assert_eq!(eval_file_vars(&mut engine), "");
6828    }
6829
6830    #[test]
6831    fn test_set_file_vars_keeps_user_global_for_stdin() {
6832        let cli = Cli {
6833            input: InputArgs {
6834                args: Some(vec!["__FILE__".to_string(), "custom".to_string()]),
6835                ..Default::default()
6836            },
6837            ..Cli::default()
6838        };
6839        let mut engine = cli.create_engine().unwrap();
6840        let mut cache = ProgramCache::default();
6841        cli.set_file_vars(&mut engine, &mut cache, None);
6842        assert_eq!(eval_file_vars(&mut engine), "custom");
6843    }
6844
6845    #[test]
6846    fn test_slurpfile_combined_with_args_and_argjson() {
6847        let (_, data_file) = create_file("test_slurpfile_combined.json", r#"{"c": 3}"#);
6848        let (_, output_file) = create_file("test_slurpfile_combined_out.md", "");
6849        let data_file_clone = data_file.clone();
6850        let output_file_clone = output_file.clone();
6851
6852        defer! {
6853            std::fs::remove_file(&data_file_clone).ok();
6854            std::fs::remove_file(&output_file_clone).ok();
6855        }
6856
6857        let cli = Cli {
6858            input: InputArgs {
6859                program: ProgramArgs {
6860                    input_format: Some(InputFormat::Null),
6861                    ..Default::default()
6862                },
6863                args: Some(vec!["name".to_string(), "Alice".to_string()]),
6864                argjson: Some(vec!["count".to_string(), "42".to_string()]),
6865                slurp_file: Some(vec!["data".to_string(), data_file.to_string_lossy().to_string()]),
6866                ..Default::default()
6867            },
6868            output: OutputArgs {
6869                output_format: Some(OutputFormat::Raw),
6870                output_file: Some(output_file.clone()),
6871                ..Default::default()
6872            },
6873            query: Some("data".to_string()),
6874            ..Cli::default()
6875        };
6876
6877        assert!(cli.run().is_ok());
6878        let result = fs::read_to_string(&output_file).expect("Failed to read output");
6879        assert_eq!(result.trim(), r#"[{"c": 3}]"#, "query: data");
6880    }
6881
6882    #[test]
6883    fn test_files_without_data_single_file() {
6884        let (_, input_file) = create_file("test_files_no_data_single.md", "# hello");
6885        let (_, output_file) = create_file("test_files_no_data_single_out.md", "");
6886        let input_file_clone = input_file.clone();
6887        let output_file_clone = output_file.clone();
6888
6889        defer! {
6890            if input_file_clone.exists() { std::fs::remove_file(&input_file_clone).ok(); }
6891            if output_file_clone.exists() { std::fs::remove_file(&output_file_clone).ok(); }
6892        }
6893
6894        let cli = Cli {
6895            input: InputArgs::default(),
6896            output: OutputArgs {
6897                output_format: Some(OutputFormat::Text),
6898                output_file: Some(output_file.clone()),
6899                ..Default::default()
6900            },
6901            query: Some("self".to_string()),
6902            files: Some(vec![input_file]),
6903            argv: None,
6904            ..Cli::default()
6905        };
6906
6907        assert!(cli.run().is_ok());
6908        let result = fs::read_to_string(&output_file).expect("Failed to read output");
6909        assert!(result.contains("hello"), "file content should be processed");
6910    }
6911
6912    #[test]
6913    fn test_files_without_data_multiple_files() {
6914        // Verify each file is processed independently; output_file is per-run so check each separately.
6915        let (_, file1) = create_file("test_files_no_data_multi1.md", "# file1");
6916        let (_, file2) = create_file("test_files_no_data_multi2.md", "# file2");
6917        let (_, out1) = create_file("test_files_no_data_multi_out1.md", "");
6918        let (_, out2) = create_file("test_files_no_data_multi_out2.md", "");
6919        let file1_clone = file1.clone();
6920        let file2_clone = file2.clone();
6921        let out1_clone = out1.clone();
6922        let out2_clone = out2.clone();
6923
6924        defer! {
6925            if file1_clone.exists() { std::fs::remove_file(&file1_clone).ok(); }
6926            if file2_clone.exists() { std::fs::remove_file(&file2_clone).ok(); }
6927            if out1_clone.exists() { std::fs::remove_file(&out1_clone).ok(); }
6928            if out2_clone.exists() { std::fs::remove_file(&out2_clone).ok(); }
6929        }
6930
6931        for (input, output, expected) in [(&file1, &out1, "file1"), (&file2, &out2, "file2")] {
6932            let cli = Cli {
6933                input: InputArgs::default(),
6934                output: OutputArgs {
6935                    output_format: Some(OutputFormat::Text),
6936                    output_file: Some(output.clone()),
6937                    ..Default::default()
6938                },
6939                query: Some("self".to_string()),
6940                files: Some(vec![input.clone()]),
6941                argv: None,
6942                ..Cli::default()
6943            };
6944            assert!(cli.run().is_ok());
6945            let result = fs::read_to_string(output).expect("Failed to read output");
6946            assert!(result.contains(expected), "file content '{}' should appear", expected);
6947        }
6948
6949        // Also verify multi-file run (no output_file) succeeds without error
6950        let cli = Cli {
6951            input: InputArgs::default(),
6952            output: OutputArgs::default(),
6953            query: Some("self".to_string()),
6954            files: Some(vec![file1, file2]),
6955            argv: None,
6956            ..Cli::default()
6957        };
6958        assert!(cli.run().is_ok(), "multi-file run without --argv should succeed");
6959    }
6960
6961    #[test]
6962    fn test_files_glob_pattern_expansion() {
6963        let (temp_dir, file1) = create_file("mq_glob_expand_test_a.md", "# File One");
6964        let (_, file2) = create_file("mq_glob_expand_test_b.md", "# File Two");
6965        let output_file = temp_dir.join("mq_glob_expand_result.md");
6966        let file1_clone = file1.clone();
6967        let file2_clone = file2.clone();
6968        let output_clone = output_file.clone();
6969
6970        defer! {
6971            if file1_clone.exists() { std::fs::remove_file(&file1_clone).ok(); }
6972            if file2_clone.exists() { std::fs::remove_file(&file2_clone).ok(); }
6973            if output_clone.exists() { std::fs::remove_file(&output_clone).ok(); }
6974        }
6975
6976        let pattern = temp_dir.join("mq_glob_expand_test_*.md");
6977        let cli = Cli {
6978            input: InputArgs {
6979                program: ProgramArgs {
6980                    aggregate: true,
6981                    ..Default::default()
6982                },
6983                eval_all: true,
6984                ..Default::default()
6985            },
6986            output: OutputArgs {
6987                output_format: Some(OutputFormat::Text),
6988                output_file: Some(output_file.clone()),
6989                ..Default::default()
6990            },
6991            query: Some(".h | len".to_string()),
6992            files: Some(vec![pattern]),
6993            argv: None,
6994            ..Cli::default()
6995        };
6996
6997        assert!(cli.run().is_ok(), "glob pattern should expand and run successfully");
6998        let result = fs::read_to_string(&output_file).expect("Failed to read output");
6999        assert_eq!(result.trim(), "2", "both files matched by the glob should be counted");
7000    }
7001
7002    #[test]
7003    fn test_files_multiple_glob_patterns_expansion() {
7004        let (temp_dir, file1) = create_file("mq_glob_multi_test_a.md", "# File One");
7005        let (_, file2) = create_file("mq_glob_multi_other_b.md", "# File Two");
7006        let output_file = temp_dir.join("mq_glob_multi_result.md");
7007        let file1_clone = file1.clone();
7008        let file2_clone = file2.clone();
7009        let output_clone = output_file.clone();
7010
7011        defer! {
7012            if file1_clone.exists() { std::fs::remove_file(&file1_clone).ok(); }
7013            if file2_clone.exists() { std::fs::remove_file(&file2_clone).ok(); }
7014            if output_clone.exists() { std::fs::remove_file(&output_clone).ok(); }
7015        }
7016
7017        let pattern1 = temp_dir.join("mq_glob_multi_test_*.md");
7018        let pattern2 = temp_dir.join("mq_glob_multi_other_*.md");
7019        let cli = Cli {
7020            input: InputArgs {
7021                program: ProgramArgs {
7022                    aggregate: true,
7023                    ..Default::default()
7024                },
7025                eval_all: true,
7026                ..Default::default()
7027            },
7028            output: OutputArgs {
7029                output_format: Some(OutputFormat::Text),
7030                output_file: Some(output_file.clone()),
7031                ..Default::default()
7032            },
7033            query: Some(".h | len".to_string()),
7034            files: Some(vec![pattern1, pattern2]),
7035            argv: None,
7036            ..Cli::default()
7037        };
7038
7039        assert!(
7040            cli.run().is_ok(),
7041            "multiple glob patterns should expand and run successfully"
7042        );
7043        let result = fs::read_to_string(&output_file).expect("Failed to read output");
7044        assert_eq!(
7045            result.trim(),
7046            "2",
7047            "files matched by both glob patterns should be counted"
7048        );
7049    }
7050
7051    #[test]
7052    fn test_files_glob_pattern_no_matches_falls_through_as_literal() {
7053        let cli = Cli {
7054            input: InputArgs::default(),
7055            output: OutputArgs::default(),
7056            query: Some("self".to_string()),
7057            files: Some(vec![PathBuf::from("/nonexistent/mq_glob_no_match_*.md")]),
7058            argv: None,
7059            ..Cli::default()
7060        };
7061
7062        assert!(
7063            cli.run().is_err(),
7064            "non-matching glob pattern should surface a file error"
7065        );
7066    }
7067
7068    #[test]
7069    fn test_files_with_data_does_not_mix() {
7070        // --argv values must not be treated as files, and files must not appear in ARGS
7071        let (_, input_file) = create_file("test_files_with_data.md", "# content");
7072        let (_, output_file) = create_file("test_files_with_data_out.md", "");
7073        let input_file_clone = input_file.clone();
7074        let output_file_clone = output_file.clone();
7075
7076        defer! {
7077            if input_file_clone.exists() { std::fs::remove_file(&input_file_clone).ok(); }
7078            if output_file_clone.exists() { std::fs::remove_file(&output_file_clone).ok(); }
7079        }
7080
7081        let cli = Cli {
7082            input: InputArgs::default(),
7083            output: OutputArgs {
7084                output_format: Some(OutputFormat::Raw),
7085                output_file: Some(output_file.clone()),
7086                ..Default::default()
7087            },
7088            query: Some("ARGS".to_string()),
7089            files: Some(vec![input_file]),
7090            argv: Some(vec!["alpha".to_string(), "beta".to_string()]),
7091            ..Cli::default()
7092        };
7093
7094        assert!(cli.run().is_ok());
7095        let result = fs::read_to_string(&output_file).expect("Failed to read output");
7096        assert!(result.contains("alpha"), "ARGS.positional should contain 'alpha'");
7097        assert!(result.contains("beta"), "ARGS.positional should contain 'beta'");
7098        assert!(!result.contains("content"), "file content must not appear in ARGS");
7099    }
7100
7101    #[test]
7102    fn test_files_without_data_args_undefined() {
7103        // Without --argv or --args, ARGS must be undefined (runtime error expected)
7104        let (_, input_file) = create_file("test_files_no_args_undefined.md", "# x");
7105        let input_file_clone = input_file.clone();
7106
7107        defer! {
7108            if input_file_clone.exists() { std::fs::remove_file(&input_file_clone).ok(); }
7109        }
7110
7111        let cli = Cli {
7112            input: InputArgs::default(),
7113            output: OutputArgs::default(),
7114            query: Some("ARGS".to_string()),
7115            files: Some(vec![input_file]),
7116            argv: None,
7117            ..Cli::default()
7118        };
7119
7120        assert!(
7121            cli.run().is_err(),
7122            "ARGS should be undefined when neither --args nor --argv is given"
7123        );
7124    }
7125
7126    #[test]
7127    fn test_files_with_data_file_content_processed() {
7128        // File content is still processed even when --argv is given
7129        let (_, input_file) = create_file("test_files_data_content.md", "# heading");
7130        let (_, output_file) = create_file("test_files_data_content_out.md", "");
7131        let input_file_clone = input_file.clone();
7132        let output_file_clone = output_file.clone();
7133
7134        defer! {
7135            if input_file_clone.exists() { std::fs::remove_file(&input_file_clone).ok(); }
7136            if output_file_clone.exists() { std::fs::remove_file(&output_file_clone).ok(); }
7137        }
7138
7139        let cli = Cli {
7140            input: InputArgs::default(),
7141            output: OutputArgs {
7142                output_format: Some(OutputFormat::Text),
7143                output_file: Some(output_file.clone()),
7144                ..Default::default()
7145            },
7146            query: Some("self".to_string()),
7147            files: Some(vec![input_file]),
7148            argv: Some(vec!["x".to_string()]),
7149            ..Cli::default()
7150        };
7151
7152        assert!(cli.run().is_ok());
7153        let result = fs::read_to_string(&output_file).expect("Failed to read output");
7154        assert!(
7155            result.contains("heading"),
7156            "file content should still be processed when --argv is given"
7157        );
7158    }
7159
7160    #[test]
7161    fn test_args_pair_works() {
7162        let cli = Cli {
7163            input: InputArgs {
7164                program: ProgramArgs {
7165                    input_format: Some(InputFormat::Null),
7166                    ..Default::default()
7167                },
7168                args: Some(vec!["name".to_string(), "Alice".to_string()]),
7169                ..Default::default()
7170            },
7171            query: Some("name".to_string()),
7172            ..Cli::default()
7173        };
7174        assert!(cli.run().is_ok(), "--args with a valid NAME VALUE pair should succeed");
7175    }
7176
7177    #[cfg(feature = "watch")]
7178    #[test]
7179    fn test_watch_targets_requires_a_file() {
7180        let cli = Cli {
7181            files: None,
7182            query: Some("self".to_string()),
7183            ..Cli::default()
7184        };
7185        assert!(
7186            cli.watch_targets().is_err(),
7187            "--watch on stdin (no files) should be rejected"
7188        );
7189    }
7190
7191    #[cfg(feature = "watch")]
7192    #[test]
7193    fn test_watch_targets_includes_input_files() {
7194        let file = PathBuf::from("input.md");
7195        let cli = Cli {
7196            files: Some(vec![file.clone()]),
7197            query: Some("self".to_string()),
7198            ..Cli::default()
7199        };
7200        assert_eq!(cli.watch_targets().unwrap(), vec![file]);
7201    }
7202
7203    #[cfg(feature = "watch")]
7204    #[test]
7205    fn test_watch_targets_includes_query_file_when_from_file() {
7206        let input_file = PathBuf::from("input.md");
7207        let query_file = "query.mq";
7208        let cli = Cli {
7209            input: InputArgs {
7210                from_file: true,
7211                ..Default::default()
7212            },
7213            files: Some(vec![input_file.clone()]),
7214            query: Some(query_file.to_string()),
7215            ..Cli::default()
7216        };
7217        assert_eq!(
7218            cli.watch_targets().unwrap(),
7219            vec![input_file, PathBuf::from(query_file)]
7220        );
7221    }
7222}