context_creator/
lib.rs

1//! Context Creator - 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 logging;
11pub mod remote;
12pub mod utils;
13
14use anyhow::Result;
15use std::path::Path;
16use std::sync::Arc;
17use tracing::{debug, info};
18
19pub use cli::Config;
20pub use core::{cache::FileCache, context_builder::ContextOptions, walker::WalkOptions};
21pub use utils::error::ContextCreatorError;
22
23/// Main entry point for the context creator library
24pub fn run(mut config: Config) -> Result<()> {
25    // Load configuration from file first
26    config.load_from_file()?;
27
28    // Validate configuration BEFORE processing
29    // This ensures mutual exclusivity checks work correctly
30    config.validate()?;
31
32    // Handle remote repository if specified
33    let _temp_dir = if let Some(repo_url) = &config.remote {
34        if config.verbose > 0 {
35            debug!(
36                "Starting context-creator with remote repository: {}",
37                repo_url
38            );
39        }
40
41        // Fetch the repository
42        let temp_dir = crate::remote::fetch_repository(repo_url, config.verbose > 0)?;
43        let repo_path = crate::remote::get_repo_path(&temp_dir, repo_url)?;
44
45        // Update config to use the cloned repository
46        config.paths = Some(vec![repo_path]);
47
48        Some(temp_dir) // Keep temp_dir alive until end of function
49    } else {
50        None
51    };
52
53    // No need to update config since get_directories() handles resolution
54
55    // Setup logging based on verbosity
56    if config.verbose > 0 {
57        debug!("Starting context-creator with configuration:");
58        debug!("  Directories: {:?}", config.get_directories());
59        debug!("  Max tokens: {:?}", config.max_tokens);
60        debug!("  LLM tool: {}", config.llm_tool.command());
61        debug!("  Progress: {}", config.progress);
62        debug!("  Quiet: {}", config.quiet);
63        if let Some(output) = &config.output_file {
64            debug!("  Output file: {}", output.display());
65        }
66        if let Some(prompt) = config.get_prompt() {
67            debug!("  Prompt: {}", prompt);
68        }
69    }
70
71    // Configuration was already validated above
72
73    // Create walker with options
74    if config.verbose > 0 {
75        debug!("Creating directory walker with options...");
76    }
77    let walk_options = WalkOptions::from_config(&config)?;
78
79    // Create context options
80    if config.verbose > 0 {
81        debug!("Creating context generation options...");
82    }
83    let context_options = ContextOptions::from_config(&config)?;
84
85    // Create shared file cache
86    if config.verbose > 0 {
87        debug!("Creating file cache for I/O optimization...");
88    }
89    let cache = Arc::new(FileCache::new());
90
91    // Process all directories
92    let mut all_outputs = Vec::new();
93
94    let directories = config.get_directories();
95    for (index, directory) in directories.iter().enumerate() {
96        if config.progress && !config.quiet && directories.len() > 1 {
97            info!(
98                "Processing directory {} of {}: {}",
99                index + 1,
100                directories.len(),
101                directory.display()
102            );
103        }
104
105        let output = process_directory(
106            directory,
107            walk_options.clone(),
108            context_options.clone(),
109            cache.clone(),
110            &config,
111        )?;
112        all_outputs.push((directory.clone(), output));
113    }
114
115    // Combine outputs from all directories
116    let output = if all_outputs.len() == 1 {
117        // Single directory - return output as-is
118        all_outputs.into_iter().next().unwrap().1
119    } else {
120        // Multiple directories - combine with headers
121        let mut combined = String::new();
122        combined.push_str("# Code Context - Multiple Directories\n\n");
123
124        for (path, content) in all_outputs {
125            combined.push_str(&format!("## Directory: {}\n\n", path.display()));
126            combined.push_str(&content);
127            combined.push_str("\n\n");
128        }
129
130        combined
131    };
132
133    // Handle output based on configuration
134    let resolved_prompt = config.get_prompt();
135    match (
136        config.output_file.as_ref(),
137        resolved_prompt.as_ref(),
138        config.copy,
139    ) {
140        (Some(file), None, false) => {
141            // Write to file
142            std::fs::write(file, output)?;
143            if !config.quiet {
144                println!(" Written to {}", file.display());
145            }
146        }
147        (None, Some(prompt), false) => {
148            // Send to LLM CLI with prompt
149            if config.progress && !config.quiet {
150                info!("Sending context to {}...", config.llm_tool.command());
151            }
152            execute_with_llm(prompt, &output, &config)?;
153        }
154        (None, Some(prompt), true) => {
155            // Copy to clipboard then send to LLM
156            copy_to_clipboard(&output)?;
157            if !config.quiet {
158                println!("✓ Copied to clipboard");
159            }
160            if config.progress && !config.quiet {
161                info!("Sending context to {}...", config.llm_tool.command());
162            }
163            execute_with_llm(prompt, &output, &config)?;
164        }
165        (None, None, true) => {
166            // Copy to clipboard
167            copy_to_clipboard(&output)?;
168            if !config.quiet {
169                println!("✓ Copied to clipboard");
170            }
171        }
172        (None, None, false) => {
173            // Print to stdout
174            print!("{output}");
175        }
176        (Some(_), _, true) => {
177            // This should have been caught by validation
178            return Err(ContextCreatorError::InvalidConfiguration(
179                "Cannot specify both --copy and --output".to_string(),
180            )
181            .into());
182        }
183        (Some(_), Some(_), _) => {
184            return Err(ContextCreatorError::InvalidConfiguration(
185                "Cannot specify both output file and prompt".to_string(),
186            )
187            .into());
188        }
189    }
190
191    Ok(())
192}
193
194/// Process a directory and generate markdown output
195fn process_directory(
196    path: &Path,
197    walk_options: WalkOptions,
198    context_options: ContextOptions,
199    cache: Arc<FileCache>,
200    config: &Config,
201) -> Result<String> {
202    // Walk the directory
203    if config.progress && !config.quiet {
204        info!("Scanning directory: {}", path.display());
205    }
206    let mut files = core::walker::walk_directory(path, walk_options.clone())?;
207
208    if config.progress && !config.quiet {
209        info!("Found {} files", files.len());
210    }
211
212    // Perform semantic analysis if requested
213    if config.trace_imports || config.include_callers || config.include_types {
214        if config.progress && !config.quiet {
215            info!("Analyzing semantic dependencies...");
216        }
217
218        // Perform single-pass project analysis
219        let project_analysis = core::project_analyzer::ProjectAnalysis::analyze_project(
220            path,
221            &walk_options,
222            config,
223            &cache,
224        )?;
225
226        // Create initial set from our filtered files
227        let mut initial_files_map = std::collections::HashMap::new();
228        for file in files {
229            if let Some(analyzed_file) = project_analysis.get_file(&file.path) {
230                initial_files_map.insert(file.path.clone(), analyzed_file.clone());
231            } else {
232                initial_files_map.insert(file.path.clone(), file);
233            }
234        }
235
236        // Expand file list based on semantic relationships, using the full project context
237        if config.progress && !config.quiet {
238            info!("Expanding file list based on semantic relationships...");
239        }
240
241        // Pass the project analysis file map as context for expansion
242        let files_map = core::file_expander::expand_file_list_with_context(
243            initial_files_map,
244            config,
245            &cache,
246            &walk_options,
247            &project_analysis.file_map,
248        )?;
249
250        // Convert back to Vec<FileInfo>
251        files = files_map.into_values().collect();
252
253        // Clean up imported_by fields to only include files in our final set
254        let final_paths: std::collections::HashSet<_> =
255            files.iter().map(|f| f.path.clone()).collect();
256        for file in &mut files {
257            file.imported_by.retain(|path| final_paths.contains(path));
258        }
259
260        if config.progress && !config.quiet {
261            info!("Expanded to {} files", files.len());
262        }
263    }
264
265    if config.verbose > 0 {
266        debug!("File list:");
267        for file in &files {
268            debug!(
269                "  {} ({})",
270                file.relative_path.display(),
271                file.file_type_display()
272            );
273        }
274    }
275
276    // Prioritize files if needed
277    let prioritized_files = if context_options.max_tokens.is_some() {
278        if config.progress && !config.quiet {
279            info!("Prioritizing files for token limit...");
280        }
281        core::prioritizer::prioritize_files(files, &context_options, cache.clone())?
282    } else {
283        files
284    };
285
286    if config.progress && !config.quiet {
287        info!(
288            "Generating markdown from {} files...",
289            prioritized_files.len()
290        );
291    }
292
293    // Generate markdown
294    let markdown =
295        core::context_builder::generate_markdown(prioritized_files, context_options, cache)?;
296
297    if config.progress && !config.quiet {
298        info!("Markdown generation complete");
299    }
300
301    Ok(markdown)
302}
303
304/// Execute LLM CLI with the generated context
305fn execute_with_llm(prompt: &str, context: &str, config: &Config) -> Result<()> {
306    use std::io::Write;
307    use std::process::{Command, Stdio};
308
309    let full_input = format!("{prompt}\n\n{context}");
310    let tool_command = config.llm_tool.command();
311
312    let mut child = Command::new(tool_command)
313        .stdin(Stdio::piped())
314        .stdout(Stdio::inherit())
315        .stderr(Stdio::inherit())
316        .spawn()
317        .map_err(|e| {
318            if e.kind() == std::io::ErrorKind::NotFound {
319                ContextCreatorError::LlmToolNotFound {
320                    tool: tool_command.to_string(),
321                    install_instructions: config.llm_tool.install_instructions().to_string(),
322                }
323            } else {
324                ContextCreatorError::SubprocessError(e.to_string())
325            }
326        })?;
327
328    if let Some(mut stdin) = child.stdin.take() {
329        stdin.write_all(full_input.as_bytes())?;
330        stdin.flush()?;
331    }
332
333    let status = child.wait()?;
334    if !status.success() {
335        return Err(ContextCreatorError::SubprocessError(format!(
336            "{tool_command} exited with status: {status}"
337        ))
338        .into());
339    }
340
341    if !config.quiet {
342        info!("{} completed successfully", tool_command);
343    }
344
345    Ok(())
346}
347
348/// Copy content to system clipboard
349fn copy_to_clipboard(content: &str) -> Result<()> {
350    use arboard::Clipboard;
351
352    let mut clipboard = Clipboard::new().map_err(|e| {
353        ContextCreatorError::ClipboardError(format!("Failed to access clipboard: {e}"))
354    })?;
355
356    clipboard.set_text(content).map_err(|e| {
357        ContextCreatorError::ClipboardError(format!("Failed to copy to clipboard: {e}"))
358    })?;
359
360    Ok(())
361}