1use clap::{Parser, Subcommand};
2use colored::Colorize;
3use glob::glob;
4use itertools::Itertools;
5use miette::IntoDiagnostic;
6use miette::miette;
7use mq_lang::DefaultEngine;
8use rayon::prelude::*;
9use std::collections::VecDeque;
10use std::io::BufRead;
11use std::io::IsTerminal;
12use std::io::{self, BufWriter, Read, Write};
13use std::process::Command;
14use std::str::FromStr;
15use std::{fs, path::PathBuf};
16
17#[derive(Parser, Debug, Default)]
18#[command(name = "mq")]
19#[command(author = env!("CARGO_PKG_AUTHORS"))]
20#[command(version = env!("CARGO_PKG_VERSION"))]
21#[command(after_help = "# Examples:\n\n\
22 ## To filter markdown nodes:\n\
23 mq 'query' file.md\n\n\
24 ## To read query from file:\n\
25 mq -f 'file' file.md\n\n\
26 ## To start a REPL session:\n\
27 mq repl\n\n\
28 ## To format mq file:\n\
29 mq fmt --check file.mq")]
30#[command(
31 about = "mq is a markdown processor that can filter markdown nodes by using jq-like syntax.",
32 long_about = None
33)]
34pub struct Cli {
35 #[clap(flatten)]
36 input: InputArgs,
37
38 #[clap(flatten)]
39 output: OutputArgs,
40
41 #[clap(subcommand)]
42 commands: Option<Commands>,
43
44 #[arg(long)]
46 list: bool,
47
48 #[arg(short = 'P', default_value_t = 10)]
50 parallel_threshold: usize,
51
52 #[arg(value_name = "QUERY OR FILE")]
53 query: Option<String>,
54 files: Option<Vec<PathBuf>>,
55}
56
57#[derive(Clone, Debug, Default, clap::ValueEnum)]
65enum InputFormat {
66 #[default]
67 Markdown,
68 Mdx,
69 Html,
70 Text,
71 Null,
72 Raw,
73}
74
75#[derive(Clone, Debug, Default, clap::ValueEnum)]
76enum OutputFormat {
77 #[default]
78 Markdown,
79 Html,
80 Text,
81 Json,
82 None,
83}
84
85#[derive(Clone, Debug, Default, clap::ValueEnum)]
86enum DocFormat {
87 #[default]
88 Markdown,
89 Text,
90}
91
92#[derive(Debug, Clone, Default, clap::ValueEnum)]
93pub enum ListStyle {
94 #[default]
95 Dash,
96 Plus,
97 Star,
98}
99
100#[derive(Debug, Clone, PartialEq, Default, clap::ValueEnum)]
101pub enum LinkTitleStyle {
102 #[default]
103 Double,
104 Single,
105 Paren,
106}
107
108#[derive(Debug, Clone, PartialEq, Default, clap::ValueEnum)]
109pub enum LinkUrlStyle {
110 #[default]
111 None,
112 Angle,
113}
114
115#[derive(Clone, Debug, clap::Args, Default)]
116struct InputArgs {
117 #[arg(short = 'A', long, default_value_t = false)]
119 aggregate: bool,
120
121 #[arg(short, long, default_value_t = false)]
123 from_file: bool,
124
125 #[arg(short = 'I', long, value_enum)]
127 input_format: Option<InputFormat>,
128
129 #[arg(short = 'L', long = "directory")]
131 module_directories: Option<Vec<PathBuf>>,
132
133 #[arg(short = 'M', long)]
135 module_names: Option<Vec<String>>,
136
137 #[arg(long, value_names = ["NAME", "VALUE"])]
139 args: Option<Vec<String>>,
140
141 #[arg(long="rawfile", value_names = ["NAME", "FILE"])]
143 raw_file: Option<Vec<String>>,
144
145 #[arg(long, default_value_t = false)]
147 stream: bool,
148
149 #[arg(long = "json", default_value_t = false)]
150 include_json: bool,
151
152 #[arg(long = "csv", default_value_t = false)]
154 include_csv: bool,
155
156 #[arg(long = "fuzzy", default_value_t = false)]
158 include_fuzzy: bool,
159
160 #[arg(long = "yaml", default_value_t = false)]
162 include_yaml: bool,
163
164 #[arg(long = "toml", default_value_t = false)]
166 include_toml: bool,
167
168 #[arg(long = "xml", default_value_t = false)]
170 include_xml: bool,
171
172 #[arg(long = "test", default_value_t = false)]
174 include_test: bool,
175}
176
177#[derive(Clone, Debug, clap::Args, Default)]
178struct OutputArgs {
179 #[arg(short = 'F', long, value_enum, default_value_t)]
181 output_format: OutputFormat,
182
183 #[arg(
185 short = 'U',
186 long = "update",
187 short_alias='i',
188 aliases=["in-place", "inplace"],
189 default_value_t = false
190 )]
191 update: bool,
192
193 #[clap(long, default_value_t = false)]
195 unbuffered: bool,
196
197 #[clap(long, value_enum, default_value_t = ListStyle::Dash)]
199 list_style: ListStyle,
200
201 #[clap(long, value_enum, default_value_t = LinkTitleStyle::Double)]
203 link_title_style: LinkTitleStyle,
204
205 #[clap(long, value_enum, default_value_t = LinkUrlStyle::None)]
207 link_url_style: LinkUrlStyle,
208
209 #[clap(short = 'S', long, value_name = "QUERY")]
211 separator: Option<String>,
212
213 #[clap(short = 'o', long = "output", value_name = "FILE")]
215 output_file: Option<PathBuf>,
216}
217
218#[derive(Debug, Subcommand)]
219enum Commands {
220 Repl,
222 Fmt {
224 #[arg(short, long, default_value_t = 2)]
226 indent_width: usize,
227 #[arg(short, long)]
229 check: bool,
230 #[arg(long, default_value_t = false)]
232 sort_imports: bool,
233 #[arg(long, default_value_t = false)]
235 sort_functions: bool,
236 #[arg(long, default_value_t = false)]
238 sort_fields: bool,
239 files: Option<Vec<PathBuf>>,
241 },
242 Docs {
244 #[arg(short = 'M', long)]
246 module_names: Option<Vec<String>>,
247 #[arg(short = 'F', long, value_enum, default_value_t)]
249 format: DocFormat,
250 },
251 Check {
253 files: Vec<PathBuf>,
255 },
256 #[cfg(feature = "debugger")]
258 Dap,
259}
260
261impl Cli {
262 fn get_external_commands_dir() -> Option<PathBuf> {
264 let home_dir = dirs::home_dir()?;
265 let mq_bin_dir = home_dir.join(".mq").join("bin");
266 if mq_bin_dir.exists() && mq_bin_dir.is_dir() {
267 Some(mq_bin_dir)
268 } else {
269 None
270 }
271 }
272
273 fn find_external_commands() -> Vec<String> {
275 let mut commands = Vec::new();
276
277 if let Some(bin_dir) = Self::get_external_commands_dir()
278 && let Ok(entries) = fs::read_dir(bin_dir)
279 {
280 for entry in entries.flatten() {
281 if let Ok(file_name) = entry.file_name().into_string()
282 && file_name.starts_with("mq-")
283 {
284 let subcommand = file_name.strip_prefix("mq-").unwrap();
286 commands.push(subcommand.to_string());
287 }
288 }
289 }
290
291 commands.sort();
292 commands
293 }
294
295 fn execute_external_command(&self, args: &[String]) -> miette::Result<()> {
297 if args.is_empty() {
298 return Err(miette!("No subcommand specified"));
299 }
300
301 let subcommand = &args[0];
302 let bin_dir = Self::get_external_commands_dir()
303 .ok_or_else(|| miette!("External commands directory (~/.mq/bin) not found"))?;
304
305 let command_path = bin_dir.join(format!("mq-{}", subcommand));
306
307 if !command_path.exists() {
308 return Err(miette!(
309 "External subcommand 'mq-{}' not found in ~/.mq/bin\nSearched at: {}",
310 subcommand,
311 command_path.display()
312 ));
313 }
314
315 #[cfg(unix)]
317 {
318 use std::os::unix::fs::PermissionsExt;
319 let metadata = fs::metadata(&command_path).into_diagnostic()?;
320 let permissions = metadata.permissions();
321 if permissions.mode() & 0o111 == 0 {
322 return Err(miette!(
323 "External subcommand 'mq-{}' is not executable. Run: chmod +x {}",
324 subcommand,
325 command_path.display()
326 ));
327 }
328 }
329
330 let status = Command::new(&command_path).args(&args[1..]).status().map_err(|e| {
332 miette!(
333 "Failed to execute external subcommand 'mq-{}' at {}: {}",
334 subcommand,
335 command_path.display(),
336 e
337 )
338 })?;
339
340 if !status.success() {
341 let code = status.code().unwrap_or(1);
342 std::process::exit(code);
343 }
344
345 Ok(())
346 }
347
348 fn list_commands(&self) -> miette::Result<()> {
350 let mut output = vec![
351 format!("{}", "Built-in subcommands:".bold().cyan()),
352 format!(
353 " {} - Start a REPL session for interactive query execution",
354 "repl".green()
355 ),
356 format!(
357 " {} - Format mq files based on specified formatting options",
358 "fmt".green()
359 ),
360 format!(" {} - Show functions documentation for the query", "docs".green()),
361 format!(" {} - Check syntax errors in mq files", "check".green()),
362 ];
363
364 #[cfg(feature = "debugger")]
365 output.push(format!(" {} - Start a debug adapter for mq", "dap".green()));
366
367 let external_commands = Self::find_external_commands();
368 if !external_commands.is_empty() {
369 output.push("".to_string());
370 output.push(format!("{}", "External subcommands (from ~/.mq/bin):".bold().yellow()));
371 for cmd in external_commands {
372 output.push(format!(" {}", cmd.bright_yellow()));
373 }
374 }
375
376 println!("{}", output.join("\n"));
377 Ok(())
378 }
379
380 pub fn run(&self) -> miette::Result<()> {
381 if self.list {
382 return self.list_commands();
383 }
384
385 if !self.input.from_file
388 && self.commands.is_none()
389 && let Some(query_value) = &self.query
390 && let Some(bin_dir) = Self::get_external_commands_dir()
391 {
392 if query_value
394 .chars()
395 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
396 {
397 let command_path = bin_dir.join(format!("mq-{}", query_value));
398 if command_path.exists() {
399 let mut args = vec![query_value.clone()];
400 if let Some(files) = &self.files {
401 args.extend(files.iter().map(|p| p.to_string_lossy().to_string()));
402 }
403 return self.execute_external_command(&args);
404 }
405 }
406 }
407
408 if !matches!(self.input.input_format, Some(InputFormat::Markdown) | None) && self.output.update {
409 return Err(miette!("The output format is not supported for the update option"));
410 }
411
412 match &self.commands {
413 Some(Commands::Repl) => mq_repl::Repl::new(vec![mq_lang::RuntimeValue::String("".to_string())]).run(),
414 None if self.query.is_none() => {
415 mq_repl::Repl::new(vec![mq_lang::RuntimeValue::String("".to_string())]).run()
416 }
417 Some(Commands::Fmt {
418 indent_width,
419 check,
420 files,
421 sort_imports,
422 sort_fields,
423 sort_functions,
424 }) => {
425 let mut formatter = mq_formatter::Formatter::new(Some(mq_formatter::FormatterConfig {
426 indent_width: *indent_width,
427 sort_imports: *sort_imports,
428 sort_fields: *sort_fields,
429 sort_functions: *sort_functions,
430 }));
431 let files = match files {
432 Some(f) => f,
433 None => &glob("./**/*.mq")
434 .into_diagnostic()?
435 .collect::<Result<Vec<_>, _>>()
436 .into_diagnostic()?,
437 };
438
439 for file in files {
440 if !file.exists() {
441 return Err(miette!("File not found: {}", file.display()));
442 }
443
444 let content = fs::read_to_string(file).into_diagnostic()?;
445 let formatted = formatter
446 .format(&content)
447 .map_err(|e| miette!("{}: {e}", file.display()))?;
448
449 if *check && formatted != content {
450 return Err(miette!("The input is not formatted"));
451 } else if formatted != content {
452 fs::write(file, formatted).into_diagnostic()?;
453 }
454 }
455
456 Ok(())
457 }
458 Some(Commands::Docs { module_names, format }) => self.docs(module_names, format),
459 Some(Commands::Check { files }) => {
460 let stdout = io::stdout();
461 let mut handle = BufWriter::new(stdout.lock());
462 let mut has_error = false;
463
464 for file in files {
465 if !file.exists() {
466 return Err(miette!("File not found: {}", file.display()));
467 }
468
469 let content = fs::read_to_string(file).into_diagnostic()?;
470 let mut hir = mq_hir::Hir::default();
471 hir.add_code(None, &content);
472
473 let errors = hir.error_ranges();
474 let warnings = hir.warning_ranges();
475
476 if !errors.is_empty() || !warnings.is_empty() {
477 has_error = true;
478 writeln!(handle, "Checking: {}", file.display()).into_diagnostic()?;
479
480 for (message, range) in errors {
481 writeln!(
482 handle,
483 " {}: {} at line {}, column {}",
484 "Error".red().bold(),
485 message,
486 range.start.line,
487 range.start.column
488 )
489 .into_diagnostic()?;
490 }
491
492 for (message, range) in warnings {
493 writeln!(
494 handle,
495 " {}: {} at line {}, column {}",
496 "Warning".yellow().bold(),
497 message,
498 range.start.line,
499 range.start.column
500 )
501 .into_diagnostic()?;
502 }
503 writeln!(handle).into_diagnostic()?;
504 }
505 }
506
507 handle.flush().into_diagnostic()?;
508
509 if has_error { Err(miette!("")) } else { Ok(()) }
510 }
511 #[cfg(feature = "debugger")]
512 Some(Commands::Dap) => mq_dap::start().map_err(|e| miette!(e.to_string())),
513 None => {
514 if self.input.stream {
515 self.process_streaming()
516 } else {
517 self.process_batch()
518 }
519 }
520 }
521 }
522
523 fn create_engine(&self) -> miette::Result<DefaultEngine> {
524 let mut engine = mq_lang::DefaultEngine::default();
525 engine.load_builtin_module();
526
527 if let Some(dirs) = &self.input.module_directories {
528 engine.set_search_paths(dirs.clone());
529 }
530
531 if let Some(modules) = &self.input.module_names {
532 for module_name in modules {
533 engine.load_module(module_name).map_err(|e| *e)?;
534 }
535 }
536
537 if let Some(args) = &self.input.args {
538 args.chunks(2).for_each(|v| {
539 engine.define_string_value(&v[0], &v[1]);
540 });
541 }
542
543 if let Some(raw_file) = &self.input.raw_file {
544 for v in raw_file.chunks(2) {
545 let path = PathBuf::from_str(&v[1]).into_diagnostic()?;
546
547 if !path.exists() {
548 return Err(miette!("File not found: {}", path.display()));
549 }
550
551 let content = fs::read_to_string(&path).into_diagnostic()?;
552 engine.define_string_value(&v[0], &content);
553 }
554 }
555
556 #[cfg(feature = "debugger")]
557 {
558 use crate::debugger::DebuggerHandler;
559 let handler = DebuggerHandler::new(engine.clone());
560 engine.set_debugger_handler(Box::new(handler));
561 engine.debugger().write().unwrap().activate();
562 }
563
564 Ok(engine)
565 }
566
567 fn get_query(&self) -> miette::Result<String> {
568 let query = match self.query.as_ref() {
569 Some(q) if self.input.from_file => {
570 let path = PathBuf::from_str(q).into_diagnostic()?;
571 fs::read_to_string(path).into_diagnostic()?
572 }
573 Some(q) => q.clone(),
574 None => return Err(miette!("Query is required")),
575 };
576
577 let includes = [
578 ("csv", self.input.include_csv),
579 ("fuzzy", self.input.include_fuzzy),
580 ("json", self.input.include_json),
581 ("toml", self.input.include_toml),
582 ("yaml", self.input.include_yaml),
583 ("xml", self.input.include_xml),
584 ("test", self.input.include_test),
585 ]
586 .iter()
587 .filter(|(_, enabled)| *enabled)
588 .map(|(name, _)| format!(r#"include "{}""#, name))
589 .join(" | ");
590
591 let aggregate = self.input.aggregate.then_some(r#"nodes | import "section""#);
592
593 let query = match (includes.is_empty(), query.is_empty()) {
594 (true, false) => query,
595 (false, true) => includes,
596 (false, false) => format!("{} | {}", includes, query),
597 (true, true) => String::new(),
598 };
599
600 Ok(aggregate.map(|agg| format!("{} | {}", agg, query)).unwrap_or(query))
601 }
602
603 fn execute(
604 &self,
605 engine: &mut mq_lang::DefaultEngine,
606 query: &str,
607 file: &Option<PathBuf>,
608 content: &str,
609 ) -> miette::Result<()> {
610 if let Some(file) = file {
611 engine.define_string_value("__FILE__", file.to_string_lossy().as_ref());
612 }
613
614 let input = match self.input.input_format.as_ref().unwrap_or_else(|| {
615 if let Some(file) = file {
616 match file
617 .extension()
618 .unwrap_or_default()
619 .to_string_lossy()
620 .to_lowercase()
621 .as_str()
622 {
623 "md" | "markdown" => &InputFormat::Markdown,
624 "mdx" => &InputFormat::Mdx,
625 "html" | "htm" => &InputFormat::Html,
626 "txt" | "csv" | "tsv" | "json" | "toml" | "yaml" | "yml" | "xml" => &InputFormat::Raw,
627 _ => &InputFormat::Markdown,
628 }
629 } else if io::stdin().is_terminal() {
630 &InputFormat::Null
631 } else {
632 &InputFormat::Markdown
633 }
634 }) {
635 InputFormat::Markdown => mq_lang::parse_markdown_input(content)?,
636 InputFormat::Mdx => mq_lang::parse_mdx_input(content)?,
637 InputFormat::Text => mq_lang::parse_text_input(content)?,
638 InputFormat::Html => mq_lang::parse_html_input(content)?,
639 InputFormat::Null => mq_lang::null_input(),
640 InputFormat::Raw => mq_lang::raw_input(content),
641 };
642
643 let runtime_values = if self.output.update {
644 let results = engine.eval(query, input.clone().into_iter()).map_err(|e| *e)?;
645 let current_values: mq_lang::RuntimeValues = input.clone().into();
646
647 if current_values.len() != results.len() {
648 return Err(miette!("The number of input and output values do not match"));
649 }
650
651 current_values.update_with(results)
652 } else {
653 engine.eval(query, input.into_iter()).map_err(|e| *e)?
654 };
655
656 if let Some(separator) = &self.output.separator {
657 let separator = engine
658 .eval(
659 separator,
660 vec![mq_lang::RuntimeValue::String("".to_string())].into_iter(),
661 )
662 .map_err(|e| *e)?;
663 self.print(separator)?;
664 }
665
666 self.print(runtime_values)
667 }
668
669 fn process_batch(&self) -> Result<(), miette::Error> {
670 let query = self.get_query()?;
671 let files = self.read_contents()?;
672
673 if files.len() > self.parallel_threshold {
674 files.par_iter().try_for_each(|(file, content)| {
675 let mut engine = self.create_engine()?;
676 self.execute(&mut engine, &query, file, content)
677 })?;
678 } else {
679 let mut engine = self.create_engine()?;
680 files
681 .iter()
682 .try_for_each(|(file, content)| self.execute(&mut engine, &query, file, content))?;
683 }
684
685 Ok(())
686 }
687
688 fn process_streaming(&self) -> miette::Result<()> {
689 let query = self.get_query()?;
690 let mut engine = self.create_engine()?;
691
692 self.process_lines(|file, line| self.execute(&mut engine, &query, &file.cloned(), line))
693 }
694
695 fn process_lines<F>(&self, mut process: F) -> miette::Result<()>
696 where
697 F: FnMut(Option<&PathBuf>, &str) -> miette::Result<()>,
698 {
699 if let Some(files) = &self.files {
701 for file in files {
702 let file_handle = fs::File::open(file).into_diagnostic()?;
703 let reader = io::BufReader::new(file_handle);
704 for line_result in reader.lines() {
705 let line = line_result.into_diagnostic()?;
706 process(Some(file), &line)?;
707 }
708 }
709 } else {
710 let stdin = io::stdin();
712 let reader = io::BufReader::new(stdin.lock());
713 for line_result in reader.lines() {
714 let line = line_result.into_diagnostic()?;
715 process(None, &line)?;
716 }
717 }
718 Ok(())
719 }
720
721 fn read_contents(&self) -> miette::Result<Vec<(Option<PathBuf>, String)>> {
722 if matches!(self.input.input_format, Some(InputFormat::Null)) {
723 return Ok(vec![(None, "".to_string())]);
724 }
725
726 self.files
727 .clone()
728 .map(|files| {
729 let load_contents: miette::Result<Vec<String>> = files
730 .iter()
731 .map(|file| fs::read_to_string(file).into_diagnostic())
732 .collect();
733 load_contents.map(move |contents| {
734 files
735 .into_iter()
736 .zip(contents)
737 .map(|(file, content)| (Some(file), content))
738 .collect::<Vec<_>>()
739 })
740 })
741 .unwrap_or_else(|| {
742 if io::stdin().is_terminal() {
743 return Ok(vec![(None, "".to_string())]);
744 }
745
746 let mut input = String::new();
747 io::stdin().read_to_string(&mut input).into_diagnostic()?;
748 Ok(vec![(None, input)])
749 })
750 }
751
752 #[inline(always)]
753 fn write_ignore_pipe<W: Write>(handle: &mut W, data: &[u8]) -> miette::Result<()> {
754 match handle.write_all(data) {
755 Ok(()) => Ok(()),
756 Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => Ok(()),
757 Err(e) => Err(miette!(e)),
758 }
759 }
760
761 fn print(&self, runtime_values: mq_lang::RuntimeValues) -> miette::Result<()> {
762 let stdout = io::stdout();
763 let mut handle: Box<dyn Write> = if let Some(output_file) = &self.output.output_file {
764 let file = fs::File::create(output_file).into_diagnostic()?;
765 Box::new(BufWriter::new(file))
766 } else if self.output.unbuffered {
767 Box::new(stdout.lock())
768 } else {
769 Box::new(BufWriter::new(stdout.lock()))
770 };
771 let runtime_values = runtime_values.values();
772 let mut markdown = mq_markdown::Markdown::new(
773 runtime_values
774 .iter()
775 .map(|runtime_value| match runtime_value {
776 mq_lang::RuntimeValue::Markdown(node, _) => node.clone(),
777 _ => runtime_value.to_string().into(),
778 })
779 .collect(),
780 );
781 markdown.set_options(mq_markdown::RenderOptions {
782 list_style: match self.output.list_style.clone() {
783 ListStyle::Dash => mq_markdown::ListStyle::Dash,
784 ListStyle::Plus => mq_markdown::ListStyle::Plus,
785 ListStyle::Star => mq_markdown::ListStyle::Star,
786 },
787 link_title_style: match self.output.link_title_style.clone() {
788 LinkTitleStyle::Double => mq_markdown::TitleSurroundStyle::Double,
789 LinkTitleStyle::Single => mq_markdown::TitleSurroundStyle::Single,
790 LinkTitleStyle::Paren => mq_markdown::TitleSurroundStyle::Paren,
791 },
792 link_url_style: match self.output.link_url_style.clone() {
793 LinkUrlStyle::None => mq_markdown::UrlSurroundStyle::None,
794 LinkUrlStyle::Angle => mq_markdown::UrlSurroundStyle::Angle,
795 },
796 });
797
798 match self.output.output_format {
799 OutputFormat::Html => Self::write_ignore_pipe(&mut handle, markdown.to_html().as_bytes())?,
800 OutputFormat::Text => {
801 Self::write_ignore_pipe(&mut handle, markdown.to_text().as_bytes())?;
802 }
803 OutputFormat::Markdown => {
804 Self::write_ignore_pipe(&mut handle, markdown.to_string().as_bytes())?;
805 }
806 OutputFormat::Json => {
807 Self::write_ignore_pipe(&mut handle, markdown.to_json()?.as_bytes())?;
808 }
809 OutputFormat::None => {}
810 }
811
812 if !self.output.unbuffered
813 && let Err(e) = handle.flush()
814 && e.kind() != std::io::ErrorKind::BrokenPipe
815 {
816 return Err(miette!(e));
817 }
818
819 Ok(())
820 }
821
822 fn docs(&self, module_names: &Option<Vec<String>>, format: &DocFormat) -> Result<(), miette::Error> {
823 let mut hir = mq_hir::Hir::default();
824
825 if let Some(module_names) = module_names {
826 hir.builtin.disabled = true;
827
828 for module_name in module_names {
829 hir.add_code(None, &format!("include \"{}\"", module_name));
830 }
831 } else {
832 hir.add_code(None, "");
833 }
834
835 let symbols = hir
836 .symbols()
837 .sorted_by_key(|(_, symbol)| symbol.value.clone())
838 .filter_map(|(_, symbol)| match symbol {
839 mq_hir::Symbol {
840 kind: mq_hir::SymbolKind::Function(params),
841 value: Some(value),
842 doc,
843 ..
844 }
845 | mq_hir::Symbol {
846 kind: mq_hir::SymbolKind::Macro(params),
847 value: Some(value),
848 doc,
849 ..
850 } if !symbol.is_internal_function() => {
851 let name = if symbol.is_deprecated() {
852 format!("~~`{}`~~", value)
853 } else {
854 format!("`{}`", value)
855 };
856 let description = doc.iter().map(|(_, d)| d.to_string()).join("\n");
857 let args = params.iter().map(|p| format!("`{}`", p.name)).join(", ");
858 let example = format!("{}({})", value, params.iter().map(|p| p.name.as_str()).join(", "));
859
860 Some([name, description, args, example])
861 }
862 _ => None,
863 })
864 .collect::<VecDeque<_>>();
865
866 match format {
867 DocFormat::Markdown => {
868 let mut doc_csv = symbols
869 .iter()
870 .map(|[name, description, args, example]| {
871 mq_lang::RuntimeValue::String([name, description, args, example].into_iter().join("\t"))
872 })
873 .collect::<VecDeque<_>>();
874
875 doc_csv.push_front(mq_lang::RuntimeValue::String(
876 ["Function Name", "Description", "Parameters", "Example"]
877 .iter()
878 .join("\t"),
879 ));
880
881 let mut engine = self.create_engine()?;
882 let doc_values = engine
883 .eval(
884 r#"include "csv" | tsv_parse(false) | csv_to_markdown_table()"#,
885 mq_lang::raw_input(&doc_csv.iter().join("\n")).into_iter(),
886 )
887 .map_err(|e| *e)?;
888 self.print(doc_values)?;
889 }
890 DocFormat::Text => {
891 println!(
892 "{}",
893 symbols
894 .iter()
895 .map(|[name, description, args, _]| {
896 let name = name.replace('`', "");
897 let args = args.replace('`', "");
898 format!("# {description}\ndef {name}({args})")
899 })
900 .join("\n\n")
901 );
902 }
903 }
904
905 Ok(())
906 }
907}
908#[cfg(test)]
909mod tests {
910 use scopeguard::defer;
911 use std::io::Write;
912 use std::{fs::File, path::PathBuf};
913
914 use super::*;
915
916 fn create_file(name: &str, content: &str) -> (PathBuf, PathBuf) {
917 let temp_dir = std::env::temp_dir();
918 let temp_file_path = temp_dir.join(name);
919 let mut file = File::create(&temp_file_path).expect("Failed to create temp file");
920 file.write_all(content.as_bytes())
921 .expect("Failed to write to temp file");
922
923 (temp_dir, temp_file_path)
924 }
925
926 #[test]
927 fn test_cli_null_input() {
928 let cli = Cli {
929 input: InputArgs {
930 input_format: Some(InputFormat::Null),
931 ..Default::default()
932 },
933 output: OutputArgs::default(),
934 commands: None,
935 query: Some("self".to_string()),
936 files: None,
937 ..Cli::default()
938 };
939
940 assert!(cli.run().is_ok());
941 }
942
943 #[test]
944 fn test_cli_raw_input() {
945 let (_, temp_file_path) = create_file("test1.md", "# test");
946 let temp_file_path_clone = temp_file_path.clone();
947
948 defer! {
949 if temp_file_path_clone.exists() {
950 std::fs::remove_file(&temp_file_path_clone).expect("Failed to delete temp file");
951 }
952 }
953
954 let cli = Cli {
955 input: InputArgs {
956 input_format: Some(InputFormat::Text),
957 ..Default::default()
958 },
959 output: OutputArgs::default(),
960 commands: None,
961 query: Some("self".to_string()),
962 files: Some(vec![temp_file_path]),
963 ..Cli::default()
964 };
965
966 assert!(cli.run().is_ok());
967 }
968
969 #[test]
970 fn test_cli_output_formats() {
971 let (_, temp_file_path) = create_file("test2.md", "# test");
972 let temp_file_path_clone = temp_file_path.clone();
973
974 defer! {
975 if temp_file_path_clone.exists() {
976 std::fs::remove_file(&temp_file_path_clone).expect("Failed to delete temp file");
977 }
978 }
979
980 for format in [OutputFormat::Markdown, OutputFormat::Html, OutputFormat::Text] {
981 let cli = Cli {
982 input: InputArgs::default(),
983 output: OutputArgs {
984 output_format: format.clone(),
985 ..Default::default()
986 },
987 commands: None,
988 query: Some("self".to_string()),
989 files: Some(vec![temp_file_path.clone()]),
990 ..Cli::default()
991 };
992
993 assert!(cli.run().is_ok());
994 }
995 }
996
997 #[test]
998 fn test_cli_list_styles() {
999 let (_, temp_file_path) = create_file("test3.md", "# test");
1000 let temp_file_path_clone = temp_file_path.clone();
1001
1002 defer! {
1003 if temp_file_path_clone.exists() {
1004 std::fs::remove_file(&temp_file_path_clone).expect("Failed to delete temp file");
1005 }
1006 }
1007
1008 for style in [ListStyle::Dash, ListStyle::Plus, ListStyle::Star] {
1009 let cli = Cli {
1010 input: InputArgs::default(),
1011 output: OutputArgs {
1012 list_style: style.clone(),
1013 ..Default::default()
1014 },
1015 commands: None,
1016 query: Some("self".to_string()),
1017 files: Some(vec![temp_file_path.clone()]),
1018 ..Cli::default()
1019 };
1020
1021 assert!(cli.run().is_ok());
1022 }
1023 }
1024
1025 #[test]
1026 fn test_cli_fmt_command() {
1027 let (_, temp_file_path) = create_file("test1.mq", "def math(): 42;");
1028 let temp_file_path_clone = temp_file_path.clone();
1029
1030 defer! {
1031 if temp_file_path_clone.exists() {
1032 std::fs::remove_file(&temp_file_path_clone).expect("Failed to delete temp file");
1033 }
1034 }
1035
1036 let cli = Cli {
1037 input: InputArgs::default(),
1038 output: OutputArgs::default(),
1039 commands: Some(Commands::Fmt {
1040 indent_width: 2,
1041 check: false,
1042 files: Some(vec![temp_file_path.clone()]),
1043 sort_functions: false,
1044 sort_fields: false,
1045 sort_imports: false,
1046 }),
1047 query: None,
1048 files: Some(vec![temp_file_path]),
1049 ..Cli::default()
1050 };
1051
1052 assert!(cli.run().is_ok());
1053 }
1054
1055 #[test]
1056 fn test_cli_fmt_command_with_check() {
1057 let (_, temp_file_path) = create_file("test2.mq", "def math(): 42;");
1058 let temp_file_path_clone = temp_file_path.clone();
1059
1060 defer! {
1061 if temp_file_path_clone.exists() {
1062 std::fs::remove_file(&temp_file_path_clone).expect("Failed to delete temp file");
1063 }
1064 }
1065
1066 let cli = Cli {
1067 input: InputArgs::default(),
1068 output: OutputArgs::default(),
1069 commands: Some(Commands::Fmt {
1070 indent_width: 2,
1071 check: true,
1072 files: Some(vec![temp_file_path.clone()]),
1073 sort_functions: false,
1074 sort_fields: false,
1075 sort_imports: false,
1076 }),
1077 query: None,
1078 files: Some(vec![temp_file_path]),
1079 ..Cli::default()
1080 };
1081
1082 assert!(cli.run().is_ok());
1083 }
1084
1085 #[test]
1086 fn test_cli_update_flag() {
1087 let (_, temp_file_path) = create_file("test4.md", "# test");
1088 let temp_file_path_clone = temp_file_path.clone();
1089
1090 defer! {
1091 if temp_file_path_clone.exists() {
1092 std::fs::remove_file(&temp_file_path_clone).expect("Failed to delete temp file");
1093 }
1094 }
1095
1096 let cli = Cli {
1097 input: InputArgs::default(),
1098 output: OutputArgs {
1099 update: true,
1100 ..Default::default()
1101 },
1102 commands: None,
1103 query: Some("self".to_string()),
1104 files: Some(vec![temp_file_path]),
1105 ..Cli::default()
1106 };
1107
1108 assert!(cli.run().is_ok());
1109 }
1110
1111 #[test]
1112 fn test_cli_with_module_names() {
1113 let (temp_dir, temp_file_path) = create_file("math.mq", "def math(): 42;");
1114 let (_, temp_md_file_path) = create_file("test.md", "# test");
1115 let temp_md_file_path_clone = temp_md_file_path.clone();
1116
1117 defer! {
1118 if temp_file_path.exists() {
1119 std::fs::remove_file(&temp_file_path).expect("Failed to delete temp file");
1120 }
1121
1122 if temp_md_file_path_clone.exists() {
1123 std::fs::remove_file(&temp_md_file_path_clone).expect("Failed to delete temp file");
1124 }
1125 }
1126
1127 let cli = Cli {
1128 input: InputArgs {
1129 module_names: Some(vec!["math".to_string()]),
1130 module_directories: Some(vec![temp_dir.clone()]),
1131 ..Default::default()
1132 },
1133 output: OutputArgs::default(),
1134 commands: None,
1135 query: Some("math".to_owned()),
1136 files: Some(vec![temp_md_file_path]),
1137 ..Cli::default()
1138 };
1139
1140 assert!(cli.run().is_ok());
1141 }
1142
1143 #[test]
1144 fn test_find_external_commands() {
1145 let commands = Cli::find_external_commands();
1147 assert!(commands.iter().all(|cmd| !cmd.is_empty()));
1149 }
1150
1151 #[test]
1152 fn test_get_external_commands_dir() {
1153 let dir = Cli::get_external_commands_dir();
1155 if let Some(path) = dir {
1156 assert!(path.ends_with(".mq/bin") || path.ends_with(".mq\\bin"));
1157 }
1158 }
1159
1160 #[test]
1161 fn test_external_command_execution() {
1162 let temp_dir = std::env::temp_dir().join("mq-run-test");
1164 let bin_dir = temp_dir.join(".mq").join("bin");
1165 fs::create_dir_all(&bin_dir).expect("Failed to create test directory");
1166
1167 defer! {
1168 if temp_dir.exists() {
1169 std::fs::remove_dir_all(&temp_dir).ok();
1170 }
1171 }
1172
1173 let test_cmd_path = bin_dir.join("mq-testcmd");
1175 #[cfg(unix)]
1176 {
1177 use std::os::unix::fs::PermissionsExt;
1178 fs::write(&test_cmd_path, "#!/bin/sh\necho 'test output'").expect("Failed to write test command");
1179 let mut perms = fs::metadata(&test_cmd_path)
1180 .expect("Failed to get metadata")
1181 .permissions();
1182 perms.set_mode(0o755);
1183 fs::set_permissions(&test_cmd_path, perms).expect("Failed to set permissions");
1184 }
1185 #[cfg(not(unix))]
1186 {
1187 fs::write(&test_cmd_path, "@echo off\necho test output").expect("Failed to write test command");
1188 }
1189
1190 assert!(test_cmd_path.exists());
1193 }
1194
1195 #[test]
1196 fn test_cli_check_command_valid_file() {
1197 let (_, temp_file_path) = create_file("test_check.mq", "def math(): 42;");
1198 let temp_file_path_clone = temp_file_path.clone();
1199
1200 defer! {
1201 if temp_file_path_clone.exists() {
1202 std::fs::remove_file(&temp_file_path_clone).expect("Failed to delete temp file");
1203 }
1204 }
1205
1206 let cli = Cli {
1207 input: InputArgs::default(),
1208 output: OutputArgs::default(),
1209 commands: Some(Commands::Check {
1210 files: vec![temp_file_path],
1211 }),
1212 query: None,
1213 files: None,
1214 ..Cli::default()
1215 };
1216
1217 assert!(cli.run().is_ok());
1218 }
1219
1220 #[test]
1221 fn test_cli_check_command_invalid_file() {
1222 let (_, temp_file_path) = create_file("test_check_invalid.mq", "def math(): 42; | unknown_var");
1223 let temp_file_path_clone = temp_file_path.clone();
1224
1225 defer! {
1226 if temp_file_path_clone.exists() {
1227 std::fs::remove_file(&temp_file_path_clone).expect("Failed to delete temp file");
1228 }
1229 }
1230
1231 let cli = Cli {
1232 input: InputArgs::default(),
1233 output: OutputArgs::default(),
1234 commands: Some(Commands::Check {
1235 files: vec![temp_file_path],
1236 }),
1237 query: None,
1238 files: None,
1239 ..Cli::default()
1240 };
1241
1242 assert!(cli.run().is_err());
1243 }
1244
1245 #[test]
1246 fn test_cli_check_command_file_not_found() {
1247 let cli = Cli {
1248 input: InputArgs::default(),
1249 output: OutputArgs::default(),
1250 commands: Some(Commands::Check {
1251 files: vec![PathBuf::from("nonexistent.mq")],
1252 }),
1253 query: None,
1254 files: None,
1255 ..Cli::default()
1256 };
1257
1258 assert!(cli.run().is_err());
1259 }
1260
1261 #[test]
1262 fn test_docs_command_no_modules() {
1263 let cli = Cli {
1264 input: InputArgs::default(),
1265 output: OutputArgs::default(),
1266 commands: Some(Commands::Docs {
1267 module_names: None,
1268 format: DocFormat::Markdown,
1269 }),
1270 query: None,
1271 files: None,
1272 ..Cli::default()
1273 };
1274
1275 assert!(cli.run().is_ok());
1276 }
1277
1278 #[test]
1279 fn test_docs_command_with_modules() {
1280 let cli = Cli {
1281 input: InputArgs::default(),
1282 output: OutputArgs::default(),
1283 commands: Some(Commands::Docs {
1284 module_names: Some(vec!["string".to_string()]),
1285 format: DocFormat::Markdown,
1286 }),
1287 query: None,
1288 files: None,
1289 ..Cli::default()
1290 };
1291
1292 assert!(cli.run().is_ok());
1293 }
1294
1295 #[test]
1296 fn test_input_format_mdx() {
1297 let (_, temp_file_path) = create_file("test_mdx.mdx", "# MDX test");
1298 let (_, output_file) = create_file("test_mdx_output.md", "");
1299 let temp_file_path_clone = temp_file_path.clone();
1300 let output_file_clone = output_file.clone();
1301
1302 defer! {
1303 if temp_file_path_clone.exists() {
1304 std::fs::remove_file(&temp_file_path_clone).ok();
1305 }
1306 if output_file_clone.exists() {
1307 std::fs::remove_file(&output_file_clone).ok();
1308 }
1309 }
1310
1311 let cli = Cli {
1312 input: InputArgs {
1313 input_format: Some(InputFormat::Mdx),
1314 ..Default::default()
1315 },
1316 output: OutputArgs {
1317 output_file: Some(output_file.clone()),
1318 ..Default::default()
1319 },
1320 commands: None,
1321 query: Some("self".to_string()),
1322 files: Some(vec![temp_file_path]),
1323 ..Cli::default()
1324 };
1325
1326 assert!(cli.run().is_ok());
1327 let output_content = fs::read_to_string(&output_file).expect("Failed to read output");
1328 assert!(output_content.contains("# MDX test"), "Output should contain heading");
1329 }
1330
1331 #[test]
1332 fn test_input_format_html() {
1333 let (_, temp_file_path) = create_file("test_html.html", "<h1>HTML test</h1>");
1334 let (_, output_file) = create_file("test_html_output.md", "");
1335 let temp_file_path_clone = temp_file_path.clone();
1336 let output_file_clone = output_file.clone();
1337
1338 defer! {
1339 if temp_file_path_clone.exists() {
1340 std::fs::remove_file(&temp_file_path_clone).ok();
1341 }
1342 if output_file_clone.exists() {
1343 std::fs::remove_file(&output_file_clone).ok();
1344 }
1345 }
1346
1347 let cli = Cli {
1348 input: InputArgs {
1349 input_format: Some(InputFormat::Html),
1350 ..Default::default()
1351 },
1352 output: OutputArgs {
1353 output_file: Some(output_file.clone()),
1354 ..Default::default()
1355 },
1356 commands: None,
1357 query: Some("self".to_string()),
1358 files: Some(vec![temp_file_path]),
1359 ..Cli::default()
1360 };
1361
1362 assert!(cli.run().is_ok());
1363 let output_content = fs::read_to_string(&output_file).expect("Failed to read output");
1364 assert!(
1365 output_content.contains("# HTML test"),
1366 "Output should contain converted heading"
1367 );
1368 }
1369
1370 #[test]
1371 fn test_output_format_json() {
1372 let (_, temp_file_path) = create_file("test_json.md", "# Test");
1373 let (_, output_file) = create_file("test_json_output.json", "");
1374 let temp_file_path_clone = temp_file_path.clone();
1375 let output_file_clone = output_file.clone();
1376
1377 defer! {
1378 if temp_file_path_clone.exists() {
1379 std::fs::remove_file(&temp_file_path_clone).ok();
1380 }
1381 if output_file_clone.exists() {
1382 std::fs::remove_file(&output_file_clone).ok();
1383 }
1384 }
1385
1386 let cli = Cli {
1387 input: InputArgs::default(),
1388 output: OutputArgs {
1389 output_format: OutputFormat::Json,
1390 output_file: Some(output_file.clone()),
1391 ..Default::default()
1392 },
1393 commands: None,
1394 query: Some("self".to_string()),
1395 files: Some(vec![temp_file_path]),
1396 ..Cli::default()
1397 };
1398
1399 assert!(cli.run().is_ok());
1400 let output_content = fs::read_to_string(&output_file).expect("Failed to read output");
1401 assert!(!output_content.is_empty(), "JSON output should not be empty");
1402 assert!(
1403 output_content.starts_with('{') || output_content.starts_with('['),
1404 "JSON output should be valid JSON"
1405 );
1406 }
1407
1408 #[test]
1409 fn test_output_format_none() {
1410 let (_, temp_file_path) = create_file("test_none.md", "# Test");
1411 let temp_file_path_clone = temp_file_path.clone();
1412
1413 defer! {
1414 if temp_file_path_clone.exists() {
1415 std::fs::remove_file(&temp_file_path_clone).ok();
1416 }
1417 }
1418
1419 let cli = Cli {
1420 input: InputArgs::default(),
1421 output: OutputArgs {
1422 output_format: OutputFormat::None,
1423 ..Default::default()
1424 },
1425 commands: None,
1426 query: Some("self".to_string()),
1427 files: Some(vec![temp_file_path]),
1428 ..Cli::default()
1429 };
1430
1431 assert!(cli.run().is_ok());
1432 }
1433
1434 #[test]
1435 fn test_link_title_styles() {
1436 let (_, temp_file_path) = create_file("test_link_title.md", "[link](url \"title\")");
1437 let temp_file_path_clone = temp_file_path.clone();
1438
1439 defer! {
1440 if temp_file_path_clone.exists() {
1441 std::fs::remove_file(&temp_file_path_clone).ok();
1442 }
1443 }
1444
1445 for (style, expected_char) in [
1446 (LinkTitleStyle::Double, '"'),
1447 (LinkTitleStyle::Single, '\''),
1448 (LinkTitleStyle::Paren, '('),
1449 ] {
1450 let (_, output_file) = create_file(&format!("test_link_title_{:?}.md", style), "");
1451 let output_file_clone = output_file.clone();
1452
1453 defer! {
1454 if output_file_clone.exists() {
1455 std::fs::remove_file(&output_file_clone).ok();
1456 }
1457 }
1458
1459 let cli = Cli {
1460 input: InputArgs::default(),
1461 output: OutputArgs {
1462 link_title_style: style.clone(),
1463 output_file: Some(output_file.clone()),
1464 ..Default::default()
1465 },
1466 commands: None,
1467 query: Some("self".to_string()),
1468 files: Some(vec![temp_file_path.clone()]),
1469 ..Cli::default()
1470 };
1471
1472 assert!(cli.run().is_ok());
1473 let output_content = fs::read_to_string(&output_file).expect("Failed to read output");
1474 if style == LinkTitleStyle::Paren {
1475 assert!(
1476 output_content.contains("(title)"),
1477 "Paren style should wrap title with parens"
1478 );
1479 } else {
1480 assert!(
1481 output_content.contains(expected_char),
1482 "Link title should use {:?} style",
1483 style
1484 );
1485 }
1486 }
1487 }
1488
1489 #[test]
1490 fn test_link_url_styles() {
1491 let (_, temp_file_path) = create_file("test_link_url.md", "[link](https://example.com)");
1492 let temp_file_path_clone = temp_file_path.clone();
1493
1494 defer! {
1495 if temp_file_path_clone.exists() {
1496 std::fs::remove_file(&temp_file_path_clone).ok();
1497 }
1498 }
1499
1500 for style in [LinkUrlStyle::None, LinkUrlStyle::Angle] {
1501 let (_, output_file) = create_file(&format!("test_link_url_{:?}.md", style), "");
1502 let output_file_clone = output_file.clone();
1503
1504 defer! {
1505 if output_file_clone.exists() {
1506 std::fs::remove_file(&output_file_clone).ok();
1507 }
1508 }
1509
1510 let cli = Cli {
1511 input: InputArgs::default(),
1512 output: OutputArgs {
1513 link_url_style: style.clone(),
1514 output_file: Some(output_file.clone()),
1515 ..Default::default()
1516 },
1517 commands: None,
1518 query: Some("self".to_string()),
1519 files: Some(vec![temp_file_path.clone()]),
1520 ..Cli::default()
1521 };
1522
1523 assert!(cli.run().is_ok());
1524 let output_content = fs::read_to_string(&output_file).expect("Failed to read output");
1525 if style == LinkUrlStyle::Angle {
1526 assert!(
1527 output_content.contains("<https://example.com>"),
1528 "Angle style should wrap URL with angle brackets"
1529 );
1530 } else {
1531 assert!(
1532 output_content.contains("(https://example.com)"),
1533 "None style should not wrap URL"
1534 );
1535 }
1536 }
1537 }
1538
1539 #[test]
1540 fn test_aggregate_flag() {
1541 let (_, temp_file1) = create_file("test_agg1.md", "# Test 1");
1542 let (_, temp_file2) = create_file("test_agg2.md", "# Test 2");
1543 let (_, output_file) = create_file("test_agg_output.md", "");
1544 let temp_file1_clone = temp_file1.clone();
1545 let temp_file2_clone = temp_file2.clone();
1546 let output_file_clone = output_file.clone();
1547
1548 defer! {
1549 if temp_file1_clone.exists() {
1550 std::fs::remove_file(&temp_file1_clone).ok();
1551 }
1552 if temp_file2_clone.exists() {
1553 std::fs::remove_file(&temp_file2_clone).ok();
1554 }
1555 if output_file_clone.exists() {
1556 std::fs::remove_file(&output_file_clone).ok();
1557 }
1558 }
1559
1560 let cli = Cli {
1561 input: InputArgs {
1562 aggregate: true,
1563 ..Default::default()
1564 },
1565 output: OutputArgs {
1566 output_file: Some(output_file.clone()),
1567 output_format: OutputFormat::Text,
1568 ..Default::default()
1569 },
1570 commands: None,
1571 query: Some("len()".to_string()),
1572 files: Some(vec![temp_file1, temp_file2]),
1573 ..Cli::default()
1574 };
1575
1576 assert!(cli.run().is_ok());
1577 let output_content = fs::read_to_string(&output_file).expect("Failed to read output");
1578 assert!(!output_content.is_empty(), "Aggregated output should not be empty");
1579 }
1580
1581 #[test]
1582 fn test_from_file_flag() {
1583 let (_, query_file) = create_file("test_query.mq", "self");
1584 let (_, input_file) = create_file("test_from_file.md", "# Test");
1585 let query_file_clone = query_file.clone();
1586 let input_file_clone = input_file.clone();
1587
1588 defer! {
1589 if query_file_clone.exists() {
1590 std::fs::remove_file(&query_file_clone).ok();
1591 }
1592 if input_file_clone.exists() {
1593 std::fs::remove_file(&input_file_clone).ok();
1594 }
1595 }
1596
1597 let cli = Cli {
1598 input: InputArgs {
1599 from_file: true,
1600 ..Default::default()
1601 },
1602 output: OutputArgs::default(),
1603 commands: None,
1604 query: Some(query_file.to_string_lossy().to_string()),
1605 files: Some(vec![input_file]),
1606 ..Cli::default()
1607 };
1608
1609 assert!(cli.run().is_ok());
1610 }
1611
1612 #[test]
1613 fn test_separator_flag() {
1614 let (_, temp_file1) = create_file("test_sep1.md", "# Test 1");
1615 let (_, temp_file2) = create_file("test_sep2.md", "# Test 2");
1616 let (_, output_file) = create_file("test_sep_output.md", "");
1617 let temp_file1_clone = temp_file1.clone();
1618 let temp_file2_clone = temp_file2.clone();
1619 let output_file_clone = output_file.clone();
1620
1621 defer! {
1622 if temp_file1_clone.exists() {
1623 std::fs::remove_file(&temp_file1_clone).ok();
1624 }
1625 if temp_file2_clone.exists() {
1626 std::fs::remove_file(&temp_file2_clone).ok();
1627 }
1628 if output_file_clone.exists() {
1629 std::fs::remove_file(&output_file_clone).ok();
1630 }
1631 }
1632
1633 let cli = Cli {
1634 input: InputArgs::default(),
1635 output: OutputArgs {
1636 separator: Some("\"---\"".to_string()),
1637 output_file: Some(output_file.clone()),
1638 ..Default::default()
1639 },
1640 commands: None,
1641 query: Some("self".to_string()),
1642 files: Some(vec![temp_file1, temp_file2]),
1643 ..Cli::default()
1644 };
1645
1646 assert!(cli.run().is_ok());
1647 let output_content = fs::read_to_string(&output_file).expect("Failed to read output");
1648 assert!(!output_content.is_empty(), "Output should not be empty");
1649 assert!(output_content.contains("# Test"), "File content should be present");
1650 }
1651
1652 #[test]
1653 fn test_output_file_flag() {
1654 let (_, temp_input) = create_file("test_input_out.md", "# Test Output");
1655 let temp_output = std::env::temp_dir().join("test_output_file.md");
1656 let temp_input_clone = temp_input.clone();
1657 let temp_output_clone = temp_output.clone();
1658
1659 defer! {
1660 if temp_input_clone.exists() {
1661 std::fs::remove_file(&temp_input_clone).ok();
1662 }
1663 if temp_output_clone.exists() {
1664 std::fs::remove_file(&temp_output_clone).ok();
1665 }
1666 }
1667
1668 let cli = Cli {
1669 input: InputArgs::default(),
1670 output: OutputArgs {
1671 output_file: Some(temp_output.clone()),
1672 ..Default::default()
1673 },
1674 commands: None,
1675 query: Some("self".to_string()),
1676 files: Some(vec![temp_input]),
1677 ..Cli::default()
1678 };
1679
1680 assert!(cli.run().is_ok());
1681 assert!(temp_output.exists(), "Output file should exist");
1682 let output_content = fs::read_to_string(&temp_output).expect("Failed to read output");
1683 assert!(
1684 output_content.contains("# Test Output"),
1685 "Output content should match input"
1686 );
1687 }
1688
1689 #[test]
1690 fn test_unbuffered_output() {
1691 let (_, temp_file) = create_file("test_unbuf.md", "# Test");
1692 let temp_file_clone = temp_file.clone();
1693
1694 defer! {
1695 if temp_file_clone.exists() {
1696 std::fs::remove_file(&temp_file_clone).ok();
1697 }
1698 }
1699
1700 let cli = Cli {
1701 input: InputArgs::default(),
1702 output: OutputArgs {
1703 unbuffered: true,
1704 ..Default::default()
1705 },
1706 commands: None,
1707 query: Some("self".to_string()),
1708 files: Some(vec![temp_file]),
1709 ..Cli::default()
1710 };
1711
1712 assert!(cli.run().is_ok());
1713 }
1714
1715 #[test]
1716 fn test_include_csv_module() {
1717 let (_, temp_file) = create_file("test_csv.csv", "a,b\n1,2\n3,4");
1718 let (_, output_file) = create_file("test_csv_output.txt", "");
1719 let temp_file_clone = temp_file.clone();
1720 let output_file_clone = output_file.clone();
1721
1722 defer! {
1723 if temp_file_clone.exists() {
1724 std::fs::remove_file(&temp_file_clone).ok();
1725 }
1726 if output_file_clone.exists() {
1727 std::fs::remove_file(&output_file_clone).ok();
1728 }
1729 }
1730
1731 let cli = Cli {
1732 input: InputArgs {
1733 include_csv: true,
1734 input_format: Some(InputFormat::Raw),
1735 ..Default::default()
1736 },
1737 output: OutputArgs {
1738 output_file: Some(output_file.clone()),
1739 output_format: OutputFormat::Text,
1740 ..Default::default()
1741 },
1742 commands: None,
1743 query: Some("csv_parse(true) | len()".to_string()),
1744 files: Some(vec![temp_file]),
1745 ..Cli::default()
1746 };
1747
1748 assert!(cli.run().is_ok());
1749 let output_content = fs::read_to_string(&output_file).expect("Failed to read output");
1750 assert!(!output_content.is_empty(), "CSV output should not be empty");
1751 }
1752
1753 #[test]
1754 fn test_include_json_module() {
1755 let (_, temp_file) = create_file("test_json_module.json", r#"{"key": "value", "num": 42}"#);
1756 let (_, output_file) = create_file("test_json_module_output.txt", "");
1757 let temp_file_clone = temp_file.clone();
1758 let output_file_clone = output_file.clone();
1759
1760 defer! {
1761 if temp_file_clone.exists() {
1762 std::fs::remove_file(&temp_file_clone).ok();
1763 }
1764 if output_file_clone.exists() {
1765 std::fs::remove_file(&output_file_clone).ok();
1766 }
1767 }
1768
1769 let cli = Cli {
1770 input: InputArgs {
1771 include_json: true,
1772 input_format: Some(InputFormat::Raw),
1773 ..Default::default()
1774 },
1775 output: OutputArgs {
1776 output_file: Some(output_file.clone()),
1777 output_format: OutputFormat::Text,
1778 ..Default::default()
1779 },
1780 commands: None,
1781 query: Some("json_parse()".to_string()),
1782 files: Some(vec![temp_file]),
1783 ..Cli::default()
1784 };
1785
1786 assert!(cli.run().is_ok());
1787 let output_content = fs::read_to_string(&output_file).expect("Failed to read output");
1788 assert!(!output_content.is_empty(), "JSON output should not be empty");
1789 }
1790
1791 #[test]
1792 fn test_include_yaml_module() {
1793 let (_, temp_file) = create_file("test_yaml.yaml", "key: value\nnum: 42");
1794 let (_, output_file) = create_file("test_yaml_output.txt", "");
1795 let temp_file_clone = temp_file.clone();
1796 let output_file_clone = output_file.clone();
1797
1798 defer! {
1799 if temp_file_clone.exists() {
1800 std::fs::remove_file(&temp_file_clone).ok();
1801 }
1802 if output_file_clone.exists() {
1803 std::fs::remove_file(&output_file_clone).ok();
1804 }
1805 }
1806
1807 let cli = Cli {
1808 input: InputArgs {
1809 include_yaml: true,
1810 input_format: Some(InputFormat::Raw),
1811 ..Default::default()
1812 },
1813 output: OutputArgs {
1814 output_file: Some(output_file.clone()),
1815 output_format: OutputFormat::Text,
1816 ..Default::default()
1817 },
1818 commands: None,
1819 query: Some("yaml_parse()".to_string()),
1820 files: Some(vec![temp_file]),
1821 ..Cli::default()
1822 };
1823
1824 assert!(cli.run().is_ok());
1825 let output_content = fs::read_to_string(&output_file).expect("Failed to read output");
1826 assert!(!output_content.is_empty(), "YAML output should not be empty");
1827 }
1828
1829 #[test]
1830 fn test_include_toml_module() {
1831 let (_, temp_file) = create_file("test_toml.toml", "key = \"value\"\nnum = 42");
1832 let (_, output_file) = create_file("test_toml_output.txt", "");
1833 let temp_file_clone = temp_file.clone();
1834 let output_file_clone = output_file.clone();
1835
1836 defer! {
1837 if temp_file_clone.exists() {
1838 std::fs::remove_file(&temp_file_clone).ok();
1839 }
1840 if output_file_clone.exists() {
1841 std::fs::remove_file(&output_file_clone).ok();
1842 }
1843 }
1844
1845 let cli = Cli {
1846 input: InputArgs {
1847 include_toml: true,
1848 input_format: Some(InputFormat::Raw),
1849 ..Default::default()
1850 },
1851 output: OutputArgs {
1852 output_file: Some(output_file.clone()),
1853 output_format: OutputFormat::Text,
1854 ..Default::default()
1855 },
1856 commands: None,
1857 query: Some("toml_parse()".to_string()),
1858 files: Some(vec![temp_file]),
1859 ..Cli::default()
1860 };
1861
1862 assert!(cli.run().is_ok());
1863 let output_content = fs::read_to_string(&output_file).expect("Failed to read output");
1864 assert!(!output_content.is_empty(), "TOML output should not be empty");
1865 }
1866
1867 #[test]
1868 fn test_include_xml_module() {
1869 let (_, temp_file) = create_file("test_xml.xml", "<root><key>value</key><num>42</num></root>");
1870 let (_, output_file) = create_file("test_xml_output.txt", "");
1871 let temp_file_clone = temp_file.clone();
1872 let output_file_clone = output_file.clone();
1873
1874 defer! {
1875 if temp_file_clone.exists() {
1876 std::fs::remove_file(&temp_file_clone).ok();
1877 }
1878 if output_file_clone.exists() {
1879 std::fs::remove_file(&output_file_clone).ok();
1880 }
1881 }
1882
1883 let cli = Cli {
1884 input: InputArgs {
1885 include_xml: true,
1886 input_format: Some(InputFormat::Raw),
1887 ..Default::default()
1888 },
1889 output: OutputArgs {
1890 output_file: Some(output_file.clone()),
1891 output_format: OutputFormat::Text,
1892 ..Default::default()
1893 },
1894 commands: None,
1895 query: Some("xml_parse()".to_string()),
1896 files: Some(vec![temp_file]),
1897 ..Cli::default()
1898 };
1899
1900 assert!(cli.run().is_ok());
1901 let output_content = fs::read_to_string(&output_file).expect("Failed to read output");
1902 assert!(!output_content.is_empty(), "XML output should not be empty");
1903 }
1904
1905 #[test]
1906 fn test_fmt_file_not_found() {
1907 let cli = Cli {
1908 input: InputArgs::default(),
1909 output: OutputArgs::default(),
1910 commands: Some(Commands::Fmt {
1911 indent_width: 2,
1912 check: false,
1913 files: Some(vec![PathBuf::from("nonexistent.mq")]),
1914 sort_functions: false,
1915 sort_fields: false,
1916 sort_imports: false,
1917 }),
1918 query: None,
1919 files: None,
1920 ..Cli::default()
1921 };
1922
1923 assert!(cli.run().is_err());
1924 }
1925
1926 #[test]
1927 fn test_fmt_check_unformatted_file() {
1928 let (_, temp_file) = create_file("test_unformatted.mq", "def math(): 42;");
1929 let temp_file_clone = temp_file.clone();
1930
1931 defer! {
1932 if temp_file_clone.exists() {
1933 std::fs::remove_file(&temp_file_clone).ok();
1934 }
1935 }
1936
1937 let cli = Cli {
1938 input: InputArgs::default(),
1939 output: OutputArgs::default(),
1940 commands: Some(Commands::Fmt {
1941 indent_width: 2,
1942 check: true,
1943 files: Some(vec![temp_file]),
1944 sort_functions: false,
1945 sort_fields: false,
1946 sort_imports: false,
1947 }),
1948 query: None,
1949 files: None,
1950 ..Cli::default()
1951 };
1952
1953 assert!(cli.run().is_err());
1954 }
1955
1956 #[test]
1957 fn test_update_with_non_markdown_input() {
1958 let cli = Cli {
1959 input: InputArgs {
1960 input_format: Some(InputFormat::Html),
1961 ..Default::default()
1962 },
1963 output: OutputArgs {
1964 update: true,
1965 ..Default::default()
1966 },
1967 commands: None,
1968 query: Some("self".to_string()),
1969 files: None,
1970 ..Cli::default()
1971 };
1972
1973 assert!(cli.run().is_err());
1974 }
1975
1976 #[test]
1977 fn test_list_commands() {
1978 let cli = Cli {
1979 list: true,
1980 ..Cli::default()
1981 };
1982
1983 assert!(cli.run().is_ok());
1984 }
1985
1986 #[test]
1987 fn test_parallel_threshold() {
1988 let files: Vec<PathBuf> = (0..15)
1989 .map(|i| {
1990 let (_, path) = create_file(&format!("test_parallel_{}.md", i), "# Test");
1991 path
1992 })
1993 .collect();
1994
1995 let files_clone = files.clone();
1996 defer! {
1997 for file in &files_clone {
1998 if file.exists() {
1999 std::fs::remove_file(file).ok();
2000 }
2001 }
2002 }
2003
2004 let cli = Cli {
2005 input: InputArgs::default(),
2006 output: OutputArgs::default(),
2007 commands: None,
2008 query: Some("self".to_string()),
2009 files: Some(files),
2010 parallel_threshold: 10,
2011 ..Cli::default()
2012 };
2013
2014 assert!(cli.run().is_ok());
2015 }
2016}