use base64::{engine::general_purpose, Engine};
use mime_guess::MimeGuess;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tokio::sync::Mutex;
use tracing::{debug, error, info, instrument};
use crate::errors::{Result, WinxError};
use crate::state::bash_state::BashState;
use crate::types::ReadImage;
use crate::utils::path::{expand_user, validate_path_in_workspace};
pub const SUPPORTED_MIME_TYPES: [&str; 4] = ["image/jpeg", "image/png", "image/gif", "image/webp"];
const MAX_IMAGE_BYTES: u64 = 50 * 1024 * 1024;
#[instrument(level = "debug", skip(file_path))]
fn read_image_from_path(
file_path: &str,
cwd: &Path,
workspace_root: &Path,
) -> Result<(String, String)> {
debug!("Reading image: {}", file_path);
let file_path = expand_user(file_path);
let path = if Path::new(&file_path).is_absolute() {
PathBuf::from(&file_path)
} else {
cwd.join(&file_path)
};
let path = validate_path_in_workspace(&path, workspace_root)
.map_err(|e| WinxError::PathSecurityError { path: path.clone(), message: e.to_string() })?;
if !path.is_file() {
return Err(WinxError::FileAccessError {
path: path.clone(),
message: "File does not exist or is not a regular file".to_string(),
});
}
let size = std::fs::metadata(&path).map_or(0, |m| m.len());
if size > MAX_IMAGE_BYTES {
return Err(WinxError::FileAccessError {
path: path.clone(),
message: format!("Image too large: {size} bytes (max {MAX_IMAGE_BYTES} bytes)"),
});
}
let image_bytes = std::fs::read(&path).map_err(|e| WinxError::FileAccessError {
path: path.clone(),
message: format!("Error reading file: {e}"),
})?;
let image_b64 = general_purpose::STANDARD.encode(&image_bytes);
let mime_type =
MimeGuess::from_path(&path).first_raw().unwrap_or("application/octet-stream").to_string();
if SUPPORTED_MIME_TYPES.contains(&mime_type.as_str()) {
Ok((mime_type, image_b64))
} else {
debug!("Detected MIME type '{}' is not in the supported list", mime_type);
let extension = path.extension().and_then(|ext| ext.to_str()).unwrap_or("").to_lowercase();
let mime_type = match extension.as_str() {
"png" => "image/png",
"gif" => "image/gif",
"webp" => "image/webp",
_ => "image/jpeg", };
debug!("Using fallback MIME type: {}", mime_type);
Ok((mime_type.to_string(), image_b64))
}
}
#[instrument(level = "info", skip(bash_state_arc, read_image))]
pub async fn handle_tool_call(
bash_state_arc: &Arc<Mutex<Option<BashState>>>,
read_image: ReadImage,
) -> Result<(String, String)> {
info!("ReadImage tool called with: {:?}", read_image);
let cwd: PathBuf;
let workspace_root: PathBuf;
{
let bash_state_guard = bash_state_arc.lock().await;
let Some(bash_state) = &*bash_state_guard else {
error!("BashState not initialized");
return Err(WinxError::BashStateNotInitialized);
};
cwd = bash_state.cwd.clone();
workspace_root = bash_state.workspace_root.clone();
}
read_image_from_path(&read_image.file_path, &cwd, &workspace_root)
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn reads_image_inside_workspace() {
let ws = TempDir::new().unwrap();
let img = ws.path().join("shot.png");
fs::write(&img, b"\x89PNG\r\n\x1a\nfake").unwrap();
let (mime, b64) =
read_image_from_path(img.to_str().unwrap(), ws.path(), ws.path()).unwrap();
assert_eq!(mime, "image/png");
assert!(!b64.is_empty());
}
#[test]
fn rejects_image_outside_workspace() {
let ws = TempDir::new().unwrap();
let outside = TempDir::new().unwrap();
let secret = outside.path().join("secret.png");
fs::write(&secret, b"\x89PNG secret").unwrap();
let err = read_image_from_path(secret.to_str().unwrap(), ws.path(), ws.path());
assert!(matches!(err, Err(WinxError::PathSecurityError { .. })));
}
}