code_digest/
lib.rs

1//! Code Digest - High-performance CLI tool to convert codebases to Markdown for LLM context
2//!
3//! This library provides the core functionality for traversing directories,
4//! processing files, and generating formatted Markdown output suitable for
5//! large language model consumption.
6
7pub mod cli;
8pub mod config;
9pub mod core;
10pub mod remote;
11pub mod utils;
12
13use anyhow::Result;
14use std::path::Path;
15use std::sync::Arc;
16
17pub use cli::Config;
18pub use core::{cache::FileCache, digest::DigestOptions, walker::WalkOptions};
19pub use utils::error::CodeDigestError;
20
21/// Main entry point for the code digest library
22pub fn run(mut config: Config) -> Result<()> {
23    // Handle remote repository if specified
24    let _temp_dir = if let Some(repo_url) = &config.repo {
25        if config.verbose {
26            eprintln!("šŸ”§ Starting code-digest with remote repository: {repo_url}");
27        }
28
29        // Fetch the repository
30        let temp_dir = crate::remote::fetch_repository(repo_url, config.verbose)?;
31        let repo_path = crate::remote::get_repo_path(&temp_dir, repo_url)?;
32
33        // Update config to use the cloned repository
34        config.paths = Some(vec![repo_path]);
35
36        Some(temp_dir) // Keep temp_dir alive until end of function
37    } else {
38        None
39    };
40
41    // No need to update config since get_directories() handles resolution
42
43    // Setup logging based on verbosity
44    if config.verbose {
45        eprintln!("šŸ”§ Starting code-digest with configuration:");
46        eprintln!("  Directories: {:?}", config.get_directories());
47        eprintln!("  Max tokens: {:?}", config.max_tokens);
48        eprintln!("  LLM tool: {}", config.llm_tool.command());
49        eprintln!("  Progress: {}", config.progress);
50        eprintln!("  Quiet: {}", config.quiet);
51        if let Some(output) = &config.output_file {
52            eprintln!("  Output file: {}", output.display());
53        }
54        if let Some(prompt) = config.get_prompt() {
55            eprintln!("  Prompt: {prompt}");
56        }
57    }
58
59    // Validate configuration
60    config.validate()?;
61
62    // Create walker with options
63    if config.verbose {
64        eprintln!("🚶 Creating directory walker with options...");
65    }
66    let walk_options = WalkOptions::from_config(&config)?;
67
68    // Create digest options
69    if config.verbose {
70        eprintln!("šŸ“„ Creating markdown digest options...");
71    }
72    let digest_options = DigestOptions::from_config(&config)?;
73
74    // Create shared file cache
75    if config.verbose {
76        eprintln!("šŸ’¾ Creating file cache for I/O optimization...");
77    }
78    let cache = Arc::new(FileCache::new());
79
80    // Process all directories
81    let mut all_outputs = Vec::new();
82
83    let directories = config.get_directories();
84    for (index, directory) in directories.iter().enumerate() {
85        if config.progress && !config.quiet && directories.len() > 1 {
86            eprintln!(
87                "šŸ“‚ Processing directory {} of {}: {}",
88                index + 1,
89                directories.len(),
90                directory.display()
91            );
92        }
93
94        let output = process_directory(
95            directory,
96            walk_options.clone(),
97            digest_options.clone(),
98            cache.clone(),
99            &config,
100        )?;
101        all_outputs.push((directory.clone(), output));
102    }
103
104    // Combine outputs from all directories
105    let output = if all_outputs.len() == 1 {
106        // Single directory - return output as-is
107        all_outputs.into_iter().next().unwrap().1
108    } else {
109        // Multiple directories - combine with headers
110        let mut combined = String::new();
111        combined.push_str("# Code Digest - Multiple Directories\n\n");
112
113        for (path, content) in all_outputs {
114            combined.push_str(&format!("## Directory: {}\n\n", path.display()));
115            combined.push_str(&content);
116            combined.push_str("\n\n");
117        }
118
119        combined
120    };
121
122    // Handle output based on configuration
123    let resolved_prompt = config.get_prompt();
124    match (config.output_file.as_ref(), resolved_prompt.as_ref(), config.copy) {
125        (Some(file), None, false) => {
126            // Write to file
127            std::fs::write(file, output)?;
128            if !config.quiet {
129                println!(" Written to {}", file.display());
130            }
131        }
132        (None, Some(prompt), false) => {
133            // Send to LLM CLI with prompt
134            if config.progress && !config.quiet {
135                eprintln!("šŸ¤– Sending context to {}...", config.llm_tool.command());
136            }
137            execute_with_llm(prompt, &output, &config)?;
138        }
139        (None, Some(prompt), true) => {
140            // Copy to clipboard then send to LLM
141            copy_to_clipboard(&output)?;
142            if !config.quiet {
143                println!("āœ“ Copied to clipboard");
144            }
145            if config.progress && !config.quiet {
146                eprintln!("šŸ¤– Sending context to {}...", config.llm_tool.command());
147            }
148            execute_with_llm(prompt, &output, &config)?;
149        }
150        (None, None, true) => {
151            // Copy to clipboard
152            copy_to_clipboard(&output)?;
153            if !config.quiet {
154                println!("āœ“ Copied to clipboard");
155            }
156        }
157        (None, None, false) => {
158            // Print to stdout
159            print!("{output}");
160        }
161        (Some(_), _, true) => {
162            // This should have been caught by validation
163            return Err(CodeDigestError::InvalidConfiguration(
164                "Cannot specify both --copy and --output".to_string(),
165            )
166            .into());
167        }
168        (Some(_), Some(_), _) => {
169            return Err(CodeDigestError::InvalidConfiguration(
170                "Cannot specify both output file and prompt".to_string(),
171            )
172            .into());
173        }
174    }
175
176    Ok(())
177}
178
179/// Process a directory and generate markdown output
180fn process_directory(
181    path: &Path,
182    walk_options: WalkOptions,
183    digest_options: DigestOptions,
184    cache: Arc<FileCache>,
185    config: &Config,
186) -> Result<String> {
187    // Walk the directory
188    if config.progress && !config.quiet {
189        eprintln!("šŸ” Scanning directory: {}", path.display());
190    }
191    let files = core::walker::walk_directory(path, walk_options)?;
192
193    if config.progress && !config.quiet {
194        eprintln!("šŸ“ Found {} files", files.len());
195    }
196
197    if config.verbose {
198        eprintln!("šŸ“‹ File list:");
199        for file in &files {
200            eprintln!("  {} ({})", file.relative_path.display(), file.file_type_display());
201        }
202    }
203
204    // Prioritize files if needed
205    let prioritized_files = if digest_options.max_tokens.is_some() {
206        if config.progress && !config.quiet {
207            eprintln!("šŸŽÆ Prioritizing files for token limit...");
208        }
209        core::prioritizer::prioritize_files(files, &digest_options, cache.clone())?
210    } else {
211        files
212    };
213
214    if config.progress && !config.quiet {
215        eprintln!("šŸ“ Generating markdown from {} files...", prioritized_files.len());
216    }
217
218    // Generate markdown
219    let markdown = core::digest::generate_markdown(prioritized_files, digest_options, cache)?;
220
221    if config.progress && !config.quiet {
222        eprintln!("āœ… Markdown generation complete");
223    }
224
225    Ok(markdown)
226}
227
228/// Execute LLM CLI with the generated context
229fn execute_with_llm(prompt: &str, context: &str, config: &Config) -> Result<()> {
230    use std::io::Write;
231    use std::process::{Command, Stdio};
232
233    let full_input = format!("{prompt}\n\n{context}");
234    let tool_command = config.llm_tool.command();
235
236    let mut child = Command::new(tool_command)
237        .stdin(Stdio::piped())
238        .stdout(Stdio::inherit())
239        .stderr(Stdio::inherit())
240        .spawn()
241        .map_err(|e| {
242            if e.kind() == std::io::ErrorKind::NotFound {
243                CodeDigestError::LlmToolNotFound {
244                    tool: tool_command.to_string(),
245                    install_instructions: config.llm_tool.install_instructions().to_string(),
246                }
247            } else {
248                CodeDigestError::SubprocessError(e.to_string())
249            }
250        })?;
251
252    if let Some(mut stdin) = child.stdin.take() {
253        stdin.write_all(full_input.as_bytes())?;
254        stdin.flush()?;
255    }
256
257    let status = child.wait()?;
258    if !status.success() {
259        return Err(CodeDigestError::SubprocessError(format!(
260            "{tool_command} exited with status: {status}"
261        ))
262        .into());
263    }
264
265    if !config.quiet {
266        eprintln!("\nāœ“ {tool_command} completed successfully");
267    }
268
269    Ok(())
270}
271
272/// Copy content to system clipboard
273fn copy_to_clipboard(content: &str) -> Result<()> {
274    use arboard::Clipboard;
275
276    let mut clipboard = Clipboard::new()
277        .map_err(|e| CodeDigestError::ClipboardError(format!("Failed to access clipboard: {e}")))?;
278
279    clipboard.set_text(content).map_err(|e| {
280        CodeDigestError::ClipboardError(format!("Failed to copy to clipboard: {e}"))
281    })?;
282
283    Ok(())
284}