1use crate::llm::ContentSource;
2use crate::{Environment, PrimitiveToolName, Tool, ToolContext, ToolResult, ToolTier};
3use anyhow::{Context, Result};
4use serde::Deserialize;
5use serde_json::{Value, json};
6use std::sync::Arc;
7
8use super::PrimitiveToolContext;
9
10const MAX_LINE_LENGTH: usize = 500;
12
13const LINE_TRUNCATION_MARKER: &str = "... [line truncated]";
15
16const DEFAULT_LIMIT: usize = 2000;
18
19const MAX_FILE_BYTES: usize = 10 * 1024 * 1024;
23
24const MAX_MEDIA_BYTES: usize = 5 * 1024 * 1024;
28
29pub struct ReadTool<E: Environment> {
30 ctx: PrimitiveToolContext<E>,
31}
32
33impl<E: Environment> ReadTool<E> {
34 #[must_use]
35 pub const fn new(environment: Arc<E>, capabilities: crate::AgentCapabilities) -> Self {
36 Self {
37 ctx: PrimitiveToolContext::new(environment, capabilities),
38 }
39 }
40}
41
42#[derive(Debug, Deserialize)]
43struct ReadInput {
44 #[serde(alias = "file_path")]
45 path: String,
46 #[serde(
48 default = "defaults::offset",
49 deserialize_with = "super::deserialize_usize_from_string_or_int"
50 )]
51 offset: usize,
52 #[serde(
54 default = "defaults::limit",
55 deserialize_with = "super::deserialize_usize_from_string_or_int"
56 )]
57 limit: usize,
58}
59
60mod defaults {
61 pub const fn offset() -> usize {
62 1
63 }
64 pub const fn limit() -> usize {
65 super::DEFAULT_LIMIT
66 }
67}
68
69impl<E: Environment + 'static, Ctx: Send + Sync + 'static> Tool<Ctx> for ReadTool<E> {
70 type Name = PrimitiveToolName;
71
72 fn name(&self) -> PrimitiveToolName {
73 PrimitiveToolName::Read
74 }
75
76 fn display_name(&self) -> &'static str {
77 "Read File"
78 }
79
80 fn description(&self) -> &'static str {
81 "Read text files with 1-indexed line numbers. Also supports images (PNG/JPEG/GIF/WebP) and PDF documents."
82 }
83
84 fn tier(&self) -> ToolTier {
85 ToolTier::Observe
86 }
87
88 fn input_schema(&self) -> Value {
89 json!({
90 "type": "object",
91 "properties": {
92 "path": {
93 "type": "string",
94 "description": "Path to the file to read"
95 },
96 "offset": {
97 "anyOf": [
98 {"type": "integer"},
99 {"type": "string", "pattern": "^[0-9]+$"}
100 ],
101 "description": "Line number to start from (1-based). Accepts either an integer or a numeric string. Default: 1"
102 },
103 "limit": {
104 "anyOf": [
105 {"type": "integer"},
106 {"type": "string", "pattern": "^[0-9]+$"}
107 ],
108 "description": "Maximum number of lines to return. Accepts either an integer or a numeric string. Default: 2000"
109 }
110 },
111 "required": ["path"]
112 })
113 }
114
115 async fn execute(&self, _ctx: &ToolContext<Ctx>, input: Value) -> Result<ToolResult> {
116 let input: ReadInput = ReadInput::deserialize(&input)
117 .with_context(|| format!("Invalid input for read tool: {input}"))?;
118
119 if input.offset == 0 {
120 return Ok(ToolResult::error("offset must be a 1-indexed line number"));
121 }
122
123 if input.limit == 0 {
124 return Ok(ToolResult::error("limit must be greater than zero"));
125 }
126
127 let path = self.ctx.environment.resolve_path(&input.path);
128
129 if let Err(reason) = self.ctx.capabilities.check_read(&path) {
130 return Ok(ToolResult::error(format!(
131 "Permission denied: cannot read '{path}': {reason}"
132 )));
133 }
134
135 let exists = self
136 .ctx
137 .environment
138 .exists(&path)
139 .await
140 .context("Failed to check file existence")?;
141
142 if !exists {
143 return Ok(ToolResult::error(format!("File not found: '{path}'")));
144 }
145
146 let is_dir = self
147 .ctx
148 .environment
149 .is_dir(&path)
150 .await
151 .context("Failed to check if path is directory")?;
152
153 if is_dir {
154 return Ok(ToolResult::error(format!(
155 "'{path}' is a directory, not a file"
156 )));
157 }
158
159 let bytes = self
160 .ctx
161 .environment
162 .read_file_bytes(&path)
163 .await
164 .context("Failed to read file")?;
165
166 if let Some(media_type) = detect_media_type(&path) {
168 if bytes.len() > MAX_MEDIA_BYTES {
171 return Ok(ToolResult::error(format!(
172 "Media file '{path}' is {} bytes, which exceeds the {MAX_MEDIA_BYTES}-byte attachment limit",
173 bytes.len()
174 )));
175 }
176 let encoded = base64_encode(&bytes);
177 return Ok(
178 ToolResult::success(format!("Read {media_type} file: '{path}'"))
179 .with_documents(vec![ContentSource::new(media_type, encoded)]),
180 );
181 }
182
183 if bytes.len() > MAX_FILE_BYTES {
185 return Ok(ToolResult::error(format!(
186 "File '{path}' is {} bytes, which exceeds the {MAX_FILE_BYTES}-byte read limit; use offset/limit on a smaller range or a different tool",
187 bytes.len()
188 )));
189 }
190
191 let content = String::from_utf8_lossy(&bytes);
193 let collected = read_lines(&content, input.offset, input.limit);
194
195 if collected.is_empty() {
196 return Ok(ToolResult::error("offset exceeds file length"));
197 }
198
199 Ok(ToolResult::success(collected.join("\n")))
200 }
201}
202
203fn read_lines(content: &str, offset: usize, limit: usize) -> Vec<String> {
204 let total_lines = content.split('\n').count();
205 let mut collected = Vec::new();
206 let mut line_number = 0usize;
207 let mut last_emitted = 0usize;
208
209 for raw_line in content.split('\n') {
210 line_number += 1;
211
212 if line_number < offset {
213 continue;
214 }
215
216 if collected.len() >= limit {
217 break;
218 }
219
220 let line = raw_line.strip_suffix('\r').unwrap_or(raw_line);
222 let display = truncate_line(line);
223 collected.push(format!("L{line_number}: {display}"));
224 last_emitted = line_number;
225 }
226
227 if !collected.is_empty() && last_emitted < total_lines {
230 collected.push(format!(
231 "... [showing lines {offset}-{last_emitted} of {total_lines}; use offset/limit to read more]"
232 ));
233 }
234
235 collected
236}
237
238fn truncate_line(line: &str) -> String {
239 if line.len() <= MAX_LINE_LENGTH {
240 line.to_string()
241 } else {
242 format!(
243 "{}{LINE_TRUNCATION_MARKER}",
244 super::truncate_str(line, MAX_LINE_LENGTH)
245 )
246 }
247}
248
249fn detect_media_type(path: &str) -> Option<&'static str> {
251 let ext = std::path::Path::new(path).extension()?.to_ascii_lowercase();
252
253 match ext.to_str()? {
254 "png" => Some("image/png"),
255 "jpg" | "jpeg" => Some("image/jpeg"),
256 "gif" => Some("image/gif"),
257 "webp" => Some("image/webp"),
258 "pdf" => Some("application/pdf"),
259 _ => None,
260 }
261}
262
263fn base64_encode(data: &[u8]) -> String {
264 use base64::Engine;
265 base64::engine::general_purpose::STANDARD.encode(data)
266}
267
268#[cfg(test)]
269mod tests {
270 use super::*;
271 use crate::{AgentCapabilities, InMemoryFileSystem};
272
273 fn create_test_tool(
274 fs: Arc<InMemoryFileSystem>,
275 capabilities: AgentCapabilities,
276 ) -> ReadTool<InMemoryFileSystem> {
277 ReadTool::new(fs, capabilities)
278 }
279
280 fn tool_ctx() -> ToolContext<()> {
281 ToolContext::new(())
282 }
283
284 #[tokio::test]
285 async fn reads_entire_file() -> anyhow::Result<()> {
286 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
287 fs.write_file("test.txt", "alpha\nbeta\ngamma").await?;
288
289 let tool = create_test_tool(fs, AgentCapabilities::full_access());
290 let result = tool
291 .execute(&tool_ctx(), json!({"path": "/workspace/test.txt"}))
292 .await?;
293
294 assert!(result.success);
295 assert_eq!(result.output, "L1: alpha\nL2: beta\nL3: gamma");
296 Ok(())
297 }
298
299 #[tokio::test]
300 async fn reads_with_offset() -> anyhow::Result<()> {
301 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
302 fs.write_file("test.txt", "alpha\nbeta\ngamma").await?;
303
304 let tool = create_test_tool(fs, AgentCapabilities::full_access());
305 let result = tool
306 .execute(
307 &tool_ctx(),
308 json!({"path": "/workspace/test.txt", "offset": 2}),
309 )
310 .await?;
311
312 assert!(result.success);
313 assert_eq!(result.output, "L2: beta\nL3: gamma");
314 Ok(())
315 }
316
317 #[tokio::test]
318 async fn reads_with_limit() -> anyhow::Result<()> {
319 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
320 fs.write_file("test.txt", "alpha\nbeta\ngamma").await?;
321
322 let tool = create_test_tool(fs, AgentCapabilities::full_access());
323 let result = tool
324 .execute(
325 &tool_ctx(),
326 json!({"path": "/workspace/test.txt", "limit": 2}),
327 )
328 .await?;
329
330 assert!(result.success);
331 assert!(result.output.starts_with("L1: alpha\nL2: beta"));
332 assert!(result.output.contains("showing lines 1-2 of 3"));
333 Ok(())
334 }
335
336 #[tokio::test]
337 async fn reads_with_offset_and_limit() -> anyhow::Result<()> {
338 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
339 fs.write_file("test.txt", "alpha\nbeta\ngamma\ndelta\nepsilon")
340 .await?;
341
342 let tool = create_test_tool(fs, AgentCapabilities::full_access());
343 let result = tool
344 .execute(
345 &tool_ctx(),
346 json!({"path": "/workspace/test.txt", "offset": 2, "limit": 2}),
347 )
348 .await?;
349
350 assert!(result.success);
351 assert!(result.output.starts_with("L2: beta\nL3: gamma"));
352 assert!(result.output.contains("showing lines 2-3 of 5"));
353 Ok(())
354 }
355
356 #[tokio::test]
357 async fn accepts_string_offset_and_limit() -> anyhow::Result<()> {
358 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
359 fs.write_file("test.txt", "alpha\nbeta\ngamma").await?;
360
361 let tool = create_test_tool(fs, AgentCapabilities::full_access());
362 let result = tool
363 .execute(
364 &tool_ctx(),
365 json!({"path": "/workspace/test.txt", "offset": "2", "limit": "1"}),
366 )
367 .await?;
368
369 assert!(result.success);
370 assert!(result.output.starts_with("L2: beta"));
371 assert!(result.output.contains("showing lines 2-2 of 3"));
372 Ok(())
373 }
374
375 #[tokio::test]
376 async fn errors_on_offset_zero() -> anyhow::Result<()> {
377 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
378 fs.write_file("test.txt", "alpha").await?;
379
380 let tool = create_test_tool(fs, AgentCapabilities::full_access());
381 let result = tool
382 .execute(
383 &tool_ctx(),
384 json!({"path": "/workspace/test.txt", "offset": 0}),
385 )
386 .await?;
387
388 assert!(!result.success);
389 assert!(result.output.contains("1-indexed"));
390 Ok(())
391 }
392
393 #[tokio::test]
394 async fn errors_on_limit_zero() -> anyhow::Result<()> {
395 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
396 fs.write_file("test.txt", "alpha").await?;
397
398 let tool = create_test_tool(fs, AgentCapabilities::full_access());
399 let result = tool
400 .execute(
401 &tool_ctx(),
402 json!({"path": "/workspace/test.txt", "limit": 0}),
403 )
404 .await?;
405
406 assert!(!result.success);
407 assert!(result.output.contains("greater than zero"));
408 Ok(())
409 }
410
411 #[tokio::test]
412 async fn errors_when_offset_exceeds_length() -> anyhow::Result<()> {
413 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
414 fs.write_file("short.txt", "only").await?;
415
416 let tool = create_test_tool(fs, AgentCapabilities::full_access());
417 let result = tool
418 .execute(
419 &tool_ctx(),
420 json!({"path": "/workspace/short.txt", "offset": 100}),
421 )
422 .await?;
423
424 assert!(!result.success);
425 assert!(result.output.contains("offset exceeds file length"));
426 Ok(())
427 }
428
429 #[tokio::test]
430 async fn errors_on_nonexistent_file() -> anyhow::Result<()> {
431 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
432
433 let tool = create_test_tool(fs, AgentCapabilities::full_access());
434 let result = tool
435 .execute(&tool_ctx(), json!({"path": "/workspace/nope.txt"}))
436 .await?;
437
438 assert!(!result.success);
439 assert!(result.output.contains("File not found"));
440 Ok(())
441 }
442
443 #[tokio::test]
444 async fn errors_on_directory() -> anyhow::Result<()> {
445 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
446 fs.create_dir("/workspace/subdir").await?;
447
448 let tool = create_test_tool(fs, AgentCapabilities::full_access());
449 let result = tool
450 .execute(&tool_ctx(), json!({"path": "/workspace/subdir"}))
451 .await?;
452
453 assert!(!result.success);
454 assert!(result.output.contains("is a directory"));
455 Ok(())
456 }
457
458 #[tokio::test]
459 async fn errors_on_permission_denied() -> anyhow::Result<()> {
460 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
461 fs.write_file("secret.txt", "secret").await?;
462
463 let tool = create_test_tool(fs, AgentCapabilities::none());
464 let result = tool
465 .execute(&tool_ctx(), json!({"path": "/workspace/secret.txt"}))
466 .await?;
467
468 assert!(!result.success);
469 assert!(result.output.contains("Permission denied"));
470 Ok(())
471 }
472
473 #[tokio::test]
474 async fn respects_denied_paths() -> anyhow::Result<()> {
475 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
476 fs.write_file("secrets/key.txt", "API_KEY=secret").await?;
477
478 let caps =
479 AgentCapabilities::read_only().with_denied_paths(vec!["/workspace/secrets/**".into()]);
480
481 let tool = create_test_tool(fs, caps);
482 let result = tool
483 .execute(&tool_ctx(), json!({"path": "/workspace/secrets/key.txt"}))
484 .await?;
485
486 assert!(!result.success);
487 assert!(result.output.contains("Permission denied"));
488 Ok(())
489 }
490
491 #[tokio::test]
492 async fn handles_crlf_line_endings() -> anyhow::Result<()> {
493 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
494 fs.write_file_bytes("crlf.txt", b"one\r\ntwo\r\n").await?;
495
496 let tool = create_test_tool(fs, AgentCapabilities::full_access());
497 let result = tool
498 .execute(&tool_ctx(), json!({"path": "/workspace/crlf.txt"}))
499 .await?;
500
501 assert!(result.success);
502 assert_eq!(result.output, "L1: one\nL2: two\nL3: ");
503 Ok(())
504 }
505
506 #[tokio::test]
507 async fn handles_non_utf8() -> anyhow::Result<()> {
508 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
509 fs.write_file_bytes(
510 "bin.txt",
511 &[0xff, 0xfe, b'\n', b'p', b'l', b'a', b'i', b'n', b'\n'],
512 )
513 .await?;
514
515 let tool = create_test_tool(fs, AgentCapabilities::full_access());
516 let result = tool
517 .execute(&tool_ctx(), json!({"path": "/workspace/bin.txt"}))
518 .await?;
519
520 assert!(result.success);
521 assert!(result.output.contains("L2: plain"));
522 Ok(())
523 }
524
525 #[tokio::test]
526 async fn truncates_long_lines() -> anyhow::Result<()> {
527 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
528 let long_line = "x".repeat(MAX_LINE_LENGTH + 50);
529 fs.write_file("long.txt", &long_line).await?;
530
531 let tool = create_test_tool(fs, AgentCapabilities::full_access());
532 let result = tool
533 .execute(&tool_ctx(), json!({"path": "/workspace/long.txt"}))
534 .await?;
535
536 assert!(result.success);
537 let expected = "x".repeat(MAX_LINE_LENGTH);
538 assert!(result.output.starts_with(&format!("L1: {expected}")));
539 assert!(result.output.contains(LINE_TRUNCATION_MARKER));
540 Ok(())
541 }
542
543 #[tokio::test]
544 async fn handles_special_characters() -> anyhow::Result<()> {
545 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
546 fs.write_file("special.txt", "特殊字符\néàü\n🎉emoji")
547 .await?;
548
549 let tool = create_test_tool(fs, AgentCapabilities::full_access());
550 let result = tool
551 .execute(&tool_ctx(), json!({"path": "/workspace/special.txt"}))
552 .await?;
553
554 assert!(result.success);
555 assert!(result.output.contains("特殊字符"));
556 assert!(result.output.contains("éàü"));
557 assert!(result.output.contains("🎉emoji"));
558 Ok(())
559 }
560
561 #[tokio::test]
562 async fn respects_limit_with_more_lines() -> anyhow::Result<()> {
563 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
564 let content: String = (1..=100)
565 .map(|i| format!("line {i}"))
566 .collect::<Vec<_>>()
567 .join("\n");
568 fs.write_file("many.txt", &content).await?;
569
570 let tool = create_test_tool(fs, AgentCapabilities::full_access());
571 let result = tool
572 .execute(
573 &tool_ctx(),
574 json!({"path": "/workspace/many.txt", "offset": 50, "limit": 3}),
575 )
576 .await?;
577
578 assert!(result.success);
579 assert!(
580 result
581 .output
582 .starts_with("L50: line 50\nL51: line 51\nL52: line 52")
583 );
584 assert!(result.output.contains("showing lines 50-52 of 100"));
585 Ok(())
586 }
587
588 #[tokio::test]
589 async fn tool_metadata() {
590 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
591 let tool = create_test_tool(fs, AgentCapabilities::full_access());
592
593 assert_eq!(Tool::<()>::name(&tool), PrimitiveToolName::Read);
594 assert_eq!(Tool::<()>::tier(&tool), ToolTier::Observe);
595
596 let schema = Tool::<()>::input_schema(&tool);
597 assert!(schema["properties"].get("path").is_some());
598 assert!(schema["properties"].get("offset").is_some());
599 assert!(schema["properties"].get("limit").is_some());
600 }
601
602 #[test]
603 fn read_lines_basic() {
604 let lines = read_lines("alpha\nbeta\ngamma", 1, 2000);
605 assert_eq!(
606 lines,
607 vec![
608 "L1: alpha".to_string(),
609 "L2: beta".to_string(),
610 "L3: gamma".to_string(),
611 ]
612 );
613 }
614
615 #[test]
616 fn read_lines_with_offset_and_limit() {
617 let lines = read_lines("a\nb\nc\nd\ne", 2, 2);
618 assert_eq!(
619 lines,
620 vec![
621 "L2: b".to_string(),
622 "L3: c".to_string(),
623 "... [showing lines 2-3 of 5; use offset/limit to read more]".to_string(),
624 ]
625 );
626 }
627
628 #[test]
629 fn read_lines_no_continuation_marker_when_complete() {
630 let lines = read_lines("a\nb\nc", 1, 2000);
631 assert_eq!(
632 lines,
633 vec![
634 "L1: a".to_string(),
635 "L2: b".to_string(),
636 "L3: c".to_string()
637 ]
638 );
639 }
640
641 #[test]
642 fn read_lines_offset_past_end_returns_empty() {
643 let lines = read_lines("only", 5, 10);
644 assert!(lines.is_empty());
645 }
646
647 #[test]
648 fn detect_media_type_images() {
649 assert_eq!(detect_media_type("photo.png"), Some("image/png"));
650 assert_eq!(detect_media_type("photo.PNG"), Some("image/png"));
651 assert_eq!(detect_media_type("photo.jpg"), Some("image/jpeg"));
652 assert_eq!(detect_media_type("photo.jpeg"), Some("image/jpeg"));
653 assert_eq!(detect_media_type("photo.gif"), Some("image/gif"));
654 assert_eq!(detect_media_type("photo.webp"), Some("image/webp"));
655 assert_eq!(detect_media_type("doc.pdf"), Some("application/pdf"));
656 assert_eq!(detect_media_type("code.rs"), None);
657 assert_eq!(detect_media_type("data.json"), None);
658 }
659
660 #[tokio::test]
661 async fn reads_image_as_document() -> anyhow::Result<()> {
662 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
663 let png_bytes = [0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A];
665 fs.write_file_bytes("image.png", &png_bytes).await?;
666
667 let tool = create_test_tool(fs, AgentCapabilities::full_access());
668 let result = tool
669 .execute(&tool_ctx(), json!({"path": "/workspace/image.png"}))
670 .await?;
671
672 assert!(result.success);
673 assert_eq!(result.documents.len(), 1);
674 assert_eq!(result.documents[0].media_type, "image/png");
675 Ok(())
676 }
677
678 #[tokio::test]
679 async fn reads_pdf_as_document() -> anyhow::Result<()> {
680 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
681 fs.write_file_bytes("doc.pdf", b"%PDF-1.4 fake").await?;
682
683 let tool = create_test_tool(fs, AgentCapabilities::full_access());
684 let result = tool
685 .execute(&tool_ctx(), json!({"path": "/workspace/doc.pdf"}))
686 .await?;
687
688 assert!(result.success);
689 assert_eq!(result.documents.len(), 1);
690 assert_eq!(result.documents[0].media_type, "application/pdf");
691 Ok(())
692 }
693
694 #[tokio::test]
695 async fn text_files_have_no_documents() -> anyhow::Result<()> {
696 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
697 fs.write_file("test.txt", "hello").await?;
698
699 let tool = create_test_tool(fs, AgentCapabilities::full_access());
700 let result = tool
701 .execute(&tool_ctx(), json!({"path": "/workspace/test.txt"}))
702 .await?;
703
704 assert!(result.success);
705 assert!(result.documents.is_empty());
706 Ok(())
707 }
708
709 #[tokio::test]
710 async fn rejects_oversized_text_file() -> anyhow::Result<()> {
711 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
712 let big = vec![b'a'; MAX_FILE_BYTES + 1];
713 fs.write_file_bytes("big.txt", &big).await?;
714
715 let tool = create_test_tool(fs, AgentCapabilities::full_access());
716 let result = tool
717 .execute(&tool_ctx(), json!({"path": "/workspace/big.txt"}))
718 .await?;
719
720 assert!(!result.success);
721 assert!(result.output.contains("read limit"));
722 Ok(())
723 }
724
725 #[tokio::test]
726 async fn rejects_oversized_media_file() -> anyhow::Result<()> {
727 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
728 let big = vec![0u8; MAX_MEDIA_BYTES + 1];
729 fs.write_file_bytes("big.png", &big).await?;
730
731 let tool = create_test_tool(fs, AgentCapabilities::full_access());
732 let result = tool
733 .execute(&tool_ctx(), json!({"path": "/workspace/big.png"}))
734 .await?;
735
736 assert!(!result.success);
738 assert!(result.output.contains("attachment limit"));
739 assert!(result.documents.is_empty());
740 Ok(())
741 }
742
743 #[test]
744 fn truncate_line_appends_marker() {
745 let long = "x".repeat(MAX_LINE_LENGTH + 10);
746 let out = truncate_line(&long);
747 assert!(out.starts_with(&"x".repeat(MAX_LINE_LENGTH)));
748 assert!(out.ends_with(LINE_TRUNCATION_MARKER));
749 }
750
751 #[test]
752 fn truncate_line_short_unchanged() {
753 assert_eq!(truncate_line("short"), "short");
754 }
755}