Skip to main content

opendev_repl/file_injector/
mod.rs

1//! File content injection for `@` mentions with structured XML tags.
2//!
3//! Mirrors `opendev/repl/file_content_injector.py`.
4//!
5//! Supports:
6//! - Text files: Injected with `<file_content>` tag
7//! - Large files: Truncated with head/tail in `<file_truncated>` tag
8//! - Directories: Tree listing in `<directory_listing>` tag
9//! - PDFs: Extracted text in `<pdf_content>` tag (placeholder)
10//! - Images: Multimodal blocks for vision models (base64 encoded)
11
12mod constants;
13mod processors;
14
15use constants::*;
16use opendev_runtime::gitignore::GitIgnoreParser;
17use regex::Regex;
18use std::collections::HashSet;
19use std::fs;
20use std::path::{Path, PathBuf};
21
22// ---------------------------------------------------------------------------
23// Public types
24// ---------------------------------------------------------------------------
25
26/// A base64-encoded image block for multimodal API calls.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct ImageBlock {
29    /// MIME type, e.g. `"image/png"`.
30    pub media_type: String,
31    /// Base64-encoded image data.
32    pub data: String,
33}
34
35/// Result of file content injection.
36#[derive(Debug, Clone, Default)]
37pub struct InjectionResult {
38    /// XML-tagged content for text injection.
39    pub text_content: String,
40    /// Multimodal image blocks for the API (base64 encoded).
41    pub image_blocks: Vec<ImageBlock>,
42    /// Error messages for failed references.
43    pub errors: Vec<String>,
44}
45
46// ---------------------------------------------------------------------------
47// FileContentInjector
48// ---------------------------------------------------------------------------
49
50/// Handles `@` mention file content injection with structured XML tags.
51pub struct FileContentInjector {
52    /// Working directory for resolving relative paths.
53    working_dir: PathBuf,
54    /// GitIgnore parser for filtering directory listings.
55    pub(super) gitignore: GitIgnoreParser,
56}
57
58impl FileContentInjector {
59    /// Create a new injector rooted at `working_dir`.
60    pub fn new(working_dir: PathBuf) -> Self {
61        let working_dir = working_dir
62            .canonicalize()
63            .unwrap_or_else(|_| working_dir.clone());
64        let gitignore = GitIgnoreParser::new(&working_dir);
65        Self {
66            working_dir,
67            gitignore,
68        }
69    }
70
71    // -- public API ---------------------------------------------------------
72
73    /// Extract `@` references from `query` and inject file contents.
74    pub fn inject_content(&self, query: &str) -> InjectionResult {
75        let refs = self.extract_refs(query);
76
77        if refs.is_empty() {
78            return InjectionResult::default();
79        }
80
81        let mut text_parts: Vec<String> = Vec::new();
82        let mut image_blocks: Vec<ImageBlock> = Vec::new();
83        let mut errors: Vec<String> = Vec::new();
84
85        for (ref_str, path) in &refs {
86            match self.process_ref(ref_str, path) {
87                Ok((text, opt_img)) => {
88                    text_parts.push(text);
89                    if let Some(img) = opt_img {
90                        image_blocks.push(img);
91                    }
92                }
93                Err(e) => {
94                    text_parts.push(format!(
95                        "<file_error path=\"{}\" reason=\"{}\" />",
96                        ref_str, e
97                    ));
98                    errors.push(format!("{}: {}", ref_str, e));
99                }
100            }
101        }
102
103        InjectionResult {
104            text_content: text_parts.join("\n\n"),
105            image_blocks,
106            errors,
107        }
108    }
109
110    /// Extract file references from a query string.
111    ///
112    /// Supports:
113    /// - Quoted paths: `@"path with spaces/file.py"`
114    /// - Unquoted paths: `@main.py`, `@src/utils.py`
115    ///
116    /// Excludes email addresses like `user@example.com`.
117    pub fn extract_refs(&self, query: &str) -> Vec<(String, PathBuf)> {
118        let mut refs: Vec<String> = Vec::new();
119        let mut seen = HashSet::new();
120
121        // Pattern 1: Quoted paths  @"path with spaces/file.py"
122        let quoted_re = Regex::new(r#"@"([^"]+)""#).expect("valid regex");
123        for cap in quoted_re.captures_iter(query) {
124            let r = cap[1].to_string();
125            if seen.insert(r.clone()) {
126                refs.push(r);
127            }
128        }
129
130        // Pattern 2: Unquoted paths
131        // Match @ followed by path-like chars, only when @ is at start-of-string,
132        // after whitespace, or after non-word character. This avoids emails.
133        let unquoted_re = Regex::new(r"(?:^|\s|[^\w])@([a-zA-Z0-9_./\-]+)").expect("valid regex");
134        for cap in unquoted_re.captures_iter(query) {
135            let r = cap[1].to_string();
136            if seen.insert(r.clone()) {
137                refs.push(r);
138            }
139        }
140
141        refs.into_iter()
142            .map(|r| {
143                let p = self.resolve_path(&r);
144                (r, p)
145            })
146            .collect()
147    }
148
149    /// Resolve a reference string to an absolute path.
150    pub fn resolve_path(&self, ref_str: &str) -> PathBuf {
151        let path = PathBuf::from(ref_str);
152        let resolved = if path.is_absolute() {
153            path
154        } else {
155            self.working_dir.join(path)
156        };
157        // Canonicalize if the path exists; otherwise keep as-is.
158        resolved.canonicalize().unwrap_or(resolved)
159    }
160
161    /// Check whether a path is a text file suitable for injection.
162    pub fn is_text_file(path: &Path) -> bool {
163        let ext = ext_lower(path);
164        let name = path
165            .file_name()
166            .map(|n| n.to_string_lossy().to_string())
167            .unwrap_or_default();
168
169        if SAFE_TEXT_EXTENSIONS.contains(&ext.as_str()) || SAFE_FILENAMES.contains(&name.as_str()) {
170            return true;
171        }
172
173        if BINARY_EXTENSIONS.contains(&ext.as_str()) {
174            return false;
175        }
176
177        Self::detect_text_file(path)
178    }
179
180    /// Heuristic text-file detection: read 8 KB sample, reject null bytes,
181    /// accept valid UTF-8, fallback to printability ratio.
182    pub fn detect_text_file(path: &Path) -> bool {
183        let sample = match fs::read(path) {
184            Ok(data) => {
185                if data.len() > 8192 {
186                    data[..8192].to_vec()
187                } else {
188                    data
189                }
190            }
191            Err(_) => return false,
192        };
193
194        if sample.is_empty() {
195            return true; // empty file counts as text
196        }
197
198        // Null bytes are a strong binary indicator.
199        if sample.contains(&0u8) {
200            return false;
201        }
202
203        // Valid UTF-8 ⇒ text
204        if std::str::from_utf8(&sample).is_ok() {
205            return true;
206        }
207
208        // Fallback: check printability ratio on latin-1 interpretation.
209        let printable = sample
210            .iter()
211            .filter(|&&b| {
212                b.is_ascii_graphic() || b.is_ascii_whitespace() || (0xA0..=0xFF).contains(&b)
213            })
214            .count();
215        let ratio = printable as f64 / sample.len() as f64;
216        ratio > 0.85
217    }
218
219    /// Get the syntax-highlighting language for a path.
220    pub fn get_language(path: &Path) -> &'static str {
221        lang_for_ext(&ext_lower(path))
222    }
223
224    /// Format a byte size as a human-readable string.
225    pub fn format_size(size: u64) -> String {
226        if size < 1024 {
227            format!("{}B", size)
228        } else if size < 1024 * 1024 {
229            format!("{:.1}KB", size as f64 / 1024.0)
230        } else {
231            format!("{:.1}MB", size as f64 / (1024.0 * 1024.0))
232        }
233    }
234}
235
236// ---------------------------------------------------------------------------
237// Tests
238// ---------------------------------------------------------------------------
239
240#[cfg(test)]
241mod tests;