Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 77 additions & 3 deletions crates/goose-provider-types/src/images.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,7 @@ pub fn detect_image_path(text: &str) -> Option<Cow<'_, str>> {
};

let terminator = text.get(end..).and_then(|rest| rest.chars().next());
let terminated =
terminator.is_none_or(|c| c == '/' || c.is_whitespace() || c == '"' || c == '\'');
let terminated = terminator.is_none_or(is_path_terminator);

if terminated {
let mut floor = end.saturating_sub(MAX_PATH_LEN);
Expand All @@ -63,7 +62,7 @@ pub fn detect_image_path(text: &str) -> Option<Cow<'_, str>> {
let preceded_by_boundary = text
.get(..start)
.and_then(|prefix| prefix.chars().next_back())
.is_none_or(|c| c.is_whitespace() || c == '"' || c == '\'');
.is_none_or(is_path_leading_boundary);
if !preceded_by_boundary {
continue;
}
Expand Down Expand Up @@ -140,6 +139,30 @@ fn is_existing_image_path(candidate: &str) -> bool {
path.is_absolute() && path.is_file() && is_image_file(path)
}

fn is_path_leading_boundary(c: char) -> bool {
c.is_whitespace()
|| matches!(
c,
'"' | '\'' | '\u{00AB}' | '\u{00BB}' | '\u{2018}'
..='\u{201F}' | '\u{2039}' | '\u{203A}'
)
}

fn is_path_terminator(c: char) -> bool {
c == '/'
|| c.is_whitespace()
|| matches!(
c,
'"' | '\'' | '\u{00AB}' | '\u{00BB}' | '\u{2013}'
..='\u{201F}' | '\u{2026}' | '\u{2039}' | '\u{203A}'
Comment on lines +156 to +157

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Support Unicode quotes at the leading boundary

When a user wraps the path in smart quotes, e.g. “/tmp/photo.png”, detection still returns None: the trailing now passes this terminator check, but the slash is skipped because the leading-boundary check still accepts only whitespace and ASCII quotes. Since ASCII-quoted paths are supported and this change adds Unicode quote terminators, the matching opening Unicode quotes should be accepted as path boundaries too.

Useful? React with 👍 / 👎.

)
|| ('\u{2300}'..='\u{23FF}').contains(&c)
|| ('\u{2600}'..='\u{27BF}').contains(&c)
|| ('\u{2B00}'..='\u{2BFF}').contains(&c)
|| ('\u{1F1E6}'..='\u{1F1FF}').contains(&c)
|| ('\u{1F300}'..='\u{1FAFF}').contains(&c)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include flag emoji in terminator range

When a user appends a flag emoji immediately after an image path, e.g. /tmp/photo.png🇺🇸, the first scalar is a Regional Indicator Symbol (U+1F1E6..U+1F1FF), which is outside this range. detect_image_path therefore still treats the flag as part of the filename and returns None, so the newly added emoji-terminator behavior misses a common emoji class; include that range or use Unicode emoji properties if emoji suffixes are meant to terminate paths.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 87b633a by adding the Regional Indicator Symbol range (U+1F1E6..U+1F1FF) and covering a flag emoji suffix in the unicode separator regression test. Local verification: cargo fmt --check && cargo test -p goose-providers test_detect_image_path.

Comment on lines +160 to +163

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include common emoji blocks as terminators

When a user appends a common emoji outside these hard-coded ranges, such as /tmp/photo.png⭐ (U+2B50) or /tmp/photo.png⌚ (U+231A), is_path_terminator returns false, so detect_image_path treats the emoji as part of the filename and misses the existing image. This is still reproducible with U+2B50 even after the regional-indicator fix; include the remaining emoji blocks or use a Unicode emoji property if emoji suffixes are meant to terminate paths.

Useful? React with 👍 / 👎.

Comment on lines +159 to +163

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Recognize keycap emoji terminators

When a user appends a keycap emoji immediately after an existing image path, e.g. /tmp/photo.png1️⃣, the first scalar after .png is the ASCII digit 1, so it does not match any of these emoji ranges and detect_image_path treats the emoji sequence as part of the filename and returns None. After the added emoji ranges, this multi-scalar emoji case still remains outside the per-character predicate; handle keycap sequences (or use Unicode emoji segmentation/properties) before rejecting the suffix.

Useful? React with 👍 / 👎.

}

/// Case-insensitive ASCII substring search returning a byte index into
/// `haystack` (no allocation, so the index stays valid for slicing).
fn find_ascii_ci(haystack: &str, needle: &str, from: usize) -> Option<usize> {
Expand Down Expand Up @@ -286,6 +309,10 @@ mod tests {
assert_eq!(detect_image_path(&text).as_deref(), Some(png_path_str));
let text = format!("describe '{}'", png_path_str);
assert_eq!(detect_image_path(&text).as_deref(), Some(png_path_str));
let text = format!("describe “{}” please", png_path_str);
assert_eq!(detect_image_path(&text).as_deref(), Some(png_path_str));
let text = format!("describe «{}» please", png_path_str);
assert_eq!(detect_image_path(&text).as_deref(), Some(png_path_str));

// A stray closing quote in prose must not act as a terminator for an
// unquoted path.
Expand Down Expand Up @@ -355,6 +382,53 @@ mod tests {
assert_eq!(detect_image_path(&text).as_deref(), Some(png_path_str));
}

#[test]
fn test_detect_image_path_with_unicode_separators() {
let temp_dir = tempfile::tempdir().unwrap();
let png_data = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
let png_path = temp_dir.path().join("photo.png");
std::fs::write(&png_path, png_data).unwrap();
let png_path_str = png_path.to_str().unwrap();

assert_eq!(
detect_image_path(&format!("{png_path_str}🙂")).as_deref(),
Some(png_path_str)
);
assert_eq!(
detect_image_path(&format!("{png_path_str}🇺🇸")).as_deref(),
Some(png_path_str)
);
assert_eq!(
detect_image_path(&format!("{png_path_str}⌚")).as_deref(),
Some(png_path_str)
);
assert_eq!(
detect_image_path(&format!("{png_path_str}⭐")).as_deref(),
Some(png_path_str)
);
assert_eq!(
detect_image_path(&format!("{png_path_str}… more text")).as_deref(),
Some(png_path_str)
);
assert_eq!(
detect_image_path(&format!("{png_path_str}\u{2014}more text")).as_deref(),
Some(png_path_str)
);

assert_eq!(
detect_image_path(&format!("{png_path_str}\u{200B}.backup")).as_deref(),
None
);
assert_eq!(
detect_image_path(&format!("{png_path_str}\u{0301}")).as_deref(),
None
);
assert_eq!(
detect_image_path(&format!("file:{png_path_str}")).as_deref(),
None
);
}

#[test]
fn test_detect_image_path_ignores_urls_and_longer_extensions() {
let temp_dir = tempfile::tempdir().unwrap();
Expand Down
Loading