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#[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 #[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 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 if let Some(mime) = image_mime(path) {
165 return read_image(path, path_str, mime);
166 }
167
168 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 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
241fn 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
256fn 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
269fn 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
358fn 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
399fn 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
409fn png_dimensions(data: &[u8]) -> Option<(u32, u32)> {
411 if data.len() < 24 {
412 return None;
413 }
414 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
423fn 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 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 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
454fn gif_dimensions(data: &[u8]) -> Option<(u32, u32)> {
456 if data.len() < 10 {
457 return None;
458 }
459 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 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 #[test]
758 fn test_png_dimensions_valid() {
759 let mut data = vec![0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A];
761 data.extend_from_slice(&[0, 0, 0, 13]);
763 data.extend_from_slice(b"IHDR");
765 data.extend_from_slice(&640u32.to_be_bytes());
767 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 #[test]
787 fn test_jpeg_dimensions_valid() {
788 let mut data = vec![0xFF, 0xD8]; data.extend_from_slice(&[0xFF, 0xC0]);
792 data.extend_from_slice(&[0x00, 0x11]);
794 data.push(8);
796 data.extend_from_slice(&480u16.to_be_bytes());
798 data.extend_from_slice(&640u16.to_be_bytes());
800 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 #[test]
814 fn test_gif87a_dimensions() {
815 let mut data = b"GIF87a".to_vec();
816 data.extend_from_slice(&320u16.to_le_bytes());
818 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 #[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 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 assert!(truncated.ends_with("..."));
917 let body = truncated.strip_suffix("...").unwrap();
918 assert_eq!(body, "a".repeat(MAX_LINE_LEN - 1));
920 assert!(body.is_char_boundary(body.len()));
921 }
922}