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
25static HAD_TRUTHY_OUTPUT: AtomicBool = AtomicBool::new(false);
30
31static 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
42type ProgramKey = (Option<String>, Option<String>);
44
45#[derive(Default)]
47struct ProgramCache {
48 programs: Vec<(ProgramKey, mq_lang::CompiledProgram)>,
50 separator: Option<mq_lang::CompiledProgram>,
51 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 #[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 #[arg(short = 'T', long = "format", value_enum)]
100 format: Option<IoFormat>,
101
102 #[clap(subcommand)]
103 commands: Option<Commands>,
104
105 #[arg(long)]
107 list: bool,
108
109 #[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 #[arg(long = "argv", num_args = 0..)]
119 argv: Option<Vec<String>>,
120
121 #[arg(long, value_name = "SECONDS", value_parser = parse_timeout, allow_negative_numbers = true)]
124 timeout: Option<Duration>,
125
126 #[arg(long, default_value_t = false)]
128 stack_trace: bool,
129
130 #[arg(long, value_enum, default_value_t = ErrorFormat::Human)]
132 error_format: ErrorFormat,
133
134 #[cfg(feature = "debugger")]
136 #[arg(long = "stop-on-error", default_value_t = false)]
137 stop_on_error: bool,
138
139 #[cfg(feature = "debug-trace")]
141 #[arg(long = "dump-stack", default_value_t = false)]
142 dump_stack: bool,
143
144 #[cfg(feature = "vm-profile")]
146 #[arg(long = "vm-profile", default_value_t = false)]
147 vm_profile: bool,
148
149 #[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 #[arg(skip)]
164 bytecode: std::sync::OnceLock<mq_lang::Mqc>,
165}
166
167#[cfg(unix)]
168const UNIX_EXECUTABLE_BITS: u32 = 0o111;
169
170#[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 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
269enum 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#[derive(Clone, Copy, Debug, Default, PartialEq, clap::ValueEnum)]
309pub enum ErrorFormat {
310 #[default]
312 Human,
313 Json,
315}
316
317#[derive(Clone, Debug, clap::ValueEnum, PartialEq)]
319enum SandboxProfile {
320 Strict,
322 ReadOnly,
324 Networked,
326 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#[derive(Clone, Debug, clap::Args, Default, PartialEq)]
448struct ProgramArgs {
449 #[arg(short = 'A', long, default_value_t = false)]
451 aggregate: bool,
452
453 #[arg(short = 'I', long, value_enum)]
455 input_format: Option<InputFormat>,
456
457 #[arg(long = "csv-delimiter", value_name = "CHAR")]
461 csv_delimiter: Option<char>,
462
463 #[arg(long = "no-header", default_value_t = false)]
467 no_header: bool,
468
469 #[arg(short = 'L', long = "directory")]
471 module_directories: Option<Vec<PathBuf>>,
472
473 #[arg(short = 'M', long)]
475 module_names: Option<Vec<String>>,
476
477 #[arg(short = 'm', long)]
479 import_module_names: Option<Vec<String>>,
480
481 #[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 #[cfg(feature = "http-import")]
492 #[arg(long = "allowed-domain", hide = true)]
493 allowed_domains: Option<Vec<String>>,
494
495 #[cfg(feature = "http-import")]
498 #[arg(long = "refresh-modules", default_value_t = false)]
499 refresh_modules: bool,
500
501 #[cfg(feature = "http-import")]
504 #[arg(long = "clear-cache", default_value_t = false)]
505 clear_cache: bool,
506
507 #[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 #[cfg(feature = "http-import")]
517 #[arg(long = "frozen", default_value_t = false)]
518 frozen: bool,
519
520 #[cfg(feature = "http-import")]
523 #[arg(long = "lockfile", value_name = "PATH")]
524 lockfile_path: Option<PathBuf>,
525
526 #[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 #[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 #[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 #[arg(long = "allow-run", num_args = 0.., require_equals = true, value_delimiter = ',', value_name = "COMMAND")]
563 allow_run: Option<Vec<String>>,
564
565 #[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 #[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 #[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 #[arg(short, long, default_value_t = false)]
602 from_file: bool,
603
604 #[arg(long, num_args = 2, value_names = ["NAME", "VALUE"], aliases = ["arg", "define"])]
607 args: Option<Vec<String>>,
608
609 #[arg(long, num_args = 2, value_names = ["NAME", "JSON_VALUE"])]
611 argjson: Option<Vec<String>>,
612
613 #[arg(long="rawfile", num_args = 2, value_names = ["NAME", "FILE"])]
615 raw_file: Option<Vec<String>>,
616
617 #[arg(long = "slurpfile", num_args = 2, value_names = ["NAME", "FILE"])]
621 slurp_file: Option<Vec<String>>,
622
623 #[arg(long, default_value_t = false)]
625 stream: bool,
626
627 #[cfg(feature = "watch")]
632 #[arg(long, default_value_t = false)]
633 watch: bool,
634
635 #[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 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 #[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 #[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 #[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 #[arg(short = 'F', long, value_enum, conflicts_with = "quiet")]
697 output_format: Option<OutputFormat>,
698
699 #[arg(short = 'U', long = "update", default_value_t = false)]
701 update: bool,
702
703 #[arg(long = "diff", default_value_t = false, requires = "update")]
707 diff: bool,
708
709 #[clap(long, default_value_t = false, conflicts_with = "quiet")]
711 unbuffered: bool,
712
713 #[clap(long, value_enum, default_value_t = ListStyle::Dash, conflicts_with = "quiet")]
715 list_style: ListStyle,
716
717 #[clap(long, value_enum, default_value_t = LinkTitleStyle::Double, conflicts_with = "quiet")]
719 link_title_style: LinkTitleStyle,
720
721 #[clap(long, value_enum, default_value_t = LinkUrlStyle::None, conflicts_with = "quiet")]
723 link_url_style: LinkUrlStyle,
724
725 #[clap(short = 'S', long, value_name = "QUERY", conflicts_with = "quiet")]
727 separator: Option<String>,
728
729 #[clap(short = 'o', long = "output", value_name = "FILE", conflicts_with = "quiet")]
731 output_file: Option<PathBuf>,
732
733 #[clap(long, value_enum, default_value_t = AtomicOutput::Auto, requires = "output_file", conflicts_with = "quiet")]
736 atomic_output: AtomicOutput,
737
738 #[clap(long, default_value_t = false, requires = "output_file", conflicts_with_all = ["append", "quiet"])]
740 no_clobber: bool,
741
742 #[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 #[clap(skip)]
754 output_claimed: std::sync::Arc<AtomicBool>,
755
756 #[arg(
758 short = 'C',
759 long = "color-output",
760 default_value_t = false,
761 conflicts_with = "quiet"
762 )]
763 color_output: bool,
764
765 #[clap(short = 'B', long, value_name = "NUM", conflicts_with = "quiet")]
767 before_context: Option<usize>,
768
769 #[clap(long, value_name = "NUM", conflicts_with = "quiet")]
771 after_context: Option<usize>,
772
773 #[clap(long, value_name = "NUM", conflicts_with = "quiet")]
775 context: Option<usize>,
776
777 #[arg(short = 'e', long = "exit-status", default_value_t = false)]
780 exit_status: bool,
781
782 #[arg(short = 'c', long = "count", default_value_t = false, conflicts_with_all = ["update", "stream", "quiet"])]
785 count: bool,
786
787 #[arg(long, value_name = "N", conflicts_with_all = ["update", "quiet"])]
789 skip: Option<usize>,
790
791 #[arg(long, value_name = "N", conflicts_with_all = ["update", "quiet"])]
793 limit: Option<usize>,
794
795 #[arg(long, default_value_t = false, conflicts_with = "quiet")]
799 no_position: bool,
800
801 #[arg(long, default_value_t = false, conflicts_with = "quiet")]
803 compact: bool,
804
805 #[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 #[arg(long, default_value_t = false, conflicts_with_all = ["indent", "compact", "quiet"])]
818 tab: bool,
819}
820
821impl OutputArgs {
822 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 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 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 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 Repl {
868 files: Option<Vec<PathBuf>>,
870 },
871 #[cfg(feature = "debugger")]
873 Dap,
874 Completion {
876 #[arg(value_enum)]
878 shell: CompletionShell,
879 },
880 Help {
883 name: Option<String>,
886 #[arg(long, conflicts_with = "markdown")]
888 json: bool,
889 #[arg(long)]
892 markdown: bool,
893 },
894 #[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 #[arg(value_name = "QUERY OR FILE")]
901 query: String,
902 #[arg(short, long)]
904 from_file: bool,
905 #[arg(short, long)]
907 output: Option<PathBuf>,
908 #[clap(flatten)]
909 program: Box<ProgramArgs>,
910 },
911}
912
913#[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#[derive(Clone, Copy)]
927enum HelpFormat {
928 Human,
929 Json,
930 Markdown,
931}
932
933impl Cli {
934 const EXAMPLES_TOPIC: &'static str = "examples";
937
938 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 fn find_external_commands() -> Vec<String> {
951 let mut seen = std::collections::HashSet::new();
952
953 if let Some(bin_dir) = Self::get_external_commands_dir() {
955 Self::collect_mq_commands_from_dir(&bin_dir, &mut seen);
956 }
957
958 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 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 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 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 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 #[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 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 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 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 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 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 if !name.contains("::")
1195 && let Some(module) = help::lookup_module(name)
1196 {
1197 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 InputFormat::Bytes => mq_lang::bytes_input(content.as_bytes()),
2172 InputFormat::Cbor => mq_lang::bytes_input(content.as_bytes()),
2174 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 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 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 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 #[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 fn program_key(&self, file: &Option<PathBuf>) -> ProgramKey {
2356 (self.auto_query_prefix(file), self.mqc_input_format(file))
2357 }
2358
2359 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 #[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 #[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 #[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 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 if files.len() > self.parallel_threshold && !self.output.append {
2540 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 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 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 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 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 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 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 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 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 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 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 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 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 _ => None,
3000 },
3001 _ => None,
3002 }
3003 }
3004
3005 fn is_falsy(value: &mq_lang::RuntimeValue) -> bool {
3012 matches!(value, mq_lang::RuntimeValue::Boolean(false)) || value.is_empty()
3013 }
3014
3015 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 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 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 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 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 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 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 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 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 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 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 let commands = Cli::find_external_commands();
4214 assert!(commands.iter().all(|cmd| !cmd.is_empty()));
4216 }
4217
4218 #[test]
4219 fn test_get_external_commands_dir() {
4220 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 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 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 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 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 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 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 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 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]
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 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]
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]
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]
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 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)] 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)] #[case("file.gz", InputFormat::Markdown)] #[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)] #[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)] 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 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 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 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 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 #[case(&[0x65, 0x68, 0x65, 0x6c, 0x6c, 0x6f], None, "hello", "cbor_auto_hello")]
6406 #[case(&[0x18, 0x2a], Some(InputFormat::Cbor), "42", "cbor_explicit_42")]
6408 #[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 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 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 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 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 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 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}