context_creator/core/
context_builder.rs

1//! Context creation functionality for LLM consumption
2
3use crate::cli::OutputFormat;
4use crate::core::cache::FileCache;
5use crate::core::walker::FileInfo;
6use crate::formatters::{create_formatter, DigestData};
7use crate::utils::file_ext::FileType;
8use crate::utils::git::{format_git_context_to_markdown, get_file_git_context_with_depth};
9use anyhow::Result;
10use std::collections::HashMap;
11use std::path::{Path, PathBuf};
12use std::sync::Arc;
13use tracing::warn;
14
15/// Options for generating context for LLM consumption
16#[derive(Debug, Clone)]
17pub struct ContextOptions {
18    /// Maximum tokens allowed in the output
19    pub max_tokens: Option<usize>,
20    /// Include file tree in output
21    pub include_tree: bool,
22    /// Include token count statistics
23    pub include_stats: bool,
24    /// Group files by type
25    pub group_by_type: bool,
26    /// Sort files by priority
27    pub sort_by_priority: bool,
28    /// Template for file headers
29    pub file_header_template: String,
30    /// Template for the document header
31    pub doc_header_template: String,
32    /// Include table of contents
33    pub include_toc: bool,
34    /// Enable enhanced context with file metadata
35    pub enhanced_context: bool,
36    /// Include git commit history in file headers
37    pub git_context: bool,
38    /// Number of git commits to show per file
39    pub git_context_depth: usize,
40}
41
42impl ContextOptions {
43    /// Create ContextOptions from CLI config
44    pub fn from_config(config: &crate::cli::Config) -> Result<Self> {
45        Ok(ContextOptions {
46            max_tokens: config.get_effective_context_tokens(),
47            include_tree: true,
48            include_stats: true,
49            group_by_type: false,
50            sort_by_priority: true,
51            file_header_template: "## {path}".to_string(),
52            doc_header_template: "# Code Context: {directory}".to_string(),
53            include_toc: true,
54            enhanced_context: config.enhanced_context,
55            git_context: config.git_context,
56            git_context_depth: config.git_context_depth,
57        })
58    }
59}
60
61impl Default for ContextOptions {
62    fn default() -> Self {
63        ContextOptions {
64            max_tokens: None,
65            include_tree: true,
66            include_stats: true,
67            group_by_type: false,
68            sort_by_priority: true,
69            file_header_template: "## {path}".to_string(),
70            doc_header_template: "# Code Context: {directory}".to_string(),
71            include_toc: true,
72            enhanced_context: false,
73            git_context: false,
74            git_context_depth: 3,
75        }
76    }
77}
78
79/// Estimate the total size of the markdown output
80fn estimate_output_size(files: &[FileInfo], options: &ContextOptions, cache: &FileCache) -> usize {
81    let mut size = 0;
82
83    // Document header
84    if !options.doc_header_template.is_empty() {
85        size += options.doc_header_template.len() + 50; // Extra for replacements and newlines
86    }
87
88    // Statistics section
89    if options.include_stats {
90        size += 500; // Estimated size for stats
91        size += files.len() * 50; // For file type listing
92    }
93
94    // File tree
95    if options.include_tree {
96        size += 100; // Headers
97        size += files.len() * 100; // Estimated per-file in tree
98    }
99
100    // Table of contents
101    if options.include_toc {
102        size += 50; // Header
103        size += files.len() * 100; // Per-file TOC entry
104    }
105
106    // File contents
107    for file in files {
108        // Header template
109        size +=
110            options.file_header_template.len() + file.relative_path.to_string_lossy().len() + 20;
111
112        // File content + code fence
113        if let Ok(content) = cache.get_or_load(&file.path) {
114            size += content.len() + 20; // Content + fence markers
115        } else {
116            size += file.size as usize; // Fallback to file size
117        }
118    }
119
120    // Add 20% buffer for formatting and unexpected overhead
121    size + (size / 5)
122}
123
124/// Generate markdown from a list of files
125pub fn generate_markdown(
126    files: Vec<FileInfo>,
127    options: ContextOptions,
128    cache: Arc<FileCache>,
129) -> Result<String> {
130    let mut output = create_output_buffer(&files, &options, &cache);
131
132    add_document_header(&mut output, &options);
133    add_statistics_section(&mut output, &files, &options);
134    add_file_tree_section(&mut output, &files, &options);
135
136    let sorted_files = sort_files_by_priority(files, &options);
137    add_table_of_contents(&mut output, &sorted_files, &options);
138    add_file_contents(&mut output, sorted_files, &options, &cache)?;
139
140    Ok(output)
141}
142
143// Helper functions - each 10 lines or less
144
145fn create_output_buffer(
146    files: &[FileInfo],
147    options: &ContextOptions,
148    cache: &Arc<FileCache>,
149) -> String {
150    let estimated_size = estimate_output_size(files, options, cache);
151    String::with_capacity(estimated_size)
152}
153
154fn add_document_header(output: &mut String, options: &ContextOptions) {
155    if !options.doc_header_template.is_empty() {
156        let header = options.doc_header_template.replace("{directory}", ".");
157        output.push_str(&header);
158        output.push_str("\n\n");
159    }
160}
161
162fn add_statistics_section(output: &mut String, files: &[FileInfo], options: &ContextOptions) {
163    if options.include_stats {
164        let stats = generate_statistics(files);
165        output.push_str(&stats);
166        output.push_str("\n\n");
167    }
168}
169
170fn add_file_tree_section(output: &mut String, files: &[FileInfo], options: &ContextOptions) {
171    if options.include_tree {
172        let tree = generate_file_tree(files, options);
173        output.push_str("## File Structure\n\n");
174        output.push_str("```\n");
175        output.push_str(&tree);
176        output.push_str("```\n\n");
177    }
178}
179
180fn sort_files_by_priority(mut files: Vec<FileInfo>, options: &ContextOptions) -> Vec<FileInfo> {
181    if options.sort_by_priority {
182        files.sort_by(|a, b| {
183            b.priority
184                .partial_cmp(&a.priority)
185                .unwrap_or(std::cmp::Ordering::Equal)
186                .then_with(|| a.relative_path.cmp(&b.relative_path))
187        });
188    }
189    files
190}
191
192fn add_table_of_contents(output: &mut String, files: &[FileInfo], options: &ContextOptions) {
193    if options.include_toc {
194        output.push_str("## Table of Contents\n\n");
195        for file in files {
196            add_toc_entry(output, file);
197        }
198        output.push('\n');
199    }
200}
201
202fn add_toc_entry(output: &mut String, file: &FileInfo) {
203    let anchor = path_to_anchor(&file.relative_path);
204    output.push_str(&format!(
205        "- [{path}](#{anchor})\n",
206        path = file.relative_path.display(),
207        anchor = anchor
208    ));
209}
210
211fn add_file_contents(
212    output: &mut String,
213    files: Vec<FileInfo>,
214    options: &ContextOptions,
215    cache: &Arc<FileCache>,
216) -> Result<()> {
217    if options.group_by_type {
218        add_grouped_files(output, files, options, cache)
219    } else {
220        add_ungrouped_files(output, files, options, cache)
221    }
222}
223
224fn add_grouped_files(
225    output: &mut String,
226    files: Vec<FileInfo>,
227    options: &ContextOptions,
228    cache: &Arc<FileCache>,
229) -> Result<()> {
230    let grouped = group_files_by_type(files);
231    for (file_type, group_files) in grouped {
232        output.push_str(&format!("## {} Files\n\n", file_type_display(&file_type)));
233        for file in group_files {
234            append_file_content(output, &file, options, cache)?;
235        }
236    }
237    Ok(())
238}
239
240fn add_ungrouped_files(
241    output: &mut String,
242    files: Vec<FileInfo>,
243    options: &ContextOptions,
244    cache: &Arc<FileCache>,
245) -> Result<()> {
246    for file in files {
247        append_file_content(output, &file, options, cache)?;
248    }
249    Ok(())
250}
251
252/// Generate digest using the appropriate formatter
253pub fn generate_digest(
254    files: Vec<FileInfo>,
255    options: ContextOptions,
256    cache: Arc<FileCache>,
257    output_format: OutputFormat,
258    base_directory: &str,
259) -> Result<String> {
260    // Create formatter based on output format
261    let mut formatter = create_formatter(output_format);
262
263    // Create digest data
264    let data = DigestData {
265        files: &files,
266        options: &options,
267        cache: &cache,
268        base_directory,
269    };
270
271    // Render all sections
272    formatter.render_header(&data)?;
273    formatter.render_statistics(&data)?;
274    formatter.render_file_tree(&data)?;
275    formatter.render_toc(&data)?;
276
277    // Render file details
278    for file in &files {
279        formatter.render_file_details(file, &data)?;
280    }
281
282    // Finalize and return
283    Ok(formatter.finalize())
284}
285
286/// Append a single file's content to the output
287fn append_file_content(
288    output: &mut String,
289    file: &FileInfo,
290    options: &ContextOptions,
291    cache: &FileCache,
292) -> Result<()> {
293    let content = load_file_content(file, cache)?;
294    add_file_header(output, file, options);
295    add_semantic_info(output, file);
296    add_file_body(output, &content, &file.file_type);
297    Ok(())
298}
299
300fn load_file_content(file: &FileInfo, cache: &FileCache) -> Result<String> {
301    match cache.get_or_load(&file.path) {
302        Ok(content) => Ok(content.to_string()),
303        Err(e) => {
304            warn!("Could not read file {}: {}", file.path.display(), e);
305            Ok(String::new())
306        }
307    }
308}
309
310fn add_file_header(output: &mut String, file: &FileInfo, options: &ContextOptions) {
311    let path_with_metadata = format_path_with_metadata(file, options);
312    let header = options
313        .file_header_template
314        .replace("{path}", &path_with_metadata);
315    output.push_str(&header);
316    output.push('\n');
317
318    // Add git context if enabled
319    if options.git_context {
320        // Find the repository root from the file path
321        let repo_root = file.path.parent().unwrap_or(Path::new("."));
322        if let Some(git_context) =
323            get_file_git_context_with_depth(repo_root, &file.path, options.git_context_depth)
324        {
325            output.push_str(&format_git_context_to_markdown(&git_context));
326        }
327    }
328
329    output.push('\n');
330}
331
332pub fn format_path_with_metadata(file: &FileInfo, options: &ContextOptions) -> String {
333    if options.enhanced_context {
334        format!(
335            "{} ({}, {})",
336            file.relative_path.display(),
337            format_size(file.size),
338            file_type_display(&file.file_type)
339        )
340    } else {
341        file.relative_path.display().to_string()
342    }
343}
344
345fn add_semantic_info(output: &mut String, file: &FileInfo) {
346    add_imports_info(output, &file.imports);
347    add_imported_by_info(output, &file.imported_by);
348    add_function_calls_info(output, &file.function_calls);
349    add_type_references_info(output, &file.type_references);
350}
351
352fn add_imports_info(output: &mut String, imports: &[PathBuf]) {
353    if !imports.is_empty() {
354        output.push_str("Imports: ");
355        let names = format_import_names(imports);
356        output.push_str(&format!("{}\n\n", names.join(", ")));
357    }
358}
359
360pub fn format_import_names(imports: &[PathBuf]) -> Vec<String> {
361    imports.iter().map(|p| format_import_name(p)).collect()
362}
363
364fn format_import_name(path: &Path) -> String {
365    let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
366
367    if filename == "__init__.py" {
368        get_python_module_name(path)
369    } else {
370        strip_common_extensions(filename).to_string()
371    }
372}
373
374fn get_python_module_name(path: &Path) -> String {
375    path.parent()
376        .and_then(|parent| parent.file_name())
377        .and_then(|n| n.to_str())
378        .unwrap_or("unknown")
379        .to_string()
380}
381
382fn strip_common_extensions(filename: &str) -> &str {
383    filename
384        .strip_suffix(".py")
385        .or_else(|| filename.strip_suffix(".rs"))
386        .or_else(|| filename.strip_suffix(".js"))
387        .or_else(|| filename.strip_suffix(".ts"))
388        .unwrap_or(filename)
389}
390
391fn add_imported_by_info(output: &mut String, imported_by: &[PathBuf]) {
392    if !imported_by.is_empty() {
393        output.push_str("Imported by: ");
394        let names = format_imported_by_names(imported_by);
395        output.push_str(&format!("{}\n\n", names.join(", ")));
396    }
397}
398
399pub fn format_imported_by_names(imported_by: &[PathBuf]) -> Vec<String> {
400    imported_by
401        .iter()
402        .map(|p| {
403            p.file_name()
404                .and_then(|n| n.to_str())
405                .unwrap_or_else(|| p.to_str().unwrap_or("unknown"))
406                .to_string()
407        })
408        .collect()
409}
410
411fn add_function_calls_info(
412    output: &mut String,
413    calls: &[crate::core::semantic::analyzer::FunctionCall],
414) {
415    if !calls.is_empty() {
416        output.push_str("Function calls: ");
417        let names = format_function_call_names(calls);
418        output.push_str(&format!("{}\n\n", names.join(", ")));
419    }
420}
421
422fn format_function_call_names(
423    calls: &[crate::core::semantic::analyzer::FunctionCall],
424) -> Vec<String> {
425    calls
426        .iter()
427        .map(|fc| {
428            if let Some(module) = &fc.module {
429                format!("{}.{}", module, fc.name)
430            } else {
431                fc.name.clone()
432            }
433        })
434        .collect()
435}
436
437fn add_type_references_info(
438    output: &mut String,
439    refs: &[crate::core::semantic::analyzer::TypeReference],
440) {
441    if !refs.is_empty() {
442        output.push_str("Type references: ");
443        let names: Vec<String> = refs
444            .iter()
445            .map(|tr| {
446                if let Some(module) = &tr.module {
447                    // Check if module already ends with the type name to avoid duplication
448                    if module.ends_with(&format!("::{}", tr.name)) {
449                        module.clone()
450                    } else {
451                        format!("{}.{}", module, tr.name)
452                    }
453                } else {
454                    tr.name.clone()
455                }
456            })
457            .collect();
458        output.push_str(&format!("{}\n\n", names.join(", ")));
459    }
460}
461
462fn add_file_body(output: &mut String, content: &str, file_type: &FileType) {
463    let language = get_language_hint(file_type);
464    output.push_str(&format!("```{language}\n"));
465    output.push_str(content);
466    if !content.ends_with('\n') {
467        output.push('\n');
468    }
469    output.push_str("```\n\n");
470}
471
472/// Generate statistics about the files
473pub fn generate_statistics(files: &[FileInfo]) -> String {
474    let mut stats = create_stats_buffer(files.len());
475    add_stats_header(&mut stats);
476    add_file_count(&mut stats, files.len());
477    add_total_size(&mut stats, calculate_total_size(files));
478    add_file_type_breakdown(&mut stats, count_file_types(files));
479    stats
480}
481
482fn create_stats_buffer(file_count: usize) -> String {
483    String::with_capacity(500 + file_count * 50)
484}
485
486fn add_stats_header(stats: &mut String) {
487    stats.push_str("## Statistics\n\n");
488}
489
490fn add_file_count(stats: &mut String, count: usize) {
491    stats.push_str(&format!("- Total files: {count}\n"));
492}
493
494fn add_total_size(stats: &mut String, size: u64) {
495    stats.push_str(&format!("- Total size: {} bytes\n", format_size(size)));
496}
497
498fn calculate_total_size(files: &[FileInfo]) -> u64 {
499    files.iter().map(|f| f.size).sum()
500}
501
502fn count_file_types(files: &[FileInfo]) -> Vec<(FileType, usize)> {
503    let mut type_counts: HashMap<FileType, usize> = HashMap::new();
504    for file in files {
505        *type_counts.entry(file.file_type.clone()).or_insert(0) += 1;
506    }
507    let mut types: Vec<_> = type_counts.into_iter().collect();
508    types.sort_by_key(|(_, count)| std::cmp::Reverse(*count));
509    types
510}
511
512fn add_file_type_breakdown(stats: &mut String, types: Vec<(FileType, usize)>) {
513    stats.push_str("\n### Files by type:\n");
514    for (file_type, count) in types {
515        stats.push_str(&format!("- {}: {}\n", file_type_display(&file_type), count));
516    }
517}
518
519/// Generate a file tree representation
520pub fn generate_file_tree(files: &[FileInfo], options: &ContextOptions) -> String {
521    use std::collections::{BTreeMap, HashMap};
522
523    #[derive(Default)]
524    struct TreeNode {
525        files: Vec<String>,
526        dirs: BTreeMap<String, TreeNode>,
527    }
528
529    let mut root = TreeNode::default();
530
531    // Create a lookup map from relative path to FileInfo for metadata
532    let file_lookup: HashMap<String, &FileInfo> = files
533        .iter()
534        .map(|f| (f.relative_path.to_string_lossy().to_string(), f))
535        .collect();
536
537    // Build tree structure
538    for file in files {
539        let parts: Vec<_> = file
540            .relative_path
541            .components()
542            .map(|c| c.as_os_str().to_string_lossy().to_string())
543            .collect();
544
545        let mut current = &mut root;
546        for (i, part) in parts.iter().enumerate() {
547            if i == parts.len() - 1 {
548                // File
549                current.files.push(part.clone());
550            } else {
551                // Directory
552                current = current.dirs.entry(part.clone()).or_default();
553            }
554        }
555    }
556
557    // Render tree
558    #[allow(clippy::too_many_arguments)]
559    fn render_tree(
560        node: &TreeNode,
561        prefix: &str,
562        _is_last: bool,
563        current_path: &str,
564        file_lookup: &HashMap<String, &FileInfo>,
565        options: &ContextOptions,
566    ) -> String {
567        // Pre-allocate with estimated size
568        let estimated_size = (node.dirs.len() + node.files.len()) * 100;
569        let mut output = String::with_capacity(estimated_size);
570
571        // Render directories
572        let dir_count = node.dirs.len();
573        for (i, (name, child)) in node.dirs.iter().enumerate() {
574            let is_last_dir = i == dir_count - 1 && node.files.is_empty();
575            let connector = if is_last_dir {
576                "└── "
577            } else {
578                "├── "
579            };
580            let extension = if is_last_dir { "    " } else { "│   " };
581
582            output.push_str(&format!("{prefix}{connector}{name}/\n"));
583            let child_path = if current_path.is_empty() {
584                name.clone()
585            } else {
586                format!("{current_path}/{name}")
587            };
588            output.push_str(&render_tree(
589                child,
590                &format!("{prefix}{extension}"),
591                is_last_dir,
592                &child_path,
593                file_lookup,
594                options,
595            ));
596        }
597
598        // Render files
599        let file_count = node.files.len();
600        for (i, name) in node.files.iter().enumerate() {
601            let is_last_file = i == file_count - 1;
602            let connector = if is_last_file {
603                "└── "
604            } else {
605                "├── "
606            };
607
608            let file_path = if current_path.is_empty() {
609                name.clone()
610            } else {
611                format!("{current_path}/{name}")
612            };
613
614            // Include metadata if enhanced context is enabled
615            let display_name = if options.enhanced_context {
616                if let Some(file_info) = file_lookup.get(&file_path) {
617                    format!(
618                        "{} ({}, {})",
619                        name,
620                        format_size(file_info.size),
621                        file_type_display(&file_info.file_type)
622                    )
623                } else {
624                    name.clone()
625                }
626            } else {
627                name.clone()
628            };
629
630            output.push_str(&format!("{prefix}{connector}{display_name}\n"));
631        }
632
633        output
634    }
635
636    // Pre-allocate output string
637    let mut output = String::with_capacity(files.len() * 100 + 10);
638    output.push_str(".\n");
639    output.push_str(&render_tree(&root, "", true, "", &file_lookup, options));
640    output
641}
642
643/// Group files by their type
644fn group_files_by_type(files: Vec<FileInfo>) -> Vec<(FileType, Vec<FileInfo>)> {
645    let mut groups: HashMap<FileType, Vec<FileInfo>> = HashMap::new();
646
647    for file in files {
648        groups.entry(file.file_type.clone()).or_default().push(file);
649    }
650
651    let mut result: Vec<_> = groups.into_iter().collect();
652    result.sort_by_key(|(file_type, _)| file_type_priority(file_type));
653    result
654}
655
656/// Get display name for file type
657pub fn file_type_display(file_type: &FileType) -> &'static str {
658    match file_type {
659        FileType::Rust => "Rust",
660        FileType::Python => "Python",
661        FileType::JavaScript => "JavaScript",
662        FileType::TypeScript => "TypeScript",
663        FileType::Go => "Go",
664        FileType::Java => "Java",
665        FileType::Cpp => "C++",
666        FileType::C => "C",
667        FileType::CSharp => "C#",
668        FileType::Ruby => "Ruby",
669        FileType::Php => "PHP",
670        FileType::Swift => "Swift",
671        FileType::Kotlin => "Kotlin",
672        FileType::Scala => "Scala",
673        FileType::Haskell => "Haskell",
674        FileType::Dart => "Dart",
675        FileType::Lua => "Lua",
676        FileType::R => "R",
677        FileType::Julia => "Julia",
678        FileType::Elixir => "Elixir",
679        FileType::Elm => "Elm",
680        FileType::Markdown => "Markdown",
681        FileType::Json => "JSON",
682        FileType::Yaml => "YAML",
683        FileType::Toml => "TOML",
684        FileType::Xml => "XML",
685        FileType::Html => "HTML",
686        FileType::Css => "CSS",
687        FileType::Text => "Text",
688        FileType::Other => "Other",
689    }
690}
691
692/// Get language hint for syntax highlighting
693pub fn get_language_hint(file_type: &FileType) -> &'static str {
694    match file_type {
695        FileType::Rust => "rust",
696        FileType::Python => "python",
697        FileType::JavaScript => "javascript",
698        FileType::TypeScript => "typescript",
699        FileType::Go => "go",
700        FileType::Java => "java",
701        FileType::Cpp => "cpp",
702        FileType::C => "c",
703        FileType::CSharp => "csharp",
704        FileType::Ruby => "ruby",
705        FileType::Php => "php",
706        FileType::Swift => "swift",
707        FileType::Kotlin => "kotlin",
708        FileType::Scala => "scala",
709        FileType::Haskell => "haskell",
710        FileType::Dart => "dart",
711        FileType::Lua => "lua",
712        FileType::R => "r",
713        FileType::Julia => "julia",
714        FileType::Elixir => "elixir",
715        FileType::Elm => "elm",
716        FileType::Markdown => "markdown",
717        FileType::Json => "json",
718        FileType::Yaml => "yaml",
719        FileType::Toml => "toml",
720        FileType::Xml => "xml",
721        FileType::Html => "html",
722        FileType::Css => "css",
723        FileType::Text => "text",
724        FileType::Other => "",
725    }
726}
727
728/// Get priority for file type ordering
729fn file_type_priority(file_type: &FileType) -> u8 {
730    match file_type {
731        FileType::Rust => 1,
732        FileType::Python => 2,
733        FileType::JavaScript => 3,
734        FileType::TypeScript => 3,
735        FileType::Go => 4,
736        FileType::Java => 5,
737        FileType::Cpp => 6,
738        FileType::C => 7,
739        FileType::CSharp => 8,
740        FileType::Ruby => 9,
741        FileType::Php => 10,
742        FileType::Swift => 11,
743        FileType::Kotlin => 12,
744        FileType::Scala => 13,
745        FileType::Haskell => 14,
746        FileType::Dart => 15,
747        FileType::Lua => 16,
748        FileType::R => 17,
749        FileType::Julia => 18,
750        FileType::Elixir => 19,
751        FileType::Elm => 20,
752        FileType::Markdown => 21,
753        FileType::Json => 22,
754        FileType::Yaml => 23,
755        FileType::Toml => 24,
756        FileType::Xml => 25,
757        FileType::Html => 26,
758        FileType::Css => 27,
759        FileType::Text => 28,
760        FileType::Other => 29,
761    }
762}
763
764/// Convert path to anchor-friendly string
765pub fn path_to_anchor(path: &Path) -> String {
766    path.display()
767        .to_string()
768        .replace(['/', '\\', '.', ' '], "-")
769        .to_lowercase()
770}
771
772/// Format file size in human-readable format
773fn format_size(size: u64) -> String {
774    const UNITS: &[&str] = &["B", "KB", "MB", "GB"];
775    let mut size = size as f64;
776    let mut unit_index = 0;
777
778    while size >= 1024.0 && unit_index < UNITS.len() - 1 {
779        size /= 1024.0;
780        unit_index += 1;
781    }
782
783    if unit_index == 0 {
784        format!("{} {}", size as u64, UNITS[unit_index])
785    } else {
786        format!("{:.2} {}", size, UNITS[unit_index])
787    }
788}
789
790#[cfg(test)]
791mod tests {
792    use super::*;
793    use std::path::PathBuf;
794
795    fn create_test_cache() -> Arc<FileCache> {
796        Arc::new(FileCache::new())
797    }
798
799    #[test]
800    fn test_format_size() {
801        assert_eq!(format_size(512), "512 B");
802        assert_eq!(format_size(1024), "1.00 KB");
803        assert_eq!(format_size(1536), "1.50 KB");
804        assert_eq!(format_size(1048576), "1.00 MB");
805    }
806
807    #[test]
808    fn test_path_to_anchor() {
809        assert_eq!(path_to_anchor(Path::new("src/main.rs")), "src-main-rs");
810        assert_eq!(path_to_anchor(Path::new("test file.txt")), "test-file-txt");
811    }
812
813    #[test]
814    fn test_file_type_display() {
815        assert_eq!(file_type_display(&FileType::Rust), "Rust");
816        assert_eq!(file_type_display(&FileType::Python), "Python");
817    }
818
819    #[test]
820    fn test_generate_statistics() {
821        let files = vec![
822            FileInfo {
823                path: PathBuf::from("test1.rs"),
824                relative_path: PathBuf::from("test1.rs"),
825                size: 100,
826                file_type: FileType::Rust,
827                priority: 1.0,
828                imports: Vec::new(),
829                imported_by: Vec::new(),
830                function_calls: Vec::new(),
831                type_references: Vec::new(),
832                exported_functions: Vec::new(),
833            },
834            FileInfo {
835                path: PathBuf::from("test2.py"),
836                relative_path: PathBuf::from("test2.py"),
837                size: 200,
838                file_type: FileType::Python,
839                priority: 0.9,
840                imports: Vec::new(),
841                imported_by: Vec::new(),
842                function_calls: Vec::new(),
843                type_references: Vec::new(),
844                exported_functions: Vec::new(),
845            },
846        ];
847
848        let stats = generate_statistics(&files);
849        assert!(stats.contains("Total files: 2"));
850        assert!(stats.contains("Total size: 300 B"));
851        assert!(stats.contains("Rust: 1"));
852        assert!(stats.contains("Python: 1"));
853    }
854
855    #[test]
856    fn test_generate_statistics_empty() {
857        let files = vec![];
858        let stats = generate_statistics(&files);
859        assert!(stats.contains("Total files: 0"));
860        assert!(stats.contains("Total size: 0 B"));
861    }
862
863    #[test]
864    fn test_generate_statistics_large_files() {
865        let files = vec![
866            FileInfo {
867                path: PathBuf::from("large.rs"),
868                relative_path: PathBuf::from("large.rs"),
869                size: 2_000_000, // 2MB
870                file_type: FileType::Rust,
871                priority: 1.0,
872                imports: Vec::new(),
873                imported_by: Vec::new(),
874                function_calls: Vec::new(),
875                type_references: Vec::new(),
876                exported_functions: Vec::new(),
877            },
878            FileInfo {
879                path: PathBuf::from("huge.py"),
880                relative_path: PathBuf::from("huge.py"),
881                size: 50_000_000, // 50MB
882                file_type: FileType::Python,
883                priority: 0.9,
884                imports: Vec::new(),
885                imported_by: Vec::new(),
886                function_calls: Vec::new(),
887                type_references: Vec::new(),
888                exported_functions: Vec::new(),
889            },
890        ];
891
892        let stats = generate_statistics(&files);
893        assert!(stats.contains("Total files: 2"));
894        assert!(stats.contains("MB bytes")); // Just check that it's in MB
895        assert!(stats.contains("Python: 1"));
896        assert!(stats.contains("Rust: 1"));
897    }
898
899    #[test]
900    fn test_generate_file_tree_with_grouping() {
901        let files = vec![
902            FileInfo {
903                path: PathBuf::from("src/main.rs"),
904                relative_path: PathBuf::from("src/main.rs"),
905                size: 1000,
906                file_type: FileType::Rust,
907                priority: 1.5,
908                imports: Vec::new(),
909                imported_by: Vec::new(),
910                function_calls: Vec::new(),
911                type_references: Vec::new(),
912                exported_functions: Vec::new(),
913            },
914            FileInfo {
915                path: PathBuf::from("src/lib.rs"),
916                relative_path: PathBuf::from("src/lib.rs"),
917                size: 2000,
918                file_type: FileType::Rust,
919                priority: 1.2,
920                imports: Vec::new(),
921                imported_by: Vec::new(),
922                function_calls: Vec::new(),
923                type_references: Vec::new(),
924                exported_functions: Vec::new(),
925            },
926            FileInfo {
927                path: PathBuf::from("tests/test.rs"),
928                relative_path: PathBuf::from("tests/test.rs"),
929                size: 500,
930                file_type: FileType::Rust,
931                priority: 0.8,
932                imports: Vec::new(),
933                imported_by: Vec::new(),
934                function_calls: Vec::new(),
935                type_references: Vec::new(),
936                exported_functions: Vec::new(),
937            },
938        ];
939
940        let options = ContextOptions::default();
941        let tree = generate_file_tree(&files, &options);
942        assert!(tree.contains("src/"));
943        assert!(tree.contains("tests/"));
944        assert!(tree.contains("main.rs"));
945        assert!(tree.contains("lib.rs"));
946        assert!(tree.contains("test.rs"));
947    }
948
949    #[test]
950    fn test_context_options_from_config() {
951        use crate::cli::Config;
952        use tempfile::TempDir;
953
954        let temp_dir = TempDir::new().unwrap();
955        let config = Config {
956            paths: Some(vec![temp_dir.path().to_path_buf()]),
957            max_tokens: Some(100000),
958            ..Config::default()
959        };
960
961        let options = ContextOptions::from_config(&config).unwrap();
962        assert_eq!(options.max_tokens, Some(100000));
963        assert!(options.include_tree);
964        assert!(options.include_stats);
965        assert!(!options.group_by_type); // Default is false according to implementation
966    }
967
968    #[test]
969    fn test_generate_markdown_structure_headers() {
970        let files = vec![];
971
972        let options = ContextOptions {
973            max_tokens: None,
974            include_tree: true,
975            include_stats: true,
976            group_by_type: true,
977            sort_by_priority: true,
978            file_header_template: "## {path}".to_string(),
979            doc_header_template: "# Code Context".to_string(),
980            include_toc: true,
981            enhanced_context: false,
982            git_context: false,
983            git_context_depth: 3,
984        };
985
986        let cache = create_test_cache();
987        let markdown = generate_markdown(files, options, cache).unwrap();
988
989        // Check that main structure is present even with no files
990        assert!(markdown.contains("# Code Context"));
991        assert!(markdown.contains("## Statistics"));
992    }
993
994    #[test]
995    fn test_enhanced_tree_generation_with_metadata() {
996        use crate::core::walker::FileInfo;
997        use crate::utils::file_ext::FileType;
998        use std::path::PathBuf;
999
1000        let files = vec![
1001            FileInfo {
1002                path: PathBuf::from("src/main.rs"),
1003                relative_path: PathBuf::from("src/main.rs"),
1004                size: 145,
1005                file_type: FileType::Rust,
1006                priority: 1.5,
1007                imports: Vec::new(),
1008                imported_by: Vec::new(),
1009                function_calls: Vec::new(),
1010                type_references: Vec::new(),
1011                exported_functions: Vec::new(),
1012            },
1013            FileInfo {
1014                path: PathBuf::from("src/lib.rs"),
1015                relative_path: PathBuf::from("src/lib.rs"),
1016                size: 89,
1017                file_type: FileType::Rust,
1018                priority: 1.2,
1019                imports: Vec::new(),
1020                imported_by: Vec::new(),
1021                function_calls: Vec::new(),
1022                type_references: Vec::new(),
1023                exported_functions: Vec::new(),
1024            },
1025        ];
1026
1027        let options = ContextOptions {
1028            max_tokens: None,
1029            include_tree: true,
1030            include_stats: true,
1031            group_by_type: false,
1032            sort_by_priority: true,
1033            file_header_template: "## {path}".to_string(),
1034            doc_header_template: "# Code Context".to_string(),
1035            include_toc: true,
1036            enhanced_context: true,
1037            git_context: false,
1038            git_context_depth: 3,
1039        };
1040
1041        let cache = create_test_cache();
1042        let markdown = generate_markdown(files, options, cache).unwrap();
1043
1044        // Should include file sizes and types in tree
1045        assert!(markdown.contains("main.rs (145 B, Rust)"));
1046        assert!(markdown.contains("lib.rs (89 B, Rust)"));
1047    }
1048
1049    #[test]
1050    fn test_enhanced_file_headers_with_metadata() {
1051        use crate::core::walker::FileInfo;
1052        use crate::utils::file_ext::FileType;
1053        use std::path::PathBuf;
1054
1055        let files = vec![FileInfo {
1056            path: PathBuf::from("src/main.rs"),
1057            relative_path: PathBuf::from("src/main.rs"),
1058            size: 145,
1059            file_type: FileType::Rust,
1060            priority: 1.5,
1061            imports: Vec::new(),
1062            imported_by: Vec::new(),
1063            function_calls: Vec::new(),
1064            type_references: Vec::new(),
1065            exported_functions: Vec::new(),
1066        }];
1067
1068        let options = ContextOptions {
1069            max_tokens: None,
1070            include_tree: true,
1071            include_stats: true,
1072            group_by_type: false,
1073            sort_by_priority: true,
1074            file_header_template: "## {path}".to_string(),
1075            doc_header_template: "# Code Context".to_string(),
1076            include_toc: true,
1077            enhanced_context: true,
1078            git_context: false,
1079            git_context_depth: 3,
1080        };
1081
1082        let cache = create_test_cache();
1083        let markdown = generate_markdown(files, options, cache).unwrap();
1084
1085        // Should include metadata in file headers
1086        assert!(markdown.contains("## src/main.rs (145 B, Rust)"));
1087    }
1088
1089    #[test]
1090    fn test_basic_mode_unchanged() {
1091        use crate::core::walker::FileInfo;
1092        use crate::utils::file_ext::FileType;
1093        use std::path::PathBuf;
1094
1095        let files = vec![FileInfo {
1096            path: PathBuf::from("src/main.rs"),
1097            relative_path: PathBuf::from("src/main.rs"),
1098            size: 145,
1099            file_type: FileType::Rust,
1100            priority: 1.5,
1101            imports: Vec::new(),
1102            imported_by: Vec::new(),
1103            function_calls: Vec::new(),
1104            type_references: Vec::new(),
1105            exported_functions: Vec::new(),
1106        }];
1107
1108        let options = ContextOptions {
1109            max_tokens: None,
1110            include_tree: true,
1111            include_stats: true,
1112            group_by_type: false,
1113            sort_by_priority: true,
1114            file_header_template: "## {path}".to_string(),
1115            doc_header_template: "# Code Context".to_string(),
1116            include_toc: true,
1117            enhanced_context: false,
1118            git_context: false,
1119            git_context_depth: 3,
1120        };
1121
1122        let cache = create_test_cache();
1123        let markdown = generate_markdown(files, options, cache).unwrap();
1124
1125        // Should NOT include metadata - backward compatibility
1126        assert!(markdown.contains("## src/main.rs"));
1127        assert!(!markdown.contains("## src/main.rs (145 B, Rust)"));
1128        assert!(markdown.contains("main.rs") && !markdown.contains("main.rs (145 B, Rust)"));
1129    }
1130}