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