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::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
26/// Parse the @ pattern in text and replace image file paths/URLs with base64 content
27///
28/// The function looks for patterns like `@./path/to/image.png`, `@image.jpg`, or `@https://example.com/image.png`
29/// and replaces them with base64 encoded content that can be processed by LLMs
30///
31/// # Arguments
32///
33/// * `input` - The user input text that may contain @ patterns
34/// * `base_dir` - The base directory to resolve relative paths from
35///
36/// # Returns
37///
38/// * `MessageContent` - Either a single text string or multiple content parts
39///   containing both text and base64-encoded images
40pub 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
44/// Parse the @ pattern in text with provider-specific input options.
45pub 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, &quote_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        // The regex may greedily consume trailing text after the image extension.
451        // Try progressively shorter suffixes to find the actual image path.
452        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        // Create a simple PNG file for testing
624        let mut temp_file = std::io::BufWriter::new(std::fs::File::create(&image_path).unwrap());
625        // Write a minimal PNG header (not a real image, but valid for testing)
626        temp_file
627            .write_all(&[
628                0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG header
629                0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, // IHDR chunk start
630            ])
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); // Text part + image part
644                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, // PNG header
660                0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, // IHDR chunk start
661            ])
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, // PNG header
687                0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, // IHDR chunk start
688            ])
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, // PNG header
714                0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, // IHDR chunk start
715            ])
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, // PNG header
743                0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, // IHDR chunk start
744            ])
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, // PNG header
773                0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, // IHDR chunk start
774            ])
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, // PNG header
802                0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, // IHDR chunk start
803            ])
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, // PNG header
831                0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, // IHDR chunk start
832            ])
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        // For URL tests, we expect the result to be text (since mock server isn't available)
897        // In real usage with a valid URL, it would return multi-part content with image
898        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        // Without lookaheads, the regex cannot distinguish "image.png" + "2"
1091        // from "image.png2". The trim-trailing-text post-processing handles
1092        // the realistic case (space-separated trailing words).
1093        let input = "/path/to/image.png2 more text";
1094        let captures: Vec<_> = ABSOLUTE_IMAGE_PATH_REGEX.captures_iter(input).collect();
1095        // The regex matches; the caller trims "2 more text" and validates the path.
1096        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}