Skip to main content

mq_run/
cli.rs

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    /// List all available subcommands (built-in and external)
45    #[arg(long)]
46    list: bool,
47
48    /// Number of files to process before switching to parallel processing
49    #[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/// Represents the input format for processing.
58/// - Markdown: Standard Markdown parsing.
59/// - Mdx: MDX parsing.
60/// - Html: HTML parsing.
61/// - Text: Treats input as plain text.
62/// - Null: No input.
63/// - Raw: Treats all input as a single string, without parsing.
64#[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    /// Aggregate all input files/content into a single array
118    #[arg(short = 'A', long, default_value_t = false)]
119    aggregate: bool,
120
121    /// load filter from the file
122    #[arg(short, long, default_value_t = false)]
123    from_file: bool,
124
125    /// Set input format
126    #[arg(short = 'I', long, value_enum)]
127    input_format: Option<InputFormat>,
128
129    /// Search modules from the directory
130    #[arg(short = 'L', long = "directory")]
131    module_directories: Option<Vec<PathBuf>>,
132
133    /// Load additional modules from specified files
134    #[arg(short = 'M', long)]
135    module_names: Option<Vec<String>>,
136
137    /// Sets string that can be referenced at runtime
138    #[arg(long, value_names = ["NAME", "VALUE"])]
139    args: Option<Vec<String>>,
140
141    /// Sets file contents that can be referenced at runtime
142    #[arg(long="rawfile", value_names = ["NAME", "FILE"])]
143    raw_file: Option<Vec<String>>,
144
145    /// Enable streaming mode for processing large files line by line
146    #[arg(long, default_value_t = false)]
147    stream: bool,
148
149    #[arg(long = "json", default_value_t = false)]
150    include_json: bool,
151
152    /// Include the built-in CSV module
153    #[arg(long = "csv", default_value_t = false)]
154    include_csv: bool,
155
156    /// Include the built-in Fuzzy module
157    #[arg(long = "fuzzy", default_value_t = false)]
158    include_fuzzy: bool,
159
160    /// Include the built-in YAML module
161    #[arg(long = "yaml", default_value_t = false)]
162    include_yaml: bool,
163
164    /// Include the built-in TOML module
165    #[arg(long = "toml", default_value_t = false)]
166    include_toml: bool,
167
168    /// Include the built-in XML module
169    #[arg(long = "xml", default_value_t = false)]
170    include_xml: bool,
171
172    /// Include the built-in test module
173    #[arg(long = "test", default_value_t = false)]
174    include_test: bool,
175}
176
177#[derive(Clone, Debug, clap::Args, Default)]
178struct OutputArgs {
179    /// Set output format
180    #[arg(short = 'F', long, value_enum, default_value_t)]
181    output_format: OutputFormat,
182
183    /// Update the input markdown (aliases: -i, --in-place, --inplace)
184    #[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    /// Unbuffered output
194    #[clap(long, default_value_t = false)]
195    unbuffered: bool,
196
197    /// Set the list style for markdown output
198    #[clap(long, value_enum, default_value_t = ListStyle::Dash)]
199    list_style: ListStyle,
200
201    /// Set the link title surround style for markdown output
202    #[clap(long, value_enum, default_value_t = LinkTitleStyle::Double)]
203    link_title_style: LinkTitleStyle,
204
205    /// Set the link URL surround style for markdown links
206    #[clap(long, value_enum, default_value_t = LinkUrlStyle::None)]
207    link_url_style: LinkUrlStyle,
208
209    /// Specify a query to insert between files as a separator
210    #[clap(short = 'S', long, value_name = "QUERY")]
211    separator: Option<String>,
212
213    /// Output to the specified file
214    #[clap(short = 'o', long = "output", value_name = "FILE")]
215    output_file: Option<PathBuf>,
216}
217
218#[derive(Debug, Subcommand)]
219enum Commands {
220    /// Start a REPL session for interactive query execution
221    Repl,
222    /// Format mq files based on specified formatting options.
223    Fmt {
224        /// Number of spaces for indentation
225        #[arg(short, long, default_value_t = 2)]
226        indent_width: usize,
227        /// Check if files are formatted without modifying them
228        #[arg(short, long)]
229        check: bool,
230        /// Sort imports
231        #[arg(long, default_value_t = false)]
232        sort_imports: bool,
233        /// Sort functions
234        #[arg(long, default_value_t = false)]
235        sort_functions: bool,
236        /// Sort fields
237        #[arg(long, default_value_t = false)]
238        sort_fields: bool,
239        /// Path to the mq file to format
240        files: Option<Vec<PathBuf>>,
241    },
242    /// Show functions documentation for the query
243    Docs {
244        /// Specify additional module names to load for documentation
245        #[arg(short = 'M', long)]
246        module_names: Option<Vec<String>>,
247        /// Specify the documentation output format
248        #[arg(short = 'F', long, value_enum, default_value_t)]
249        format: DocFormat,
250    },
251    /// Check syntax errors in mq files
252    Check {
253        /// Path to the mq file to check
254        files: Vec<PathBuf>,
255    },
256    /// Start a debug adapter for mq
257    #[cfg(feature = "debugger")]
258    Dap,
259}
260
261impl Cli {
262    /// Get the path to the external commands directory (~/.mq/bin)
263    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    /// Find all external commands (mq-* files in ~/.mq/bin)
274    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                    // Remove "mq-" prefix to get the subcommand name
285                    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    /// Execute an external subcommand
296    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        // Check if the file is executable
316        #[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        // Execute the external command with remaining arguments
331        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    /// List all available subcommands (built-in and external)
349    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        // Check if query is actually an external subcommand
386        // This handles the case where clap parses "mq test arg1" as query="test", files=["arg1"]
387        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            // Only treat as external command if query_value is a valid file name
393            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 files are specified, process each file line by line
700        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            // Otherwise, process stdin line by line
711            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        // This test will only pass if ~/.mq/bin exists and contains mq-* files
1146        let commands = Cli::find_external_commands();
1147        // We can't assert specific commands, but we can check the function works
1148        assert!(commands.iter().all(|cmd| !cmd.is_empty()));
1149    }
1150
1151    #[test]
1152    fn test_get_external_commands_dir() {
1153        // This test checks if the function returns a valid path or None
1154        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        // Create a temporary directory for testing
1163        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        // Create a test external command
1174        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        // Note: We can't easily test execute_external_command without modifying HOME
1191        // This test just verifies the command file was created correctly
1192        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}