codebase_to_prompt/
lib.rs1use 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::{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: 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}
50
51pub fn run(config: Config) -> Result<()> {
59 let mut output_path = config.output.clone();
60
61 if config.append_date || config.append_git_hash {
62 append_date_and_git_hash(&mut output_path, &config)?;
63 }
64
65 let writer = determine_output_writer(&output_path)?;
66
67 process_directory(&config, writer)
68}
69
70fn append_date_and_git_hash(output_path: &mut Option<PathBuf>, config: &Config) -> Result<()> {
79 if let Some(path) = output_path {
80 let mut new_filename = path
81 .file_stem()
82 .and_then(|s| s.to_str())
83 .unwrap_or_default()
84 .to_string();
85
86 if config.append_date {
87 new_filename.push('_');
88 new_filename.push_str(&Local::now().format("%Y%m%d").to_string());
89 info!("Appending date to filename.");
90 }
91
92 if config.append_git_hash {
93 match Repository::open(&config.directory) {
94 Ok(repo) => {
95 let head = repo.head().context("Failed to get repository HEAD")?;
96 if let Some(oid) = head.target() {
97 new_filename.push('_');
98 new_filename.push_str(&oid.to_string()[..7]);
99 info!("Appending git hash to filename.");
100 }
101 }
102 Err(_) => warn!("Not a git repository, cannot append git hash."),
103 }
104 }
105
106 if let Some(ext) = path.extension().and_then(|s| s.to_str()) {
107 new_filename.push('.');
108 new_filename.push_str(ext);
109 }
110 path.set_file_name(new_filename);
111 }
112 Ok(())
113}
114
115fn determine_output_writer(output_path: &Option<PathBuf>) -> Result<Box<dyn Write>> {
123 if let Some(path) = output_path {
124 info!("Output will be written to: {}", path.display());
125 let file = File::create(path)
126 .with_context(|| format!("Failed to create output file: {}", path.display()))?;
127 Ok(Box::new(BufWriter::new(file)))
128 } else {
129 info!("Output will be written to stdout.");
130 Ok(Box::new(BufWriter::new(io::stdout())))
131 }
132}
133
134fn process_directory(config: &Config, mut writer: Box<dyn Write>) -> Result<()> {
143 let (gitignore, _) = Gitignore::new(config.directory.join(".gitignore"));
144
145 let walker = WalkDir::new(&config.directory)
146 .into_iter()
147 .filter_entry(|e| should_include_entry(e, &gitignore, config));
148
149 for result in walker {
150 let entry = match result {
151 Ok(entry) => entry,
152 Err(err) => {
153 error!("Failed to access entry: {}", err);
154 continue;
155 }
156 };
157
158 if let Err(err) = process_file_entry(&entry, &mut writer, config) {
159 error!("{}", err);
160 }
161 }
162
163 info!("File bundling complete.");
164 Ok(())
165}
166
167fn should_include_entry(entry: &DirEntry, gitignore: &Gitignore, config: &Config) -> bool {
177 !is_hidden(entry, config) && !is_ignored(entry, gitignore, config)
178}
179
180fn process_file_entry(entry: &DirEntry, writer: &mut dyn Write, config: &Config) -> Result<()> {
190 let path = entry.path();
191 if !path.is_file() {
192 return Ok(());
193 }
194
195 let extension = path.extension().and_then(|s| s.to_str()).unwrap_or("");
196
197 let apply_include_filter =
198 !(config.include.is_empty() || config.include.len() == 1 && config.include[0].is_empty());
199
200 if apply_include_filter && !config.include.contains(&extension.to_string()) {
201 return Ok(());
202 }
203
204 if config.exclude.contains(&extension.to_string()) {
205 return Ok(());
206 }
207
208 let relative_path = path.strip_prefix(&config.directory).unwrap_or(path);
209 let content = match fs::read_to_string(path) {
210 Ok(content) => content,
211 Err(_) => {
212 warn!("Skipping non-UTF-8 file: {}", path.display());
213 return Ok(()); }
215 };
216
217 write_file_content(writer, relative_path, &content, extension, config)
218 .with_context(|| format!("Failed to write file content for {}", path.display()))
219}
220
221fn write_file_content(
233 writer: &mut dyn Write,
234 path: &Path,
235 content: &str,
236 extension: &str,
237 config: &Config,
238) -> Result<()> {
239 match config.format {
240 Format::Markdown => {
241 writeln!(writer, "### `{}`\n", path.display())?;
242 writeln!(writer, "```{}", extension)?;
243 write_content_lines(writer, content, config.line_numbers)?;
244 writeln!(writer, "```\n")?;
245 }
246 Format::Text | Format::Console => {
247 writeln!(writer, "./{}\n---", path.display())?;
249 write_content_lines(writer, content, config.line_numbers)?;
250 writeln!(writer, "---")?;
251 }
252 }
253 Ok(())
254}
255
256fn write_content_lines(writer: &mut dyn Write, content: &str, line_numbers: bool) -> Result<()> {
266 if line_numbers {
267 for (i, line) in content.lines().enumerate() {
268 writeln!(writer, "{:4} | {}", i + 1, line)?;
269 }
270 } else {
271 writeln!(writer, "{}", content)?;
272 }
273 Ok(())
274}
275
276fn is_hidden(entry: &DirEntry, config: &Config) -> bool {
285 config.ignore_hidden
286 && entry
287 .file_name()
288 .to_str()
289 .map(|s| s.starts_with('.'))
290 .unwrap_or(false)
291}
292
293fn is_ignored(entry: &DirEntry, gitignore: &Gitignore, config: &Config) -> bool {
303 if !config.respect_gitignore {
304 return false;
305 }
306 gitignore
307 .matched(entry.path(), entry.file_type().is_dir())
308 .is_ignore()
309}