1use std::fs::{self, File};
2use std::io::{self, BufWriter, Write};
3use std::path::{Path, PathBuf};
4
5use anyhow::{Context, Result};
6use chrono::Local;
7use clap::ValueEnum;
8use git2::Repository;
9use ignore::gitignore::Gitignore;
10use tracing::{debug, error, info, warn};
11use walkdir::{DirEntry, WalkDir};
12
13#[derive(Debug, Clone, ValueEnum, Default)]
19pub enum Format {
20 Markdown,
21 Text,
22 #[default]
23 Console,
24}
25
26#[derive(Debug)]
28pub struct Config {
29 pub directory: Vec<PathBuf>,
31 pub output: Option<PathBuf>,
33 pub include: Vec<String>,
35 pub exclude: Vec<String>,
37 pub format: Format,
39 pub append_date: bool,
41 pub append_git_hash: bool,
43 pub line_numbers: bool,
45 pub ignore_hidden: bool,
47 pub respect_gitignore: bool,
49 pub relative_path: bool,
51}
52
53pub fn run(config: Config) -> Result<()> {
61 let mut output_path = config.output.clone();
62
63 if config.append_date || config.append_git_hash {
64 append_date_and_git_hash(&mut output_path, &config)?;
65 }
66
67 let mut writer = determine_output_writer(&output_path)?;
68
69 for dir in &config.directory {
70 if !dir.is_dir() {
71 return Err(anyhow::anyhow!(
72 "The specified path is not a directory: {}",
73 dir.display()
74 ));
75 }
76
77 process_directory(&config, &mut writer, dir)?;
78 }
79
80 Ok(())
81}
82
83fn append_date_and_git_hash(output_path: &mut Option<PathBuf>, config: &Config) -> Result<()> {
92 if let Some(path) = output_path {
93 let mut new_filename = path
94 .file_stem()
95 .and_then(|s| s.to_str())
96 .unwrap_or_default()
97 .to_string();
98
99 if config.append_date {
100 new_filename.push('_');
101 new_filename.push_str(&Local::now().format("%Y%m%d").to_string());
102 info!("Appending date to filename.");
103 }
104
105 if config.append_git_hash {
106 for dir in &config.directory {
107 match Repository::open(&dir) {
108 Ok(repo) => {
109 let head = repo.head().context("Failed to get repository HEAD")?;
110 if let Some(oid) = head.target() {
111 new_filename.push('_');
112 new_filename.push_str(&oid.to_string()[..7]);
113 info!("Appending git hash to filename.");
114 }
115 }
116 Err(_) => warn!("Not a git repository, cannot append git hash."),
117 }
118 }
119 }
120
121 if let Some(ext) = path.extension().and_then(|s| s.to_str()) {
122 new_filename.push('.');
123 new_filename.push_str(ext);
124 }
125 path.set_file_name(new_filename);
126 }
127 Ok(())
128}
129
130fn determine_output_writer(output_path: &Option<PathBuf>) -> Result<Box<dyn Write>> {
138 if let Some(path) = output_path {
139 info!("Output will be written to: {}", path.display());
140 let file = File::create(path)
141 .with_context(|| format!("Failed to create output file: {}", path.display()))?;
142 Ok(Box::new(BufWriter::new(file)))
143 } else {
144 info!("Output will be written to stdout.");
145 Ok(Box::new(BufWriter::new(io::stdout())))
146 }
147}
148
149fn process_directory(config: &Config, writer: &mut dyn Write, dir: &PathBuf) -> Result<()> {
158 let (gitignore, _) = Gitignore::new(dir.join(".gitignore"));
159
160 let walker = WalkDir::new(dir)
161 .into_iter()
162 .filter_entry(|e| should_include_entry(e, &gitignore, config));
163
164 for result in walker {
165 let entry = match result {
166 Ok(entry) => {
167 debug!("Processing entry: {:?}", entry);
168 entry
169 }
170 Err(err) => {
171 error!("Failed to access entry: {}", err);
172 continue;
173 }
174 };
175
176 if let Err(err) = process_file_entry(&entry, writer, config, dir) {
177 error!("{}", err);
178 }
179 }
180
181 info!("File bundling complete.");
182 Ok(())
183}
184
185fn should_include_entry(entry: &DirEntry, gitignore: &Gitignore, config: &Config) -> bool {
195 !is_hidden(entry, config) && !is_ignored(entry, gitignore, config)
196}
197
198fn process_file_entry(
208 entry: &DirEntry,
209 writer: &mut dyn Write,
210 config: &Config,
211 dir: &PathBuf,
212) -> Result<()> {
213 debug!("File process entry process for {:?}", entry.path());
214
215 let path = entry.path();
216 if !path.is_file() {
217 debug!("{:?} is not a file.", path);
218 return Ok(());
219 }
220
221 let extension = path.extension().and_then(|s| s.to_str()).unwrap_or("");
222
223 debug!("Extension {:?} for {:?}", extension, path);
224
225 let apply_include_filter = !(config.include.is_empty());
226
227 if apply_include_filter && !config.include.contains(&extension.to_string()) {
228 return Ok(());
229 }
230
231 let apply_exclude_filter = !(config.exclude.is_empty());
232
233 if apply_exclude_filter && config.exclude.contains(&extension.to_string()) {
234 return Ok(());
235 }
236
237 let relative_path = path.strip_prefix(dir).unwrap_or(path);
238 let content = match fs::read_to_string(path) {
239 Ok(content) => content,
240 Err(_) => {
241 warn!("Skipping non-UTF-8 file: {}", path.display());
242 return Ok(()); }
244 };
245
246 match &config.relative_path {
247 true => write_file_content(writer, relative_path, &content, extension, config)
248 .with_context(|| {
249 format!(
250 "Failed to write file content for {}",
251 relative_path.display()
252 )
253 }),
254 false => write_file_content(writer, path, &content, extension, config)
255 .with_context(|| format!("Failed to write file content for {}", path.display())),
256 }
257}
258
259fn write_file_content(
271 writer: &mut dyn Write,
272 path: &Path,
273 content: &str,
274 extension: &str,
275 config: &Config,
276) -> Result<()> {
277 match config.format {
278 Format::Markdown => {
279 writeln!(writer, "### `{}`\n", path.display())?;
280 writeln!(writer, "```{}", extension)?;
281 write_content_lines(writer, content, config.line_numbers)?;
282 writeln!(writer, "```\n")?;
283 }
284 Format::Text | Format::Console => {
285 writeln!(writer, "./{}\n---", path.display())?;
287 write_content_lines(writer, content, config.line_numbers)?;
288 writeln!(writer, "---")?;
289 }
290 }
291 Ok(())
292}
293
294fn write_content_lines(writer: &mut dyn Write, content: &str, line_numbers: bool) -> Result<()> {
304 if line_numbers {
305 for (i, line) in content.lines().enumerate() {
306 writeln!(writer, "{:4} | {}", i + 1, line)?;
307 }
308 } else {
309 writeln!(writer, "{}", content)?;
310 }
311 Ok(())
312}
313
314fn is_hidden(entry: &DirEntry, config: &Config) -> bool {
323 config.ignore_hidden
324 && entry
325 .file_name()
326 .to_str()
327 .map(|s| s.starts_with('.'))
328 .unwrap_or(false)
329}
330
331fn is_ignored(entry: &DirEntry, gitignore: &Gitignore, config: &Config) -> bool {
341 if !config.respect_gitignore {
342 return false;
343 }
344 gitignore
345 .matched(entry.path(), entry.file_type().is_dir())
346 .is_ignore()
347}