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