Skip to main content

opendev_repl/file_injector/
processors.rs

1//! File type processors: text, large files, directories, PDFs, and images.
2
3use base64::Engine as _;
4use std::fs;
5use std::path::Path;
6
7use super::constants::*;
8use super::{FileContentInjector, ImageBlock};
9
10impl FileContentInjector {
11    /// Process a single `@` reference, dispatching on file type.
12    pub(super) fn process_ref(
13        &self,
14        ref_str: &str,
15        path: &Path,
16    ) -> Result<(String, Option<ImageBlock>), String> {
17        if !path.exists() {
18            return Err("File not found".to_string());
19        }
20
21        if path.is_dir() {
22            return Ok((self.process_directory(path, ref_str), None));
23        }
24
25        let ext = ext_lower(path);
26
27        if ext == ".pdf" {
28            return Ok((Self::process_pdf(path, ref_str), None));
29        }
30
31        if IMAGE_EXTENSIONS.contains(&ext.as_str()) {
32            let (tag, block) = Self::process_image(path, ref_str);
33            return Ok((tag, block));
34        }
35
36        if Self::is_text_file(path) {
37            return Ok((Self::process_text_file(path, ref_str)?, None));
38        }
39
40        Err("Unsupported file type".to_string())
41    }
42
43    /// Process a text file: read content, optionally truncate.
44    pub fn process_text_file(path: &Path, ref_str: &str) -> Result<String, String> {
45        let content = fs::read_to_string(path).map_err(|e| format!("Read error: {}", e))?;
46        let lines: Vec<&str> = content.lines().collect();
47        let line_count = lines.len();
48        let size = content.len() as u64;
49
50        if size > MAX_FILE_SIZE || line_count > MAX_LINES {
51            return Ok(Self::process_large_file(path, ref_str, &content, &lines));
52        }
53
54        let language = Self::get_language(path);
55        let lang_attr = if language.is_empty() {
56            String::new()
57        } else {
58            format!(" language=\"{}\"", language)
59        };
60
61        let abs_path = path.to_string_lossy();
62
63        Ok(format!(
64            "<file_content path=\"{}\" absolute_path=\"{}\" exists=\"true\"{}>\n{}\n</file_content>",
65            ref_str, abs_path, lang_attr, content
66        ))
67    }
68
69    /// Process a large file with head/tail truncation.
70    pub fn process_large_file(
71        path: &Path,
72        ref_str: &str,
73        _content: &str,
74        lines: &[&str],
75    ) -> String {
76        let total_lines = lines.len();
77        let head: Vec<&str> = lines.iter().take(HEAD_LINES).copied().collect();
78        let tail_start = total_lines.saturating_sub(TAIL_LINES);
79        let tail: Vec<&str> = lines.iter().skip(tail_start).copied().collect();
80        let omitted = if total_lines > HEAD_LINES + TAIL_LINES {
81            total_lines - HEAD_LINES - TAIL_LINES
82        } else {
83            0
84        };
85
86        let language = Self::get_language(path);
87        let lang_attr = if language.is_empty() {
88            String::new()
89        } else {
90            format!(" language=\"{}\"", language)
91        };
92
93        let abs_path = path.to_string_lossy();
94        let head_content = head.join("\n");
95        let tail_content = tail.join("\n");
96
97        format!(
98            "<file_truncated path=\"{}\" absolute_path=\"{}\" exists=\"true\" total_lines=\"{}\"{}>\n\
99             === HEAD (lines 1-{}) ===\n\
100             {}\n\n\
101             === TRUNCATED ({} lines omitted) ===\n\n\
102             === TAIL (lines {}-{}) ===\n\
103             {}\n\
104             </file_truncated>",
105            ref_str,
106            abs_path,
107            total_lines,
108            lang_attr,
109            HEAD_LINES,
110            head_content,
111            omitted,
112            total_lines - TAIL_LINES + 1,
113            total_lines,
114            tail_content,
115        )
116    }
117
118    /// Process a directory: recursive tree listing.
119    pub fn process_directory(&self, path: &Path, ref_str: &str) -> String {
120        let tree = self.build_tree(path, "", 0);
121        let item_count = tree.iter().filter(|l| !l.ends_with("...")).count();
122        let dir_name = path
123            .file_name()
124            .map(|n| n.to_string_lossy().to_string())
125            .unwrap_or_else(|| path.to_string_lossy().to_string());
126
127        format!(
128            "<directory_listing path=\"{}\" count=\"{}\">\n{}/\n{}\n</directory_listing>",
129            ref_str,
130            item_count,
131            dir_name,
132            tree.join("\n"),
133        )
134    }
135
136    /// Recursively build a tree listing for a directory.
137    pub(super) fn build_tree(&self, dir_path: &Path, prefix: &str, depth: usize) -> Vec<String> {
138        if depth > MAX_DIR_DEPTH {
139            return vec![format!("{}...", prefix)]; // mirrors Python "└── ..."
140        }
141
142        let entries = match fs::read_dir(dir_path) {
143            Ok(rd) => rd,
144            Err(_) => return vec![format!("{}[permission denied]", prefix)],
145        };
146
147        let mut items: Vec<std::path::PathBuf> = entries
148            .filter_map(|e| {
149                e.ok()
150                    .map(|e| e.path().canonicalize().unwrap_or_else(|_| e.path()))
151            })
152            .collect();
153
154        // Sort: directories first, then by lowercase name
155        items.sort_by(|a, b| {
156            let a_dir = a.is_dir();
157            let b_dir = b.is_dir();
158            match (a_dir, b_dir) {
159                (true, false) => std::cmp::Ordering::Less,
160                (false, true) => std::cmp::Ordering::Greater,
161                _ => {
162                    let a_name = a
163                        .file_name()
164                        .unwrap_or_default()
165                        .to_string_lossy()
166                        .to_lowercase();
167                    let b_name = b
168                        .file_name()
169                        .unwrap_or_default()
170                        .to_string_lossy()
171                        .to_lowercase();
172                    a_name.cmp(&b_name)
173                }
174            }
175        });
176
177        // Filter ignored entries using GitIgnoreParser (respects .gitignore + always-ignored dirs)
178        let items: Vec<std::path::PathBuf> = items
179            .into_iter()
180            .filter(|p| !self.gitignore.is_ignored(p))
181            .take(MAX_DIR_ITEMS)
182            .collect();
183
184        let mut lines: Vec<String> = Vec::new();
185        let count = items.len();
186
187        for (i, item) in items.iter().enumerate() {
188            let is_last = i == count - 1;
189            let connector = if is_last {
190                "\u{2514}\u{2500}\u{2500} "
191            } else {
192                "\u{251C}\u{2500}\u{2500} "
193            };
194            let new_prefix = if is_last {
195                format!("{}    ", prefix)
196            } else {
197                format!("{}\u{2502}   ", prefix)
198            };
199
200            let name = item
201                .file_name()
202                .unwrap_or_default()
203                .to_string_lossy()
204                .to_string();
205
206            if item.is_dir() {
207                lines.push(format!("{}{}{}/", prefix, connector, name));
208                lines.extend(self.build_tree(item, &new_prefix, depth + 1));
209            } else {
210                let size_str = item
211                    .metadata()
212                    .map(|m| format!(" ({})", Self::format_size(m.len())))
213                    .unwrap_or_default();
214                lines.push(format!("{}{}{}{}", prefix, connector, name, size_str));
215            }
216        }
217
218        lines
219    }
220
221    /// Process a PDF file (placeholder -- real extraction needs an external crate).
222    pub fn process_pdf(path: &Path, ref_str: &str) -> String {
223        // NOTE: Full PDF text extraction requires a crate like `lopdf` or `pdf-extract`.
224        // For now we emit a placeholder tag.
225        let abs_path = path.to_string_lossy();
226        format!(
227            "<pdf_content path=\"{}\" absolute_path=\"{}\" pages=\"?\">\n\
228             [PDF text extraction not yet implemented. Add a PDF crate for full support.]\n\
229             </pdf_content>",
230            ref_str, abs_path,
231        )
232    }
233
234    /// Process an image: base64 encode and emit an XML tag plus an [`ImageBlock`].
235    pub fn process_image(path: &Path, ref_str: &str) -> (String, Option<ImageBlock>) {
236        let data = match fs::read(path) {
237            Ok(d) => d,
238            Err(e) => {
239                return (
240                    format!(
241                        "<file_error path=\"{}\" reason=\"Failed to read image file: {}\" />",
242                        ref_str, e
243                    ),
244                    None,
245                );
246            }
247        };
248
249        let ext = ext_lower(path);
250        let mime_type = match ext.as_str() {
251            ".png" => "image/png",
252            ".jpg" | ".jpeg" => "image/jpeg",
253            ".gif" => "image/gif",
254            ".webp" => "image/webp",
255            ".bmp" => "image/bmp",
256            _ => "image/png",
257        };
258
259        let b64 = base64::engine::general_purpose::STANDARD.encode(&data);
260
261        let tag = format!(
262            "<image path=\"{}\" type=\"{}\">\n[Image attached as multimodal content]\n</image>",
263            ref_str, mime_type,
264        );
265
266        let block = ImageBlock {
267            media_type: mime_type.to_string(),
268            data: b64,
269        };
270
271        (tag, Some(block))
272    }
273}