1use anyhow::Result;
8use regex::Regex;
9use std::path::{Path, PathBuf};
10use std::sync::LazyLock;
11
12use crate::llm::provider::{ContentPart, MessageContent};
13use crate::utils::file_input::read_input_file_any_path;
14use crate::utils::image_processing::{read_image_file_any_path, read_image_from_url};
15use vtcode_commons::fs::{
16 is_windows_absolute_path, trim_trailing_image_path_str, unescape_whitespace,
17};
18use vtcode_commons::paths::is_safe_relative_path;
19
20#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
21pub struct AtPatternOptions {
22 pub allow_local_non_image_file_inputs: bool,
23 pub allow_remote_non_image_file_inputs: bool,
24}
25
26pub async fn parse_at_patterns(input: &str, base_dir: &Path) -> Result<MessageContent> {
41 parse_at_patterns_with_options(input, base_dir, AtPatternOptions::default()).await
42}
43
44pub async fn parse_at_patterns_with_options(
46 input: &str,
47 base_dir: &Path,
48 options: AtPatternOptions,
49) -> Result<MessageContent> {
50 let at_matches = vtcode_commons::at_pattern::find_at_patterns(input);
51 let protected_ranges: Vec<(usize, usize)> =
52 at_matches.iter().map(|m| (m.start, m.end)).collect();
53 let raw_matches = find_raw_image_path_matches(input, &protected_ranges);
54 let data_url_matches = find_data_url_matches(input, &protected_ranges);
55
56 if at_matches.is_empty() && raw_matches.is_empty() && data_url_matches.is_empty() {
57 return Ok(MessageContent::text(input.to_string()));
58 }
59
60 let mut matches: Vec<PathMatch> = Vec::new();
61 for m in at_matches {
62 matches.push(PathMatch::At {
63 start: m.start,
64 end: m.end,
65 full_match: m.full_match.to_string(),
66 path: m.path.to_string(),
67 });
68 }
69 for m in raw_matches {
70 matches.push(PathMatch::Raw {
71 start: m.start,
72 end: m.end,
73 raw: m.raw,
74 });
75 }
76 for m in data_url_matches {
77 matches.push(PathMatch::DataUrl {
78 start: m.start,
79 end: m.end,
80 mime_type: m.mime_type,
81 data: m.data,
82 });
83 }
84 matches.sort_by_key(|m| m.start());
85
86 let mut parts = Vec::with_capacity(matches.len());
87 let mut last_end = 0;
88
89 for m in matches {
90 let match_start = m.start();
91 let match_end = m.end();
92
93 if match_start < last_end {
94 continue;
95 }
96
97 if match_start > last_end {
98 let text_before = &input[last_end..match_start];
99 if !text_before.trim().is_empty() {
100 parts.push(ContentPart::text(text_before.to_string()));
101 }
102 }
103
104 match m {
105 PathMatch::At {
106 full_match, path, ..
107 } => {
108 let is_url = path.starts_with("http://") || path.starts_with("https://");
109 if is_url {
110 if looks_like_image_url(&path) {
111 match read_image_from_url(&path).await {
112 Ok(image_data) => {
113 parts.push(ContentPart::Image {
114 data: image_data.base64_data,
115 mime_type: image_data.mime_type,
116 content_type: "image".to_owned(),
117 });
118 }
119 Err(e) => {
120 tracing::warn!("Failed to load image from URL {}: {}", path, e);
121 parts.push(ContentPart::text(full_match));
122 }
123 }
124 } else if options.allow_remote_non_image_file_inputs {
125 parts.push(ContentPart::file_from_url(path));
126 } else {
127 parts.push(ContentPart::text(full_match));
128 }
129 } else if let Some(file_path) = resolve_image_path(&path, base_dir) {
130 if crate::utils::image_processing::has_supported_image_extension(&file_path) {
131 match read_image_file_any_path(&file_path).await {
132 Ok(image_data) => {
133 parts.push(ContentPart::Image {
134 data: image_data.base64_data,
135 mime_type: image_data.mime_type,
136 content_type: "image".to_owned(),
137 });
138 }
139 Err(_) => {
140 parts.push(ContentPart::text(full_match));
141 }
142 }
143 } else if options.allow_local_non_image_file_inputs {
144 match read_input_file_any_path(&file_path).await {
145 Ok(file_data) => {
146 parts.push(ContentPart::file_from_data(
147 file_data.filename,
148 file_data.base64_data,
149 ));
150 }
151 Err(_) => {
152 parts.push(ContentPart::text(full_match));
153 }
154 }
155 } else {
156 parts.push(ContentPart::text(full_match));
157 }
158 } else {
159 parts.push(ContentPart::text(full_match));
160 }
161 }
162 PathMatch::Raw { raw, .. } => {
163 if let Some(image_path) = resolve_image_path(&raw, base_dir) {
164 if !image_path.exists() {
165 parts.push(ContentPart::text(raw));
166 continue;
167 }
168 match read_image_file_any_path(&image_path).await {
169 Ok(image_data) => {
170 parts.push(ContentPart::Image {
171 data: image_data.base64_data,
172 mime_type: image_data.mime_type,
173 content_type: "image".to_owned(),
174 });
175 }
176 Err(_) => {
177 parts.push(ContentPart::text(raw));
178 }
179 }
180 } else {
181 parts.push(ContentPart::text(raw));
182 }
183 }
184 PathMatch::DataUrl {
185 mime_type, data, ..
186 } => {
187 parts.push(ContentPart::Image {
188 data,
189 mime_type,
190 content_type: "image".to_owned(),
191 });
192 }
193 }
194
195 last_end = match_end;
196 }
197
198 if last_end < input.len() {
199 let text_after = &input[last_end..];
200 if !text_after.trim().is_empty() {
201 parts.push(ContentPart::text(text_after.to_string()));
202 }
203 }
204
205 if parts.is_empty() {
206 return Ok(MessageContent::text(input.to_string()));
207 }
208
209 if parts
210 .iter()
211 .all(|part| matches!(part, ContentPart::Text { .. }))
212 {
213 let text = parts
214 .iter()
215 .filter_map(ContentPart::as_text)
216 .collect::<String>();
217 return Ok(MessageContent::text(text));
218 }
219
220 Ok(MessageContent::parts(parts))
221}
222
223#[derive(Debug)]
224struct RawPathMatch {
225 start: usize,
226 end: usize,
227 raw: String,
228}
229
230#[derive(Debug)]
231struct DataUrlMatch {
232 start: usize,
233 end: usize,
234 mime_type: String,
235 data: String,
236}
237
238#[derive(Debug)]
239enum PathMatch {
240 At {
241 start: usize,
242 end: usize,
243 full_match: String,
244 path: String,
245 },
246 Raw {
247 start: usize,
248 end: usize,
249 raw: String,
250 },
251 DataUrl {
252 start: usize,
253 end: usize,
254 mime_type: String,
255 data: String,
256 },
257}
258
259impl PathMatch {
260 fn start(&self) -> usize {
261 match self {
262 PathMatch::At { start, .. } | PathMatch::Raw { start, .. } => *start,
263 PathMatch::DataUrl { start, .. } => *start,
264 }
265 }
266
267 fn end(&self) -> usize {
268 match self {
269 PathMatch::At { end, .. } | PathMatch::Raw { end, .. } => *end,
270 PathMatch::DataUrl { end, .. } => *end,
271 }
272 }
273}
274
275fn find_raw_image_path_matches(
276 input: &str,
277 protected_ranges: &[(usize, usize)],
278) -> Vec<RawPathMatch> {
279 let mut matches = Vec::new();
280 let mut quote_ranges = Vec::new();
281 let mut active_quote: Option<(char, usize)> = None;
282
283 for (idx, ch) in input.char_indices() {
284 match active_quote {
285 Some((quote, start)) => {
286 if ch == quote {
287 let end = idx + ch.len_utf8();
288 quote_ranges.push((start, end));
289 let inner_start = start + quote.len_utf8();
290 let inner_end = idx;
291 if inner_end > inner_start
292 && !overlaps_range(inner_start, inner_end, protected_ranges)
293 {
294 let inner = &input[inner_start..inner_end];
295 if looks_like_image_path(inner) {
296 matches.push(RawPathMatch {
297 start: inner_start,
298 end: inner_end,
299 raw: inner.to_string(),
300 });
301 }
302 }
303 active_quote = None;
304 }
305 }
306 None => {
307 if ch == '"' || ch == '\'' {
308 active_quote = Some((ch, idx));
309 }
310 }
311 }
312 }
313
314 add_spacey_absolute_path_matches(input, protected_ranges, "e_ranges, &mut matches);
315
316 let mut quote_idx = 0usize;
317 let mut token_start: Option<usize> = None;
318 let mut pos = 0usize;
319 while pos < input.len() {
320 if let Some((range_start, range_end)) = quote_ranges.get(quote_idx).copied() {
321 if pos >= range_end {
322 quote_idx += 1;
323 continue;
324 }
325 if pos >= range_start {
326 if let Some(start) = token_start.take() {
327 collect_unquoted_match(
328 input,
329 start,
330 range_start,
331 protected_ranges,
332 &mut matches,
333 );
334 }
335 pos = range_end;
336 continue;
337 }
338 }
339
340 let Some(ch) = input[pos..].chars().next() else {
341 break;
342 };
343 if ch.is_ascii_whitespace() {
344 if let Some(start) = token_start.take() {
345 collect_unquoted_match(input, start, pos, protected_ranges, &mut matches);
346 }
347 pos += ch.len_utf8();
348 continue;
349 }
350
351 if ch == '\\'
352 && let Some(next) = input[pos + ch.len_utf8()..].chars().next()
353 && next.is_ascii_whitespace()
354 {
355 if token_start.is_none() {
356 token_start = Some(pos);
357 }
358 pos += ch.len_utf8() + next.len_utf8();
359 continue;
360 }
361
362 if token_start.is_none() {
363 token_start = Some(pos);
364 }
365 pos += ch.len_utf8();
366 }
367
368 if let Some(start) = token_start.take() {
369 collect_unquoted_match(input, start, input.len(), protected_ranges, &mut matches);
370 }
371
372 matches
373}
374
375static DATA_IMAGE_URL_REGEX: LazyLock<Regex> = LazyLock::new(|| {
376 match Regex::new(
377 r#"(?ix)
378 (?:^|[\s\(\[\{<\"'`])
379 (
380 data:image/[a-z0-9+\-\.]+;base64,[a-z0-9+/=]+
381 )"#,
382 ) {
383 Ok(regex) => regex,
384 Err(error) => panic!("Failed to compile data image regex: {error}"),
385 }
386});
387
388fn find_data_url_matches(input: &str, protected_ranges: &[(usize, usize)]) -> Vec<DataUrlMatch> {
389 DATA_IMAGE_URL_REGEX
390 .captures_iter(input)
391 .filter_map(|capture| {
392 let data_match = capture.get(1)?;
393 let start = data_match.start();
394 let end = data_match.end();
395 if overlaps_range(start, end, protected_ranges) {
396 return None;
397 }
398 let raw = data_match.as_str();
399 let (mime_type, data) = parse_data_image_url(raw)?;
400 Some(DataUrlMatch {
401 start,
402 end,
403 mime_type,
404 data,
405 })
406 })
407 .collect()
408}
409
410static ABSOLUTE_IMAGE_PATH_REGEX: LazyLock<Regex> = LazyLock::new(|| {
411 match Regex::new(
412 r#"(?ix)
413 (?:^|[\s\(\[\{<\"'`])
414 (
415 (?:file://)?(?:~/|[A-Za-z]:[\\/]|/)
416 [^\n]+?
417 \.(?:png|jpe?g|gif|bmp|webp|tiff?|svg)
418 )"#,
419 ) {
420 Ok(regex) => regex,
421 Err(error) => panic!("Failed to compile absolute image path regex: {error}"),
422 }
423});
424
425fn add_spacey_absolute_path_matches(
426 input: &str,
427 protected_ranges: &[(usize, usize)],
428 quote_ranges: &[(usize, usize)],
429 matches: &mut Vec<RawPathMatch>,
430) {
431 for capture in ABSOLUTE_IMAGE_PATH_REGEX.captures_iter(input) {
432 let Some(path_match) = capture.get(1) else {
433 continue;
434 };
435 let start = path_match.start();
436 let full_end = path_match.end();
437 if overlaps_range(start, full_end, protected_ranges) {
438 continue;
439 }
440 if overlaps_range(start, full_end, quote_ranges) {
441 continue;
442 }
443 if matches
444 .iter()
445 .any(|existing| ranges_overlap(start, full_end, existing.start, existing.end))
446 {
447 continue;
448 }
449
450 let raw = path_match.as_str();
453 let trimmed = trim_trailing_image_path_str(raw);
454 let end = start + trimmed.len();
455
456 matches.push(RawPathMatch {
457 start,
458 end,
459 raw: trimmed.to_string(),
460 });
461 }
462}
463
464fn ranges_overlap(start: usize, end: usize, other_start: usize, other_end: usize) -> bool {
465 start < other_end && end > other_start
466}
467
468fn collect_unquoted_match(
469 input: &str,
470 start: usize,
471 end: usize,
472 protected_ranges: &[(usize, usize)],
473 matches: &mut Vec<RawPathMatch>,
474) {
475 let Some((trim_start, trim_end)) = trim_token_bounds(input, start, end) else {
476 return;
477 };
478 if overlaps_range(trim_start, trim_end, protected_ranges) {
479 return;
480 }
481
482 let token = &input[trim_start..trim_end];
483 if token.starts_with('@') {
484 return;
485 }
486 if looks_like_image_path(token) {
487 matches.push(RawPathMatch {
488 start: trim_start,
489 end: trim_end,
490 raw: token.to_string(),
491 });
492 }
493}
494
495fn trim_token_bounds(input: &str, start: usize, end: usize) -> Option<(usize, usize)> {
496 if start >= end || end > input.len() {
497 return None;
498 }
499 let slice = &input[start..end];
500 let mut first_non_punct: Option<usize> = None;
501 let mut last_non_punct_end: Option<usize> = None;
502
503 for (idx, ch) in slice.char_indices() {
504 if first_non_punct.is_none() && !is_leading_punct(ch) {
505 first_non_punct = Some(idx);
506 }
507 if first_non_punct.is_some() && !is_trailing_punct(ch) {
508 last_non_punct_end = Some(idx + ch.len_utf8());
509 }
510 }
511
512 let first = first_non_punct?;
513 let last_end = last_non_punct_end?;
514
515 if first >= last_end {
516 return None;
517 }
518
519 Some((start + first, start + last_end))
520}
521
522fn is_leading_punct(ch: char) -> bool {
523 matches!(ch, '(' | '[' | '{' | '<' | '"' | '\'' | '`')
524}
525
526fn is_trailing_punct(ch: char) -> bool {
527 matches!(
528 ch,
529 ')' | ']' | '}' | '>' | '"' | '\'' | '`' | ',' | '.' | ';' | ':' | '!' | '?'
530 )
531}
532
533fn looks_like_image_path(token: &str) -> bool {
534 let trimmed = token.trim();
535 if trimmed.is_empty() {
536 return false;
537 }
538 if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
539 return false;
540 }
541
542 let unescaped = unescape_whitespace(trimmed);
543 let mut candidate = unescaped.as_str();
544 if let Some(rest) = candidate.strip_prefix("file://") {
545 candidate = rest;
546 }
547 if let Some(rest) = candidate.strip_prefix("~/") {
548 candidate = rest;
549 }
550
551 if candidate.is_empty() {
552 return false;
553 }
554
555 crate::utils::image_processing::has_supported_image_extension(Path::new(candidate))
556}
557
558fn parse_data_image_url(raw: &str) -> Option<(String, String)> {
559 let trimmed = raw.trim_matches(|ch: char| matches!(ch, '"' | '\''));
560 let rest = trimmed.strip_prefix("data:")?;
561 let (mime_type, data) = rest.split_once(";base64,")?;
562 if !mime_type.starts_with("image/") {
563 return None;
564 }
565 let data = data.trim();
566 if data.is_empty() {
567 return None;
568 }
569 Some((mime_type.to_string(), data.to_string()))
570}
571
572fn looks_like_image_url(url: &str) -> bool {
573 let without_query = url.split(['?', '#']).next().map(str::trim).unwrap_or(url);
574 crate::utils::image_processing::has_supported_image_extension(Path::new(without_query))
575}
576
577fn resolve_image_path(token: &str, base_dir: &Path) -> Option<PathBuf> {
578 let unescaped = unescape_whitespace(token.trim());
579 if unescaped.is_empty() {
580 return None;
581 }
582
583 let mut candidate = unescaped.as_str();
584 if let Some(rest) = candidate.strip_prefix("file://") {
585 candidate = rest;
586 }
587
588 if let Some(rest) = candidate.strip_prefix("~/") {
589 if let Some(home) = dirs::home_dir() {
590 return Some(home.join(rest));
591 }
592 return None;
593 }
594
595 if Path::new(candidate).is_absolute() || is_windows_absolute_path(candidate) {
596 return Some(PathBuf::from(candidate));
597 }
598
599 if !is_safe_relative_path(candidate) {
600 return None;
601 }
602
603 Some(base_dir.join(candidate))
604}
605
606fn overlaps_range(start: usize, end: usize, ranges: &[(usize, usize)]) -> bool {
607 ranges
608 .iter()
609 .any(|(range_start, range_end)| start < *range_end && end > *range_start)
610}
611
612#[cfg(test)]
613mod tests {
614 use super::*;
615 use std::io::Write;
616 use tempfile::TempDir;
617
618 #[tokio::test]
619 async fn test_parse_at_patterns_with_image() {
620 let temp_dir = TempDir::new().unwrap();
621 let image_path = temp_dir.path().join("test.png");
622
623 let mut temp_file = std::io::BufWriter::new(std::fs::File::create(&image_path).unwrap());
625 temp_file
627 .write_all(&[
628 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, ])
631 .unwrap();
632 temp_file.flush().unwrap();
633
634 let input = format!(
635 "Look at this image: @{}",
636 image_path.file_name().unwrap().to_string_lossy()
637 );
638
639 let result = parse_at_patterns(&input, temp_dir.path()).await.unwrap();
640
641 match result {
642 MessageContent::Parts(parts) => {
643 assert_eq!(parts.len(), 2); assert!(matches!(parts[0], ContentPart::Text { .. }));
645 assert!(matches!(parts[1], ContentPart::Image { .. }));
646 }
647 _ => panic!("Expected multi-part content"),
648 }
649 }
650
651 #[tokio::test]
652 async fn test_parse_raw_absolute_image_path() {
653 let temp_dir = TempDir::new().unwrap();
654 let image_path = temp_dir.path().join("absolute.png");
655
656 let mut temp_file = std::io::BufWriter::new(std::fs::File::create(&image_path).unwrap());
657 temp_file
658 .write_all(&[
659 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, ])
662 .unwrap();
663 temp_file.flush().unwrap();
664
665 let input = format!("see {}", image_path.display());
666 let result = parse_at_patterns(&input, temp_dir.path()).await.unwrap();
667
668 match result {
669 MessageContent::Parts(parts) => {
670 assert_eq!(parts.len(), 2);
671 assert!(matches!(parts[0], ContentPart::Text { .. }));
672 assert!(matches!(parts[1], ContentPart::Image { .. }));
673 }
674 _ => panic!("Expected multi-part content"),
675 }
676 }
677
678 #[tokio::test]
679 async fn test_parse_raw_relative_image_path() {
680 let temp_dir = TempDir::new().unwrap();
681 let image_path = temp_dir.path().join("relative.png");
682
683 let mut temp_file = std::io::BufWriter::new(std::fs::File::create(&image_path).unwrap());
684 temp_file
685 .write_all(&[
686 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, ])
689 .unwrap();
690 temp_file.flush().unwrap();
691
692 let input = "see relative.png";
693 let result = parse_at_patterns(input, temp_dir.path()).await.unwrap();
694
695 match result {
696 MessageContent::Parts(parts) => {
697 assert_eq!(parts.len(), 2);
698 assert!(matches!(parts[0], ContentPart::Text { .. }));
699 assert!(matches!(parts[1], ContentPart::Image { .. }));
700 }
701 _ => panic!("Expected multi-part content"),
702 }
703 }
704
705 #[tokio::test]
706 async fn test_parse_raw_quoted_image_path_with_spaces() {
707 let temp_dir = TempDir::new().unwrap();
708 let image_path = temp_dir.path().join("with space.png");
709
710 let mut temp_file = std::io::BufWriter::new(std::fs::File::create(&image_path).unwrap());
711 temp_file
712 .write_all(&[
713 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, ])
716 .unwrap();
717 temp_file.flush().unwrap();
718
719 let input = format!("see \"{}\"", image_path.display());
720 let result = parse_at_patterns(&input, temp_dir.path()).await.unwrap();
721
722 match result {
723 MessageContent::Parts(parts) => {
724 assert!(
725 parts
726 .iter()
727 .any(|part| matches!(part, ContentPart::Image { .. }))
728 );
729 }
730 _ => panic!("Expected multi-part content"),
731 }
732 }
733
734 #[tokio::test]
735 async fn test_parse_raw_escaped_space_image_path() {
736 let temp_dir = TempDir::new().unwrap();
737 let image_path = temp_dir.path().join("escaped space.png");
738
739 let mut temp_file = std::io::BufWriter::new(std::fs::File::create(&image_path).unwrap());
740 temp_file
741 .write_all(&[
742 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, ])
745 .unwrap();
746 temp_file.flush().unwrap();
747
748 let escaped = image_path.to_string_lossy().replace(' ', "\\ ");
749 let input = format!("see {}", escaped);
750 let result = parse_at_patterns(&input, temp_dir.path()).await.unwrap();
751
752 match result {
753 MessageContent::Parts(parts) => {
754 assert!(
755 parts
756 .iter()
757 .any(|part| matches!(part, ContentPart::Image { .. }))
758 );
759 }
760 _ => panic!("Expected multi-part content"),
761 }
762 }
763
764 #[tokio::test]
765 async fn test_parse_raw_unescaped_space_image_path() {
766 let temp_dir = TempDir::new().unwrap();
767 let image_path = temp_dir.path().join("unescaped space.png");
768
769 let mut temp_file = std::io::BufWriter::new(std::fs::File::create(&image_path).unwrap());
770 temp_file
771 .write_all(&[
772 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, ])
775 .unwrap();
776 temp_file.flush().unwrap();
777
778 let input = format!("see {} now", image_path.display());
779 let result = parse_at_patterns(&input, temp_dir.path()).await.unwrap();
780
781 match result {
782 MessageContent::Parts(parts) => {
783 assert!(
784 parts
785 .iter()
786 .any(|part| matches!(part, ContentPart::Image { .. }))
787 );
788 }
789 _ => panic!("Expected multi-part content"),
790 }
791 }
792
793 #[tokio::test]
794 async fn test_parse_raw_narrow_no_break_space_image_path() {
795 let temp_dir = TempDir::new().unwrap();
796 let image_path = temp_dir.path().join("narrow\u{202F}space.png");
797
798 let mut temp_file = std::io::BufWriter::new(std::fs::File::create(&image_path).unwrap());
799 temp_file
800 .write_all(&[
801 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, ])
804 .unwrap();
805 temp_file.flush().unwrap();
806
807 let input = format!("see {} now", image_path.display());
808 let result = parse_at_patterns(&input, temp_dir.path()).await.unwrap();
809
810 match result {
811 MessageContent::Parts(parts) => {
812 assert!(
813 parts
814 .iter()
815 .any(|part| matches!(part, ContentPart::Image { .. }))
816 );
817 }
818 _ => panic!("Expected multi-part content"),
819 }
820 }
821
822 #[tokio::test]
823 async fn test_parse_at_absolute_image_path() {
824 let temp_dir = TempDir::new().unwrap();
825 let image_path = temp_dir.path().join("at-absolute.png");
826
827 let mut temp_file = std::io::BufWriter::new(std::fs::File::create(&image_path).unwrap());
828 temp_file
829 .write_all(&[
830 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, ])
833 .unwrap();
834 temp_file.flush().unwrap();
835
836 let input = format!("see @{}", image_path.display());
837 let result = parse_at_patterns(&input, temp_dir.path()).await.unwrap();
838
839 match result {
840 MessageContent::Parts(parts) => {
841 assert_eq!(parts.len(), 2);
842 assert!(matches!(parts[0], ContentPart::Text { .. }));
843 assert!(matches!(parts[1], ContentPart::Image { .. }));
844 }
845 _ => panic!("Expected multi-part content"),
846 }
847 }
848
849 #[tokio::test]
850 async fn test_parse_at_patterns_regular_text() {
851 let temp_dir = TempDir::new().unwrap();
852 let input = "This is just regular text with @ symbol not followed by file";
853
854 let result = parse_at_patterns(input, temp_dir.path()).await.unwrap();
855
856 match result {
857 MessageContent::Text(text) => {
858 assert_eq!(text, input);
859 }
860 _ => panic!("Expected single text content"),
861 }
862 }
863
864 #[test]
865 fn test_is_safe_relative_path() {
866 use vtcode_commons::paths::is_safe_relative_path;
867 assert!(!is_safe_relative_path("../../etc/passwd"));
868 assert!(!is_safe_relative_path("../file.txt"));
869 assert!(is_safe_relative_path("file.txt"));
870 assert!(is_safe_relative_path("./path/file.txt"));
871 assert!(is_safe_relative_path(" path with spaces .txt "));
872 }
873
874 #[tokio::test]
875 async fn test_parse_at_patterns_invalid_file() {
876 let temp_dir = TempDir::new().unwrap();
877 let input = "Look at @nonexistent.png which doesn't exist";
878
879 let result = parse_at_patterns(input, temp_dir.path()).await.unwrap();
880
881 match result {
882 MessageContent::Text(text) => {
883 assert_eq!(text, input);
884 }
885 other => panic!("Expected single text content, got {other:?}"),
886 }
887 }
888
889 #[tokio::test]
890 async fn test_parse_at_patterns_url() {
891 let temp_dir = TempDir::new().unwrap();
892 let input = "Look at @https://example.com/image.png";
893
894 let result = parse_at_patterns(input, temp_dir.path()).await.unwrap();
895
896 if let MessageContent::Text(text) = result {
899 assert!(text.contains("@https://example.com/image.png"));
900 }
901 }
902
903 #[tokio::test]
904 async fn test_parse_at_patterns_data_url_image() {
905 let temp_dir = TempDir::new().unwrap();
906 let input = "inline data:image/png;base64,aGVsbG8=";
907
908 let result = parse_at_patterns(input, temp_dir.path()).await.unwrap();
909
910 match result {
911 MessageContent::Parts(parts) => {
912 assert_eq!(parts.len(), 2);
913 assert!(matches!(parts[0], ContentPart::Text { .. }));
914 assert!(matches!(parts[1], ContentPart::Image { .. }));
915 }
916 _ => panic!("Expected multi-part content"),
917 }
918 }
919
920 #[tokio::test]
921 async fn test_parse_at_patterns_with_non_image_file_input_enabled() {
922 let temp_dir = TempDir::new().unwrap();
923 let file_path = temp_dir.path().join("report.pdf");
924 std::fs::write(&file_path, b"%PDF-1.7\nhello").unwrap();
925
926 let input = format!(
927 "Summarize @{}",
928 file_path.file_name().unwrap().to_string_lossy()
929 );
930 let result = parse_at_patterns_with_options(
931 &input,
932 temp_dir.path(),
933 AtPatternOptions {
934 allow_local_non_image_file_inputs: true,
935 allow_remote_non_image_file_inputs: false,
936 },
937 )
938 .await
939 .unwrap();
940
941 match result {
942 MessageContent::Parts(parts) => {
943 assert_eq!(parts.len(), 2);
944 assert!(matches!(parts[0], ContentPart::Text { .. }));
945 match &parts[1] {
946 ContentPart::File {
947 filename,
948 file_data,
949 file_url,
950 ..
951 } => {
952 assert_eq!(filename.as_deref(), Some("report.pdf"));
953 assert!(file_data.as_ref().is_some_and(|value| !value.is_empty()));
954 assert!(file_url.is_none());
955 }
956 other => panic!("Expected file content part, got {other:?}"),
957 }
958 }
959 other => panic!("Expected multi-part content, got {other:?}"),
960 }
961 }
962
963 #[tokio::test]
964 async fn test_parse_at_patterns_with_non_image_url_input_enabled() {
965 let temp_dir = TempDir::new().unwrap();
966 let input = "Summarize @https://example.com/report.pdf";
967
968 let result = parse_at_patterns_with_options(
969 input,
970 temp_dir.path(),
971 AtPatternOptions {
972 allow_local_non_image_file_inputs: false,
973 allow_remote_non_image_file_inputs: true,
974 },
975 )
976 .await
977 .unwrap();
978
979 match result {
980 MessageContent::Parts(parts) => {
981 assert_eq!(parts.len(), 2);
982 assert!(matches!(parts[0], ContentPart::Text { .. }));
983 match &parts[1] {
984 ContentPart::File {
985 file_url,
986 file_data,
987 ..
988 } => {
989 assert_eq!(file_url.as_deref(), Some("https://example.com/report.pdf"));
990 assert!(file_data.is_none());
991 }
992 other => panic!("Expected file content part, got {other:?}"),
993 }
994 }
995 other => panic!("Expected multi-part content, got {other:?}"),
996 }
997 }
998
999 #[tokio::test]
1000 async fn test_parse_at_patterns_keeps_remote_non_image_url_as_text_when_remote_disabled() {
1001 let temp_dir = TempDir::new().unwrap();
1002 let input = "Summarize @https://example.com/report.pdf";
1003
1004 let result = parse_at_patterns_with_options(
1005 input,
1006 temp_dir.path(),
1007 AtPatternOptions {
1008 allow_local_non_image_file_inputs: true,
1009 allow_remote_non_image_file_inputs: false,
1010 },
1011 )
1012 .await
1013 .unwrap();
1014
1015 match result {
1016 MessageContent::Text(text) => assert_eq!(text, input),
1017 other => panic!("Expected plain text content, got {other:?}"),
1018 }
1019 }
1020
1021 #[tokio::test]
1022 async fn test_parse_at_patterns_keeps_non_image_file_as_text_when_disabled() {
1023 let temp_dir = TempDir::new().unwrap();
1024 let file_path = temp_dir.path().join("report.pdf");
1025 std::fs::write(&file_path, b"%PDF-1.7\nhello").unwrap();
1026 let input = format!(
1027 "Summarize @{}",
1028 file_path.file_name().unwrap().to_string_lossy()
1029 );
1030
1031 let result = parse_at_patterns(&input, temp_dir.path()).await.unwrap();
1032
1033 match result {
1034 MessageContent::Text(text) => assert_eq!(text, input),
1035 other => panic!("Expected plain text content, got {other:?}"),
1036 }
1037 }
1038
1039 #[tokio::test]
1040 async fn test_parse_at_patterns_never_auto_parses_raw_non_image_paths() {
1041 let temp_dir = TempDir::new().unwrap();
1042 let file_path = temp_dir.path().join("notes.txt");
1043 std::fs::write(&file_path, b"hello").unwrap();
1044 let input = "Please read notes.txt";
1045
1046 let result = parse_at_patterns_with_options(
1047 input,
1048 temp_dir.path(),
1049 AtPatternOptions {
1050 allow_local_non_image_file_inputs: true,
1051 allow_remote_non_image_file_inputs: false,
1052 },
1053 )
1054 .await
1055 .unwrap();
1056
1057 match result {
1058 MessageContent::Text(text) => assert_eq!(text, input),
1059 other => panic!("Expected plain text content, got {other:?}"),
1060 }
1061 }
1062
1063 #[test]
1064 fn regex_does_not_match_trailing_text_after_extension() {
1065 let input = "/Users/foo/Desktop/Screenshot 2026-02-06 at 3.39.48 PM.png can you see";
1066 let captures: Vec<_> = ABSOLUTE_IMAGE_PATH_REGEX.captures_iter(input).collect();
1067 assert_eq!(captures.len(), 1, "Should match exactly one image path");
1068 let matched = captures[0].get(1).unwrap().as_str();
1069 assert!(
1070 !matched.contains("can you"),
1071 "Match should not include trailing text, got: {matched}"
1072 );
1073 assert!(
1074 matched.ends_with(".png"),
1075 "Match should end with the image extension, got: {matched}"
1076 );
1077 }
1078
1079 #[test]
1080 fn regex_matches_image_path_without_trailing_text() {
1081 let input = "/Users/foo/Desktop/Screenshot 2026-02-06 at 3.39.48 PM.png";
1082 let captures: Vec<_> = ABSOLUTE_IMAGE_PATH_REGEX.captures_iter(input).collect();
1083 assert_eq!(captures.len(), 1);
1084 let matched = captures[0].get(1).unwrap().as_str();
1085 assert!(matched.ends_with(".png"));
1086 }
1087
1088 #[test]
1089 fn regex_does_not_match_extension_followed_by_word_char() {
1090 let input = "/path/to/image.png2 more text";
1094 let captures: Vec<_> = ABSOLUTE_IMAGE_PATH_REGEX.captures_iter(input).collect();
1095 assert_eq!(captures.len(), 1);
1097 let matched = captures[0].get(1).unwrap().as_str();
1098 let trimmed = trim_trailing_image_path_str(matched);
1099 assert!(
1100 trimmed.ends_with(".png"),
1101 "Trimmed path should end with .png, got: {trimmed}"
1102 );
1103 }
1104
1105 #[test]
1106 fn regex_matches_image_path_with_file_prefix() {
1107 let input = "file:///Users/foo/image.png";
1108 let captures: Vec<_> = ABSOLUTE_IMAGE_PATH_REGEX.captures_iter(input).collect();
1109 assert_eq!(captures.len(), 1);
1110 assert!(captures[0].get(1).unwrap().as_str().contains("image.png"));
1111 }
1112
1113 #[tokio::test]
1114 async fn test_raw_image_path_with_trailing_text_resolves_only_path() {
1115 let temp_dir = TempDir::new().unwrap();
1116 let image_path = temp_dir.path().join("screenshot.png");
1117
1118 let mut temp_file = std::io::BufWriter::new(std::fs::File::create(&image_path).unwrap());
1119 temp_file
1120 .write_all(&[
1121 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48,
1122 0x44, 0x52,
1123 ])
1124 .unwrap();
1125 temp_file.flush().unwrap();
1126
1127 let input = format!("see {} now", image_path.display());
1128 let result = parse_at_patterns(&input, temp_dir.path()).await.unwrap();
1129
1130 match result {
1131 MessageContent::Parts(parts) => {
1132 let image_count = parts
1133 .iter()
1134 .filter(|p| matches!(p, ContentPart::Image { .. }))
1135 .count();
1136 assert_eq!(image_count, 1, "Should detect exactly one image");
1137 }
1138 other => panic!("Expected multi-part content, got {other:?}"),
1139 }
1140 }
1141}