Changes: - grok-shell: request workspaces:read/write OAuth2 scopes - security: fix SSRF bypass via HTTP redirect in hook runner - fix(grok-build): enterprise STT WSS URL + API-key voice bearer - Harden identity-change purge and sync-marker invariants - sandbox + workspace-server: delete the legacy ready-file arm - Show billing URL when browser cannot open - fix(pager): show folder-trust UI in minimal mode - fix(pager): drain task_backgrounded before no-wait headless exit - grok-agent-sdk: stop SDK-spawned agents from staging self-updates they can never adopt - Split settings_modal into directory module - Delegate VS Code SSH file links - grok-shell: release the workspace session binding when a session is removed - keep skills reachable when their name collides with a client builtin - Preserve semantic link targets
4808 lines
179 KiB
Rust
4808 lines
179 KiB
Rust
//! Shared prompt-side image types and helpers.
|
||
//!
|
||
//! This is the single source of truth for image data attached to the prompt.
|
||
//! Both the view layer ([`crate::views::prompt_widget`]) and the app/dispatch
|
||
//! layer ([`crate::app::dispatch`]) consume these types, so they live here
|
||
//! rather than inside any single view or app module.
|
||
|
||
use std::collections::HashSet;
|
||
use std::path::PathBuf;
|
||
use std::sync::{Arc, OnceLock};
|
||
use std::time::Instant;
|
||
|
||
use xai_ratatui_textarea::ElementId;
|
||
|
||
/// Tracing target for image-pipeline diagnostics.
|
||
///
|
||
/// Filter with `RUST_LOG=prompt_images=debug` to surface logs from
|
||
/// `insert_image`, `sync_images_with_textarea`, and the drop-classifier
|
||
/// (`try_read_dropped_paths`) — useful for RCing cross-platform paste
|
||
/// regressions from a single user's session capture.
|
||
pub const PROMPT_IMAGES_TRACING_TARGET: &str = "prompt_images";
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Scrollable image viewer state
|
||
// -------------------------------------------------------------------------
|
||
|
||
/// State for a modal image viewer.
|
||
///
|
||
/// Supports deferred loading: [`open_from_path_deferred`] returns instantly
|
||
/// with `loading: true`, then [`finish_loading`] performs the heavy I/O on
|
||
/// the next tick so the UI can show a spinner while the file is read.
|
||
pub struct ImageViewerState {
|
||
/// Original encoded image bytes.
|
||
pub image_bytes: Vec<u8>,
|
||
/// Encoded bytes prepared for terminal display (e.g. JPEG converted to PNG for Kitty).
|
||
pub display_bytes: Vec<u8>,
|
||
pub mime_type: String,
|
||
pub image_width: u32,
|
||
pub image_height: u32,
|
||
pub display_number: usize,
|
||
/// Override title (e.g. filename). When `None`, uses "Image #N".
|
||
pub title: Option<String>,
|
||
/// Deferred loading in progress; display bytes not yet available.
|
||
pub loading: bool,
|
||
/// Identity used by the shared terminal overlay upload owner.
|
||
pub overlay_owner_id: u64,
|
||
/// File path for deferred loading; consumed by [`finish_loading`].
|
||
source_path: Option<PathBuf>,
|
||
/// Shared modal chrome state (close button hit-test, hover, etc.).
|
||
pub modal_state: crate::modal_window_state::ModalWindowState,
|
||
}
|
||
|
||
pub fn decode_image_dimensions(bytes: &[u8]) -> Option<(u32, u32)> {
|
||
// Unrestricted: accepts any format `image` recognises so paste previews
|
||
// don't fail for non-allow-listed types.
|
||
xai_grok_tools::util::image_validate::validate_image_bytes_unrestricted(bytes, false)
|
||
.ok()
|
||
.map(|(w, h, _)| (w, h))
|
||
}
|
||
|
||
fn is_decodable_image(bytes: &[u8]) -> bool {
|
||
decode_image_dimensions(bytes).is_some()
|
||
}
|
||
|
||
impl ImageViewerState {
|
||
/// Create a viewer for a prompt-side image. Loads synchronously since
|
||
/// prompt images already have bytes in memory.
|
||
pub fn open(image: &PastedImage) -> Option<Self> {
|
||
let bytes = if let Some(ref b) = image.encoded_bytes {
|
||
b.to_vec()
|
||
} else if let Some(ref path) = image.session_image_path {
|
||
std::fs::read(path).ok()?
|
||
} else {
|
||
return None;
|
||
};
|
||
|
||
let (w, h) = decode_image_dimensions(&bytes)?;
|
||
let display_bytes = crate::terminal::image::prepare_overlay_image_bytes(&bytes)
|
||
.unwrap_or_else(|| bytes.clone());
|
||
|
||
Some(Self {
|
||
image_bytes: bytes,
|
||
display_bytes,
|
||
mime_type: image.mime_type.clone(),
|
||
image_width: w,
|
||
image_height: h,
|
||
display_number: image.display_number,
|
||
title: None,
|
||
loading: false,
|
||
overlay_owner_id: image.preview.identity(),
|
||
source_path: None,
|
||
modal_state: Default::default(),
|
||
})
|
||
}
|
||
|
||
/// Create a viewer from a file path, loading synchronously. Prefer
|
||
/// [`open_from_path_deferred`] from input handlers to avoid blocking.
|
||
pub fn open_from_path(path: &std::path::Path) -> Option<Self> {
|
||
let bytes = std::fs::read(path).ok()?;
|
||
|
||
let (w, h) = decode_image_dimensions(&bytes)?;
|
||
let display_bytes = crate::terminal::image::prepare_overlay_image_bytes(&bytes)
|
||
.unwrap_or_else(|| bytes.clone());
|
||
|
||
let mime_type = xai_grok_shared::clipboard::mime_from_bytes(&bytes).to_owned();
|
||
let file_name = path.file_name().map(|n| n.to_string_lossy().into_owned());
|
||
|
||
Some(Self {
|
||
image_bytes: bytes,
|
||
display_bytes,
|
||
mime_type,
|
||
image_width: w,
|
||
image_height: h,
|
||
display_number: 1,
|
||
title: file_name,
|
||
loading: false,
|
||
overlay_owner_id: crate::terminal::overlay::next_owner_id(),
|
||
source_path: None,
|
||
modal_state: Default::default(),
|
||
})
|
||
}
|
||
|
||
/// Create a loading-state viewer for a file path.
|
||
///
|
||
/// Returns immediately with `loading: true`. A background thread
|
||
/// runs [`load_image_data`] and the tick handler polls for the result,
|
||
/// then calls [`apply_loaded`] to complete the load.
|
||
pub fn open_from_path_deferred(path: &std::path::Path) -> Self {
|
||
Self {
|
||
image_bytes: Vec::new(),
|
||
display_bytes: Vec::new(),
|
||
mime_type: String::new(),
|
||
image_width: 0,
|
||
image_height: 0,
|
||
display_number: 1,
|
||
title: path.file_name().map(|n| n.to_string_lossy().into_owned()),
|
||
loading: true,
|
||
overlay_owner_id: crate::terminal::overlay::next_owner_id(),
|
||
source_path: Some(path.to_path_buf()),
|
||
modal_state: Default::default(),
|
||
}
|
||
}
|
||
|
||
/// Take the source path for background loading. Returns `None` if
|
||
/// already taken or not in loading state.
|
||
pub fn take_source_path(&mut self) -> Option<PathBuf> {
|
||
if self.loading {
|
||
self.source_path.take()
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
|
||
/// Apply loaded data from a background thread.
|
||
pub fn apply_loaded(&mut self, data: LoadedImageData) {
|
||
self.image_bytes = data.image_bytes;
|
||
self.display_bytes = data.display_bytes;
|
||
self.mime_type = data.mime_type;
|
||
self.image_width = data.image_width;
|
||
self.image_height = data.image_height;
|
||
self.loading = false;
|
||
}
|
||
|
||
/// Complete the deferred load synchronously (convenience for tests).
|
||
/// Returns `false` on failure.
|
||
pub fn finish_loading(&mut self) -> bool {
|
||
if !self.loading {
|
||
return true;
|
||
}
|
||
let Some(path) = self.source_path.take() else {
|
||
return false;
|
||
};
|
||
match load_image_data(&path) {
|
||
ImageLoadResult::Loaded(data) => {
|
||
self.apply_loaded(data);
|
||
true
|
||
}
|
||
ImageLoadResult::Failed => false,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Result of a background image load.
|
||
pub enum ImageLoadResult {
|
||
Loaded(LoadedImageData),
|
||
Failed,
|
||
}
|
||
|
||
/// Data loaded from an image file, ready to apply to the viewer.
|
||
pub struct LoadedImageData {
|
||
pub image_bytes: Vec<u8>,
|
||
pub display_bytes: Vec<u8>,
|
||
pub mime_type: String,
|
||
pub image_width: u32,
|
||
pub image_height: u32,
|
||
}
|
||
|
||
/// Load image data from a file path. This is the heavy work (file read,
|
||
/// decode, format conversion) that runs on a background thread.
|
||
pub fn load_image_data(path: &std::path::Path) -> ImageLoadResult {
|
||
let bytes = match std::fs::read(path) {
|
||
Ok(b) => b,
|
||
Err(e) => {
|
||
tracing::warn!("image viewer: failed to read {}: {e}", path.display());
|
||
return ImageLoadResult::Failed;
|
||
}
|
||
};
|
||
let (w, h) = match decode_image_dimensions(&bytes) {
|
||
Some(dims) => dims,
|
||
None => {
|
||
tracing::warn!("image viewer: failed to decode {}", path.display());
|
||
return ImageLoadResult::Failed;
|
||
}
|
||
};
|
||
let display_bytes = crate::terminal::image::prepare_overlay_image_bytes(&bytes)
|
||
.unwrap_or_else(|| bytes.clone());
|
||
let mime_type = xai_grok_shared::clipboard::mime_from_bytes(&bytes).to_owned();
|
||
|
||
ImageLoadResult::Loaded(LoadedImageData {
|
||
image_bytes: bytes,
|
||
display_bytes,
|
||
mime_type,
|
||
image_width: w,
|
||
image_height: h,
|
||
})
|
||
}
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Video viewer state
|
||
// -------------------------------------------------------------------------
|
||
|
||
/// Target frames per second for terminal video playback.
|
||
const VIDEO_FPS: f64 = 10.0;
|
||
|
||
/// Maximum pixel width for extracted frames.
|
||
const VIDEO_MAX_WIDTH: u32 = 640;
|
||
|
||
/// Modal video viewer state.
|
||
///
|
||
/// Holds pre-extracted frames and playback position. The rendering path
|
||
/// reuses the same `post_flush_escapes` pipeline as the image viewer.
|
||
pub struct VideoViewerState {
|
||
/// Pre-extracted frames (PNG for Kitty, JPEG for iTerm2).
|
||
pub frames: Vec<Vec<u8>>,
|
||
/// Current frame index.
|
||
current_frame: usize,
|
||
/// Whether playback is active.
|
||
pub playing: bool,
|
||
/// Playback frame rate.
|
||
pub fps: f64,
|
||
/// Last frame advance timestamp (for pacing).
|
||
last_frame_time: Instant,
|
||
/// Original video pixel dimensions.
|
||
pub video_width: u32,
|
||
pub video_height: u32,
|
||
/// Total duration in seconds.
|
||
pub duration_secs: f64,
|
||
/// Display title (file name).
|
||
pub title: Option<String>,
|
||
}
|
||
|
||
impl VideoViewerState {
|
||
/// Minimal viewer for tests (pager and render unit tests); the real
|
||
/// `open_from_path` needs ffmpeg and a graphics-capable terminal, neither
|
||
/// available under `cargo test`. Public so dependent crates can construct
|
||
/// a viewer without pulling in decode/graphics.
|
||
pub fn test_stub() -> Self {
|
||
Self {
|
||
frames: vec![Vec::new()],
|
||
current_frame: 0,
|
||
playing: false,
|
||
fps: 1.0,
|
||
last_frame_time: Instant::now(),
|
||
video_width: 1,
|
||
video_height: 1,
|
||
duration_secs: 0.0,
|
||
title: None,
|
||
}
|
||
}
|
||
|
||
/// Open a video file for playback. Returns `None` if ffmpeg is
|
||
/// unavailable or the video cannot be decoded.
|
||
///
|
||
/// Extracts all frames upfront — for short videos (5–15s at 10fps)
|
||
/// this is 50–150 frames and takes ~1–3 seconds.
|
||
pub fn open_from_path(path: &std::path::Path) -> Option<Self> {
|
||
use crate::terminal::image::{GraphicsProtocol, detect_graphics_protocol};
|
||
|
||
let protocol = detect_graphics_protocol();
|
||
if protocol == GraphicsProtocol::None {
|
||
return None;
|
||
}
|
||
|
||
let (width, height, duration, fps) = ffprobe_metadata(path)?;
|
||
let target_fps = VIDEO_FPS.min(fps);
|
||
|
||
// PNG for Kitty (required), JPEG for iTerm2 (smaller).
|
||
let ext = match protocol {
|
||
GraphicsProtocol::Kitty => "png",
|
||
GraphicsProtocol::ITerm2 => "jpg",
|
||
GraphicsProtocol::None => return None,
|
||
};
|
||
|
||
let vf = if width > VIDEO_MAX_WIDTH {
|
||
format!("fps={target_fps},scale={}:-2", VIDEO_MAX_WIDTH)
|
||
} else {
|
||
format!("fps={target_fps}")
|
||
};
|
||
|
||
let tmp_dir = make_temp_dir();
|
||
std::fs::create_dir_all(&tmp_dir).ok()?;
|
||
|
||
let mut ffmpeg_cmd = std::process::Command::new("ffmpeg");
|
||
ffmpeg_cmd
|
||
.args(["-hide_banner", "-loglevel", "error", "-i"])
|
||
.arg(path)
|
||
.args(["-vf", &vf, "-q:v", "5"])
|
||
.arg(tmp_dir.join(format!("%06d.{ext}")))
|
||
.stdin(std::process::Stdio::null())
|
||
.stdout(std::process::Stdio::null())
|
||
.stderr(std::process::Stdio::null());
|
||
xai_tty_utils::detach_std_command(&mut ffmpeg_cmd);
|
||
let status = ffmpeg_cmd.status();
|
||
|
||
match &status {
|
||
Err(e) => tracing::debug!("ffmpeg not available: {e}"),
|
||
Ok(s) if !s.success() => tracing::debug!("ffmpeg exited with {s}"),
|
||
_ => {}
|
||
}
|
||
if !status.as_ref().is_ok_and(|s| s.success()) {
|
||
let _ = std::fs::remove_dir_all(&tmp_dir);
|
||
return None;
|
||
}
|
||
|
||
let frames = load_frames(&tmp_dir, ext);
|
||
let _ = std::fs::remove_dir_all(&tmp_dir);
|
||
|
||
if frames.is_empty() {
|
||
return None;
|
||
}
|
||
|
||
Some(Self {
|
||
current_frame: 0,
|
||
playing: true,
|
||
fps: target_fps,
|
||
last_frame_time: Instant::now(),
|
||
video_width: width,
|
||
video_height: height,
|
||
duration_secs: duration,
|
||
title: path.file_name().map(|n| n.to_string_lossy().into_owned()),
|
||
frames,
|
||
})
|
||
}
|
||
|
||
/// Advance playback based on elapsed time. Returns `true` if the
|
||
/// frame changed (caller should redraw).
|
||
pub fn tick(&mut self) -> bool {
|
||
if !self.playing || self.frames.is_empty() {
|
||
return false;
|
||
}
|
||
|
||
let frame_duration = std::time::Duration::from_secs_f64(1.0 / self.fps);
|
||
if self.last_frame_time.elapsed() < frame_duration {
|
||
return false;
|
||
}
|
||
|
||
self.current_frame = (self.current_frame + 1) % self.frames.len();
|
||
self.last_frame_time = Instant::now();
|
||
true
|
||
}
|
||
|
||
/// Toggle play/pause.
|
||
pub fn toggle_play_pause(&mut self) {
|
||
self.playing = !self.playing;
|
||
if self.playing {
|
||
self.last_frame_time = Instant::now();
|
||
}
|
||
}
|
||
|
||
/// Seek forward by ~1 second.
|
||
pub fn seek_forward(&mut self) {
|
||
let skip = self.fps.round() as usize;
|
||
self.current_frame = (self.current_frame + skip).min(self.frames.len().saturating_sub(1));
|
||
self.last_frame_time = Instant::now();
|
||
}
|
||
|
||
/// Seek backward by ~1 second.
|
||
pub fn seek_backward(&mut self) {
|
||
let skip = self.fps.round() as usize;
|
||
self.current_frame = self.current_frame.saturating_sub(skip);
|
||
self.last_frame_time = Instant::now();
|
||
}
|
||
|
||
/// Current frame image data.
|
||
pub fn current_frame_data(&self) -> &[u8] {
|
||
&self.frames[self.current_frame]
|
||
}
|
||
|
||
/// Current playback position in seconds.
|
||
pub fn position_secs(&self) -> f64 {
|
||
if self.fps <= 0.0 {
|
||
return 0.0;
|
||
}
|
||
self.current_frame as f64 / self.fps
|
||
}
|
||
|
||
/// Playback progress fraction (0.0–1.0).
|
||
pub fn progress(&self) -> f64 {
|
||
if self.frames.len() <= 1 {
|
||
return 0.0;
|
||
}
|
||
self.current_frame as f64 / (self.frames.len() - 1) as f64
|
||
}
|
||
}
|
||
|
||
/// Extract a single poster frame from a video file via ffmpeg.
|
||
/// Returns `(image_bytes, width, height)`. The image format depends on
|
||
/// the active terminal protocol (PNG for Kitty, JPEG for iTerm2).
|
||
pub fn extract_poster_frame(path: &std::path::Path) -> Option<(Vec<u8>, u32, u32)> {
|
||
use crate::terminal::image::{GraphicsProtocol, detect_graphics_protocol};
|
||
|
||
let protocol = detect_graphics_protocol();
|
||
let ext = match protocol {
|
||
GraphicsProtocol::Kitty => "png",
|
||
GraphicsProtocol::ITerm2 => "jpg",
|
||
GraphicsProtocol::None => return None,
|
||
};
|
||
|
||
// Try seeking to 1s for a representative frame; fall back to first frame.
|
||
let try_extract = |seek: Option<&str>| {
|
||
let mut cmd = std::process::Command::new("ffmpeg");
|
||
cmd.args(["-hide_banner", "-loglevel", "error"]);
|
||
if let Some(ss) = seek {
|
||
cmd.args(["-ss", ss]);
|
||
}
|
||
cmd.args(["-i"])
|
||
.arg(path)
|
||
.args(["-frames:v", "1", "-f", "image2pipe", "-vcodec", ext])
|
||
.arg("-")
|
||
.stdin(std::process::Stdio::null())
|
||
.stderr(std::process::Stdio::null());
|
||
xai_tty_utils::detach_std_command(&mut cmd);
|
||
cmd.output()
|
||
.ok()
|
||
.filter(|o| o.status.success() && !o.stdout.is_empty())
|
||
};
|
||
|
||
let output = try_extract(Some("1")).or_else(|| try_extract(None))?;
|
||
|
||
let (w, h) = decode_image_dimensions(&output.stdout)?;
|
||
|
||
Some((output.stdout, w, h))
|
||
}
|
||
|
||
/// Create a unique temp directory path for frame extraction.
|
||
fn make_temp_dir() -> PathBuf {
|
||
std::env::temp_dir().join(format!(
|
||
"grok-video-{}-{}",
|
||
std::process::id(),
|
||
std::time::SystemTime::now()
|
||
.duration_since(std::time::UNIX_EPOCH)
|
||
.unwrap_or_default()
|
||
.as_nanos()
|
||
))
|
||
}
|
||
|
||
/// Read all frame files from `dir` with the given extension, sorted by name.
|
||
fn load_frames(dir: &std::path::Path, ext: &str) -> Vec<Vec<u8>> {
|
||
let mut paths: Vec<_> = std::fs::read_dir(dir)
|
||
.into_iter()
|
||
.flatten()
|
||
.filter_map(|e| e.ok())
|
||
.map(|e| e.path())
|
||
.filter(|p| p.extension().is_some_and(|e| e == ext))
|
||
.collect();
|
||
paths.sort();
|
||
paths.iter().filter_map(|p| std::fs::read(p).ok()).collect()
|
||
}
|
||
|
||
/// Probe video metadata via ffprobe. Returns `(width, height, duration, fps)`.
|
||
fn ffprobe_metadata(path: &std::path::Path) -> Option<(u32, u32, f64, f64)> {
|
||
let mut cmd = std::process::Command::new("ffprobe");
|
||
cmd.args([
|
||
"-v",
|
||
"quiet",
|
||
"-select_streams",
|
||
"v:0",
|
||
"-show_entries",
|
||
"stream=width,height,r_frame_rate,duration",
|
||
"-show_entries",
|
||
"format=duration",
|
||
"-of",
|
||
"csv=p=0:s=,",
|
||
])
|
||
.arg(path)
|
||
.stdin(std::process::Stdio::null())
|
||
.stderr(std::process::Stdio::null());
|
||
xai_tty_utils::detach_std_command(&mut cmd);
|
||
let output = match cmd.output() {
|
||
Ok(o) => o,
|
||
Err(e) => {
|
||
tracing::debug!("ffprobe not available: {e}");
|
||
return None;
|
||
}
|
||
};
|
||
|
||
if !output.status.success() {
|
||
return None;
|
||
}
|
||
|
||
let text = String::from_utf8_lossy(&output.stdout);
|
||
// Stream line: width,height,r_frame_rate[,duration]
|
||
// Format line: duration
|
||
let lines: Vec<&str> = text.trim().lines().collect();
|
||
let parts: Vec<&str> = lines.first()?.split(',').collect();
|
||
if parts.len() < 3 {
|
||
return None;
|
||
}
|
||
|
||
let width: u32 = parts[0].trim().parse().ok()?;
|
||
let height: u32 = parts[1].trim().parse().ok()?;
|
||
let fps = parse_fraction(parts[2].trim()).unwrap_or(30.0);
|
||
|
||
// Try stream duration, fall back to format duration.
|
||
let duration = parts
|
||
.get(3)
|
||
.and_then(|d| d.trim().parse::<f64>().ok())
|
||
.or_else(|| lines.get(1).and_then(|l| l.trim().parse::<f64>().ok()))
|
||
.unwrap_or(0.0);
|
||
|
||
Some((width, height, duration, fps))
|
||
}
|
||
|
||
/// Parse a fraction like "30/1" or "30000/1001".
|
||
fn parse_fraction(s: &str) -> Option<f64> {
|
||
if let Some((num, den)) = s.split_once('/') {
|
||
let n: f64 = num.parse().ok()?;
|
||
let d: f64 = den.parse().ok()?;
|
||
if d == 0.0 {
|
||
return None;
|
||
}
|
||
Some(n / d)
|
||
} else {
|
||
s.parse::<f64>().ok()
|
||
}
|
||
}
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Inline media info (for scrollback inline rendering)
|
||
// -------------------------------------------------------------------------
|
||
|
||
/// Metadata for inline media rendering in the scrollback.
|
||
/// Returned by blocks that want to display media inline.
|
||
#[derive(Debug, Clone)]
|
||
pub struct InlineMediaInfo {
|
||
/// Path to the media file.
|
||
pub path: PathBuf,
|
||
/// Pixel dimensions of the media.
|
||
pub width: u32,
|
||
pub height: u32,
|
||
/// Whether this is a video (poster frame) vs. a static image.
|
||
pub is_video: bool,
|
||
/// Alt text / description from the markdown `` syntax.
|
||
pub alt_text: String,
|
||
}
|
||
|
||
use xai_grok_shared::clipboard::mime_to_extension;
|
||
|
||
/// A single image pasted into the prompt.
|
||
///
|
||
/// Tracks everything needed for display, preview, persistence, and
|
||
/// eventual submission as a `ContentBlock::Image`.
|
||
#[derive(Debug, Clone)]
|
||
pub struct PastedImage {
|
||
/// The [`ElementId`] of the corresponding `KIND_IMAGE` element in the
|
||
/// `TextArea` buffer. Used to reconcile live elements against stored images.
|
||
pub element_id: ElementId,
|
||
|
||
/// 1-based display number for the current prompt (e.g. the `1` in
|
||
/// `[Image #1]`). Reset when the prompt is cleared.
|
||
pub display_number: usize,
|
||
|
||
/// MIME type of the encoded image (e.g. `"image/png"`).
|
||
pub mime_type: String,
|
||
|
||
/// Header-validated dimensions used for pre-insertion/send policy.
|
||
pub dimensions: Option<(u32, u32)>,
|
||
|
||
/// Size of the encoded image bytes.
|
||
pub byte_len: usize,
|
||
|
||
/// Encoded image bytes kept in memory. Set to `None` once the image
|
||
/// has been durably written to [`session_image_path`](Self::session_image_path)
|
||
/// to avoid holding large buffers for the lifetime of the prompt.
|
||
pub encoded_bytes: Option<Arc<[u8]>>,
|
||
|
||
/// Original user-visible path for file-path pastes.
|
||
///
|
||
/// This remains stable after persistence so previews show the path the
|
||
/// user pasted. Model/send loading uses `session_image_path` first.
|
||
pub source_path: Option<PathBuf>,
|
||
|
||
/// Temporary staging path before the image is finalized into the session
|
||
/// directory. Cleaned up when the chip is removed or on send.
|
||
pub staged_temp_path: Option<PathBuf>,
|
||
|
||
/// Final durable path under `session_dir(info)/images/`. Once set, this
|
||
/// is the canonical on-disk location and [`encoded_bytes`](Self::encoded_bytes)
|
||
/// may be released.
|
||
pub session_image_path: Option<PathBuf>,
|
||
|
||
/// Shared preview preparation state; draw only reads its resolved result.
|
||
pub preview: PromptImagePreview,
|
||
}
|
||
|
||
impl PastedImage {
|
||
pub fn preview_preparation(&self) -> Option<PromptImagePreviewPreparation> {
|
||
if !self.preview.is_pending() {
|
||
return None;
|
||
}
|
||
Some(PromptImagePreviewPreparation {
|
||
preview: self.preview.clone(),
|
||
source: self.encoded_bytes.as_ref()?.clone(),
|
||
protocol: crate::terminal::image::detect_graphics_protocol(),
|
||
dimensions: self.dimensions,
|
||
})
|
||
}
|
||
|
||
pub fn prepare_preview_blocking(&self) {
|
||
if let Some(preparation) = self.preview_preparation() {
|
||
preparation.run();
|
||
}
|
||
}
|
||
|
||
pub fn preview_dimensions(&self) -> Option<(u32, u32)> {
|
||
self.dimensions.or_else(|| self.preview.dimensions())
|
||
}
|
||
}
|
||
|
||
/// Shared readiness of the terminal pixel payload for a pasted image.
|
||
#[derive(Debug, Clone)]
|
||
pub struct PromptImagePreview {
|
||
identity: u64,
|
||
result: Arc<OnceLock<PromptImagePreviewResult>>,
|
||
}
|
||
|
||
#[derive(Debug)]
|
||
enum PromptImagePreviewResult {
|
||
Ready {
|
||
bytes: Arc<[u8]>,
|
||
dimensions: (u32, u32),
|
||
},
|
||
Failed,
|
||
Unsupported {
|
||
dimensions: (u32, u32),
|
||
},
|
||
}
|
||
|
||
impl Default for PromptImagePreview {
|
||
fn default() -> Self {
|
||
Self {
|
||
identity: crate::terminal::overlay::next_owner_id(),
|
||
result: Arc::new(OnceLock::new()),
|
||
}
|
||
}
|
||
}
|
||
|
||
impl PromptImagePreview {
|
||
pub fn identity(&self) -> u64 {
|
||
self.identity
|
||
}
|
||
|
||
pub fn is_pending(&self) -> bool {
|
||
self.result.get().is_none()
|
||
}
|
||
|
||
pub fn is_failed(&self) -> bool {
|
||
matches!(self.result.get(), Some(PromptImagePreviewResult::Failed))
|
||
}
|
||
|
||
pub fn prepared(&self) -> Option<(&[u8], (u32, u32))> {
|
||
match self.result.get()? {
|
||
PromptImagePreviewResult::Ready { bytes, dimensions } => {
|
||
Some((bytes.as_ref(), *dimensions))
|
||
}
|
||
PromptImagePreviewResult::Failed | PromptImagePreviewResult::Unsupported { .. } => None,
|
||
}
|
||
}
|
||
|
||
pub fn dimensions(&self) -> Option<(u32, u32)> {
|
||
match self.result.get()? {
|
||
PromptImagePreviewResult::Ready { dimensions, .. }
|
||
| PromptImagePreviewResult::Unsupported { dimensions } => Some(*dimensions),
|
||
PromptImagePreviewResult::Failed => None,
|
||
}
|
||
}
|
||
|
||
fn finish(&self, result: PromptImagePreviewResult) {
|
||
let _ = self.result.set(result);
|
||
}
|
||
|
||
pub fn mark_failed(&self) {
|
||
self.finish(PromptImagePreviewResult::Failed);
|
||
}
|
||
|
||
#[cfg(any(test, feature = "test-support"))]
|
||
pub fn ready_for_test(bytes: Vec<u8>, dimensions: (u32, u32)) -> Self {
|
||
let preview = Self::default();
|
||
preview.finish(PromptImagePreviewResult::Ready {
|
||
bytes: Arc::from(bytes),
|
||
dimensions,
|
||
});
|
||
preview
|
||
}
|
||
}
|
||
|
||
/// Off-thread work required to prepare one prompt image preview.
|
||
#[derive(Debug, Clone)]
|
||
pub struct PromptImagePreviewPreparation {
|
||
preview: PromptImagePreview,
|
||
source: Arc<[u8]>,
|
||
protocol: crate::terminal::image::GraphicsProtocol,
|
||
dimensions: Option<(u32, u32)>,
|
||
}
|
||
|
||
impl PromptImagePreviewPreparation {
|
||
pub fn preview(&self) -> PromptImagePreview {
|
||
self.preview.clone()
|
||
}
|
||
|
||
pub fn run(self) {
|
||
let Some(dimensions) = self
|
||
.dimensions
|
||
.or_else(|| decode_image_dimensions(&self.source))
|
||
else {
|
||
self.preview.finish(PromptImagePreviewResult::Failed);
|
||
return;
|
||
};
|
||
let result = match self.protocol {
|
||
crate::terminal::image::GraphicsProtocol::Kitty => {
|
||
let bytes =
|
||
if crate::terminal::image::kitty_format_from_bytes(&self.source).is_some() {
|
||
self.source
|
||
} else {
|
||
let Some(converted) =
|
||
crate::terminal::image::prepare_kitty_overlay_image_bytes(&self.source)
|
||
else {
|
||
self.preview.finish(PromptImagePreviewResult::Failed);
|
||
return;
|
||
};
|
||
Arc::from(converted)
|
||
};
|
||
PromptImagePreviewResult::Ready { bytes, dimensions }
|
||
}
|
||
crate::terminal::image::GraphicsProtocol::ITerm2 => PromptImagePreviewResult::Ready {
|
||
bytes: self.source,
|
||
dimensions,
|
||
},
|
||
crate::terminal::image::GraphicsProtocol::None => {
|
||
PromptImagePreviewResult::Unsupported { dimensions }
|
||
}
|
||
};
|
||
self.preview.finish(result);
|
||
}
|
||
}
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Display helpers
|
||
// -------------------------------------------------------------------------
|
||
|
||
/// Build the buffer text for an image chip.
|
||
///
|
||
/// Always path-free: `[Image #1]`. Filepaths live on the [`PastedImage`]
|
||
/// record and surface only in the hover/cursor preview overlay — never in
|
||
/// the prompt-bar chip.
|
||
pub fn display_text(display_number: usize) -> String {
|
||
format!("[Image #{display_number}]")
|
||
}
|
||
|
||
/// Derive a file extension from a MIME type.
|
||
///
|
||
/// Delegates to [`xai_grok_shared::clipboard::mime_to_extension`].
|
||
pub fn extension_for_mime(mime: &str) -> &'static str {
|
||
mime_to_extension(mime)
|
||
}
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Reconciliation
|
||
// -------------------------------------------------------------------------
|
||
|
||
/// Remove entries from `images` whose `element_id` is not present in
|
||
/// `live_ids`.
|
||
///
|
||
/// This is the primary mechanism that prevents deleted image chips from
|
||
/// being submitted. Call at cleanup boundaries (prompt clear, drain-for-send,
|
||
/// explicit chip deletion) rather than on every keystroke.
|
||
pub fn reconcile(images: &mut Vec<PastedImage>, live_ids: &HashSet<ElementId>) {
|
||
images.retain(|img| {
|
||
if live_ids.contains(&img.element_id) {
|
||
return true;
|
||
}
|
||
// Clean up temp-file-only staged images for removed chips.
|
||
// Session-persisted files are intentionally left as orphans in v1.
|
||
cleanup_temp_file(img);
|
||
false
|
||
});
|
||
}
|
||
|
||
/// Drain `images`, cleaning up each entry's temp file. The caller
|
||
/// is responsible for resetting `image_counter` if appropriate —
|
||
/// resetting the counter is a separate semantic concern from clearing
|
||
/// a Vec's contents. Use [`reset_counter`] when the counter should
|
||
/// also be zeroed.
|
||
pub fn drain_and_cleanup(images: &mut Vec<PastedImage>) {
|
||
for img in images.drain(..) {
|
||
cleanup_temp_file(&img);
|
||
}
|
||
}
|
||
|
||
/// Reset the monotonic image counter to 0. Pair with
|
||
/// [`drain_and_cleanup`] when a prompt is fully reset (Ctrl+C,
|
||
/// `set_text("")`, successful send).
|
||
pub fn reset_counter(image_counter: &mut usize) {
|
||
*image_counter = 0;
|
||
}
|
||
|
||
/// Drain images and reset the counter in one call. Prefer
|
||
/// [`drain_and_cleanup`] and [`reset_counter`] when only one side
|
||
/// is needed; this shim exists so "wipe everything" call sites
|
||
/// don't have to repeat the pair.
|
||
pub fn clear(images: &mut Vec<PastedImage>, image_counter: &mut usize) {
|
||
drain_and_cleanup(images);
|
||
reset_counter(image_counter);
|
||
}
|
||
|
||
/// Delete a staged temp file if it exists and no session-persisted copy
|
||
/// has been made. Session-persisted files are left intact (orphan cleanup
|
||
/// is acceptable in v1).
|
||
pub fn cleanup_temp_file(img: &PastedImage) {
|
||
if img.session_image_path.is_some() {
|
||
return; // already persisted to session dir, leave it
|
||
}
|
||
if let Some(ref path) = img.staged_temp_path {
|
||
let _ = std::fs::remove_file(path);
|
||
}
|
||
}
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Construction from file path
|
||
// -------------------------------------------------------------------------
|
||
|
||
/// Image file extensions recognized when a pasted path is checked.
|
||
///
|
||
/// Formats omitted on purpose: HEIC/HEIF/AVIF/ICO/SVG. The inline
|
||
/// image overlay doesn't decode or render them today, so promoting
|
||
/// them to chips would falsely promise rendering. Drops of these
|
||
/// extensions fall through to NonImage path text instead.
|
||
const IMAGE_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "bmp", "tiff", "tif"];
|
||
|
||
/// Normalize a media path by dropping the Windows `\\?\` verbatim prefix
|
||
/// (`\\?\C:\x` → `C:\x`, `\\?\UNC\srv\s` → `\\srv\s`). Applied once when a
|
||
/// scrollback media ref is built so every consumer (display, open, copy) gets
|
||
/// a path that GUI openers and the clipboard can resolve. No-op off Windows.
|
||
fn strip_verbatim_prefix(path: &std::path::Path) -> PathBuf {
|
||
let s = path.to_string_lossy();
|
||
if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
|
||
PathBuf::from(format!(r"\\{rest}"))
|
||
} else if let Some(rest) = s.strip_prefix(r"\\?\") {
|
||
PathBuf::from(rest)
|
||
} else {
|
||
path.to_path_buf()
|
||
}
|
||
}
|
||
|
||
/// Drive-letter (`X:\` / `X:/`) or UNC (`\\…`) Windows path. Used to
|
||
/// short-circuit [`shell_unescape`], which would otherwise treat the
|
||
/// separator backslashes as escape characters and collapse the path.
|
||
fn looks_like_windows_path(s: &str) -> bool {
|
||
let b = s.as_bytes();
|
||
let drive = b.len() >= 3
|
||
&& b[0].is_ascii_alphabetic()
|
||
&& b[1] == b':'
|
||
&& (b[2] == b'\\' || b[2] == b'/');
|
||
drive || b.starts_with(b"\\\\")
|
||
}
|
||
|
||
/// Strip shell backslash escapes (`\X` → `X`) so terminal-pasted file
|
||
/// paths with escaped spaces / parens resolve on disk. Windows-style
|
||
/// paths pass through unchanged — see [`looks_like_windows_path`].
|
||
fn shell_unescape(s: &str) -> std::borrow::Cow<'_, str> {
|
||
if !s.contains('\\') || looks_like_windows_path(s) {
|
||
return std::borrow::Cow::Borrowed(s);
|
||
}
|
||
let mut result = String::with_capacity(s.len());
|
||
let mut chars = s.chars();
|
||
while let Some(c) = chars.next() {
|
||
if c == '\\' {
|
||
match chars.next() {
|
||
Some(next) => result.push(next),
|
||
None => result.push(c),
|
||
}
|
||
} else {
|
||
result.push(c);
|
||
}
|
||
}
|
||
std::borrow::Cow::Owned(result)
|
||
}
|
||
|
||
/// Strip a single pair of matching ASCII single or double quotes that
|
||
/// wrap `s`. Otherwise return `s` unchanged.
|
||
fn strip_matching_quotes(s: &str) -> &str {
|
||
let bytes = s.as_bytes();
|
||
if bytes.len() >= 2 {
|
||
let first = bytes[0];
|
||
let last = bytes[bytes.len() - 1];
|
||
if (first == b'"' && last == b'"') || (first == b'\'' && last == b'\'') {
|
||
return &s[1..s.len() - 1];
|
||
}
|
||
}
|
||
s
|
||
}
|
||
|
||
/// Resolve one paste token to a filesystem path.
|
||
///
|
||
/// Accepts bare paths (with optional shell backslash escapes), `file://`
|
||
/// URLs (percent-decoded by the `url` crate), and paths wrapped in a
|
||
/// single pair of `"…"` or `'…'` quotes. Returns `None` if a `file://`
|
||
/// prefix is present but the URL is not parseable as a local path.
|
||
fn token_to_path(token: &str) -> Option<PathBuf> {
|
||
let token = token.trim();
|
||
if token.is_empty() {
|
||
return None;
|
||
}
|
||
let unquoted = strip_matching_quotes(token);
|
||
|
||
if unquoted.starts_with("file://") {
|
||
let url = url::Url::parse(unquoted).ok()?;
|
||
if url.scheme() != "file" {
|
||
return None;
|
||
}
|
||
return url.to_file_path().ok();
|
||
}
|
||
|
||
let unescaped = shell_unescape(unquoted);
|
||
Some(PathBuf::from(unescaped.into_owned()))
|
||
}
|
||
|
||
/// Validate that `path` points to a readable image file and load it as
|
||
/// a [`PastedImage`]. Returns `None` if the extension isn't recognized,
|
||
/// the file is missing, empty, or whose bytes don't sniff as an image.
|
||
fn read_image_at_path(path: &std::path::Path) -> Option<PastedImage> {
|
||
let ext = path.extension()?.to_str()?.to_ascii_lowercase();
|
||
if !IMAGE_EXTENSIONS.contains(&ext.as_str()) {
|
||
return None;
|
||
}
|
||
if !path.is_file() {
|
||
return None;
|
||
}
|
||
|
||
let data = std::fs::read(path).ok()?;
|
||
if data.is_empty() {
|
||
return None;
|
||
}
|
||
|
||
let mime_type = xai_grok_shared::clipboard::mime_from_bytes(&data);
|
||
if mime_type == "application/octet-stream" {
|
||
return None;
|
||
}
|
||
let dimensions = decode_image_dimensions(&data)?;
|
||
|
||
Some(PastedImage {
|
||
element_id: ElementId::from_raw(0),
|
||
display_number: 0,
|
||
mime_type: mime_type.to_owned(),
|
||
dimensions: Some(dimensions),
|
||
byte_len: data.len(),
|
||
encoded_bytes: Some(Arc::from(data)),
|
||
source_path: Some(path.to_path_buf()),
|
||
staged_temp_path: None,
|
||
session_image_path: None,
|
||
preview: PromptImagePreview::default(),
|
||
})
|
||
}
|
||
|
||
/// Whether `s` begins with a drop-style path anchor: `/`, `~/`, a
|
||
/// Windows drive (`X:\` or `X:/`), or a Windows UNC (`\\`). ASCII-only
|
||
/// so it never inspects a partial UTF-8 codepoint.
|
||
fn starts_with_path_anchor(s: &str) -> bool {
|
||
let b = s.as_bytes();
|
||
matches!(b.first(), Some(b'/'))
|
||
|| b.starts_with(b"~/")
|
||
|| (b.len() >= 3
|
||
&& b[0].is_ascii_alphabetic()
|
||
&& b[1] == b':'
|
||
&& (b[2] == b'\\' || b[2] == b'/'))
|
||
|| b.starts_with(b"\\\\")
|
||
}
|
||
|
||
/// Whether `s` begins with something the space-splitter should treat as
|
||
/// a path token boundary: a bare path anchor, a `file://` URL, or a
|
||
/// quoted form of either (quotes are stripped before re-checking).
|
||
pub fn starts_with_drop_anchor(s: &str) -> bool {
|
||
if starts_with_path_anchor(s) || s.starts_with("file://") {
|
||
return true;
|
||
}
|
||
let unq = strip_matching_quotes(s);
|
||
!std::ptr::eq(unq, s) && (starts_with_path_anchor(unq) || unq.starts_with("file://"))
|
||
}
|
||
|
||
/// Split `s` on each space that is immediately followed by a drop-style
|
||
/// anchor: a bare path start (`/`, `~/`, `X:\`) or a `file://` URL.
|
||
/// Operates on ASCII bytes (the only characters we need to match) and
|
||
/// never splits inside a multi-byte UTF-8 sequence because all match
|
||
/// bytes are ASCII.
|
||
fn split_space_before_path(s: &str) -> Vec<&str> {
|
||
let bytes = s.as_bytes();
|
||
let mut parts = Vec::new();
|
||
let mut start = 0;
|
||
let mut i = 0;
|
||
while i < bytes.len() {
|
||
if bytes[i] == b' ' && starts_with_drop_anchor(&s[i + 1..]) {
|
||
parts.push(&s[start..i]);
|
||
start = i + 1;
|
||
}
|
||
i += 1;
|
||
}
|
||
parts.push(&s[start..]);
|
||
parts
|
||
}
|
||
|
||
/// Tokenize one trimmed line into one or more path candidates.
|
||
///
|
||
/// Returns the whole line as a single token unless every space-separated
|
||
/// part itself starts with a drop-style anchor — the all-parts gate
|
||
/// keeps prose like `"check /tmp/foo.png please"` or bash pastes like
|
||
/// `"! /tmp/foo.png"` from being mis-split. Empty input yields an empty
|
||
/// `Vec` so the caller's `flat_map` skips blank lines cleanly.
|
||
fn space_split_line(line: &str) -> Vec<&str> {
|
||
let line = line.trim();
|
||
if line.is_empty() {
|
||
return Vec::new();
|
||
}
|
||
let parts: Vec<&str> = split_space_before_path(line)
|
||
.into_iter()
|
||
.map(str::trim)
|
||
.filter(|s| !s.is_empty())
|
||
.collect();
|
||
if parts.len() > 1 && parts.iter().all(|p| starts_with_drop_anchor(p)) {
|
||
parts
|
||
} else {
|
||
vec![line]
|
||
}
|
||
}
|
||
|
||
/// Normalize line endings (`\r\n`/`\r` → `\n`) without allocating when
|
||
/// the input has no carriage returns (the common case from macOS).
|
||
fn normalize_line_endings(text: &str) -> std::borrow::Cow<'_, str> {
|
||
if text.contains('\r') {
|
||
std::borrow::Cow::Owned(text.replace("\r\n", "\n").replace('\r', "\n"))
|
||
} else {
|
||
std::borrow::Cow::Borrowed(text)
|
||
}
|
||
}
|
||
|
||
/// Filter the output of [`try_read_dropped_paths`] down to image
|
||
/// entries only — kept as a stable API for prior image-only callers.
|
||
/// See [`try_read_dropped_paths`] for the full tokenisation and
|
||
/// decoding behaviour spec.
|
||
pub fn try_read_images_from_paste(text: &str) -> Vec<PastedImage> {
|
||
try_read_dropped_paths(text)
|
||
.into_iter()
|
||
.filter_map(|d| match d {
|
||
DroppedPath::Image(img) => Some(img),
|
||
DroppedPath::NonImage(_) => None,
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// Classification of a single drop-style paste token.
|
||
///
|
||
/// Returned by [`try_read_dropped_paths`]. Images are routed to
|
||
/// `[Image #N]` chip insertion; non-images are inserted as decoded
|
||
/// absolute path text so the user can reference them by path / let the
|
||
/// agent read them.
|
||
#[derive(Debug)]
|
||
pub enum DroppedPath {
|
||
/// Token resolved to a readable image file (extension in
|
||
/// [`IMAGE_EXTENSIONS`] and bytes sniff as a known image format).
|
||
Image(PastedImage),
|
||
/// Token resolved to a `file://` URL or an existing on-disk path
|
||
/// (file *or* directory) that is not a recognised image — the
|
||
/// caller should insert this decoded path as plain text in the
|
||
/// prompt.
|
||
///
|
||
/// The stored `PathBuf` is canonicalised when possible
|
||
/// (`canonicalize()` succeeds) so symlinks resolve to their target,
|
||
/// matching the image branch's `read_image_at_path` behaviour. The
|
||
/// raw decoded path is used as a fallback when canonicalisation
|
||
/// fails (broken symlinks, permission errors, network mounts that
|
||
/// are unreachable, or `file://` URLs to non-existent paths).
|
||
#[allow(dead_code)]
|
||
NonImage(PathBuf),
|
||
}
|
||
|
||
/// Resolve one paste token to either an image chip, a non-image path
|
||
/// for text insertion, or `None` when the token does not look like a
|
||
/// drop-event path at all.
|
||
///
|
||
/// Non-image bare paths must satisfy *three* conditions to be
|
||
/// intercepted: the token must start with a drop anchor (`/`, `~/`,
|
||
/// `X:\`), the path must exist on disk (file OR directory), and the
|
||
/// raw bytes must not decode as an image (else the image branch wins).
|
||
/// The drop-anchor gate prevents arbitrary prose strings that happen
|
||
/// to coincide with a filesystem path from being intercepted from
|
||
/// inside a sentence. `file://` URLs bypass both the anchor gate and
|
||
/// the existence gate (an explicit URI is unambiguous drop intent).
|
||
///
|
||
/// Predicate order: cheap anchor/`file://` checks run first; the
|
||
/// (relatively) more expensive [`read_image_at_path`] file-read +
|
||
/// magic-byte sniff runs only for tokens that pass the gate. Bare
|
||
/// cwd-relative image filenames are intentionally NOT intercepted —
|
||
/// drag-and-drop / Finder-paste always emit absolute paths or
|
||
/// `file://` URLs, never `foo.png`-style relative refs.
|
||
///
|
||
/// **Silent fallthroughs**: a typo'd `file://` URL (missing path)
|
||
/// and an image-extension-but-garbage-bytes drop both land as text
|
||
/// with no toast. Drops can be partial (network mounts, broken
|
||
/// symlinks, corrupted exports) and a toast for every such case
|
||
/// would be noisier than the silent path-as-text behaviour the
|
||
/// user already gets.
|
||
fn try_read_dropped_path(token: &str) -> Option<DroppedPath> {
|
||
let trimmed_unq = strip_matching_quotes(token.trim());
|
||
let is_file_url = trimmed_unq.starts_with("file://");
|
||
let is_bare_anchored = starts_with_path_anchor(trimmed_unq);
|
||
if !is_file_url && !is_bare_anchored {
|
||
return None;
|
||
}
|
||
let path = token_to_path(token)?;
|
||
// Reject empty or root paths. `file:///` decodes to an empty
|
||
// path on some platforms or to `/` on others; either way it is
|
||
// never a legitimate drop target (root would force the user to
|
||
// "attach the entire filesystem").
|
||
if path.as_os_str().is_empty() || path == std::path::Path::new("/") {
|
||
return None;
|
||
}
|
||
// Reject the bytes that actually corrupt the terminal/text-paste
|
||
// pipeline — NUL, CR, LF — produced by pathological encodings
|
||
// like `file:///path%00.png`. TAB and other low-control bytes
|
||
// are legal in Unix filenames and tolerated by the TUI text
|
||
// rendering, so they pass through.
|
||
if path
|
||
.as_os_str()
|
||
.as_encoded_bytes()
|
||
.iter()
|
||
.any(|&b| b == 0 || b == b'\r' || b == b'\n')
|
||
{
|
||
return None;
|
||
}
|
||
// Image branch wins when extension + magic bytes both match —
|
||
// keeps the established "`[Image #N]` for image drops" behaviour
|
||
// for tokens that already passed the anchor / `file://` gate.
|
||
if let Some(img) = read_image_at_path(&path) {
|
||
return Some(DroppedPath::Image(img));
|
||
}
|
||
// `file://` is an unambiguous drop URI even when the file is
|
||
// missing (stale links, network mounts). Bare anchored paths
|
||
// require the target to *exist* (file or directory) so that prose
|
||
// typed at the path-anchor start, e.g. `/tmp is a dir on Unix`,
|
||
// falls through to plain text paste.
|
||
if !is_file_url && !path.exists() {
|
||
return None;
|
||
}
|
||
// Canonicalise when possible so the inserted text matches what
|
||
// other code paths see (e.g. cwd-relative comparisons). Fall back
|
||
// to the raw decoded path when canonicalisation fails (broken
|
||
// symlinks, permission issues, network mounts, `file://` URLs to
|
||
// missing files).
|
||
let resolved = dunce::canonicalize(&path).unwrap_or(path);
|
||
Some(DroppedPath::NonImage(resolved))
|
||
}
|
||
|
||
/// Parse `text` from a terminal paste into a list of [`DroppedPath`]
|
||
/// entries — images and non-image file paths interleaved in the order
|
||
/// they appeared.
|
||
///
|
||
/// This is the superset routine used by the drag-and-drop / Finder-paste
|
||
/// pipeline. It handles `file://` URLs (percent-decoded, including
|
||
/// `%20`/`%23`/`%3F` etc.), bare absolute paths, shell-escaped tokens,
|
||
/// quoted tokens, and multi-file payloads (newline- or space-separated).
|
||
/// Trailing whitespace and CRLF/CR line endings are tolerated.
|
||
///
|
||
/// Non-image bare paths are only intercepted when the token itself
|
||
/// begins with a drop anchor (`/`, `~/`, `X:\`) **and** the path
|
||
/// exists on disk. This guards against prose that happens to coincide
|
||
/// with a filesystem path being eaten from inside a sentence. See
|
||
/// [`try_read_dropped_path`] for the full predicate.
|
||
///
|
||
/// **Whole-paste-or-nothing.** A paste of
|
||
/// `"file:///foo.png\nplease look at this"` must not emit just the
|
||
/// image and silently lose the comment — "screenshot URL + caption"
|
||
/// pastes are common in practice (browser/Slack right-click
|
||
/// "Copy image address" plus hand-typed prose). The function
|
||
/// returns an empty `Vec` if *any* non-whitespace line fails to
|
||
/// resolve to a drop, so the caller falls through to plain-text
|
||
/// paste of the whole payload.
|
||
///
|
||
/// Concretely:
|
||
/// - Empty/whitespace lines are separators (skipped).
|
||
/// - For each non-empty line, all tokens emitted by
|
||
/// [`space_split_line`] must resolve. If any fail, the whole paste
|
||
/// falls through to prose.
|
||
/// - If every non-empty line fully resolves, entries are emitted in
|
||
/// source order.
|
||
///
|
||
/// Returns an empty `Vec` when no token resolves to either an image
|
||
/// or a recognised file path — callers should then treat the payload
|
||
/// as a plain text paste.
|
||
pub fn try_read_dropped_paths(text: &str) -> Vec<DroppedPath> {
|
||
let trimmed = text.trim();
|
||
if trimmed.is_empty() {
|
||
return Vec::new();
|
||
}
|
||
|
||
let normalized = normalize_line_endings(trimmed);
|
||
let mut result = Vec::new();
|
||
for line in normalized.split('\n') {
|
||
let tokens = space_split_line(line);
|
||
if tokens.is_empty() {
|
||
// Blank line — treat as separator.
|
||
continue;
|
||
}
|
||
let resolved: Vec<DroppedPath> = tokens
|
||
.iter()
|
||
.filter_map(|t| try_read_dropped_path(t))
|
||
.collect();
|
||
// Any-line-fails-falls-through: even a single-token line that
|
||
// doesn't resolve poisons the whole paste so the user's prose
|
||
// is preserved via the caller's plain-text-paste fallback.
|
||
if resolved.len() < tokens.len() {
|
||
return Vec::new();
|
||
}
|
||
result.extend(resolved);
|
||
}
|
||
let (images, non_images) = result.iter().fold((0usize, 0usize), |(i, n), e| match e {
|
||
DroppedPath::Image(_) => (i + 1, n),
|
||
DroppedPath::NonImage(_) => (i, n + 1),
|
||
});
|
||
tracing::debug!(
|
||
target: PROMPT_IMAGES_TRACING_TARGET,
|
||
input_len = text.len(),
|
||
anchor = paste_anchor_kind(trimmed),
|
||
images,
|
||
non_images,
|
||
"try_read_dropped_paths verdict",
|
||
);
|
||
result
|
||
}
|
||
|
||
/// Categorical label for the leading bytes of a paste, used in
|
||
/// diagnostic logging only. Lossy by design — the classifier itself
|
||
/// re-derives the real predicate.
|
||
fn paste_anchor_kind(trimmed: &str) -> &'static str {
|
||
if trimmed.starts_with("file://") {
|
||
"file_url"
|
||
} else if trimmed.starts_with('/') {
|
||
"absolute"
|
||
} else if trimmed.starts_with("~/") {
|
||
"tilde"
|
||
} else if trimmed.len() >= 3
|
||
&& trimmed.as_bytes()[0].is_ascii_alphabetic()
|
||
&& &trimmed.as_bytes()[1..3] == b":\\"
|
||
{
|
||
"windows_drive"
|
||
} else if trimmed.starts_with("\\\\") {
|
||
"windows_unc"
|
||
} else {
|
||
"none"
|
||
}
|
||
}
|
||
|
||
/// Check whether `text` looks like a single file path to an image that
|
||
/// exists on disk. Returns the loaded image if so.
|
||
///
|
||
/// Thin wrapper over [`try_read_images_from_paste`] that returns `Some`
|
||
/// only when the paste resolves to exactly one image; returns `None`
|
||
/// for zero or multiple images. Trailing whitespace (including a single
|
||
/// trailing `\n` or `\r\n`) is tolerated; multi-image payloads return
|
||
/// `None` so the caller can route them through the multi-image helper
|
||
/// instead.
|
||
pub fn try_read_image_from_path(text: &str) -> Option<PastedImage> {
|
||
let mut images = try_read_images_from_paste(text);
|
||
if images.len() == 1 {
|
||
Some(images.remove(0))
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Construction from clipboard data
|
||
// -------------------------------------------------------------------------
|
||
|
||
/// Build a `PastedImage` from raw clipboard [`ImageData`].
|
||
///
|
||
/// `element_id` and `display_number` are set to placeholder values and
|
||
/// will be overwritten by [`crate::views::prompt_widget::PromptWidget::insert_image`].
|
||
pub fn from_clipboard_data(data: &crate::clipboard::ImageData) -> PastedImage {
|
||
PastedImage {
|
||
element_id: ElementId::from_raw(0),
|
||
display_number: 0,
|
||
mime_type: data.mime_type.clone(),
|
||
dimensions: decode_image_dimensions(&data.data),
|
||
byte_len: data.data.len(),
|
||
encoded_bytes: Some(Arc::from(data.data.clone())),
|
||
source_path: None,
|
||
staged_temp_path: None,
|
||
session_image_path: None,
|
||
preview: PromptImagePreview::default(),
|
||
}
|
||
}
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Session image persistence
|
||
// -------------------------------------------------------------------------
|
||
|
||
/// Persist image bytes into the session `images/` directory.
|
||
///
|
||
/// Creates the directory if it does not exist. Uses a UUID-v4 filename
|
||
/// to avoid collisions (two pastes of the same image are independent).
|
||
///
|
||
/// On success:
|
||
/// - Sets `img.session_image_path` to the written file.
|
||
/// - Leaves `img.source_path` unchanged as the original display path.
|
||
/// - Drops `encoded_bytes` from memory.
|
||
pub fn persist_to_session(
|
||
img: &mut PastedImage,
|
||
session_images_dir: &std::path::Path,
|
||
) -> anyhow::Result<()> {
|
||
let bytes = img
|
||
.encoded_bytes
|
||
.as_ref()
|
||
.ok_or_else(|| anyhow::anyhow!("no encoded bytes to persist"))?;
|
||
|
||
std::fs::create_dir_all(session_images_dir)?;
|
||
|
||
let ext = extension_for_mime(&img.mime_type);
|
||
let filename = format!("image-{}.{}", uuid::Uuid::new_v4(), ext);
|
||
let path = session_images_dir.join(&filename);
|
||
// Atomic write: a crash mid-write leaves no partially-written final file.
|
||
let tmp_path = path.with_extension(format!("{ext}.tmp"));
|
||
let write_result: anyhow::Result<()> = (|| {
|
||
use std::io::Write as _;
|
||
let mut f = std::fs::File::create(&tmp_path)?;
|
||
f.write_all(bytes)?;
|
||
f.sync_all()?;
|
||
drop(f);
|
||
std::fs::rename(&tmp_path, &path)?;
|
||
Ok(())
|
||
})();
|
||
if write_result.is_err() {
|
||
let _ = std::fs::remove_file(&tmp_path);
|
||
}
|
||
write_result?;
|
||
|
||
img.session_image_path = Some(path);
|
||
img.encoded_bytes = None;
|
||
Ok(())
|
||
}
|
||
|
||
/// Derive the `images/` directory for a session.
|
||
///
|
||
/// Returns `None` if session identity is not yet known.
|
||
pub fn session_images_dir(
|
||
session_id: Option<&agent_client_protocol::SessionId>,
|
||
cwd: &std::path::Path,
|
||
) -> Option<PathBuf> {
|
||
let sid = session_id?;
|
||
let info = xai_grok_shared::session::info::Info {
|
||
id: sid.clone(),
|
||
cwd: cwd.to_string_lossy().into_owned(),
|
||
};
|
||
Some(xai_grok_shared::session::session_dir(&info).join("images"))
|
||
}
|
||
|
||
/// Derive the `mermaid/` cache directory for a session.
|
||
///
|
||
/// Mirrors [`session_images_dir`]: rendered diagram PNGs live alongside the
|
||
/// session's other artifacts (`events.jsonl`, `images/`) so they are owned by
|
||
/// the session and torn down with it. Returns `None` until session identity is
|
||
/// known (no diagrams are cached on disk before then).
|
||
pub fn session_mermaid_dir(
|
||
session_id: Option<&agent_client_protocol::SessionId>,
|
||
cwd: &std::path::Path,
|
||
) -> Option<PathBuf> {
|
||
let sid = session_id?;
|
||
let info = xai_grok_shared::session::info::Info {
|
||
id: sid.clone(),
|
||
cwd: cwd.to_string_lossy().into_owned(),
|
||
};
|
||
Some(xai_grok_shared::session::session_dir(&info).join("mermaid"))
|
||
}
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Image loading for send
|
||
// -------------------------------------------------------------------------
|
||
|
||
const MAX_SEND_BYTES: usize = 50_000_000; // 50 MB
|
||
|
||
/// Load image bytes from a `PastedImage` (in-memory or from disk).
|
||
/// Returns `None` if the image cannot be loaded or exceeds [`MAX_SEND_BYTES`].
|
||
pub fn load_for_send(img: &PastedImage) -> Option<(Vec<u8>, String)> {
|
||
let raw_bytes = if let Some(ref b) = img.encoded_bytes {
|
||
b.to_vec()
|
||
} else if let Some(ref path) = img.session_image_path {
|
||
match std::fs::read(path) {
|
||
Ok(b) => b,
|
||
Err(e) => {
|
||
tracing::warn!(
|
||
"image {}: failed to read {}: {e}",
|
||
img.display_number,
|
||
path.display()
|
||
);
|
||
return None;
|
||
}
|
||
}
|
||
} else {
|
||
tracing::warn!("image {}: no bytes or file path", img.display_number);
|
||
return None;
|
||
};
|
||
|
||
if raw_bytes.len() > MAX_SEND_BYTES {
|
||
tracing::warn!(
|
||
"image {}: {} bytes exceeds send limit, skipping",
|
||
img.display_number,
|
||
raw_bytes.len()
|
||
);
|
||
return None;
|
||
}
|
||
if let Some((width, height)) = img.dimensions
|
||
&& (width < 8 || height < 8)
|
||
{
|
||
tracing::warn!(
|
||
"image {}: dimensions {}x{} are below the 8x8 minimum",
|
||
img.display_number,
|
||
width,
|
||
height
|
||
);
|
||
return None;
|
||
}
|
||
|
||
Some((raw_bytes, img.mime_type.clone()))
|
||
}
|
||
|
||
// -------------------------------------------------------------------------
|
||
// ACP content block construction
|
||
// -------------------------------------------------------------------------
|
||
|
||
/// Build ACP `ContentBlock` values from prompt text and attached images,
|
||
/// with an optional fallback that re-loads orphan
|
||
/// `[Image #N: <path>]` placeholders from disk.
|
||
///
|
||
/// Behaviour for each placeholder in `text`:
|
||
/// - If a [`PastedImage`] with the matching `display_number` is present
|
||
/// in `images`, the placeholder is left untouched and the
|
||
/// `PastedImage` provides the bytes.
|
||
/// - Otherwise, if `workspace_cwd` is `Some`, attempt to load the
|
||
/// placeholder's path via the shared
|
||
/// [`xai_grok_shell::session::placeholder_images::load_placeholder_image`]
|
||
/// helper. On success: attach a `ContentBlock::Image` and leave the
|
||
/// placeholder text in place. On failure: strip the placeholder from
|
||
/// the forwarded text and emit a `tracing::warn!` (no UI alert
|
||
/// surface exists today — the warn log is the established pattern,
|
||
/// see `load_for_send` for prior art).
|
||
/// - When `workspace_cwd` is `None`, the orphan placeholder is left in
|
||
/// the text unchanged (legacy behaviour, used by unit tests).
|
||
///
|
||
/// Path validation, extension allowlist, and the 50-MB size cap come
|
||
/// from the shared helper so the TUI and the server use the same rules.
|
||
pub fn build_content_blocks_with_workspace(
|
||
text: String,
|
||
images: Vec<PastedImage>,
|
||
workspace_cwd: Option<&std::path::Path>,
|
||
) -> Vec<agent_client_protocol::ContentBlock> {
|
||
let allowed: Option<Vec<std::path::PathBuf>> =
|
||
workspace_cwd.map(xai_grok_shared::placeholder_images::default_allowed_prefixes);
|
||
build_content_blocks_with_prefixes(text, images, allowed.as_deref())
|
||
}
|
||
|
||
/// Test-injectable variant of [`build_content_blocks_with_workspace`].
|
||
///
|
||
/// Accepts an explicit `allowed_prefixes` slice so unit tests can
|
||
/// pass a hermetic prefix list and avoid reading the ambient process
|
||
/// `$HOME`. Production calls go through
|
||
/// [`build_content_blocks_with_workspace`].
|
||
pub fn build_content_blocks_with_prefixes(
|
||
text: String,
|
||
images: Vec<PastedImage>,
|
||
allowed_prefixes: Option<&[std::path::PathBuf]>,
|
||
) -> Vec<agent_client_protocol::ContentBlock> {
|
||
build_content_blocks_with_prefixes_and_caps(
|
||
text,
|
||
images,
|
||
allowed_prefixes,
|
||
xai_grok_shared::placeholder_images::MAX_PLACEHOLDER_AGGREGATE_BYTES,
|
||
)
|
||
}
|
||
|
||
/// Test-injectable variant of [`build_content_blocks_with_prefixes`]
|
||
/// that takes an explicit aggregate-bytes cap.
|
||
///
|
||
/// Mirrors the server-side
|
||
/// [`xai_grok_shell::session::placeholder_images::recover_orphan_placeholders_with_prefixes_and_caps`].
|
||
/// Aggregate-cap semantics match: `aggregate + image.len() > cap`
|
||
/// triggers the loop break (inclusive boundary — a running total
|
||
/// exactly equal to the cap is admitted).
|
||
pub fn build_content_blocks_with_prefixes_and_caps(
|
||
text: String,
|
||
images: Vec<PastedImage>,
|
||
allowed_prefixes: Option<&[std::path::PathBuf]>,
|
||
aggregate_max: usize,
|
||
) -> Vec<agent_client_protocol::ContentBlock> {
|
||
use agent_client_protocol::{ContentBlock, ImageContent, TextContent};
|
||
use base64::Engine as _;
|
||
|
||
// Phase 1: rewrite the text to strip failed-load placeholders, and
|
||
// collect successfully-loaded orphan images. PastedImage-backed
|
||
// placeholders (display_number present in `images`) are left alone.
|
||
let (rewritten_text, orphan_images) =
|
||
resolve_orphan_placeholders(text, &images, allowed_prefixes, aggregate_max);
|
||
|
||
// Phase 2: drop `[Image #N: <path>]` → `[Image #N]`. The path
|
||
// tempts the model into a redundant `Read` on its own
|
||
// attachment.
|
||
let rewritten_text =
|
||
xai_grok_shared::placeholder_images::strip_paths_from_image_placeholders(rewritten_text);
|
||
|
||
let mut blocks = Vec::with_capacity(1 + images.len() + orphan_images.len());
|
||
blocks.push(ContentBlock::Text(TextContent::new(rewritten_text)));
|
||
|
||
for img in &images {
|
||
let (bytes, mime_type) = match load_for_send(img) {
|
||
Some(loaded) => loaded,
|
||
None => continue,
|
||
};
|
||
|
||
let data = base64::engine::general_purpose::STANDARD.encode(&bytes);
|
||
|
||
// Canonicalize the display path only when no durable wire path exists.
|
||
let uri = if let Some(path) = img.session_image_path.as_ref() {
|
||
Some(format!("file://{}", path.display()))
|
||
} else {
|
||
img.source_path
|
||
.as_ref()
|
||
.and_then(|path| dunce::canonicalize(path).ok())
|
||
.map(|canonical| format!("file://{}", canonical.display()))
|
||
};
|
||
|
||
blocks.push(ContentBlock::Image(
|
||
ImageContent::new(data, mime_type)
|
||
.uri(uri)
|
||
// Record the `[Image #N]` display number so the server resolves
|
||
// the token by number, not list position. See `AttachedImages`.
|
||
.meta(Some(
|
||
xai_grok_shared::placeholder_images::display_number_meta(img.display_number),
|
||
)),
|
||
));
|
||
}
|
||
|
||
for orphan in orphan_images {
|
||
blocks.push(ContentBlock::Image(orphan));
|
||
}
|
||
|
||
blocks
|
||
}
|
||
|
||
/// Scan `text` for `[Image #N: <path>]` placeholders that lack a
|
||
/// matching [`PastedImage`] and attempt to recover them from disk.
|
||
///
|
||
/// Returns `(rewritten_text, recovered_images)`:
|
||
/// - Placeholders with a matching `PastedImage` (by `display_number`)
|
||
/// are left untouched.
|
||
/// - Orphan placeholders whose path loads successfully via the shared
|
||
/// helper are kept in the text and produce a recovered
|
||
/// `ImageContent`.
|
||
/// - Orphan placeholders whose path fails to load are stripped from
|
||
/// the text and a `tracing::warn!` is emitted.
|
||
///
|
||
/// `allowed_prefixes == None` short-circuits to the legacy behaviour:
|
||
/// the text is returned unchanged and no recovery is attempted.
|
||
fn resolve_orphan_placeholders(
|
||
text: String,
|
||
images: &[PastedImage],
|
||
allowed_prefixes: Option<&[std::path::PathBuf]>,
|
||
aggregate_max: usize,
|
||
) -> (String, Vec<agent_client_protocol::ImageContent>) {
|
||
use agent_client_protocol::ImageContent;
|
||
use base64::Engine as _;
|
||
|
||
let Some(allowed) = allowed_prefixes else {
|
||
return (text, Vec::new());
|
||
};
|
||
|
||
let attached_numbers: std::collections::HashSet<usize> =
|
||
images.iter().map(|i| i.display_number).collect();
|
||
|
||
let placeholders = xai_grok_shared::placeholder_images::extract_placeholders(&text);
|
||
if placeholders.is_empty() {
|
||
return (text, Vec::new());
|
||
}
|
||
|
||
let mut recovered: Vec<ImageContent> = Vec::new();
|
||
// Spans to delete from the text (failed loads). Recorded as
|
||
// half-open byte ranges so we can splice them out in one pass.
|
||
let mut strip_spans: Vec<(usize, usize)> = Vec::new();
|
||
let mut aggregate_bytes: usize = 0;
|
||
|
||
for ph in &placeholders {
|
||
if attached_numbers.contains(&ph.display_number) {
|
||
continue; // PastedImage already supplies these bytes.
|
||
}
|
||
match xai_grok_shared::placeholder_images::load_placeholder_image(&ph.path, allowed) {
|
||
Ok(loaded) => {
|
||
let next_total = aggregate_bytes.saturating_add(loaded.data.len());
|
||
if next_total > aggregate_max {
|
||
tracing::warn!(
|
||
path = ?ph.path,
|
||
aggregate_bytes,
|
||
per_image_bytes = loaded.data.len(),
|
||
cap = aggregate_max,
|
||
"TUI placeholder fallback: aggregate-bytes cap reached; skipping remaining orphan placeholders",
|
||
);
|
||
break;
|
||
}
|
||
aggregate_bytes = next_total;
|
||
let data = base64::engine::general_purpose::STANDARD.encode(&loaded.data);
|
||
let uri = dunce::canonicalize(std::path::Path::new(&ph.path))
|
||
.ok()
|
||
.map(|p| format!("file://{}", p.display()));
|
||
recovered.push(
|
||
ImageContent::new(data, loaded.mime_type)
|
||
.uri(uri)
|
||
// Same `[Image #N]` → number mapping as inline images.
|
||
.meta(Some(
|
||
xai_grok_shared::placeholder_images::display_number_meta(
|
||
ph.display_number,
|
||
),
|
||
)),
|
||
);
|
||
tracing::info!(
|
||
path = ?ph.path,
|
||
"TUI placeholder fallback: loaded orphan image from disk",
|
||
);
|
||
}
|
||
Err(e) => {
|
||
tracing::warn!(
|
||
path = ?ph.path,
|
||
error = %e,
|
||
"TUI placeholder fallback: orphan image failed to load; stripping placeholder text",
|
||
);
|
||
strip_spans.push(ph.span);
|
||
}
|
||
}
|
||
}
|
||
|
||
if strip_spans.is_empty() {
|
||
return (text, recovered);
|
||
}
|
||
|
||
// Splice out the failed-load spans in reverse order so earlier
|
||
// indices stay valid. Only collapse the single whitespace seam
|
||
// created by the strip itself — global collapsing of all
|
||
// 2+-space runs would mangle code blocks, indentation-sensitive
|
||
// markdown, and double-space punctuation elsewhere in the text.
|
||
let mut rewritten = text;
|
||
strip_spans.sort_by_key(|(s, _)| *s);
|
||
for (start, end) in strip_spans.into_iter().rev() {
|
||
collapse_strip_seam(&mut rewritten, start, end);
|
||
}
|
||
(rewritten, recovered)
|
||
}
|
||
|
||
/// Splice out `[start, end)` from `text` and, only if both sides of
|
||
/// the seam are ASCII whitespace, collapse the run to a single space.
|
||
/// Newlines are preserved (treated as non-collapsible boundaries).
|
||
fn collapse_strip_seam(text: &mut String, start: usize, end: usize) {
|
||
text.replace_range(start..end, "");
|
||
// After removal `start` is the seam position. Walk left/right
|
||
// over the immediate space chars only; do not cross newlines or
|
||
// non-space whitespace (tab/CR).
|
||
let bytes = text.as_bytes();
|
||
let mut left = start;
|
||
while left > 0 && bytes.get(left - 1) == Some(&b' ') {
|
||
left -= 1;
|
||
}
|
||
let mut right = start;
|
||
while right < bytes.len() && bytes.get(right) == Some(&b' ') {
|
||
right += 1;
|
||
}
|
||
if right - left > 1 {
|
||
// Replace the run with a single space if there is content on
|
||
// both sides; otherwise (seam at start/end of text) trim
|
||
// entirely.
|
||
let has_left_content = left > 0;
|
||
let has_right_content = right < bytes.len();
|
||
let replacement = if has_left_content && has_right_content {
|
||
" "
|
||
} else {
|
||
""
|
||
};
|
||
text.replace_range(left..right, replacement);
|
||
}
|
||
}
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Scrollback image references
|
||
// -------------------------------------------------------------------------
|
||
|
||
/// An image file referenced in scrollback content via `` markdown
|
||
/// or a bare absolute path. Validated on construction: path must exist, have a
|
||
/// recognized image extension, and decode successfully.
|
||
#[derive(Debug, Clone)]
|
||
pub struct ScrollbackImageRef {
|
||
/// Absolute path to the image file on disk.
|
||
pub path: PathBuf,
|
||
/// Pixel dimensions `(width, height)`, decoded on construction.
|
||
pub dimensions: Option<(u32, u32)>,
|
||
/// Alt text from the `` markdown syntax (empty for bare paths).
|
||
pub alt_text: String,
|
||
}
|
||
|
||
impl ScrollbackImageRef {
|
||
/// Construct from a file path, returning `None` if the path doesn't
|
||
/// exist, isn't a file, lacks a recognized image extension, or can't
|
||
/// be decoded as an image.
|
||
pub fn from_path(path: impl Into<PathBuf>) -> Option<Self> {
|
||
Self::from_path_with_alt(path, String::new())
|
||
}
|
||
|
||
/// Construct with alt text from the markdown `` syntax.
|
||
pub fn from_path_with_alt(path: impl Into<PathBuf>, alt_text: String) -> Option<Self> {
|
||
let path = strip_verbatim_prefix(&path.into());
|
||
let ext = path.extension()?.to_str()?.to_ascii_lowercase();
|
||
if !IMAGE_EXTENSIONS.contains(&ext.as_str()) {
|
||
return None;
|
||
}
|
||
if !path.is_file() {
|
||
return None;
|
||
}
|
||
let bytes = std::fs::read(&path).ok()?;
|
||
if !is_decodable_image(&bytes) {
|
||
return None;
|
||
}
|
||
let dimensions = decode_image_dimensions(&bytes);
|
||
Some(Self {
|
||
path,
|
||
dimensions,
|
||
alt_text,
|
||
})
|
||
}
|
||
}
|
||
|
||
/// Regex pattern for `` — captures alt text (group 1) and path (group 2).
|
||
const MARKDOWN_IMAGE_REF_PATTERN: &str = r"!\[([^\]]*)\]\(([^)\s]+)\)";
|
||
|
||
/// Whether text consists only of markdown media references (``).
|
||
///
|
||
/// `resolved_ref_count` is the total number of resolved media refs
|
||
/// (images + videos) extracted from the same text. Unresolved or
|
||
/// undecodable paths are not counted, preventing false positives.
|
||
pub fn is_media_only_markdown(text: &str, resolved_ref_count: usize) -> bool {
|
||
use std::sync::LazyLock;
|
||
|
||
if resolved_ref_count == 0 {
|
||
return false;
|
||
}
|
||
|
||
static ONLY_MD_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
|
||
regex::Regex::new(&format!(r"^\s*(?:{}\s*)+$", MARKDOWN_IMAGE_REF_PATTERN)).unwrap()
|
||
});
|
||
static MD_RE: LazyLock<regex::Regex> =
|
||
LazyLock::new(|| regex::Regex::new(MARKDOWN_IMAGE_REF_PATTERN).unwrap());
|
||
|
||
if !ONLY_MD_RE.is_match(text) {
|
||
return false;
|
||
}
|
||
|
||
let unique_ref_count = MD_RE
|
||
.captures_iter(text)
|
||
.filter_map(|cap| cap.get(2).map(|m| m.as_str()))
|
||
.collect::<std::collections::HashSet<_>>()
|
||
.len();
|
||
unique_ref_count == resolved_ref_count
|
||
}
|
||
|
||
/// Extract image references from text (markdown or tool output).
|
||
///
|
||
/// Scans for `` patterns and bare absolute image paths.
|
||
/// Only returns references where the file exists on disk and decodes as an
|
||
/// image.
|
||
pub fn extract_image_refs(text: &str) -> Vec<ScrollbackImageRef> {
|
||
use std::sync::LazyLock;
|
||
|
||
static MD_RE: LazyLock<regex::Regex> =
|
||
LazyLock::new(|| regex::Regex::new(MARKDOWN_IMAGE_REF_PATTERN).unwrap());
|
||
|
||
static PATH_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
|
||
let exts = IMAGE_EXTENSIONS.join("|");
|
||
// Unix absolute paths (/...) and Windows absolute paths (C:\..., C:/...,
|
||
// or \\... UNC). Drive letters accept either separator.
|
||
regex::Regex::new(&format!(
|
||
r"(?:^|[\s,])((?:/|[A-Za-z]:[\\/]|\\\\)[^\s,]+\.(?:{exts}))(?:[\s,.(]|$)"
|
||
))
|
||
.unwrap()
|
||
});
|
||
|
||
let mut refs = Vec::new();
|
||
let mut seen = std::collections::HashSet::new();
|
||
|
||
for cap in MD_RE.captures_iter(text) {
|
||
if let Some(m) = cap.get(2) {
|
||
let path_str = m.as_str();
|
||
let alt_text = cap
|
||
.get(1)
|
||
.map(|a| a.as_str().to_owned())
|
||
.unwrap_or_default();
|
||
if seen.insert(path_str.to_owned())
|
||
&& let Some(r) = ScrollbackImageRef::from_path_with_alt(path_str, alt_text)
|
||
{
|
||
refs.push(r);
|
||
}
|
||
}
|
||
}
|
||
|
||
for cap in PATH_RE.captures_iter(text) {
|
||
if let Some(m) = cap.get(1) {
|
||
let path_str = m.as_str();
|
||
if seen.insert(path_str.to_owned())
|
||
&& let Some(r) = ScrollbackImageRef::from_path(path_str)
|
||
{
|
||
refs.push(r);
|
||
}
|
||
}
|
||
}
|
||
|
||
refs
|
||
}
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Scrollback video references
|
||
// -------------------------------------------------------------------------
|
||
|
||
const VIDEO_EXTENSIONS: &[&str] = &["mp4", "webm", "mov", "avi", "mkv"];
|
||
|
||
/// A video file referenced in scrollback content via `` markdown
|
||
/// or a bare absolute path. Validated on construction: path must exist and have a
|
||
/// recognized video extension.
|
||
#[derive(Debug, Clone)]
|
||
pub struct ScrollbackVideoRef {
|
||
/// Absolute path to the video file on disk.
|
||
pub path: PathBuf,
|
||
/// Alt text from the `` markdown syntax (empty for bare paths).
|
||
pub alt_text: String,
|
||
}
|
||
|
||
impl ScrollbackVideoRef {
|
||
/// Validate that `path` exists and has a recognized video extension.
|
||
pub fn from_path(path: impl Into<PathBuf>) -> Option<Self> {
|
||
Self::from_path_with_alt(path, String::new())
|
||
}
|
||
|
||
/// Construct with alt text from the markdown `` syntax.
|
||
pub fn from_path_with_alt(path: impl Into<PathBuf>, alt_text: String) -> Option<Self> {
|
||
let path = strip_verbatim_prefix(&path.into());
|
||
let ext = path.extension()?.to_str()?.to_ascii_lowercase();
|
||
if !VIDEO_EXTENSIONS.contains(&ext.as_str()) || !path.is_file() {
|
||
return None;
|
||
}
|
||
Some(Self { path, alt_text })
|
||
}
|
||
}
|
||
|
||
/// Extract video references from text (markdown `` and bare paths).
|
||
pub fn extract_video_refs(text: &str) -> Vec<ScrollbackVideoRef> {
|
||
use std::sync::LazyLock;
|
||
|
||
// Reuse the markdown image ref pattern — video_gen uses .
|
||
static MD_RE: LazyLock<regex::Regex> =
|
||
LazyLock::new(|| regex::Regex::new(MARKDOWN_IMAGE_REF_PATTERN).unwrap());
|
||
|
||
static VIDEO_PATH_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
|
||
let exts = VIDEO_EXTENSIONS.join("|");
|
||
// Unix absolute paths (/...) and Windows absolute paths (C:\..., C:/...,
|
||
// or \\... UNC). Drive letters accept either separator.
|
||
regex::Regex::new(&format!(
|
||
r"(?:^|[\s,])((?:/|[A-Za-z]:[\\/]|\\\\)[^\s,]+\.(?:{exts}))(?:[\s,.(]|$)"
|
||
))
|
||
.unwrap()
|
||
});
|
||
|
||
let mut refs = Vec::new();
|
||
let mut seen = std::collections::HashSet::new();
|
||
|
||
// MD_RE: group 1 = alt text, group 2 = path
|
||
for cap in MD_RE.captures_iter(text) {
|
||
if let Some(m) = cap.get(2) {
|
||
let path_str = m.as_str();
|
||
let alt_text = cap
|
||
.get(1)
|
||
.map(|a| a.as_str().to_owned())
|
||
.unwrap_or_default();
|
||
if seen.insert(path_str.to_owned())
|
||
&& let Some(r) = ScrollbackVideoRef::from_path_with_alt(path_str, alt_text)
|
||
{
|
||
refs.push(r);
|
||
}
|
||
}
|
||
}
|
||
|
||
// VIDEO_PATH_RE: group 1 = path (no alt text)
|
||
for cap in VIDEO_PATH_RE.captures_iter(text) {
|
||
if let Some(m) = cap.get(1) {
|
||
let path_str = m.as_str();
|
||
if seen.insert(path_str.to_owned())
|
||
&& let Some(r) = ScrollbackVideoRef::from_path(path_str)
|
||
{
|
||
refs.push(r);
|
||
}
|
||
}
|
||
}
|
||
|
||
refs
|
||
}
|
||
|
||
// =========================================================================
|
||
// Tests
|
||
// =========================================================================
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
/// The image dir is keyed off the session's cwd — the glue the cross-cwd
|
||
/// resume fix relies on: with `AgentSession.cwd` anchored to the origin cwd,
|
||
/// pasted images land under that origin, not the process cwd.
|
||
#[test]
|
||
fn session_images_dir_keys_off_the_passed_cwd() {
|
||
let id = agent_client_protocol::SessionId::new("sid-xyz");
|
||
let a = session_images_dir(Some(&id), std::path::Path::new("/origin/a")).unwrap();
|
||
let b = session_images_dir(Some(&id), std::path::Path::new("/origin/b")).unwrap();
|
||
assert_ne!(
|
||
a, b,
|
||
"same id under different cwd must map to different image dirs"
|
||
);
|
||
assert!(a.ends_with(std::path::Path::new("sid-xyz").join("images")));
|
||
assert!(session_images_dir(None, std::path::Path::new("/x")).is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn strip_verbatim_prefix_normalizes_paths() {
|
||
let strip = |s| strip_verbatim_prefix(std::path::Path::new(s));
|
||
assert_eq!(strip(r"\\?\C:\x\1.jpg"), PathBuf::from(r"C:\x\1.jpg"));
|
||
assert_eq!(
|
||
strip(r"\\?\UNC\srv\s\1.jpg"),
|
||
PathBuf::from(r"\\srv\s\1.jpg")
|
||
);
|
||
// Plain paths (the only form off Windows) pass through untouched.
|
||
assert_eq!(strip("/Users/k/1.jpg"), PathBuf::from("/Users/k/1.jpg"));
|
||
}
|
||
|
||
// Helper to create a `PastedImage` with minimal required fields.
|
||
fn make_image(element_id: u64, display_number: usize) -> PastedImage {
|
||
PastedImage {
|
||
element_id: ElementId::from_raw(element_id),
|
||
display_number,
|
||
mime_type: "image/png".into(),
|
||
dimensions: None,
|
||
byte_len: 1024,
|
||
encoded_bytes: None,
|
||
source_path: None,
|
||
staged_temp_path: None,
|
||
session_image_path: None,
|
||
preview: PromptImagePreview::default(),
|
||
}
|
||
}
|
||
|
||
// ----- display_text ---------------------------------------------------
|
||
|
||
#[test]
|
||
fn display_text_format() {
|
||
assert_eq!(display_text(1), "[Image #1]");
|
||
assert_eq!(display_text(5), "[Image #5]");
|
||
assert_eq!(display_text(10), "[Image #10]");
|
||
}
|
||
|
||
// ----- extension_for_mime ---------------------------------------------
|
||
|
||
#[test]
|
||
fn extension_for_known_mimes() {
|
||
assert_eq!(extension_for_mime("image/png"), "png");
|
||
assert_eq!(extension_for_mime("image/jpeg"), "jpg");
|
||
assert_eq!(extension_for_mime("image/tiff"), "tiff");
|
||
}
|
||
|
||
#[test]
|
||
fn extension_for_unknown_mime() {
|
||
assert_eq!(extension_for_mime("application/octet-stream"), "bin");
|
||
}
|
||
|
||
fn make_persistable_image(bytes: Vec<u8>) -> PastedImage {
|
||
PastedImage {
|
||
element_id: ElementId::from_raw(1),
|
||
display_number: 1,
|
||
mime_type: "image/png".into(),
|
||
dimensions: None,
|
||
byte_len: bytes.len(),
|
||
encoded_bytes: Some(Arc::from(bytes)),
|
||
source_path: None,
|
||
staged_temp_path: None,
|
||
session_image_path: None,
|
||
preview: PromptImagePreview::default(),
|
||
}
|
||
}
|
||
|
||
/// `persist_to_session` is atomic (write tmp + rename) and clears
|
||
/// `encoded_bytes` after success.
|
||
#[test]
|
||
fn persist_to_session_writes_full_bytes() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let payload = b"\x89PNG\r\n\x1a\nfake-but-recognizable-payload".to_vec();
|
||
let mut img = make_persistable_image(payload.clone());
|
||
persist_to_session(&mut img, dir.path()).expect("persist succeeded");
|
||
let path = img.session_image_path.as_ref().expect("path set");
|
||
let on_disk = std::fs::read(path).expect("readable");
|
||
assert_eq!(on_disk, payload);
|
||
assert!(img.encoded_bytes.is_none(), "in-memory bytes released");
|
||
// No .tmp left behind after success. `Path::extension()` returns
|
||
// `OsStr("tmp")` without the dot, so check filenames directly.
|
||
let leftovers: Vec<_> = std::fs::read_dir(dir.path())
|
||
.unwrap()
|
||
.filter_map(|e| e.ok())
|
||
.filter(|e| e.file_name().to_string_lossy().ends_with(".tmp"))
|
||
.collect();
|
||
assert!(leftovers.is_empty(), "leaked tmp files: {leftovers:?}");
|
||
}
|
||
|
||
/// On `File::create` failure (read-only dir) `persist_to_session`
|
||
/// returns `Err` and leaves no `.tmp` behind.
|
||
#[cfg(unix)]
|
||
#[test]
|
||
fn persist_cleans_up_tmp_on_create_failure() {
|
||
use std::os::unix::fs::PermissionsExt;
|
||
let euid = unsafe { libc::geteuid() };
|
||
if euid == 0 {
|
||
return;
|
||
}
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let mut img = make_persistable_image(b"abc".to_vec());
|
||
std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o500)).unwrap();
|
||
let result = persist_to_session(&mut img, dir.path());
|
||
std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o700)).unwrap();
|
||
assert!(result.is_err());
|
||
let leftovers: Vec<_> = std::fs::read_dir(dir.path())
|
||
.unwrap()
|
||
.filter_map(|e| e.ok())
|
||
.filter(|e| e.file_name().to_string_lossy().ends_with(".tmp"))
|
||
.collect();
|
||
assert!(leftovers.is_empty(), "tmp leaked: {leftovers:?}");
|
||
}
|
||
|
||
/// Integration: persist → load_for_send round-trips the bytes.
|
||
#[test]
|
||
fn persist_then_load_for_send_round_trips() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let payload = b"\x89PNG\r\n\x1a\nround-trip-bytes-12345".to_vec();
|
||
let mut img = make_persistable_image(payload.clone());
|
||
persist_to_session(&mut img, dir.path()).expect("persist");
|
||
let (bytes, mime) = load_for_send(&img).expect("load");
|
||
assert_eq!(bytes, payload);
|
||
assert_eq!(mime, "image/png");
|
||
}
|
||
|
||
/// `encoded_bytes.is_none()` is an error.
|
||
#[test]
|
||
fn persist_no_bytes_returns_err() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let mut img = make_persistable_image(b"x".to_vec());
|
||
img.encoded_bytes = None;
|
||
let err = persist_to_session(&mut img, dir.path()).unwrap_err();
|
||
assert!(err.to_string().contains("no encoded bytes"));
|
||
}
|
||
|
||
#[test]
|
||
fn persist_preserves_original_source_path_for_file_paste() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let mut img = make_persistable_image(b"abc".to_vec());
|
||
let original_source = std::path::PathBuf::from("/tmp/ephemeral/original.png");
|
||
img.source_path = Some(original_source.clone());
|
||
persist_to_session(&mut img, dir.path()).expect("persist");
|
||
assert_eq!(img.source_path.as_ref(), Some(&original_source));
|
||
assert_ne!(
|
||
img.source_path.as_ref(),
|
||
img.session_image_path.as_ref(),
|
||
"display and durable paths have distinct ownership"
|
||
);
|
||
}
|
||
|
||
/// Write into a path that cannot be created returns `Err`.
|
||
#[cfg(unix)]
|
||
#[test]
|
||
fn persist_readonly_dir_returns_err() {
|
||
use std::os::unix::fs::PermissionsExt;
|
||
// Root bypasses read-only mode; skip.
|
||
let euid = unsafe { libc::geteuid() };
|
||
if euid == 0 {
|
||
return;
|
||
}
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let readonly = dir.path().join("ro");
|
||
std::fs::create_dir(&readonly).unwrap();
|
||
std::fs::set_permissions(&readonly, std::fs::Permissions::from_mode(0o500)).unwrap();
|
||
let mut img = make_persistable_image(b"abc".to_vec());
|
||
let res = persist_to_session(&mut img, &readonly);
|
||
let _ = std::fs::set_permissions(&readonly, std::fs::Permissions::from_mode(0o700));
|
||
assert!(res.is_err(), "expected write to read-only dir to fail");
|
||
}
|
||
|
||
// ----- shell_unescape -------------------------------------------------
|
||
|
||
#[test]
|
||
fn shell_unescape_spaces() {
|
||
assert_eq!(
|
||
shell_unescape(r"/path/to/my\ file.png"),
|
||
"/path/to/my file.png"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn shell_unescape_parens() {
|
||
assert_eq!(
|
||
shell_unescape(r"/Downloads/screenshot\ \(2\).png"),
|
||
"/Downloads/screenshot (2).png"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn shell_unescape_no_escapes() {
|
||
assert_eq!(shell_unescape("/simple/path.png"), "/simple/path.png");
|
||
}
|
||
|
||
#[test]
|
||
fn shell_unescape_trailing_backslash() {
|
||
assert_eq!(shell_unescape("trailing\\"), "trailing\\");
|
||
}
|
||
|
||
#[test]
|
||
fn shell_unescape_literal_backslash() {
|
||
assert_eq!(shell_unescape(r"path\\name"), r"path\name");
|
||
}
|
||
|
||
// ----- shell_unescape / Windows-path round-trip ----------------------
|
||
//
|
||
// `\` is a path separator on Windows, not a shell escape. The
|
||
// unescape must skip Windows-looking inputs or it would collapse
|
||
// `C:\Users\Alice\image.png` to `C:UsersAliceimage.png`.
|
||
|
||
#[test]
|
||
fn shell_unescape_preserves_windows_drive_letter() {
|
||
assert_eq!(
|
||
shell_unescape(r"C:\Users\Alice\image.png"),
|
||
r"C:\Users\Alice\image.png"
|
||
);
|
||
// Forward-slash drive paths (MSYS2 / MinGW) also pass through.
|
||
assert_eq!(shell_unescape("C:/x/y.png"), "C:/x/y.png");
|
||
}
|
||
|
||
#[test]
|
||
fn shell_unescape_preserves_windows_unc() {
|
||
assert_eq!(
|
||
shell_unescape(r"\\server\share\image.png"),
|
||
r"\\server\share\image.png"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn looks_like_windows_path_positive_cases() {
|
||
assert!(looks_like_windows_path(r"C:\Users\Alice"));
|
||
assert!(looks_like_windows_path(r"d:\Downloads"));
|
||
assert!(looks_like_windows_path("C:/Users/Alice"));
|
||
assert!(looks_like_windows_path(r"\\server\share\file.png"));
|
||
assert!(looks_like_windows_path(r"\\?\C:\long\path"));
|
||
}
|
||
|
||
#[test]
|
||
fn looks_like_windows_path_negative_cases() {
|
||
assert!(!looks_like_windows_path("/Users/Alice/file.png"));
|
||
assert!(!looks_like_windows_path("~/Downloads/file.png"));
|
||
assert!(!looks_like_windows_path("file://path"));
|
||
assert!(!looks_like_windows_path("C:"));
|
||
assert!(!looks_like_windows_path("C:foo"));
|
||
assert!(!looks_like_windows_path(r"\foo"));
|
||
assert!(!looks_like_windows_path(""));
|
||
}
|
||
|
||
#[test]
|
||
fn token_to_path_round_trips_quoted_windows_path() {
|
||
// Windows Terminal wraps paths-with-spaces in double quotes;
|
||
// quote stripping then shell_unescape-skip must leave the
|
||
// path intact for downstream `is_file()`.
|
||
let path = token_to_path("\"C:\\Users\\Alice\\My Folder\\image.png\"").unwrap();
|
||
assert_eq!(
|
||
path,
|
||
std::path::PathBuf::from(r"C:\Users\Alice\My Folder\image.png")
|
||
);
|
||
}
|
||
|
||
// ----- try_read_image_from_path ----------------------------------------
|
||
|
||
#[test]
|
||
fn try_read_image_with_escaped_parens() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let real_path = dir.path().join("screenshot (2).png");
|
||
let png = make_test_png(10, 10);
|
||
std::fs::write(&real_path, &png).unwrap();
|
||
|
||
// Simulate what the terminal pastes: escaped spaces and parens
|
||
let escaped = format!("{}/screenshot\\ \\(2\\).png", dir.path().display());
|
||
let result = try_read_image_from_path(&escaped);
|
||
assert!(
|
||
result.is_some(),
|
||
"should recognize path with escaped parens"
|
||
);
|
||
let img = result.unwrap();
|
||
assert_eq!(img.mime_type, "image/png");
|
||
assert!(
|
||
img.source_path.is_some(),
|
||
"source_path should be set for file-path images"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn try_read_image_with_escaped_spaces() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let real_path = dir.path().join("my file.png");
|
||
let png = make_test_png(10, 10);
|
||
std::fs::write(&real_path, &png).unwrap();
|
||
|
||
let escaped = format!("{}/my\\ file.png", dir.path().display());
|
||
let result = try_read_image_from_path(&escaped);
|
||
assert!(result.is_some(), "should recognize path with escaped space");
|
||
assert!(result.unwrap().source_path.is_some());
|
||
}
|
||
|
||
// ----- single-file resilience (drop with trailing whitespace / quotes /
|
||
// file:// URLs) ---------------------------------------------------
|
||
|
||
/// Writes a real PNG at `path`. Helper to keep the multi-file tests tidy.
|
||
fn write_png(path: &std::path::Path, w: u32, h: u32) {
|
||
std::fs::write(path, make_test_png(w, h)).unwrap();
|
||
}
|
||
|
||
#[test]
|
||
fn try_read_image_bare_absolute_path() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let p = dir.path().join("foo.png");
|
||
write_png(&p, 2, 2);
|
||
|
||
let result = try_read_image_from_path(&p.display().to_string()).unwrap();
|
||
assert!(result.source_path.is_some());
|
||
assert_eq!(result.mime_type, "image/png");
|
||
}
|
||
|
||
#[test]
|
||
fn try_read_image_trailing_newline() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let p = dir.path().join("foo.png");
|
||
write_png(&p, 2, 2);
|
||
|
||
let pasted = format!("{}\n", p.display());
|
||
assert!(
|
||
try_read_image_from_path(&pasted).is_some(),
|
||
"single trailing newline must not break drop"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn try_read_image_trailing_crlf() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let p = dir.path().join("foo.png");
|
||
write_png(&p, 2, 2);
|
||
|
||
let pasted = format!("{}\r\n", p.display());
|
||
assert!(try_read_image_from_path(&pasted).is_some());
|
||
}
|
||
|
||
#[test]
|
||
fn try_read_image_double_quoted_path() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let p = dir.path().join("foo.png");
|
||
write_png(&p, 2, 2);
|
||
|
||
let pasted = format!("\"{}\"", p.display());
|
||
assert!(try_read_image_from_path(&pasted).is_some());
|
||
}
|
||
|
||
#[test]
|
||
fn try_read_image_single_quoted_path() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let p = dir.path().join("foo.png");
|
||
write_png(&p, 2, 2);
|
||
|
||
let pasted = format!("'{}'", p.display());
|
||
assert!(try_read_image_from_path(&pasted).is_some());
|
||
}
|
||
|
||
// ----- file:// URL parsing -------------------------------------------
|
||
|
||
#[test]
|
||
fn try_read_image_file_url() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let p = dir.path().join("foo.png");
|
||
write_png(&p, 2, 2);
|
||
|
||
let url = format!("file://{}", p.display());
|
||
assert!(try_read_image_from_path(&url).is_some());
|
||
}
|
||
|
||
#[test]
|
||
fn try_read_image_file_url_percent_encoded() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let p = dir.path().join("has space.png");
|
||
write_png(&p, 2, 2);
|
||
|
||
// Build a file:// URL with %20 in place of the literal space.
|
||
let url = format!(
|
||
"file://{}/has%20space.png",
|
||
dir.path().display().to_string().replace(' ', "%20")
|
||
);
|
||
assert!(
|
||
try_read_image_from_path(&url).is_some(),
|
||
"percent-encoded file:// URL must decode to a real path"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn try_read_image_file_url_trailing_newline() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let p = dir.path().join("foo.png");
|
||
write_png(&p, 2, 2);
|
||
|
||
let pasted = format!("file://{}\n", p.display());
|
||
assert!(try_read_image_from_path(&pasted).is_some());
|
||
}
|
||
|
||
#[test]
|
||
fn try_read_image_file_url_quoted() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let p = dir.path().join("foo.png");
|
||
write_png(&p, 2, 2);
|
||
|
||
let pasted = format!("\"file://{}\"", p.display());
|
||
assert!(try_read_image_from_path(&pasted).is_some());
|
||
}
|
||
|
||
// ----- multi-file drop -----------------------------------------------
|
||
|
||
/// Non-image paths are canonicalized before insertion.
|
||
fn canon(p: &std::path::Path) -> PathBuf {
|
||
dunce::canonicalize(p).unwrap_or_else(|_| p.to_path_buf())
|
||
}
|
||
|
||
#[test]
|
||
fn multi_file_newline_separated() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let a = dir.path().join("a.png");
|
||
let b = dir.path().join("b.png");
|
||
write_png(&a, 2, 2);
|
||
write_png(&b, 2, 2);
|
||
|
||
let pasted = format!("{}\n{}", a.display(), b.display());
|
||
let images = try_read_images_from_paste(&pasted);
|
||
assert_eq!(images.len(), 2);
|
||
assert_eq!(images[0].source_path.as_ref().unwrap(), &a);
|
||
assert_eq!(images[1].source_path.as_ref().unwrap(), &b);
|
||
}
|
||
|
||
#[test]
|
||
fn multi_file_space_separated_absolute_paths() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let a = dir.path().join("a.png");
|
||
let b = dir.path().join("b.png");
|
||
write_png(&a, 2, 2);
|
||
write_png(&b, 2, 2);
|
||
|
||
let pasted = format!("{} {}", a.display(), b.display());
|
||
let images = try_read_images_from_paste(&pasted);
|
||
assert_eq!(images.len(), 2);
|
||
assert_eq!(images[0].source_path.as_ref().unwrap(), &a);
|
||
assert_eq!(images[1].source_path.as_ref().unwrap(), &b);
|
||
}
|
||
|
||
#[test]
|
||
fn multi_file_mixed_line_endings() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let a = dir.path().join("a.png");
|
||
let b = dir.path().join("b.png");
|
||
let c = dir.path().join("c.png");
|
||
write_png(&a, 2, 2);
|
||
write_png(&b, 2, 2);
|
||
write_png(&c, 2, 2);
|
||
|
||
let pasted = format!("{}\r\n{}\n{}", a.display(), b.display(), c.display());
|
||
let images = try_read_images_from_paste(&pasted);
|
||
assert_eq!(images.len(), 3);
|
||
}
|
||
|
||
#[test]
|
||
fn multi_file_file_urls_and_bare_paths_interleaved() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let a = dir.path().join("a.png");
|
||
let b = dir.path().join("b.png");
|
||
let c = dir.path().join("c.png");
|
||
write_png(&a, 2, 2);
|
||
write_png(&b, 2, 2);
|
||
write_png(&c, 2, 2);
|
||
|
||
let pasted = format!(
|
||
"file://{}\n{}\nfile://{}",
|
||
a.display(),
|
||
b.display(),
|
||
c.display()
|
||
);
|
||
let images = try_read_images_from_paste(&pasted);
|
||
assert_eq!(images.len(), 3);
|
||
}
|
||
|
||
/// The whole-paste-or-nothing rule (preserves prose) causes the
|
||
/// *entire* paste to fall through to plain text when any line
|
||
/// fails to resolve. A non-image text file IS still a valid
|
||
/// drop (it becomes a NonImage entry) — but a truly missing
|
||
/// path breaks the batch.
|
||
#[test]
|
||
fn multi_file_missing_middle_path_falls_through() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let a = dir.path().join("a.png");
|
||
let c = dir.path().join("c.png");
|
||
write_png(&a, 2, 2);
|
||
write_png(&c, 2, 2);
|
||
let missing = dir.path().join("missing_zzz.png");
|
||
|
||
let pasted = format!("{}\n{}\n{}", a.display(), missing.display(), c.display());
|
||
let images = try_read_images_from_paste(&pasted);
|
||
assert!(
|
||
images.is_empty(),
|
||
"missing middle path must cause whole-paste fall-through; \
|
||
got {images:?}",
|
||
);
|
||
}
|
||
|
||
/// A non-image file in the middle IS a valid drop (becomes
|
||
/// `NonImage`), so the batch still produces 2 images plus 1
|
||
/// NonImage path — nothing is silently lost.
|
||
#[test]
|
||
fn multi_file_non_image_middle_still_emits_all_entries() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let a = dir.path().join("a.png");
|
||
let txt = dir.path().join("notes.txt");
|
||
let c = dir.path().join("c.png");
|
||
write_png(&a, 2, 2);
|
||
std::fs::write(&txt, b"not an image").unwrap();
|
||
write_png(&c, 2, 2);
|
||
|
||
let pasted = format!("{}\n{}\n{}", a.display(), txt.display(), c.display());
|
||
let entries = dropped_paths(&pasted);
|
||
assert_eq!(entries.len(), 3);
|
||
// Pin the source-order of the variants, not just the
|
||
// counts. The drop classifier inserts in source order and
|
||
// order determines the final prompt layout — a regression
|
||
// that scrambled the order would still pass a count-only
|
||
// assertion.
|
||
assert!(
|
||
matches!(entries[0], DroppedPath::Image(_)),
|
||
"entries[0] must be Image; got {:?}",
|
||
entries[0],
|
||
);
|
||
assert!(
|
||
matches!(entries[1], DroppedPath::NonImage(_)),
|
||
"entries[1] must be NonImage; got {:?}",
|
||
entries[1],
|
||
);
|
||
assert!(
|
||
matches!(entries[2], DroppedPath::Image(_)),
|
||
"entries[2] must be Image; got {:?}",
|
||
entries[2],
|
||
);
|
||
}
|
||
|
||
// ----- negatives — must not auto-attach ------------------------------
|
||
|
||
#[test]
|
||
fn free_prose_containing_slash_returns_empty() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let p = dir.path().join("foo.png");
|
||
write_png(&p, 2, 2);
|
||
|
||
// The path exists, but the surrounding prose attaches extra
|
||
// tokens that prevent the path from validating.
|
||
let pasted = format!("check out {} for the bug", p.display());
|
||
let images = try_read_images_from_paste(&pasted);
|
||
assert!(
|
||
images.is_empty(),
|
||
"free prose containing an image path must not auto-attach"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn missing_single_path_returns_empty() {
|
||
let images = try_read_images_from_paste("/tmp/does_not_exist_zzz.png");
|
||
assert!(images.is_empty());
|
||
assert!(try_read_image_from_path("/tmp/does_not_exist_zzz.png").is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn relative_path_returns_empty() {
|
||
// Relative path "foo.png" is not a real file at the test cwd.
|
||
assert!(try_read_images_from_paste("foo.png").is_empty());
|
||
assert!(try_read_image_from_path("foo.png").is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn random_multi_line_text_returns_empty() {
|
||
assert!(try_read_images_from_paste("line one\nline two").is_empty());
|
||
}
|
||
|
||
// ----- additional edge cases ----
|
||
|
||
#[test]
|
||
fn bash_mode_prefix_not_treated_as_image() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let p = dir.path().join("foo.png");
|
||
write_png(&p, 2, 2);
|
||
|
||
// "! /tmp/foo.png" is a bash-mode prefix paste, not a 2-token
|
||
// file drop. The all-parts-anchored gate must reject the split
|
||
// and leave the whole payload as one (invalid) token so the
|
||
// caller can detect the `! ` prefix.
|
||
let pasted = format!("! {}", p.display());
|
||
assert!(try_read_images_from_paste(&pasted).is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn prose_ending_in_png_is_not_attached() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let foo = dir.path().join("foo.png");
|
||
let bar = dir.path().join("bar.png");
|
||
write_png(&foo, 2, 2);
|
||
write_png(&bar, 2, 2);
|
||
|
||
// Prose that *ends* with an image extension is a footgun.
|
||
// With the all-parts-anchored gate, the first part
|
||
// "see" doesn't anchor and we fall back to a single token that
|
||
// fails validation as a path.
|
||
let pasted = format!("see {} referenced in {}", foo.display(), bar.display());
|
||
assert!(try_read_images_from_paste(&pasted).is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn leading_space_before_single_path_attaches() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let p = dir.path().join("foo.png");
|
||
write_png(&p, 2, 2);
|
||
|
||
// Leading space followed by a path produces a split-empty + path
|
||
// pair; after trim+filter the parts collapse to a single token
|
||
// and the path still attaches. Pins down this behavior.
|
||
let pasted = format!(" {}", p.display());
|
||
assert!(try_read_image_from_path(&pasted).is_some());
|
||
}
|
||
|
||
#[test]
|
||
fn newline_wins_space_inside_line_not_split() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let a = dir.path().join("a.png");
|
||
let bc = dir.path().join("b.png c.png"); // a single file with a space in its name
|
||
let other = dir.path().join("d.png");
|
||
write_png(&a, 2, 2);
|
||
write_png(&bc, 2, 2);
|
||
write_png(&other, 2, 2);
|
||
|
||
// Mixed payload: newline-split wins; the second line is NOT
|
||
// further space-split, so "b.png c.png" is one filename. Pins
|
||
// down the "newline wins, space is only a single-line fallback"
|
||
// rule.
|
||
let pasted = format!("{}\n{}\n{}", a.display(), bc.display(), other.display());
|
||
let images = try_read_images_from_paste(&pasted);
|
||
assert_eq!(
|
||
images.len(),
|
||
3,
|
||
"second line must be treated as a single filename with a space"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn quoted_path_with_internal_backslash_escape() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
// Inside the quotes the user supplied a backslash escape. Our
|
||
// code still runs `shell_unescape` after stripping quotes — this
|
||
// diverges from POSIX shell semantics (where backslash inside
|
||
// double quotes is mostly literal) but is consistent with the
|
||
// pre-existing single-image wrapper and the common drag-and-drop
|
||
// flow. The test pins down the actual behavior.
|
||
let p = dir.path().join("my file.png");
|
||
write_png(&p, 2, 2);
|
||
let pasted = format!("\"{}/my\\ file.png\"", dir.path().display());
|
||
assert!(try_read_image_from_path(&pasted).is_some());
|
||
}
|
||
|
||
// ----- file:// URL edge cases ----------------------------
|
||
|
||
#[test]
|
||
fn file_url_with_localhost_host() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let p = dir.path().join("foo.png");
|
||
write_png(&p, 2, 2);
|
||
|
||
// `file://localhost/...` is accepted by the `url` crate and
|
||
// yields the same local path as `file:///...`. Pins behavior.
|
||
let pasted = format!("file://localhost{}", p.display());
|
||
assert!(try_read_image_from_path(&pasted).is_some());
|
||
}
|
||
|
||
#[test]
|
||
fn file_url_with_query_string_accepted() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let p = dir.path().join("foo.png");
|
||
write_png(&p, 2, 2);
|
||
|
||
// `url::Url::to_file_path()` on `file:///…/foo.png?q=1` strips
|
||
// the query, so the file is found and the image attaches. Pins
|
||
// the current `url` crate contract; a future change that, say,
|
||
// started including the query in the path component would trip
|
||
// this assertion.
|
||
let pasted = format!("file://{}?q=1", p.display());
|
||
assert!(try_read_image_from_path(&pasted).is_some());
|
||
}
|
||
|
||
#[test]
|
||
fn file_url_with_fragment_accepted() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let p = dir.path().join("foo.png");
|
||
write_png(&p, 2, 2);
|
||
|
||
// Fragments are stripped by `url::Url::to_file_path()`, so this
|
||
// resolves to the same local path. Pins behavior.
|
||
let pasted = format!("file://{}#frag", p.display());
|
||
assert!(try_read_image_from_path(&pasted).is_some());
|
||
}
|
||
|
||
// ----- multi-file space-separated mixed file:// + bare ---
|
||
|
||
#[test]
|
||
fn multi_file_space_separated_file_url_then_bare() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let a = dir.path().join("a.png");
|
||
let b = dir.path().join("b.png");
|
||
write_png(&a, 2, 2);
|
||
write_png(&b, 2, 2);
|
||
|
||
let pasted = format!("file://{} {}", a.display(), b.display());
|
||
let images = try_read_images_from_paste(&pasted);
|
||
assert_eq!(images.len(), 2, "file:// + bare path split must work");
|
||
assert_eq!(images[0].source_path.as_ref().unwrap(), &a);
|
||
assert_eq!(images[1].source_path.as_ref().unwrap(), &b);
|
||
}
|
||
|
||
#[test]
|
||
fn multi_file_space_separated_bare_then_file_url() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let a = dir.path().join("a.png");
|
||
let b = dir.path().join("b.png");
|
||
write_png(&a, 2, 2);
|
||
write_png(&b, 2, 2);
|
||
|
||
let pasted = format!("{} file://{}", a.display(), b.display());
|
||
let images = try_read_images_from_paste(&pasted);
|
||
assert_eq!(images.len(), 2, "bare + file:// path split must work");
|
||
assert_eq!(images[0].source_path.as_ref().unwrap(), &a);
|
||
assert_eq!(images[1].source_path.as_ref().unwrap(), &b);
|
||
}
|
||
|
||
#[test]
|
||
fn multi_file_mixed_newline_and_space_separated() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let a = dir.path().join("a.png");
|
||
let b = dir.path().join("b.png");
|
||
let c = dir.path().join("c.png");
|
||
let d = dir.path().join("d.png");
|
||
write_png(&a, 2, 2);
|
||
write_png(&b, 2, 2);
|
||
write_png(&c, 2, 2);
|
||
write_png(&d, 2, 2);
|
||
|
||
let pasted = format!(
|
||
"{} {}\n{} {}",
|
||
a.display(),
|
||
b.display(),
|
||
c.display(),
|
||
d.display()
|
||
);
|
||
let images = try_read_images_from_paste(&pasted);
|
||
assert_eq!(images.len(), 4, "mixed newline+space must flatten to 4");
|
||
assert_eq!(images[0].source_path.as_ref().unwrap(), &a);
|
||
assert_eq!(images[1].source_path.as_ref().unwrap(), &b);
|
||
assert_eq!(images[2].source_path.as_ref().unwrap(), &c);
|
||
assert_eq!(images[3].source_path.as_ref().unwrap(), &d);
|
||
}
|
||
|
||
/// A valid drop line followed by a prose comment line causes
|
||
/// the whole paste to fall through to plain text — the dominant
|
||
/// real-world use case is "screenshot URL + caption" and the
|
||
/// caption must survive verbatim instead of being silently
|
||
/// dropped on the floor.
|
||
#[test]
|
||
fn multi_file_valid_line_plus_prose_line_falls_through() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let a = dir.path().join("a.png");
|
||
let b = dir.path().join("b.png");
|
||
write_png(&a, 2, 2);
|
||
write_png(&b, 2, 2);
|
||
|
||
let pasted = format!(
|
||
"{} {}\nbut please ignore the second screenshot",
|
||
a.display(),
|
||
b.display()
|
||
);
|
||
let images = try_read_images_from_paste(&pasted);
|
||
assert!(
|
||
images.is_empty(),
|
||
"valid-drop-plus-prose paste must fall through so the prose \
|
||
survives via plain-text-paste; got {images:?}",
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn multi_file_space_separated_both_file_urls() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let a = dir.path().join("a.png");
|
||
let b = dir.path().join("b.png");
|
||
write_png(&a, 2, 2);
|
||
write_png(&b, 2, 2);
|
||
|
||
let pasted = format!("file://{} file://{}", a.display(), b.display());
|
||
let images = try_read_images_from_paste(&pasted);
|
||
assert_eq!(images.len(), 2, "two file:// URLs must split on space");
|
||
assert_eq!(images[0].source_path.as_ref().unwrap(), &a);
|
||
assert_eq!(images[1].source_path.as_ref().unwrap(), &b);
|
||
}
|
||
|
||
#[test]
|
||
fn file_url_single_slash_rejected_at_anchor_gate() {
|
||
// Single-slash `file:` URLs lack the `file://` prefix the
|
||
// anchor gate requires, and don't start with a path
|
||
// anchor (`/`, `~/`, `X:\`) either — `file:` starts with the
|
||
// ASCII letter `f`. So they fall through to plain text paste
|
||
// regardless of filesystem state. Pins observable wrapper
|
||
// behaviour against accidentally relaxing the anchor gate to
|
||
// also accept `file:` (single-slash). A `url`-crate upgrade
|
||
// that started or stopped accepting `file:/...` at parse
|
||
// time would still satisfy this assertion because the
|
||
// anchor gate now rejects single-slash strings *before* any
|
||
// URL parsing runs.
|
||
let pasted = "file:/tmp/should_not_exist_abc_grok_pager.png";
|
||
assert!(try_read_image_from_path(pasted).is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn file_url_lookalike_scheme_rejected() {
|
||
// `file_url://` does NOT start with the literal `file://`, so
|
||
// the URL branch is skipped and the bare-path branch runs;
|
||
// the resulting "path" can't be on disk → None.
|
||
let pasted = "file_url:///tmp/foo.png";
|
||
assert!(try_read_image_from_path(pasted).is_none());
|
||
}
|
||
|
||
// ----- try_read_dropped_paths -----------------------------------------
|
||
|
||
fn dropped_paths(text: &str) -> Vec<DroppedPath> {
|
||
try_read_dropped_paths(text)
|
||
}
|
||
|
||
fn dropped_image_paths(text: &str) -> Vec<PathBuf> {
|
||
dropped_paths(text)
|
||
.into_iter()
|
||
.filter_map(|d| match d {
|
||
DroppedPath::Image(img) => img.source_path,
|
||
_ => None,
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
#[cfg(unix)]
|
||
#[test]
|
||
fn image_source_path_preserves_user_visible_symlink() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let target = dir.path().join("target.png");
|
||
let visible = dir.path().join("visible.png");
|
||
write_png(&target, 8, 8);
|
||
std::os::unix::fs::symlink(&target, &visible).unwrap();
|
||
|
||
let images = try_read_images_from_paste(&visible.display().to_string());
|
||
assert_eq!(images.len(), 1);
|
||
assert_eq!(images[0].source_path.as_deref(), Some(visible.as_path()));
|
||
assert_ne!(visible, dunce::canonicalize(&visible).unwrap());
|
||
}
|
||
|
||
fn dropped_non_image_paths(text: &str) -> Vec<PathBuf> {
|
||
dropped_paths(text)
|
||
.into_iter()
|
||
.filter_map(|d| match d {
|
||
DroppedPath::NonImage(p) => Some(p),
|
||
_ => None,
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
#[test]
|
||
fn dropped_path_non_image_file_url_returns_decoded_path() {
|
||
// A `file://` URL pointing at a non-image file should produce a
|
||
// NonImage entry with the decoded absolute path — not be silently
|
||
// ignored or routed to an image chip.
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let txt = dir.path().join("notes.txt");
|
||
std::fs::write(&txt, b"hello").unwrap();
|
||
|
||
let url = format!("file://{}", txt.display());
|
||
let non_images = dropped_non_image_paths(&url);
|
||
assert_eq!(non_images.len(), 1);
|
||
assert_eq!(non_images[0], canon(&txt));
|
||
// No image chip should be created for a .txt file.
|
||
assert!(dropped_image_paths(&url).is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn dropped_path_non_image_bare_path_returns_decoded_path() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let txt = dir.path().join("data.json");
|
||
std::fs::write(&txt, b"{}").unwrap();
|
||
|
||
let non_images = dropped_non_image_paths(&txt.display().to_string());
|
||
assert_eq!(non_images.len(), 1);
|
||
assert_eq!(non_images[0], canon(&txt));
|
||
}
|
||
|
||
#[test]
|
||
fn paste_anchor_kind_classifies_each_form() {
|
||
assert_eq!(paste_anchor_kind("file:///tmp/x.png"), "file_url");
|
||
assert_eq!(paste_anchor_kind("/tmp/x.png"), "absolute");
|
||
assert_eq!(paste_anchor_kind("~/Downloads/x.png"), "tilde");
|
||
assert_eq!(
|
||
paste_anchor_kind("C:\\Users\\Alice\\x.png"),
|
||
"windows_drive"
|
||
);
|
||
assert_eq!(paste_anchor_kind("hello world"), "none");
|
||
assert_eq!(paste_anchor_kind(""), "none");
|
||
// ~ without slash is just prose, not a tilde anchor.
|
||
assert_eq!(paste_anchor_kind("~lonely"), "none");
|
||
// Lowercase Windows drive letters also accepted.
|
||
assert_eq!(paste_anchor_kind("d:\\path"), "windows_drive");
|
||
// Windows UNC paths (`\\server\share\...`) get their own bucket.
|
||
assert_eq!(paste_anchor_kind(r"\\server\share\x.png"), "windows_unc");
|
||
}
|
||
|
||
#[test]
|
||
fn starts_with_path_anchor_accepts_unc() {
|
||
assert!(starts_with_path_anchor(r"\\server\share\file.png"));
|
||
assert!(starts_with_path_anchor(r"\\?\C:\Users\Alice"));
|
||
// Single backslash is not a UNC prefix and not a path anchor.
|
||
assert!(!starts_with_path_anchor(r"\foo"));
|
||
}
|
||
|
||
#[test]
|
||
fn dropped_path_image_file_url_returns_image() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let img = dir.path().join("pic.png");
|
||
write_png(&img, 2, 2);
|
||
|
||
let url = format!("file://{}", img.display());
|
||
let images = dropped_image_paths(&url);
|
||
assert_eq!(images.len(), 1);
|
||
assert_eq!(images[0], img);
|
||
assert!(dropped_non_image_paths(&url).is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn dropped_path_percent_encoded_space_round_trips() {
|
||
// `%20` must decode to a real space; the path with spaces must
|
||
// survive intact (no truncation at the space).
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let sub = dir.path().join("My Documents");
|
||
std::fs::create_dir_all(&sub).unwrap();
|
||
let txt = sub.join("notes report.txt");
|
||
std::fs::write(&txt, b"x").unwrap();
|
||
|
||
// Construct the `file://` URL with `%20` for every space.
|
||
let encoded = txt.display().to_string().replace(' ', "%20");
|
||
let url = format!("file://{}", encoded);
|
||
|
||
let non_images = dropped_non_image_paths(&url);
|
||
assert_eq!(
|
||
non_images.len(),
|
||
1,
|
||
"percent-encoded space must round-trip; got {non_images:?}"
|
||
);
|
||
assert_eq!(non_images[0], canon(&txt));
|
||
}
|
||
|
||
#[test]
|
||
fn dropped_path_percent_encoded_hash_round_trips() {
|
||
// `%23` decodes to `#`. The URL parser must not treat the
|
||
// suffix as a fragment.
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let txt = dir.path().join("notes#draft.txt");
|
||
std::fs::write(&txt, b"x").unwrap();
|
||
|
||
let encoded = txt.display().to_string().replace('#', "%23");
|
||
let url = format!("file://{}", encoded);
|
||
|
||
let non_images = dropped_non_image_paths(&url);
|
||
assert_eq!(
|
||
non_images.len(),
|
||
1,
|
||
"percent-encoded `#` must round-trip; got {non_images:?}"
|
||
);
|
||
assert_eq!(non_images[0], canon(&txt));
|
||
}
|
||
|
||
#[test]
|
||
fn dropped_path_percent_encoded_question_round_trips() {
|
||
// `%3F` decodes to `?`. The URL parser must not treat the
|
||
// suffix as a query string.
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let txt = dir.path().join("query?file.txt");
|
||
std::fs::write(&txt, b"x").unwrap();
|
||
|
||
let encoded = txt.display().to_string().replace('?', "%3F");
|
||
let url = format!("file://{}", encoded);
|
||
|
||
let non_images = dropped_non_image_paths(&url);
|
||
assert_eq!(
|
||
non_images.len(),
|
||
1,
|
||
"percent-encoded `?` must round-trip; got {non_images:?}"
|
||
);
|
||
assert_eq!(non_images[0], canon(&txt));
|
||
}
|
||
|
||
#[test]
|
||
fn dropped_path_multi_file_mixed_image_and_non_image() {
|
||
// Drop one image + one text file. Image should become an
|
||
// image chip, non-image should become a decoded path —
|
||
// *neither* should be silently dropped on the floor.
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let png = dir.path().join("a.png");
|
||
let txt = dir.path().join("b.txt");
|
||
write_png(&png, 2, 2);
|
||
std::fs::write(&txt, b"hi").unwrap();
|
||
|
||
let pasted = format!("{}\n{}", png.display(), txt.display());
|
||
let entries = dropped_paths(&pasted);
|
||
assert_eq!(entries.len(), 2, "both entries must be reported");
|
||
|
||
let mut saw_image = false;
|
||
let mut saw_non_image = false;
|
||
for entry in entries {
|
||
match entry {
|
||
DroppedPath::Image(img) => {
|
||
assert_eq!(img.source_path.as_ref().unwrap(), &png);
|
||
saw_image = true;
|
||
}
|
||
DroppedPath::NonImage(p) => {
|
||
assert_eq!(p, canon(&txt));
|
||
saw_non_image = true;
|
||
}
|
||
}
|
||
}
|
||
assert!(saw_image && saw_non_image);
|
||
}
|
||
|
||
#[test]
|
||
fn dropped_path_multi_file_url_mixed() {
|
||
// Same as above but both tokens are `file://` URLs.
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let png = dir.path().join("img.png");
|
||
let txt = dir.path().join("doc.md");
|
||
write_png(&png, 2, 2);
|
||
std::fs::write(&txt, b"# hi").unwrap();
|
||
|
||
let pasted = format!("file://{} file://{}", png.display(), txt.display());
|
||
let entries = dropped_paths(&pasted);
|
||
assert_eq!(entries.len(), 2);
|
||
// Pin the variant of each token — a regression that collapses
|
||
// both into Image (or both into NonImage) would otherwise be
|
||
// missed by a bare `len() == 2` assertion.
|
||
let mut saw_image = false;
|
||
let mut saw_non_image = false;
|
||
for entry in entries {
|
||
match entry {
|
||
DroppedPath::Image(img) => {
|
||
assert_eq!(img.source_path.as_ref().unwrap(), &png);
|
||
saw_image = true;
|
||
}
|
||
DroppedPath::NonImage(p) => {
|
||
assert_eq!(p, canon(&txt));
|
||
saw_non_image = true;
|
||
}
|
||
}
|
||
}
|
||
assert!(saw_image && saw_non_image);
|
||
}
|
||
|
||
#[test]
|
||
fn dropped_path_two_non_image_file_urls_both_intercepted() {
|
||
// The most likely real-world Finder multi-select drop pattern
|
||
// for source code review: drag two text files into the TUI.
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let a = dir.path().join("a.md");
|
||
let b = dir.path().join("b.md");
|
||
std::fs::write(&a, b"# a").unwrap();
|
||
std::fs::write(&b, b"# b").unwrap();
|
||
|
||
let pasted = format!("file://{}\nfile://{}", a.display(), b.display());
|
||
let entries = dropped_paths(&pasted);
|
||
assert_eq!(entries.len(), 2);
|
||
let paths: Vec<_> = entries
|
||
.into_iter()
|
||
.map(|d| match d {
|
||
DroppedPath::NonImage(p) => p,
|
||
_ => panic!("expected both as NonImage"),
|
||
})
|
||
.collect();
|
||
assert_eq!(paths[0], canon(&a));
|
||
assert_eq!(paths[1], canon(&b));
|
||
}
|
||
|
||
#[test]
|
||
fn dropped_path_plus_sign_not_decoded_as_space() {
|
||
// RFC 3986 path-style decoding preserves `+`; only
|
||
// application/x-www-form-urlencoded decoding maps `+` → ` `.
|
||
// Filenames with `+` are common (`C++ Source.cpp`, `5+5.txt`).
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let txt = dir.path().join("c++ source.cpp");
|
||
std::fs::write(&txt, b"int main() {}").unwrap();
|
||
|
||
// No percent-encoding: literal `+` in the URL.
|
||
// (Spaces still need to be encoded.)
|
||
let encoded = txt.display().to_string().replace(' ', "%20");
|
||
let url = format!("file://{}", encoded);
|
||
|
||
let non_images = dropped_non_image_paths(&url);
|
||
assert_eq!(non_images.len(), 1, "got {non_images:?}");
|
||
let got = &non_images[0];
|
||
assert_eq!(got, &canon(&txt));
|
||
// Belt-and-braces: the decoded path must still contain a `+`
|
||
// character, not a stray space.
|
||
assert!(
|
||
got.to_string_lossy().contains('+'),
|
||
"`+` must survive decoding intact; got {got:?}"
|
||
);
|
||
}
|
||
|
||
/// Build a `file://` URL by URL-encoding `dir.path()` as a path
|
||
/// segment first, then appending the (already-encoded) leaf. Used
|
||
/// by tests so a `TMPDIR` resolving under a path with characters
|
||
/// that need percent-encoding (e.g. `+` or space) doesn't yield a
|
||
/// silently-unparseable URL.
|
||
fn build_file_url(dir: &std::path::Path, encoded_leaf: &str) -> String {
|
||
let base = url::Url::from_file_path(dir)
|
||
.expect("tempdir path must round-trip through url::Url::from_file_path");
|
||
let url = format!("{}/{}", base.as_str().trim_end_matches('/'), encoded_leaf);
|
||
assert!(
|
||
url::Url::parse(&url).is_ok(),
|
||
"constructed URL must parse: {url}"
|
||
);
|
||
url
|
||
}
|
||
|
||
#[test]
|
||
fn dropped_path_multibyte_utf8_percent_encoded_round_trips() {
|
||
// macOS Finder emits `%XX` triplets for each UTF-8 byte of
|
||
// non-ASCII filename characters. `…` (U+2026) encodes as
|
||
// `%E2%80%A6`. The full triplet sequence must decode to the
|
||
// original codepoint, not be partially decoded or dropped.
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let txt = dir.path().join("ellipsis…file.md");
|
||
std::fs::write(&txt, b"# hi").unwrap();
|
||
|
||
let url = build_file_url(dir.path(), "ellipsis%E2%80%A6file.md");
|
||
|
||
let non_images = dropped_non_image_paths(&url);
|
||
assert_eq!(non_images.len(), 1, "got {non_images:?}");
|
||
assert_eq!(non_images[0], canon(&txt));
|
||
}
|
||
|
||
#[test]
|
||
fn dropped_path_mixed_case_percent_hex_equivalent() {
|
||
// RFC 3986 §2.1: `%2F` and `%2f` are equivalent. Some
|
||
// producers emit lowercase, some uppercase — both must
|
||
// round-trip to the same decoded path. Uses the ellipsis
|
||
// codepoint `…` (UTF-8 bytes `E2 80 A6`) so the percent
|
||
// triplets contain letters and case actually matters.
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let ellipsis_file = dir.path().join("e…e.txt");
|
||
std::fs::write(&ellipsis_file, b"x").unwrap();
|
||
|
||
let upper = build_file_url(dir.path(), "e%E2%80%A6e.txt");
|
||
let lower = build_file_url(dir.path(), "e%e2%80%a6e.txt");
|
||
|
||
let up = dropped_non_image_paths(&upper);
|
||
let lo = dropped_non_image_paths(&lower);
|
||
assert_eq!(up, lo, "mixed-case %XX must decode identically");
|
||
assert_eq!(up.len(), 1);
|
||
assert_eq!(up[0], canon(&ellipsis_file));
|
||
}
|
||
|
||
#[test]
|
||
fn dropped_path_invalid_percent_sequence_tolerated_outcome() {
|
||
// `%ZZ` is not a valid percent escape. The workspace-pinned
|
||
// `url` crate is lenient: it accepts the URL and `to_file_path`
|
||
// returns the path with the literal `%ZZ` triplet preserved.
|
||
// Two outcomes are acceptable: (a) the parser preserves the
|
||
// literal `%ZZ` and we emit a single `NonImage` with `%ZZ` in
|
||
// the path, or (b) the parser rejects the URL and we emit
|
||
// empty Vec → caller falls through to plain text paste. Any
|
||
// *third* outcome — empty Vec without falling through, or
|
||
// partial decoding of the suffix — must fail the test.
|
||
//
|
||
// Hermeticity: build the URL under a tempfile so a hostile
|
||
// `/tmp/bad%ZZname.txt` left around by a previous test can't
|
||
// change the variant emitted.
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let base = url::Url::from_file_path(dir.path()).unwrap();
|
||
let url = format!("{}/bad%ZZname.txt", base.as_str().trim_end_matches('/'));
|
||
let entries = dropped_paths(&url);
|
||
let ok = entries.is_empty()
|
||
|| (entries.len() == 1
|
||
&& matches!(&entries[0], DroppedPath::NonImage(p) if p.to_string_lossy().contains("%ZZ")));
|
||
assert!(
|
||
ok,
|
||
"%ZZ outcome must be either empty-Vec or single NonImage with literal `%ZZ`; got {entries:?}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn dropped_path_extension_says_image_bytes_say_no_falls_to_non_image() {
|
||
// `.png` extension but bytes are garbage — `read_image_at_path`
|
||
// rejects via `mime_from_bytes` returning octet-stream, so the
|
||
// NonImage gate fires and the user gets the path text instead
|
||
// of a chip.
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let fake = dir.path().join("corrupt.png");
|
||
std::fs::write(&fake, b"this is not a PNG").unwrap();
|
||
|
||
let url = format!("file://{}", fake.display());
|
||
let entries = dropped_paths(&url);
|
||
assert_eq!(entries.len(), 1);
|
||
match &entries[0] {
|
||
DroppedPath::NonImage(p) => assert_eq!(p, &canon(&fake)),
|
||
other => panic!("expected NonImage fallthrough, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
/// A bare cwd-relative image filename like `foo.png` (no `/`,
|
||
/// no `~/`, no `X:\`, no `file://`) is rejected by the anchor
|
||
/// gate BEFORE `read_image_at_path` runs — so even if `foo.png`
|
||
/// happened to exist in the test process's cwd, it would not be
|
||
/// intercepted as an Image. The contrast case (an absolute-path
|
||
/// `file://` URL to the same content) is intercepted,
|
||
/// documenting the asymmetry next to executable code.
|
||
#[test]
|
||
fn dropped_path_bare_cwd_relative_image_name_not_intercepted() {
|
||
// Create a real PNG at <tempdir>/foo.png. The bare name
|
||
// `foo.png` must NOT be intercepted: the anchor gate
|
||
// short-circuits before `read_image_at_path` runs.
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let abs = dir.path().join("foo.png");
|
||
write_png(&abs, 2, 2);
|
||
|
||
// Bare relative name → rejected at the anchor gate.
|
||
let bare = dropped_paths("foo.png");
|
||
assert!(
|
||
bare.is_empty(),
|
||
"bare cwd-relative image name must NOT be intercepted; got {bare:?}"
|
||
);
|
||
|
||
// Same content via an absolute `file://` URL → accepted as Image.
|
||
let url = format!("file://{}", abs.display());
|
||
let via_url = dropped_paths(&url);
|
||
assert_eq!(via_url.len(), 1);
|
||
assert!(
|
||
matches!(via_url[0], DroppedPath::Image(_)),
|
||
"absolute file:// to the same PNG must be intercepted as Image; got {via_url:?}"
|
||
);
|
||
}
|
||
|
||
/// `file:///` (empty path) and `file://` (root path) are
|
||
/// pathological — never legitimate drop URIs. They must be
|
||
/// rejected upstream of the existence check so the fallback
|
||
/// branch can't emit a bogus `NonImage("/")` entry.
|
||
#[test]
|
||
fn file_url_with_empty_or_root_path_is_rejected() {
|
||
// `file:///` parses to path `/` on Unix. Rejected by the
|
||
// root-path gate.
|
||
let entries = dropped_paths("file:///");
|
||
assert!(
|
||
entries.is_empty(),
|
||
"file:/// must be rejected; got {entries:?}",
|
||
);
|
||
|
||
// `file://` (no path component) is also rejected — either
|
||
// url-parse returns None for the empty path, or the empty-
|
||
// OsStr gate catches it.
|
||
let entries = dropped_paths("file://");
|
||
assert!(
|
||
entries.is_empty(),
|
||
"file:// must be rejected; got {entries:?}",
|
||
);
|
||
}
|
||
|
||
/// A `file://` URL with a percent-encoded NUL or embedded
|
||
/// CR/LF byte decodes to a path that corrupts the terminal/text
|
||
/// pipeline when inserted as text. Reject these at parse time
|
||
/// so the prompt never sees them.
|
||
///
|
||
/// The gate is intentionally narrow (NUL, CR, LF) — TAB and
|
||
/// other low-control bytes are legal in Unix filenames and the
|
||
/// TUI's text path renders them fine.
|
||
#[test]
|
||
fn file_url_with_nul_byte_path_is_rejected() {
|
||
let entries = dropped_paths("file:///tmp/path%00.png");
|
||
assert!(
|
||
entries.is_empty(),
|
||
"NUL-byte path must be rejected; got {entries:?}",
|
||
);
|
||
|
||
// CR and LF likewise corrupt the paste pipeline; reject them.
|
||
let entries = dropped_paths("file:///tmp/cr%0Dpath.txt");
|
||
assert!(
|
||
entries.is_empty(),
|
||
"CR-byte path must be rejected; got {entries:?}",
|
||
);
|
||
let entries = dropped_paths("file:///tmp/lf%0Apath.txt");
|
||
assert!(
|
||
entries.is_empty(),
|
||
"LF-byte path must be rejected; got {entries:?}",
|
||
);
|
||
}
|
||
|
||
/// HEIC/HEIF/AVIF/ICO are intentionally NOT in `IMAGE_EXTENSIONS`
|
||
/// — the inline overlay doesn't render them, so we fall through
|
||
/// to NonImage path text instead of falsely promoting a chip.
|
||
#[test]
|
||
fn unsupported_image_extensions_fall_to_non_image() {
|
||
for ext in ["heic", "heif", "avif", "ico"] {
|
||
assert!(!IMAGE_EXTENSIONS.contains(&ext), "{ext} must be omitted");
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let path = dir.path().join(format!("img.{ext}"));
|
||
std::fs::write(&path, b"any bytes").unwrap();
|
||
let url = format!("file://{}", path.display());
|
||
let entries = dropped_paths(&url);
|
||
assert_eq!(entries.len(), 1, "{ext}: {entries:?}");
|
||
assert!(
|
||
matches!(entries[0], DroppedPath::NonImage(_)),
|
||
"{ext} must fall through to NonImage, got {:?}",
|
||
entries[0],
|
||
);
|
||
}
|
||
}
|
||
|
||
/// SVG is intentionally NOT in `IMAGE_EXTENSIONS` (XML/text
|
||
/// formats aren't sniffed as images and the inline overlay does
|
||
/// not render SVG). An `.svg` drop must fall through to NonImage
|
||
/// so the user gets a path string they can pass to the agent.
|
||
#[test]
|
||
fn svg_extension_not_intercepted_as_image() {
|
||
assert!(!IMAGE_EXTENSIONS.contains(&"svg"));
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let svg = dir.path().join("logo.svg");
|
||
std::fs::write(
|
||
&svg,
|
||
br#"<?xml version="1.0"?><svg xmlns="http://www.w3.org/2000/svg"/>"#,
|
||
)
|
||
.unwrap();
|
||
|
||
let url = format!("file://{}", svg.display());
|
||
let entries = dropped_paths(&url);
|
||
assert_eq!(entries.len(), 1);
|
||
assert!(
|
||
matches!(entries[0], DroppedPath::NonImage(_)),
|
||
"SVG must fall through to NonImage; got {:?}",
|
||
entries[0],
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn dropped_path_trailing_spaces_tolerated() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let txt = dir.path().join("trail3.txt");
|
||
std::fs::write(&txt, b"x").unwrap();
|
||
|
||
let url = format!("file://{} ", txt.display());
|
||
let non_images = dropped_non_image_paths(&url);
|
||
assert_eq!(non_images.len(), 1, "got {non_images:?}");
|
||
assert_eq!(non_images[0], canon(&txt));
|
||
}
|
||
|
||
#[test]
|
||
fn dropped_path_trailing_tab_and_newline_tolerated() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let txt = dir.path().join("trail4.txt");
|
||
std::fs::write(&txt, b"x").unwrap();
|
||
|
||
let url = format!("file://{}\t\n", txt.display());
|
||
let non_images = dropped_non_image_paths(&url);
|
||
assert_eq!(non_images.len(), 1, "got {non_images:?}");
|
||
assert_eq!(non_images[0], canon(&txt));
|
||
}
|
||
|
||
#[test]
|
||
fn dropped_path_empty_line_between_file_urls_tolerated() {
|
||
// Double newline between two `file://` URLs. `tokenize_paste`
|
||
// is supposed to filter the empty intermediate token; pin
|
||
// that for the `DroppedPath` flow.
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let a = dir.path().join("blank1.txt");
|
||
let b = dir.path().join("blank2.txt");
|
||
std::fs::write(&a, b"x").unwrap();
|
||
std::fs::write(&b, b"x").unwrap();
|
||
|
||
let pasted = format!("file://{}\n\nfile://{}", a.display(), b.display());
|
||
let non_images = dropped_non_image_paths(&pasted);
|
||
assert_eq!(non_images.len(), 2, "got {non_images:?}");
|
||
assert_eq!(non_images[0], canon(&a));
|
||
assert_eq!(non_images[1], canon(&b));
|
||
}
|
||
|
||
#[test]
|
||
fn dropped_path_double_space_between_file_urls_tolerated() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let a = dir.path().join("dbl1.txt");
|
||
let b = dir.path().join("dbl2.txt");
|
||
std::fs::write(&a, b"x").unwrap();
|
||
std::fs::write(&b, b"x").unwrap();
|
||
|
||
let pasted = format!("file://{} file://{}", a.display(), b.display());
|
||
let non_images = dropped_non_image_paths(&pasted);
|
||
assert_eq!(non_images.len(), 2, "got {non_images:?}");
|
||
assert_eq!(non_images[0], canon(&a));
|
||
assert_eq!(non_images[1], canon(&b));
|
||
}
|
||
|
||
#[test]
|
||
fn dropped_path_directory_via_file_url_intercepted_as_non_image() {
|
||
// A directory dropped as a `file://` URL is intercepted as
|
||
// NonImage (the NonImage branch uses `path.exists()`, not
|
||
// `is_file()`). Useful for "drag a folder into the TUI" UX.
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let sub = dir.path().join("a_folder");
|
||
std::fs::create_dir_all(&sub).unwrap();
|
||
|
||
let url = format!("file://{}", sub.display());
|
||
let entries = dropped_paths(&url);
|
||
assert_eq!(entries.len(), 1);
|
||
assert!(matches!(entries[0], DroppedPath::NonImage(_)));
|
||
}
|
||
|
||
#[test]
|
||
fn dropped_path_directory_via_bare_path_intercepted_as_non_image() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let sub = dir.path().join("another_folder");
|
||
std::fs::create_dir_all(&sub).unwrap();
|
||
|
||
let entries = dropped_paths(&sub.display().to_string());
|
||
assert_eq!(entries.len(), 1);
|
||
assert!(matches!(entries[0], DroppedPath::NonImage(_)));
|
||
}
|
||
|
||
#[test]
|
||
fn dropped_path_prose_with_embedded_existing_path_not_truncated() {
|
||
// /etc/hosts exists on every Unix-y machine. A sentence that
|
||
// mentions it must NOT be truncated to just the path token.
|
||
// (Regression sentinel for "prose-with-real-paths" — the
|
||
// full sentence isn't a valid file, so the all-anchored
|
||
// tokenizer gate falls back to a single-token line which
|
||
// doesn't exist on disk and hence is rejected.)
|
||
let entries = dropped_paths("I read /etc/passwd and got confused");
|
||
assert!(
|
||
entries.is_empty(),
|
||
"prose with embedded existing path must fall through to plain text; got {entries:?}"
|
||
);
|
||
|
||
let entries = dropped_paths("Look at /etc/hosts to debug DNS");
|
||
assert!(
|
||
entries.is_empty(),
|
||
"prose with embedded existing path must fall through to plain text; got {entries:?}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn try_read_images_from_paste_equals_image_filtered_dropped_paths() {
|
||
// `try_read_images_from_paste` is now a thin filter over
|
||
// `try_read_dropped_paths`. Lock in the delegation invariant
|
||
// for several input shapes so a regression that diverges
|
||
// only in one shape (e.g. the empty-paste case) would still
|
||
// break the public-API contract visibly.
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let png1 = dir.path().join("img1.png");
|
||
let png2 = dir.path().join("img2.png");
|
||
let txt1 = dir.path().join("doc1.md");
|
||
let txt2 = dir.path().join("doc2.md");
|
||
write_png(&png1, 2, 2);
|
||
write_png(&png2, 2, 2);
|
||
std::fs::write(&txt1, b"# x").unwrap();
|
||
std::fs::write(&txt2, b"# y").unwrap();
|
||
|
||
// Cover: empty, prose, image-only, non-image-only,
|
||
// mixed-image-and-non-image, multi-image, multi-non-image.
|
||
let inputs: Vec<String> = vec![
|
||
String::new(),
|
||
"hello world this is just prose".to_string(),
|
||
format!("file://{}", png1.display()),
|
||
format!("file://{}", txt1.display()),
|
||
format!("file://{} file://{}", png1.display(), txt1.display()),
|
||
format!("file://{}\nfile://{}", png1.display(), png2.display()),
|
||
format!("file://{}\nfile://{}", txt1.display(), txt2.display()),
|
||
];
|
||
|
||
for input in &inputs {
|
||
let images = try_read_images_from_paste(input);
|
||
let dropped = try_read_dropped_paths(input);
|
||
let image_paths_via_dropped: Vec<PathBuf> = dropped
|
||
.into_iter()
|
||
.filter_map(|d| match d {
|
||
DroppedPath::Image(img) => img.source_path,
|
||
DroppedPath::NonImage(_) => None,
|
||
})
|
||
.collect();
|
||
let image_paths_via_filter: Vec<PathBuf> = images
|
||
.into_iter()
|
||
.filter_map(|img| img.source_path)
|
||
.collect();
|
||
assert_eq!(
|
||
image_paths_via_filter, image_paths_via_dropped,
|
||
"delegation invariant must hold for input {input:?}"
|
||
);
|
||
}
|
||
}
|
||
|
||
/// A single line that space-splits into multiple anchored
|
||
/// tokens where some don't resolve must NOT silently emit only
|
||
/// the resolving ones. The entire line falls through so the
|
||
/// unresolved substring isn't dropped into the void.
|
||
#[test]
|
||
fn dropped_path_per_line_partial_resolution_falls_through() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
// Hermeticity: `space_split_line` splits on each space that
|
||
// precedes a drop anchor. A `$TMPDIR` containing a space
|
||
// would inject extra split points into our `bogus` token and
|
||
// break the intended two-token tokenisation. macOS' default
|
||
// `/var/folders/...` and Linux' default `/tmp/...` are safe;
|
||
// a CI sandbox with `TMPDIR=/some path/foo` is not. Fail
|
||
// loudly rather than silently producing the wrong test
|
||
// shape.
|
||
assert!(
|
||
!dir.path().to_string_lossy().contains(' '),
|
||
"this test assumes no-space TMPDIR; got {:?}",
|
||
dir.path()
|
||
);
|
||
let real = dir.path().join("real_dir");
|
||
std::fs::create_dir_all(&real).unwrap();
|
||
|
||
// Build a line that space-splits to [bogus_anchored, real_dir].
|
||
// `bogus_anchored` is `<tmpdir>/nope nope nope` — anchored
|
||
// (starts with `/`) so the all-anchored gate keeps it, but
|
||
// doesn't exist on disk.
|
||
let bogus = dir.path().join("nope nope nope");
|
||
let pasted = format!("{} {}", bogus.display(), real.display());
|
||
|
||
// bogus.exists() is false; real.exists() is true. After the
|
||
// per-line all-or-nothing gate the line should emit *nothing*
|
||
// so the caller falls through to plain text paste — the user
|
||
// sees the verbatim string in the prompt rather than only
|
||
// `real_dir` appearing.
|
||
let entries = dropped_paths(&pasted);
|
||
assert!(
|
||
entries.is_empty(),
|
||
"partial-resolution within a single line must fall through; got {entries:?}"
|
||
);
|
||
}
|
||
|
||
/// A drop-line + prose-line paste falls through to plain text
|
||
/// so the prose isn't silently lost — the screenshot-URL-
|
||
/// plus-caption pattern is the dominant use case and naive
|
||
/// per-line independence would eat the caption.
|
||
#[test]
|
||
fn dropped_path_url_plus_prose_falls_through() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let real = dir.path().join("real.txt");
|
||
std::fs::write(&real, b"x").unwrap();
|
||
|
||
let pasted = format!(
|
||
"file://{}\nbut please ignore the second comment line",
|
||
real.display()
|
||
);
|
||
let entries = dropped_paths(&pasted);
|
||
assert!(
|
||
entries.is_empty(),
|
||
"URL-plus-prose paste must fall through; got {entries:?}",
|
||
);
|
||
}
|
||
|
||
/// The canonical case: a screenshot URL followed by a
|
||
/// hand-typed caption must NOT lose the caption — the whole
|
||
/// paste falls through to plain text.
|
||
#[test]
|
||
fn dropped_path_url_plus_caption_falls_through_for_plain_paste() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let png = dir.path().join("screenshot.png");
|
||
write_png(&png, 2, 2);
|
||
|
||
let pasted = format!("file://{}\nplease look at this", png.display());
|
||
let entries = dropped_paths(&pasted);
|
||
assert!(
|
||
entries.is_empty(),
|
||
"screenshot URL + caption must fall through to plain text \
|
||
paste; got {entries:?}",
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn dropped_path_single_token_existing_bare_path_intercepted() {
|
||
// The flip side of the prose test: a single bare anchored
|
||
// path that exists IS a drop. Use a hermetic tempfile-backed
|
||
// directory so the test doesn't depend on `/tmp` existing or
|
||
// having any particular contents in a CI sandbox.
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let entries = dropped_paths(&dir.path().display().to_string());
|
||
// The directory exists; the NonImage gate uses `path.exists()`
|
||
// (not `is_file()`), so directories qualify.
|
||
assert_eq!(entries.len(), 1);
|
||
assert!(matches!(entries[0], DroppedPath::NonImage(_)));
|
||
}
|
||
|
||
#[test]
|
||
fn dropped_path_trailing_newline_tolerated_non_image() {
|
||
// Trailing-newline tolerance must extend to non-image paths.
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let txt = dir.path().join("trail.txt");
|
||
std::fs::write(&txt, b"x").unwrap();
|
||
|
||
let url = format!("file://{}\n", txt.display());
|
||
let non_images = dropped_non_image_paths(&url);
|
||
assert_eq!(non_images.len(), 1);
|
||
assert_eq!(non_images[0], canon(&txt));
|
||
}
|
||
|
||
#[test]
|
||
fn dropped_path_trailing_crlf_tolerated_non_image() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let txt = dir.path().join("trail2.txt");
|
||
std::fs::write(&txt, b"x").unwrap();
|
||
|
||
let url = format!("file://{}\r\n", txt.display());
|
||
let non_images = dropped_non_image_paths(&url);
|
||
assert_eq!(non_images.len(), 1);
|
||
assert_eq!(non_images[0], canon(&txt));
|
||
}
|
||
|
||
#[test]
|
||
fn dropped_path_prose_not_intercepted() {
|
||
// Free-form text must NOT be intercepted as a drop path.
|
||
let entries = dropped_paths("hello world this is just text");
|
||
assert!(entries.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn dropped_path_nonexistent_bare_path_not_intercepted() {
|
||
// A bare path to a non-existent file is NOT intercepted: the
|
||
// user might just have typed `/etc/passwd` as part of prose.
|
||
// (The image branch already filters on file existence; this
|
||
// mirrors that for the non-image branch.)
|
||
let entries = dropped_paths("/tmp/definitely_does_not_exist_xyz_grok_pager.txt");
|
||
assert!(
|
||
entries.is_empty(),
|
||
"bare nonexistent path must fall through to prose; got {entries:?}"
|
||
);
|
||
}
|
||
|
||
/// Regression sentinel for the `canonicalize().unwrap_or(path)`
|
||
/// fallback inside `try_read_dropped_path`. A `file://` URL is an
|
||
/// unambiguous drop URI even when the target is missing (stale
|
||
/// path, network mount). `canonicalize()` returns `Err` for
|
||
/// missing targets — the fallback branch must emit the raw
|
||
/// decoded path as `NonImage` so the user still gets a usable
|
||
/// path string.
|
||
#[test]
|
||
fn dropped_path_nonexistent_file_url_still_intercepted() {
|
||
// Use a tempdir-rooted nonexistent path so a developer's or
|
||
// CI sandbox's filesystem can't accidentally make the path
|
||
// resolve (`/tmp/definitely_does_not_exist...` could be
|
||
// present on a noisy machine).
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let nonexistent = dir.path().join("does/not/exist/at/all.txt");
|
||
let url = format!("file://{}", nonexistent.display());
|
||
let entries = dropped_paths(&url);
|
||
assert_eq!(entries.len(), 1);
|
||
// Explicit variant check first so a regression that
|
||
// collapsed `NonImage` into `Image` (or returned no entries)
|
||
// fails with a clean error rather than a destructuring panic.
|
||
assert!(
|
||
matches!(entries[0], DroppedPath::NonImage(_)),
|
||
"expected NonImage variant; got {:?}",
|
||
entries[0]
|
||
);
|
||
match &entries[0] {
|
||
DroppedPath::NonImage(p) => {
|
||
assert_eq!(p, &nonexistent);
|
||
}
|
||
_ => unreachable!("matches! above asserted the variant"),
|
||
}
|
||
}
|
||
|
||
// ----- reconcile ------------------------------------------------------
|
||
|
||
#[test]
|
||
fn reconcile_keeps_live_images() {
|
||
let mut images = vec![make_image(1, 1), make_image(2, 2), make_image(3, 3)];
|
||
let live: HashSet<ElementId> = [ElementId::from_raw(1), ElementId::from_raw(3)].into();
|
||
|
||
reconcile(&mut images, &live);
|
||
|
||
assert_eq!(images.len(), 2);
|
||
assert_eq!(images[0].display_number, 1);
|
||
assert_eq!(images[1].display_number, 3);
|
||
}
|
||
|
||
#[test]
|
||
fn reconcile_removes_all_when_empty_live_set() {
|
||
let mut images = vec![make_image(1, 1), make_image(2, 2)];
|
||
let live: HashSet<ElementId> = HashSet::new();
|
||
|
||
reconcile(&mut images, &live);
|
||
|
||
assert!(images.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn reconcile_noop_when_all_live() {
|
||
let mut images = vec![make_image(1, 1), make_image(2, 2)];
|
||
let live: HashSet<ElementId> = [ElementId::from_raw(1), ElementId::from_raw(2)].into();
|
||
|
||
reconcile(&mut images, &live);
|
||
|
||
assert_eq!(images.len(), 2);
|
||
}
|
||
|
||
#[test]
|
||
fn reconcile_noop_on_empty_images() {
|
||
let mut images: Vec<PastedImage> = Vec::new();
|
||
let live: HashSet<ElementId> = [ElementId::from_raw(1)].into();
|
||
|
||
reconcile(&mut images, &live);
|
||
|
||
assert!(images.is_empty());
|
||
}
|
||
|
||
// ----- clear ----------------------------------------------------------
|
||
|
||
#[test]
|
||
fn clear_resets_images_and_counter() {
|
||
let mut images = vec![make_image(1, 1), make_image(2, 2)];
|
||
let mut counter = 5usize;
|
||
|
||
clear(&mut images, &mut counter);
|
||
|
||
assert!(images.is_empty());
|
||
assert_eq!(counter, 0);
|
||
}
|
||
|
||
// ----- persist_to_session ------------------------------------------------
|
||
|
||
#[test]
|
||
fn persist_writes_file_and_clears_bytes() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let images_dir = dir.path().join("images");
|
||
|
||
let png_bytes = vec![0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a, 0, 0];
|
||
let mut img = PastedImage {
|
||
element_id: ElementId::from_raw(1),
|
||
display_number: 1,
|
||
mime_type: "image/png".into(),
|
||
dimensions: None,
|
||
byte_len: png_bytes.len(),
|
||
encoded_bytes: Some(Arc::from(png_bytes.clone())),
|
||
source_path: None,
|
||
staged_temp_path: None,
|
||
session_image_path: None,
|
||
preview: PromptImagePreview::default(),
|
||
};
|
||
|
||
persist_to_session(&mut img, &images_dir).unwrap();
|
||
|
||
// File was written
|
||
let path = img.session_image_path.as_ref().unwrap();
|
||
assert!(path.exists());
|
||
assert!(path.to_string_lossy().ends_with(".png"));
|
||
assert_eq!(std::fs::read(path).unwrap(), png_bytes);
|
||
|
||
// In-memory bytes released
|
||
assert!(img.encoded_bytes.is_none());
|
||
|
||
// source_path stays None for clipboard pastes — the chip
|
||
// should show `[Image #1]` without an internal path.
|
||
assert!(
|
||
img.source_path.is_none(),
|
||
"clipboard paste source_path should remain None"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn persist_creates_directory() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let images_dir = dir.path().join("deep").join("nested").join("images");
|
||
assert!(!images_dir.exists());
|
||
|
||
let mut img = PastedImage {
|
||
element_id: ElementId::from_raw(1),
|
||
display_number: 1,
|
||
mime_type: "image/jpeg".into(),
|
||
dimensions: None,
|
||
byte_len: 4,
|
||
encoded_bytes: Some(Arc::from(vec![0xff, 0xd8, 0xff, 0xe0])),
|
||
source_path: None,
|
||
staged_temp_path: None,
|
||
session_image_path: None,
|
||
preview: PromptImagePreview::default(),
|
||
};
|
||
|
||
persist_to_session(&mut img, &images_dir).unwrap();
|
||
|
||
assert!(images_dir.exists());
|
||
let path = img.session_image_path.as_ref().unwrap();
|
||
assert!(path.to_string_lossy().ends_with(".jpg"));
|
||
}
|
||
|
||
#[test]
|
||
fn persist_fails_without_bytes() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let mut img = make_image(1, 1); // encoded_bytes is None
|
||
let result = persist_to_session(&mut img, dir.path());
|
||
assert!(result.is_err());
|
||
}
|
||
|
||
// ----- persist_to_session path ownership --------------------------------
|
||
|
||
#[test]
|
||
fn persist_clipboard_image_keeps_source_path_none() {
|
||
// Clipboard paste (Copy Image in browser/Slack): source_path
|
||
// starts as None and should stay None after persistence so the
|
||
// chip shows `[Image #1]` without an internal session path.
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let images_dir = dir.path().join("images");
|
||
|
||
let png = make_test_png(100, 80);
|
||
let mut img = PastedImage {
|
||
element_id: ElementId::from_raw(1),
|
||
display_number: 1,
|
||
mime_type: "image/png".into(),
|
||
dimensions: Some((100, 80)),
|
||
byte_len: png.len(),
|
||
encoded_bytes: Some(Arc::from(png)),
|
||
source_path: None, // clipboard paste — no original path
|
||
staged_temp_path: None,
|
||
session_image_path: None,
|
||
preview: PromptImagePreview::default(),
|
||
};
|
||
|
||
persist_to_session(&mut img, &images_dir).unwrap();
|
||
|
||
// source_path stays None for clipboard pastes.
|
||
assert!(
|
||
img.source_path.is_none(),
|
||
"clipboard paste should not gain a source_path"
|
||
);
|
||
|
||
// session_image_path is set (bytes are persisted for reload).
|
||
assert!(img.session_image_path.is_some());
|
||
|
||
// Display text shows no path.
|
||
let text = display_text(1);
|
||
assert_eq!(text, "[Image #1]");
|
||
}
|
||
|
||
#[test]
|
||
fn persist_file_image_keeps_original_and_durable_paths_distinct() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let images_dir = dir.path().join("images");
|
||
|
||
let png = make_test_png(50, 50);
|
||
let original_path = PathBuf::from("/tmp/ephemeral-screenshot.png");
|
||
let mut img = PastedImage {
|
||
element_id: ElementId::from_raw(1),
|
||
display_number: 1,
|
||
mime_type: "image/png".into(),
|
||
dimensions: Some((50, 50)),
|
||
byte_len: png.len(),
|
||
encoded_bytes: Some(Arc::from(png)),
|
||
source_path: Some(original_path.clone()),
|
||
staged_temp_path: None,
|
||
session_image_path: None,
|
||
preview: PromptImagePreview::default(),
|
||
};
|
||
|
||
persist_to_session(&mut img, &images_dir).unwrap();
|
||
|
||
let session_path = img.session_image_path.as_ref().unwrap();
|
||
assert_eq!(img.source_path.as_ref(), Some(&original_path));
|
||
assert_ne!(
|
||
img.source_path.as_ref(),
|
||
Some(session_path),
|
||
"source_path is the original display path, not the durable copy"
|
||
);
|
||
}
|
||
|
||
// ----- from_clipboard_data -----------------------------------------------
|
||
|
||
#[test]
|
||
fn from_clipboard_data_populates_fields() {
|
||
let data = crate::clipboard::ImageData {
|
||
data: vec![1, 2, 3, 4],
|
||
mime_type: "image/png".into(),
|
||
};
|
||
let img = from_clipboard_data(&data);
|
||
assert_eq!(img.mime_type, "image/png");
|
||
assert_eq!(img.byte_len, 4);
|
||
assert!(img.encoded_bytes.is_some());
|
||
assert_eq!(img.encoded_bytes.as_ref().unwrap().len(), 4);
|
||
}
|
||
|
||
#[test]
|
||
fn from_clipboard_prepares_preview_before_render() {
|
||
let _guard = crate::terminal::image::set_protocol_for_test(
|
||
crate::terminal::image::GraphicsProtocol::Kitty,
|
||
);
|
||
let data = crate::clipboard::ImageData {
|
||
data: make_test_png(16, 12),
|
||
mime_type: "image/png".into(),
|
||
};
|
||
|
||
let img = from_clipboard_data(&data);
|
||
assert!(img.preview.is_pending());
|
||
img.preview_preparation().unwrap().run();
|
||
let (bytes, dimensions) = img.preview.prepared().expect("valid PNG becomes ready");
|
||
assert_eq!(dimensions, (16, 12));
|
||
assert!(crate::terminal::image::kitty_format_from_bytes(bytes).is_some());
|
||
}
|
||
|
||
#[test]
|
||
fn from_clipboard_marks_corrupt_preview_failed_without_losing_send_bytes() {
|
||
let _guard = crate::terminal::image::set_protocol_for_test(
|
||
crate::terminal::image::GraphicsProtocol::Kitty,
|
||
);
|
||
let data = crate::clipboard::ImageData {
|
||
data: b"\x89PNG\r\n\x1a\ncorrupt".to_vec(),
|
||
mime_type: "image/png".into(),
|
||
};
|
||
|
||
let img = from_clipboard_data(&data);
|
||
img.preview_preparation().unwrap().run();
|
||
assert!(img.preview.is_failed());
|
||
assert_eq!(img.encoded_bytes.as_deref(), Some(data.data.as_slice()));
|
||
}
|
||
|
||
// ----- test PNG helper --------------------------------------------------
|
||
|
||
/// Generate a valid minimal PNG of the given dimensions.
|
||
fn make_test_png(width: u32, height: u32) -> Vec<u8> {
|
||
use image::{ImageBuffer, Rgba};
|
||
let img: ImageBuffer<Rgba<u8>, Vec<u8>> =
|
||
ImageBuffer::from_pixel(width, height, Rgba([128, 64, 32, 255]));
|
||
let mut buf = Vec::new();
|
||
img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png)
|
||
.unwrap();
|
||
buf
|
||
}
|
||
|
||
fn make_test_jpeg(width: u32, height: u32) -> Vec<u8> {
|
||
use image::{ImageBuffer, Rgb};
|
||
let img: ImageBuffer<Rgb<u8>, Vec<u8>> =
|
||
ImageBuffer::from_pixel(width, height, Rgb([128, 64, 32]));
|
||
let mut buf = Vec::new();
|
||
img.write_to(
|
||
&mut std::io::Cursor::new(&mut buf),
|
||
image::ImageFormat::Jpeg,
|
||
)
|
||
.unwrap();
|
||
buf
|
||
}
|
||
|
||
/// Create a `PastedImage` with real PNG bytes.
|
||
fn make_real_image(width: u32, height: u32) -> PastedImage {
|
||
let png = make_test_png(width, height);
|
||
PastedImage {
|
||
element_id: ElementId::from_raw(1),
|
||
display_number: 1,
|
||
mime_type: "image/png".into(),
|
||
dimensions: Some((width, height)),
|
||
byte_len: png.len(),
|
||
encoded_bytes: Some(Arc::from(png)),
|
||
source_path: None,
|
||
staged_temp_path: None,
|
||
session_image_path: None,
|
||
preview: PromptImagePreview::default(),
|
||
}
|
||
}
|
||
|
||
// ----- load_for_send ------------------------------------------------
|
||
|
||
#[test]
|
||
fn load_small_image_passes_through() {
|
||
let img = make_real_image(100, 80);
|
||
let original_len = img.encoded_bytes.as_ref().unwrap().len();
|
||
let (bytes, mime) = load_for_send(&img).unwrap();
|
||
assert_eq!(mime, "image/png");
|
||
assert_eq!(bytes.len(), original_len);
|
||
}
|
||
|
||
#[test]
|
||
fn load_rejects_image_below_eight_pixel_minimum() {
|
||
let img = make_real_image(1, 1);
|
||
assert!(load_for_send(&img).is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn load_from_file() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let path = dir.path().join("test.png");
|
||
let png = make_test_png(50, 50);
|
||
std::fs::write(&path, &png).unwrap();
|
||
|
||
let img = PastedImage {
|
||
element_id: ElementId::from_raw(1),
|
||
display_number: 1,
|
||
mime_type: "image/png".into(),
|
||
dimensions: Some((50, 50)),
|
||
byte_len: png.len(),
|
||
encoded_bytes: None, // bytes released
|
||
source_path: None,
|
||
staged_temp_path: None,
|
||
session_image_path: Some(path),
|
||
preview: PromptImagePreview::default(),
|
||
};
|
||
let (bytes, _) = load_for_send(&img).unwrap();
|
||
assert_eq!(bytes.len(), png.len());
|
||
}
|
||
|
||
#[test]
|
||
fn load_returns_none_for_missing_data() {
|
||
let img = make_image(1, 1); // no bytes, no file
|
||
assert!(load_for_send(&img).is_none());
|
||
}
|
||
|
||
// ----- build_content_blocks_with_workspace --------------------------------
|
||
|
||
fn build_blocks_no_workspace(
|
||
text: String,
|
||
images: Vec<PastedImage>,
|
||
) -> Vec<agent_client_protocol::ContentBlock> {
|
||
build_content_blocks_with_workspace(text, images, None)
|
||
}
|
||
|
||
#[test]
|
||
fn build_blocks_text_only() {
|
||
let blocks = build_blocks_no_workspace("hello".into(), vec![]);
|
||
assert_eq!(blocks.len(), 1);
|
||
assert!(matches!(
|
||
&blocks[0],
|
||
agent_client_protocol::ContentBlock::Text(_)
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn build_blocks_with_in_memory_image() {
|
||
let img = make_real_image(100, 80);
|
||
let blocks = build_blocks_no_workspace("look at this [Image #1]".into(), vec![img]);
|
||
assert_eq!(blocks.len(), 2);
|
||
if let agent_client_protocol::ContentBlock::Image(ic) = &blocks[1] {
|
||
assert_eq!(ic.mime_type, "image/png");
|
||
assert!(!ic.data.is_empty());
|
||
assert!(ic.uri.is_none());
|
||
} else {
|
||
panic!("expected Image block");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn build_blocks_with_file_image() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let path = dir.path().join("test.png");
|
||
let png = make_test_png(60, 40);
|
||
std::fs::write(&path, &png).unwrap();
|
||
|
||
let img = PastedImage {
|
||
element_id: ElementId::from_raw(1),
|
||
display_number: 1,
|
||
mime_type: "image/png".into(),
|
||
dimensions: Some((60, 40)),
|
||
byte_len: png.len(),
|
||
encoded_bytes: None,
|
||
source_path: None,
|
||
staged_temp_path: None,
|
||
session_image_path: Some(path.clone()),
|
||
preview: PromptImagePreview::default(),
|
||
};
|
||
let blocks = build_blocks_no_workspace("text".into(), vec![img]);
|
||
assert_eq!(blocks.len(), 2);
|
||
if let agent_client_protocol::ContentBlock::Image(ic) = &blocks[1] {
|
||
assert!(!ic.data.is_empty());
|
||
// The durable session copy is surfaced through `uri` even for
|
||
// clipboard pastes (no `source_path`). This is the reference
|
||
// `image_edit` resolves `[Image #N]` against; vision is
|
||
// unaffected because `pick_user_image_url` never forwards a
|
||
// `file://` URI to the model.
|
||
assert_eq!(
|
||
ic.uri.as_deref(),
|
||
Some(format!("file://{}", path.display()).as_str()),
|
||
"clipboard images must carry the durable session path as a file:// URI"
|
||
);
|
||
} else {
|
||
panic!("expected Image block");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn build_blocks_omits_uri_for_stale_source_path() {
|
||
let mut img = make_real_image(100, 80);
|
||
img.source_path = Some(PathBuf::from("/Users/test/logo.png"));
|
||
let blocks = build_blocks_no_workspace("text".into(), vec![img]);
|
||
assert_eq!(blocks.len(), 2);
|
||
if let agent_client_protocol::ContentBlock::Image(ic) = &blocks[1] {
|
||
assert!(ic.uri.is_none());
|
||
assert!(!ic.data.is_empty());
|
||
} else {
|
||
panic!("expected Image block");
|
||
}
|
||
}
|
||
|
||
#[cfg(unix)]
|
||
#[test]
|
||
fn build_blocks_canonicalizes_source_only_for_wire_uri() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let target = dir.path().join("target.png");
|
||
let visible = dir.path().join("visible.png");
|
||
std::fs::write(&target, make_test_png(8, 8)).unwrap();
|
||
std::os::unix::fs::symlink(&target, &visible).unwrap();
|
||
let mut img = make_real_image(8, 8);
|
||
img.source_path = Some(visible.clone());
|
||
|
||
let blocks = build_blocks_no_workspace("text".into(), vec![img]);
|
||
let agent_client_protocol::ContentBlock::Image(image) = &blocks[1] else {
|
||
panic!("expected image");
|
||
};
|
||
let canonical_target = dunce::canonicalize(&target).unwrap();
|
||
assert_eq!(
|
||
image.uri.as_deref(),
|
||
Some(format!("file://{}", canonical_target.display()).as_str())
|
||
);
|
||
assert_ne!(visible, target);
|
||
}
|
||
|
||
#[test]
|
||
fn build_blocks_sets_display_number_meta() {
|
||
let mut img = make_real_image(40, 30);
|
||
img.display_number = 3;
|
||
let blocks = build_blocks_no_workspace("text [Image #3]".into(), vec![img]);
|
||
assert_eq!(blocks.len(), 2);
|
||
let agent_client_protocol::ContentBlock::Image(ic) = &blocks[1] else {
|
||
panic!("expected Image block");
|
||
};
|
||
assert_eq!(
|
||
xai_grok_shared::placeholder_images::display_number_from_meta(ic.meta.as_ref()),
|
||
Some(3),
|
||
"image block _meta must carry the real display number for token resolution"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn build_blocks_uri_prefers_durable_session_path() {
|
||
let mut img = make_real_image(100, 80);
|
||
img.source_path = Some(PathBuf::from("/Users/test/original.png"));
|
||
img.session_image_path = Some(PathBuf::from("/Users/test/.grok/session/image.png"));
|
||
let blocks = build_blocks_no_workspace("text".into(), vec![img]);
|
||
assert_eq!(blocks.len(), 2);
|
||
if let agent_client_protocol::ContentBlock::Image(ic) = &blocks[1] {
|
||
assert_eq!(
|
||
ic.uri.as_deref(),
|
||
Some("file:///Users/test/.grok/session/image.png"),
|
||
"model URI must prefer the durable session path"
|
||
);
|
||
} else {
|
||
panic!("expected Image block");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn build_blocks_skips_missing_image() {
|
||
let img = make_image(1, 1); // no bytes, no file path
|
||
let blocks = build_blocks_no_workspace("text".into(), vec![img]);
|
||
// Only the text block; image was skipped.
|
||
assert_eq!(blocks.len(), 1);
|
||
}
|
||
|
||
#[test]
|
||
fn build_blocks_one_bad_one_good() {
|
||
let bad = make_image(1, 1); // no bytes
|
||
let good = make_real_image(50, 50);
|
||
let blocks = build_blocks_no_workspace("text".into(), vec![bad, good]);
|
||
// Text + 1 good image; bad image skipped.
|
||
assert_eq!(blocks.len(), 2);
|
||
}
|
||
|
||
// ----- Orphan placeholder fallback ----------------------------------
|
||
//
|
||
// These tests go through `build_content_blocks_with_prefixes` with
|
||
// an explicit hermetic prefix list, so they do NOT read the
|
||
// ambient process `$HOME`. CI runners with unusual `HOME`
|
||
// settings cannot flip the outcomes.
|
||
|
||
#[test]
|
||
fn build_blocks_orphan_placeholder_loaded_from_disk() {
|
||
use base64::Engine as _;
|
||
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let path = dir.path().join("orphan.png");
|
||
let on_disk = make_test_png(20, 20);
|
||
std::fs::write(&path, &on_disk).unwrap();
|
||
|
||
let text = format!(
|
||
"look at [Image #1: {}] please",
|
||
dunce::canonicalize(&path).unwrap().display(),
|
||
);
|
||
let allowed = [dunce::canonicalize(dir.path()).unwrap()];
|
||
let blocks = build_content_blocks_with_prefixes(text, vec![], Some(&allowed));
|
||
|
||
// Text block + 1 recovered image.
|
||
assert_eq!(blocks.len(), 2);
|
||
let agent_client_protocol::ContentBlock::Image(ic) = &blocks[1] else {
|
||
panic!("expected recovered Image block");
|
||
};
|
||
assert_eq!(ic.mime_type, "image/png");
|
||
assert!(!ic.data.is_empty());
|
||
assert!(
|
||
ic.uri.as_deref().unwrap().starts_with("file://"),
|
||
"uri should be file:// URI form, got {:?}",
|
||
ic.uri
|
||
);
|
||
// Base64-encoded `data` must round-trip back to the on-disk
|
||
// PNG bytes. A regression emitting raw bytes or
|
||
// double-encoding would fail this assertion.
|
||
let decoded = base64::engine::general_purpose::STANDARD
|
||
.decode(&ic.data)
|
||
.expect("data must be valid base64");
|
||
assert_eq!(decoded, on_disk);
|
||
// Placeholder anchor stays but the path is now stripped — the
|
||
// image is already attached inline, so the model has no reason
|
||
// to call `Read` on the path (and the path component would
|
||
// tempt it to). The bracketed `[Image #N]` form preserves the
|
||
// positional anchor inside the prose.
|
||
let agent_client_protocol::ContentBlock::Text(t) = &blocks[0] else {
|
||
panic!("first block must be text");
|
||
};
|
||
assert!(
|
||
t.text.contains("[Image #1]"),
|
||
"anchor should be preserved on successful recovery, got: {}",
|
||
t.text
|
||
);
|
||
assert!(
|
||
!t.text.contains("[Image #1:"),
|
||
"the path-bearing form must be stripped once the image is attached, got: {}",
|
||
t.text
|
||
);
|
||
assert!(
|
||
!t.text.contains("orphan.png"),
|
||
"file name leaked through after path strip, got: {}",
|
||
t.text
|
||
);
|
||
}
|
||
|
||
// Regression: when the user types `[Image #N: <path>]` and the file
|
||
// exists at paste time, the prompt-widget creates a PastedImage and
|
||
// the image is attached inline. The path in the prompt text must
|
||
// then be stripped to `[Image #N]` so the model doesn't follow up
|
||
// with a redundant `Read` tool call on the same file.
|
||
#[test]
|
||
fn build_blocks_strips_path_when_pasted_image_attached() {
|
||
let mut img = make_real_image(40, 30);
|
||
img.display_number = 1;
|
||
img.source_path = Some(std::path::PathBuf::from(
|
||
"/Users/me/Desktop/Screenshot 2026-05-22 at 16.01.21.png",
|
||
));
|
||
let text = "what is that?[Image #1: /Users/me/Desktop/Screenshot 2026-05-22 at 16.01.21.png] thanks".to_string();
|
||
|
||
// Hermetic: no orphan-recovery path needed (PastedImage matches).
|
||
let blocks = build_content_blocks_with_prefixes(text, vec![img], Some(&[]));
|
||
|
||
assert_eq!(blocks.len(), 2, "expected text + 1 inline image");
|
||
let agent_client_protocol::ContentBlock::Text(t) = &blocks[0] else {
|
||
panic!("first block must be text");
|
||
};
|
||
assert_eq!(
|
||
t.text, "what is that?[Image #1] thanks",
|
||
"placeholder path must be stripped while the anchor survives"
|
||
);
|
||
let agent_client_protocol::ContentBlock::Image(ic) = &blocks[1] else {
|
||
panic!("second block must be the inline image");
|
||
};
|
||
assert_eq!(ic.mime_type, "image/png");
|
||
assert!(!ic.data.is_empty(), "inline image must carry base64 bytes");
|
||
}
|
||
|
||
#[test]
|
||
fn build_blocks_orphan_placeholder_missing_file_is_stripped_with_warn() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let bogus = dir.path().join("nope.png");
|
||
let text = format!("before [Image #4: {}] after", bogus.display());
|
||
|
||
let allowed = [dunce::canonicalize(dir.path()).unwrap()];
|
||
let blocks = build_content_blocks_with_prefixes(text, vec![], Some(&allowed));
|
||
// No image attached, only text block.
|
||
assert_eq!(blocks.len(), 1);
|
||
let agent_client_protocol::ContentBlock::Text(t) = &blocks[0] else {
|
||
panic!("expected text block");
|
||
};
|
||
// Pin the exact post-strip text — the strip seam (space
|
||
// before + space after the placeholder) collapses to a
|
||
// single space.
|
||
assert_eq!(t.text, "before after");
|
||
}
|
||
|
||
#[test]
|
||
fn collapse_strip_seam_preserves_code_block_indentation() {
|
||
// A naive implementation could collapse ALL 2+-space runs
|
||
// in the text after a single strip; indented code further
|
||
// down the text must survive intact.
|
||
let mut text = String::from("hello [Image #1: /tmp/x.png] world\n fn foo() {}\n");
|
||
let span = (text.find("[Image").unwrap(), text.find("]").unwrap() + 1);
|
||
collapse_strip_seam(&mut text, span.0, span.1);
|
||
// The strip seam (space–space) collapses to one space, while
|
||
// the 4-space code indentation further down is untouched.
|
||
assert_eq!(text, "hello world\n fn foo() {}\n");
|
||
}
|
||
|
||
#[test]
|
||
fn collapse_strip_seam_preserves_double_space_elsewhere() {
|
||
// Punctuation double-space far from the strip seam stays.
|
||
let mut text = String::from("a. b [Image #1: /x.png] c. d");
|
||
let span = (text.find("[Image").unwrap(), text.find("]").unwrap() + 1);
|
||
collapse_strip_seam(&mut text, span.0, span.1);
|
||
assert_eq!(text, "a. b c. d");
|
||
}
|
||
|
||
#[test]
|
||
fn build_blocks_orphan_skipped_when_pasted_image_present() {
|
||
// PastedImage with display_number 1 is attached; the matching
|
||
// placeholder must NOT trigger an on-disk load even when the
|
||
// placeholder's path doesn't exist.
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let missing = dir.path().join("does-not-exist.png");
|
||
let img = make_real_image(40, 40);
|
||
let text = format!("see [Image #1: {}]", missing.display());
|
||
|
||
let allowed = [dunce::canonicalize(dir.path()).unwrap()];
|
||
let blocks = build_content_blocks_with_prefixes(text, vec![img], Some(&allowed));
|
||
// Text + the PastedImage's own block; no orphan recovery
|
||
// (skipped because `display_number` matches).
|
||
assert_eq!(blocks.len(), 2);
|
||
let agent_client_protocol::ContentBlock::Text(t) = &blocks[0] else {
|
||
panic!("first block must be text");
|
||
};
|
||
// Phase 2 universal strip: the anchor `[Image #1]` survives so
|
||
// the model can place the inline image, but the path is gone
|
||
// even though no orphan-recovery loaded it (a PastedImage
|
||
// already provided the bytes). This avoids the "model calls
|
||
// Read on the path even though the image is attached" pattern.
|
||
assert!(
|
||
t.text.contains("[Image #1]"),
|
||
"anchor must survive when a PastedImage backs the placeholder, got: {}",
|
||
t.text
|
||
);
|
||
assert!(
|
||
!t.text.contains("[Image #1:"),
|
||
"path must be stripped even when the load was skipped, got: {}",
|
||
t.text
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn build_blocks_no_workspace_falls_back_to_legacy_behavior() {
|
||
// Without a workspace cwd, orphan placeholders are not loaded
|
||
// from disk (legacy behaviour preserved). The Phase 2 path
|
||
// strip still runs — it is independent of the allowlist —
|
||
// because the model-facing prompt should never contain the
|
||
// path-bearing form regardless of whether the load happened.
|
||
let text = "look at [Image #2: /nowhere/missing.png]";
|
||
let blocks = build_content_blocks_with_workspace(text.into(), vec![], None);
|
||
// Text block only — no recovery without a workspace.
|
||
assert_eq!(blocks.len(), 1);
|
||
let agent_client_protocol::ContentBlock::Text(t) = &blocks[0] else {
|
||
panic!("expected text block");
|
||
};
|
||
assert!(
|
||
t.text.contains("[Image #2]"),
|
||
"anchor must survive the no-workspace path, got: {}",
|
||
t.text
|
||
);
|
||
assert!(
|
||
!t.text.contains("[Image #2:"),
|
||
"path strip is unconditional once the regex matches, got: {}",
|
||
t.text
|
||
);
|
||
}
|
||
|
||
// ----- TUI aggregate-cap injectable variant + tests -----------------
|
||
//
|
||
// Mirrors the server-side
|
||
// `recover_orphan_placeholders_with_prefixes_and_caps` tests so a
|
||
// refactor of the TUI loop (e.g. moving `aggregate_bytes += ...`
|
||
// before the cap check, or swapping `break` for `continue`) is
|
||
// caught here even though the cap constant is shared.
|
||
|
||
/// Two orphan placeholders, aggregate cap admits exactly one.
|
||
/// Asserts the second placeholder did NOT load (only one image
|
||
/// block in the output) and the first one did.
|
||
#[test]
|
||
fn build_blocks_orphan_aggregate_cap_breaks_loop() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let p1 = dir.path().join("a.png");
|
||
let p2 = dir.path().join("b.png");
|
||
let png = make_test_png(20, 20);
|
||
std::fs::write(&p1, &png).unwrap();
|
||
std::fs::write(&p2, &png).unwrap();
|
||
let c1 = dunce::canonicalize(&p1).unwrap();
|
||
let c2 = dunce::canonicalize(&p2).unwrap();
|
||
let text = format!("[Image #1: {}] [Image #2: {}]", c1.display(), c2.display());
|
||
let allowed = [dunce::canonicalize(dir.path()).unwrap()];
|
||
// Cap admits the first image but not the cumulative second.
|
||
let blocks =
|
||
build_content_blocks_with_prefixes_and_caps(text, vec![], Some(&allowed), png.len());
|
||
// Text + 1 recovered image (not 2).
|
||
assert_eq!(blocks.len(), 2);
|
||
let agent_client_protocol::ContentBlock::Image(ic) = &blocks[1] else {
|
||
panic!("expected recovered Image block");
|
||
};
|
||
let attached_uri = ic.uri.as_deref().unwrap();
|
||
assert!(
|
||
attached_uri.contains("a.png"),
|
||
"first placeholder must be the one kept, got: {attached_uri}"
|
||
);
|
||
// Cap-breach is a `break` path, not an `Err`-path strip —
|
||
// the rejected placeholder's anchor must survive in the
|
||
// prompt. Symmetric to the single-image inclusive-boundary
|
||
// pin in
|
||
// `build_blocks_orphan_aggregate_cap_inclusive_boundary_rejects_at_one_below`.
|
||
//
|
||
// Phase 2 universal path-strip: the `: <path>` component is
|
||
// stripped uniformly across every surviving placeholder, so
|
||
// the anchor `[Image #2]` is what survives. Intent: anchor
|
||
// preserved so the model still sees the in-prose position;
|
||
// path is gone because the image isn't attached and a bare
|
||
// path would tempt a `Read`.
|
||
let agent_client_protocol::ContentBlock::Text(t) = &blocks[0] else {
|
||
panic!("expected text block");
|
||
};
|
||
assert!(
|
||
t.text.contains("[Image #2]"),
|
||
"anchor of rejected placeholder must survive cap breach, got: {}",
|
||
t.text
|
||
);
|
||
assert!(
|
||
!t.text.contains("[Image #2:"),
|
||
"path of rejected placeholder must be stripped, got: {}",
|
||
t.text
|
||
);
|
||
}
|
||
|
||
/// Inclusive boundary: cap == single image size admits the image.
|
||
#[test]
|
||
fn build_blocks_orphan_aggregate_cap_inclusive_boundary() {
|
||
use base64::Engine as _;
|
||
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let path = dir.path().join("one.png");
|
||
let png = make_test_png(20, 20);
|
||
std::fs::write(&path, &png).unwrap();
|
||
let canon = dunce::canonicalize(&path).unwrap();
|
||
let text = format!("[Image #1: {}]", canon.display());
|
||
let allowed = [dunce::canonicalize(dir.path()).unwrap()];
|
||
let blocks =
|
||
build_content_blocks_with_prefixes_and_caps(text, vec![], Some(&allowed), png.len());
|
||
assert_eq!(blocks.len(), 2);
|
||
// Symmetric to
|
||
// `build_blocks_orphan_placeholder_loaded_from_disk` —
|
||
// decode the base64 data and assert byte-for-byte equality
|
||
// with the on-disk PNG so a regression emitting wrong bytes
|
||
// at the inclusive boundary is caught.
|
||
let agent_client_protocol::ContentBlock::Image(ic) = &blocks[1] else {
|
||
panic!("expected recovered Image block");
|
||
};
|
||
assert_eq!(ic.mime_type, "image/png");
|
||
let decoded = base64::engine::general_purpose::STANDARD
|
||
.decode(&ic.data)
|
||
.expect("data must be valid base64");
|
||
assert_eq!(decoded, png);
|
||
}
|
||
|
||
/// Reject side: cap == image size - 1 rejects the image.
|
||
///
|
||
/// **Text-side contract.** Aggregate-cap breach is a `break`
|
||
/// path in `resolve_orphan_placeholders`, not a per-image
|
||
/// `Err` path. Only `Err`-path failures strip the placeholder
|
||
/// text; cap-breach intentionally **leaves the placeholder
|
||
/// text intact** because the load itself succeeded (the file
|
||
/// is valid, just doesn't fit in the budget). The test pins
|
||
/// both halves of this contract: no image block AND
|
||
/// placeholder text preserved.
|
||
#[test]
|
||
fn build_blocks_orphan_aggregate_cap_inclusive_boundary_rejects_at_one_below() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let path = dir.path().join("one.png");
|
||
let png = make_test_png(20, 20);
|
||
std::fs::write(&path, &png).unwrap();
|
||
let canon = dunce::canonicalize(&path).unwrap();
|
||
let text = format!("[Image #1: {}]", canon.display());
|
||
let allowed = [dunce::canonicalize(dir.path()).unwrap()];
|
||
let blocks = build_content_blocks_with_prefixes_and_caps(
|
||
text,
|
||
vec![],
|
||
Some(&allowed),
|
||
png.len() - 1,
|
||
);
|
||
// Cap below image size → no recovered image; only the text
|
||
// block remains.
|
||
assert_eq!(blocks.len(), 1);
|
||
let agent_client_protocol::ContentBlock::Text(t) = &blocks[0] else {
|
||
panic!("expected text block");
|
||
};
|
||
// Placeholder anchor is NOT stripped on aggregate-cap
|
||
// breach (cap-breach is a `break` path, not a load `Err`).
|
||
// Pinning the preservation half of the contract.
|
||
//
|
||
// Phase 2 path-strip update: the bracketed anchor
|
||
// `[Image #N]` survives, but the `: <path>` component is
|
||
// stripped uniformly across every surviving placeholder. The
|
||
// model can still see *where* in the prose the image was
|
||
// referenced via the anchor; the path metadata is no longer
|
||
// leaked because no image is actually attached.
|
||
assert!(
|
||
t.text.contains("[Image #1]"),
|
||
"anchor must survive aggregate-cap breach, got: {}",
|
||
t.text
|
||
);
|
||
assert!(
|
||
!t.text.contains("[Image #1:"),
|
||
"path-bearing form must be stripped, got: {}",
|
||
t.text
|
||
);
|
||
}
|
||
|
||
// ----- T8: cleanup and lifecycle edge cases ------------------------------
|
||
|
||
#[test]
|
||
fn clear_deletes_staged_temp_file() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let tmp_path = dir.path().join("staged.png");
|
||
std::fs::write(&tmp_path, b"fake").unwrap();
|
||
assert!(tmp_path.exists());
|
||
|
||
let mut images = vec![PastedImage {
|
||
element_id: ElementId::from_raw(1),
|
||
display_number: 1,
|
||
mime_type: "image/png".into(),
|
||
dimensions: None,
|
||
byte_len: 4,
|
||
encoded_bytes: None,
|
||
source_path: None,
|
||
staged_temp_path: Some(tmp_path.clone()),
|
||
session_image_path: None, // not yet persisted to session
|
||
preview: PromptImagePreview::default(),
|
||
}];
|
||
let mut counter = 1;
|
||
clear(&mut images, &mut counter);
|
||
|
||
assert!(images.is_empty());
|
||
assert!(!tmp_path.exists(), "staged temp file should be deleted");
|
||
}
|
||
|
||
#[test]
|
||
fn clear_preserves_session_persisted_file() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let session_path = dir.path().join("image-abc.png");
|
||
std::fs::write(&session_path, b"real").unwrap();
|
||
|
||
let mut images = vec![PastedImage {
|
||
element_id: ElementId::from_raw(1),
|
||
display_number: 1,
|
||
mime_type: "image/png".into(),
|
||
dimensions: None,
|
||
byte_len: 4,
|
||
encoded_bytes: None,
|
||
source_path: None,
|
||
staged_temp_path: None,
|
||
session_image_path: Some(session_path.clone()),
|
||
preview: PromptImagePreview::default(),
|
||
}];
|
||
let mut counter = 1;
|
||
clear(&mut images, &mut counter);
|
||
|
||
assert!(session_path.exists(), "session file should NOT be deleted");
|
||
}
|
||
|
||
#[test]
|
||
fn reconcile_deletes_staged_temp_for_removed_chip() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let tmp_path = dir.path().join("orphan.png");
|
||
std::fs::write(&tmp_path, b"orphan").unwrap();
|
||
|
||
let mut images = vec![PastedImage {
|
||
element_id: ElementId::from_raw(42),
|
||
display_number: 1,
|
||
mime_type: "image/png".into(),
|
||
dimensions: None,
|
||
byte_len: 6,
|
||
encoded_bytes: None,
|
||
source_path: None,
|
||
staged_temp_path: Some(tmp_path.clone()),
|
||
session_image_path: None,
|
||
preview: PromptImagePreview::default(),
|
||
}];
|
||
|
||
// Element 42 is no longer live.
|
||
let live: HashSet<ElementId> = HashSet::new();
|
||
reconcile(&mut images, &live);
|
||
|
||
assert!(images.is_empty());
|
||
assert!(!tmp_path.exists(), "staged temp file should be cleaned up");
|
||
}
|
||
|
||
#[test]
|
||
fn reconcile_preserves_session_file_for_removed_chip() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let session_path = dir.path().join("persisted.png");
|
||
std::fs::write(&session_path, b"keep").unwrap();
|
||
|
||
let mut images = vec![PastedImage {
|
||
element_id: ElementId::from_raw(42),
|
||
display_number: 1,
|
||
mime_type: "image/png".into(),
|
||
dimensions: None,
|
||
byte_len: 4,
|
||
encoded_bytes: None,
|
||
source_path: None,
|
||
staged_temp_path: None,
|
||
session_image_path: Some(session_path.clone()),
|
||
preview: PromptImagePreview::default(),
|
||
}];
|
||
|
||
let live: HashSet<ElementId> = HashSet::new();
|
||
reconcile(&mut images, &live);
|
||
|
||
assert!(images.is_empty());
|
||
assert!(
|
||
session_path.exists(),
|
||
"session-persisted file should remain"
|
||
);
|
||
}
|
||
|
||
// ----- ScrollbackImageRef ------------------------------------------------
|
||
|
||
#[test]
|
||
fn scrollback_ref_from_valid_image_path() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let path = dir.path().join("test.png");
|
||
std::fs::write(&path, make_test_png(100, 80)).unwrap();
|
||
|
||
let r = ScrollbackImageRef::from_path(&path).unwrap();
|
||
assert_eq!(r.path, path);
|
||
}
|
||
|
||
#[test]
|
||
fn scrollback_ref_rejects_non_image_extension() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let path = dir.path().join("data.txt");
|
||
std::fs::write(&path, b"hello").unwrap();
|
||
|
||
assert!(ScrollbackImageRef::from_path(&path).is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn scrollback_ref_rejects_undecodable_image_extension() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let path = dir.path().join("not-an-image.png");
|
||
std::fs::write(&path, b"hello").unwrap();
|
||
|
||
assert!(ScrollbackImageRef::from_path(&path).is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn scrollback_ref_accepts_valid_jpeg_path() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let path = dir.path().join("photo.jpg");
|
||
std::fs::write(&path, make_test_jpeg(25, 20)).unwrap();
|
||
|
||
let r = ScrollbackImageRef::from_path(&path).unwrap();
|
||
assert_eq!(r.path, path);
|
||
}
|
||
|
||
#[test]
|
||
fn extract_skips_undecodable_image_paths() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let path = dir.path().join("not-an-image.png");
|
||
std::fs::write(&path, b"hello").unwrap();
|
||
|
||
let text = format!("", path.display());
|
||
let refs = extract_image_refs(&text);
|
||
assert!(refs.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn scrollback_ref_rejects_missing_file() {
|
||
assert!(ScrollbackImageRef::from_path("/nonexistent/image.png").is_none());
|
||
}
|
||
|
||
// ----- extract_image_refs ------------------------------------------------
|
||
|
||
#[test]
|
||
fn extract_markdown_image_ref() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let path = dir.path().join("hero.png");
|
||
std::fs::write(&path, make_test_png(50, 50)).unwrap();
|
||
|
||
let text = format!("Here is the image: ", path.display());
|
||
let refs = extract_image_refs(&text);
|
||
assert_eq!(refs.len(), 1);
|
||
assert_eq!(refs[0].path, path);
|
||
}
|
||
|
||
#[test]
|
||
fn extract_bare_absolute_path() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let path = dir.path().join("photo.png");
|
||
std::fs::write(&path, make_test_png(25, 20)).unwrap();
|
||
|
||
let text = format!("saved to {} (1234 bytes)", path.display());
|
||
let refs = extract_image_refs(&text);
|
||
assert_eq!(refs.len(), 1);
|
||
assert_eq!(refs[0].path, path);
|
||
}
|
||
|
||
#[test]
|
||
fn extract_bare_absolute_jpeg_path() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let path = dir.path().join("photo.jpg");
|
||
std::fs::write(&path, make_test_jpeg(25, 20)).unwrap();
|
||
|
||
let text = format!("saved to {} (1234 bytes)", path.display());
|
||
let refs = extract_image_refs(&text);
|
||
assert_eq!(refs.len(), 1);
|
||
assert_eq!(refs[0].path, path);
|
||
}
|
||
|
||
#[test]
|
||
fn media_only_markdown_accepts_image_ref_only() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let path = dir.path().join("generated.jpg");
|
||
std::fs::write(&path, make_test_jpeg(25, 20)).unwrap();
|
||
|
||
let text = format!(" \n\n", path.display());
|
||
let refs = extract_image_refs(&text);
|
||
assert_eq!(refs.len(), 1);
|
||
assert!(is_media_only_markdown(&text, refs.len()));
|
||
}
|
||
|
||
#[test]
|
||
fn media_only_markdown_rejects_mixed_text() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let path = dir.path().join("generated.jpg");
|
||
std::fs::write(&path, make_test_jpeg(25, 20)).unwrap();
|
||
|
||
let text = format!("Here is an image: ", path.display());
|
||
let refs = extract_image_refs(&text);
|
||
assert_eq!(refs.len(), 1);
|
||
assert!(!is_media_only_markdown(&text, refs.len()));
|
||
}
|
||
|
||
#[test]
|
||
fn extract_deduplicates_same_path() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let path = dir.path().join("dup.png");
|
||
std::fs::write(&path, make_test_png(10, 10)).unwrap();
|
||
|
||
let text = format!(" and ", p = path.display());
|
||
let refs = extract_image_refs(&text);
|
||
assert_eq!(refs.len(), 1);
|
||
}
|
||
|
||
#[test]
|
||
fn extract_skips_nonexistent_paths() {
|
||
let text = "";
|
||
let refs = extract_image_refs(text);
|
||
assert!(refs.is_empty());
|
||
}
|
||
|
||
// ----- open_from_path ----------------------------------------------------
|
||
|
||
#[test]
|
||
fn open_from_path_valid_image() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let path = dir.path().join("viewer.png");
|
||
let png = make_test_png(200, 150);
|
||
std::fs::write(&path, &png).unwrap();
|
||
|
||
let viewer = ImageViewerState::open_from_path(&path).unwrap();
|
||
assert_eq!(viewer.image_width, 200);
|
||
assert_eq!(viewer.image_height, 150);
|
||
assert_eq!(viewer.display_number, 1);
|
||
assert_eq!(viewer.image_bytes, png);
|
||
assert_eq!(viewer.display_bytes, viewer.image_bytes);
|
||
}
|
||
|
||
#[test]
|
||
fn open_from_path_valid_jpeg_image() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let path = dir.path().join("viewer.jpg");
|
||
let jpeg = make_test_jpeg(200, 150);
|
||
std::fs::write(&path, &jpeg).unwrap();
|
||
|
||
let viewer = ImageViewerState::open_from_path(&path).unwrap();
|
||
assert_eq!(viewer.image_width, 200);
|
||
assert_eq!(viewer.image_height, 150);
|
||
assert_eq!(viewer.image_bytes, jpeg);
|
||
}
|
||
|
||
#[test]
|
||
fn open_from_path_missing_file() {
|
||
assert!(
|
||
ImageViewerState::open_from_path(std::path::Path::new("/no/such/file.png")).is_none()
|
||
);
|
||
}
|
||
|
||
// ----- open_from_path_deferred -------------------------------------------
|
||
|
||
#[test]
|
||
fn deferred_open_starts_in_loading_state() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let path = dir.path().join("deferred.png");
|
||
let png = make_test_png(80, 60);
|
||
std::fs::write(&path, &png).unwrap();
|
||
|
||
let viewer = ImageViewerState::open_from_path_deferred(&path);
|
||
assert!(viewer.loading);
|
||
assert!(viewer.image_bytes.is_empty());
|
||
assert_eq!(viewer.image_width, 0);
|
||
assert_eq!(viewer.title.as_deref(), Some("deferred.png"));
|
||
}
|
||
|
||
#[test]
|
||
fn deferred_finish_loading_succeeds() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let path = dir.path().join("deferred.png");
|
||
let png = make_test_png(80, 60);
|
||
std::fs::write(&path, &png).unwrap();
|
||
|
||
let mut viewer = ImageViewerState::open_from_path_deferred(&path);
|
||
assert!(viewer.finish_loading());
|
||
assert!(!viewer.loading);
|
||
assert_eq!(viewer.image_width, 80);
|
||
assert_eq!(viewer.image_height, 60);
|
||
assert_eq!(viewer.image_bytes, png);
|
||
}
|
||
|
||
#[test]
|
||
fn deferred_finish_loading_missing_file_fails() {
|
||
let mut viewer =
|
||
ImageViewerState::open_from_path_deferred(std::path::Path::new("/no/such/file.png"));
|
||
assert!(!viewer.finish_loading());
|
||
}
|
||
|
||
#[test]
|
||
fn deferred_finish_loading_idempotent() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let path = dir.path().join("idem.png");
|
||
std::fs::write(&path, make_test_png(10, 10)).unwrap();
|
||
|
||
let mut viewer = ImageViewerState::open_from_path_deferred(&path);
|
||
assert!(viewer.finish_loading());
|
||
// Second call is a no-op, returns true.
|
||
assert!(viewer.finish_loading());
|
||
assert!(!viewer.loading);
|
||
}
|
||
}
|