Skip to main content

codebase_to_prompt/
lib.rs

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/// Represents the output format for the bundled files.
14///
15/// - `Markdown`: Outputs files in Markdown format with code blocks.
16/// - `Text`: Outputs files as plain text.
17/// - `Console`: Outputs files formatted for console display (default).
18#[derive(Debug, Clone, ValueEnum, Default)]
19pub enum Format {
20    Markdown,
21    Text,
22    #[default]
23    Console,
24}
25
26/// Configuration options for the file bundling process.
27#[derive(Debug)]
28pub struct Config {
29    /// The directories to process.
30    pub directory: Vec<PathBuf>,
31    /// The optional output file path. If not provided, output is written to stdout.
32    pub output: Option<PathBuf>,
33    /// File extensions to include in the output.
34    pub include: Vec<String>,
35    /// File extensions to exclude from the output.
36    pub exclude: Vec<String>,
37    /// The format of the output (Markdown, Text, or Console).
38    pub format: Format,
39    /// Whether to append the current date to the output file name.
40    pub append_date: bool,
41    /// Whether to append the current Git hash to the output file name.
42    pub append_git_hash: bool,
43    /// Whether to include line numbers in the output.
44    pub line_numbers: bool,
45    /// Whether to ignore hidden files and directories.
46    pub ignore_hidden: bool,
47    /// Whether to respect `.gitignore` rules.
48    pub respect_gitignore: bool,
49    /// Whether to use relative paths in the output.
50    pub relative_path: bool,
51}
52
53/// Runs the file bundling process based on the provided configuration.
54///
55/// # Arguments
56/// * `config` - The configuration options for the bundling process.
57///
58/// # Returns
59/// * `Result<()>` - Returns `Ok(())` if successful, or an error if the process fails.
60pub 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
83/// Appends the current date and/or Git hash to the output file name if required.
84///
85/// # Arguments
86/// * `output_path` - The optional output file path to modify.
87/// * `config` - The configuration options for the bundling process.
88///
89/// # Returns
90/// * `Result<()>` - Returns `Ok(())` if successful, or an error if the operation fails.
91fn 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
130/// Determines the output writer (file or stdout) based on the configuration.
131///
132/// # Arguments
133/// * `output_path` - The optional output file path.
134///
135/// # Returns
136/// * `Result<Box<dyn Write>>` - Returns a writer for the output.
137fn 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
149/// Processes the specified directory and writes the bundled content to the writer.
150///
151/// # Arguments
152/// * `config` - The configuration options for the bundling process.
153/// * `writer` - The writer to output the bundled content.
154///
155/// # Returns
156/// * `Result<()>` - Returns `Ok(())` if successful, or an error if the process fails.
157fn 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
185/// Determines if a directory entry should be included based on the configuration.
186///
187/// # Arguments
188/// * `entry` - The directory entry to check.
189/// * `gitignore` - The `.gitignore` rules to respect.
190/// * `config` - The configuration options for the bundling process.
191///
192/// # Returns
193/// * `bool` - Returns `true` if the entry should be included, `false` otherwise.
194fn should_include_entry(entry: &DirEntry, gitignore: &Gitignore, config: &Config) -> bool {
195    !is_hidden(entry, config) && !is_ignored(entry, gitignore, config)
196}
197
198/// Processes a single file entry and writes its content to the writer.
199///
200/// # Arguments
201/// * `entry` - The file entry to process.
202/// * `writer` - The writer to output the file content.
203/// * `config` - The configuration options for the bundling process.
204///
205/// # Returns
206/// * `Result<()>` - Returns `Ok(())` if successful, or an error if the process fails.
207fn 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(()); // Skip non-text files
243        }
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
259/// Writes the content of a single file to the writer based on the specified format.
260///
261/// # Arguments
262/// * `writer` - The writer to output the file content.
263/// * `path` - The relative path of the file.
264/// * `content` - The content of the file.
265/// * `extension` - The file extension.
266/// * `config` - The configuration options for the bundling process.
267///
268/// # Returns
269/// * `Result<()>` - Returns `Ok(())` if successful, or an error if the operation fails.
270fn 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            // In Console mode, we could add colors or other specific formatting later
286            writeln!(writer, "./{}\n---", path.display())?;
287            write_content_lines(writer, content, config.line_numbers)?;
288            writeln!(writer, "---")?;
289        }
290    }
291    Ok(())
292}
293
294/// Writes content line by line to the writer, optionally including line numbers.
295///
296/// # Arguments
297/// * `writer` - The writer to output the content.
298/// * `content` - The content to write.
299/// * `line_numbers` - Whether to include line numbers.
300///
301/// # Returns
302/// * `Result<()>` - Returns `Ok(())` if successful, or an error if the operation fails.
303fn 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
314/// Checks if a directory entry is hidden based on the configuration.
315///
316/// # Arguments
317/// * `entry` - The directory entry to check.
318/// * `config` - The configuration options for the bundling process.
319///
320/// # Returns
321/// * `bool` - Returns `true` if the entry is hidden, `false` otherwise.
322fn 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
331/// Checks if a directory entry is ignored by `.gitignore` rules.
332///
333/// # Arguments
334/// * `entry` - The directory entry to check.
335/// * `gitignore` - The `.gitignore` rules to respect.
336/// * `config` - The configuration options for the bundling process.
337///
338/// # Returns
339/// * `bool` - Returns `true` if the entry is ignored, `false` otherwise.
340fn 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}