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