Skip to main content

pi/core/tools/
read.rs

1//! Read tool: text files and supported images.
2//!
3//! Ports `.references/pi/packages/coding-agent/src/core/tools/read.ts` plus the
4//! image pipeline from `utils/{mime,image-process,image-resize-core}.ts`.
5
6use std::fmt::Write as _;
7use std::io::Cursor;
8use std::path::PathBuf;
9use std::sync::Arc;
10
11use base64::Engine as _;
12use base64::engine::general_purpose::STANDARD as BASE64;
13use futures::FutureExt as _;
14use futures::future::BoxFuture;
15use image::codecs::jpeg::JpegEncoder;
16use image::imageops::FilterType;
17use image::{DynamicImage, ImageDecoder, ImageFormat, ImageReader};
18use pi_agent::{AgentTool, AgentToolResult, ToolError, ToolUpdates};
19use pi_ai::types::{ImageContent, Model, ModelInput, TextContent, ToolResultContent};
20use schemars::JsonSchema;
21use serde::{Deserialize, Serialize};
22use serde_json::{Map, Value, json};
23use tokio::task;
24use tokio_util::sync::CancellationToken;
25
26use super::{
27    DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, PathResolveError, TruncationOptions, TruncationResult,
28    format_size, resolve_read_path_async, truncate_head,
29};
30
31/// Default max image edge (TypeScript `ImageResizeOptions.maxWidth/maxHeight`).
32const IMAGE_MAX_DIMENSION: u32 = 2000;
33/// Default max base64 payload size (4.5 MiB).
34const IMAGE_MAX_BASE64_BYTES: usize = 4_718_592;
35/// Default JPEG quality for the first encode candidate.
36const IMAGE_JPEG_QUALITY: u8 = 80;
37/// Bytes sniffed for MIME detection (TypeScript `IMAGE_TYPE_SNIFF_BYTES`).
38const IMAGE_TYPE_SNIFF_BYTES: usize = 4100;
39
40const PNG_SIGNATURE: [u8; 8] = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
41
42/// TypeBox-compatible read arguments (fixture `read.json`).
43#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
44pub struct ReadToolInput {
45    /// Path to the file to read (relative or absolute).
46    #[schemars(description = "Path to the file to read (relative or absolute)")]
47    pub path: String,
48    /// Line number to start reading from (1-indexed).
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    #[schemars(description = "Line number to start reading from (1-indexed)")]
51    pub offset: Option<f64>,
52    /// Maximum number of lines to read.
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    #[schemars(description = "Maximum number of lines to read")]
55    pub limit: Option<f64>,
56}
57
58/// Optional structured details returned by the read tool.
59#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
60#[serde(rename_all = "camelCase")]
61pub struct ReadToolDetails {
62    /// Truncation metadata when head truncation applied.
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub truncation: Option<TruncationResult>,
65}
66
67/// Options for [`ReadTool`].
68#[derive(Clone, Debug)]
69pub struct ReadToolOptions {
70    /// Working directory used to resolve relative paths.
71    pub cwd: PathBuf,
72    /// Whether to auto-resize images to provider inline limits. Default: true.
73    pub auto_resize_images: bool,
74    /// When `Some` and the model does not accept images, append the non-vision note.
75    pub model: Option<Model>,
76}
77
78impl ReadToolOptions {
79    /// Builds options for `cwd` with default image resizing and no model.
80    #[must_use]
81    pub fn new(cwd: impl Into<PathBuf>) -> Self {
82        Self {
83            cwd: cwd.into(),
84            auto_resize_images: true,
85            model: None,
86        }
87    }
88
89    /// Sets the model used for non-vision image omission notes.
90    #[must_use]
91    pub fn with_model(mut self, model: Option<Model>) -> Self {
92        self.model = model;
93        self
94    }
95
96    /// Enables or disables automatic image resizing.
97    #[must_use]
98    pub fn with_auto_resize_images(mut self, auto_resize_images: bool) -> Self {
99        self.auto_resize_images = auto_resize_images;
100        self
101    }
102}
103
104/// Agent tool that reads text and supported image files.
105#[derive(Clone, Debug)]
106pub struct ReadTool {
107    cwd: PathBuf,
108    auto_resize_images: bool,
109    model: Option<Model>,
110    parameters: Value,
111    description: String,
112}
113
114impl ReadTool {
115    /// Creates a read tool rooted at `cwd`.
116    #[must_use]
117    pub fn new(cwd: impl Into<PathBuf>) -> Self {
118        Self::with_options(ReadToolOptions::new(cwd))
119    }
120
121    /// Creates a read tool from explicit options.
122    #[must_use]
123    pub fn with_options(options: ReadToolOptions) -> Self {
124        let description = format!(
125            "Read the contents of a file. Supports text files and images (jpg, png, gif, webp, bmp). Images are sent as attachments. For text files, output is truncated to {DEFAULT_MAX_LINES} lines or {}KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete.",
126            DEFAULT_MAX_BYTES / 1024
127        );
128        Self {
129            cwd: options.cwd,
130            auto_resize_images: options.auto_resize_images,
131            model: options.model,
132            parameters: read_parameters_schema(),
133            description,
134        }
135    }
136
137    /// Returns the JSON Schema for read arguments (normalized `TypeBox` shape).
138    #[must_use]
139    pub fn parameters_schema() -> Value {
140        read_parameters_schema()
141    }
142
143    /// Validates raw tool arguments into [`ReadToolInput`].
144    ///
145    /// # Errors
146    ///
147    /// Returns [`ToolError`] when required fields are missing or mistyped.
148    pub fn parse_input(args: &Map<String, Value>) -> Result<ReadToolInput, ToolError> {
149        serde_json::from_value(Value::Object(args.clone()))
150            .map_err(|error| ToolError::new(format!("Read tool input is invalid. {error}")))
151    }
152}
153
154impl AgentTool for ReadTool {
155    fn name(&self) -> &'static str {
156        "read"
157    }
158
159    fn label(&self) -> &'static str {
160        "read"
161    }
162
163    fn description(&self) -> &str {
164        &self.description
165    }
166
167    fn parameters(&self) -> &Value {
168        &self.parameters
169    }
170
171    fn validate_arguments(
172        &self,
173        args: &Map<String, Value>,
174    ) -> Result<Map<String, Value>, ToolError> {
175        let _ = Self::parse_input(args)?;
176        Ok(args.clone())
177    }
178
179    fn execute(
180        &self,
181        _tool_call_id: &str,
182        args: Map<String, Value>,
183        cancel: CancellationToken,
184        _updates: ToolUpdates,
185    ) -> BoxFuture<'static, Result<AgentToolResult, ToolError>> {
186        let cwd = self.cwd.clone();
187        let auto_resize_images = self.auto_resize_images;
188        let model = self.model.clone();
189        async move {
190            throw_if_cancelled(&cancel)?;
191            let input = ReadTool::parse_input(&args)?;
192            let absolute_path =
193                resolve_read_path_async(&input.path, cwd.to_string_lossy().as_ref())
194                    .await
195                    .map_err(|error| path_error(&error))?;
196            throw_if_cancelled(&cancel)?;
197
198            // Readable access check (TypeScript fs.access R_OK).
199            ensure_readable(&absolute_path).await?;
200            throw_if_cancelled(&cancel)?;
201
202            let bytes = tokio::fs::read(&absolute_path).await.map_err(|error| {
203                ToolError::new(format!("Could not read file {absolute_path}: {error}"))
204            })?;
205            throw_if_cancelled(&cancel)?;
206
207            let sniff_len = bytes.len().min(IMAGE_TYPE_SNIFF_BYTES);
208            if let Some(mime) = detect_supported_image_mime_type(&bytes[..sniff_len]) {
209                let non_vision = non_vision_image_note(model.as_ref());
210                let processed = {
211                    let cancel = cancel.clone();
212                    task::spawn_blocking(move || {
213                        throw_if_cancelled(&cancel)?;
214                        Ok::<ProcessImageResult, ToolError>(process_image_bytes(
215                            &bytes,
216                            &mime,
217                            auto_resize_images,
218                        ))
219                    })
220                    .await
221                    .map_err(|error| {
222                        ToolError::new(format!("image processing failed: {error}"))
223                    })??
224                };
225                throw_if_cancelled(&cancel)?;
226                return Ok(image_tool_result(processed, non_vision.as_deref()));
227            }
228
229            // Byte-preserving UTF-8 decode (lossy only at invalid sequences, matching
230            // Node Buffer.toString("utf-8") replacement behavior for invalid bytes).
231            let text_content = String::from_utf8_lossy(&bytes).into_owned();
232            let result = build_text_result(&input, &text_content)?;
233            throw_if_cancelled(&cancel)?;
234            Ok(result)
235        }
236        .boxed()
237    }
238}
239
240fn build_text_result(
241    input: &ReadToolInput,
242    text_content: &str,
243) -> Result<AgentToolResult, ToolError> {
244    // TypeScript split("\n") keeps a trailing empty entry in totalFileLines.
245    let all_lines: Vec<&str> = text_content.split('\n').collect();
246    let total_file_lines = all_lines.len();
247    let offset = input.offset.map(floored_nonnegative_usize);
248    let limit = input.limit.map(floored_nonnegative_usize);
249
250    let start_line = offset.map_or(0, |value| value.saturating_sub(1));
251
252    if start_line >= all_lines.len() {
253        return Err(ToolError::new(format!(
254            "Offset {} is beyond end of file ({total_file_lines} lines total)",
255            offset.unwrap_or(0)
256        )));
257    }
258    let start_line_display = start_line + 1;
259
260    let (selected_content, user_limited_lines) = if let Some(limit) = limit {
261        let end_line = start_line.saturating_add(limit).min(all_lines.len());
262        let selected = all_lines[start_line..end_line].join("\n");
263        (selected, Some(end_line - start_line))
264    } else {
265        (all_lines[start_line..].join("\n"), None)
266    };
267
268    let truncation = truncate_head(
269        &selected_content,
270        TruncationOptions {
271            max_lines: Some(DEFAULT_MAX_LINES),
272            max_bytes: Some(DEFAULT_MAX_BYTES),
273        },
274    );
275
276    let (output_text, details) = if truncation.first_line_exceeds_limit {
277        let first_line_size =
278            format_size(u64::try_from(all_lines[start_line].len()).unwrap_or(u64::MAX));
279        let output = format!(
280            "[Line {start_line_display} is {first_line_size}, exceeds {} limit. Use bash: sed -n '{start_line_display}p' {} | head -c {DEFAULT_MAX_BYTES}]",
281            format_size(u64::try_from(DEFAULT_MAX_BYTES).unwrap_or(u64::MAX)),
282            input.path
283        );
284        (
285            output,
286            Some(ReadToolDetails {
287                truncation: Some(truncation),
288            }),
289        )
290    } else if truncation.truncated {
291        let end_line_display = start_line_display + truncation.output_lines - 1;
292        let next_offset = end_line_display + 1;
293        let mut output = truncation.content.clone();
294        match truncation.truncated_by {
295            Some(super::TruncatedBy::Lines) => {
296                write!(
297                    output,
298                    "\n\n[Showing lines {start_line_display}-{end_line_display} of {total_file_lines}. Use offset={next_offset} to continue.]"
299                )
300                .map_err(|error| ToolError::new(format!("Could not format read output: {error}")))?;
301            }
302            _ => {
303                write!(
304                    output,
305                    "\n\n[Showing lines {start_line_display}-{end_line_display} of {total_file_lines} ({} limit). Use offset={next_offset} to continue.]",
306                    format_size(u64::try_from(DEFAULT_MAX_BYTES).unwrap_or(u64::MAX))
307                )
308                .map_err(|error| ToolError::new(format!("Could not format read output: {error}")))?;
309            }
310        }
311        (
312            output,
313            Some(ReadToolDetails {
314                truncation: Some(truncation),
315            }),
316        )
317    } else if let Some(user_limited) = user_limited_lines {
318        if start_line + user_limited < all_lines.len() {
319            let remaining = all_lines.len() - (start_line + user_limited);
320            let next_offset = start_line + user_limited + 1;
321            (
322                format!(
323                    "{}\n\n[{remaining} more lines in file. Use offset={next_offset} to continue.]",
324                    truncation.content
325                ),
326                None,
327            )
328        } else {
329            (truncation.content, None)
330        }
331    } else {
332        (truncation.content, None)
333    };
334
335    Ok(AgentToolResult {
336        content: vec![ToolResultContent::Text(TextContent::new(output_text))],
337        details: details_value(details),
338        added_tool_names: None,
339        terminate: None,
340    })
341}
342
343fn details_value(details: Option<ReadToolDetails>) -> Value {
344    match details {
345        Some(details) => serde_json::to_value(details).unwrap_or_else(|_| json!({})),
346        None => Value::Null,
347    }
348}
349
350fn image_tool_result(processed: ProcessImageResult, non_vision: Option<&str>) -> AgentToolResult {
351    match processed {
352        ProcessImageResult::Failed(error) => {
353            let mut text_note = format!("Read image file [{}]\n{}", error.mime_type, error.message);
354            if let Some(note) = non_vision {
355                text_note.push('\n');
356                text_note.push_str(note);
357            }
358            AgentToolResult {
359                content: vec![ToolResultContent::Text(TextContent::new(text_note))],
360                details: Value::Null,
361                added_tool_names: None,
362                terminate: None,
363            }
364        }
365        ProcessImageResult::Ok(processed) => {
366            let ProcessedImage {
367                data,
368                mime_type,
369                hints,
370                ..
371            } = processed;
372            let mut text_note = format!("Read image file [{mime_type}]");
373            if !hints.is_empty() {
374                text_note.push('\n');
375                text_note.push_str(&hints.join("\n"));
376            }
377            if let Some(note) = non_vision {
378                text_note.push('\n');
379                text_note.push_str(note);
380            }
381            let mut content = vec![ToolResultContent::Text(TextContent::new(text_note))];
382            if non_vision.is_none() {
383                content.push(ToolResultContent::Image(ImageContent::new(data, mime_type)));
384            }
385            AgentToolResult {
386                content,
387                details: Value::Null,
388                added_tool_names: None,
389                terminate: None,
390            }
391        }
392    }
393}
394
395fn non_vision_image_note(model: Option<&Model>) -> Option<String> {
396    let model = model?;
397    if model.input.contains(&ModelInput::Image) {
398        None
399    } else {
400        Some(
401            "[Current model does not support images. The image will be omitted from this request.]"
402                .to_owned(),
403        )
404    }
405}
406
407async fn ensure_readable(path: &str) -> Result<(), ToolError> {
408    match tokio::fs::metadata(path).await {
409        Ok(meta) if meta.is_file() || meta.is_dir() => {
410            // Open for read to mirror access(R_OK).
411            tokio::fs::File::open(path)
412                .await
413                .map_err(|error| ToolError::new(format!("Could not read file {path}: {error}")))?;
414            Ok(())
415        }
416        Ok(_) => Err(ToolError::new(format!(
417            "Could not read file {path}: not a regular file"
418        ))),
419        Err(error) => Err(ToolError::new(format!(
420            "Could not read file {path}: {error}"
421        ))),
422    }
423}
424
425// ---------------------------------------------------------------------------
426// MIME sniffing (mime.ts)
427// ---------------------------------------------------------------------------
428
429/// Detects a supported inline image MIME type from magic bytes.
430#[must_use]
431pub fn detect_supported_image_mime_type(buffer: &[u8]) -> Option<String> {
432    if starts_with(buffer, &[0xff, 0xd8, 0xff]) {
433        return if buffer.get(3) == Some(&0xf7) {
434            None
435        } else {
436            Some("image/jpeg".to_owned())
437        };
438    }
439    if starts_with(buffer, &PNG_SIGNATURE) {
440        return if is_png(buffer) && !is_animated_png(buffer) {
441            Some("image/png".to_owned())
442        } else {
443            None
444        };
445    }
446    if starts_with_ascii(buffer, 0, b"GIF") {
447        return Some("image/gif".to_owned());
448    }
449    if starts_with_ascii(buffer, 0, b"RIFF") && starts_with_ascii(buffer, 8, b"WEBP") {
450        return Some("image/webp".to_owned());
451    }
452    if starts_with_ascii(buffer, 0, b"BM") && is_bmp(buffer) {
453        return Some("image/bmp".to_owned());
454    }
455    None
456}
457
458fn is_png(buffer: &[u8]) -> bool {
459    buffer.len() >= 16
460        && read_u32_be(buffer, PNG_SIGNATURE.len()) == Some(13)
461        && starts_with_ascii(buffer, 12, b"IHDR")
462}
463
464fn is_animated_png(buffer: &[u8]) -> bool {
465    let mut offset = PNG_SIGNATURE.len();
466    while offset + 8 <= buffer.len() {
467        let Some(chunk_length) = read_u32_be(buffer, offset) else {
468            return false;
469        };
470        let chunk_type_offset = offset + 4;
471        if starts_with_ascii(buffer, chunk_type_offset, b"acTL") {
472            return true;
473        }
474        if starts_with_ascii(buffer, chunk_type_offset, b"IDAT") {
475            return false;
476        }
477        let next_offset = usize::try_from(chunk_length)
478            .ok()
479            .and_then(|chunk_length| offset.checked_add(8 + chunk_length))
480            .and_then(|v| v.checked_add(4));
481        let Some(next_offset) = next_offset else {
482            return false;
483        };
484        if next_offset <= offset || next_offset > buffer.len() {
485            return false;
486        }
487        offset = next_offset;
488    }
489    false
490}
491
492fn is_bmp(buffer: &[u8]) -> bool {
493    if buffer.len() < 26 {
494        return false;
495    }
496    let Some(declared_file_size) = read_u32_le(buffer, 2) else {
497        return false;
498    };
499    let Some(pixel_data_offset) = read_u32_le(buffer, 10) else {
500        return false;
501    };
502    let Some(dib_header_size) = read_u32_le(buffer, 14) else {
503        return false;
504    };
505    if declared_file_size != 0 && declared_file_size < 26 {
506        return false;
507    }
508    if pixel_data_offset < 14 + dib_header_size {
509        return false;
510    }
511    if declared_file_size != 0 && pixel_data_offset >= declared_file_size {
512        return false;
513    }
514
515    let (color_planes, bits_per_pixel) = if dib_header_size == 12 {
516        (
517            read_u16_le(buffer, 22).unwrap_or(0),
518            read_u16_le(buffer, 24).unwrap_or(0),
519        )
520    } else if (40..=124).contains(&dib_header_size) {
521        if buffer.len() < 30 {
522            return false;
523        }
524        (
525            read_u16_le(buffer, 26).unwrap_or(0),
526            read_u16_le(buffer, 28).unwrap_or(0),
527        )
528    } else {
529        return false;
530    };
531
532    color_planes == 1 && matches!(bits_per_pixel, 1 | 4 | 8 | 16 | 24 | 32)
533}
534
535fn read_u16_le(buffer: &[u8], offset: usize) -> Option<u16> {
536    let bytes = buffer.get(offset..)?.get(..2)?.try_into().ok()?;
537    Some(u16::from_le_bytes(bytes))
538}
539
540fn read_u32_be(buffer: &[u8], offset: usize) -> Option<u32> {
541    let bytes = buffer.get(offset..)?.get(..4)?.try_into().ok()?;
542    Some(u32::from_be_bytes(bytes))
543}
544
545fn read_u32_le(buffer: &[u8], offset: usize) -> Option<u32> {
546    let bytes = buffer.get(offset..)?.get(..4)?.try_into().ok()?;
547    Some(u32::from_le_bytes(bytes))
548}
549
550fn starts_with(buffer: &[u8], bytes: &[u8]) -> bool {
551    buffer.len() >= bytes.len() && buffer[..bytes.len()] == *bytes
552}
553
554fn starts_with_ascii(buffer: &[u8], offset: usize, text: &[u8]) -> bool {
555    buffer
556        .get(offset..offset + text.len())
557        .is_some_and(|slice| slice == text)
558}
559
560// ---------------------------------------------------------------------------
561// Image processing (image-process.ts + image-resize-core.ts)
562// ---------------------------------------------------------------------------
563
564/// Successfully processed image data ready for an inline attachment.
565#[derive(Clone, Debug, Eq, PartialEq)]
566pub struct ProcessedImage {
567    /// Base64-encoded attachment bytes.
568    pub data: String,
569    /// MIME type of the encoded attachment.
570    pub mime_type: String,
571    /// Human-readable conversion and resize hints.
572    pub hints: Vec<String>,
573    /// Original and delivered dimensions when the resize pipeline decoded them.
574    pub dimensions: Option<ProcessedImageDimensions>,
575}
576
577/// Original and delivered image dimensions.
578#[derive(Clone, Copy, Debug, Eq, PartialEq)]
579pub struct ProcessedImageDimensions {
580    /// Width after applying EXIF orientation, before resizing.
581    pub original_width: u32,
582    /// Height after applying EXIF orientation, before resizing.
583    pub original_height: u32,
584    /// Delivered attachment width.
585    pub width: u32,
586    /// Delivered attachment height.
587    pub height: u32,
588    /// Whether the delivered bytes were re-encoded by the resize pipeline.
589    pub was_resized: bool,
590}
591
592/// Failure produced when image bytes cannot become a supported attachment.
593#[derive(Clone, Debug, Eq, PartialEq)]
594pub struct ProcessImageError {
595    /// MIME type detected for the source bytes.
596    pub mime_type: String,
597    /// Exact user-facing omission notice.
598    pub message: String,
599}
600
601/// Result of [`process_image_bytes`].
602#[derive(Clone, Debug, Eq, PartialEq)]
603pub enum ProcessImageResult {
604    /// Supported attachment data.
605    Ok(ProcessedImage),
606    /// Conversion or resizing failure.
607    Failed(ProcessImageError),
608}
609
610struct NormalizedImage {
611    bytes: Vec<u8>,
612    mime_type: String,
613    converted_from: Option<String>,
614}
615
616struct ResizedImage {
617    data: String,
618    mime_type: String,
619    original_width: u32,
620    original_height: u32,
621    width: u32,
622    height: u32,
623    was_resized: bool,
624}
625
626/// Processes detected image bytes using the same pipeline as [`ReadTool`].
627///
628/// Supported JPEG/PNG/GIF/WebP bytes are preserved when already within
629/// provider limits. BMP and other decodable inputs are normalized to PNG.
630/// With `auto_resize_images`, EXIF orientation is applied and output is kept
631/// below the 2000×2000 and 4.5 MiB base64 limits.
632#[must_use]
633pub fn process_image_bytes(
634    bytes: &[u8],
635    mime_type: &str,
636    auto_resize_images: bool,
637) -> ProcessImageResult {
638    let Some(normalized) = normalize_image(bytes, mime_type) else {
639        return ProcessImageResult::Failed(ProcessImageError {
640            mime_type: mime_type.to_owned(),
641            message: "[Image omitted: could not be converted to a supported inline image format.]"
642                .to_owned(),
643        });
644    };
645
646    if auto_resize_images {
647        let Some(resized) = resize_image(&normalized.bytes, &normalized.mime_type) else {
648            return ProcessImageResult::Failed(ProcessImageError {
649                mime_type: normalized.mime_type,
650                message: "[Image omitted: could not be resized below the inline image size limit.]"
651                    .to_owned(),
652            });
653        };
654        let mut hints = Vec::new();
655        if let Some(hint) =
656            conversion_hint(normalized.converted_from.as_deref(), &resized.mime_type)
657        {
658            hints.push(hint);
659        }
660        if let Some(note) = format_dimension_note(&resized) {
661            hints.push(note);
662        }
663        let dimensions = ProcessedImageDimensions {
664            original_width: resized.original_width,
665            original_height: resized.original_height,
666            width: resized.width,
667            height: resized.height,
668            was_resized: resized.was_resized,
669        };
670        return ProcessImageResult::Ok(ProcessedImage {
671            data: resized.data,
672            mime_type: resized.mime_type,
673            hints,
674            dimensions: Some(dimensions),
675        });
676    }
677
678    let mut hints = Vec::new();
679    if let Some(hint) = conversion_hint(normalized.converted_from.as_deref(), &normalized.mime_type)
680    {
681        hints.push(hint);
682    }
683    ProcessImageResult::Ok(ProcessedImage {
684        data: BASE64.encode(&normalized.bytes),
685        mime_type: normalized.mime_type,
686        hints,
687        dimensions: None,
688    })
689}
690
691fn base_mime_type(mime_type: &str) -> String {
692    mime_type
693        .split(';')
694        .next()
695        .unwrap_or(mime_type)
696        .trim()
697        .to_ascii_lowercase()
698}
699
700fn normalize_supported_image_mime_type(mime_type: &str) -> Option<&'static str> {
701    match base_mime_type(mime_type).as_str() {
702        "image/png" => Some("image/png"),
703        "image/jpeg" | "image/jpg" => Some("image/jpeg"),
704        "image/gif" => Some("image/gif"),
705        "image/webp" => Some("image/webp"),
706        _ => None,
707    }
708}
709
710fn normalize_image(bytes: &[u8], mime_type: &str) -> Option<NormalizedImage> {
711    if let Some(normalized) = normalize_supported_image_mime_type(mime_type) {
712        return Some(NormalizedImage {
713            bytes: bytes.to_vec(),
714            mime_type: normalized.to_owned(),
715            converted_from: None,
716        });
717    }
718
719    let png_bytes = convert_image_bytes_to_png(bytes)?;
720    Some(NormalizedImage {
721        bytes: png_bytes,
722        mime_type: "image/png".to_owned(),
723        converted_from: Some(base_mime_type(mime_type)),
724    })
725}
726
727fn conversion_hint(from: Option<&str>, to: &str) -> Option<String> {
728    let from = from?;
729    if from == to {
730        None
731    } else {
732        Some(format!("[Image converted from {from} to {to}.]"))
733    }
734}
735
736/// Decode bytes into PNG, applying the decoder's reported orientation.
737///
738/// Shared with the clipboard/Kitty display paths so the image decoder is not
739/// duplicated. Returns `None` when the bytes cannot be decoded.
740#[must_use]
741pub fn convert_image_bytes_to_png(bytes: &[u8]) -> Option<Vec<u8>> {
742    let mut reader = ImageReader::new(Cursor::new(bytes))
743        .with_guessed_format()
744        .ok()?;
745    reader.no_limits();
746    let mut decoder = reader.into_decoder().ok()?;
747    let orientation = decoder
748        .orientation()
749        .unwrap_or(image::metadata::Orientation::NoTransforms);
750    let mut image = DynamicImage::from_decoder(decoder).ok()?;
751    image.apply_orientation(orientation);
752    let mut out = Vec::new();
753    image
754        .write_to(&mut Cursor::new(&mut out), ImageFormat::Png)
755        .ok()?;
756    Some(out)
757}
758
759fn resize_image(input_bytes: &[u8], mime_type: &str) -> Option<ResizedImage> {
760    let input_base64_size = input_bytes.len().div_ceil(3) * 4;
761
762    let mut reader = ImageReader::new(Cursor::new(input_bytes))
763        .with_guessed_format()
764        .ok()?;
765    reader.no_limits();
766    let mut decoder = reader.into_decoder().ok()?;
767    let orientation = decoder
768        .orientation()
769        .unwrap_or(image::metadata::Orientation::NoTransforms);
770    let mut image = DynamicImage::from_decoder(decoder).ok()?;
771    image.apply_orientation(orientation);
772
773    let original_width = image.width();
774    let original_height = image.height();
775    let format = mime_type.split('/').nth(1).unwrap_or("png");
776
777    if original_width <= IMAGE_MAX_DIMENSION
778        && original_height <= IMAGE_MAX_DIMENSION
779        && input_base64_size < IMAGE_MAX_BASE64_BYTES
780    {
781        return Some(ResizedImage {
782            data: BASE64.encode(input_bytes),
783            mime_type: if mime_type.is_empty() {
784                format!("image/{format}")
785            } else {
786                mime_type.to_owned()
787            },
788            original_width,
789            original_height,
790            width: original_width,
791            height: original_height,
792            was_resized: false,
793        });
794    }
795
796    let mut target_width = original_width;
797    let mut target_height = original_height;
798    if target_width > IMAGE_MAX_DIMENSION {
799        target_height = rounded_scaled_dimension(target_height, IMAGE_MAX_DIMENSION, target_width);
800        target_width = IMAGE_MAX_DIMENSION;
801    }
802    if target_height > IMAGE_MAX_DIMENSION {
803        target_width = rounded_scaled_dimension(target_width, IMAGE_MAX_DIMENSION, target_height);
804        target_height = IMAGE_MAX_DIMENSION;
805    }
806    target_width = target_width.max(1);
807    target_height = target_height.max(1);
808
809    let mut quality_steps = vec![IMAGE_JPEG_QUALITY, 85, 70, 55, 40];
810    quality_steps.sort_unstable();
811    quality_steps.dedup();
812    // Preserve first-try order: preferred quality first, then the rest descending-ish.
813    let mut ordered = vec![IMAGE_JPEG_QUALITY];
814    for q in [85_u8, 70, 55, 40] {
815        if q != IMAGE_JPEG_QUALITY {
816            ordered.push(q);
817        }
818    }
819
820    let mut current_width = target_width;
821    let mut current_height = target_height;
822
823    loop {
824        let candidates = try_encodings(&image, current_width, current_height, &ordered);
825        for candidate in candidates {
826            if candidate.encoded_size < IMAGE_MAX_BASE64_BYTES {
827                return Some(ResizedImage {
828                    data: candidate.data,
829                    mime_type: candidate.mime_type,
830                    original_width,
831                    original_height,
832                    width: current_width,
833                    height: current_height,
834                    was_resized: true,
835                });
836            }
837        }
838
839        if current_width == 1 && current_height == 1 {
840            break;
841        }
842        let next_width = if current_width == 1 {
843            1
844        } else {
845            current_width.saturating_mul(3) / 4
846        };
847        let next_height = if current_height == 1 {
848            1
849        } else {
850            current_height.saturating_mul(3) / 4
851        };
852        if next_width == current_width && next_height == current_height {
853            break;
854        }
855        current_width = next_width;
856        current_height = next_height;
857    }
858
859    None
860}
861
862struct EncodedCandidate {
863    data: String,
864    encoded_size: usize,
865    mime_type: String,
866}
867
868fn try_encodings(
869    image: &DynamicImage,
870    width: u32,
871    height: u32,
872    jpeg_qualities: &[u8],
873) -> Vec<EncodedCandidate> {
874    let resized = if image.width() == width && image.height() == height {
875        image.clone()
876    } else {
877        image.resize_exact(width, height, FilterType::Lanczos3)
878    };
879
880    let mut candidates = Vec::new();
881    if let Some(png) = encode_png(&resized) {
882        candidates.push(encode_candidate(&png, "image/png"));
883    }
884    for &quality in jpeg_qualities {
885        if let Some(jpeg) = encode_jpeg(&resized, quality) {
886            candidates.push(encode_candidate(&jpeg, "image/jpeg"));
887        }
888    }
889    candidates
890}
891
892fn encode_candidate(bytes: &[u8], mime_type: &str) -> EncodedCandidate {
893    let data = BASE64.encode(bytes);
894    let encoded_size = data.len();
895    EncodedCandidate {
896        data,
897        encoded_size,
898        mime_type: mime_type.to_owned(),
899    }
900}
901
902fn encode_png(image: &DynamicImage) -> Option<Vec<u8>> {
903    let mut out = Vec::new();
904    image
905        .write_to(&mut Cursor::new(&mut out), ImageFormat::Png)
906        .ok()?;
907    Some(out)
908}
909
910fn encode_jpeg(image: &DynamicImage, quality: u8) -> Option<Vec<u8>> {
911    let rgb = image.to_rgb8();
912    let mut out = Vec::new();
913    let mut encoder = JpegEncoder::new_with_quality(&mut out, quality);
914    encoder
915        .encode(
916            rgb.as_raw(),
917            rgb.width(),
918            rgb.height(),
919            image::ExtendedColorType::Rgb8,
920        )
921        .ok()?;
922    Some(out)
923}
924
925fn format_dimension_note(result: &ResizedImage) -> Option<String> {
926    if !result.was_resized {
927        return None;
928    }
929    let scale = f64::from(result.original_width) / f64::from(result.width.max(1));
930    Some(format!(
931        "[Image: original {}x{}, displayed at {}x{}. Multiply coordinates by {:.2} to map to original image.]",
932        result.original_width, result.original_height, result.width, result.height, scale
933    ))
934}
935
936// ---------------------------------------------------------------------------
937// Schema helpers
938// ---------------------------------------------------------------------------
939
940fn read_parameters_schema() -> Value {
941    normalize_tool_schema(schemars::schema_for!(ReadToolInput))
942}
943
944fn normalize_tool_schema(schema: schemars::Schema) -> Value {
945    let mut value = serde_json::to_value(schema).unwrap_or_else(|_| Value::Object(Map::new()));
946    if let Value::Object(map) = &mut value {
947        map.remove("$schema");
948        map.remove("title");
949        map.remove("description");
950        map.remove("additionalProperties");
951        normalize_schema_node(map);
952    }
953    value
954}
955
956fn normalize_schema_node(map: &mut Map<String, Value>) {
957    map.remove("format");
958    // schemars represents Option<T> as ["number","null"]; TypeBox optional
959    // numbers are just "number".
960    if let Some(Value::Array(types)) = map.get("type").cloned() {
961        let non_null: Vec<Value> = types
962            .into_iter()
963            .filter(|t| t.as_str() != Some("null"))
964            .collect();
965        if non_null.len() == 1 {
966            map.insert("type".to_owned(), non_null[0].clone());
967        } else if !non_null.is_empty() {
968            map.insert("type".to_owned(), Value::Array(non_null));
969        }
970    }
971    let keys: Vec<String> = map.keys().cloned().collect();
972    for key in keys {
973        match map.get_mut(&key) {
974            Some(Value::Object(child)) => normalize_schema_node(child),
975            Some(Value::Array(items)) => {
976                for item in items {
977                    if let Value::Object(child) = item {
978                        normalize_schema_node(child);
979                    }
980                }
981            }
982            _ => {}
983        }
984    }
985}
986
987fn throw_if_cancelled(cancel: &CancellationToken) -> Result<(), ToolError> {
988    if cancel.is_cancelled() {
989        Err(ToolError::new("Operation aborted"))
990    } else {
991        Ok(())
992    }
993}
994
995fn path_error(error: &PathResolveError) -> ToolError {
996    ToolError::new(error.to_string())
997}
998
999/// Builds an [`Arc<dyn AgentTool>`] read tool for `cwd`.
1000#[must_use]
1001pub fn create_read_tool(cwd: impl Into<PathBuf>) -> Arc<dyn AgentTool> {
1002    Arc::new(ReadTool::new(cwd))
1003}
1004
1005fn floored_nonnegative_usize(value: f64) -> usize {
1006    if !value.is_finite() || value <= 0.0 {
1007        return 0;
1008    }
1009    value.floor().to_string().parse().unwrap_or(usize::MAX)
1010}
1011
1012fn rounded_scaled_dimension(value: u32, numerator: u32, denominator: u32) -> u32 {
1013    let denominator = u64::from(denominator);
1014    let scaled = u64::from(value) * u64::from(numerator);
1015    u32::try_from((scaled + denominator / 2) / denominator).unwrap_or(u32::MAX)
1016}
1017
1018#[cfg(test)]
1019mod tests {
1020    use super::*;
1021    use pi_ai::types::ModelCost;
1022    use serde_json::json;
1023    use tempfile::tempdir;
1024
1025    fn fixture_schema() -> Result<Value, serde_json::Error> {
1026        let text = include_str!("../../../tests/fixtures/tool-schemas/read.json");
1027        serde_json::from_str(text)
1028    }
1029
1030    fn text_model() -> Model {
1031        Model {
1032            id: "text-only".to_owned(),
1033            name: "text-only".to_owned(),
1034            api: "test".to_owned(),
1035            provider: "test".to_owned(),
1036            base_url: String::new(),
1037            reasoning: false,
1038            input: vec![ModelInput::Text],
1039            cost: ModelCost {
1040                input: 0.0,
1041                output: 0.0,
1042                cache_read: 0.0,
1043                cache_write: 0.0,
1044                tiers: None,
1045            },
1046            context_window: 8_000,
1047            max_tokens: 1_024,
1048            headers: None,
1049            compat: None,
1050            thinking_level_map: None,
1051            extra: std::collections::BTreeMap::default(),
1052        }
1053    }
1054
1055    fn vision_model() -> Model {
1056        let mut model = text_model();
1057        model.id = "vision".to_owned();
1058        model.name = "vision".to_owned();
1059        model.input = vec![ModelInput::Text, ModelInput::Image];
1060        model
1061    }
1062
1063    fn json_map(value: Value) -> Result<Map<String, Value>, &'static str> {
1064        if let Value::Object(map) = value {
1065            Ok(map)
1066        } else {
1067            Err("expected JSON object")
1068        }
1069    }
1070
1071    fn text_of(result: &AgentToolResult) -> String {
1072        match result.content.first() {
1073            Some(ToolResultContent::Text(text)) => text.text.to_string(),
1074            _ => String::new(),
1075        }
1076    }
1077
1078    fn tiny_bmp_1x1_red() -> Vec<u8> {
1079        let mut buffer = vec![0_u8; 58];
1080        buffer[0] = b'B';
1081        buffer[1] = b'M';
1082        buffer[2..6].copy_from_slice(&58_u32.to_le_bytes());
1083        buffer[10..14].copy_from_slice(&54_u32.to_le_bytes());
1084        buffer[14..18].copy_from_slice(&40_u32.to_le_bytes());
1085        buffer[18..22].copy_from_slice(&1_i32.to_le_bytes());
1086        buffer[22..26].copy_from_slice(&1_i32.to_le_bytes());
1087        buffer[26..28].copy_from_slice(&1_u16.to_le_bytes());
1088        buffer[28..30].copy_from_slice(&24_u16.to_le_bytes());
1089        buffer[30..34].copy_from_slice(&0_u32.to_le_bytes());
1090        buffer[34..38].copy_from_slice(&4_u32.to_le_bytes());
1091        buffer[56] = 0xff;
1092        buffer
1093    }
1094
1095    fn solid_png(width: u32, height: u32) -> Result<Vec<u8>, image::ImageError> {
1096        let img = DynamicImage::ImageRgb8(image::RgbImage::from_pixel(
1097            width,
1098            height,
1099            image::Rgb([10, 20, 30]),
1100        ));
1101        let mut out = Vec::new();
1102        img.write_to(&mut Cursor::new(&mut out), ImageFormat::Png)?;
1103        Ok(out)
1104    }
1105
1106    #[test]
1107    fn schema_matches_typebox_fixture() -> Result<(), Box<dyn std::error::Error>> {
1108        let schema = ReadTool::parameters_schema();
1109        assert_eq!(schema, fixture_schema()?);
1110        Ok(())
1111    }
1112
1113    #[test]
1114    fn detects_bmp_and_png_magic() -> Result<(), Box<dyn std::error::Error>> {
1115        assert_eq!(
1116            detect_supported_image_mime_type(&tiny_bmp_1x1_red()).as_deref(),
1117            Some("image/bmp")
1118        );
1119        let png = solid_png(2, 2)?;
1120        assert_eq!(
1121            detect_supported_image_mime_type(&png).as_deref(),
1122            Some("image/png")
1123        );
1124        Ok(())
1125    }
1126
1127    #[test]
1128    fn public_image_helper_preserves_small_supported_bytes()
1129    -> Result<(), Box<dyn std::error::Error>> {
1130        let png = solid_png(3, 2)?;
1131        let result = process_image_bytes(&png, "image/png", true);
1132        let ProcessImageResult::Ok(processed) = result else {
1133            return Err("expected processed image".into());
1134        };
1135        assert_eq!(processed.mime_type, "image/png");
1136        assert_eq!(
1137            BASE64.decode(&processed.data).ok().as_deref(),
1138            Some(png.as_slice())
1139        );
1140        assert!(processed.hints.is_empty());
1141        assert_eq!(
1142            processed.dimensions,
1143            Some(ProcessedImageDimensions {
1144                original_width: 3,
1145                original_height: 2,
1146                width: 3,
1147                height: 2,
1148                was_resized: false,
1149            })
1150        );
1151        Ok(())
1152    }
1153
1154    #[tokio::test]
1155    async fn offset_beyond_end_errors() -> Result<(), Box<dyn std::error::Error>> {
1156        let dir = tempdir()?;
1157        let path = dir.path().join("lines.txt");
1158        tokio::fs::write(&path, "a\nb\nc").await?;
1159        let tool = ReadTool::new(dir.path());
1160        let result = tool
1161            .execute(
1162                "1",
1163                json_map(json!({"path": "lines.txt", "offset": 10}))?,
1164                CancellationToken::new(),
1165                ToolUpdates::noop(),
1166            )
1167            .await;
1168        let Err(err) = result else {
1169            return Err("expected offset error".into());
1170        };
1171        assert_eq!(
1172            err.message(),
1173            "Offset 10 is beyond end of file (3 lines total)"
1174        );
1175        Ok(())
1176    }
1177
1178    #[tokio::test]
1179    async fn first_huge_line_notice() -> Result<(), Box<dyn std::error::Error>> {
1180        let dir = tempdir()?;
1181        let path = dir.path().join("huge.txt");
1182        let huge = "x".repeat(DEFAULT_MAX_BYTES + 10);
1183        tokio::fs::write(&path, &huge).await?;
1184        let tool = ReadTool::new(dir.path());
1185        let result = tool
1186            .execute(
1187                "1",
1188                json_map(json!({"path": "huge.txt"}))?,
1189                CancellationToken::new(),
1190                ToolUpdates::noop(),
1191            )
1192            .await?;
1193        let text = text_of(&result);
1194        assert!(
1195            text.contains("exceeds") && text.contains("sed -n '1p'"),
1196            "unexpected: {text}"
1197        );
1198        assert!(result.details.get("truncation").is_some());
1199        Ok(())
1200    }
1201
1202    #[tokio::test]
1203    async fn continuation_notices_for_limit_and_truncation()
1204    -> Result<(), Box<dyn std::error::Error>> {
1205        let dir = tempdir()?;
1206        let path = dir.path().join("many.txt");
1207        let mut content = String::new();
1208        for i in 1..=10 {
1209            writeln!(content, "line{i}")?;
1210        }
1211        tokio::fs::write(&path, &content).await?;
1212        let tool = ReadTool::new(dir.path());
1213        let result = tool
1214            .execute(
1215                "1",
1216                json_map(json!({"path": "many.txt", "offset": 1, "limit": 3}))?,
1217                CancellationToken::new(),
1218                ToolUpdates::noop(),
1219            )
1220            .await?;
1221        let text = text_of(&result);
1222        assert!(
1223            text.contains("more lines in file. Use offset=4 to continue."),
1224            "{text}"
1225        );
1226
1227        // Force line truncation via many short lines.
1228        let path2 = dir.path().join("lots.txt");
1229        let mut lots = String::new();
1230        for i in 0..(DEFAULT_MAX_LINES + 50) {
1231            writeln!(lots, "L{i}")?;
1232        }
1233        tokio::fs::write(&path2, &lots).await?;
1234        let result = tool
1235            .execute(
1236                "2",
1237                json_map(json!({"path": "lots.txt"}))?,
1238                CancellationToken::new(),
1239                ToolUpdates::noop(),
1240            )
1241            .await?;
1242        let text = text_of(&result);
1243        assert!(
1244            text.contains("Showing lines 1-") && text.contains("Use offset="),
1245            "{text}"
1246        );
1247        Ok(())
1248    }
1249
1250    #[tokio::test]
1251    async fn missing_file_errors() -> Result<(), Box<dyn std::error::Error>> {
1252        let dir = tempdir()?;
1253        let tool = ReadTool::new(dir.path());
1254        let result = tool
1255            .execute(
1256                "1",
1257                json_map(json!({"path": "nope.txt"}))?,
1258                CancellationToken::new(),
1259                ToolUpdates::noop(),
1260            )
1261            .await;
1262        let Err(err) = result else {
1263            return Err("expected missing-file error".into());
1264        };
1265        assert!(
1266            err.message().contains("Could not read file")
1267                || err.message().contains("No such file")
1268                || err.message().contains("not found")
1269                || err.message().contains("os error"),
1270            "{}",
1271            err.message()
1272        );
1273        Ok(())
1274    }
1275
1276    #[tokio::test]
1277    async fn cancellation_wins() -> Result<(), Box<dyn std::error::Error>> {
1278        let dir = tempdir()?;
1279        let path = dir.path().join("a.txt");
1280        tokio::fs::write(&path, "hi").await?;
1281        let tool = ReadTool::new(dir.path());
1282        let cancel = CancellationToken::new();
1283        cancel.cancel();
1284        let result = tool
1285            .execute(
1286                "1",
1287                json_map(json!({"path": "a.txt"}))?,
1288                cancel,
1289                ToolUpdates::noop(),
1290            )
1291            .await;
1292        let Err(err) = result else {
1293            return Err("expected cancellation error".into());
1294        };
1295        assert_eq!(err.message(), "Operation aborted");
1296        Ok(())
1297    }
1298
1299    #[tokio::test]
1300    async fn supported_image_passthrough_and_bmp_conversion()
1301    -> Result<(), Box<dyn std::error::Error>> {
1302        let dir = tempdir()?;
1303        let png_path = dir.path().join("tiny.png");
1304        let png = solid_png(4, 4)?;
1305        tokio::fs::write(&png_path, &png).await?;
1306        let tool = ReadTool::with_options(
1307            ReadToolOptions::new(dir.path()).with_model(Some(vision_model())),
1308        );
1309        let result = tool
1310            .execute(
1311                "1",
1312                json_map(json!({"path": "tiny.png"}))?,
1313                CancellationToken::new(),
1314                ToolUpdates::noop(),
1315            )
1316            .await?;
1317        assert!(matches!(
1318            result.content.get(1),
1319            Some(ToolResultContent::Image(_))
1320        ));
1321        let text = text_of(&result);
1322        assert!(text.starts_with("Read image file [image/png]"), "{text}");
1323
1324        let bmp_path = dir.path().join("tiny.bmp");
1325        tokio::fs::write(&bmp_path, tiny_bmp_1x1_red()).await?;
1326        let result = tool
1327            .execute(
1328                "2",
1329                json_map(json!({"path": "tiny.bmp"}))?,
1330                CancellationToken::new(),
1331                ToolUpdates::noop(),
1332            )
1333            .await?;
1334        let text = text_of(&result);
1335        assert!(
1336            text.contains("[Image converted from image/bmp to image/png.]"),
1337            "{text}"
1338        );
1339        match result.content.get(1) {
1340            Some(ToolResultContent::Image(image)) => {
1341                assert_eq!(image.mime_type, "image/png");
1342                let decoded = BASE64.decode(&image.data)?;
1343                assert_eq!(&decoded[..4], &[0x89, b'P', b'N', b'G']);
1344            }
1345            other => return Err(format!("expected image content, got {other:?}").into()),
1346        }
1347        Ok(())
1348    }
1349
1350    #[tokio::test]
1351    async fn resize_dimensions_and_size() -> Result<(), Box<dyn std::error::Error>> {
1352        let dir = tempdir()?;
1353        let path = dir.path().join("big.png");
1354        // Large solid image forces dimension reduction.
1355        let png = solid_png(2500, 100)?;
1356        tokio::fs::write(&path, &png).await?;
1357        let tool = ReadTool::with_options(
1358            ReadToolOptions::new(dir.path()).with_model(Some(vision_model())),
1359        );
1360        let result = tool
1361            .execute(
1362                "1",
1363                json_map(json!({"path": "big.png"}))?,
1364                CancellationToken::new(),
1365                ToolUpdates::noop(),
1366            )
1367            .await?;
1368        let text = text_of(&result);
1369        assert!(
1370            text.contains("original 2500x100") && text.contains("displayed at"),
1371            "{text}"
1372        );
1373        match result.content.get(1) {
1374            Some(ToolResultContent::Image(image)) => {
1375                assert!(image.data.len() < IMAGE_MAX_BASE64_BYTES);
1376            }
1377            other => return Err(format!("expected image, got {other:?}").into()),
1378        }
1379        Ok(())
1380    }
1381
1382    #[tokio::test]
1383    async fn non_vision_note_omits_image() -> Result<(), Box<dyn std::error::Error>> {
1384        let dir = tempdir()?;
1385        let path = dir.path().join("pic.png");
1386        tokio::fs::write(&path, solid_png(8, 8)?).await?;
1387        let tool =
1388            ReadTool::with_options(ReadToolOptions::new(dir.path()).with_model(Some(text_model())));
1389        let result = tool
1390            .execute(
1391                "1",
1392                json_map(json!({"path": "pic.png"}))?,
1393                CancellationToken::new(),
1394                ToolUpdates::noop(),
1395            )
1396            .await?;
1397        assert_eq!(result.content.len(), 1);
1398        let text = text_of(&result);
1399        assert!(
1400            text.contains(
1401                "[Current model does not support images. The image will be omitted from this request.]"
1402            ),
1403            "{text}"
1404        );
1405        Ok(())
1406    }
1407
1408    #[test]
1409    fn sniffs_only_first_chunk_size_constant() {
1410        // Guard the constant used by file sniffing parity.
1411        assert_eq!(IMAGE_TYPE_SNIFF_BYTES, 4100);
1412    }
1413}