Skip to main content

ctx/
output.rs

1//! Context generation from discovered files.
2//!
3//! Turns a set of [`FileEntry`] values (from [`crate::walker`]) into final
4//! formatted output: [`generate_context`] builds the full result in memory
5//! and returns a [`ContextResult`] with size/count stats, while
6//! [`stream_context`] writes files to a writer as they are read, for
7//! pipe-friendly streaming.
8
9use std::fs;
10use std::io::{self, Write};
11use std::path::Path;
12
13use crate::formatter::{get_formatter, OutputFormat};
14use crate::tree::generate_tree;
15use crate::walker::FileEntry;
16
17/// Result of context generation.
18pub struct ContextResult {
19    pub content: String,
20    pub file_count: usize,
21    pub total_size: u64,
22    /// Byte length of the generated output (used for token estimation).
23    pub output_bytes: usize,
24}
25
26/// Generate context output from file entries.
27pub fn generate_context(
28    root: &Path,
29    entries: &[FileEntry],
30    format: OutputFormat,
31    include_tree: bool,
32    show_sizes: bool,
33) -> io::Result<ContextResult> {
34    let formatter = get_formatter(format);
35
36    // Determine root name for tree
37    let root_name = root
38        .file_name()
39        .map(|s| s.to_string_lossy().to_string())
40        .unwrap_or_else(|| "project".to_string());
41
42    // Generate tree if requested
43    let tree_block = if include_tree {
44        let tree = generate_tree(&root_name, entries, show_sizes);
45        Some(formatter.format_tree(&tree))
46    } else {
47        None
48    };
49
50    // Generate file blocks
51    let mut file_blocks = Vec::new();
52    let mut total_size = 0u64;
53    let mut processed_count = 0usize;
54
55    for entry in entries {
56        match read_file_content(&entry.absolute_path) {
57            Ok(content) => {
58                let block = formatter.format_file(entry, &content);
59                file_blocks.push(block);
60                total_size += entry.size;
61                processed_count += 1;
62            }
63            Err(e) => {
64                eprintln!(
65                    "Warning: could not read {}: {}",
66                    entry.relative_path.display(),
67                    e
68                );
69            }
70        }
71    }
72
73    // Join file blocks
74    let separator = get_separator(format);
75    let files_block = file_blocks.join(&separator);
76
77    // Wrap everything
78    let content = formatter.wrap(tree_block.as_deref(), &files_block);
79
80    Ok(ContextResult {
81        output_bytes: content.len(),
82        content,
83        file_count: processed_count,
84        total_size,
85    })
86}
87
88/// Stream context output, printing each file as it's processed.
89pub fn stream_context(
90    root: &Path,
91    entries: &[FileEntry],
92    format: OutputFormat,
93    include_tree: bool,
94    show_sizes: bool,
95) -> io::Result<ContextResult> {
96    let formatter = get_formatter(format);
97    let mut stdout = io::stdout().lock();
98
99    // Determine root name for tree
100    let root_name = root
101        .file_name()
102        .map(|s| s.to_string_lossy().to_string())
103        .unwrap_or_else(|| "project".to_string());
104
105    // Generate tree if requested
106    let tree_block = if include_tree {
107        let tree = generate_tree(&root_name, entries, show_sizes);
108        Some(formatter.format_tree(&tree))
109    } else {
110        None
111    };
112
113    // Print opening (skip if empty to avoid blank lines in NDJSON)
114    let start = formatter.stream_start(tree_block.as_deref());
115    let mut output_bytes = 0usize;
116    if !start.is_empty() {
117        writeln!(stdout, "{}", start)?;
118        output_bytes += start.len() + 1;
119    }
120
121    // Stream file blocks
122    let mut total_size = 0u64;
123    let mut processed_count = 0usize;
124    let separator = formatter.separator();
125
126    for (i, entry) in entries.iter().enumerate() {
127        match read_file_content(&entry.absolute_path) {
128            Ok(content) => {
129                let block = formatter.format_file(entry, &content);
130                if i > 0 {
131                    write!(stdout, "{}", separator)?;
132                    output_bytes += separator.len();
133                }
134                write!(stdout, "{}", block)?;
135                output_bytes += block.len();
136                stdout.flush()?;
137                total_size += entry.size;
138                processed_count += 1;
139            }
140            Err(e) => {
141                eprintln!(
142                    "Warning: could not read {}: {}",
143                    entry.relative_path.display(),
144                    e
145                );
146            }
147        }
148    }
149
150    // Print closing
151    let end = formatter.stream_end();
152    if !end.is_empty() {
153        writeln!(stdout, "\n{}", end)?;
154        output_bytes += end.len() + 2;
155    } else {
156        writeln!(stdout)?;
157        output_bytes += 1;
158    }
159
160    Ok(ContextResult {
161        content: String::new(), // Not used in streaming mode
162        file_count: processed_count,
163        total_size,
164        output_bytes,
165    })
166}
167
168/// Read file content, handling encoding gracefully.
169fn read_file_content(path: &Path) -> io::Result<String> {
170    let bytes = fs::read(path)?;
171
172    // Try UTF-8 first
173    match String::from_utf8(bytes.clone()) {
174        Ok(s) => Ok(s),
175        Err(_) => {
176            // Fall back to lossy conversion
177            Ok(String::from_utf8_lossy(&bytes).into_owned())
178        }
179    }
180}
181
182/// Get the separator between file blocks based on format.
183fn get_separator(format: OutputFormat) -> String {
184    match format {
185        OutputFormat::Xml => "\n".to_string(),
186        OutputFormat::Markdown => "\n\n".to_string(),
187        OutputFormat::Plain => "\n".to_string(),
188        OutputFormat::Json => ",".to_string(), // Comma for non-streaming JSON array
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195
196    #[test]
197    fn test_separator_xml() {
198        assert_eq!(get_separator(OutputFormat::Xml), "\n");
199    }
200
201    #[test]
202    fn test_separator_markdown() {
203        assert_eq!(get_separator(OutputFormat::Markdown), "\n\n");
204    }
205}