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