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