#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ImageRef {
pub source: String,
pub kind: ImageSourceKind,
pub start: usize,
pub end: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ImageSourceKind {
LocalFile,
DataUri,
RemoteUrl,
}
pub fn parse_image_markers(text: &str) -> Vec<ImageRef> {
let mut refs = Vec::new();
let marker_prefix = "[IMAGE:";
let mut search_from = 0;
while search_from < text.len() {
let start = match text[search_from..].find(marker_prefix) {
Some(pos) => search_from + pos,
None => break,
};
let content_start = start + marker_prefix.len();
let end = match text[content_start..].find(']') {
Some(pos) => content_start + pos + 1,
None => break, };
let source = text[content_start..end - 1].trim().to_string();
if !source.is_empty() {
let kind = classify_source(&source);
refs.push(ImageRef {
source,
kind,
start,
end,
});
}
search_from = end;
}
refs
}
pub fn strip_image_markers(text: &str) -> String {
let refs = parse_image_markers(text);
if refs.is_empty() {
return text.to_string();
}
let mut result = String::with_capacity(text.len());
let mut last_end = 0;
for r in &refs {
result.push_str(&text[last_end..r.start]);
last_end = r.end;
}
result.push_str(&text[last_end..]);
let cleaned: Vec<&str> = result.split_whitespace().collect();
cleaned.join(" ")
}
fn classify_source(source: &str) -> ImageSourceKind {
if source.starts_with("data:") {
ImageSourceKind::DataUri
} else if source.starts_with("http://") || source.starts_with("https://") {
ImageSourceKind::RemoteUrl
} else {
ImageSourceKind::LocalFile
}
}
pub fn validate_image_refs(
refs: &[ImageRef],
max_images: usize,
allow_remote_fetch: bool,
) -> Result<(), String> {
if refs.len() > max_images {
return Err(format!(
"Too many images: {} (max {})",
refs.len(),
max_images
));
}
for r in refs {
if r.kind == ImageSourceKind::RemoteUrl && !allow_remote_fetch {
return Err(format!("Remote image fetch is disabled: {}", r.source));
}
}
Ok(())
}
pub fn check_vision_support(
image_refs: &[ImageRef],
vision_support: Option<bool>,
) -> Result<(), String> {
if image_refs.is_empty() {
return Ok(());
}
match vision_support {
Some(false) => Err(format!(
"Provider does not support vision, but message contains {} image(s). \
Set model_support_vision = true in config or remove images.",
image_refs.len()
)),
_ => Ok(()),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_local_file() {
let refs = parse_image_markers("Look at this [IMAGE:/tmp/screenshot.png] image");
assert_eq!(refs.len(), 1);
assert_eq!(refs[0].source, "/tmp/screenshot.png");
assert_eq!(refs[0].kind, ImageSourceKind::LocalFile);
}
#[test]
fn parse_data_uri() {
let refs = parse_image_markers("[IMAGE:data:image/png;base64,iVBOR...]");
assert_eq!(refs.len(), 1);
assert_eq!(refs[0].kind, ImageSourceKind::DataUri);
}
#[test]
fn parse_remote_url() {
let refs = parse_image_markers("[IMAGE:https://example.com/photo.jpg]");
assert_eq!(refs.len(), 1);
assert_eq!(refs[0].source, "https://example.com/photo.jpg");
assert_eq!(refs[0].kind, ImageSourceKind::RemoteUrl);
}
#[test]
fn parse_multiple_markers() {
let text = "Compare [IMAGE:/a.png] with [IMAGE:/b.png] and [IMAGE:https://c.jpg]";
let refs = parse_image_markers(text);
assert_eq!(refs.len(), 3);
assert_eq!(refs[0].kind, ImageSourceKind::LocalFile);
assert_eq!(refs[1].kind, ImageSourceKind::LocalFile);
assert_eq!(refs[2].kind, ImageSourceKind::RemoteUrl);
}
#[test]
fn parse_no_markers() {
let refs = parse_image_markers("Just a normal message");
assert!(refs.is_empty());
}
#[test]
fn parse_unclosed_marker_skipped() {
let refs = parse_image_markers("[IMAGE:/broken");
assert!(refs.is_empty());
}
#[test]
fn parse_empty_marker_skipped() {
let refs = parse_image_markers("[IMAGE:]");
assert!(refs.is_empty());
}
#[test]
fn strip_markers_removes_images() {
let text = "Look at [IMAGE:/tmp/a.png] this image";
let stripped = strip_image_markers(text);
assert_eq!(stripped, "Look at this image");
}
#[test]
fn strip_no_markers_unchanged() {
let text = "hello world";
assert_eq!(strip_image_markers(text), "hello world");
}
#[test]
fn validate_too_many_images() {
let refs = vec![
ImageRef {
source: "/a.png".into(),
kind: ImageSourceKind::LocalFile,
start: 0,
end: 10,
},
ImageRef {
source: "/b.png".into(),
kind: ImageSourceKind::LocalFile,
start: 20,
end: 30,
},
];
assert!(validate_image_refs(&refs, 1, true).is_err());
assert!(validate_image_refs(&refs, 2, true).is_ok());
}
#[test]
fn validate_remote_fetch_disabled() {
let refs = vec![ImageRef {
source: "https://example.com/a.png".into(),
kind: ImageSourceKind::RemoteUrl,
start: 0,
end: 30,
}];
assert!(validate_image_refs(&refs, 4, false).is_err());
assert!(validate_image_refs(&refs, 4, true).is_ok());
}
#[test]
fn validate_local_files_always_ok() {
let refs = vec![ImageRef {
source: "/tmp/a.png".into(),
kind: ImageSourceKind::LocalFile,
start: 0,
end: 10,
}];
assert!(validate_image_refs(&refs, 4, false).is_ok());
}
fn sample_image_ref() -> ImageRef {
ImageRef {
source: "/tmp/a.png".into(),
kind: ImageSourceKind::LocalFile,
start: 0,
end: 10,
}
}
#[test]
fn vision_check_no_images_always_ok() {
assert!(check_vision_support(&[], Some(false)).is_ok());
assert!(check_vision_support(&[], Some(true)).is_ok());
assert!(check_vision_support(&[], None).is_ok());
}
#[test]
fn vision_check_explicit_false_rejects_images() {
let refs = vec![sample_image_ref()];
let err = check_vision_support(&refs, Some(false)).unwrap_err();
assert!(err.contains("does not support vision"));
assert!(err.contains("1 image(s)"));
}
#[test]
fn vision_check_explicit_true_allows_images() {
let refs = vec![sample_image_ref()];
assert!(check_vision_support(&refs, Some(true)).is_ok());
}
#[test]
fn vision_check_none_allows_images() {
let refs = vec![sample_image_ref()];
assert!(check_vision_support(&refs, None).is_ok());
}
}