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