Skip to main content

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