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;