Skip to main content

vtcode_core/utils/
at_pattern.rs

1//! # @ Pattern Parsing Utilities
2//!
3//! This module provides utilities for parsing @ symbol patterns in user input
4//! to automatically load and embed image files as base64-encoded content
5//! for LLM processing.
6
7use 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
23/// Parse the @ pattern in text and replace image file paths/URLs with base64 content
24///
25/// The function looks for patterns like `@./path/to/image.png`, `@image.jpg`, or `@https://example.com/image.png`
26/// and replaces them with base64 encoded content that can be processed by LLMs
27///
28/// # Arguments
29///
30/// * `input` - The user input text that may contain @ patterns
31/// * `base_dir` - The base directory to resolve relative paths from
32///
33/// # Returns
34///
35/// * `MessageContent` - Either a single text string or multiple content parts
36///   containing both text and base64-encoded images
37pub 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
41/// Parse the @ pattern in text with provider-specific input options.
42pub 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, &quote_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        // Create a simple PNG file for testing
632        let mut temp_file = std::io::BufWriter::new(std::fs::File::create(&image_path).unwrap());
633        // Write a minimal PNG header (not a real image, but valid for testing)
634        temp_file
635            .write_all(&[
636                0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG header
637                0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, // IHDR chunk start
638            ])
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); // Text part + image part
652                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, // PNG header
668                0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, // IHDR chunk start
669            ])
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, // PNG header
695                0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, // IHDR chunk start
696            ])
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, // PNG header
722                0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, // IHDR chunk start
723            ])
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, // PNG header
751                0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, // IHDR chunk start
752            ])
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, // PNG header
781                0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, // IHDR chunk start
782            ])
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, // PNG header
810                0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, // IHDR chunk start
811            ])
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, // PNG header
839                0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, // IHDR chunk start
840            ])
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        // For URL tests, we expect the result to be text (since mock server isn't available)
905        // In real usage with a valid URL, it would return multi-part content with image
906        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}