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#[derive(Default)]
16pub struct ReadFile;
17
18pub 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 path: String,
33 #[serde(default = "default_offset")]
35 #[schemars(range(min = 1))]
36 offset: usize,
37 #[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 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 if let Some(mime) = image_mime(path) {
144 return read_image(path, path_str, mime);
145 }
146
147 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 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
220fn 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
235fn 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
248fn 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
308fn 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
349fn 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
359fn png_dimensions(data: &[u8]) -> Option<(u32, u32)> {
361 if data.len() < 24 {
362 return None;
363 }
364 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
373fn 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 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 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
404fn gif_dimensions(data: &[u8]) -> Option<(u32, u32)> {
406 if data.len() < 10 {
407 return None;
408 }
409 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 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 #[test]
674 fn test_png_dimensions_valid() {
675 let mut data = vec![0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A];
677 data.extend_from_slice(&[0, 0, 0, 13]);
679 data.extend_from_slice(b"IHDR");
681 data.extend_from_slice(&640u32.to_be_bytes());
683 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 #[test]
703 fn test_jpeg_dimensions_valid() {
704 let mut data = vec![0xFF, 0xD8]; data.extend_from_slice(&[0xFF, 0xC0]);
708 data.extend_from_slice(&[0x00, 0x11]);
710 data.push(8);
712 data.extend_from_slice(&480u16.to_be_bytes());
714 data.extend_from_slice(&640u16.to_be_bytes());
716 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 #[test]
730 fn test_gif87a_dimensions() {
731 let mut data = b"GIF87a".to_vec();
732 data.extend_from_slice(&320u16.to_le_bytes());
734 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 #[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 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 assert!(truncated.ends_with("..."));
830 let body = truncated.strip_suffix("...").unwrap();
831 assert_eq!(body, "a".repeat(MAX_LINE_LEN - 1));
833 assert!(body.is_char_boundary(body.len()));
834 }
835}