use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ImageFormat {
Png,
Jpeg,
Gif,
Webp,
Svg,
Unknown,
}
impl ImageFormat {
pub fn from_extension(ext: &str) -> Self {
match ext.to_lowercase().as_str() {
"png" => Self::Png,
"jpg" | "jpeg" => Self::Jpeg,
"gif" => Self::Gif,
"webp" => Self::Webp,
"svg" => Self::Svg,
_ => Self::Unknown,
}
}
pub fn from_path(path: &std::path::Path) -> Self {
path.extension()
.and_then(|e| e.to_str())
.map(Self::from_extension)
.unwrap_or(Self::Unknown)
}
pub fn from_mime(mime: &str) -> Self {
match mime {
"image/png" => Self::Png,
"image/jpeg" => Self::Jpeg,
"image/gif" => Self::Gif,
"image/webp" => Self::Webp,
"image/svg+xml" => Self::Svg,
_ => Self::Unknown,
}
}
pub fn mime_type(&self) -> &'static str {
match self {
Self::Png => "image/png",
Self::Jpeg => "image/jpeg",
Self::Gif => "image/gif",
Self::Webp => "image/webp",
Self::Svg => "image/svg+xml",
Self::Unknown => "application/octet-stream",
}
}
pub fn is_supported(&self) -> bool {
!matches!(self, Self::Unknown)
}
pub fn as_str(&self) -> &'static str {
match self {
Self::Png => "png",
Self::Jpeg => "jpeg",
Self::Gif => "gif",
Self::Webp => "webp",
Self::Svg => "svg",
Self::Unknown => "unknown",
}
}
}
impl std::fmt::Display for ImageFormat {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_str())
}
}
impl std::str::FromStr for ImageFormat {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(Self::from_extension(s))
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImageMetadata {
pub image_id: String,
pub message_id: Option<String>,
pub conversation_id: String,
pub file_name: Option<String>,
pub format: ImageFormat,
pub mime_type: String,
pub width: Option<u32>,
pub height: Option<u32>,
pub file_size_bytes: u64,
pub file_hash: String,
pub analysis: String,
pub extracted_text: Option<String>,
pub tags: Vec<String>,
pub created_at: i64,
}
impl ImageMetadata {
pub fn new(
image_id: String,
conversation_id: String,
format: ImageFormat,
file_size_bytes: u64,
file_hash: String,
analysis: String,
) -> Self {
Self {
image_id,
message_id: None,
conversation_id,
file_name: None,
format,
mime_type: format.mime_type().to_string(),
width: None,
height: None,
file_size_bytes,
file_hash,
analysis,
extracted_text: None,
tags: Vec::new(),
created_at: chrono::Utc::now().timestamp(),
}
}
pub fn with_message_id(mut self, message_id: String) -> Self {
self.message_id = Some(message_id);
self
}
pub fn with_file_name(mut self, file_name: String) -> Self {
self.file_name = Some(file_name);
self
}
pub fn with_dimensions(mut self, width: u32, height: u32) -> Self {
self.width = Some(width);
self.height = Some(height);
self
}
pub fn with_extracted_text(mut self, text: String) -> Self {
self.extracted_text = Some(text);
self
}
pub fn with_tags(mut self, tags: Vec<String>) -> Self {
self.tags = tags;
self
}
pub fn searchable_text(&self) -> String {
let mut text = self.analysis.clone();
if let Some(ref extracted) = self.extracted_text {
text.push_str("\n\n");
text.push_str(extracted);
}
if !self.tags.is_empty() {
text.push_str("\n\nTags: ");
text.push_str(&self.tags.join(", "));
}
text
}
}
#[derive(Debug, Clone)]
pub struct ImageSearchRequest {
pub query: String,
pub conversation_id: Option<String>,
pub limit: usize,
pub min_score: f32,
pub format: Option<ImageFormat>,
pub include_ocr: bool,
}
impl ImageSearchRequest {
pub fn new(query: impl Into<String>) -> Self {
Self {
query: query.into(),
conversation_id: None,
limit: 10,
min_score: 0.5,
format: None,
include_ocr: true,
}
}
pub fn with_conversation(mut self, conversation_id: String) -> Self {
self.conversation_id = Some(conversation_id);
self
}
pub fn with_limit(mut self, limit: usize) -> Self {
self.limit = limit;
self
}
pub fn with_min_score(mut self, min_score: f32) -> Self {
self.min_score = min_score;
self
}
pub fn with_format(mut self, format: ImageFormat) -> Self {
self.format = Some(format);
self
}
}
#[derive(Debug, Clone)]
pub struct ImageSearchResult {
pub image_id: String,
pub conversation_id: String,
pub file_name: Option<String>,
pub format: ImageFormat,
pub analysis: String,
pub extracted_text: Option<String>,
pub tags: Vec<String>,
pub score: f32,
pub width: Option<u32>,
pub height: Option<u32>,
pub created_at: i64,
}
impl ImageSearchResult {
pub fn from_metadata(meta: ImageMetadata, score: f32) -> Self {
Self {
image_id: meta.image_id,
conversation_id: meta.conversation_id,
file_name: meta.file_name,
format: meta.format,
analysis: meta.analysis,
extracted_text: meta.extracted_text,
tags: meta.tags,
score,
width: meta.width,
height: meta.height,
created_at: meta.created_at,
}
}
}
#[derive(Debug, Clone)]
pub enum ImageStorage {
Base64(String),
FilePath(String),
Url(String),
}
impl ImageStorage {
#[cfg(feature = "native")]
pub fn from_bytes(bytes: &[u8]) -> Self {
use base64::{Engine, engine::general_purpose::STANDARD};
Self::Base64(STANDARD.encode(bytes))
}
pub fn storage_type(&self) -> &'static str {
match self {
Self::Base64(_) => "base64",
Self::FilePath(_) => "file",
Self::Url(_) => "url",
}
}
pub fn value(&self) -> &str {
match self {
Self::Base64(v) | Self::FilePath(v) | Self::Url(v) => v,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_image_format_from_extension() {
assert_eq!(ImageFormat::from_extension("png"), ImageFormat::Png);
assert_eq!(ImageFormat::from_extension("jpg"), ImageFormat::Jpeg);
assert_eq!(ImageFormat::from_extension("jpeg"), ImageFormat::Jpeg);
assert_eq!(ImageFormat::from_extension("gif"), ImageFormat::Gif);
assert_eq!(ImageFormat::from_extension("webp"), ImageFormat::Webp);
assert_eq!(ImageFormat::from_extension("svg"), ImageFormat::Svg);
assert_eq!(ImageFormat::from_extension("bmp"), ImageFormat::Unknown);
}
#[test]
fn test_image_format_mime_type() {
assert_eq!(ImageFormat::Png.mime_type(), "image/png");
assert_eq!(ImageFormat::Jpeg.mime_type(), "image/jpeg");
}
#[test]
fn test_image_format_from_mime() {
assert_eq!(ImageFormat::from_mime("image/png"), ImageFormat::Png);
assert_eq!(ImageFormat::from_mime("image/jpeg"), ImageFormat::Jpeg);
}
#[test]
fn test_image_metadata_builder() {
let meta = ImageMetadata::new(
"img-123".to_string(),
"conv-456".to_string(),
ImageFormat::Png,
1024,
"hash123".to_string(),
"A screenshot of code".to_string(),
)
.with_message_id("msg-789".to_string())
.with_file_name("screenshot.png".to_string())
.with_dimensions(1920, 1080)
.with_tags(vec!["code".to_string(), "screenshot".to_string()]);
assert_eq!(meta.image_id, "img-123");
assert_eq!(meta.message_id, Some("msg-789".to_string()));
assert_eq!(meta.width, Some(1920));
assert_eq!(meta.height, Some(1080));
assert_eq!(meta.tags.len(), 2);
}
#[test]
fn test_searchable_text() {
let meta = ImageMetadata::new(
"img-123".to_string(),
"conv-456".to_string(),
ImageFormat::Png,
1024,
"hash123".to_string(),
"A diagram showing architecture".to_string(),
)
.with_extracted_text("Component A -> Component B".to_string())
.with_tags(vec!["diagram".to_string(), "architecture".to_string()]);
let text = meta.searchable_text();
assert!(text.contains("diagram showing architecture"));
assert!(text.contains("Component A"));
assert!(text.contains("Tags: diagram, architecture"));
}
#[test]
fn test_image_search_request_builder() {
let request = ImageSearchRequest::new("architecture diagram")
.with_conversation("conv-123".to_string())
.with_limit(5)
.with_min_score(0.7)
.with_format(ImageFormat::Png);
assert_eq!(request.query, "architecture diagram");
assert_eq!(request.conversation_id, Some("conv-123".to_string()));
assert_eq!(request.limit, 5);
assert_eq!(request.min_score, 0.7);
assert_eq!(request.format, Some(ImageFormat::Png));
}
#[test]
fn test_image_storage() {
let storage = ImageStorage::from_bytes(b"test image data");
assert_eq!(storage.storage_type(), "base64");
assert!(!storage.value().is_empty());
let file_storage = ImageStorage::FilePath("/path/to/image.png".to_string());
assert_eq!(file_storage.storage_type(), "file");
assert_eq!(file_storage.value(), "/path/to/image.png");
}
}