Skip to main content

lash_tool_files/
read_file.rs

1use serde_json::json;
2use std::io::{BufRead, BufReader};
3use std::path::Path;
4
5use lash_core::{ToolCall, ToolDefinition, ToolResult, ToolRetryPolicy, ToolScheduling};
6
7use lash_tool_support::{
8    StaticToolExecute, StaticToolProvider, object_schema, parse_optional_usize_arg, require_str,
9    run_blocking_value,
10};
11
12/// Read files with line-number-prefixed output. Supports images natively.
13#[derive(Default)]
14pub struct ReadFile;
15
16/// Build the cached `read_file` tool provider.
17pub fn read_file_provider() -> StaticToolProvider<ReadFile> {
18    StaticToolProvider::new(vec![read_file_tool_definition()], ReadFile)
19}
20
21const DEFAULT_LIMIT: usize = 2000;
22const MAX_LINE_LEN: usize = 2000;
23const MAX_OUTPUT_BYTES: usize = 50 * 1024;
24const MAX_OUTPUT_BYTES_LABEL: &str = "50 KB";
25
26struct ImageAttachmentData {
27    data: Vec<u8>,
28    media_type: lash_core::MediaType,
29    width: Option<u32>,
30    height: Option<u32>,
31    label: String,
32}
33
34enum ReadFileBlockingResult {
35    Tool(ToolResult),
36    Image(ImageAttachmentData),
37}
38
39impl ReadFileBlockingResult {
40    fn tool(result: ToolResult) -> Self {
41        Self::Tool(result)
42    }
43
44    async fn into_tool_result(self, context: &lash_core::ToolContext<'_>) -> ToolResult {
45        match self {
46            Self::Tool(result) => result,
47            Self::Image(image) => store_image_attachment(context, image).await,
48        }
49    }
50}
51
52#[async_trait::async_trait]
53impl StaticToolExecute for ReadFile {
54    async fn execute(&self, call: ToolCall<'_>) -> ToolResult {
55        let args = call.args;
56        let path_str = match require_str(args, "path") {
57            Ok(s) => s.to_string(),
58            Err(e) => return e,
59        };
60
61        let offset = args
62            .get("offset")
63            .and_then(|v| v.as_u64())
64            .map(|v| v as usize)
65            .unwrap_or(1)
66            .max(1);
67
68        let limit = match parse_limit(args) {
69            Ok(limit) => limit,
70            Err(e) => return e,
71        };
72
73        match run_blocking_value(move || execute_read_file_sync(&path_str, offset, limit)).await {
74            Ok(result) => result.into_tool_result(call.context).await,
75            Err(err) => ToolResult::err_fmt(format_args!("{err}")),
76        }
77    }
78}
79
80fn read_file_tool_definition() -> ToolDefinition {
81    ToolDefinition::raw(
82                "tool:read_file",
83                "read_file",
84                "Read a file. Text returns lines prefixed as `LINE: text`, PDFs return extracted text, and images return visual content. Default: 2000 lines. Use `ls` for directories.",
85                object_schema(
86                    serde_json::json!({
87                        "path": { "type": "string" },
88                        "offset": {
89                            "type": "integer",
90                            "minimum": 1,
91                            "description": "Line offset to start reading from (1-based)"
92                        },
93                        "limit": {
94                            "type": "integer",
95                            "minimum": 1,
96                            "default": DEFAULT_LIMIT,
97                            "description": "Maximum lines to read (default: 2000)."
98                        }
99                    }),
100                    &["path"],
101                ),
102                serde_json::json!({ "type": "string" }),
103            )
104            .with_examples(vec![
105                r#"await files.read({ path: "Cargo.toml" })?"#.into(),
106                r#"await files.read({ path: "src/main.rs", offset: 1, limit: 120 })?"#.into(),
107            ])
108            .with_agent_surface(lash_tool_support::agent_surface(
109                ["files"],
110                "read",
111                &["cat", "view_file"],
112            ))
113            .with_scheduling(ToolScheduling::Parallel)
114            .with_retry_policy(ToolRetryPolicy::safe(2, 25, 100))
115}
116
117fn parse_limit(args: &serde_json::Value) -> Result<usize, ToolResult> {
118    Ok(
119        parse_optional_usize_arg(args, "limit", Some(DEFAULT_LIMIT), false, 1)?
120            .unwrap_or(DEFAULT_LIMIT),
121    )
122}
123
124fn execute_read_file_sync(path_str: &str, offset: usize, limit: usize) -> ReadFileBlockingResult {
125    let path = Path::new(path_str);
126    if !path.exists() {
127        return ReadFileBlockingResult::tool(ToolResult::err_fmt(format_args!(
128            "Path does not exist: {path_str}. Use `ls` or `glob` to locate the correct path."
129        )));
130    }
131
132    // Directory — still works but nudges toward ls
133    if path.is_dir() {
134        let mut output = list_directory(path, offset, limit).into_output();
135        if output.is_success()
136            && let lash_core::ToolCallOutcome::Success(lash_core::ToolValue::String(s)) =
137                &mut output.outcome
138        {
139            s.insert_str(0, "(Hint: use `ls` for directory listings.)\n");
140        }
141        return ReadFileBlockingResult::tool(ToolResult::from_output(output));
142    }
143
144    // Image files — return as visual attachment
145    if let Some(mime) = image_mime(path) {
146        return read_image(path, path_str, mime);
147    }
148
149    // PDF files — extract text via pdf-extract (pure Rust)
150    if path
151        .extension()
152        .and_then(|e| e.to_str())
153        .map(|e| e.eq_ignore_ascii_case("pdf"))
154        .unwrap_or(false)
155    {
156        return ReadFileBlockingResult::tool(read_pdf(path, path_str, offset, limit));
157    }
158
159    // Binary detection
160    if is_likely_binary(path) {
161        return ReadFileBlockingResult::tool(ToolResult::err_fmt(format_args!(
162            "Binary file detected: {path_str}. Use image-aware reads for images, or `shell.exec` for binary inspection."
163        )));
164    }
165
166    let file = match std::fs::File::open(path) {
167        Ok(file) => file,
168        Err(e) => {
169            return ReadFileBlockingResult::tool(ToolResult::err_fmt(format_args!(
170                "Failed to open file: {e}"
171            )));
172        }
173    };
174    let reader = BufReader::new(file);
175    let slice = match collect_window(
176        reader.lines(),
177        offset,
178        limit,
179        |line_no, line| format!("{line_no}: {line}"),
180        "file",
181    ) {
182        Ok(slice) => slice,
183        Err(err) => return ReadFileBlockingResult::tool(err),
184    };
185
186    ReadFileBlockingResult::tool(ToolResult::ok(json!(render_window(
187        &slice,
188        WindowKind::Lines
189    ))))
190}
191
192fn list_directory(path: &Path, offset: usize, limit: usize) -> ToolResult {
193    match std::fs::read_dir(path) {
194        Ok(entries) => {
195            let mut items: Vec<String> = Vec::new();
196            for entry in entries.flatten() {
197                let name = entry.file_name().to_string_lossy().to_string();
198                let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false);
199                if is_dir {
200                    items.push(format!("{}/", name));
201                } else {
202                    items.push(name);
203                }
204            }
205            items.sort();
206            let slice = match collect_window(
207                items.into_iter().map(Ok::<String, std::io::Error>),
208                offset,
209                limit,
210                |_index, entry| entry.to_string(),
211                "directory",
212            ) {
213                Ok(slice) => slice,
214                Err(err) => return err,
215            };
216            ToolResult::ok(json!(render_window(&slice, WindowKind::Entries)))
217        }
218        Err(e) => ToolResult::err_fmt(format_args!("Failed to read directory: {e}")),
219    }
220}
221
222/// Simple binary detection: check first 8KB for null bytes.
223fn is_likely_binary(path: &Path) -> bool {
224    use std::io::Read;
225    let mut file = match std::fs::File::open(path) {
226        Ok(f) => f,
227        Err(_) => return false,
228    };
229    let mut buf = [0u8; 8192];
230    let n = match file.read(&mut buf) {
231        Ok(n) => n,
232        Err(_) => return false,
233    };
234    buf[..n].contains(&0)
235}
236
237/// Return the MIME type for supported image extensions.
238fn image_mime(path: &Path) -> Option<&'static str> {
239    let ext = path.extension()?.to_str()?.to_ascii_lowercase();
240    match ext.as_str() {
241        "png" => Some("image/png"),
242        "jpg" | "jpeg" => Some("image/jpeg"),
243        "gif" => Some("image/gif"),
244        "webp" => Some("image/webp"),
245        "bmp" => Some("image/bmp"),
246        _ => None,
247    }
248}
249
250/// Read image metadata. Image bytes must be attached through ToolContext by
251/// callers that need model-visible attachments.
252fn read_image(path: &Path, path_str: &str, mime: &str) -> ReadFileBlockingResult {
253    let data = match std::fs::read(path) {
254        Ok(d) => d,
255        Err(e) => {
256            return ReadFileBlockingResult::tool(ToolResult::err_fmt(format_args!(
257                "Failed to read image: {e}"
258            )));
259        }
260    };
261
262    let size_kb = data.len() / 1024;
263    let dims = image_dimensions(&data, mime);
264    let label = match dims {
265        Some((w, h)) => format!("{} ({}KB {}x{})", path_str, size_kb, w, h),
266        None => format!("{} ({}KB)", path_str, size_kb),
267    };
268
269    let Some(media_type) = lash_core::MediaType::from_mime(mime) else {
270        return ReadFileBlockingResult::tool(ToolResult::err_fmt(format_args!(
271            "Unsupported image MIME type: {mime}"
272        )));
273    };
274    ReadFileBlockingResult::Image(ImageAttachmentData {
275        data,
276        media_type,
277        width: dims.map(|(width, _)| width),
278        height: dims.map(|(_, height)| height),
279        label,
280    })
281}
282
283async fn store_image_attachment(
284    context: &lash_core::ToolContext<'_>,
285    image: ImageAttachmentData,
286) -> ToolResult {
287    let reference = match context
288        .attachments()
289        .put(
290            image.data,
291            lash_core::AttachmentCreateMeta::new(
292                image.media_type,
293                image.width,
294                image.height,
295                Some(image.label),
296            ),
297        )
298        .await
299    {
300        Ok(reference) => reference,
301        Err(err) => {
302            return ToolResult::err_fmt(format_args!("Failed to store image attachment: {err}"));
303        }
304    };
305    ToolResult::from_output(lash_core::ToolCallOutput::success(
306        lash_core::ToolValue::Attachment(reference),
307    ))
308}
309
310/// Extract text from a PDF file using the pdf-extract crate (pure Rust).
311fn read_pdf(path: &Path, path_str: &str, offset: usize, limit: usize) -> ToolResult {
312    let pdf_bytes = match std::fs::read(path) {
313        Ok(b) => b,
314        Err(e) => return ToolResult::err_fmt(format_args!("Failed to read PDF: {e}")),
315    };
316
317    let file_size_kb = pdf_bytes.len() / 1024;
318
319    let text = match pdf_extract::extract_text_from_mem(&pdf_bytes) {
320        Ok(t) => t,
321        Err(e) => {
322            return ToolResult::err_fmt(format_args!(
323                "Failed to extract text from PDF {path_str}: {e}"
324            ));
325        }
326    };
327
328    let slice = match collect_window(
329        text.lines()
330            .map(|line| Ok::<String, std::io::Error>(line.to_string())),
331        offset,
332        limit,
333        |line_no, line| format!("{line_no}: {line}"),
334        "PDF",
335    ) {
336        Ok(slice) => slice,
337        Err(err) => return err,
338    };
339
340    let mut formatted = render_window(&slice, WindowKind::Lines);
341
342    let header = format!(
343        "[PDF: {} ({}KB, {} lines extracted)]\n",
344        path_str, file_size_kb, slice.total_items
345    );
346    formatted.insert_str(0, &header);
347
348    ToolResult::ok(json!(formatted))
349}
350
351/// Extract width x height from image headers (zero deps).
352fn image_dimensions(data: &[u8], mime: &str) -> Option<(u32, u32)> {
353    match mime {
354        "image/png" => png_dimensions(data),
355        "image/jpeg" => jpeg_dimensions(data),
356        "image/gif" => gif_dimensions(data),
357        _ => None,
358    }
359}
360
361/// PNG: width at bytes 16-19, height at bytes 20-23 (IHDR chunk, big-endian).
362fn png_dimensions(data: &[u8]) -> Option<(u32, u32)> {
363    if data.len() < 24 {
364        return None;
365    }
366    // Verify PNG signature
367    if &data[..8] != b"\x89PNG\r\n\x1a\n" {
368        return None;
369    }
370    let w = u32::from_be_bytes([data[16], data[17], data[18], data[19]]);
371    let h = u32::from_be_bytes([data[20], data[21], data[22], data[23]]);
372    Some((w, h))
373}
374
375/// JPEG: scan for SOF0/SOF2 marker (0xFF 0xC0 or 0xFF 0xC2), height then width.
376fn jpeg_dimensions(data: &[u8]) -> Option<(u32, u32)> {
377    let mut i = 0;
378    while i + 1 < data.len() {
379        if data[i] != 0xFF {
380            i += 1;
381            continue;
382        }
383        let marker = data[i + 1];
384        // SOF0 (0xC0) or SOF2 (0xC2) — baseline or progressive
385        if marker == 0xC0 || marker == 0xC2 {
386            if i + 9 >= data.len() {
387                return None;
388            }
389            let h = u16::from_be_bytes([data[i + 5], data[i + 6]]) as u32;
390            let w = u16::from_be_bytes([data[i + 7], data[i + 8]]) as u32;
391            return Some((w, h));
392        }
393        // Skip non-SOF markers
394        if marker == 0xD8 || marker == 0xD9 || marker == 0x01 || (0xD0..=0xD7).contains(&marker) {
395            i += 2;
396        } else if i + 3 < data.len() {
397            let len = u16::from_be_bytes([data[i + 2], data[i + 3]]) as usize;
398            i += 2 + len;
399        } else {
400            break;
401        }
402    }
403    None
404}
405
406/// GIF: width at bytes 6-7, height at bytes 8-9 (little-endian).
407fn gif_dimensions(data: &[u8]) -> Option<(u32, u32)> {
408    if data.len() < 10 {
409        return None;
410    }
411    // Verify GIF signature
412    if &data[..3] != b"GIF" {
413        return None;
414    }
415    let w = u16::from_le_bytes([data[6], data[7]]) as u32;
416    let h = u16::from_le_bytes([data[8], data[9]]) as u32;
417    Some((w, h))
418}
419
420struct WindowSlice {
421    rendered: Vec<String>,
422    total_items: usize,
423    shown_start: Option<usize>,
424    shown_end: Option<usize>,
425    has_more_items: bool,
426    truncated_by_bytes: bool,
427}
428
429enum WindowKind {
430    Lines,
431    Entries,
432}
433
434fn collect_window<I, E, F>(
435    items: I,
436    offset: usize,
437    limit: usize,
438    mut format_item: F,
439    item_label: &str,
440) -> Result<WindowSlice, ToolResult>
441where
442    I: IntoIterator<Item = Result<String, E>>,
443    E: std::fmt::Display,
444    F: FnMut(usize, &str) -> String,
445{
446    let mut total_items = 0usize;
447    let mut bytes = 0usize;
448    let mut rendered = Vec::new();
449    let mut has_more_items = false;
450    let mut truncated_by_bytes = false;
451
452    for item in items {
453        let item = item.map_err(|err| {
454            ToolResult::err_fmt(format_args!("Failed to read {item_label}: {err}"))
455        })?;
456        total_items += 1;
457        if total_items < offset {
458            continue;
459        }
460        if rendered.len() >= limit {
461            has_more_items = true;
462            continue;
463        }
464
465        let item = truncate_line(&item);
466        let rendered_item = format_item(total_items, &item);
467        let size = rendered_item.len() + usize::from(!rendered.is_empty());
468        if bytes + size > MAX_OUTPUT_BYTES {
469            truncated_by_bytes = true;
470            has_more_items = true;
471            break;
472        }
473        bytes += size;
474        rendered.push(rendered_item);
475    }
476
477    if total_items < offset && !(total_items == 0 && offset == 1) {
478        return Err(ToolResult::err_fmt(format_args!(
479            "Offset {offset} is out of range for this {item_label} ({total_items} items)"
480        )));
481    }
482
483    let shown_start = (!rendered.is_empty()).then_some(offset);
484    let shown_end = shown_start.map(|start| start + rendered.len().saturating_sub(1));
485
486    Ok(WindowSlice {
487        rendered,
488        total_items,
489        shown_start,
490        shown_end,
491        has_more_items,
492        truncated_by_bytes,
493    })
494}
495
496fn render_window(slice: &WindowSlice, kind: WindowKind) -> String {
497    let mut output = slice.rendered.join("\n");
498    let Some(shown_start) = slice.shown_start else {
499        return output;
500    };
501    let Some(shown_end) = slice.shown_end else {
502        return output;
503    };
504
505    let next_offset = shown_end + 1;
506    match kind {
507        WindowKind::Lines => {
508            if slice.truncated_by_bytes {
509                output.push_str(&format!(
510                    "\n[output capped at {}. Showing lines {}-{}. Use offset={} to continue.]",
511                    MAX_OUTPUT_BYTES_LABEL, shown_start, shown_end, next_offset
512                ));
513            } else if slice.has_more_items {
514                output.push_str(&format!(
515                    "\n[results truncated: showing lines {}-{} of {}. Use offset={} to continue.]",
516                    shown_start, shown_end, slice.total_items, next_offset
517                ));
518            }
519        }
520        WindowKind::Entries => {
521            if slice.truncated_by_bytes {
522                output.push_str(&format!(
523                    "\n[output capped at {}. Showing entries {}-{}. Use offset={} to continue.]",
524                    MAX_OUTPUT_BYTES_LABEL, shown_start, shown_end, next_offset
525                ));
526            } else if slice.has_more_items {
527                output.push_str(&format!(
528                    "\n[results truncated: showing entries {}-{} of {}. Use offset={} to continue.]",
529                    shown_start, shown_end, slice.total_items, next_offset
530                ));
531            }
532        }
533    }
534    output
535}
536
537fn truncate_line(line: &str) -> String {
538    if line.len() > MAX_LINE_LEN {
539        // Slice on a char boundary at or below MAX_LINE_LEN so a multi-byte
540        // char straddling the limit doesn't panic ("not a char boundary").
541        let end = floor_char_boundary(line, MAX_LINE_LEN);
542        format!("{}...", &line[..end])
543    } else {
544        line.to_string()
545    }
546}
547
548fn floor_char_boundary(text: &str, index: usize) -> usize {
549    if index >= text.len() {
550        return text.len();
551    }
552    let mut idx = index;
553    while idx > 0 && !text.is_char_boundary(idx) {
554        idx -= 1;
555    }
556    idx
557}
558
559#[cfg(test)]
560mod tests {
561    use super::*;
562    use std::sync::Arc;
563
564    use lash_core::AttachmentStore;
565    use serde_json::json;
566    use tempfile::TempDir;
567
568    #[tokio::test]
569    async fn test_read_file() {
570        let dir = TempDir::new().unwrap();
571        let path = dir.path().join("test.txt");
572        std::fs::write(&path, "line1\nline2\nline3").unwrap();
573        let result = lash_core::testing::run_tool(
574            &read_file_provider(),
575            "read_file",
576            &json!({"path": path.to_str().unwrap()}),
577        )
578        .await;
579        assert!(result.is_success());
580        let value = result.value_for_projection();
581        let text = value.as_str().unwrap();
582        assert!(text.contains("1: line1"));
583        assert!(text.contains("2: line2"));
584        assert!(text.contains("3: line3"));
585        assert!(!text.contains('|'));
586    }
587
588    #[tokio::test]
589    async fn test_read_with_offset_and_limit() {
590        let dir = TempDir::new().unwrap();
591        let path = dir.path().join("test.txt");
592        std::fs::write(&path, "line1\nline2\nline3\nline4\nline5").unwrap();
593        let result = lash_core::testing::run_tool(
594            &read_file_provider(),
595            "read_file",
596            &json!({"path": path.to_str().unwrap(), "offset": 2, "limit": 2}),
597        )
598        .await;
599        assert!(result.is_success());
600        let value = result.value_for_projection();
601        let text = value.as_str().unwrap();
602        assert!(text.contains("2: line2"));
603        assert!(text.contains("3: line3"));
604        assert!(!text.contains("1: line1"));
605        assert!(!text.contains("4: line4"));
606        assert!(text.contains("results truncated"));
607        assert!(text.contains("offset=4"));
608    }
609
610    #[tokio::test]
611    async fn test_read_caps_large_output_by_bytes() {
612        let dir = TempDir::new().unwrap();
613        let path = dir.path().join("test.txt");
614        let content = (0..200)
615            .map(|idx| format!("{idx}: {}", "x".repeat(400)))
616            .collect::<Vec<_>>()
617            .join("\n");
618        std::fs::write(&path, content).unwrap();
619        let result = lash_core::testing::run_tool(
620            &read_file_provider(),
621            "read_file",
622            &json!({"path": path.to_str().unwrap(), "limit": 200}),
623        )
624        .await;
625        assert!(result.is_success());
626        let value = result.value_for_projection();
627        let text = value.as_str().unwrap();
628        assert!(text.contains("output capped at 50 KB"));
629        assert!(text.contains("Use offset="));
630    }
631
632    #[tokio::test]
633    async fn test_read_nonexistent() {
634        let result = lash_core::testing::run_tool(
635            &read_file_provider(),
636            "read_file",
637            &json!({"path": "/nonexistent/path/to/file.txt"}),
638        )
639        .await;
640        assert!(!result.is_success());
641    }
642
643    // ── PNG dimensions ──
644
645    #[test]
646    fn test_png_dimensions_valid() {
647        // Minimal valid PNG header (first 24 bytes)
648        let mut data = vec![0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A];
649        // IHDR chunk length (4 bytes)
650        data.extend_from_slice(&[0, 0, 0, 13]);
651        // IHDR tag
652        data.extend_from_slice(b"IHDR");
653        // Width: 640 (big-endian)
654        data.extend_from_slice(&640u32.to_be_bytes());
655        // Height: 480 (big-endian)
656        data.extend_from_slice(&480u32.to_be_bytes());
657        let (w, h) = png_dimensions(&data).unwrap();
658        assert_eq!((w, h), (640, 480));
659    }
660
661    #[test]
662    fn test_png_dimensions_truncated() {
663        assert!(png_dimensions(&[0x89, b'P', b'N', b'G']).is_none());
664    }
665
666    #[test]
667    fn test_png_dimensions_wrong_sig() {
668        let data = vec![0; 24];
669        assert!(png_dimensions(&data).is_none());
670    }
671
672    // ── JPEG dimensions ──
673
674    #[test]
675    fn test_jpeg_dimensions_valid() {
676        // Minimal JPEG with SOI + SOF0
677        let mut data = vec![0xFF, 0xD8]; // SOI
678        // SOF0 marker
679        data.extend_from_slice(&[0xFF, 0xC0]);
680        // Length (including these 2 bytes)
681        data.extend_from_slice(&[0x00, 0x11]);
682        // Precision
683        data.push(8);
684        // Height: 480 (big-endian u16)
685        data.extend_from_slice(&480u16.to_be_bytes());
686        // Width: 640 (big-endian u16)
687        data.extend_from_slice(&640u16.to_be_bytes());
688        // Padding to satisfy i+9 < len bounds check
689        data.push(0);
690        let (w, h) = jpeg_dimensions(&data).unwrap();
691        assert_eq!((w, h), (640, 480));
692    }
693
694    #[test]
695    fn test_jpeg_dimensions_truncated() {
696        assert!(jpeg_dimensions(&[0xFF, 0xD8, 0xFF, 0xC0]).is_none());
697    }
698
699    // ── GIF dimensions ──
700
701    #[test]
702    fn test_gif87a_dimensions() {
703        let mut data = b"GIF87a".to_vec();
704        // Width: 320 (little-endian u16)
705        data.extend_from_slice(&320u16.to_le_bytes());
706        // Height: 200 (little-endian u16)
707        data.extend_from_slice(&200u16.to_le_bytes());
708        let (w, h) = gif_dimensions(&data).unwrap();
709        assert_eq!((w, h), (320, 200));
710    }
711
712    #[test]
713    fn test_gif89a_dimensions() {
714        let mut data = b"GIF89a".to_vec();
715        data.extend_from_slice(&100u16.to_le_bytes());
716        data.extend_from_slice(&50u16.to_le_bytes());
717        let (w, h) = gif_dimensions(&data).unwrap();
718        assert_eq!((w, h), (100, 50));
719    }
720
721    #[test]
722    fn test_gif_bad_signature() {
723        let data = b"NOT_GIF___".to_vec();
724        assert!(gif_dimensions(&data).is_none());
725    }
726
727    // ── image_mime ──
728
729    #[test]
730    fn test_image_mime() {
731        assert_eq!(image_mime(Path::new("photo.png")), Some("image/png"));
732        assert_eq!(image_mime(Path::new("photo.jpg")), Some("image/jpeg"));
733        assert_eq!(image_mime(Path::new("photo.jpeg")), Some("image/jpeg"));
734        assert_eq!(image_mime(Path::new("anim.gif")), Some("image/gif"));
735        assert_eq!(image_mime(Path::new("photo.webp")), Some("image/webp"));
736        assert_eq!(image_mime(Path::new("photo.bmp")), Some("image/bmp"));
737        assert_eq!(image_mime(Path::new("file.txt")), None);
738        assert_eq!(image_mime(Path::new("noext")), None);
739    }
740
741    #[tokio::test]
742    async fn test_read_image_returns_attachment_value() {
743        let dir = TempDir::new().unwrap();
744        let path = dir.path().join("tiny.png");
745        let mut data = vec![0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A];
746        data.extend_from_slice(&[0, 0, 0, 13]);
747        data.extend_from_slice(b"IHDR");
748        data.extend_from_slice(&1u32.to_be_bytes());
749        data.extend_from_slice(&1u32.to_be_bytes());
750        std::fs::write(&path, &data).unwrap();
751
752        let store = Arc::new(lash_core::InMemoryAttachmentStore::new());
753        let host = Arc::new(lash_core::testing::MockSessionManager::default());
754        let context = lash_core::ToolContext::__for_testing(
755            "test-session".into(),
756            host.clone(),
757            host.clone(),
758            host,
759            Arc::new(lash_core::UnavailableProcessService),
760            store.clone(),
761            lash_core::DirectCompletionClient::from_fn(|_, _| {
762                Err(lash_core::PluginError::Session(
763                    "direct completions are unavailable in read_file tests".to_string(),
764                ))
765            }),
766            None,
767        );
768        let result = ReadFile
769            .execute(lash_core::ToolCall {
770                name: "read_file",
771                args: &json!({"path": path.to_str().unwrap()}),
772                context: &context,
773                progress: None,
774            })
775            .await;
776
777        let lash_core::ToolCallOutcome::Success(lash_core::ToolValue::Attachment(reference)) =
778            result.into_output().outcome
779        else {
780            panic!("expected attachment result");
781        };
782        assert_eq!(reference.byte_len, data.len() as u64);
783        assert_eq!(reference.width, Some(1));
784        assert_eq!(reference.height, Some(1));
785        assert_eq!(store.get(&reference.id).await.unwrap().bytes, data);
786    }
787
788    #[test]
789    fn truncate_line_does_not_split_multibyte_char() {
790        // Pad with ASCII so a 3-byte char ('€') straddles the MAX_LINE_LEN
791        // byte boundary: bytes [MAX_LINE_LEN - 1 .. MAX_LINE_LEN + 2). A naive
792        // `&line[..MAX_LINE_LEN]` slice would panic on a non-char boundary.
793        let mut line = "a".repeat(MAX_LINE_LEN - 1);
794        line.push('€');
795        line.push_str(&"b".repeat(50));
796        assert!(line.len() > MAX_LINE_LEN);
797
798        let truncated = truncate_line(&line);
799
800        // Truncation succeeded (no panic) and kept the char-boundary prefix.
801        assert!(truncated.ends_with("..."));
802        let body = truncated.strip_suffix("...").unwrap();
803        // The straddling '€' is dropped entirely, leaving only the padding.
804        assert_eq!(body, "a".repeat(MAX_LINE_LEN - 1));
805        assert!(body.is_char_boundary(body.len()));
806    }
807}