Skip to main content

mq_run/
cli.rs

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