1use clap::{Parser, Subcommand};
2use colored::Colorize;
3use glob::glob;
4use miette::IntoDiagnostic;
5use miette::miette;
6use mq_lang::DefaultEngine;
7use rayon::prelude::*;
8use std::io::BufRead;
9use std::io::IsTerminal;
10use std::io::{self, BufWriter, Read, Write};
11use std::path::Path;
12use std::process::Command;
13use std::str::FromStr;
14use std::{fs, path::PathBuf};
15use which::which;
16
17use crate::grep;
18
19#[derive(Parser, Debug, Default)]
20#[command(name = "mq")]
21#[command(author = env!("CARGO_PKG_AUTHORS"))]
22#[command(version = env!("CARGO_PKG_VERSION"))]
23#[command(after_help = "# Examples:\n\n\
24 ## To filter markdown nodes:\n\
25 mq 'query' file.md\n\n\
26 ## To read query from file:\n\
27 mq -f 'file' file.md\n\n\
28 ## To start a REPL session:\n\
29 mq repl\n\n\
30 ## To format mq file:\n\
31 mq fmt --check file.mq")]
32#[command(
33 about = "mq is a markdown processor that can filter markdown nodes by using jq-like syntax.",
34 long_about = None
35)]
36pub struct Cli {
37 #[clap(flatten)]
38 input: InputArgs,
39
40 #[clap(flatten)]
41 output: OutputArgs,
42
43 #[clap(subcommand)]
44 commands: Option<Commands>,
45
46 #[arg(long)]
48 list: bool,
49
50 #[arg(short = 'P', default_value_t = 10)]
52 parallel_threshold: usize,
53
54 #[arg(value_name = "QUERY OR FILE")]
55 query: Option<String>,
56 files: Option<Vec<PathBuf>>,
57}
58
59#[cfg(unix)]
60const UNIX_EXECUTABLE_BITS: u32 = 0o111;
61
62#[derive(Clone, Debug, Default, clap::ValueEnum, PartialEq)]
70enum InputFormat {
71 #[default]
72 Markdown,
73 Mdx,
74 Html,
75 Text,
76 Null,
77 Raw,
78}
79
80impl InputFormat {
81 fn from_extension(ext: &str) -> Self {
82 match ext.to_lowercase().as_str() {
83 "md" | "markdown" => Self::Markdown,
84 "mdx" => Self::Mdx,
85 "html" | "htm" => Self::Html,
86 "txt" | "log" | "csv" | "psv" | "tsv" | "toon" | "json" | "toml" | "yaml" | "yml" | "xml" => Self::Raw,
87 "jsonl" | "ndjson" => Self::Text,
88 _ => Self::Markdown,
89 }
90 }
91}
92
93#[derive(Clone, Debug, Default, clap::ValueEnum)]
94enum OutputFormat {
95 #[default]
96 Markdown,
97 Html,
98 Text,
99 Json,
100 Table,
101 Grep,
102 None,
103}
104
105#[derive(Debug, Clone, Default, clap::ValueEnum)]
106pub enum ListStyle {
107 #[default]
108 Dash,
109 Plus,
110 Star,
111}
112
113#[derive(Debug, Clone, PartialEq, Default, clap::ValueEnum)]
114pub enum LinkTitleStyle {
115 #[default]
116 Double,
117 Single,
118 Paren,
119}
120
121#[derive(Debug, Clone, PartialEq, Default, clap::ValueEnum)]
122pub enum LinkUrlStyle {
123 #[default]
124 None,
125 Angle,
126}
127
128#[derive(Clone, Debug, clap::Args, Default)]
129struct InputArgs {
130 #[arg(short = 'A', long, default_value_t = false)]
132 aggregate: bool,
133
134 #[arg(short, long, default_value_t = false)]
136 from_file: bool,
137
138 #[arg(short = 'I', long, value_enum)]
140 input_format: Option<InputFormat>,
141
142 #[arg(short = 'L', long = "directory")]
144 module_directories: Option<Vec<PathBuf>>,
145
146 #[arg(short = 'M', long)]
148 module_names: Option<Vec<String>>,
149
150 #[arg(short = 'm', long)]
152 import_module_names: Option<Vec<String>>,
153
154 #[arg(long, value_names = ["NAME", "VALUE"])]
156 args: Option<Vec<String>>,
157
158 #[arg(long="rawfile", value_names = ["NAME", "FILE"])]
160 raw_file: Option<Vec<String>>,
161
162 #[arg(long, default_value_t = false)]
164 stream: bool,
165}
166
167#[derive(Clone, Debug, clap::Args, Default)]
168struct OutputArgs {
169 #[arg(short = 'F', long, value_enum, default_value_t)]
171 output_format: OutputFormat,
172
173 #[arg(
175 short = 'U',
176 long = "update",
177 short_alias='i',
178 aliases=["in-place", "inplace"],
179 default_value_t = false
180 )]
181 update: bool,
182
183 #[clap(long, default_value_t = false)]
185 unbuffered: bool,
186
187 #[clap(long, value_enum, default_value_t = ListStyle::Dash)]
189 list_style: ListStyle,
190
191 #[clap(long, value_enum, default_value_t = LinkTitleStyle::Double)]
193 link_title_style: LinkTitleStyle,
194
195 #[clap(long, value_enum, default_value_t = LinkUrlStyle::None)]
197 link_url_style: LinkUrlStyle,
198
199 #[clap(short = 'S', long, value_name = "QUERY")]
201 separator: Option<String>,
202
203 #[clap(short = 'o', long = "output", value_name = "FILE")]
205 output_file: Option<PathBuf>,
206
207 #[arg(short = 'C', long = "color-output", default_value_t = false)]
209 color_output: bool,
210
211 #[clap(short = 'B', long, value_name = "NUM")]
213 before_context: Option<usize>,
214
215 #[clap(long, value_name = "NUM")]
217 after_context: Option<usize>,
218
219 #[clap(long, value_name = "NUM")]
221 context: Option<usize>,
222}
223
224impl OutputArgs {
225 fn context_counts(&self) -> (usize, usize) {
228 let base = self.context.unwrap_or(0);
229 let before = self.before_context.unwrap_or(base);
230 let after = self.after_context.unwrap_or(base);
231 (before, after)
232 }
233}
234
235#[derive(Debug, Subcommand)]
236enum Commands {
237 Repl,
239 Fmt {
241 #[arg(short, long, default_value_t = 2)]
243 indent_width: usize,
244 #[arg(short, long)]
246 check: bool,
247 #[arg(long, default_value_t = false)]
249 sort_imports: bool,
250 #[arg(long, default_value_t = false)]
252 sort_functions: bool,
253 #[arg(long, default_value_t = false)]
255 sort_fields: bool,
256 files: Option<Vec<PathBuf>>,
258 },
259 #[cfg(feature = "debugger")]
261 Dap,
262}
263
264impl Cli {
265 fn get_external_commands_dir() -> Option<PathBuf> {
267 let home_dir = dirs::home_dir()?;
268 let mq_bin_dir = home_dir.join(".local").join("bin");
269 if mq_bin_dir.exists() && mq_bin_dir.is_dir() {
270 Some(mq_bin_dir)
271 } else {
272 None
273 }
274 }
275
276 fn find_external_commands() -> Vec<String> {
278 let mut seen = std::collections::HashSet::new();
279
280 if let Some(bin_dir) = Self::get_external_commands_dir() {
282 Self::collect_mq_commands_from_dir(&bin_dir, &mut seen);
283 }
284
285 if let Ok(path_var) = std::env::var("PATH") {
287 for dir in std::env::split_paths(&path_var) {
288 Self::collect_mq_commands_from_dir(&dir, &mut seen);
289 }
290 }
291
292 let mut commands: Vec<String> = seen.into_iter().collect();
293 commands.sort();
294 commands
295 }
296
297 fn collect_mq_commands_from_dir(dir: &Path, seen: &mut std::collections::HashSet<String>) {
299 if let Ok(entries) = fs::read_dir(dir) {
300 for entry in entries.flatten() {
301 if let Ok(file_name) = entry.file_name().into_string()
302 && file_name.starts_with("mq-")
303 && Self::is_executable_file(&entry)
304 && let Some(subcommand) = file_name.strip_prefix("mq-")
305 {
306 let subcommand = Self::strip_executable_extension(subcommand);
307 if !subcommand.is_empty() {
308 seen.insert(subcommand);
309 }
310 }
311 }
312 }
313 }
314
315 fn is_executable_file(entry: &fs::DirEntry) -> bool {
319 #[cfg(unix)]
320 {
321 use std::os::unix::fs::PermissionsExt;
322 entry
323 .metadata()
324 .map(|m| m.is_file() && m.permissions().mode() & UNIX_EXECUTABLE_BITS != 0)
325 .unwrap_or(false)
326 }
327 #[cfg(windows)]
328 {
329 let path = entry.path();
330 let is_file = entry.metadata().map(|m| m.is_file()).unwrap_or(false);
331 is_file
332 && path.extension().and_then(|e| e.to_str()).is_some_and(|ext| {
333 ext.eq_ignore_ascii_case("exe")
334 || ext.eq_ignore_ascii_case("cmd")
335 || ext.eq_ignore_ascii_case("bat")
336 || ext.eq_ignore_ascii_case("com")
337 })
338 }
339 #[cfg(not(any(unix, windows)))]
340 {
341 entry.metadata().map(|m| m.is_file()).unwrap_or(false)
342 }
343 }
344
345 fn strip_executable_extension(name: &str) -> String {
347 if cfg!(windows) {
348 let path = Path::new(name);
349 match path.extension().and_then(|e| e.to_str()) {
350 Some("exe" | "cmd" | "bat" | "com") => {
351 path.file_stem().unwrap_or_default().to_string_lossy().to_string()
352 }
353 _ => name.to_string(),
354 }
355 } else {
356 name.to_string()
357 }
358 }
359
360 fn execute_external_command(&self, command_path: PathBuf, args: &[String]) -> miette::Result<()> {
362 if args.is_empty() {
363 return Err(miette!("No subcommand specified"));
364 }
365
366 let subcommand = &args[0];
367
368 #[cfg(unix)]
370 {
371 use std::os::unix::fs::PermissionsExt;
372 let metadata = fs::metadata(&command_path).into_diagnostic()?;
373 let permissions = metadata.permissions();
374 if permissions.mode() & 0o111 == 0 {
375 return Err(miette!(
376 "External subcommand 'mq-{}' is not executable. Run: chmod +x {}",
377 subcommand,
378 command_path.display()
379 ));
380 }
381 }
382
383 let status = Command::new(&command_path).args(&args[1..]).status().map_err(|e| {
385 miette!(
386 "Failed to execute external subcommand 'mq-{}' at {}: {}",
387 subcommand,
388 command_path.display(),
389 e
390 )
391 })?;
392
393 if !status.success() {
394 let code = status.code().unwrap_or(1);
395 std::process::exit(code);
396 }
397
398 Ok(())
399 }
400
401 fn list_commands(&self) -> miette::Result<()> {
403 let mut output = vec![
404 format!("{}", "Built-in subcommands:".bold().cyan()),
405 format!(
406 " {} - Start a REPL session for interactive query execution",
407 "repl".green()
408 ),
409 format!(
410 " {} - Format mq files based on specified formatting options",
411 "fmt".green()
412 ),
413 ];
414
415 #[cfg(feature = "debugger")]
416 output.push(format!(" {} - Start a debug adapter for mq", "dap".green()));
417
418 let external_commands = Self::find_external_commands();
419 if !external_commands.is_empty() {
420 output.push("".to_string());
421 output.push(format!(
422 "{}",
423 "External subcommands (from ~/.local/bin and PATH):".bold().yellow()
424 ));
425 for cmd in external_commands {
426 output.push(format!(" {}", cmd.bright_yellow()));
427 }
428 }
429
430 println!("{}", output.join("\n"));
431 Ok(())
432 }
433
434 pub fn run(&self) -> miette::Result<()> {
435 if self.list {
436 return self.list_commands();
437 }
438
439 if (self.output.before_context.is_some()
440 || self.output.after_context.is_some()
441 || self.output.context.is_some())
442 && !matches!(self.output.output_format, OutputFormat::Grep)
443 {
444 return Err(miette!(
445 "--before-context, --after-context, and --context are only valid with -F grep"
446 ));
447 }
448
449 if !self.input.from_file
452 && self.commands.is_none()
453 && let Some(query_value) = &self.query
454 {
455 if query_value
457 .chars()
458 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
459 {
460 let command_path = {
461 let command_bin = format!("mq-{}", query_value);
462 let command_path = Self::get_external_commands_dir().unwrap_or_default().join(&command_bin);
463
464 if !command_path.exists() {
465 which(&command_bin).ok()
466 } else {
467 Some(command_path)
468 }
469 };
470
471 if let Some(command_path) = command_path {
472 let mut args = vec![query_value.clone()];
473 if let Some(files) = &self.files {
474 args.extend(files.iter().map(|p| p.to_string_lossy().to_string()));
475 }
476 return self.execute_external_command(command_path, &args);
477 }
478 }
479 }
480
481 if !matches!(self.input.input_format, Some(InputFormat::Markdown) | None) && self.output.update {
482 return Err(miette!("The output format is not supported for the update option"));
483 }
484
485 match &self.commands {
486 Some(Commands::Repl) => mq_repl::Repl::new(vec![mq_lang::RuntimeValue::String("".to_string())]).run(),
487 None if self.query.is_none() => {
488 mq_repl::Repl::new(vec![mq_lang::RuntimeValue::String("".to_string())]).run()
489 }
490 Some(Commands::Fmt {
491 indent_width,
492 check,
493 files,
494 sort_imports,
495 sort_fields,
496 sort_functions,
497 }) => {
498 let mut formatter = mq_formatter::Formatter::new(Some(mq_formatter::FormatterConfig {
499 indent_width: *indent_width,
500 sort_imports: *sort_imports,
501 sort_fields: *sort_fields,
502 sort_functions: *sort_functions,
503 }));
504 let files = match files {
505 Some(f) => f,
506 None => &glob("./**/*.mq")
507 .into_diagnostic()?
508 .collect::<Result<Vec<_>, _>>()
509 .into_diagnostic()?,
510 };
511
512 for file in files {
513 if !file.exists() {
514 return Err(miette!("File not found: {}", file.display()));
515 }
516
517 let content = fs::read_to_string(file).into_diagnostic()?;
518 let formatted = formatter
519 .format(&content)
520 .map_err(|e| miette!("{}: {e}", file.display()))?;
521
522 if *check && formatted != content {
523 return Err(miette!("The input is not formatted"));
524 } else if formatted != content {
525 fs::write(file, formatted).into_diagnostic()?;
526 }
527 }
528
529 Ok(())
530 }
531 #[cfg(feature = "debugger")]
532 Some(Commands::Dap) => mq_dap::start().map_err(|e| miette!(e.to_string())),
533 None => {
534 if self.input.stream {
535 self.process_streaming()
536 } else {
537 self.process_batch()
538 }
539 }
540 }
541 }
542
543 fn create_engine(&self) -> miette::Result<DefaultEngine> {
544 let mut engine = mq_lang::DefaultEngine::default();
545 engine.load_builtin_module();
546
547 if self.input.aggregate {
548 engine.import_module("section").map_err(|e| *e)?;
549 }
550
551 if let Some(dirs) = &self.input.module_directories {
552 engine.set_search_paths(dirs.clone());
553 }
554
555 if let Some(modules) = &self.input.module_names {
556 for module_name in modules {
557 engine.load_module(module_name).map_err(|e| *e)?;
558 }
559 }
560
561 if let Some(modules) = &self.input.import_module_names {
562 for module_name in modules {
563 engine.import_module(module_name).map_err(|e| *e)?;
564 }
565 }
566
567 if let Some(args) = &self.input.args {
568 args.chunks(2).for_each(|v| {
569 engine.define_string_value(&v[0], &v[1]);
570 });
571 }
572
573 if let Some(raw_file) = &self.input.raw_file {
574 for v in raw_file.chunks(2) {
575 let path = PathBuf::from_str(&v[1]).into_diagnostic()?;
576
577 if !path.exists() {
578 return Err(miette!("File not found: {}", path.display()));
579 }
580
581 let content = fs::read_to_string(&path).into_diagnostic()?;
582 engine.define_string_value(&v[0], &content);
583 }
584 }
585
586 #[cfg(feature = "debugger")]
587 {
588 use crate::debugger::DebuggerHandler;
589 let handler = DebuggerHandler::new(engine.clone());
590 engine.set_debugger_handler(Box::new(handler));
591 engine.debugger().write().unwrap().activate();
592 }
593
594 Ok(engine)
595 }
596
597 fn get_query(&self) -> miette::Result<String> {
598 let query = match self.query.as_ref() {
599 Some(q) if self.input.from_file => {
600 let path = PathBuf::from_str(q).into_diagnostic()?;
601 fs::read_to_string(path).into_diagnostic()?
602 }
603 Some(q) => q.clone(),
604 None => return Err(miette!("Query is required")),
605 };
606
607 let aggregate = self.input.aggregate.then_some("nodes");
608 Ok(aggregate.map(|agg| format!("{} | {}", agg, query)).unwrap_or(query))
609 }
610
611 fn execute(
612 &self,
613 engine: &mut mq_lang::DefaultEngine,
614 query: &str,
615 file: &Option<PathBuf>,
616 content: &str,
617 ) -> miette::Result<()> {
618 if let Some(file) = file {
619 engine.define_string_value("__FILE__", file.to_string_lossy().as_ref());
620 engine.define_string_value(
621 "__FILE_NAME__",
622 file.file_name().unwrap_or_default().to_string_lossy().as_ref(),
623 );
624 engine.define_string_value(
625 "__FILE_STEM__",
626 file.file_stem().unwrap_or_default().to_string_lossy().as_ref(),
627 );
628 }
629
630 let input = match self.input.input_format.as_ref().cloned().unwrap_or_else(|| {
631 if let Some(file) = file {
632 InputFormat::from_extension(&file.extension().unwrap_or_default().to_string_lossy())
633 } else if io::stdin().is_terminal() {
634 InputFormat::Null
635 } else {
636 InputFormat::Markdown
637 }
638 }) {
639 InputFormat::Markdown => mq_lang::parse_markdown_input(content)?,
640 InputFormat::Mdx => mq_lang::parse_mdx_input(content)?,
641 InputFormat::Text => mq_lang::parse_text_input(content)?,
642 InputFormat::Html => mq_lang::parse_html_input(content)?,
643 InputFormat::Null => mq_lang::null_input(),
644 InputFormat::Raw => mq_lang::raw_input(content),
645 };
646
647 let is_grep = matches!(self.output.output_format, OutputFormat::Grep);
648 let original_input: Option<Vec<mq_lang::RuntimeValue>> = is_grep.then(|| input.clone());
649
650 let runtime_values = if self.output.update {
651 let results = engine.eval(query, input.clone().into_iter()).map_err(|e| *e)?;
652 let current_values: mq_lang::RuntimeValues = input.clone().into();
653
654 if current_values.len() != results.len() {
655 return Err(miette!("The number of input and output values do not match"));
656 }
657
658 current_values.update_with(results)
659 } else {
660 engine.eval(query, input.into_iter()).map_err(|e| *e)?
661 };
662
663 if let Some(separator) = &self.output.separator {
664 let separator = engine
665 .eval(
666 separator,
667 vec![mq_lang::RuntimeValue::String("".to_string())].into_iter(),
668 )
669 .map_err(|e| *e)?;
670 self.print(separator)?;
671 }
672
673 if let Some(orig) = original_input {
674 let (before, after) = self.output.context_counts();
675 grep::print_grep(
676 runtime_values,
677 &orig,
678 file,
679 &self.output.output_file,
680 self.output.unbuffered,
681 before,
682 after,
683 )
684 } else {
685 self.print(runtime_values)
686 }
687 }
688
689 fn process_batch(&self) -> Result<(), miette::Error> {
690 let query = self.get_query()?;
691 let files = self.read_contents()?;
692
693 if files.len() > self.parallel_threshold {
694 files.par_iter().try_for_each(|(file, content)| {
695 let mut engine = self.create_engine()?;
696 self.execute(&mut engine, &query, file, content)
697 })?;
698 } else {
699 let mut engine = self.create_engine()?;
700 files
701 .iter()
702 .try_for_each(|(file, content)| self.execute(&mut engine, &query, file, content))?;
703 }
704
705 Ok(())
706 }
707
708 fn process_streaming(&self) -> miette::Result<()> {
709 let query = self.get_query()?;
710 let mut engine = self.create_engine()?;
711
712 self.process_lines(|file, line| self.execute(&mut engine, &query, &file.cloned(), line))
713 }
714
715 fn process_lines<F>(&self, mut process: F) -> miette::Result<()>
716 where
717 F: FnMut(Option<&PathBuf>, &str) -> miette::Result<()>,
718 {
719 if let Some(files) = &self.files {
721 for file in files {
722 let file_handle = fs::File::open(file).into_diagnostic()?;
723 let reader = io::BufReader::new(file_handle);
724 for line_result in reader.lines() {
725 let line = line_result.into_diagnostic()?;
726 process(Some(file), &line)?;
727 }
728 }
729 } else {
730 let stdin = io::stdin();
732 let reader = io::BufReader::new(stdin.lock());
733 for line_result in reader.lines() {
734 let line = line_result.into_diagnostic()?;
735 process(None, &line)?;
736 }
737 }
738 Ok(())
739 }
740
741 fn read_contents(&self) -> miette::Result<Vec<(Option<PathBuf>, String)>> {
742 if matches!(self.input.input_format, Some(InputFormat::Null)) {
743 return Ok(vec![(None, "".to_string())]);
744 }
745
746 self.files
747 .clone()
748 .map(|files| {
749 let load_contents: miette::Result<Vec<String>> = files
750 .iter()
751 .map(|file| fs::read_to_string(file).into_diagnostic())
752 .collect();
753 load_contents.map(move |contents| {
754 files
755 .into_iter()
756 .zip(contents)
757 .map(|(file, content)| (Some(file), content))
758 .collect::<Vec<_>>()
759 })
760 })
761 .unwrap_or_else(|| {
762 if io::stdin().is_terminal() {
763 return Ok(vec![(None, "".to_string())]);
764 }
765
766 let mut input = String::new();
767 io::stdin().read_to_string(&mut input).into_diagnostic()?;
768 Ok(vec![(None, input)])
769 })
770 }
771
772 fn is_no_color() -> bool {
774 std::env::var("NO_COLOR").is_ok_and(|v| !v.is_empty())
775 }
776
777 #[inline(always)]
778 fn write_ignore_pipe<W: Write>(handle: &mut W, data: &[u8]) -> miette::Result<()> {
779 match handle.write_all(data) {
780 Ok(()) => Ok(()),
781 Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => Ok(()),
782 Err(e) => Err(miette!(e)),
783 }
784 }
785
786 fn collect_markdown_nodes(value: &mq_lang::RuntimeValue, nodes: &mut Vec<mq_markdown::Node>) {
791 match value {
792 mq_lang::RuntimeValue::Markdown(node, _) => nodes.push(node.clone()),
793 mq_lang::RuntimeValue::Array(items) => {
794 for item in items {
795 Self::collect_markdown_nodes(item, nodes);
796 }
797 }
798 _ => {}
799 }
800 }
801
802 fn is_typed_dict(map: &std::collections::BTreeMap<mq_lang::Ident, mq_lang::RuntimeValue>) -> bool {
804 let type_key = mq_lang::Ident::new("type");
805 matches!(
806 map.get(&type_key),
807 Some(mq_lang::RuntimeValue::Symbol(s)) if matches!(s.as_str().as_str(), "section" | "table")
808 )
809 }
810
811 fn expand_typed_dict(
816 map: &std::collections::BTreeMap<mq_lang::Ident, mq_lang::RuntimeValue>,
817 ) -> Option<Vec<mq_markdown::Node>> {
818 let type_key = mq_lang::Ident::new("type");
819 match map.get(&type_key) {
820 Some(mq_lang::RuntimeValue::Symbol(s)) => match s.as_str().as_str() {
821 "section" => {
822 let mut nodes = Vec::new();
823 if let Some(header) = map.get(&mq_lang::Ident::new("header")) {
824 Self::collect_markdown_nodes(header, &mut nodes);
825 }
826 if let Some(children) = map.get(&mq_lang::Ident::new("children")) {
827 Self::collect_markdown_nodes(children, &mut nodes);
828 }
829 Some(nodes)
830 }
831 "table" => {
832 let mut nodes = Vec::new();
835 if let Some(header) = map.get(&mq_lang::Ident::new("header")) {
836 Self::collect_markdown_nodes(header, &mut nodes);
837 }
838 if let Some(align) = map.get(&mq_lang::Ident::new("align")) {
839 Self::collect_markdown_nodes(align, &mut nodes);
840 }
841 if let Some(rows) = map.get(&mq_lang::Ident::new("rows")) {
842 Self::collect_markdown_nodes(rows, &mut nodes);
843 }
844 Some(nodes)
845 }
846 _ => None,
848 },
849 _ => None,
850 }
851 }
852
853 fn runtime_value_to_nodes(runtime_value: &mq_lang::RuntimeValue) -> Vec<mq_markdown::Node> {
860 match runtime_value {
861 mq_lang::RuntimeValue::Markdown(node, _) => vec![node.clone()],
862 mq_lang::RuntimeValue::Dict(map) => {
863 Self::expand_typed_dict(map).unwrap_or_else(|| vec![runtime_value.to_string().into()])
864 }
865 mq_lang::RuntimeValue::Array(items) => {
866 let has_expandable = items.iter().any(|v| match v {
867 mq_lang::RuntimeValue::Markdown(_, _) => true,
868 mq_lang::RuntimeValue::Dict(m) => Self::is_typed_dict(m),
869 _ => false,
870 });
871 if has_expandable {
872 items.iter().flat_map(Self::runtime_value_to_nodes).collect()
873 } else if items.is_empty() {
874 vec![]
875 } else {
876 vec![runtime_value.to_string().into()]
877 }
878 }
879 _ => vec![runtime_value.to_string().into()],
880 }
881 }
882
883 fn print(&self, runtime_values: mq_lang::RuntimeValues) -> miette::Result<()> {
884 let stdout = io::stdout();
885 let mut handle: Box<dyn Write> = if let Some(output_file) = &self.output.output_file {
886 let file = fs::File::create(output_file).into_diagnostic()?;
887 Box::new(BufWriter::new(file))
888 } else if self.output.unbuffered {
889 Box::new(stdout.lock())
890 } else {
891 Box::new(BufWriter::new(stdout.lock()))
892 };
893 let runtime_values = runtime_values.values();
894 let mut markdown =
895 mq_markdown::Markdown::new(runtime_values.iter().flat_map(Self::runtime_value_to_nodes).collect());
896 markdown.set_options(mq_markdown::RenderOptions {
897 list_style: match self.output.list_style.clone() {
898 ListStyle::Dash => mq_markdown::ListStyle::Dash,
899 ListStyle::Plus => mq_markdown::ListStyle::Plus,
900 ListStyle::Star => mq_markdown::ListStyle::Star,
901 },
902 link_title_style: match self.output.link_title_style.clone() {
903 LinkTitleStyle::Double => mq_markdown::TitleSurroundStyle::Double,
904 LinkTitleStyle::Single => mq_markdown::TitleSurroundStyle::Single,
905 LinkTitleStyle::Paren => mq_markdown::TitleSurroundStyle::Paren,
906 },
907 link_url_style: match self.output.link_url_style.clone() {
908 LinkUrlStyle::None => mq_markdown::UrlSurroundStyle::None,
909 LinkUrlStyle::Angle => mq_markdown::UrlSurroundStyle::Angle,
910 },
911 });
912
913 match self.output.output_format {
914 OutputFormat::Html => Self::write_ignore_pipe(&mut handle, markdown.to_html().as_bytes())?,
915 OutputFormat::Text => {
916 Self::write_ignore_pipe(&mut handle, markdown.to_text().as_bytes())?;
917 }
918 OutputFormat::Markdown if self.output.color_output && !Self::is_no_color() => {
919 let theme = mq_markdown::ColorTheme::from_env();
920 Self::write_ignore_pipe(&mut handle, markdown.to_colored_string_with_theme(&theme).as_bytes())?;
921 }
922 OutputFormat::Markdown => {
923 Self::write_ignore_pipe(&mut handle, markdown.to_string().as_bytes())?;
924 }
925 OutputFormat::Json => {
926 Self::write_ignore_pipe(&mut handle, markdown.to_json()?.as_bytes())?;
927 }
928 OutputFormat::Table => {
929 let theme = (self.output.color_output && !Self::is_no_color()).then(mq_markdown::ColorTheme::from_env);
930 let table = crate::table::runtime_values_to_table(runtime_values, theme.as_ref());
931 Self::write_ignore_pipe(&mut handle, format!("{}\n", table).as_bytes())?;
932 }
933 OutputFormat::Grep => {
934 Self::write_ignore_pipe(&mut handle, markdown.to_string().as_bytes())?;
935 }
936 OutputFormat::None => {}
937 }
938
939 if !self.output.unbuffered
940 && let Err(e) = handle.flush()
941 && e.kind() != std::io::ErrorKind::BrokenPipe
942 {
943 return Err(miette!(e));
944 }
945
946 Ok(())
947 }
948}
949
950#[cfg(test)]
951mod tests {
952 use rstest::rstest;
953 use scopeguard::defer;
954 use std::io::Write;
955 use std::{fs::File, path::PathBuf};
956
957 use super::*;
958
959 fn create_file(name: &str, content: &str) -> (PathBuf, PathBuf) {
960 let temp_dir = std::env::temp_dir();
961 let temp_file_path = temp_dir.join(name);
962 let mut file = File::create(&temp_file_path).expect("Failed to create temp file");
963 file.write_all(content.as_bytes())
964 .expect("Failed to write to temp file");
965
966 (temp_dir, temp_file_path)
967 }
968
969 #[test]
970 fn test_cli_null_input() {
971 let cli = Cli {
972 input: InputArgs {
973 input_format: Some(InputFormat::Null),
974 ..Default::default()
975 },
976 output: OutputArgs::default(),
977 commands: None,
978 query: Some("self".to_string()),
979 files: None,
980 ..Cli::default()
981 };
982
983 assert!(cli.run().is_ok());
984 }
985
986 #[test]
987 fn test_cli_raw_input() {
988 let (_, temp_file_path) = create_file("test1.md", "# test");
989 let temp_file_path_clone = temp_file_path.clone();
990
991 defer! {
992 if temp_file_path_clone.exists() {
993 std::fs::remove_file(&temp_file_path_clone).expect("Failed to delete temp file");
994 }
995 }
996
997 let cli = Cli {
998 input: InputArgs {
999 input_format: Some(InputFormat::Text),
1000 ..Default::default()
1001 },
1002 output: OutputArgs::default(),
1003 commands: None,
1004 query: Some("self".to_string()),
1005 files: Some(vec![temp_file_path]),
1006 ..Cli::default()
1007 };
1008
1009 assert!(cli.run().is_ok());
1010 }
1011
1012 #[test]
1013 fn test_cli_output_formats() {
1014 let (_, temp_file_path) = create_file("test2.md", "# test");
1015 let temp_file_path_clone = temp_file_path.clone();
1016
1017 defer! {
1018 if temp_file_path_clone.exists() {
1019 std::fs::remove_file(&temp_file_path_clone).expect("Failed to delete temp file");
1020 }
1021 }
1022
1023 for format in [
1024 OutputFormat::Markdown,
1025 OutputFormat::Html,
1026 OutputFormat::Text,
1027 OutputFormat::Table,
1028 OutputFormat::Grep,
1029 ] {
1030 let cli = Cli {
1031 input: InputArgs::default(),
1032 output: OutputArgs {
1033 output_format: format.clone(),
1034 ..Default::default()
1035 },
1036 commands: None,
1037 query: Some("self".to_string()),
1038 files: Some(vec![temp_file_path.clone()]),
1039 ..Cli::default()
1040 };
1041
1042 assert!(cli.run().is_ok());
1043 }
1044 }
1045
1046 #[test]
1047 fn test_cli_list_styles() {
1048 let (_, temp_file_path) = create_file("test3.md", "# test");
1049 let temp_file_path_clone = temp_file_path.clone();
1050
1051 defer! {
1052 if temp_file_path_clone.exists() {
1053 std::fs::remove_file(&temp_file_path_clone).expect("Failed to delete temp file");
1054 }
1055 }
1056
1057 for style in [ListStyle::Dash, ListStyle::Plus, ListStyle::Star] {
1058 let cli = Cli {
1059 input: InputArgs::default(),
1060 output: OutputArgs {
1061 list_style: style.clone(),
1062 ..Default::default()
1063 },
1064 commands: None,
1065 query: Some("self".to_string()),
1066 files: Some(vec![temp_file_path.clone()]),
1067 ..Cli::default()
1068 };
1069
1070 assert!(cli.run().is_ok());
1071 }
1072 }
1073
1074 #[test]
1075 fn test_cli_color_output() {
1076 let (_, temp_file_path) = create_file("test_color.md", "# test");
1077 let temp_file_path_clone = temp_file_path.clone();
1078
1079 defer! {
1080 if temp_file_path_clone.exists() {
1081 std::fs::remove_file(&temp_file_path_clone).expect("Failed to delete temp file");
1082 }
1083 }
1084
1085 let cli = Cli {
1086 input: InputArgs::default(),
1087 output: OutputArgs {
1088 color_output: true,
1089 ..Default::default()
1090 },
1091 commands: None,
1092 query: Some("self".to_string()),
1093 files: Some(vec![temp_file_path.clone()]),
1094 ..Cli::default()
1095 };
1096
1097 assert!(cli.run().is_ok());
1098 }
1099
1100 #[test]
1101 fn test_cli_fmt_command() {
1102 let (_, temp_file_path) = create_file("test1.mq", "def math(): 42;");
1103 let temp_file_path_clone = temp_file_path.clone();
1104
1105 defer! {
1106 if temp_file_path_clone.exists() {
1107 std::fs::remove_file(&temp_file_path_clone).expect("Failed to delete temp file");
1108 }
1109 }
1110
1111 let cli = Cli {
1112 input: InputArgs::default(),
1113 output: OutputArgs::default(),
1114 commands: Some(Commands::Fmt {
1115 indent_width: 2,
1116 check: false,
1117 files: Some(vec![temp_file_path.clone()]),
1118 sort_functions: false,
1119 sort_fields: false,
1120 sort_imports: false,
1121 }),
1122 query: None,
1123 files: Some(vec![temp_file_path]),
1124 ..Cli::default()
1125 };
1126
1127 assert!(cli.run().is_ok());
1128 }
1129
1130 #[test]
1131 fn test_cli_fmt_command_with_check() {
1132 let (_, temp_file_path) = create_file("test2.mq", "def math(): 42;");
1133 let temp_file_path_clone = temp_file_path.clone();
1134
1135 defer! {
1136 if temp_file_path_clone.exists() {
1137 std::fs::remove_file(&temp_file_path_clone).expect("Failed to delete temp file");
1138 }
1139 }
1140
1141 let cli = Cli {
1142 input: InputArgs::default(),
1143 output: OutputArgs::default(),
1144 commands: Some(Commands::Fmt {
1145 indent_width: 2,
1146 check: true,
1147 files: Some(vec![temp_file_path.clone()]),
1148 sort_functions: false,
1149 sort_fields: false,
1150 sort_imports: false,
1151 }),
1152 query: None,
1153 files: Some(vec![temp_file_path]),
1154 ..Cli::default()
1155 };
1156
1157 assert!(cli.run().is_ok());
1158 }
1159
1160 #[test]
1161 fn test_cli_update_flag() {
1162 let (_, temp_file_path) = create_file("test4.md", "# test");
1163 let temp_file_path_clone = temp_file_path.clone();
1164
1165 defer! {
1166 if temp_file_path_clone.exists() {
1167 std::fs::remove_file(&temp_file_path_clone).expect("Failed to delete temp file");
1168 }
1169 }
1170
1171 let cli = Cli {
1172 input: InputArgs::default(),
1173 output: OutputArgs {
1174 update: true,
1175 ..Default::default()
1176 },
1177 commands: None,
1178 query: Some("self".to_string()),
1179 files: Some(vec![temp_file_path]),
1180 ..Cli::default()
1181 };
1182
1183 assert!(cli.run().is_ok());
1184 }
1185
1186 #[test]
1187 fn test_cli_with_module_names() {
1188 let (temp_dir, temp_file_path) = create_file("math.mq", "def math(): 42;");
1189 let (_, temp_md_file_path) = create_file("test.md", "# test");
1190 let temp_md_file_path_clone = temp_md_file_path.clone();
1191
1192 defer! {
1193 if temp_file_path.exists() {
1194 std::fs::remove_file(&temp_file_path).expect("Failed to delete temp file");
1195 }
1196
1197 if temp_md_file_path_clone.exists() {
1198 std::fs::remove_file(&temp_md_file_path_clone).expect("Failed to delete temp file");
1199 }
1200 }
1201
1202 let cli = Cli {
1203 input: InputArgs {
1204 module_names: Some(vec!["math".to_string()]),
1205 module_directories: Some(vec![temp_dir.clone()]),
1206 ..Default::default()
1207 },
1208 output: OutputArgs::default(),
1209 commands: None,
1210 query: Some("math".to_owned()),
1211 files: Some(vec![temp_md_file_path]),
1212 ..Cli::default()
1213 };
1214
1215 assert!(cli.run().is_ok());
1216 }
1217
1218 #[test]
1219 fn test_find_external_commands() {
1220 let commands = Cli::find_external_commands();
1222 assert!(commands.iter().all(|cmd| !cmd.is_empty()));
1224 }
1225
1226 #[test]
1227 fn test_get_external_commands_dir() {
1228 let dir = Cli::get_external_commands_dir();
1230 if let Some(path) = dir {
1231 assert!(path.ends_with(".local/bin") || path.ends_with(".local\\bin"));
1232 }
1233 }
1234
1235 #[test]
1236 #[cfg(unix)]
1237 fn test_collect_mq_commands_from_dir() {
1238 let temp_dir = std::env::temp_dir().join("mq-collect-test");
1239 fs::create_dir_all(&temp_dir).expect("Failed to create test directory");
1240
1241 defer! {
1242 if temp_dir.exists() {
1243 std::fs::remove_dir_all(&temp_dir).ok();
1244 }
1245 }
1246
1247 fs::write(temp_dir.join("mq-foo"), "").expect("Failed to write file");
1249 fs::write(temp_dir.join("mq-bar"), "").expect("Failed to write file");
1250 fs::write(temp_dir.join("other-cmd"), "").expect("Failed to write file");
1251 fs::write(temp_dir.join("mq-noexec"), "").expect("Failed to write file");
1252
1253 #[cfg(unix)]
1254 {
1255 use std::os::unix::fs::PermissionsExt;
1256 fs::set_permissions(temp_dir.join("mq-foo"), fs::Permissions::from_mode(0o755))
1258 .expect("Failed to set permissions");
1259 fs::set_permissions(temp_dir.join("mq-bar"), fs::Permissions::from_mode(0o755))
1260 .expect("Failed to set permissions");
1261 }
1262
1263 let mut seen = std::collections::HashSet::new();
1264 Cli::collect_mq_commands_from_dir(&temp_dir, &mut seen);
1265
1266 assert_eq!(seen.len(), 2);
1267 assert!(seen.contains("foo"));
1268 assert!(seen.contains("bar"));
1269 assert!(!seen.contains("other-cmd"));
1270 assert!(!seen.contains("noexec"));
1271 }
1272
1273 #[test]
1274 #[cfg(unix)]
1275 fn test_collect_mq_commands_from_dir_deduplicates() {
1276 let dir1 = std::env::temp_dir().join("mq-dedup-test-1");
1277 let dir2 = std::env::temp_dir().join("mq-dedup-test-2");
1278 fs::create_dir_all(&dir1).expect("Failed to create test directory");
1279 fs::create_dir_all(&dir2).expect("Failed to create test directory");
1280
1281 defer! {
1282 if dir1.exists() {
1283 std::fs::remove_dir_all(&dir1).ok();
1284 }
1285 if dir2.exists() {
1286 std::fs::remove_dir_all(&dir2).ok();
1287 }
1288 }
1289
1290 fs::write(dir1.join("mq-dup"), "").expect("Failed to write file");
1292 fs::write(dir2.join("mq-dup"), "").expect("Failed to write file");
1293 fs::write(dir2.join("mq-unique"), "").expect("Failed to write file");
1294
1295 #[cfg(unix)]
1296 {
1297 use std::os::unix::fs::PermissionsExt;
1298 fs::set_permissions(dir1.join("mq-dup"), fs::Permissions::from_mode(0o755))
1299 .expect("Failed to set permissions");
1300 fs::set_permissions(dir2.join("mq-dup"), fs::Permissions::from_mode(0o755))
1301 .expect("Failed to set permissions");
1302 fs::set_permissions(dir2.join("mq-unique"), fs::Permissions::from_mode(0o755))
1303 .expect("Failed to set permissions");
1304 }
1305
1306 let mut seen = std::collections::HashSet::new();
1307 Cli::collect_mq_commands_from_dir(&dir1, &mut seen);
1308 Cli::collect_mq_commands_from_dir(&dir2, &mut seen);
1309
1310 assert_eq!(seen.len(), 2);
1311 assert!(seen.contains("dup"));
1312 assert!(seen.contains("unique"));
1313 }
1314
1315 #[test]
1316 fn test_collect_mq_commands_from_nonexistent_dir() {
1317 let nonexistent = std::env::temp_dir().join("mq-nonexistent-dir");
1318 let mut seen = std::collections::HashSet::new();
1319 Cli::collect_mq_commands_from_dir(&nonexistent, &mut seen);
1321 assert!(seen.is_empty());
1322 }
1323
1324 #[rstest]
1325 #[case("foo", "foo")]
1326 #[case("foo.exe", "foo.exe")]
1327 #[case("foo.cmd", "foo.cmd")]
1328 #[case("foo.bat", "foo.bat")]
1329 #[case("foo.sh", "foo.sh")]
1330 #[cfg(not(windows))]
1331 fn test_strip_executable_extension_unix(#[case] input: &str, #[case] expected: &str) {
1332 assert_eq!(Cli::strip_executable_extension(input), expected);
1333 }
1334
1335 #[rstest]
1336 #[case("foo.exe", "foo")]
1337 #[case("foo.cmd", "foo")]
1338 #[case("foo.bat", "foo")]
1339 #[case("foo.com", "foo")]
1340 #[case("foo", "foo")]
1341 #[case("foo.sh", "foo.sh")]
1342 #[case("foo.txt", "foo.txt")]
1343 #[cfg(windows)]
1344 fn test_strip_executable_extension_windows(#[case] input: &str, #[case] expected: &str) {
1345 assert_eq!(Cli::strip_executable_extension(input), expected);
1346 }
1347
1348 #[test]
1349 fn test_external_command_execution() {
1350 let temp_dir = std::env::temp_dir().join("mq-run-test");
1352 let bin_dir = temp_dir.join(".mq").join("bin");
1353 fs::create_dir_all(&bin_dir).expect("Failed to create test directory");
1354
1355 defer! {
1356 if temp_dir.exists() {
1357 std::fs::remove_dir_all(&temp_dir).ok();
1358 }
1359 }
1360
1361 let test_cmd_path = bin_dir.join("mq-testcmd");
1363 #[cfg(unix)]
1364 {
1365 use std::os::unix::fs::PermissionsExt;
1366 fs::write(&test_cmd_path, "#!/bin/sh\necho 'test output'").expect("Failed to write test command");
1367 let mut perms = fs::metadata(&test_cmd_path)
1368 .expect("Failed to get metadata")
1369 .permissions();
1370 perms.set_mode(0o755);
1371 fs::set_permissions(&test_cmd_path, perms).expect("Failed to set permissions");
1372 }
1373 #[cfg(not(unix))]
1374 {
1375 fs::write(&test_cmd_path, "@echo off\necho test output").expect("Failed to write test command");
1376 }
1377
1378 assert!(test_cmd_path.exists());
1381 }
1382
1383 #[test]
1384 fn test_input_format_mdx() {
1385 let (_, temp_file_path) = create_file("test_mdx.mdx", "# MDX test");
1386 let (_, output_file) = create_file("test_mdx_output.md", "");
1387 let temp_file_path_clone = temp_file_path.clone();
1388 let output_file_clone = output_file.clone();
1389
1390 defer! {
1391 if temp_file_path_clone.exists() {
1392 std::fs::remove_file(&temp_file_path_clone).ok();
1393 }
1394 if output_file_clone.exists() {
1395 std::fs::remove_file(&output_file_clone).ok();
1396 }
1397 }
1398
1399 let cli = Cli {
1400 input: InputArgs {
1401 input_format: Some(InputFormat::Mdx),
1402 ..Default::default()
1403 },
1404 output: OutputArgs {
1405 output_file: Some(output_file.clone()),
1406 ..Default::default()
1407 },
1408 commands: None,
1409 query: Some("self".to_string()),
1410 files: Some(vec![temp_file_path]),
1411 ..Cli::default()
1412 };
1413
1414 assert!(cli.run().is_ok());
1415 let output_content = fs::read_to_string(&output_file).expect("Failed to read output");
1416 assert!(output_content.contains("# MDX test"), "Output should contain heading");
1417 }
1418
1419 #[test]
1420 fn test_input_format_html() {
1421 let (_, temp_file_path) = create_file("test_html.html", "<h1>HTML test</h1>");
1422 let (_, output_file) = create_file("test_html_output.md", "");
1423 let temp_file_path_clone = temp_file_path.clone();
1424 let output_file_clone = output_file.clone();
1425
1426 defer! {
1427 if temp_file_path_clone.exists() {
1428 std::fs::remove_file(&temp_file_path_clone).ok();
1429 }
1430 if output_file_clone.exists() {
1431 std::fs::remove_file(&output_file_clone).ok();
1432 }
1433 }
1434
1435 let cli = Cli {
1436 input: InputArgs {
1437 input_format: Some(InputFormat::Html),
1438 ..Default::default()
1439 },
1440 output: OutputArgs {
1441 output_file: Some(output_file.clone()),
1442 ..Default::default()
1443 },
1444 commands: None,
1445 query: Some("self".to_string()),
1446 files: Some(vec![temp_file_path]),
1447 ..Cli::default()
1448 };
1449
1450 assert!(cli.run().is_ok());
1451 let output_content = fs::read_to_string(&output_file).expect("Failed to read output");
1452 assert!(
1453 output_content.contains("# HTML test"),
1454 "Output should contain converted heading"
1455 );
1456 }
1457
1458 #[test]
1459 fn test_output_format_json() {
1460 let (_, temp_file_path) = create_file("test_json.md", "# Test");
1461 let (_, output_file) = create_file("test_json_output.json", "");
1462 let temp_file_path_clone = temp_file_path.clone();
1463 let output_file_clone = output_file.clone();
1464
1465 defer! {
1466 if temp_file_path_clone.exists() {
1467 std::fs::remove_file(&temp_file_path_clone).ok();
1468 }
1469 if output_file_clone.exists() {
1470 std::fs::remove_file(&output_file_clone).ok();
1471 }
1472 }
1473
1474 let cli = Cli {
1475 input: InputArgs::default(),
1476 output: OutputArgs {
1477 output_format: OutputFormat::Json,
1478 output_file: Some(output_file.clone()),
1479 ..Default::default()
1480 },
1481 commands: None,
1482 query: Some("self".to_string()),
1483 files: Some(vec![temp_file_path]),
1484 ..Cli::default()
1485 };
1486
1487 assert!(cli.run().is_ok());
1488 let output_content = fs::read_to_string(&output_file).expect("Failed to read output");
1489 assert!(!output_content.is_empty(), "JSON output should not be empty");
1490 assert!(
1491 output_content.starts_with('{') || output_content.starts_with('['),
1492 "JSON output should be valid JSON"
1493 );
1494 }
1495
1496 #[test]
1497 fn test_output_format_none() {
1498 let (_, temp_file_path) = create_file("test_none.md", "# Test");
1499 let temp_file_path_clone = temp_file_path.clone();
1500
1501 defer! {
1502 if temp_file_path_clone.exists() {
1503 std::fs::remove_file(&temp_file_path_clone).ok();
1504 }
1505 }
1506
1507 let cli = Cli {
1508 input: InputArgs::default(),
1509 output: OutputArgs {
1510 output_format: OutputFormat::None,
1511 ..Default::default()
1512 },
1513 commands: None,
1514 query: Some("self".to_string()),
1515 files: Some(vec![temp_file_path]),
1516 ..Cli::default()
1517 };
1518
1519 assert!(cli.run().is_ok());
1520 }
1521
1522 #[test]
1523 fn test_output_format_table_single_column() {
1524 let (_, temp_file_path) = create_file("test_table.md", "# Test\n\nContent");
1525 let (_, output_file) = create_file("test_table_output.md", "");
1526 let temp_file_path_clone = temp_file_path.clone();
1527 let output_file_clone = output_file.clone();
1528
1529 defer! {
1530 if temp_file_path_clone.exists() {
1531 std::fs::remove_file(&temp_file_path_clone).ok();
1532 }
1533 if output_file_clone.exists() {
1534 std::fs::remove_file(&output_file_clone).ok();
1535 }
1536 }
1537
1538 let cli = Cli {
1539 input: InputArgs::default(),
1540 output: OutputArgs {
1541 output_format: OutputFormat::Table,
1542 output_file: Some(output_file.clone()),
1543 ..Default::default()
1544 },
1545 commands: None,
1546 query: Some("self".to_string()),
1547 files: Some(vec![temp_file_path]),
1548 ..Cli::default()
1549 };
1550
1551 assert!(cli.run().is_ok());
1552 let output_content = fs::read_to_string(&output_file).expect("Failed to read output");
1553 assert!(output_content.contains("value"), "Table should have value header");
1554 assert!(output_content.contains("Test"), "Table should contain node text");
1555 }
1556
1557 #[test]
1558 fn test_output_format_table_dict() {
1559 let (_, output_file) = create_file("test_table_dict_output.md", "");
1560 let output_file_clone = output_file.clone();
1561
1562 defer! {
1563 if output_file_clone.exists() {
1564 std::fs::remove_file(&output_file_clone).ok();
1565 }
1566 }
1567
1568 let cli = Cli {
1569 input: InputArgs {
1570 input_format: Some(InputFormat::Null),
1571 ..Default::default()
1572 },
1573 output: OutputArgs {
1574 output_format: OutputFormat::Table,
1575 output_file: Some(output_file.clone()),
1576 ..Default::default()
1577 },
1578 commands: None,
1579 query: Some(r#"{name: "Alice", age: "30"}"#.to_string()),
1580 files: None,
1581 ..Cli::default()
1582 };
1583
1584 assert!(cli.run().is_ok());
1585 let output_content = fs::read_to_string(&output_file).expect("Failed to read output");
1586 assert!(output_content.contains("name"), "Table should contain name column");
1587 assert!(output_content.contains("age"), "Table should contain age column");
1588 assert!(output_content.contains("Alice"), "Table should contain Alice");
1589 assert!(output_content.contains("30"), "Table should contain 30");
1590 }
1591
1592 #[test]
1593 fn test_output_format_table_nested_dict() {
1594 let (_, output_file) = create_file("test_table_nested_dict_output.md", "");
1595 let output_file_clone = output_file.clone();
1596
1597 defer! {
1598 if output_file_clone.exists() {
1599 std::fs::remove_file(&output_file_clone).ok();
1600 }
1601 }
1602
1603 let cli = Cli {
1604 input: InputArgs {
1605 input_format: Some(InputFormat::Null),
1606 ..Default::default()
1607 },
1608 output: OutputArgs {
1609 output_format: OutputFormat::Table,
1610 output_file: Some(output_file.clone()),
1611 ..Default::default()
1612 },
1613 commands: None,
1614 query: Some(r#"{name: "Alice", addr: {city: "Tokyo", zip: "100"}}"#.to_string()),
1615 files: None,
1616 ..Cli::default()
1617 };
1618
1619 assert!(cli.run().is_ok());
1620 let output_content = fs::read_to_string(&output_file).expect("Failed to read output");
1621 assert!(output_content.contains("addr"), "Table should contain addr column");
1622 assert!(output_content.contains("name"), "Table should contain name column");
1623 assert!(output_content.contains("Alice"), "Table should contain Alice");
1624 assert!(output_content.contains("city"), "Nested table should contain city key");
1625 assert!(output_content.contains("Tokyo"), "Nested table should contain Tokyo");
1626 assert!(output_content.contains("zip"), "Nested table should contain zip key");
1627 assert!(output_content.contains("100"), "Nested table should contain 100");
1628 assert!(!output_content.contains("addr.city"), "Dot notation must not appear");
1629 }
1630
1631 #[test]
1632 fn test_output_format_table_array_value() {
1633 let (_, output_file) = create_file("test_table_array_value_output.md", "");
1634 let output_file_clone = output_file.clone();
1635
1636 defer! {
1637 if output_file_clone.exists() {
1638 std::fs::remove_file(&output_file_clone).ok();
1639 }
1640 }
1641
1642 let cli = Cli {
1643 input: InputArgs {
1644 input_format: Some(InputFormat::Null),
1645 ..Default::default()
1646 },
1647 output: OutputArgs {
1648 output_format: OutputFormat::Table,
1649 output_file: Some(output_file.clone()),
1650 ..Default::default()
1651 },
1652 commands: None,
1653 query: Some(r#"{name: "Alice", tags: ["a", "b"]}"#.to_string()),
1654 files: None,
1655 ..Cli::default()
1656 };
1657
1658 assert!(cli.run().is_ok());
1659 let output_content = fs::read_to_string(&output_file).expect("Failed to read output");
1660 assert!(output_content.contains("tags"), "Table should contain tags column");
1661 assert!(output_content.contains('a'), "Nested table should contain a");
1662 assert!(output_content.contains('b'), "Nested table should contain b");
1663 assert!(output_content.contains("Alice"), "Table should contain Alice");
1664 assert!(!output_content.contains(r#"["a""#), "Raw array repr must not appear");
1665 }
1666
1667 #[test]
1668 fn test_output_format_table_array_input() {
1669 let (_, output_file) = create_file("test_table_array_input_output.md", "");
1670 let output_file_clone = output_file.clone();
1671
1672 defer! {
1673 if output_file_clone.exists() {
1674 std::fs::remove_file(&output_file_clone).ok();
1675 }
1676 }
1677
1678 let cli = Cli {
1679 input: InputArgs {
1680 input_format: Some(InputFormat::Null),
1681 ..Default::default()
1682 },
1683 output: OutputArgs {
1684 output_format: OutputFormat::Table,
1685 output_file: Some(output_file.clone()),
1686 ..Default::default()
1687 },
1688 commands: None,
1689 query: Some(r#"[{a: "1"}, {a: "2"}]"#.to_string()),
1690 files: None,
1691 ..Cli::default()
1692 };
1693
1694 assert!(cli.run().is_ok());
1695 let output_content = fs::read_to_string(&output_file).expect("Failed to read output");
1696 assert!(output_content.contains('a'), "Table should have column 'a'");
1697 assert!(output_content.contains('1'), "Row 1 value should appear");
1698 assert!(output_content.contains('2'), "Row 2 value should appear");
1699 assert!(
1700 !output_content.contains("value"),
1701 "Should not fall back to 'value' column"
1702 );
1703 }
1704
1705 #[test]
1706 fn test_link_title_styles() {
1707 let (_, temp_file_path) = create_file("test_link_title.md", "[link](url \"title\")");
1708 let temp_file_path_clone = temp_file_path.clone();
1709
1710 defer! {
1711 if temp_file_path_clone.exists() {
1712 std::fs::remove_file(&temp_file_path_clone).ok();
1713 }
1714 }
1715
1716 for (style, expected_char) in [
1717 (LinkTitleStyle::Double, '"'),
1718 (LinkTitleStyle::Single, '\''),
1719 (LinkTitleStyle::Paren, '('),
1720 ] {
1721 let (_, output_file) = create_file(&format!("test_link_title_{:?}.md", style), "");
1722 let output_file_clone = output_file.clone();
1723
1724 defer! {
1725 if output_file_clone.exists() {
1726 std::fs::remove_file(&output_file_clone).ok();
1727 }
1728 }
1729
1730 let cli = Cli {
1731 input: InputArgs::default(),
1732 output: OutputArgs {
1733 link_title_style: style.clone(),
1734 output_file: Some(output_file.clone()),
1735 ..Default::default()
1736 },
1737 commands: None,
1738 query: Some("self".to_string()),
1739 files: Some(vec![temp_file_path.clone()]),
1740 ..Cli::default()
1741 };
1742
1743 assert!(cli.run().is_ok());
1744 let output_content = fs::read_to_string(&output_file).expect("Failed to read output");
1745 if style == LinkTitleStyle::Paren {
1746 assert!(
1747 output_content.contains("(title)"),
1748 "Paren style should wrap title with parens"
1749 );
1750 } else {
1751 assert!(
1752 output_content.contains(expected_char),
1753 "Link title should use {:?} style",
1754 style
1755 );
1756 }
1757 }
1758 }
1759
1760 #[test]
1761 fn test_link_url_styles() {
1762 let (_, temp_file_path) = create_file("test_link_url.md", "[link](https://example.com)");
1763 let temp_file_path_clone = temp_file_path.clone();
1764
1765 defer! {
1766 if temp_file_path_clone.exists() {
1767 std::fs::remove_file(&temp_file_path_clone).ok();
1768 }
1769 }
1770
1771 for style in [LinkUrlStyle::None, LinkUrlStyle::Angle] {
1772 let (_, output_file) = create_file(&format!("test_link_url_{:?}.md", style), "");
1773 let output_file_clone = output_file.clone();
1774
1775 defer! {
1776 if output_file_clone.exists() {
1777 std::fs::remove_file(&output_file_clone).ok();
1778 }
1779 }
1780
1781 let cli = Cli {
1782 input: InputArgs::default(),
1783 output: OutputArgs {
1784 link_url_style: style.clone(),
1785 output_file: Some(output_file.clone()),
1786 ..Default::default()
1787 },
1788 commands: None,
1789 query: Some("self".to_string()),
1790 files: Some(vec![temp_file_path.clone()]),
1791 ..Cli::default()
1792 };
1793
1794 assert!(cli.run().is_ok());
1795 let output_content = fs::read_to_string(&output_file).expect("Failed to read output");
1796 if style == LinkUrlStyle::Angle {
1797 assert!(
1798 output_content.contains("<https://example.com>"),
1799 "Angle style should wrap URL with angle brackets"
1800 );
1801 } else {
1802 assert!(
1803 output_content.contains("(https://example.com)"),
1804 "None style should not wrap URL"
1805 );
1806 }
1807 }
1808 }
1809
1810 #[test]
1811 fn test_aggregate_flag() {
1812 let (_, temp_file1) = create_file("test_agg1.md", "# Test 1");
1813 let (_, temp_file2) = create_file("test_agg2.md", "# Test 2");
1814 let (_, output_file) = create_file("test_agg_output.md", "");
1815 let temp_file1_clone = temp_file1.clone();
1816 let temp_file2_clone = temp_file2.clone();
1817 let output_file_clone = output_file.clone();
1818
1819 defer! {
1820 if temp_file1_clone.exists() {
1821 std::fs::remove_file(&temp_file1_clone).ok();
1822 }
1823 if temp_file2_clone.exists() {
1824 std::fs::remove_file(&temp_file2_clone).ok();
1825 }
1826 if output_file_clone.exists() {
1827 std::fs::remove_file(&output_file_clone).ok();
1828 }
1829 }
1830
1831 let cli = Cli {
1832 input: InputArgs {
1833 aggregate: true,
1834 ..Default::default()
1835 },
1836 output: OutputArgs {
1837 output_file: Some(output_file.clone()),
1838 output_format: OutputFormat::Text,
1839 ..Default::default()
1840 },
1841 commands: None,
1842 query: Some("len()".to_string()),
1843 files: Some(vec![temp_file1, temp_file2]),
1844 ..Cli::default()
1845 };
1846
1847 assert!(cli.run().is_ok());
1848 let output_content = fs::read_to_string(&output_file).expect("Failed to read output");
1849 assert!(!output_content.is_empty(), "Aggregated output should not be empty");
1850 }
1851
1852 #[test]
1853 fn test_from_file_flag() {
1854 let (_, query_file) = create_file("test_query.mq", "self");
1855 let (_, input_file) = create_file("test_from_file.md", "# Test");
1856 let query_file_clone = query_file.clone();
1857 let input_file_clone = input_file.clone();
1858
1859 defer! {
1860 if query_file_clone.exists() {
1861 std::fs::remove_file(&query_file_clone).ok();
1862 }
1863 if input_file_clone.exists() {
1864 std::fs::remove_file(&input_file_clone).ok();
1865 }
1866 }
1867
1868 let cli = Cli {
1869 input: InputArgs {
1870 from_file: true,
1871 ..Default::default()
1872 },
1873 output: OutputArgs::default(),
1874 commands: None,
1875 query: Some(query_file.to_string_lossy().to_string()),
1876 files: Some(vec![input_file]),
1877 ..Cli::default()
1878 };
1879
1880 assert!(cli.run().is_ok());
1881 }
1882
1883 #[test]
1884 fn test_separator_flag() {
1885 let (_, temp_file1) = create_file("test_sep1.md", "# Test 1");
1886 let (_, temp_file2) = create_file("test_sep2.md", "# Test 2");
1887 let (_, output_file) = create_file("test_sep_output.md", "");
1888 let temp_file1_clone = temp_file1.clone();
1889 let temp_file2_clone = temp_file2.clone();
1890 let output_file_clone = output_file.clone();
1891
1892 defer! {
1893 if temp_file1_clone.exists() {
1894 std::fs::remove_file(&temp_file1_clone).ok();
1895 }
1896 if temp_file2_clone.exists() {
1897 std::fs::remove_file(&temp_file2_clone).ok();
1898 }
1899 if output_file_clone.exists() {
1900 std::fs::remove_file(&output_file_clone).ok();
1901 }
1902 }
1903
1904 let cli = Cli {
1905 input: InputArgs::default(),
1906 output: OutputArgs {
1907 separator: Some("\"---\"".to_string()),
1908 output_file: Some(output_file.clone()),
1909 ..Default::default()
1910 },
1911 commands: None,
1912 query: Some("self".to_string()),
1913 files: Some(vec![temp_file1, temp_file2]),
1914 ..Cli::default()
1915 };
1916
1917 assert!(cli.run().is_ok());
1918 let output_content = fs::read_to_string(&output_file).expect("Failed to read output");
1919 assert!(!output_content.is_empty(), "Output should not be empty");
1920 assert!(output_content.contains("# Test"), "File content should be present");
1921 }
1922
1923 #[test]
1924 fn test_output_file_flag() {
1925 let (_, temp_input) = create_file("test_input_out.md", "# Test Output");
1926 let temp_output = std::env::temp_dir().join("test_output_file.md");
1927 let temp_input_clone = temp_input.clone();
1928 let temp_output_clone = temp_output.clone();
1929
1930 defer! {
1931 if temp_input_clone.exists() {
1932 std::fs::remove_file(&temp_input_clone).ok();
1933 }
1934 if temp_output_clone.exists() {
1935 std::fs::remove_file(&temp_output_clone).ok();
1936 }
1937 }
1938
1939 let cli = Cli {
1940 input: InputArgs::default(),
1941 output: OutputArgs {
1942 output_file: Some(temp_output.clone()),
1943 ..Default::default()
1944 },
1945 commands: None,
1946 query: Some("self".to_string()),
1947 files: Some(vec![temp_input]),
1948 ..Cli::default()
1949 };
1950
1951 assert!(cli.run().is_ok());
1952 assert!(temp_output.exists(), "Output file should exist");
1953 let output_content = fs::read_to_string(&temp_output).expect("Failed to read output");
1954 assert!(
1955 output_content.contains("# Test Output"),
1956 "Output content should match input"
1957 );
1958 }
1959
1960 #[test]
1961 fn test_unbuffered_output() {
1962 let (_, temp_file) = create_file("test_unbuf.md", "# Test");
1963 let temp_file_clone = temp_file.clone();
1964
1965 defer! {
1966 if temp_file_clone.exists() {
1967 std::fs::remove_file(&temp_file_clone).ok();
1968 }
1969 }
1970
1971 let cli = Cli {
1972 input: InputArgs::default(),
1973 output: OutputArgs {
1974 unbuffered: true,
1975 ..Default::default()
1976 },
1977 commands: None,
1978 query: Some("self".to_string()),
1979 files: Some(vec![temp_file]),
1980 ..Cli::default()
1981 };
1982
1983 assert!(cli.run().is_ok());
1984 }
1985
1986 #[test]
1987 fn test_fmt_file_not_found() {
1988 let cli = Cli {
1989 input: InputArgs::default(),
1990 output: OutputArgs::default(),
1991 commands: Some(Commands::Fmt {
1992 indent_width: 2,
1993 check: false,
1994 files: Some(vec![PathBuf::from("nonexistent.mq")]),
1995 sort_functions: false,
1996 sort_fields: false,
1997 sort_imports: false,
1998 }),
1999 query: None,
2000 files: None,
2001 ..Cli::default()
2002 };
2003
2004 assert!(cli.run().is_err());
2005 }
2006
2007 #[test]
2008 fn test_fmt_check_unformatted_file() {
2009 let (_, temp_file) = create_file("test_unformatted.mq", "def math(): 42;");
2010 let temp_file_clone = temp_file.clone();
2011
2012 defer! {
2013 if temp_file_clone.exists() {
2014 std::fs::remove_file(&temp_file_clone).ok();
2015 }
2016 }
2017
2018 let cli = Cli {
2019 input: InputArgs::default(),
2020 output: OutputArgs::default(),
2021 commands: Some(Commands::Fmt {
2022 indent_width: 2,
2023 check: true,
2024 files: Some(vec![temp_file]),
2025 sort_functions: false,
2026 sort_fields: false,
2027 sort_imports: false,
2028 }),
2029 query: None,
2030 files: None,
2031 ..Cli::default()
2032 };
2033
2034 assert!(cli.run().is_err());
2035 }
2036
2037 #[test]
2038 fn test_update_with_non_markdown_input() {
2039 let cli = Cli {
2040 input: InputArgs {
2041 input_format: Some(InputFormat::Html),
2042 ..Default::default()
2043 },
2044 output: OutputArgs {
2045 update: true,
2046 ..Default::default()
2047 },
2048 commands: None,
2049 query: Some("self".to_string()),
2050 files: None,
2051 ..Cli::default()
2052 };
2053
2054 assert!(cli.run().is_err());
2055 }
2056
2057 #[test]
2058 fn test_list_commands() {
2059 let cli = Cli {
2060 list: true,
2061 ..Cli::default()
2062 };
2063
2064 assert!(cli.run().is_ok());
2065 }
2066
2067 #[test]
2068 fn test_parallel_threshold() {
2069 let files: Vec<PathBuf> = (0..15)
2070 .map(|i| {
2071 let (_, path) = create_file(&format!("test_parallel_{}.md", i), "# Test");
2072 path
2073 })
2074 .collect();
2075
2076 let files_clone = files.clone();
2077 defer! {
2078 for file in &files_clone {
2079 if file.exists() {
2080 std::fs::remove_file(file).ok();
2081 }
2082 }
2083 }
2084
2085 let cli = Cli {
2086 input: InputArgs::default(),
2087 output: OutputArgs::default(),
2088 commands: None,
2089 query: Some("self".to_string()),
2090 files: Some(files),
2091 parallel_threshold: 10,
2092 ..Cli::default()
2093 };
2094
2095 assert!(cli.run().is_ok());
2096 }
2097
2098 #[rstest]
2099 #[case("mq-exec-owner", 0o700, true)]
2100 #[case("mq-exec-group", 0o010, true)]
2101 #[case("mq-exec-other", 0o001, true)]
2102 #[case("mq-exec-all", 0o755, true)]
2103 #[case("mq-noexec-rw", 0o644, false)]
2104 #[case("mq-noexec-ro", 0o444, false)]
2105 #[cfg(unix)]
2106 fn test_is_executable_file_unix(#[case] filename: &str, #[case] mode: u32, #[case] expected: bool) {
2107 use std::os::unix::fs::PermissionsExt;
2108
2109 let temp_dir = std::env::temp_dir().join(format!("mq-exec-test-{filename}"));
2110 fs::create_dir_all(&temp_dir).expect("Failed to create test directory");
2111
2112 defer! {
2113 if temp_dir.exists() {
2114 std::fs::remove_dir_all(&temp_dir).ok();
2115 }
2116 }
2117
2118 let file_path = temp_dir.join(filename);
2119 fs::write(&file_path, "#!/bin/sh\necho test").expect("Failed to write file");
2120 fs::set_permissions(&file_path, fs::Permissions::from_mode(mode)).expect("Failed to set permissions");
2121
2122 let entry = fs::read_dir(&temp_dir)
2123 .expect("Failed to read dir")
2124 .find(|e| e.as_ref().unwrap().file_name().to_str() == Some(filename))
2125 .unwrap()
2126 .unwrap();
2127
2128 assert_eq!(
2129 Cli::is_executable_file(&entry),
2130 expected,
2131 "File with mode {mode:#o} should return {expected}"
2132 );
2133 }
2134
2135 #[test]
2136 #[cfg(unix)]
2137 fn test_is_executable_file_unix_directory() {
2138 use std::os::unix::fs::PermissionsExt;
2139
2140 let temp_dir = std::env::temp_dir().join("mq-dir-test-unix");
2141 let sub_dir = temp_dir.join("mq-subdir");
2142 fs::create_dir_all(&sub_dir).expect("Failed to create test directory");
2143
2144 defer! {
2145 if temp_dir.exists() {
2146 std::fs::remove_dir_all(&temp_dir).ok();
2147 }
2148 }
2149
2150 fs::set_permissions(&sub_dir, fs::Permissions::from_mode(0o755)).expect("Failed to set permissions");
2151
2152 let entry = fs::read_dir(&temp_dir)
2153 .expect("Failed to read dir")
2154 .find(|e| e.as_ref().unwrap().file_name() == "mq-subdir")
2155 .unwrap()
2156 .unwrap();
2157
2158 assert!(!Cli::is_executable_file(&entry), "Directory should return false");
2159 }
2160
2161 #[rstest]
2162 #[case("mq-test.exe", true)]
2163 #[case("mq-test.cmd", true)]
2164 #[case("mq-test.bat", true)]
2165 #[case("mq-test.com", true)]
2166 #[case("mq-test.EXE", true)]
2167 #[case("mq-test.Bat", true)]
2168 #[case("mq-test.txt", false)]
2169 #[case("mq-test.sh", false)]
2170 #[case("mq-test", false)]
2171 #[cfg(windows)]
2172 fn test_is_executable_file_windows(#[case] filename: &str, #[case] expected: bool) {
2173 let temp_dir = std::env::temp_dir().join(format!("mq-exec-test-win-{}", filename.replace('.', "-")));
2174 fs::create_dir_all(&temp_dir).expect("Failed to create test directory");
2175
2176 defer! {
2177 if temp_dir.exists() {
2178 std::fs::remove_dir_all(&temp_dir).ok();
2179 }
2180 }
2181
2182 let file_path = temp_dir.join(filename);
2183 fs::write(&file_path, "test").expect("Failed to write file");
2184
2185 let entry = fs::read_dir(&temp_dir)
2186 .expect("Failed to read dir")
2187 .find(|e| e.as_ref().unwrap().file_name().to_str() == Some(filename))
2188 .unwrap()
2189 .unwrap();
2190
2191 assert_eq!(
2192 Cli::is_executable_file(&entry),
2193 expected,
2194 "File '{filename}' should return {expected}"
2195 );
2196 }
2197
2198 #[test]
2199 #[cfg(windows)]
2200 fn test_is_executable_file_windows_directory() {
2201 let temp_dir = std::env::temp_dir().join("mq-dir-test-windows");
2202 let sub_dir = temp_dir.join("mq-subdir");
2203 fs::create_dir_all(&sub_dir).expect("Failed to create test directory");
2204
2205 defer! {
2206 if temp_dir.exists() {
2207 std::fs::remove_dir_all(&temp_dir).ok();
2208 }
2209 }
2210
2211 let entry = fs::read_dir(&temp_dir)
2212 .expect("Failed to read dir")
2213 .find(|e| e.as_ref().unwrap().file_name() == "mq-subdir")
2214 .unwrap()
2215 .unwrap();
2216
2217 assert!(!Cli::is_executable_file(&entry), "Directory should return false");
2218 }
2219
2220 #[test]
2221 #[cfg(not(any(unix, windows)))]
2222 fn test_is_executable_file_other_os() {
2223 let temp_dir = std::env::temp_dir().join("mq-other-test");
2224 fs::create_dir_all(&temp_dir).expect("Failed to create test directory");
2225
2226 defer! {
2227 if temp_dir.exists() {
2228 std::fs::remove_dir_all(&temp_dir).ok();
2229 }
2230 }
2231
2232 let file = temp_dir.join("mq-test");
2233 fs::write(&file, "test").expect("Failed to write file");
2234
2235 let entry = fs::read_dir(&temp_dir)
2236 .expect("Failed to read dir")
2237 .find(|e| e.as_ref().unwrap().file_name() == "mq-test")
2238 .unwrap()
2239 .unwrap();
2240
2241 assert!(
2242 Cli::is_executable_file(&entry),
2243 "Regular file should return true on other OS"
2244 );
2245 }
2246
2247 #[test]
2248 #[cfg(not(any(unix, windows)))]
2249 fn test_is_executable_file_other_os_directory() {
2250 let temp_dir = std::env::temp_dir().join("mq-dir-other-test");
2251 let sub_dir = temp_dir.join("mq-subdir");
2252 fs::create_dir_all(&sub_dir).expect("Failed to create test directory");
2253
2254 defer! {
2255 if temp_dir.exists() {
2256 std::fs::remove_dir_all(&temp_dir).ok();
2257 }
2258 }
2259
2260 let entry = fs::read_dir(&temp_dir)
2261 .expect("Failed to read dir")
2262 .find(|e| e.as_ref().unwrap().file_name() == "mq-subdir")
2263 .unwrap()
2264 .unwrap();
2265
2266 assert!(
2267 !Cli::is_executable_file(&entry),
2268 "Directory should return false on other OS"
2269 );
2270 }
2271
2272 #[test]
2275 #[cfg(windows)]
2276 fn test_collect_mq_commands_deduplicates_windows_extensions() {
2277 let temp_dir = std::env::temp_dir().join("mq-win-dedup-ext-test");
2278 fs::create_dir_all(&temp_dir).expect("Failed to create test directory");
2279
2280 defer! {
2281 if temp_dir.exists() {
2282 std::fs::remove_dir_all(&temp_dir).ok();
2283 }
2284 }
2285
2286 fs::write(temp_dir.join("mq-foo.exe"), "test").expect("Failed to write file");
2288 fs::write(temp_dir.join("mq-foo.bat"), "@echo test").expect("Failed to write file");
2289 fs::write(temp_dir.join("mq-foo.cmd"), "@echo test").expect("Failed to write file");
2290 fs::write(temp_dir.join("mq-bar.exe"), "test").expect("Failed to write file");
2291
2292 let mut seen = std::collections::HashSet::new();
2293 Cli::collect_mq_commands_from_dir(&temp_dir, &mut seen);
2294
2295 assert_eq!(seen.len(), 2, "Should have exactly 2 unique commands");
2296 assert!(seen.contains("foo"), "Should contain 'foo'");
2297 assert!(seen.contains("bar"), "Should contain 'bar'");
2298 }
2299
2300 #[test]
2303 #[cfg(windows)]
2304 fn test_collect_mq_commands_deduplicates_across_dirs_windows() {
2305 let dir1 = std::env::temp_dir().join("mq-win-cross-dedup-1");
2306 let dir2 = std::env::temp_dir().join("mq-win-cross-dedup-2");
2307 fs::create_dir_all(&dir1).expect("Failed to create test directory");
2308 fs::create_dir_all(&dir2).expect("Failed to create test directory");
2309
2310 defer! {
2311 if dir1.exists() {
2312 std::fs::remove_dir_all(&dir1).ok();
2313 }
2314 if dir2.exists() {
2315 std::fs::remove_dir_all(&dir2).ok();
2316 }
2317 }
2318
2319 fs::write(dir1.join("mq-foo.bat"), "@echo test").expect("Failed to write file");
2320 fs::write(dir2.join("mq-foo.exe"), "test").expect("Failed to write file");
2321 fs::write(dir2.join("mq-unique.cmd"), "@echo test").expect("Failed to write file");
2322
2323 let mut seen = std::collections::HashSet::new();
2324 Cli::collect_mq_commands_from_dir(&dir1, &mut seen);
2325 Cli::collect_mq_commands_from_dir(&dir2, &mut seen);
2326
2327 assert_eq!(seen.len(), 2, "Should have exactly 2 unique commands");
2328 assert!(seen.contains("foo"), "Should contain 'foo'");
2329 assert!(seen.contains("unique"), "Should contain 'unique'");
2330 }
2331
2332 #[test]
2334 fn test_collect_mq_commands_from_empty_dir() {
2335 let temp_dir = std::env::temp_dir().join("mq-empty-dir-test");
2336 fs::create_dir_all(&temp_dir).expect("Failed to create test directory");
2337
2338 defer! {
2339 if temp_dir.exists() {
2340 std::fs::remove_dir_all(&temp_dir).ok();
2341 }
2342 }
2343
2344 let mut seen = std::collections::HashSet::new();
2345 Cli::collect_mq_commands_from_dir(&temp_dir, &mut seen);
2346 assert!(seen.is_empty(), "Empty directory should yield no commands");
2347 }
2348
2349 #[test]
2351 fn test_collect_mq_commands_ignores_non_mq_prefix() {
2352 let temp_dir = std::env::temp_dir().join("mq-prefix-test");
2353 fs::create_dir_all(&temp_dir).expect("Failed to create test directory");
2354
2355 defer! {
2356 if temp_dir.exists() {
2357 std::fs::remove_dir_all(&temp_dir).ok();
2358 }
2359 }
2360
2361 fs::write(temp_dir.join("foo"), "test").expect("Failed to write file");
2363 fs::write(temp_dir.join("bar-mq"), "test").expect("Failed to write file");
2364 fs::write(temp_dir.join("mqfoo"), "test").expect("Failed to write file");
2365
2366 #[cfg(unix)]
2367 {
2368 use std::os::unix::fs::PermissionsExt;
2369 for name in &["foo", "bar-mq", "mqfoo"] {
2370 fs::set_permissions(temp_dir.join(name), fs::Permissions::from_mode(0o755))
2371 .expect("Failed to set permissions");
2372 }
2373 }
2374
2375 let mut seen = std::collections::HashSet::new();
2376 Cli::collect_mq_commands_from_dir(&temp_dir, &mut seen);
2377 assert!(seen.is_empty(), "Files without mq- prefix should be ignored");
2378 }
2379
2380 #[rstest]
2381 #[case("md", InputFormat::Markdown)]
2382 #[case("MD", InputFormat::Markdown)]
2383 #[case("markdown", InputFormat::Markdown)]
2384 #[case("mdx", InputFormat::Mdx)]
2385 #[case("html", InputFormat::Html)]
2386 #[case("htm", InputFormat::Html)]
2387 #[case("txt", InputFormat::Raw)]
2388 #[case("log", InputFormat::Raw)]
2389 #[case("csv", InputFormat::Raw)]
2390 #[case("psv", InputFormat::Raw)]
2391 #[case("tsv", InputFormat::Raw)]
2392 #[case("json", InputFormat::Raw)]
2393 #[case("toml", InputFormat::Raw)]
2394 #[case("yaml", InputFormat::Raw)]
2395 #[case("yml", InputFormat::Raw)]
2396 #[case("xml", InputFormat::Raw)]
2397 #[case("jsonl", InputFormat::Text)]
2398 #[case("ndjson", InputFormat::Text)]
2399 #[case("unknown", InputFormat::Markdown)] fn test_from_extension(#[case] ext: &str, #[case] expected: InputFormat) {
2401 assert_eq!(InputFormat::from_extension(ext), expected);
2402 }
2403}