md_formatter/
cli.rs

1use clap::{Parser, ValueEnum};
2use glob::glob;
3use std::path::PathBuf;
4
5/// How to handle prose wrapping
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, ValueEnum)]
7pub enum WrapMode {
8    /// Wrap prose if it exceeds the print width
9    Always,
10    /// Un-wrap each block of prose into one line
11    Never,
12    /// Do nothing, leave prose as-is (default)
13    #[default]
14    Preserve,
15}
16
17impl From<WrapMode> for crate::formatter::WrapMode {
18    fn from(mode: WrapMode) -> Self {
19        match mode {
20            WrapMode::Always => Self::Always,
21            WrapMode::Never => Self::Never,
22            WrapMode::Preserve => Self::Preserve,
23        }
24    }
25}
26
27/// How to handle ordered list numbering
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, ValueEnum)]
29pub enum OrderedListMode {
30    /// Renumber items sequentially (1, 2, 3, ...) - default
31    #[default]
32    Ascending,
33    /// Use 1. for all items
34    One,
35}
36
37impl From<OrderedListMode> for crate::formatter::OrderedListMode {
38    fn from(mode: OrderedListMode) -> Self {
39        match mode {
40            OrderedListMode::Ascending => Self::Ascending,
41            OrderedListMode::One => Self::One,
42        }
43    }
44}
45
46/// Default directories to exclude when searching
47const DEFAULT_EXCLUDES: &[&str] = &["node_modules", "target", ".git", "vendor", "dist", "build"];
48
49#[derive(Parser, Debug)]
50#[command(name = "mdfmt")]
51#[command(version = env!("CARGO_PKG_VERSION"))]
52#[command(about = "Fast, opinionated Markdown formatter", long_about = None)]
53pub struct Args {
54    /// Files or directories to format (supports glob patterns, use - for stdin)
55    #[arg(value_name = "PATH")]
56    pub paths: Vec<String>,
57
58    /// Write formatted output to file in-place
59    #[arg(short, long)]
60    pub write: bool,
61
62    /// Check if files are formatted (exit with 1 if not)
63    #[arg(long)]
64    pub check: bool,
65
66    /// Read from stdin
67    #[arg(long)]
68    pub stdin: bool,
69
70    /// Line width for wrapping (default: 80)
71    #[arg(long, default_value = "80")]
72    pub width: usize,
73
74    /// How to wrap prose: always (reflow to width), never (one line per paragraph), preserve (keep as-is)
75    #[arg(long, value_enum, default_value = "preserve")]
76    pub wrap: WrapMode,
77
78    /// How to number ordered lists: ascending (1, 2, 3), one (all 1.)
79    #[arg(long = "ordered-list", value_enum, default_value = "ascending")]
80    pub ordered_list: OrderedListMode,
81
82    /// Additional directories to exclude (node_modules, target, .git, vendor, dist, build are excluded by default)
83    #[arg(long = "exclude", value_name = "DIR")]
84    pub excludes: Vec<String>,
85
86    /// Don't exclude any directories by default
87    #[arg(long)]
88    pub no_default_excludes: bool,
89}
90
91impl Args {
92    /// Get the list of directories to exclude
93    fn get_excludes(&self) -> Vec<String> {
94        let mut excludes: Vec<String> = if self.no_default_excludes {
95            Vec::new()
96        } else {
97            DEFAULT_EXCLUDES.iter().map(|s| s.to_string()).collect()
98        };
99        excludes.extend(self.excludes.clone());
100        excludes
101    }
102
103    /// Check if a path should be excluded
104    fn should_exclude(&self, path: &std::path::Path, excludes: &[String]) -> bool {
105        for component in path.components() {
106            if let std::path::Component::Normal(name) = component {
107                let name_str = name.to_string_lossy();
108                if excludes.iter().any(|e| e == name_str.as_ref()) {
109                    return true;
110                }
111            }
112        }
113        false
114    }
115
116    /// Resolve input paths to a list of markdown files or stdin
117    pub fn get_input_sources(&self) -> Result<Vec<InputSource>, String> {
118        if self.stdin || (self.paths.len() == 1 && self.paths[0] == "-") {
119            return Ok(vec![InputSource::Stdin]);
120        }
121
122        if self.paths.is_empty() {
123            return Err("No input provided. Use --stdin or specify file paths.".to_string());
124        }
125
126        let excludes = self.get_excludes();
127        let mut sources = Vec::new();
128
129        for pattern in &self.paths {
130            let path = PathBuf::from(pattern);
131
132            if path.is_dir() {
133                // If it's a directory, find all .md files recursively
134                let glob_pattern = format!("{}/**/*.md", pattern);
135                self.collect_markdown_files(&glob_pattern, &mut sources, &excludes)?;
136            } else if path.is_file() {
137                // Single file - must be .md
138                if Self::is_markdown_file(&path) {
139                    sources.push(InputSource::File(path));
140                } else {
141                    return Err(format!(
142                        "File '{}' is not a markdown file (.md)",
143                        path.display()
144                    ));
145                }
146            } else {
147                // Treat as glob pattern
148                self.collect_markdown_files(pattern, &mut sources, &excludes)?;
149            }
150        }
151
152        if sources.is_empty() {
153            return Err("No markdown files found.".to_string());
154        }
155
156        Ok(sources)
157    }
158
159    fn collect_markdown_files(
160        &self,
161        pattern: &str,
162        sources: &mut Vec<InputSource>,
163        excludes: &[String],
164    ) -> Result<(), String> {
165        let entries =
166            glob(pattern).map_err(|e| format!("Invalid glob pattern '{}': {}", pattern, e))?;
167
168        for entry in entries {
169            match entry {
170                Ok(path) => {
171                    if path.is_file()
172                        && Self::is_markdown_file(&path)
173                        && !self.should_exclude(&path, excludes)
174                    {
175                        sources.push(InputSource::File(path));
176                    }
177                }
178                Err(e) => {
179                    eprintln!("Warning: Could not read path: {}", e);
180                }
181            }
182        }
183
184        Ok(())
185    }
186
187    fn is_markdown_file(path: &std::path::Path) -> bool {
188        path.extension()
189            .map(|ext| ext.to_string_lossy().to_lowercase() == "md")
190            .unwrap_or(false)
191    }
192}
193
194#[derive(Debug)]
195pub enum InputSource {
196    File(PathBuf),
197    Stdin,
198}