1use clap::{Parser, ValueEnum};
2use glob::glob;
3use std::path::PathBuf;
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, ValueEnum)]
7pub enum WrapMode {
8 Always,
10 Never,
12 #[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
27const 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 #[arg(value_name = "PATH")]
37 pub paths: Vec<String>,
38
39 #[arg(short, long)]
41 pub write: bool,
42
43 #[arg(long)]
45 pub check: bool,
46
47 #[arg(long)]
49 pub stdin: bool,
50
51 #[arg(long, default_value = "80")]
53 pub width: usize,
54
55 #[arg(long, value_enum, default_value = "preserve")]
57 pub wrap: WrapMode,
58
59 #[arg(long = "exclude", value_name = "DIR")]
61 pub excludes: Vec<String>,
62
63 #[arg(long)]
65 pub no_default_excludes: bool,
66}
67
68impl Args {
69 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 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 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 let glob_pattern = format!("{}/**/*.md", pattern);
112 self.collect_markdown_files(&glob_pattern, &mut sources, &excludes)?;
113 } else if path.is_file() {
114 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 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}