Skip to main content

lash_tools/files/
read_file.rs

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