1use crate::agent_cx::AgentCx;
10use crate::config::Config;
11use crate::error::{Error, Result};
12use crate::extensions::{safe_canonicalize, strip_unc_prefix};
13use crate::model::{ContentBlock, ImageContent, TextContent};
14use asupersync::io::{AsyncRead, AsyncReadExt, AsyncWriteExt, ReadBuf, SeekFrom};
15use asupersync::time::{sleep, wall_now};
16use async_trait::async_trait;
17use serde::{Deserialize, Serialize};
18use sha2::Digest as _;
19use std::cmp::Ordering;
20use std::collections::{HashMap, VecDeque};
21use std::ffi::{OsStr, OsString};
22use std::fmt::Write as _;
23use std::io::{BufRead, Read, Write};
24use std::path::{Path, PathBuf};
25use std::process::{Command, Stdio};
26use std::sync::{Mutex, OnceLock, mpsc};
27use std::thread;
28use std::time::{Duration, SystemTime, UNIX_EPOCH};
29use unicode_normalization::UnicodeNormalization;
30use uuid::Uuid;
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub struct ToolEffects {
39 bits: u8,
40}
41
42impl ToolEffects {
43 const READ: u8 = 1 << 0;
44 const WRITE: u8 = 1 << 1;
45 const APPEND: u8 = 1 << 2;
46 const NETWORK: u8 = 1 << 3;
47 const PROCESS: u8 = 1 << 4;
48 const BARRIER: u8 = Self::WRITE | Self::APPEND | Self::PROCESS;
49
50 #[must_use]
52 pub const fn read() -> Self {
53 Self { bits: Self::READ }
54 }
55
56 #[must_use]
58 pub const fn write() -> Self {
59 Self { bits: Self::WRITE }
60 }
61
62 #[must_use]
64 pub const fn append() -> Self {
65 Self { bits: Self::APPEND }
66 }
67
68 #[must_use]
70 pub const fn network() -> Self {
71 Self {
72 bits: Self::NETWORK,
73 }
74 }
75
76 #[must_use]
78 pub const fn process() -> Self {
79 Self {
80 bits: Self::PROCESS,
81 }
82 }
83
84 #[must_use]
86 pub const fn union(self, other: Self) -> Self {
87 Self {
88 bits: self.bits | other.bits,
89 }
90 }
91
92 #[must_use]
94 pub const fn reads(self) -> bool {
95 self.bits & Self::READ != 0
96 }
97
98 #[must_use]
100 pub const fn writes(self) -> bool {
101 self.bits & Self::WRITE != 0
102 }
103
104 #[must_use]
106 pub const fn appends(self) -> bool {
107 self.bits & Self::APPEND != 0
108 }
109
110 #[must_use]
112 pub const fn networks(self) -> bool {
113 self.bits & Self::NETWORK != 0
114 }
115
116 #[must_use]
118 pub const fn processes(self) -> bool {
119 self.bits & Self::PROCESS != 0
120 }
121
122 #[must_use]
124 pub fn labels(self) -> Vec<&'static str> {
125 let mut labels = Vec::with_capacity(5);
126 if self.reads() {
127 labels.push("read");
128 }
129 if self.writes() {
130 labels.push("write");
131 }
132 if self.appends() {
133 labels.push("append");
134 }
135 if self.networks() {
136 labels.push("network");
137 }
138 if self.processes() {
139 labels.push("process");
140 }
141 labels
142 }
143
144 #[must_use]
146 pub const fn parallel_safe(self) -> bool {
147 self.bits != 0 && self.bits & Self::BARRIER == 0
148 }
149
150 #[must_use]
152 pub const fn compatible_with(self, other: Self) -> bool {
153 self.parallel_safe() && other.parallel_safe()
154 }
155}
156
157#[async_trait]
159pub trait Tool: Send + Sync {
160 fn name(&self) -> &str;
162
163 fn label(&self) -> &str;
165
166 fn description(&self) -> &str;
168
169 fn parameters(&self) -> serde_json::Value;
171
172 async fn execute(
178 &self,
179 tool_call_id: &str,
180 input: serde_json::Value,
181 on_update: Option<Box<dyn Fn(ToolUpdate) + Send + Sync>>,
182 ) -> Result<ToolOutput>;
183
184 #[must_use]
188 fn effects(&self) -> ToolEffects {
189 ToolEffects::write()
190 }
191}
192
193#[derive(Debug, Clone, Serialize, Deserialize)]
195#[serde(rename_all = "camelCase")]
196pub struct ToolOutput {
197 pub content: Vec<ContentBlock>,
198 pub details: Option<serde_json::Value>,
199 #[serde(default, skip_serializing_if = "is_false")]
200 pub is_error: bool,
201}
202
203#[allow(clippy::trivially_copy_pass_by_ref)] const fn is_false(value: &bool) -> bool {
205 !*value
206}
207
208#[derive(Debug, Clone, Serialize)]
210#[serde(rename_all = "camelCase")]
211pub struct ToolUpdate {
212 pub content: Vec<ContentBlock>,
213 pub details: Option<serde_json::Value>,
214}
215
216pub const DEFAULT_MAX_LINES: usize = 2000;
222
223pub const DEFAULT_MAX_BYTES: usize = 1_000_000; pub const GREP_MAX_LINE_LENGTH: usize = 500;
228
229pub const DEFAULT_GREP_LIMIT: usize = 100;
231
232pub const DEFAULT_FIND_LIMIT: usize = 1000;
234
235pub const DEFAULT_LS_LIMIT: usize = 500;
237
238pub const LS_SCAN_HARD_LIMIT: usize = 20_000;
240
241pub const READ_TOOL_MAX_BYTES: u64 = 100 * 1024 * 1024;
243
244pub const WRITE_TOOL_MAX_BYTES: usize = 100 * 1024 * 1024;
246
247pub const IMAGE_MAX_BYTES: usize = 4_718_592;
249
250pub const DEFAULT_BASH_TIMEOUT_SECS: u64 = 120;
252
253const BASH_TERMINATE_GRACE_SECS: u64 = 5;
254const BASH_CANCELLATION_SCHEMA_V1: &str = "pi.tool.bash.cancellation.v1";
255
256pub(crate) const BASH_FILE_LIMIT_BYTES: usize = 1024 * 1024 * 1024; const TOOL_OUTPUT_ARTIFACT_SCHEMA_V1: &str = "pi.tool_output_artifact.v1";
260const TOOL_OUTPUT_ARTIFACT_REDACTION_POLICY_V1: &str = "pi.tool_output_artifact.redaction.v1";
261const TOOL_OUTPUT_ARTIFACT_RETENTION_CLASS: &str = "session_scoped_temp_evidence";
262const TOOL_OUTPUT_ARTIFACT_SPILLOVER_REASON: &str = "sourceBytesExceededPreviewThreshold";
263const TOOL_OUTPUT_ARTIFACT_THRESHOLD_BYTES: usize = DEFAULT_MAX_BYTES;
264const TOOL_OUTPUT_ARTIFACT_REDACTION_MAX_BYTES_USIZE: usize = 64 * 1024 * 1024;
265const TOOL_OUTPUT_ARTIFACT_REDACTION_MAX_BYTES: u64 = 64 * 1024 * 1024;
266const TOOL_OUTPUT_ARTIFACT_MAX_BYTES_USIZE: usize = 1024 * 1024 * 1024;
267const TOOL_OUTPUT_ARTIFACT_MAX_BYTES: u64 = 1024 * 1024 * 1024;
268
269#[derive(Debug, Clone, Serialize)]
271#[serde(rename_all = "camelCase")]
272pub struct TruncationResult {
273 pub content: String,
274 pub truncated: bool,
275 #[serde(skip_serializing_if = "Option::is_none")]
276 pub truncated_by: Option<TruncatedBy>,
277 pub total_lines: usize,
278 pub total_bytes: usize,
279 pub output_lines: usize,
280 pub output_bytes: usize,
281 pub last_line_partial: bool,
282 pub first_line_exceeds_limit: bool,
283 pub max_lines: usize,
284 pub max_bytes: usize,
285}
286
287#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
288#[serde(rename_all = "camelCase")]
289pub enum TruncatedBy {
290 Lines,
291 Bytes,
292}
293
294#[derive(Debug, Clone, Copy, PartialEq, Eq)]
295pub enum BashCancellationReason {
296 Timeout,
297 AmbientCancellation,
298}
299
300impl BashCancellationReason {
301 const fn as_str(self) -> &'static str {
302 match self {
303 Self::Timeout => "timeout",
304 Self::AmbientCancellation => "ambient_cancellation",
305 }
306 }
307}
308
309#[allow(clippy::too_many_lines)]
316pub fn truncate_head(
317 content: impl Into<String>,
318 max_lines: usize,
319 max_bytes: usize,
320) -> TruncationResult {
321 let mut content = content.into();
322 let total_bytes = content.len();
323
324 let total_lines = {
325 let nl = memchr::memchr_iter(b'\n', content.as_bytes()).count();
326 if content.is_empty() {
327 0
328 } else if content.ends_with('\n') {
329 nl
330 } else {
331 nl + 1
332 }
333 };
334
335 if max_lines == 0 {
336 let truncated = !content.is_empty();
337 content.clear();
338 return TruncationResult {
339 content,
340 truncated,
341 truncated_by: if truncated {
342 Some(TruncatedBy::Lines)
343 } else {
344 None
345 },
346 total_lines,
347 total_bytes,
348 output_lines: 0,
349 output_bytes: 0,
350 last_line_partial: false,
351 first_line_exceeds_limit: false,
352 max_lines,
353 max_bytes,
354 };
355 }
356
357 if max_bytes == 0 {
358 let truncated = !content.is_empty();
359 let first_line_exceeds_limit = !content.is_empty();
360 content.clear();
361 return TruncationResult {
362 content,
363 truncated,
364 truncated_by: if truncated {
365 Some(TruncatedBy::Bytes)
366 } else {
367 None
368 },
369 total_lines,
370 total_bytes,
371 output_lines: 0,
372 output_bytes: 0,
373 last_line_partial: false,
374 first_line_exceeds_limit,
375 max_lines,
376 max_bytes,
377 };
378 }
379
380 if total_lines <= max_lines && total_bytes <= max_bytes {
381 return TruncationResult {
382 content,
383 truncated: false,
384 truncated_by: None,
385 total_lines,
386 total_bytes,
387 output_lines: total_lines,
388 output_bytes: total_bytes,
389 last_line_partial: false,
390 first_line_exceeds_limit: false,
391 max_lines,
392 max_bytes,
393 };
394 }
395
396 let first_newline = memchr::memchr(b'\n', content.as_bytes());
397 let first_line_bytes = first_newline.unwrap_or(content.len());
398
399 if first_line_bytes > max_bytes {
400 let mut valid_bytes = max_bytes;
401 while valid_bytes > 0 && !content.is_char_boundary(valid_bytes) {
402 valid_bytes -= 1;
403 }
404 content.truncate(valid_bytes);
405 return TruncationResult {
406 content,
407 truncated: true,
408 truncated_by: Some(TruncatedBy::Bytes),
409 total_lines,
410 total_bytes,
411 output_lines: usize::from(valid_bytes > 0),
412 output_bytes: valid_bytes,
413 last_line_partial: true,
414 first_line_exceeds_limit: true,
415 max_lines,
416 max_bytes,
417 };
418 }
419
420 let mut line_count = 0;
421 let mut byte_count = 0;
422 let mut truncated_by = None;
423 let mut current_offset = 0;
424 let mut last_line_partial = false;
425
426 while current_offset < content.len() {
427 if line_count >= max_lines {
428 truncated_by = Some(TruncatedBy::Lines);
429 break;
430 }
431
432 let next_newline = memchr::memchr(b'\n', &content.as_bytes()[current_offset..]);
433 let line_end_without_nl = next_newline.map_or(content.len(), |idx| current_offset + idx);
434 let line_end_with_nl = next_newline.map_or(content.len(), |idx| current_offset + idx + 1);
435
436 if line_end_without_nl > max_bytes {
437 let mut byte_limit = max_bytes.min(content.len());
438 if byte_limit < current_offset {
439 truncated_by = Some(TruncatedBy::Bytes);
440 break;
441 }
442 while byte_limit > current_offset && !content.is_char_boundary(byte_limit) {
443 byte_limit -= 1;
444 }
445 if byte_limit > current_offset {
446 byte_count = byte_limit;
447 line_count += 1;
448 last_line_partial = true;
449 }
450 truncated_by = Some(TruncatedBy::Bytes);
451 break;
452 }
453
454 if line_end_with_nl > max_bytes {
455 if line_end_without_nl > current_offset {
456 byte_count = line_end_without_nl;
457 line_count += 1;
458 }
459 truncated_by = Some(TruncatedBy::Bytes);
460 break;
461 }
462
463 byte_count = line_end_with_nl;
464 line_count += 1;
465 current_offset = line_end_with_nl;
466 }
467
468 content.truncate(byte_count);
469
470 TruncationResult {
471 truncated: truncated_by.is_some(),
472 truncated_by,
473 total_lines,
474 total_bytes,
475 output_lines: line_count,
476 output_bytes: byte_count,
477 last_line_partial,
478 first_line_exceeds_limit: false,
479 max_lines,
480 max_bytes,
481 content,
482 }
483}
484
485#[allow(clippy::too_many_lines)]
491pub fn truncate_tail(
492 content: impl Into<String>,
493 max_lines: usize,
494 max_bytes: usize,
495) -> TruncationResult {
496 let mut content = content.into();
497 let total_bytes = content.len();
498
499 let mut total_lines = memchr::memchr_iter(b'\n', content.as_bytes()).count();
502 if !content.ends_with('\n') && !content.is_empty() {
503 total_lines += 1;
504 }
505 if content.is_empty() {
506 total_lines = 0;
507 }
508
509 if max_lines == 0 {
512 let truncated = !content.is_empty();
513 return TruncationResult {
514 content: String::new(),
515 truncated,
516 truncated_by: if truncated {
517 Some(TruncatedBy::Lines)
518 } else {
519 None
520 },
521 total_lines,
522 total_bytes,
523 output_lines: 0,
524 output_bytes: 0,
525 last_line_partial: false,
526 first_line_exceeds_limit: false,
527 max_lines,
528 max_bytes,
529 };
530 }
531
532 if total_lines <= max_lines && total_bytes <= max_bytes {
534 return TruncationResult {
535 content,
536 truncated: false,
537 truncated_by: None,
538 total_lines,
539 total_bytes,
540 output_lines: total_lines,
541 output_bytes: total_bytes,
542 last_line_partial: false,
543 first_line_exceeds_limit: false,
544 max_lines,
545 max_bytes,
546 };
547 }
548
549 let mut line_count = 0usize;
550 let mut byte_count = 0usize;
551 let mut start_idx = content.len();
552 let mut partial_output: Option<String> = None;
553 let mut partial_line_truncated = false;
554 let mut truncated_by = None;
555 let mut last_line_partial = false;
556
557 {
559 let bytes = content.as_bytes();
560 let mut search_limit = bytes.len();
564 if search_limit > 0 && bytes[search_limit - 1] == b'\n' {
565 search_limit -= 1;
566 }
567
568 loop {
569 let prev_newline = memchr::memrchr(b'\n', &bytes[..search_limit]);
571 let line_start = prev_newline.map_or(0, |idx| idx + 1);
572
573 let added_bytes = start_idx - line_start;
577
578 if byte_count + added_bytes > max_bytes {
579 let remaining = max_bytes.saturating_sub(byte_count);
583 if remaining > 0 {
584 let chunk = &content[line_start..start_idx];
585 let truncated_chunk = truncate_string_to_bytes_from_end(chunk, remaining);
586 if !truncated_chunk.is_empty() {
587 partial_output = Some(truncated_chunk);
588 partial_line_truncated = true;
589 if line_count == 0 {
590 last_line_partial = true;
591 }
592 }
593 }
594 truncated_by = Some(TruncatedBy::Bytes);
595 break;
596 }
597
598 line_count += 1;
599 byte_count += added_bytes;
600 start_idx = line_start;
601
602 if line_count >= max_lines {
603 truncated_by = Some(TruncatedBy::Lines);
604 break;
605 }
606
607 if line_start == 0 {
608 break;
609 }
610
611 search_limit = line_start - 1;
617 }
618 } let partial_suffix = if partial_line_truncated {
623 Some(content[start_idx..].to_string())
624 } else {
625 None
626 };
627
628 let mut output = partial_output.unwrap_or_else(|| {
629 drop(content.drain(..start_idx));
630 content
631 });
632
633 if let Some(suffix) = partial_suffix {
648 output.push_str(&suffix);
654 let mut count = memchr::memchr_iter(b'\n', output.as_bytes()).count();
657 if !output.ends_with('\n') && !output.is_empty() {
658 count += 1;
659 }
660 if output.is_empty() {
661 count = 0;
662 }
663 line_count = count;
664 }
665
666 let output_bytes = output.len();
667
668 TruncationResult {
669 content: output,
670 truncated: truncated_by.is_some(),
671 truncated_by,
672 total_lines,
673 total_bytes,
674 output_lines: line_count,
675 output_bytes,
676 last_line_partial,
677 first_line_exceeds_limit: false,
678 max_lines,
679 max_bytes,
680 }
681}
682
683fn truncate_string_to_bytes_from_end(s: &str, max_bytes: usize) -> String {
685 let bytes = s.as_bytes();
686 if bytes.len() <= max_bytes {
687 return s.to_string();
688 }
689
690 let mut start = bytes.len().saturating_sub(max_bytes);
691 while start < bytes.len() && (bytes[start] & 0b1100_0000) == 0b1000_0000 {
692 start += 1;
693 }
694
695 std::str::from_utf8(&bytes[start..])
696 .map(str::to_string)
697 .unwrap_or_default()
698}
699
700struct HeadTruncatingLineWriter {
701 content: String,
702 max_bytes: usize,
703 total_lines: usize,
704 total_bytes: usize,
705 output_lines: usize,
706 truncated: bool,
707 last_line_partial: bool,
708 first_line_exceeds_limit: bool,
709}
710
711impl HeadTruncatingLineWriter {
712 fn new(max_bytes: usize) -> Self {
713 Self {
714 content: String::with_capacity(max_bytes.min(8192)),
715 max_bytes,
716 total_lines: 0,
717 total_bytes: 0,
718 output_lines: 0,
719 truncated: false,
720 last_line_partial: false,
721 first_line_exceeds_limit: false,
722 }
723 }
724
725 fn push_line(&mut self, line: &str) {
726 debug_assert!(!line.contains('\n'));
727
728 let line_index = self.total_lines;
729 let separator_len = usize::from(line_index > 0);
730 let piece_bytes = separator_len.saturating_add(line.len());
731 self.total_lines = self.total_lines.saturating_add(1);
732 self.total_bytes = self.total_bytes.saturating_add(piece_bytes);
733
734 if self.truncated {
735 return;
736 }
737
738 if self.max_bytes == 0 {
739 self.truncated = true;
740 self.first_line_exceeds_limit = line_index == 0 && !line.is_empty();
741 return;
742 }
743
744 let remaining = self.max_bytes.saturating_sub(self.content.len());
745 if piece_bytes <= remaining {
746 if separator_len > 0 {
747 self.content.push('\n');
748 }
749 self.content.push_str(line);
750 self.output_lines = self.output_lines.saturating_add(1);
751 return;
752 }
753
754 self.truncated = true;
755 if line_index == 0 && line.len() > self.max_bytes {
756 self.first_line_exceeds_limit = true;
757 }
758
759 let line_budget = if separator_len > 0 {
760 if remaining == 0 {
761 return;
762 }
763 self.content.push('\n');
764 remaining - 1
765 } else {
766 remaining
767 };
768
769 let valid_bytes = utf8_prefix_len(line, line_budget);
770 if valid_bytes > 0 {
771 self.content.push_str(&line[..valid_bytes]);
772 self.output_lines = self.output_lines.saturating_add(1);
773 self.last_line_partial = valid_bytes < line.len();
774 }
775 }
776
777 fn finish(self) -> TruncationResult {
778 let output_bytes = self.content.len();
779 TruncationResult {
780 content: self.content,
781 truncated: self.truncated,
782 truncated_by: if self.truncated {
783 Some(TruncatedBy::Bytes)
784 } else {
785 None
786 },
787 total_lines: self.total_lines,
788 total_bytes: self.total_bytes,
789 output_lines: self.output_lines,
790 output_bytes,
791 last_line_partial: self.last_line_partial,
792 first_line_exceeds_limit: self.first_line_exceeds_limit,
793 max_lines: usize::MAX,
794 max_bytes: self.max_bytes,
795 }
796 }
797}
798
799fn utf8_prefix_len(s: &str, max_bytes: usize) -> usize {
800 let mut valid_bytes = max_bytes.min(s.len());
801 while valid_bytes > 0 && !s.is_char_boundary(valid_bytes) {
802 valid_bytes -= 1;
803 }
804 valid_bytes
805}
806
807#[derive(Debug, Clone, Serialize)]
808#[serde(rename_all = "camelCase")]
809struct ToolOutputArtifactRef {
810 schema: &'static str,
811 id: String,
812 tool_name: String,
813 source_kind: String,
814 #[serde(skip_serializing_if = "Option::is_none")]
815 session_id: Option<String>,
816 path: String,
817 metadata_path: String,
818 sha256: String,
819 byte_count: u64,
820 line_count: usize,
821 preview_bytes: usize,
822 content_type: &'static str,
823 retention_class: &'static str,
824 spillover_reason: &'static str,
825 redaction_summary: ToolOutputArtifactRedactionSummary,
826 safe_delete_candidate: bool,
827}
828
829#[derive(Debug, Clone, Serialize)]
830#[serde(rename_all = "camelCase")]
831struct ToolOutputArtifactRedactionSummary {
832 policy: &'static str,
833 status: &'static str,
834 redacted_count: usize,
835 fields: Vec<String>,
836 raw_secret_bytes_emitted: usize,
837 binary_suspect: bool,
838 max_redaction_bytes: u64,
839}
840
841struct RedactedToolOutputArtifact {
842 bytes: Vec<u8>,
843 summary: ToolOutputArtifactRedactionSummary,
844}
845
846fn tool_output_artifact_root() -> PathBuf {
847 std::env::var_os("PI_TOOL_OUTPUT_ARTIFACT_DIR").map_or_else(
848 || Config::global_dir().join("tool-output-artifacts"),
849 PathBuf::from,
850 )
851}
852
853static TOOL_OUTPUT_ARTIFACT_SESSIONS: OnceLock<Mutex<HashMap<String, String>>> = OnceLock::new();
854
855fn tool_output_artifact_sessions() -> &'static Mutex<HashMap<String, String>> {
856 TOOL_OUTPUT_ARTIFACT_SESSIONS.get_or_init(|| Mutex::new(HashMap::new()))
857}
858
859pub(crate) struct ToolOutputArtifactSessionGuard {
860 tool_call_id: String,
861 previous_session_id: Option<String>,
862 active: bool,
863}
864
865impl Drop for ToolOutputArtifactSessionGuard {
866 fn drop(&mut self) {
867 if !self.active {
868 return;
869 }
870 let Ok(mut sessions) = tool_output_artifact_sessions().lock() else {
871 return;
872 };
873 if let Some(previous) = self.previous_session_id.take() {
874 sessions.insert(self.tool_call_id.clone(), previous);
875 } else {
876 sessions.remove(&self.tool_call_id);
877 }
878 }
879}
880
881pub(crate) fn register_tool_output_artifact_session(
882 tool_call_id: &str,
883 session_id: &str,
884) -> ToolOutputArtifactSessionGuard {
885 if session_id.is_empty() {
886 return ToolOutputArtifactSessionGuard {
887 tool_call_id: String::new(),
888 previous_session_id: None,
889 active: false,
890 };
891 }
892 let previous_session_id = tool_output_artifact_sessions()
893 .lock()
894 .ok()
895 .and_then(|mut sessions| sessions.insert(tool_call_id.to_string(), session_id.to_string()));
896 ToolOutputArtifactSessionGuard {
897 tool_call_id: tool_call_id.to_string(),
898 previous_session_id,
899 active: true,
900 }
901}
902
903fn tool_output_artifact_session_id(tool_call_id: &str) -> Option<String> {
904 tool_output_artifact_sessions()
905 .lock()
906 .ok()
907 .and_then(|sessions| sessions.get(tool_call_id).cloned())
908}
909
910fn tool_output_artifact_scope_dir(root: &Path, tool_call_id: &str) -> (PathBuf, Option<String>) {
911 let call_scope = sanitize_artifact_scope(tool_call_id);
912 if let Some(session_id) = tool_output_artifact_session_id(tool_call_id) {
913 (
914 root.join(sanitize_artifact_scope(&session_id))
915 .join(call_scope),
916 Some(session_id),
917 )
918 } else {
919 (root.join(call_scope), None)
920 }
921}
922
923fn sanitize_artifact_scope(scope: &str) -> String {
924 let mut out = String::new();
925 for ch in scope.chars().take(96) {
926 if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_') {
927 out.push(ch);
928 } else {
929 out.push('_');
930 }
931 }
932 if out.trim_matches('_').is_empty() {
933 "tool-call".to_string()
934 } else {
935 out
936 }
937}
938
939fn artifact_line_count(bytes: &[u8]) -> usize {
940 if bytes.is_empty() {
941 0
942 } else {
943 memchr::memchr_iter(b'\n', bytes).count() + usize::from(!bytes.ends_with(b"\n"))
944 }
945}
946
947fn artifact_details_object(
948 details: &mut Option<serde_json::Value>,
949) -> &mut serde_json::Map<String, serde_json::Value> {
950 let value = details.get_or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
951 if !value.is_object() {
952 *value = serde_json::Value::Object(serde_json::Map::new());
953 }
954 value
955 .as_object_mut()
956 .expect("details value forced to object")
957}
958
959fn normalize_redaction_field(field: &str) -> String {
960 let mut out = String::new();
961 let mut previous_underscore = false;
962 for ch in field.chars() {
963 let normalized = if ch.is_ascii_alphanumeric() {
964 previous_underscore = false;
965 ch.to_ascii_lowercase()
966 } else if previous_underscore {
967 continue;
968 } else {
969 previous_underscore = true;
970 '_'
971 };
972 out.push(normalized);
973 }
974 out.trim_matches('_').to_string()
975}
976
977fn record_redacted_field(fields: &mut Vec<String>, field: &str) {
978 let field = normalize_redaction_field(field);
979 if !field.is_empty() && !fields.iter().any(|existing| existing == &field) {
980 fields.push(field);
981 }
982}
983
984fn artifact_sensitive_key_value_regex() -> &'static regex::Regex {
985 static RE: OnceLock<regex::Regex> = OnceLock::new();
986 RE.get_or_init(|| {
987 regex::Regex::new(
988 r#"(?i)\b([A-Za-z_][A-Za-z0-9_.-]*(?:api[_-]?key|token|secret|password|passwd|credential|authorization)[A-Za-z0-9_.-]*)(\s*[:=]\s*)("[^"\r\n]*"|'[^'\r\n]*'|[^\s,;}]+)"#,
989 )
990 .expect("valid artifact key-value redaction regex")
991 })
992}
993
994fn artifact_bearer_token_regex() -> &'static regex::Regex {
995 static RE: OnceLock<regex::Regex> = OnceLock::new();
996 RE.get_or_init(|| {
997 regex::Regex::new(r"(?i)\b(Bearer\s+)([A-Za-z0-9._~+/=-]{8,})")
998 .expect("valid artifact bearer redaction regex")
999 })
1000}
1001
1002fn artifact_token_value_regex() -> &'static regex::Regex {
1003 static RE: OnceLock<regex::Regex> = OnceLock::new();
1004 RE.get_or_init(|| {
1005 regex::Regex::new(
1006 r"\b(sk-[A-Za-z0-9][A-Za-z0-9_-]{10,}|gh[pousr]_[A-Za-z0-9_]{10,}|AKIA[0-9A-Z]{12,})\b",
1007 )
1008 .expect("valid artifact token value redaction regex")
1009 })
1010}
1011
1012fn redacted_literal_for_value(value: &str) -> &'static str {
1013 if value.starts_with('"') && value.ends_with('"') {
1014 "\"[REDACTED]\""
1015 } else if value.starts_with('\'') && value.ends_with('\'') {
1016 "'[REDACTED]'"
1017 } else {
1018 "[REDACTED]"
1019 }
1020}
1021
1022fn redact_tool_output_artifact_text(
1023 text: &str,
1024 binary_suspect: bool,
1025) -> RedactedToolOutputArtifact {
1026 let mut fields = Vec::new();
1027 let mut redacted_count = 0usize;
1028
1029 let redacted = artifact_sensitive_key_value_regex()
1030 .replace_all(text, |caps: ®ex::Captures<'_>| {
1031 let key = caps.get(1).map_or("", |m| m.as_str());
1032 let sep = caps.get(2).map_or("", |m| m.as_str());
1033 let value = caps.get(3).map_or("", |m| m.as_str());
1034 if value == "[REDACTED]" || value == "\"[REDACTED]\"" || value == "'[REDACTED]'" {
1035 caps.get(0).map_or("", |m| m.as_str()).to_string()
1036 } else {
1037 redacted_count = redacted_count.saturating_add(1);
1038 record_redacted_field(&mut fields, key);
1039 format!("{key}{sep}{}", redacted_literal_for_value(value))
1040 }
1041 })
1042 .to_string();
1043
1044 let redacted = artifact_bearer_token_regex()
1045 .replace_all(&redacted, |caps: ®ex::Captures<'_>| {
1046 redacted_count = redacted_count.saturating_add(1);
1047 record_redacted_field(&mut fields, "authorization");
1048 let prefix = caps.get(1).map_or("", |m| m.as_str());
1049 format!("{prefix}[REDACTED]")
1050 })
1051 .to_string();
1052
1053 let redacted = artifact_token_value_regex()
1054 .replace_all(&redacted, |_caps: ®ex::Captures<'_>| {
1055 redacted_count = redacted_count.saturating_add(1);
1056 record_redacted_field(&mut fields, "tokenValue");
1057 "[REDACTED]".to_string()
1058 })
1059 .to_string();
1060
1061 fields.sort();
1062 let raw_secret_bytes_emitted = estimate_raw_secret_bytes(&redacted);
1063 let summary = ToolOutputArtifactRedactionSummary {
1064 policy: TOOL_OUTPUT_ARTIFACT_REDACTION_POLICY_V1,
1065 status: if raw_secret_bytes_emitted > 0 {
1066 "unsafe"
1067 } else if redacted_count > 0 {
1068 "redacted"
1069 } else {
1070 "clean"
1071 },
1072 redacted_count,
1073 fields,
1074 raw_secret_bytes_emitted,
1075 binary_suspect,
1076 max_redaction_bytes: TOOL_OUTPUT_ARTIFACT_REDACTION_MAX_BYTES,
1077 };
1078
1079 RedactedToolOutputArtifact {
1080 bytes: redacted.into_bytes(),
1081 summary,
1082 }
1083}
1084
1085fn estimate_raw_secret_bytes(text: &str) -> usize {
1086 let key_value_bytes = artifact_sensitive_key_value_regex()
1087 .captures_iter(text)
1088 .filter_map(|caps| {
1089 let value = caps.get(3)?.as_str();
1090 if value == "[REDACTED]" || value == "\"[REDACTED]\"" || value == "'[REDACTED]'" {
1091 None
1092 } else {
1093 caps.get(0).map(|m| m.as_str().len())
1094 }
1095 })
1096 .sum::<usize>();
1097 let bearer_bytes = artifact_bearer_token_regex()
1098 .find_iter(text)
1099 .map(|m| m.as_str().len())
1100 .sum::<usize>();
1101 let token_bytes = artifact_token_value_regex()
1102 .find_iter(text)
1103 .map(|m| m.as_str().len())
1104 .sum::<usize>();
1105 key_value_bytes
1106 .saturating_add(bearer_bytes)
1107 .saturating_add(token_bytes)
1108}
1109
1110fn redact_tool_output_artifact_bytes(bytes: &[u8]) -> std::io::Result<RedactedToolOutputArtifact> {
1111 let binary_suspect =
1112 memchr::memchr(b'\0', bytes).is_some() || std::str::from_utf8(bytes).is_err();
1113 let text = String::from_utf8_lossy(bytes);
1114 let redacted = redact_tool_output_artifact_text(text.as_ref(), binary_suspect);
1115 if redacted.summary.raw_secret_bytes_emitted > 0 {
1116 return Err(std::io::Error::new(
1117 std::io::ErrorKind::InvalidData,
1118 "artifact redaction failed closed: raw secret-looking bytes remain",
1119 ));
1120 }
1121 Ok(redacted)
1122}
1123
1124fn ensure_artifact_path_under_root(root: &Path, path: &Path) -> std::io::Result<()> {
1125 if path.starts_with(root) {
1126 Ok(())
1127 } else {
1128 Err(std::io::Error::new(
1129 std::io::ErrorKind::PermissionDenied,
1130 format!(
1131 "artifact path {} is outside artifact root {}",
1132 path.display(),
1133 root.display()
1134 ),
1135 ))
1136 }
1137}
1138
1139fn write_artifact_file_if_absent(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
1140 match std::fs::OpenOptions::new()
1141 .write(true)
1142 .create_new(true)
1143 .open(path)
1144 {
1145 Ok(mut file) => {
1146 file.write_all(bytes)?;
1147 file.sync_all()?;
1148 Ok(())
1149 }
1150 Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => Ok(()),
1151 Err(err) => Err(err),
1152 }
1153}
1154
1155fn write_text_tool_output_artifact_at_root(
1156 root: &Path,
1157 tool_name: &str,
1158 tool_call_id: &str,
1159 source_kind: &str,
1160 full_text: &str,
1161 preview_bytes: usize,
1162) -> std::io::Result<ToolOutputArtifactRef> {
1163 let bytes = full_text.as_bytes();
1164 if bytes.len() > TOOL_OUTPUT_ARTIFACT_MAX_BYTES_USIZE {
1165 return Err(std::io::Error::new(
1166 std::io::ErrorKind::InvalidData,
1167 format!(
1168 "artifact source exceeds {} hard limit",
1169 format_size(TOOL_OUTPUT_ARTIFACT_MAX_BYTES_USIZE)
1170 ),
1171 ));
1172 }
1173 if bytes.len() > TOOL_OUTPUT_ARTIFACT_REDACTION_MAX_BYTES_USIZE {
1174 return Err(std::io::Error::new(
1175 std::io::ErrorKind::InvalidData,
1176 format!(
1177 "artifact source exceeds {} redaction limit",
1178 format_size(TOOL_OUTPUT_ARTIFACT_REDACTION_MAX_BYTES_USIZE)
1179 ),
1180 ));
1181 }
1182 let redacted = redact_tool_output_artifact_bytes(bytes)?;
1183 let bytes = redacted.bytes.as_slice();
1184 let sha256 = format!("{:x}", sha2::Sha256::digest(bytes));
1185 let (scope_dir, session_id) = tool_output_artifact_scope_dir(root, tool_call_id);
1186 std::fs::create_dir_all(&scope_dir)?;
1187
1188 let id = format!("tool-artifact-{}", &sha256[..16]);
1189 let content_path = scope_dir.join(format!("{sha256}.txt"));
1190 let metadata_path = scope_dir.join(format!("{sha256}.json"));
1191 ensure_artifact_path_under_root(root, &content_path)?;
1192 ensure_artifact_path_under_root(root, &metadata_path)?;
1193 write_artifact_file_if_absent(&content_path, bytes)?;
1194
1195 let artifact = ToolOutputArtifactRef {
1196 schema: TOOL_OUTPUT_ARTIFACT_SCHEMA_V1,
1197 id,
1198 tool_name: tool_name.to_string(),
1199 source_kind: source_kind.to_string(),
1200 session_id,
1201 path: content_path.display().to_string(),
1202 metadata_path: metadata_path.display().to_string(),
1203 sha256,
1204 byte_count: bytes.len().try_into().unwrap_or(u64::MAX),
1205 line_count: artifact_line_count(bytes),
1206 preview_bytes,
1207 content_type: "text/plain; charset=utf-8",
1208 retention_class: TOOL_OUTPUT_ARTIFACT_RETENTION_CLASS,
1209 spillover_reason: TOOL_OUTPUT_ARTIFACT_SPILLOVER_REASON,
1210 redaction_summary: redacted.summary,
1211 safe_delete_candidate: true,
1212 };
1213 let metadata = serde_json::to_vec_pretty(&artifact).map_err(std::io::Error::other)?;
1214 write_artifact_file_if_absent(&metadata_path, &metadata)?;
1215 Ok(artifact)
1216}
1217
1218fn copy_text_tool_output_artifact_from_path_at_root(
1219 root: &Path,
1220 tool_name: &str,
1221 tool_call_id: &str,
1222 source_kind: &str,
1223 source_path: &Path,
1224 preview_bytes: usize,
1225) -> std::io::Result<ToolOutputArtifactRef> {
1226 let metadata = std::fs::metadata(source_path)?;
1227 if metadata.len() > TOOL_OUTPUT_ARTIFACT_MAX_BYTES {
1228 return Err(std::io::Error::new(
1229 std::io::ErrorKind::InvalidData,
1230 format!(
1231 "artifact source exceeds {} hard limit",
1232 format_size(TOOL_OUTPUT_ARTIFACT_MAX_BYTES_USIZE)
1233 ),
1234 ));
1235 }
1236 if metadata.len() > TOOL_OUTPUT_ARTIFACT_REDACTION_MAX_BYTES {
1237 return Err(std::io::Error::new(
1238 std::io::ErrorKind::InvalidData,
1239 format!(
1240 "artifact source exceeds {} redaction limit",
1241 format_size(TOOL_OUTPUT_ARTIFACT_REDACTION_MAX_BYTES_USIZE)
1242 ),
1243 ));
1244 }
1245
1246 let mut source = std::fs::File::open(source_path)?;
1247 let mut source_bytes = Vec::with_capacity(usize::try_from(metadata.len()).unwrap_or(0));
1248 source.read_to_end(&mut source_bytes)?;
1249 let redacted = redact_tool_output_artifact_bytes(&source_bytes)?;
1250 let bytes = redacted.bytes.as_slice();
1251
1252 let sha256 = format!("{:x}", sha2::Sha256::digest(bytes));
1253 let (scope_dir, session_id) = tool_output_artifact_scope_dir(root, tool_call_id);
1254 std::fs::create_dir_all(&scope_dir)?;
1255 let id = format!("tool-artifact-{}", &sha256[..16]);
1256 let content_path = scope_dir.join(format!("{sha256}.txt"));
1257 let metadata_path = scope_dir.join(format!("{sha256}.json"));
1258 ensure_artifact_path_under_root(root, &content_path)?;
1259 ensure_artifact_path_under_root(root, &metadata_path)?;
1260 write_artifact_file_if_absent(&content_path, bytes)?;
1261
1262 let artifact = ToolOutputArtifactRef {
1263 schema: TOOL_OUTPUT_ARTIFACT_SCHEMA_V1,
1264 id,
1265 tool_name: tool_name.to_string(),
1266 source_kind: source_kind.to_string(),
1267 session_id,
1268 path: content_path.display().to_string(),
1269 metadata_path: metadata_path.display().to_string(),
1270 sha256,
1271 byte_count: bytes.len().try_into().unwrap_or(u64::MAX),
1272 line_count: artifact_line_count(bytes),
1273 preview_bytes,
1274 content_type: "text/plain; charset=utf-8",
1275 retention_class: TOOL_OUTPUT_ARTIFACT_RETENTION_CLASS,
1276 spillover_reason: TOOL_OUTPUT_ARTIFACT_SPILLOVER_REASON,
1277 redaction_summary: redacted.summary,
1278 safe_delete_candidate: true,
1279 };
1280 let metadata = serde_json::to_vec_pretty(&artifact).map_err(std::io::Error::other)?;
1281 write_artifact_file_if_absent(&metadata_path, &metadata)?;
1282 Ok(artifact)
1283}
1284
1285fn append_tool_output_artifact_notice(output_text: &mut String, artifact: &ToolOutputArtifactRef) {
1286 let _ = write!(
1287 output_text,
1288 "\n\n[Full tool output artifact: {} ({} bytes, {} lines, sha256 {}). Use read on this path to inspect more.]",
1289 artifact.path, artifact.byte_count, artifact.line_count, artifact.sha256,
1290 );
1291}
1292
1293fn append_artifact_source_line(full_text: &mut String, line: &str) {
1294 if !full_text.is_empty() {
1295 full_text.push('\n');
1296 }
1297 full_text.push_str(line);
1298}
1299
1300fn record_tool_output_artifact_error(
1301 output_text: &mut String,
1302 details: &mut Option<serde_json::Value>,
1303 error: &std::io::Error,
1304) {
1305 let _ = write!(
1306 output_text,
1307 "\n\n[Tool output artifact persistence failed: {error}. Showing the bounded preview only.]"
1308 );
1309 artifact_details_object(details).insert(
1310 "artifactError".to_string(),
1311 serde_json::json!({
1312 "schema": TOOL_OUTPUT_ARTIFACT_SCHEMA_V1,
1313 "message": error.to_string(),
1314 }),
1315 );
1316}
1317
1318fn attach_text_artifact_if_needed_at_root(
1319 root: &Path,
1320 output_text: &mut String,
1321 details: &mut Option<serde_json::Value>,
1322 tool_name: &str,
1323 tool_call_id: &str,
1324 source_kind: &str,
1325 full_text: &str,
1326) -> bool {
1327 if full_text.len() <= TOOL_OUTPUT_ARTIFACT_THRESHOLD_BYTES {
1328 return false;
1329 }
1330 match write_text_tool_output_artifact_at_root(
1331 root,
1332 tool_name,
1333 tool_call_id,
1334 source_kind,
1335 full_text,
1336 output_text.len(),
1337 ) {
1338 Ok(artifact) => {
1339 append_tool_output_artifact_notice(output_text, &artifact);
1340 artifact_details_object(details).insert(
1341 "artifact".to_string(),
1342 serde_json::to_value(&artifact).expect("artifact ref serializes"),
1343 );
1344 true
1345 }
1346 Err(err) => {
1347 record_tool_output_artifact_error(output_text, details, &err);
1348 false
1349 }
1350 }
1351}
1352
1353fn attach_text_artifact_if_needed(
1354 output_text: &mut String,
1355 details: &mut Option<serde_json::Value>,
1356 tool_name: &str,
1357 tool_call_id: &str,
1358 source_kind: &str,
1359 full_text: &str,
1360) -> bool {
1361 let root = tool_output_artifact_root();
1362 attach_text_artifact_if_needed_at_root(
1363 &root,
1364 output_text,
1365 details,
1366 tool_name,
1367 tool_call_id,
1368 source_kind,
1369 full_text,
1370 )
1371}
1372
1373fn attach_text_artifact_if_needed_with_root(
1374 root: Option<&Path>,
1375 output_text: &mut String,
1376 details: &mut Option<serde_json::Value>,
1377 tool_name: &str,
1378 tool_call_id: &str,
1379 source_kind: &str,
1380 full_text: &str,
1381) -> bool {
1382 if let Some(root) = root {
1383 attach_text_artifact_if_needed_at_root(
1384 root,
1385 output_text,
1386 details,
1387 tool_name,
1388 tool_call_id,
1389 source_kind,
1390 full_text,
1391 )
1392 } else {
1393 attach_text_artifact_if_needed(
1394 output_text,
1395 details,
1396 tool_name,
1397 tool_call_id,
1398 source_kind,
1399 full_text,
1400 )
1401 }
1402}
1403
1404fn attach_text_artifact_from_path_if_needed_at_root(
1405 root: &Path,
1406 output_text: &mut String,
1407 details: &mut Option<serde_json::Value>,
1408 tool_name: &str,
1409 tool_call_id: &str,
1410 source_kind: &str,
1411 source_path: &Path,
1412) -> bool {
1413 let Ok(metadata) = std::fs::metadata(source_path) else {
1414 return false;
1415 };
1416 if metadata.len() <= u64::try_from(TOOL_OUTPUT_ARTIFACT_THRESHOLD_BYTES).unwrap_or(u64::MAX) {
1417 return false;
1418 }
1419 match copy_text_tool_output_artifact_from_path_at_root(
1420 root,
1421 tool_name,
1422 tool_call_id,
1423 source_kind,
1424 source_path,
1425 output_text.len(),
1426 ) {
1427 Ok(artifact) => {
1428 append_tool_output_artifact_notice(output_text, &artifact);
1429 artifact_details_object(details).insert(
1430 "artifact".to_string(),
1431 serde_json::to_value(&artifact).expect("artifact ref serializes"),
1432 );
1433 true
1434 }
1435 Err(err) => {
1436 record_tool_output_artifact_error(output_text, details, &err);
1437 false
1438 }
1439 }
1440}
1441
1442fn attach_text_artifact_from_path_if_needed(
1443 output_text: &mut String,
1444 details: &mut Option<serde_json::Value>,
1445 tool_name: &str,
1446 tool_call_id: &str,
1447 source_kind: &str,
1448 source_path: &Path,
1449) -> bool {
1450 let root = tool_output_artifact_root();
1451 attach_text_artifact_from_path_if_needed_at_root(
1452 &root,
1453 output_text,
1454 details,
1455 tool_name,
1456 tool_call_id,
1457 source_kind,
1458 source_path,
1459 )
1460}
1461
1462fn attach_text_artifact_from_path_if_needed_with_root(
1463 root: Option<&Path>,
1464 output_text: &mut String,
1465 details: &mut Option<serde_json::Value>,
1466 tool_name: &str,
1467 tool_call_id: &str,
1468 source_kind: &str,
1469 source_path: &Path,
1470) -> bool {
1471 if let Some(root) = root {
1472 attach_text_artifact_from_path_if_needed_at_root(
1473 root,
1474 output_text,
1475 details,
1476 tool_name,
1477 tool_call_id,
1478 source_kind,
1479 source_path,
1480 )
1481 } else {
1482 attach_text_artifact_from_path_if_needed(
1483 output_text,
1484 details,
1485 tool_name,
1486 tool_call_id,
1487 source_kind,
1488 source_path,
1489 )
1490 }
1491}
1492
1493const TOOL_OUTPUT_CACHE_MAX_ENTRIES: usize = 128;
1494const TOOL_OUTPUT_CACHE_MAX_BYTES: usize = 8 * 1024 * 1024;
1495const TOOL_OUTPUT_CACHE_MAX_ENTRY_BYTES: usize = DEFAULT_MAX_BYTES + 64 * 1024;
1496const TOOL_OUTPUT_CACHE_MAX_FINGERPRINT_FILES: usize = 2048;
1497const TOOL_OUTPUT_CACHE_MAX_FINGERPRINT_BYTES: u64 = 8 * 1024 * 1024;
1498const TOOL_OUTPUT_CACHE_MAX_FILE_HASH_BYTES: u64 = 2 * 1024 * 1024;
1499
1500#[derive(Debug, Clone, PartialEq, Eq)]
1501struct ToolCacheDependency {
1502 path: PathBuf,
1503 fingerprint: [u8; 32],
1504}
1505
1506#[derive(Debug, Clone, Copy)]
1507enum ToolCacheFingerprintMode {
1508 FileContent,
1509 DirectoryImmediate,
1510 DirectoryRecursive,
1511}
1512
1513#[derive(Debug, Clone)]
1514struct CachedToolOutput {
1515 deps: Vec<ToolCacheDependency>,
1516 output: ToolOutput,
1517 weight: usize,
1518 generation: u64,
1519}
1520
1521#[cfg(test)]
1522#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1523struct ToolOutputCacheStats {
1524 hits: usize,
1525 misses: usize,
1526 inserts: usize,
1527 invalidations: usize,
1528 disabled: usize,
1529 side_effect_accesses: usize,
1530 side_effect_insert_attempts: usize,
1531}
1532
1533#[derive(Debug, Default)]
1534struct ToolOutputCache {
1535 entries: HashMap<String, CachedToolOutput>,
1536 order: VecDeque<(String, u64)>,
1537 total_bytes: usize,
1538 generation: u64,
1539 #[cfg(test)]
1540 stats: ToolOutputCacheStats,
1541}
1542
1543impl ToolOutputCache {
1544 fn get(&mut self, key: &str, deps: &[ToolCacheDependency]) -> Option<ToolOutput> {
1545 self.generation = self.generation.saturating_add(1);
1546 let generation = self.generation;
1547 #[cfg(test)]
1548 {
1549 if is_side_effect_tool_cache_key(key) {
1550 self.stats.side_effect_accesses = self.stats.side_effect_accesses.saturating_add(1);
1551 }
1552 }
1553
1554 if self
1555 .entries
1556 .get(key)
1557 .is_some_and(|entry| entry.deps == deps)
1558 {
1559 let entry = self.entries.get_mut(key)?;
1560 entry.generation = generation;
1561 self.order.push_back((key.to_string(), generation));
1562 #[cfg(test)]
1563 {
1564 self.stats.hits = self.stats.hits.saturating_add(1);
1565 }
1566 return Some(entry.output.clone());
1567 }
1568
1569 if let Some(removed) = self.entries.remove(key) {
1570 self.total_bytes = self.total_bytes.saturating_sub(removed.weight);
1571 #[cfg(test)]
1572 {
1573 self.stats.invalidations = self.stats.invalidations.saturating_add(1);
1574 }
1575 } else {
1576 #[cfg(test)]
1577 {
1578 self.stats.misses = self.stats.misses.saturating_add(1);
1579 }
1580 }
1581
1582 None
1583 }
1584
1585 fn insert(
1586 &mut self,
1587 key: String,
1588 deps: Vec<ToolCacheDependency>,
1589 output: ToolOutput,
1590 weight: usize,
1591 ) {
1592 if weight == 0 || weight > TOOL_OUTPUT_CACHE_MAX_ENTRY_BYTES {
1593 #[cfg(test)]
1594 {
1595 self.stats.disabled = self.stats.disabled.saturating_add(1);
1596 }
1597 return;
1598 }
1599
1600 #[cfg(test)]
1601 {
1602 if is_side_effect_tool_cache_key(&key) {
1603 self.stats.side_effect_insert_attempts =
1604 self.stats.side_effect_insert_attempts.saturating_add(1);
1605 }
1606 }
1607
1608 if let Some(removed) = self.entries.remove(&key) {
1609 self.total_bytes = self.total_bytes.saturating_sub(removed.weight);
1610 }
1611
1612 self.generation = self.generation.saturating_add(1);
1613 let generation = self.generation;
1614 self.total_bytes = self.total_bytes.saturating_add(weight);
1615 self.order.push_back((key.clone(), generation));
1616 self.entries.insert(
1617 key,
1618 CachedToolOutput {
1619 deps,
1620 output,
1621 weight,
1622 generation,
1623 },
1624 );
1625 #[cfg(test)]
1626 {
1627 self.stats.inserts = self.stats.inserts.saturating_add(1);
1628 }
1629 self.evict_to_limits();
1630 }
1631
1632 fn evict_to_limits(&mut self) {
1633 while self.entries.len() > TOOL_OUTPUT_CACHE_MAX_ENTRIES
1634 || self.total_bytes > TOOL_OUTPUT_CACHE_MAX_BYTES
1635 {
1636 let Some((key, generation)) = self.order.pop_front() else {
1637 break;
1638 };
1639 if self
1640 .entries
1641 .get(&key)
1642 .is_some_and(|entry| entry.generation == generation)
1643 && let Some(removed) = self.entries.remove(&key)
1644 {
1645 self.total_bytes = self.total_bytes.saturating_sub(removed.weight);
1646 }
1647 }
1648 }
1649}
1650
1651fn tool_output_cache() -> &'static Mutex<ToolOutputCache> {
1652 static CACHE: OnceLock<Mutex<ToolOutputCache>> = OnceLock::new();
1653 CACHE.get_or_init(|| Mutex::new(ToolOutputCache::default()))
1654}
1655
1656fn lock_tool_output_cache() -> std::sync::MutexGuard<'static, ToolOutputCache> {
1657 tool_output_cache()
1658 .lock()
1659 .unwrap_or_else(std::sync::PoisonError::into_inner)
1660}
1661
1662fn tool_cache_key(tool: &str, cwd: &Path, input: &serde_json::Value) -> String {
1663 let input_json = serde_json::to_string(input).unwrap_or_else(|_| input.to_string());
1664 format!("{tool}\0{}\0{input_json}", cwd.display())
1665}
1666
1667#[cfg(test)]
1668fn is_side_effect_tool_cache_key(key: &str) -> bool {
1669 key.starts_with("write\0") || key.starts_with("edit\0") || key.starts_with("bash\0")
1670}
1671
1672fn cached_tool_output(key: &str, deps: Option<&[ToolCacheDependency]>) -> Option<ToolOutput> {
1673 let deps = deps?;
1674 lock_tool_output_cache().get(key, deps)
1675}
1676
1677fn cache_tool_output(key: String, deps: Option<Vec<ToolCacheDependency>>, output: &ToolOutput) {
1678 let Some(deps) = deps else {
1679 return;
1680 };
1681 if output.details.as_ref().is_some_and(|details| {
1682 details.as_object().is_some_and(|details| {
1683 details.contains_key("artifact") || details.contains_key("artifactError")
1684 })
1685 }) {
1686 return;
1687 }
1688 let Some(weight) = cacheable_tool_output_weight(output) else {
1689 return;
1690 };
1691 lock_tool_output_cache().insert(key, deps, output.clone(), weight);
1692}
1693
1694fn stable_cache_dependency_for_path(
1695 path: &Path,
1696 mode: ToolCacheFingerprintMode,
1697 before_deps: Option<&[ToolCacheDependency]>,
1698) -> Option<Vec<ToolCacheDependency>> {
1699 let before_deps = before_deps?;
1700 let after_deps = cache_dependency_for_path(path, mode)?;
1701 (before_deps == after_deps.as_slice()).then_some(after_deps)
1702}
1703
1704fn cacheable_tool_output_weight(output: &ToolOutput) -> Option<usize> {
1705 let mut weight = output
1706 .details
1707 .as_ref()
1708 .and_then(|details| serde_json::to_vec(details).ok())
1709 .map_or(0, |details| details.len());
1710
1711 for block in &output.content {
1712 match block {
1713 ContentBlock::Text(text) => {
1714 weight = weight.saturating_add(text.text.len());
1715 if let Some(signature) = &text.text_signature {
1716 weight = weight.saturating_add(signature.len());
1717 }
1718 }
1719 ContentBlock::Image(_)
1720 | ContentBlock::Thinking(_)
1721 | ContentBlock::RedactedThinking(_)
1722 | ContentBlock::ToolCall(_) => return None,
1723 }
1724 }
1725
1726 Some(weight)
1727}
1728
1729fn cache_dependency_for_path(
1730 path: &Path,
1731 mode: ToolCacheFingerprintMode,
1732) -> Option<Vec<ToolCacheDependency>> {
1733 let fingerprint = match mode {
1734 ToolCacheFingerprintMode::FileContent => fingerprint_file_content(path)?,
1735 ToolCacheFingerprintMode::DirectoryImmediate => fingerprint_directory_immediate(path)?,
1736 ToolCacheFingerprintMode::DirectoryRecursive => fingerprint_directory_recursive(path)?,
1737 };
1738
1739 Some(vec![ToolCacheDependency {
1740 path: path.to_path_buf(),
1741 fingerprint,
1742 }])
1743}
1744
1745fn fingerprint_file_content(path: &Path) -> Option<[u8; 32]> {
1746 let metadata = std::fs::symlink_metadata(path).ok()?;
1747 if !metadata.is_file() || metadata.len() > TOOL_OUTPUT_CACHE_MAX_FILE_HASH_BYTES {
1748 return None;
1749 }
1750
1751 let bytes = std::fs::read(path).ok()?;
1752 let mut hasher = sha2::Sha256::new();
1753 update_fingerprint_metadata(&mut hasher, Path::new(""), &metadata);
1754 hasher.update(sha2::Sha256::digest(&bytes));
1755 Some(hasher.finalize().into())
1756}
1757
1758fn fingerprint_directory_immediate(path: &Path) -> Option<[u8; 32]> {
1759 let metadata = std::fs::symlink_metadata(path).ok()?;
1760 if !metadata.is_dir() {
1761 return None;
1762 }
1763
1764 let mut entries = std::fs::read_dir(path)
1765 .ok()?
1766 .collect::<std::result::Result<Vec<_>, _>>()
1767 .ok()?;
1768 if entries.len() > TOOL_OUTPUT_CACHE_MAX_FINGERPRINT_FILES {
1769 return None;
1770 }
1771 entries.sort_by_key(std::fs::DirEntry::file_name);
1772
1773 let mut hasher = sha2::Sha256::new();
1774 update_fingerprint_metadata(&mut hasher, Path::new(""), &metadata);
1775 for entry in entries {
1776 let entry_path = entry.path();
1777 let rel = entry.file_name();
1778 let rel = Path::new(&rel);
1779 let entry_metadata = std::fs::symlink_metadata(&entry_path).ok()?;
1780 update_fingerprint_metadata(&mut hasher, rel, &entry_metadata);
1781 if entry_metadata.file_type().is_symlink() {
1782 update_symlink_target(&mut hasher, &entry_path);
1783 }
1784 }
1785
1786 Some(hasher.finalize().into())
1787}
1788
1789fn fingerprint_directory_recursive(path: &Path) -> Option<[u8; 32]> {
1790 let metadata = std::fs::symlink_metadata(path).ok()?;
1791 if metadata.is_file() {
1792 return fingerprint_file_content(path);
1793 }
1794 if !metadata.is_dir() {
1795 return None;
1796 }
1797
1798 let mut budget = FingerprintBudget::default();
1799 let mut hasher = sha2::Sha256::new();
1800 update_fingerprint_metadata(&mut hasher, Path::new(""), &metadata);
1801 fingerprint_tree(path, path, &mut budget, &mut hasher)?;
1802 Some(hasher.finalize().into())
1803}
1804
1805#[derive(Debug, Default)]
1806struct FingerprintBudget {
1807 entries: usize,
1808 bytes: u64,
1809}
1810
1811fn fingerprint_tree(
1812 root: &Path,
1813 dir: &Path,
1814 budget: &mut FingerprintBudget,
1815 hasher: &mut sha2::Sha256,
1816) -> Option<()> {
1817 let mut entries = std::fs::read_dir(dir)
1818 .ok()?
1819 .collect::<std::result::Result<Vec<_>, _>>()
1820 .ok()?;
1821 entries.sort_by_key(std::fs::DirEntry::path);
1822
1823 for entry in entries {
1824 budget.entries = budget.entries.saturating_add(1);
1825 if budget.entries > TOOL_OUTPUT_CACHE_MAX_FINGERPRINT_FILES {
1826 return None;
1827 }
1828
1829 let entry_path = entry.path();
1830 let rel = entry_path.strip_prefix(root).unwrap_or(&entry_path);
1831 let metadata = std::fs::symlink_metadata(&entry_path).ok()?;
1832 update_fingerprint_metadata(hasher, rel, &metadata);
1833
1834 if metadata.file_type().is_symlink() {
1835 update_symlink_target(hasher, &entry_path);
1836 } else if metadata.is_dir() {
1837 fingerprint_tree(root, &entry_path, budget, hasher)?;
1838 } else if metadata.is_file() {
1839 if metadata.len() > TOOL_OUTPUT_CACHE_MAX_FILE_HASH_BYTES {
1840 return None;
1841 }
1842 budget.bytes = budget.bytes.saturating_add(metadata.len());
1843 if budget.bytes > TOOL_OUTPUT_CACHE_MAX_FINGERPRINT_BYTES {
1844 return None;
1845 }
1846 let bytes = std::fs::read(&entry_path).ok()?;
1847 hasher.update(sha2::Sha256::digest(&bytes));
1848 }
1849 }
1850
1851 Some(())
1852}
1853
1854fn update_fingerprint_metadata(
1855 hasher: &mut sha2::Sha256,
1856 path: &Path,
1857 metadata: &std::fs::Metadata,
1858) {
1859 hasher.update(path.to_string_lossy().as_bytes());
1860 hasher.update([0]);
1861 let file_type = metadata.file_type();
1862 hasher.update([
1863 u8::from(metadata.is_file()),
1864 u8::from(metadata.is_dir()),
1865 u8::from(file_type.is_symlink()),
1866 ]);
1867 hasher.update(metadata.len().to_le_bytes());
1868 let modified_nanos = metadata
1869 .modified()
1870 .ok()
1871 .and_then(|modified| modified.duration_since(UNIX_EPOCH).ok())
1872 .map_or(0, |duration| duration.as_nanos());
1873 hasher.update(modified_nanos.to_le_bytes());
1874 hasher.update([0xff]);
1875}
1876
1877fn update_symlink_target(hasher: &mut sha2::Sha256, path: &Path) {
1878 if let Ok(target) = std::fs::read_link(path) {
1879 hasher.update(target.to_string_lossy().as_bytes());
1880 }
1881 hasher.update([0xfe]);
1882}
1883
1884#[cfg(test)]
1885fn reset_tool_output_cache_for_tests() {
1886 *lock_tool_output_cache() = ToolOutputCache::default();
1887}
1888
1889#[cfg(test)]
1890fn tool_output_cache_stats_for_tests() -> ToolOutputCacheStats {
1891 lock_tool_output_cache().stats
1892}
1893
1894#[allow(clippy::cast_precision_loss)]
1896fn format_size(bytes: usize) -> String {
1897 const KB: usize = 1024;
1898 const MB: usize = 1024 * 1024;
1899
1900 if bytes >= MB {
1901 format!("{:.1}MB", bytes as f64 / MB as f64)
1902 } else if bytes >= KB {
1903 format!("{:.1}KB", bytes as f64 / KB as f64)
1904 } else {
1905 format!("{bytes}B")
1906 }
1907}
1908
1909#[cfg(test)]
1910fn js_string_length(s: &str) -> usize {
1911 s.encode_utf16().count()
1913}
1914
1915fn is_special_unicode_space(c: char) -> bool {
1920 matches!(c, '\u{00A0}' | '\u{202F}' | '\u{205F}' | '\u{3000}')
1921 || ('\u{2000}'..='\u{200A}').contains(&c)
1922}
1923
1924fn normalize_unicode_spaces(s: &str) -> String {
1925 s.chars()
1926 .map(|c| if is_special_unicode_space(c) { ' ' } else { c })
1927 .collect()
1928}
1929
1930#[cfg(test)]
1931fn normalize_for_match(s: &str) -> String {
1932 let mut out = String::with_capacity(s.len());
1935 for c in s.chars() {
1936 match c {
1937 c if is_special_unicode_space(c) => out.push(' '),
1939 '\u{2018}' | '\u{2019}' => out.push('\''),
1941 '\u{201C}' | '\u{201D}' | '\u{201E}' | '\u{201F}' => out.push('"'),
1943 '\u{2010}' | '\u{2011}' | '\u{2012}' | '\u{2013}' | '\u{2014}' | '\u{2015}'
1945 | '\u{2212}' => out.push('-'),
1946 c => out.push(c),
1948 }
1949 }
1950 out
1951}
1952
1953fn expand_path(file_path: &str) -> String {
1954 let normalized = normalize_unicode_spaces(file_path);
1955 if normalized == "~" {
1956 return dirs::home_dir()
1957 .unwrap_or_else(|| PathBuf::from("~"))
1958 .to_string_lossy()
1959 .to_string();
1960 }
1961 if let Some(rest) = normalized.strip_prefix("~/") {
1962 let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("~"));
1963 return home.join(rest).to_string_lossy().to_string();
1964 }
1965 normalized
1966}
1967
1968fn resolve_to_cwd(file_path: &str, cwd: &Path) -> PathBuf {
1970 let expanded = expand_path(file_path);
1971 let expanded_path = PathBuf::from(expanded);
1972 if expanded_path.is_absolute() {
1973 expanded_path
1974 } else {
1975 cwd.join(expanded_path)
1976 }
1977}
1978
1979fn try_mac_os_screenshot_path(file_path: &str) -> String {
1980 file_path
1982 .replace(" AM.", "\u{202F}AM.")
1983 .replace(" PM.", "\u{202F}PM.")
1984}
1985
1986fn try_curly_quote_variant(file_path: &str) -> String {
1987 file_path.replace('\'', "\u{2019}")
1989}
1990
1991fn try_nfd_variant(file_path: &str) -> String {
1992 use unicode_normalization::UnicodeNormalization;
1995 file_path.nfd().collect::<String>()
1996}
1997
1998fn file_exists(path: &Path) -> bool {
1999 std::fs::metadata(path).is_ok()
2000}
2001
2002pub(crate) fn resolve_read_path(file_path: &str, cwd: &Path) -> PathBuf {
2004 let resolved = normalize_dot_segments(&resolve_to_cwd(file_path, cwd));
2005 let normalized_cwd = normalize_dot_segments(cwd);
2006 let within_cwd = resolved.starts_with(&normalized_cwd);
2007 if within_cwd && file_exists(&resolved) {
2008 return resolved;
2009 }
2010 if !within_cwd {
2011 return resolved;
2013 }
2014
2015 let Some(resolved_str) = resolved.to_str() else {
2016 return resolved;
2017 };
2018
2019 let am_pm_variant = try_mac_os_screenshot_path(resolved_str);
2020 if am_pm_variant.ne(resolved_str) {
2021 let candidate = PathBuf::from(&am_pm_variant);
2022 if candidate.starts_with(&normalized_cwd) && file_exists(&candidate) {
2023 return candidate;
2024 }
2025 }
2026
2027 let nfd_variant = try_nfd_variant(resolved_str);
2028 if nfd_variant.ne(resolved_str) {
2029 let candidate = PathBuf::from(&nfd_variant);
2030 if candidate.starts_with(&normalized_cwd) && file_exists(&candidate) {
2031 return candidate;
2032 }
2033 }
2034
2035 let curly_variant = try_curly_quote_variant(resolved_str);
2036 if curly_variant.ne(resolved_str) {
2037 let candidate = PathBuf::from(&curly_variant);
2038 if candidate.starts_with(&normalized_cwd) && file_exists(&candidate) {
2039 return candidate;
2040 }
2041 }
2042
2043 let nfd_curly_variant = try_curly_quote_variant(&nfd_variant);
2044 if nfd_curly_variant.ne(resolved_str) {
2045 let candidate = PathBuf::from(&nfd_curly_variant);
2046 if candidate.starts_with(&normalized_cwd) && file_exists(&candidate) {
2047 return candidate;
2048 }
2049 }
2050
2051 resolved
2052}
2053
2054fn enforce_cwd_scope(path: &Path, cwd: &Path, action: &str) -> Result<PathBuf> {
2055 let canonical_path = crate::extensions::safe_canonicalize(path);
2056 let canonical_cwd = crate::extensions::safe_canonicalize(cwd);
2057 if !canonical_path.starts_with(&canonical_cwd) {
2058 return Err(Error::validation(format!(
2059 "Cannot {action} outside the working directory (resolved: {}, cwd: {})",
2060 canonical_path.display(),
2061 canonical_cwd.display()
2062 )));
2063 }
2064 Ok(canonical_path)
2065}
2066
2067fn enforce_read_scope_with_roots(path: &Path, cwd: &Path, agent_dir: &Path) -> Result<PathBuf> {
2083 let canonical_path = crate::extensions::safe_canonicalize(path);
2084 let canonical_cwd = crate::extensions::safe_canonicalize(cwd);
2085 if canonical_path.starts_with(&canonical_cwd) {
2086 return Ok(canonical_path);
2087 }
2088
2089 let canonical_agent = crate::extensions::safe_canonicalize(agent_dir);
2090 if canonical_path.starts_with(&canonical_agent) {
2091 return Ok(canonical_path);
2092 }
2093
2094 Err(Error::validation(format!(
2095 "Cannot read outside the working directory or agent dir \
2096 (resolved: {}, cwd: {}, agent dir: {})",
2097 canonical_path.display(),
2098 canonical_cwd.display(),
2099 canonical_agent.display(),
2100 )))
2101}
2102
2103fn enforce_read_scope(path: &Path, cwd: &Path) -> Result<PathBuf> {
2105 let agent_dir = crate::config::Config::global_dir();
2106 enforce_read_scope_with_roots(path, cwd, &agent_dir)
2107}
2108
2109#[derive(Debug, Clone, Default)]
2115pub struct ProcessedFiles {
2116 pub text: String,
2117 pub images: Vec<ImageContent>,
2118}
2119
2120fn normalize_dot_segments(path: &Path) -> PathBuf {
2121 use std::ffi::{OsStr, OsString};
2122 use std::path::Component;
2123
2124 let mut out = PathBuf::new();
2125 let mut normals: Vec<OsString> = Vec::new();
2126 let mut has_prefix = false;
2127 let mut has_root = false;
2128
2129 for component in path.components() {
2130 match component {
2131 Component::Prefix(prefix) => {
2132 out.push(prefix.as_os_str());
2133 has_prefix = true;
2134 }
2135 Component::RootDir => {
2136 out.push(component.as_os_str());
2137 has_root = true;
2138 }
2139 Component::CurDir => {}
2140 Component::ParentDir => match normals.last() {
2141 Some(last) if last.as_os_str() != OsStr::new("..") => {
2142 normals.pop();
2143 }
2144 _ => {
2145 if !has_root && !has_prefix {
2146 normals.push(OsString::from(".."));
2147 }
2148 }
2149 },
2150 Component::Normal(part) => normals.push(part.to_os_string()),
2151 }
2152 }
2153
2154 for part in normals {
2155 out.push(part);
2156 }
2157
2158 out
2159}
2160
2161#[cfg(feature = "fuzzing")]
2162pub fn fuzz_normalize_dot_segments(path: &Path) -> PathBuf {
2163 normalize_dot_segments(path)
2164}
2165
2166#[cfg(unix)]
2167fn sync_parent_dir(path: &Path) -> std::io::Result<()> {
2168 let Some(parent) = path.parent() else {
2169 return Ok(());
2170 };
2171 std::fs::File::open(parent)?.sync_all()
2172}
2173
2174#[cfg(not(unix))]
2175fn sync_parent_dir(_path: &Path) -> std::io::Result<()> {
2176 Ok(())
2177}
2178
2179fn escape_file_tag_attribute(value: &str) -> String {
2180 let mut escaped = String::with_capacity(value.len());
2181 for ch in value.chars() {
2182 match ch {
2183 '&' => escaped.push_str("&"),
2184 '"' => escaped.push_str("""),
2185 '<' => escaped.push_str("<"),
2186 '>' => escaped.push_str(">"),
2187 '\n' => escaped.push_str(" "),
2188 '\r' => escaped.push_str(" "),
2189 '\t' => escaped.push_str("	"),
2190 _ => escaped.push(ch),
2191 }
2192 }
2193 escaped
2194}
2195
2196fn escaped_file_tag_name(path: &Path) -> String {
2197 escape_file_tag_attribute(&path.display().to_string())
2198}
2199
2200fn append_file_notice_block(out: &mut String, path: &Path, notice: &str) {
2201 let path_str = escaped_file_tag_name(path);
2202 let _ = writeln!(out, "<file name=\"{path_str}\">\n{notice}\n</file>");
2203}
2204
2205fn append_image_file_ref(out: &mut String, path: &Path, note: Option<&str>) {
2206 let path_str = escaped_file_tag_name(path);
2207 match note {
2208 Some(text) => {
2209 let _ = writeln!(out, "<file name=\"{path_str}\">{text}</file>");
2210 }
2211 None => {
2212 let _ = writeln!(out, "<file name=\"{path_str}\"></file>");
2213 }
2214 }
2215}
2216
2217fn append_text_file_block(out: &mut String, path: &Path, bytes: &[u8]) {
2218 let content = String::from_utf8_lossy(bytes);
2219 let path_str = escaped_file_tag_name(path);
2220 let _ = writeln!(out, "<file name=\"{path_str}\">");
2221
2222 let truncation = truncate_head(content.into_owned(), DEFAULT_MAX_LINES, DEFAULT_MAX_BYTES);
2223 let needs_trailing_newline = !truncation.truncated && !truncation.content.ends_with('\n');
2224 out.push_str(&truncation.content);
2225
2226 if truncation.truncated {
2227 let _ = write!(
2228 out,
2229 "\n... [Truncated: showing {}/{} lines, {}/{} bytes]",
2230 truncation.output_lines,
2231 truncation.total_lines,
2232 format_size(truncation.output_bytes),
2233 format_size(truncation.total_bytes)
2234 );
2235 } else if needs_trailing_newline {
2236 out.push('\n');
2237 }
2238 let _ = writeln!(out, "</file>");
2239}
2240
2241fn maybe_append_image_argument(
2242 out: &mut ProcessedFiles,
2243 absolute_path: &Path,
2244 bytes: &[u8],
2245 auto_resize_images: bool,
2246) -> Result<bool> {
2247 let Some(mime_type) = detect_supported_image_mime_type_from_bytes(bytes) else {
2248 return Ok(false);
2249 };
2250
2251 let resized = if auto_resize_images {
2252 resize_image_if_needed(bytes, mime_type)?
2253 } else {
2254 ResizedImage::original(bytes.to_vec(), mime_type)
2255 };
2256
2257 if resized.bytes.len() > IMAGE_MAX_BYTES {
2258 let msg = if resized.resized {
2259 format!(
2260 "[Image is too large ({} bytes) after resizing. Max allowed is {} bytes.]",
2261 resized.bytes.len(),
2262 IMAGE_MAX_BYTES
2263 )
2264 } else {
2265 format!(
2266 "[Image is too large ({} bytes). Max allowed is {} bytes.]",
2267 resized.bytes.len(),
2268 IMAGE_MAX_BYTES
2269 )
2270 };
2271 append_file_notice_block(&mut out.text, absolute_path, &msg);
2272 return Ok(true);
2273 }
2274
2275 let base64_data =
2276 base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &resized.bytes);
2277 out.images.push(ImageContent {
2278 data: base64_data,
2279 mime_type: resized.mime_type.to_string(),
2280 });
2281
2282 let note = if resized.resized {
2283 if let (Some(ow), Some(oh), Some(w), Some(h)) = (
2284 resized.original_width,
2285 resized.original_height,
2286 resized.width,
2287 resized.height,
2288 ) {
2289 if w > 0 {
2290 let scale = f64::from(ow) / f64::from(w);
2291 Some(format!(
2292 "[Image: original {ow}x{oh}, displayed at {w}x{h}. Multiply coordinates by {scale:.2} to map to original image.]"
2293 ))
2294 } else {
2295 Some(format!(
2296 "[Image: original {ow}x{oh}, displayed at {w}x{h}.]"
2297 ))
2298 }
2299 } else {
2300 None
2301 }
2302 } else {
2303 None
2304 };
2305 append_image_file_ref(&mut out.text, absolute_path, note.as_deref());
2306 Ok(true)
2307}
2308
2309pub fn process_file_arguments(
2317 file_args: &[String],
2318 cwd: &Path,
2319 auto_resize_images: bool,
2320) -> Result<ProcessedFiles> {
2321 let mut out = ProcessedFiles::default();
2322
2323 for file_arg in file_args {
2324 let resolved = resolve_read_path(file_arg, cwd);
2325 let absolute_path = normalize_dot_segments(&resolved);
2326 let absolute_path = enforce_read_scope(&absolute_path, cwd)?;
2327
2328 let meta = std::fs::metadata(&absolute_path).map_err(|e| {
2329 Error::tool(
2330 "read",
2331 format!("Cannot access file {}: {e}", absolute_path.display()),
2332 )
2333 })?;
2334 if meta.is_dir() {
2335 append_file_notice_block(
2336 &mut out.text,
2337 &absolute_path,
2338 "[Path is a directory, not a file. Use the list tool to view its contents.]",
2339 );
2340 continue;
2341 }
2342
2343 if meta.len() == 0 {
2344 continue;
2345 }
2346
2347 if meta.len() > READ_TOOL_MAX_BYTES {
2348 append_file_notice_block(
2349 &mut out.text,
2350 &absolute_path,
2351 &format!(
2352 "[File is too large ({} bytes). Max allowed is {} bytes.]",
2353 meta.len(),
2354 READ_TOOL_MAX_BYTES
2355 ),
2356 );
2357 continue;
2358 }
2359
2360 let bytes = std::fs::read(&absolute_path).map_err(|e| {
2361 Error::tool(
2362 "read",
2363 format!("Could not read file {}: {e}", absolute_path.display()),
2364 )
2365 })?;
2366
2367 if maybe_append_image_argument(&mut out, &absolute_path, &bytes, auto_resize_images)? {
2368 continue;
2369 }
2370
2371 append_text_file_block(&mut out.text, &absolute_path, &bytes);
2372 }
2373
2374 Ok(out)
2375}
2376
2377fn resolve_path(file_path: &str, cwd: &Path) -> PathBuf {
2380 normalize_dot_segments(&resolve_to_cwd(file_path, cwd))
2381}
2382
2383#[cfg(feature = "fuzzing")]
2384pub fn fuzz_resolve_path(file_path: &str, cwd: &Path) -> PathBuf {
2385 resolve_path(file_path, cwd)
2386}
2387
2388pub(crate) fn detect_supported_image_mime_type_from_bytes(bytes: &[u8]) -> Option<&'static str> {
2389 if bytes.len() >= 8 && bytes.starts_with(b"\x89PNG\r\n\x1A\n") {
2391 return Some("image/png");
2392 }
2393 if bytes.len() >= 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF {
2394 return Some("image/jpeg");
2395 }
2396 if bytes.len() >= 6 && (bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a")) {
2397 return Some("image/gif");
2398 }
2399 if bytes.len() >= 12 && bytes.starts_with(b"RIFF") && &bytes[8..12] == b"WEBP" {
2400 return Some("image/webp");
2401 }
2402 None
2403}
2404
2405#[derive(Debug, Clone)]
2406pub(crate) struct ResizedImage {
2407 pub(crate) bytes: Vec<u8>,
2408 pub(crate) mime_type: &'static str,
2409 pub(crate) resized: bool,
2410 pub(crate) width: Option<u32>,
2411 pub(crate) height: Option<u32>,
2412 pub(crate) original_width: Option<u32>,
2413 pub(crate) original_height: Option<u32>,
2414}
2415
2416impl ResizedImage {
2417 pub(crate) const fn original(bytes: Vec<u8>, mime_type: &'static str) -> Self {
2418 Self {
2419 bytes,
2420 mime_type,
2421 resized: false,
2422 width: None,
2423 height: None,
2424 original_width: None,
2425 original_height: None,
2426 }
2427 }
2428}
2429
2430#[cfg(feature = "image-resize")]
2431#[allow(clippy::too_many_lines)]
2432pub(crate) fn resize_image_if_needed(
2433 bytes: &[u8],
2434 mime_type: &'static str,
2435) -> Result<ResizedImage> {
2436 use image::codecs::jpeg::JpegEncoder;
2447 use image::codecs::png::PngEncoder;
2448 use image::imageops::FilterType;
2449 use image::{GenericImageView, ImageEncoder, ImageReader, Limits};
2450 use std::io::Cursor;
2451
2452 const MAX_WIDTH: u32 = 2000;
2453 const MAX_HEIGHT: u32 = 2000;
2454 const DEFAULT_JPEG_QUALITY: u8 = 80;
2455 const QUALITY_STEPS: [u8; 4] = [85, 70, 55, 40];
2456 const SCALE_STEPS: [f64; 5] = [1.0, 0.75, 0.5, 0.35, 0.25];
2457
2458 fn scale_u32(value: u32, numerator: u32, denominator: u32) -> u32 {
2459 let den = u64::from(denominator).max(1);
2460 let num = u64::from(value) * u64::from(numerator);
2461 let rounded = (num + den / 2) / den;
2462 u32::try_from(rounded).unwrap_or(u32::MAX)
2463 }
2464
2465 fn encode_png(img: &image::DynamicImage) -> Result<Vec<u8>> {
2466 let rgba = img.to_rgba8();
2467 let mut out = Vec::new();
2468 PngEncoder::new(&mut out)
2469 .write_image(
2470 rgba.as_raw(),
2471 rgba.width(),
2472 rgba.height(),
2473 image::ExtendedColorType::Rgba8,
2474 )
2475 .map_err(|e| Error::tool("read", format!("Failed to encode PNG: {e}")))?;
2476 Ok(out)
2477 }
2478
2479 fn encode_jpeg(img: &image::DynamicImage, quality: u8) -> Result<Vec<u8>> {
2480 let rgb = img.to_rgb8();
2481 let mut out = Vec::new();
2482 JpegEncoder::new_with_quality(&mut out, quality)
2483 .write_image(
2484 rgb.as_raw(),
2485 rgb.width(),
2486 rgb.height(),
2487 image::ExtendedColorType::Rgb8,
2488 )
2489 .map_err(|e| Error::tool("read", format!("Failed to encode JPEG: {e}")))?;
2490 Ok(out)
2491 }
2492
2493 fn try_both_formats(
2494 img: &image::DynamicImage,
2495 width: u32,
2496 height: u32,
2497 jpeg_quality: u8,
2498 ) -> Result<(Vec<u8>, &'static str)> {
2499 let resized = img.resize_exact(width, height, FilterType::Lanczos3);
2500 let png = encode_png(&resized)?;
2501 let jpeg = encode_jpeg(&resized, jpeg_quality)?;
2502 if png.len() <= jpeg.len() {
2503 Ok((png, "image/png"))
2504 } else {
2505 Ok((jpeg, "image/jpeg"))
2506 }
2507 }
2508
2509 let mut limits = Limits::default();
2512 limits.max_alloc = Some(128 * 1024 * 1024);
2513
2514 let reader = ImageReader::new(Cursor::new(bytes))
2515 .with_guessed_format()
2516 .map_err(|e| Error::tool("read", format!("Failed to detect image format: {e}")))?;
2517
2518 let mut reader = reader;
2519 reader.limits(limits);
2520
2521 let Ok(img) = reader.decode() else {
2523 return Ok(ResizedImage::original(bytes.to_vec(), mime_type));
2524 };
2525
2526 let (original_width, original_height) = img.dimensions();
2527 let original_size = bytes.len();
2528
2529 if original_width <= MAX_WIDTH
2530 && original_height <= MAX_HEIGHT
2531 && original_size <= IMAGE_MAX_BYTES
2532 {
2533 return Ok(ResizedImage {
2534 bytes: bytes.to_vec(),
2535 mime_type,
2536 resized: false,
2537 width: Some(original_width),
2538 height: Some(original_height),
2539 original_width: Some(original_width),
2540 original_height: Some(original_height),
2541 });
2542 }
2543
2544 let mut target_width = original_width;
2545 let mut target_height = original_height;
2546
2547 if target_width > MAX_WIDTH {
2548 target_height = scale_u32(target_height, MAX_WIDTH, target_width);
2549 target_width = MAX_WIDTH;
2550 }
2551 if target_height > MAX_HEIGHT {
2552 target_width = scale_u32(target_width, MAX_HEIGHT, target_height);
2553 target_height = MAX_HEIGHT;
2554 }
2555
2556 let mut best = try_both_formats(&img, target_width, target_height, DEFAULT_JPEG_QUALITY)?;
2557 let mut final_width = target_width;
2558 let mut final_height = target_height;
2559
2560 if best.0.len() <= IMAGE_MAX_BYTES {
2561 return Ok(ResizedImage {
2562 bytes: best.0,
2563 mime_type: best.1,
2564 resized: true,
2565 width: Some(final_width),
2566 height: Some(final_height),
2567 original_width: Some(original_width),
2568 original_height: Some(original_height),
2569 });
2570 }
2571
2572 for quality in QUALITY_STEPS {
2573 best = try_both_formats(&img, target_width, target_height, quality)?;
2574 if best.0.len() <= IMAGE_MAX_BYTES {
2575 return Ok(ResizedImage {
2576 bytes: best.0,
2577 mime_type: best.1,
2578 resized: true,
2579 width: Some(final_width),
2580 height: Some(final_height),
2581 original_width: Some(original_width),
2582 original_height: Some(original_height),
2583 });
2584 }
2585 }
2586
2587 for scale in SCALE_STEPS {
2588 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
2589 {
2590 final_width = (f64::from(target_width) * scale).round() as u32;
2591 final_height = (f64::from(target_height) * scale).round() as u32;
2592 }
2593
2594 if final_width < 100 || final_height < 100 {
2595 break;
2596 }
2597
2598 for quality in QUALITY_STEPS {
2599 best = try_both_formats(&img, final_width, final_height, quality)?;
2600 if best.0.len() <= IMAGE_MAX_BYTES {
2601 return Ok(ResizedImage {
2602 bytes: best.0,
2603 mime_type: best.1,
2604 resized: true,
2605 width: Some(final_width),
2606 height: Some(final_height),
2607 original_width: Some(original_width),
2608 original_height: Some(original_height),
2609 });
2610 }
2611 }
2612 }
2613
2614 Ok(ResizedImage {
2615 bytes: best.0,
2616 mime_type: best.1,
2617 resized: true,
2618 width: Some(final_width),
2619 height: Some(final_height),
2620 original_width: Some(original_width),
2621 original_height: Some(original_height),
2622 })
2623}
2624
2625#[cfg(not(feature = "image-resize"))]
2626#[expect(
2627 clippy::unnecessary_wraps,
2628 reason = "The no-feature stub preserves the feature-enabled Result API at shared call sites."
2629)]
2630pub(crate) fn resize_image_if_needed(
2631 bytes: &[u8],
2632 mime_type: &'static str,
2633) -> Result<ResizedImage> {
2634 Ok(ResizedImage::original(bytes.to_vec(), mime_type))
2635}
2636
2637pub struct ToolRegistry {
2647 tools: Vec<Box<dyn Tool>>,
2648}
2649
2650impl ToolRegistry {
2651 pub fn new(enabled: &[&str], cwd: &Path, config: Option<&Config>) -> Self {
2653 let mut tools: Vec<Box<dyn Tool>> = Vec::new();
2654 let shell_path = config.and_then(|c| c.shell_path.clone());
2655 let shell_command_prefix = config.and_then(|c| c.shell_command_prefix.clone());
2656 let image_auto_resize = config.is_none_or(Config::image_auto_resize);
2657 let block_images = config
2658 .and_then(|c| c.images.as_ref().and_then(|i| i.block_images))
2659 .unwrap_or(false);
2660
2661 for name in enabled {
2662 match *name {
2663 "read" => tools.push(Box::new(ReadTool::with_settings(
2664 cwd,
2665 image_auto_resize,
2666 block_images,
2667 ))),
2668 "bash" => tools.push(Box::new(BashTool::with_shell(
2669 cwd,
2670 shell_path.clone(),
2671 shell_command_prefix.clone(),
2672 ))),
2673 "edit" => tools.push(Box::new(EditTool::new(cwd))),
2674 "write" => tools.push(Box::new(WriteTool::new(cwd))),
2675 "grep" => tools.push(Box::new(GrepTool::new(cwd))),
2676 "find" => tools.push(Box::new(FindTool::new(cwd))),
2677 "ls" => tools.push(Box::new(LsTool::new(cwd))),
2678 "hashline_edit" => tools.push(Box::new(HashlineEditTool::new(cwd))),
2679 _ => {}
2680 }
2681 }
2682
2683 Self { tools }
2684 }
2685
2686 pub fn from_tools(tools: Vec<Box<dyn Tool>>) -> Self {
2688 Self { tools }
2689 }
2690
2691 pub fn into_tools(self) -> Vec<Box<dyn Tool>> {
2693 self.tools
2694 }
2695
2696 pub fn push(&mut self, tool: Box<dyn Tool>) {
2698 self.tools.push(tool);
2699 }
2700
2701 pub fn extend<I>(&mut self, tools: I)
2703 where
2704 I: IntoIterator<Item = Box<dyn Tool>>,
2705 {
2706 self.tools.extend(tools);
2707 }
2708
2709 pub fn tools(&self) -> &[Box<dyn Tool>] {
2711 &self.tools
2712 }
2713
2714 pub fn get(&self, name: &str) -> Option<&dyn Tool> {
2716 self.tools
2717 .iter()
2718 .find(|t| t.name() == name)
2719 .map(std::convert::AsRef::as_ref)
2720 }
2721}
2722
2723#[derive(Debug, Deserialize)]
2729#[serde(rename_all = "camelCase")]
2730struct ReadInput {
2731 path: String,
2732 offset: Option<i64>,
2733 limit: Option<i64>,
2734 #[serde(default)]
2735 hashline: bool,
2736}
2737
2738pub struct ReadTool {
2739 cwd: PathBuf,
2740 auto_resize: bool,
2742 block_images: bool,
2743 artifact_root: Option<PathBuf>,
2744}
2745
2746impl ReadTool {
2747 pub fn new(cwd: &Path) -> Self {
2748 Self {
2749 cwd: cwd.to_path_buf(),
2750 auto_resize: true,
2751 block_images: false,
2752 artifact_root: None,
2753 }
2754 }
2755
2756 pub fn with_settings(cwd: &Path, auto_resize: bool, block_images: bool) -> Self {
2757 Self {
2758 cwd: cwd.to_path_buf(),
2759 auto_resize,
2760 block_images,
2761 artifact_root: None,
2762 }
2763 }
2764
2765 #[cfg(test)]
2766 fn with_artifact_root(cwd: &Path, artifact_root: &Path) -> Self {
2767 Self {
2768 cwd: cwd.to_path_buf(),
2769 auto_resize: true,
2770 block_images: false,
2771 artifact_root: Some(artifact_root.to_path_buf()),
2772 }
2773 }
2774}
2775
2776async fn read_some<R>(reader: &mut R, dst: &mut [u8]) -> std::io::Result<usize>
2777where
2778 R: AsyncRead + Unpin,
2779{
2780 if dst.is_empty() {
2781 return Ok(0);
2782 }
2783
2784 futures::future::poll_fn(|cx| {
2785 let mut read_buf = ReadBuf::new(dst);
2786 match std::pin::Pin::new(&mut *reader).poll_read(cx, &mut read_buf) {
2787 std::task::Poll::Ready(Ok(())) => std::task::Poll::Ready(Ok(read_buf.filled().len())),
2788 std::task::Poll::Ready(Err(err)) => std::task::Poll::Ready(Err(err)),
2789 std::task::Poll::Pending => std::task::Poll::Pending,
2790 }
2791 })
2792 .await
2793}
2794
2795#[async_trait]
2796#[allow(clippy::unnecessary_literal_bound)]
2797impl Tool for ReadTool {
2798 fn name(&self) -> &str {
2799 "read"
2800 }
2801 fn label(&self) -> &str {
2802 "read"
2803 }
2804 fn description(&self) -> &str {
2805 "Read the contents of a file. Supports text files and images (jpg, png, gif, webp). Images are sent as attachments. For text files, output is truncated to 2000 lines or 1MB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete."
2806 }
2807
2808 fn parameters(&self) -> serde_json::Value {
2809 serde_json::json!({
2810 "type": "object",
2811 "properties": {
2812 "path": {
2813 "type": "string",
2814 "description": "Path to the file to read (relative or absolute)"
2815 },
2816 "offset": {
2817 "type": "integer",
2818 "description": "Line number to start reading from (1-indexed)"
2819 },
2820 "limit": {
2821 "type": "integer",
2822 "description": "Maximum number of lines to read"
2823 },
2824 "hashline": {
2825 "type": "boolean",
2826 "description": "When true, output each line as N#AB:content where N is the line number and AB is a content hash. Use with hashline_edit tool for precise edits."
2827 }
2828 },
2829 "required": ["path"]
2830 })
2831 }
2832
2833 fn effects(&self) -> ToolEffects {
2834 ToolEffects::read()
2835 }
2836
2837 #[allow(clippy::too_many_lines)]
2838 async fn execute(
2839 &self,
2840 tool_call_id: &str,
2841 input: serde_json::Value,
2842 _on_update: Option<Box<dyn Fn(ToolUpdate) + Send + Sync>>,
2843 ) -> Result<ToolOutput> {
2844 let input_value = input.clone();
2845 let input: ReadInput =
2846 serde_json::from_value(input).map_err(|e| Error::validation(e.to_string()))?;
2847
2848 if matches!(input.limit, Some(limit) if limit <= 0) {
2849 return Err(Error::validation(
2850 "`limit` must be greater than 0".to_string(),
2851 ));
2852 }
2853 if matches!(input.offset, Some(offset) if offset < 0) {
2854 return Err(Error::validation(
2855 "`offset` must be non-negative".to_string(),
2856 ));
2857 }
2858
2859 let path = resolve_read_path(&input.path, &self.cwd);
2860 let path = enforce_read_scope(&path, &self.cwd)?;
2861
2862 let meta = asupersync::fs::metadata(&path).await.ok();
2863 if let Some(meta) = &meta {
2864 if !meta.is_file() {
2865 return Err(Error::tool(
2866 "read",
2867 format!("Path {} is not a regular file", path.display()),
2868 ));
2869 }
2870 }
2871
2872 let cache_key = tool_cache_key("read", &self.cwd, &input_value);
2873 let cache_mode = ToolCacheFingerprintMode::FileContent;
2874 let cache_deps = cache_dependency_for_path(&path, cache_mode);
2875 if let Some(output) = cached_tool_output(&cache_key, cache_deps.as_deref()) {
2876 return Ok(output);
2877 }
2878
2879 let mut file = asupersync::fs::File::open(&path)
2880 .await
2881 .map_err(|e| Error::tool("read", e.to_string()))?;
2882
2883 let mut buffer = [0u8; 8192];
2885 let mut initial_read = 0;
2886 loop {
2887 let n = read_some(&mut file, &mut buffer[initial_read..])
2888 .await
2889 .map_err(|e| Error::tool("read", format!("Failed to read file: {e}")))?;
2890 if n == 0 {
2891 break;
2892 }
2893 initial_read += n;
2894 if initial_read == buffer.len() {
2895 break;
2896 }
2897 }
2898 let initial_bytes = &buffer[..initial_read];
2899
2900 if let Some(mime_type) = detect_supported_image_mime_type_from_bytes(initial_bytes) {
2901 if self.block_images {
2902 return Err(Error::tool(
2903 "read",
2904 "Images are blocked by configuration".to_string(),
2905 ));
2906 }
2907
2908 let max_image_input_bytes = usize::try_from(READ_TOOL_MAX_BYTES).unwrap_or(usize::MAX);
2912 if let Some(meta) = &meta {
2913 if meta.len() > READ_TOOL_MAX_BYTES {
2914 return Err(Error::tool(
2915 "read",
2916 format!(
2917 "Image is too large ({} bytes). Max allowed is {} bytes.",
2918 meta.len(),
2919 READ_TOOL_MAX_BYTES
2920 ),
2921 ));
2922 }
2923 }
2924 let mut all_bytes = Vec::with_capacity(initial_read);
2925 all_bytes.extend_from_slice(initial_bytes);
2926
2927 let remaining_limit = max_image_input_bytes.saturating_sub(initial_read);
2928 let mut limiter = file.take((remaining_limit as u64).saturating_add(1));
2929 limiter
2930 .read_to_end(&mut all_bytes)
2931 .await
2932 .map_err(|e| Error::tool("read", format!("Failed to read image: {e}")))?;
2933
2934 if all_bytes.len() > max_image_input_bytes {
2935 return Err(Error::tool(
2936 "read",
2937 format!(
2938 "Image is too large ({} bytes). Max allowed is {} bytes.",
2939 all_bytes.len(),
2940 READ_TOOL_MAX_BYTES
2941 ),
2942 ));
2943 }
2944
2945 let resized = if self.auto_resize {
2946 resize_image_if_needed(&all_bytes, mime_type)?
2947 } else {
2948 ResizedImage::original(all_bytes, mime_type)
2949 };
2950
2951 if resized.bytes.len() > IMAGE_MAX_BYTES {
2952 let message = if resized.resized {
2953 format!(
2954 "Image is too large ({} bytes) after resizing. Max allowed is {} bytes.",
2955 resized.bytes.len(),
2956 IMAGE_MAX_BYTES
2957 )
2958 } else {
2959 format!(
2960 "Image is too large ({} bytes). Max allowed is {} bytes.",
2961 resized.bytes.len(),
2962 IMAGE_MAX_BYTES
2963 )
2964 };
2965 return Err(Error::tool("read", message));
2966 }
2967
2968 let base64_data =
2969 base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &resized.bytes);
2970
2971 let mut note = format!("Read image file [{}]", resized.mime_type);
2972 if resized.resized {
2973 if let (Some(ow), Some(oh), Some(w), Some(h)) = (
2974 resized.original_width,
2975 resized.original_height,
2976 resized.width,
2977 resized.height,
2978 ) {
2979 if w > 0 {
2980 let scale = f64::from(ow) / f64::from(w);
2981 let _ = write!(
2982 note,
2983 "\n[Image: original {ow}x{oh}, displayed at {w}x{h}. Multiply coordinates by {scale:.2} to map to original image.]"
2984 );
2985 } else {
2986 let _ =
2987 write!(note, "\n[Image: original {ow}x{oh}, displayed at {w}x{h}.]");
2988 }
2989 }
2990 }
2991
2992 return Ok(ToolOutput {
2993 content: vec![
2994 ContentBlock::Text(TextContent::new(note)),
2995 ContentBlock::Image(ImageContent {
2996 data: base64_data,
2997 mime_type: resized.mime_type.to_string(),
2998 }),
2999 ],
3000 details: None,
3001 is_error: false,
3002 });
3003 }
3004
3005 if initial_read > 0 {
3012 file.seek(SeekFrom::Start(0))
3013 .await
3014 .map_err(|e| Error::tool("read", format!("Failed to seek: {e}")))?;
3015 }
3016
3017 let mut raw_content = Vec::new();
3018 let mut newlines_seen = 0usize;
3019
3020 let start_line_idx = match input.offset {
3022 Some(n) if n > 0 => n.saturating_sub(1).try_into().unwrap_or(usize::MAX),
3023 _ => 0,
3024 };
3025 let limit_lines = input
3026 .limit
3027 .map_or(usize::MAX, |l| l.try_into().unwrap_or(usize::MAX));
3028 let end_line_idx = start_line_idx.saturating_add(limit_lines);
3029
3030 let mut collecting = start_line_idx == 0;
3031 let mut buf = vec![0u8; 64 * 1024].into_boxed_slice(); let mut last_byte_was_newline = false;
3033 let mut pending_cr = false;
3034
3035 let mut total_bytes_read = 0u64;
3039
3040 loop {
3041 let n = read_some(&mut file, &mut buf)
3042 .await
3043 .map_err(|e| Error::tool("read", e.to_string()))?;
3044 if n == 0 {
3045 break;
3046 }
3047 total_bytes_read = total_bytes_read.saturating_add(n as u64);
3048
3049 let chunk = normalize_line_endings_chunk(&buf[..n], &mut pending_cr);
3050 if chunk.is_empty() {
3051 continue;
3052 }
3053 last_byte_was_newline = chunk.last().is_some_and(|byte| *byte == b'\n');
3054 let mut chunk_cursor = 0;
3055
3056 for pos in memchr::memchr_iter(b'\n', &chunk) {
3057 if collecting {
3059 if newlines_seen + 1 == end_line_idx {
3061 if raw_content.len() < DEFAULT_MAX_BYTES {
3063 let remaining = DEFAULT_MAX_BYTES - raw_content.len();
3064 let slice_len = (pos + 1 - chunk_cursor).min(remaining);
3065 raw_content
3066 .extend_from_slice(&chunk[chunk_cursor..chunk_cursor + slice_len]);
3067 }
3068 collecting = false;
3069 chunk_cursor = pos + 1;
3070 }
3071 }
3072
3073 newlines_seen += 1;
3074
3075 if !collecting && newlines_seen == start_line_idx {
3077 collecting = true;
3078 chunk_cursor = pos + 1;
3079 }
3080 }
3081
3082 if collecting && chunk_cursor < chunk.len() && raw_content.len() < DEFAULT_MAX_BYTES {
3084 let remaining = DEFAULT_MAX_BYTES - raw_content.len();
3085 let slice_len = (chunk.len() - chunk_cursor).min(remaining);
3086 raw_content.extend_from_slice(&chunk[chunk_cursor..chunk_cursor + slice_len]);
3087 }
3088 }
3089
3090 if pending_cr {
3091 last_byte_was_newline = true;
3092 if collecting && raw_content.len() < DEFAULT_MAX_BYTES {
3093 raw_content.push(b'\n');
3094 }
3095 newlines_seen += 1;
3096 }
3097
3098 let total_lines = if total_bytes_read == 0 {
3101 0
3102 } else if last_byte_was_newline {
3103 newlines_seen
3104 } else {
3105 newlines_seen + 1
3106 };
3107 let text_content = String::from_utf8_lossy(&raw_content).into_owned();
3108
3109 if total_lines == 0 {
3112 if input.offset.unwrap_or(0) > 0 {
3113 let offset_display = input.offset.unwrap_or(0);
3114 return Err(Error::tool(
3115 "read",
3116 format!(
3117 "Offset {offset_display} is beyond end of file ({total_lines} lines total)"
3118 ),
3119 ));
3120 }
3121 let output = ToolOutput {
3122 content: vec![ContentBlock::Text(TextContent::new(""))],
3123 details: None,
3124 is_error: false,
3125 };
3126 cache_tool_output(
3127 cache_key,
3128 stable_cache_dependency_for_path(&path, cache_mode, cache_deps.as_deref()),
3129 &output,
3130 );
3131 return Ok(output);
3132 }
3133
3134 let start_line = start_line_idx;
3138 let start_line_display = start_line.saturating_add(1);
3139
3140 if start_line >= total_lines {
3141 let offset_display = input.offset.unwrap_or(0);
3142 return Err(Error::tool(
3143 "read",
3144 format!(
3145 "Offset {offset_display} is beyond end of file ({total_lines} lines total)"
3146 ),
3147 ));
3148 }
3149
3150 let max_lines_for_truncation = input
3151 .limit
3152 .and_then(|l| usize::try_from(l).ok())
3153 .unwrap_or(DEFAULT_MAX_LINES);
3154 let display_limit = max_lines_for_truncation.saturating_add(1);
3155
3156 let lines_to_take = limit_lines.min(display_limit);
3160
3161 let mut selected_content = String::new();
3162 let line_iter = text_content.split('\n');
3163
3164 let effective_iter = if text_content.ends_with('\n') {
3166 line_iter.take(lines_to_take)
3167 } else {
3168 line_iter.take(usize::MAX)
3169 };
3170
3171 let max_line_num = start_line.saturating_add(lines_to_take).min(total_lines);
3172 let line_num_width = max_line_num.to_string().len().max(5);
3173
3174 for (i, line) in effective_iter.enumerate() {
3175 if i >= lines_to_take || start_line + i >= total_lines {
3176 break;
3177 }
3178 if i > 0 {
3179 selected_content.push('\n');
3180 }
3181 let line_idx = start_line + i; let line = line.strip_suffix('\r').unwrap_or(line);
3183 if input.hashline {
3184 let tag = format_hashline_tag(line_idx, line);
3185 let _ = write!(selected_content, "{tag}:{line}");
3186 } else {
3187 let line_num = line_idx + 1;
3188 let _ = write!(selected_content, "{line_num:>line_num_width$}→{line}");
3189 }
3190
3191 if selected_content.len() > DEFAULT_MAX_BYTES * 2 {
3192 break;
3193 }
3194 }
3195
3196 let artifact_source = (selected_content.len() > TOOL_OUTPUT_ARTIFACT_THRESHOLD_BYTES)
3197 .then(|| selected_content.clone());
3198
3199 let mut truncation = truncate_head(
3200 selected_content,
3201 max_lines_for_truncation,
3202 DEFAULT_MAX_BYTES,
3203 );
3204 truncation.total_lines = total_lines;
3205
3206 let mut output_text = std::mem::take(&mut truncation.content);
3207 let mut details: Option<serde_json::Value> = None;
3208
3209 if truncation.first_line_exceeds_limit {
3210 let first_line = text_content.split('\n').next().unwrap_or("");
3211 let first_line = first_line.strip_suffix('\r').unwrap_or(first_line);
3212 let first_line_size = format_size(first_line.len());
3213 output_text = format!(
3214 "[Line {start_line_display} is {first_line_size}, exceeds {} limit. Use bash: sed -n '{start_line_display}p' '{}' | head -c {DEFAULT_MAX_BYTES}]",
3215 format_size(DEFAULT_MAX_BYTES),
3216 input.path.replace('\'', "'\\''")
3217 );
3218 details = Some(serde_json::json!({ "truncation": truncation }));
3219 } else if truncation.truncated {
3220 let end_line_display = start_line_display
3221 .saturating_add(truncation.output_lines)
3222 .saturating_sub(1);
3223 let next_offset = end_line_display.saturating_add(1);
3224
3225 if truncation.truncated_by == Some(TruncatedBy::Lines) {
3226 let _ = write!(
3227 output_text,
3228 "\n\n[Showing lines {start_line_display}-{end_line_display} of {total_lines}. Use offset={next_offset} to continue.]"
3229 );
3230 } else {
3231 let _ = write!(
3232 output_text,
3233 "\n\n[Showing lines {start_line_display}-{end_line_display} of {total_lines} ({} limit). Use offset={next_offset} to continue.]",
3234 format_size(DEFAULT_MAX_BYTES)
3235 );
3236 }
3237
3238 details = Some(serde_json::json!({ "truncation": truncation }));
3239 } else {
3240 let displayed_lines = truncation.output_lines;
3242 let end_line_display = start_line_display
3243 .saturating_add(displayed_lines)
3244 .saturating_sub(1);
3245
3246 if end_line_display < total_lines {
3247 let remaining = total_lines.saturating_sub(end_line_display);
3248 let next_offset = end_line_display.saturating_add(1);
3249 let _ = write!(
3250 output_text,
3251 "\n\n[{remaining} more lines in file. Use offset={next_offset} to continue.]"
3252 );
3253 }
3254 }
3255
3256 if let Some(artifact_source) = artifact_source.as_deref() {
3257 attach_text_artifact_if_needed_with_root(
3258 self.artifact_root.as_deref(),
3259 &mut output_text,
3260 &mut details,
3261 "read",
3262 tool_call_id,
3263 "selectedTextWindow",
3264 artifact_source,
3265 );
3266 }
3267
3268 let output = ToolOutput {
3269 content: vec![ContentBlock::Text(TextContent::new(output_text))],
3270 details,
3271 is_error: false,
3272 };
3273 cache_tool_output(
3274 cache_key,
3275 stable_cache_dependency_for_path(&path, cache_mode, cache_deps.as_deref()),
3276 &output,
3277 );
3278 Ok(output)
3279 }
3280}
3281
3282#[derive(Debug, Deserialize)]
3288#[serde(rename_all = "camelCase")]
3289struct BashInput {
3290 command: String,
3291 timeout: Option<u64>,
3292}
3293
3294pub struct BashTool {
3295 cwd: PathBuf,
3296 shell_path: Option<String>,
3297 command_prefix: Option<String>,
3298 artifact_root: Option<PathBuf>,
3299}
3300
3301#[derive(Debug, Clone)]
3302pub struct BashRunResult {
3303 pub output: String,
3304 pub exit_code: i32,
3305 pub cancelled: bool,
3306 pub cancellation_reason: Option<BashCancellationReason>,
3307 pub timeout_ms: Option<u64>,
3308 pub truncated: bool,
3309 pub full_output_path: Option<String>,
3310 pub truncation: Option<TruncationResult>,
3311}
3312
3313#[derive(Debug)]
3314enum BashPipeFrame {
3315 Chunk(Vec<u8>),
3316 Error(String),
3317}
3318
3319#[allow(clippy::unnecessary_lazy_evaluations)] fn exit_status_code(status: std::process::ExitStatus) -> i32 {
3321 status.code().unwrap_or_else(|| {
3322 #[cfg(unix)]
3323 {
3324 use std::os::unix::process::ExitStatusExt as _;
3325 status.signal().map_or(-1, |signal| -signal)
3326 }
3327 #[cfg(not(unix))]
3328 {
3329 -1
3330 }
3331 })
3332}
3333
3334fn bash_cancellation_details(
3335 reason: BashCancellationReason,
3336 timeout_ms: Option<u64>,
3337 exit_code: i32,
3338) -> serde_json::Value {
3339 serde_json::json!({
3340 "schema": BASH_CANCELLATION_SCHEMA_V1,
3341 "status": "cancelled",
3342 "reason": reason.as_str(),
3343 "cleanup": "process_group_tree_terminated",
3344 "exitCode": exit_code,
3345 "timeoutMs": timeout_ms,
3346 })
3347}
3348
3349#[allow(clippy::too_many_lines)]
3350pub(crate) async fn run_bash_command(
3351 cwd: &Path,
3352 shell_path: Option<&str>,
3353 command_prefix: Option<&str>,
3354 command: &str,
3355 timeout_secs: Option<u64>,
3356 on_update: Option<&(dyn Fn(ToolUpdate) + Send + Sync)>,
3357) -> Result<BashRunResult> {
3358 let timeout_secs = match timeout_secs {
3359 None => Some(DEFAULT_BASH_TIMEOUT_SECS),
3360 Some(0) => None,
3361 Some(value) => Some(value),
3362 };
3363 let command = command_prefix.filter(|p| !p.trim().is_empty()).map_or_else(
3364 || command.to_string(),
3365 |prefix| format!("{prefix}\n{command}"),
3366 );
3367 let command = format!("trap 'code=$?; wait; exit $code' EXIT\n{command}");
3368
3369 if !cwd.exists() {
3370 return Err(Error::tool(
3371 "bash",
3372 format!(
3373 "Working directory does not exist: {}\nCannot execute bash commands.",
3374 cwd.display()
3375 ),
3376 ));
3377 }
3378
3379 let shell = shell_path.unwrap_or_else(|| {
3380 for path in ["/bin/bash", "/usr/bin/bash", "/usr/local/bin/bash"] {
3381 if Path::new(path).exists() {
3382 return path;
3383 }
3384 }
3385 "sh"
3386 });
3387
3388 let mut cmd = command_with_default_sigpipe_in_dir(shell, cwd)
3389 .map_err(|e| Error::tool("bash", format!("Failed to prepare shell: {e}")))?;
3390 cmd.arg("-c")
3391 .arg(&command)
3392 .current_dir(cwd)
3393 .stdin(Stdio::null())
3394 .stdout(Stdio::piped())
3395 .stderr(Stdio::piped());
3396
3397 isolate_command_process_group(&mut cmd);
3400
3401 let mut child = cmd
3402 .spawn()
3403 .map_err(|e| Error::tool("bash", format!("Failed to spawn shell: {e}")))?;
3404
3405 let stdout = child
3406 .stdout
3407 .take()
3408 .ok_or_else(|| Error::tool("bash", "Missing stdout".to_string()))?;
3409 let stderr = child
3410 .stderr
3411 .take()
3412 .ok_or_else(|| Error::tool("bash", "Missing stderr".to_string()))?;
3413
3414 let mut guard = ProcessGuard::new(child, ProcessCleanupMode::ProcessGroupTree);
3416
3417 let (tx, rx) = mpsc::sync_channel::<BashPipeFrame>(1024);
3423 let tx_stdout = tx.clone();
3424
3425 let stdout_thread = thread::spawn(move || pump_stream(stdout, "stdout", &tx_stdout));
3433 let stderr_thread = thread::spawn(move || pump_stream(stderr, "stderr", &tx));
3434
3435 let max_chunks_bytes = DEFAULT_MAX_BYTES.saturating_mul(2);
3436 let mut bash_output = BashOutputState::new(max_chunks_bytes);
3437 bash_output.timeout_ms = timeout_secs.map(|s| s.saturating_mul(1000));
3438
3439 let cx = AgentCx::for_current_or_request();
3440 let mut timed_out = false;
3441 let mut cancelled = false;
3442 let mut cancellation_reason: Option<BashCancellationReason> = None;
3443 let mut exit_code: Option<i32> = None;
3444 let start = cx
3445 .cx()
3446 .timer_driver()
3447 .map_or_else(wall_now, |timer| timer.now());
3448 let timeout = timeout_secs.map(Duration::from_secs);
3449 let mut terminate_deadline: Option<asupersync::Time> = None;
3450
3451 let tick = Duration::from_millis(10);
3452 loop {
3453 let mut updated = false;
3454 while let Ok(frame) = rx.try_recv() {
3455 if let Err(err) = ingest_bash_pipe_frame(frame, &mut bash_output).await {
3456 let _ = guard.kill();
3457 return Err(err);
3458 }
3459 updated = true;
3460 }
3461
3462 if updated {
3463 emit_bash_update(&bash_output, on_update)?;
3464 }
3465
3466 match guard.try_wait_child() {
3467 Ok(Some(status)) => {
3468 exit_code = Some(exit_status_code(status));
3469 break;
3470 }
3471 Ok(None) => {}
3472 Err(err) => return Err(Error::tool("bash", err.to_string())),
3473 }
3474
3475 let now = cx
3476 .cx()
3477 .timer_driver()
3478 .map_or_else(wall_now, |timer| timer.now());
3479
3480 if let Some(deadline) = terminate_deadline {
3481 if now >= deadline {
3482 if let Some(status) = guard.kill() {
3483 exit_code = Some(exit_status_code(status));
3484 }
3485 break; }
3487 } else if let Some(timeout) = timeout {
3488 let elapsed = std::time::Duration::from_nanos(now.duration_since(start));
3489 if elapsed >= timeout {
3490 timed_out = true;
3491 cancellation_reason = Some(BashCancellationReason::Timeout);
3492 let pid = guard.child.as_ref().map(std::process::Child::id);
3493 terminate_process_group_tree(pid);
3494 terminate_deadline = Some(now + Duration::from_secs(BASH_TERMINATE_GRACE_SECS));
3495 }
3496 }
3497
3498 if terminate_deadline.is_none() && cx.checkpoint().is_err() {
3499 cancelled = true;
3500 cancellation_reason = Some(BashCancellationReason::AmbientCancellation);
3501 let _ = guard.kill();
3502 exit_code = Some(-1);
3503 break;
3504 }
3505
3506 sleep(now, tick).await;
3507 }
3508
3509 {
3516 let drain_start = cx
3517 .cx()
3518 .timer_driver()
3519 .map_or_else(wall_now, |timer| timer.now());
3520 let drain_deadline = drain_start + Duration::from_secs(5);
3521 let allow_drain_cancellation = !cancelled && !timed_out && exit_code.is_none();
3522 loop {
3523 let mut got_data = false;
3525 while let Ok(frame) = rx.try_recv() {
3526 if let Err(err) = ingest_bash_pipe_frame(frame, &mut bash_output).await {
3527 let _ = guard.kill();
3528 return Err(err);
3529 }
3530 got_data = true;
3531 }
3532 if got_data {
3533 emit_bash_update(&bash_output, on_update)?;
3534 }
3535
3536 if stdout_thread.is_finished() && stderr_thread.is_finished() {
3539 while let Ok(frame) = rx.try_recv() {
3542 if let Err(err) = ingest_bash_pipe_frame(frame, &mut bash_output).await {
3543 let _ = guard.kill();
3544 return Err(err);
3545 }
3546 }
3547 break;
3548 }
3549
3550 let now = cx
3551 .cx()
3552 .timer_driver()
3553 .map_or_else(wall_now, |timer| timer.now());
3554 if now >= drain_deadline {
3555 break;
3556 }
3557 if allow_drain_cancellation && cx.checkpoint().is_err() {
3558 cancelled = true;
3559 cancellation_reason.get_or_insert(BashCancellationReason::AmbientCancellation);
3560 break;
3561 }
3562 sleep(now, tick).await;
3563 }
3564 }
3565
3566 if guard.child.is_some() {
3572 if let Ok(status) = guard.wait() {
3573 exit_code.get_or_insert_with(|| exit_status_code(status));
3574 }
3575 }
3576
3577 drop(bash_output.temp_file.take());
3578
3579 let raw_output = concat_chunks(&bash_output.chunks);
3580 let full_output = String::from_utf8_lossy(&raw_output).into_owned();
3581 let full_output_last_line_len = full_output.split('\n').next_back().map_or(0, str::len);
3582
3583 let mut truncation = truncate_tail(full_output, DEFAULT_MAX_LINES, DEFAULT_MAX_BYTES);
3584 if bash_output.total_bytes > bash_output.chunks_bytes {
3585 truncation.truncated = true;
3586 truncation.truncated_by = Some(TruncatedBy::Bytes);
3587 truncation.total_bytes = bash_output.total_bytes;
3588 truncation.total_lines = line_count_from_newline_count(
3589 bash_output.total_bytes,
3590 bash_output.line_count,
3591 bash_output.last_byte_was_newline,
3592 );
3593 }
3594
3595 let mut output_text = if truncation.content.is_empty() {
3596 "(no output)".to_string()
3597 } else {
3598 std::mem::take(&mut truncation.content)
3599 };
3600
3601 let mut full_output_path = None;
3602 if truncation.truncated {
3603 if let Some(path) = bash_output.temp_file_path.as_ref() {
3604 full_output_path = Some(path.display().to_string());
3605 }
3606
3607 let start_line = truncation
3608 .total_lines
3609 .saturating_sub(truncation.output_lines)
3610 .saturating_add(1);
3611 let end_line = truncation.total_lines;
3612
3613 let display_path = full_output_path.as_deref().unwrap_or("undefined");
3614 let file_limit_hit = bash_output.total_bytes > BASH_FILE_LIMIT_BYTES;
3615 let output_qualifier = if file_limit_hit {
3616 format!(
3617 "Partial output (capped at {})",
3618 format_size(BASH_FILE_LIMIT_BYTES)
3619 )
3620 } else {
3621 "Full output".to_string()
3622 };
3623
3624 if truncation.last_line_partial {
3625 let last_line_size = format_size(full_output_last_line_len);
3626 let _ = write!(
3627 output_text,
3628 "\n\n[Showing last {} of line {end_line} (line is {last_line_size}). {output_qualifier}: {display_path}]",
3629 format_size(truncation.output_bytes)
3630 );
3631 } else if truncation.truncated_by == Some(TruncatedBy::Lines) {
3632 let _ = write!(
3633 output_text,
3634 "\n\n[Showing lines {start_line}-{end_line} of {}. {output_qualifier}: {display_path}]",
3635 truncation.total_lines
3636 );
3637 } else {
3638 let _ = write!(
3639 output_text,
3640 "\n\n[Showing lines {start_line}-{end_line} of {} ({} limit). {output_qualifier}: {display_path}]",
3641 truncation.total_lines,
3642 format_size(DEFAULT_MAX_BYTES)
3643 );
3644 }
3645 }
3646
3647 if timed_out {
3648 cancelled = true;
3649 if !output_text.is_empty() {
3650 output_text.push_str("\n\n");
3651 }
3652 let timeout_display = timeout_secs.unwrap_or(0);
3653 let _ = write!(
3654 output_text,
3655 "Command timed out after {timeout_display} seconds"
3656 );
3657 }
3658
3659 let exit_code = exit_code.unwrap_or(-1);
3660 if !cancelled && exit_code != 0 {
3661 let _ = write!(output_text, "\n\nCommand exited with code {exit_code}");
3662 }
3663
3664 Ok(BashRunResult {
3665 output: output_text,
3666 exit_code,
3667 cancelled,
3668 cancellation_reason,
3669 timeout_ms: timeout_secs.map(|s| s.saturating_mul(1000)),
3670 truncated: truncation.truncated,
3671 full_output_path,
3672 truncation: if truncation.truncated {
3673 Some(truncation)
3674 } else {
3675 None
3676 },
3677 })
3678}
3679
3680impl BashTool {
3681 pub fn new(cwd: &Path) -> Self {
3682 Self {
3683 cwd: cwd.to_path_buf(),
3684 shell_path: None,
3685 command_prefix: None,
3686 artifact_root: None,
3687 }
3688 }
3689
3690 pub fn with_shell(
3691 cwd: &Path,
3692 shell_path: Option<String>,
3693 command_prefix: Option<String>,
3694 ) -> Self {
3695 Self {
3696 cwd: cwd.to_path_buf(),
3697 shell_path,
3698 command_prefix,
3699 artifact_root: None,
3700 }
3701 }
3702
3703 #[cfg(test)]
3704 fn with_artifact_root(cwd: &Path, artifact_root: &Path) -> Self {
3705 Self {
3706 cwd: cwd.to_path_buf(),
3707 shell_path: None,
3708 command_prefix: None,
3709 artifact_root: Some(artifact_root.to_path_buf()),
3710 }
3711 }
3712}
3713
3714#[async_trait]
3715#[allow(clippy::unnecessary_literal_bound)]
3716impl Tool for BashTool {
3717 fn name(&self) -> &str {
3718 "bash"
3719 }
3720 fn label(&self) -> &str {
3721 "bash"
3722 }
3723 fn description(&self) -> &str {
3724 "Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last 2000 lines or 1MB (whichever is hit first). If truncated, full output is saved to a temp file. `timeout` defaults to 120 seconds; set `timeout: 0` to disable."
3725 }
3726
3727 fn parameters(&self) -> serde_json::Value {
3728 serde_json::json!({
3729 "type": "object",
3730 "properties": {
3731 "command": {
3732 "type": "string",
3733 "description": "Bash command to execute"
3734 },
3735 "timeout": {
3736 "type": "integer",
3737 "description": "Timeout in seconds (default 120; set 0 to disable)"
3738 }
3739 },
3740 "required": ["command"]
3741 })
3742 }
3743
3744 fn effects(&self) -> ToolEffects {
3745 ToolEffects::process().union(ToolEffects::write())
3746 }
3747
3748 #[allow(clippy::too_many_lines)]
3749 async fn execute(
3750 &self,
3751 tool_call_id: &str,
3752 input: serde_json::Value,
3753 on_update: Option<Box<dyn Fn(ToolUpdate) + Send + Sync>>,
3754 ) -> Result<ToolOutput> {
3755 let input: BashInput =
3756 serde_json::from_value(input).map_err(|e| Error::validation(e.to_string()))?;
3757
3758 let result = run_bash_command(
3759 &self.cwd,
3760 self.shell_path.as_deref(),
3761 self.command_prefix.as_deref(),
3762 &input.command,
3763 input.timeout,
3764 on_update.as_deref(),
3765 )
3766 .await?;
3767
3768 let mut details_map = serde_json::Map::new();
3769 if let Some(truncation) = result.truncation.as_ref() {
3770 details_map.insert("truncation".to_string(), serde_json::to_value(truncation)?);
3771 }
3772 if let Some(path) = result.full_output_path.as_ref() {
3773 details_map.insert(
3774 "fullOutputPath".to_string(),
3775 serde_json::Value::String(path.clone()),
3776 );
3777 }
3778 if let Some(reason) = result.cancellation_reason {
3779 details_map.insert(
3780 "cancellation".to_string(),
3781 bash_cancellation_details(reason, result.timeout_ms, result.exit_code),
3782 );
3783 }
3784
3785 let details = if details_map.is_empty() {
3786 None
3787 } else {
3788 Some(serde_json::Value::Object(details_map))
3789 };
3790 let mut details = details;
3791 let mut output_text = result.output;
3792
3793 if let Some(path) = result.full_output_path.as_deref() {
3794 attach_text_artifact_from_path_if_needed_with_root(
3795 self.artifact_root.as_deref(),
3796 &mut output_text,
3797 &mut details,
3798 "bash",
3799 tool_call_id,
3800 "fullCommandOutput",
3801 Path::new(path),
3802 );
3803 }
3804
3805 let is_error = result.cancelled || result.exit_code != 0;
3806
3807 Ok(ToolOutput {
3808 content: vec![ContentBlock::Text(TextContent::new(output_text))],
3809 details,
3810 is_error,
3811 })
3812 }
3813}
3814
3815#[derive(Debug, Deserialize)]
3821#[serde(rename_all = "camelCase")]
3822struct EditInput {
3823 path: String,
3824 old_text: String,
3825 new_text: String,
3826}
3827
3828pub struct EditTool {
3829 cwd: PathBuf,
3830}
3831
3832impl EditTool {
3833 pub fn new(cwd: &Path) -> Self {
3834 Self {
3835 cwd: cwd.to_path_buf(),
3836 }
3837 }
3838}
3839
3840fn strip_bom(s: &str) -> (&str, bool) {
3841 s.strip_prefix('\u{FEFF}')
3842 .map_or_else(|| (s, false), |stripped| (stripped, true))
3843}
3844
3845fn detect_line_ending(content: &str) -> &'static str {
3846 let bytes = content.as_bytes();
3847 let mut idx = 0;
3848 while idx < bytes.len() {
3849 match bytes[idx] {
3850 b'\r' => {
3851 return if bytes.get(idx + 1) == Some(&b'\n') {
3852 "\r\n"
3853 } else {
3854 "\r"
3855 };
3856 }
3857 b'\n' => return "\n",
3858 _ => idx += 1,
3859 }
3860 }
3861 "\n"
3862}
3863
3864fn normalize_to_lf(text: &str) -> String {
3865 if !text.contains('\r') {
3866 return text.to_string();
3867 }
3868 let mut out = String::with_capacity(text.len());
3869 let mut chars = text.chars().peekable();
3870 while let Some(c) = chars.next() {
3871 if c == '\r' {
3872 out.push('\n');
3873 if chars.peek() == Some(&'\n') {
3874 chars.next();
3875 }
3876 } else {
3877 out.push(c);
3878 }
3879 }
3880 out
3881}
3882
3883fn normalize_line_endings_chunk<'a>(
3884 chunk: &'a [u8],
3885 pending_cr: &mut bool,
3886) -> std::borrow::Cow<'a, [u8]> {
3887 if !*pending_cr && memchr::memchr(b'\r', chunk).is_none() {
3888 return std::borrow::Cow::Borrowed(chunk);
3889 }
3890
3891 let mut normalized = Vec::with_capacity(chunk.len().saturating_add(usize::from(*pending_cr)));
3892 let mut idx = 0;
3893
3894 if *pending_cr {
3895 normalized.push(b'\n');
3896 if chunk.first() == Some(&b'\n') {
3897 idx = 1;
3898 }
3899 *pending_cr = false;
3900 }
3901
3902 while idx < chunk.len() {
3903 match chunk[idx] {
3904 b'\r' => {
3905 if chunk.get(idx + 1) == Some(&b'\n') {
3906 normalized.push(b'\n');
3907 idx += 2;
3908 } else if idx + 1 < chunk.len() {
3909 normalized.push(b'\n');
3910 idx += 1;
3911 } else {
3912 *pending_cr = true;
3913 idx += 1;
3914 }
3915 }
3916 byte => {
3917 normalized.push(byte);
3918 idx += 1;
3919 }
3920 }
3921 }
3922
3923 std::borrow::Cow::Owned(normalized)
3924}
3925
3926fn restore_line_endings(text: &str, ending: &str) -> String {
3927 match ending {
3928 "\r\n" => text.replace('\n', "\r\n"),
3929 "\r" => text.replace('\n', "\r"),
3930 _ => text.to_string(),
3931 }
3932}
3933
3934#[derive(Debug, Clone)]
3935struct FuzzyMatchResult {
3936 found: bool,
3937 index: usize,
3938 match_length: usize,
3939 exact_match: bool,
3940}
3941
3942fn map_normalized_range_to_original(
3946 content: &str,
3947 norm_match_start: usize,
3948 norm_match_len: usize,
3949) -> (usize, usize) {
3950 let mut norm_idx = 0;
3951 let mut orig_idx = 0;
3952 let mut match_start = None;
3953 let mut match_end = None;
3954 let norm_match_end = norm_match_start + norm_match_len;
3955 let mut last_trimmed_end = 0;
3956 let mut last_has_newline = false;
3957
3958 for line in content.split_inclusive('\n') {
3959 let line_content = line.strip_suffix('\n').unwrap_or(line);
3960 let has_newline = line.ends_with('\n');
3961 let trimmed_len = line_content
3962 .trim_end_matches(|c: char| c.is_whitespace() || is_special_unicode_space(c))
3963 .len();
3964 let trimmed_end = orig_idx + trimmed_len;
3965 last_trimmed_end = trimmed_end;
3966 last_has_newline = has_newline;
3967
3968 for (char_offset, c) in line_content.char_indices() {
3969 if norm_idx == norm_match_end && match_end.is_none() {
3972 match_end = Some(orig_idx + char_offset);
3973 }
3974
3975 if char_offset >= trimmed_len {
3976 continue;
3977 }
3978
3979 if norm_idx == norm_match_start && match_start.is_none() {
3984 match_start = Some(orig_idx + char_offset);
3985 }
3986 if match_start.is_some() && match_end.is_some() {
3987 break;
3988 }
3989
3990 let normalized_char = if is_special_unicode_space(c) {
3991 ' '
3992 } else if matches!(c, '\u{2018}' | '\u{2019}') {
3993 '\''
3994 } else if matches!(c, '\u{201C}' | '\u{201D}' | '\u{201E}' | '\u{201F}') {
3995 '"'
3996 } else if matches!(
3997 c,
3998 '\u{2010}'
3999 | '\u{2011}'
4000 | '\u{2012}'
4001 | '\u{2013}'
4002 | '\u{2014}'
4003 | '\u{2015}'
4004 | '\u{2212}'
4005 ) {
4006 '-'
4007 } else {
4008 c
4009 };
4010
4011 norm_idx += normalized_char.len_utf8();
4012 }
4013
4014 orig_idx += line_content.len();
4015
4016 if has_newline {
4017 if norm_idx == norm_match_start && match_start.is_none() {
4018 match_start = Some(orig_idx);
4019 }
4020 if norm_idx == norm_match_end && match_end.is_none() {
4021 match_end = Some(trimmed_end);
4022 }
4023
4024 norm_idx += 1;
4025 orig_idx += 1;
4026 }
4027
4028 if match_start.is_some() && match_end.is_some() {
4029 break;
4030 }
4031 }
4032
4033 if norm_idx == norm_match_end && match_end.is_none() {
4034 match_end = Some(if last_has_newline {
4035 orig_idx
4036 } else {
4037 last_trimmed_end
4038 });
4039 }
4040
4041 let start = match_start.unwrap_or(0);
4042 let end = match_end.unwrap_or(content.len());
4043 (start, end.saturating_sub(start))
4044}
4045
4046fn build_normalized_content(content: &str) -> String {
4047 let mut normalized = String::with_capacity(content.len());
4048 let mut lines = content.split('\n').peekable();
4049
4050 while let Some(line) = lines.next() {
4051 let trimmed_len = line
4052 .trim_end_matches(|c: char| c.is_whitespace() || is_special_unicode_space(c))
4053 .len();
4054 for (char_offset, c) in line.char_indices() {
4055 if char_offset >= trimmed_len {
4056 continue;
4057 }
4058 let normalized_char = if is_special_unicode_space(c) {
4059 ' '
4060 } else if matches!(c, '\u{2018}' | '\u{2019}') {
4061 '\''
4062 } else if matches!(c, '\u{201C}' | '\u{201D}' | '\u{201E}' | '\u{201F}') {
4063 '"'
4064 } else if matches!(
4065 c,
4066 '\u{2010}'
4067 | '\u{2011}'
4068 | '\u{2012}'
4069 | '\u{2013}'
4070 | '\u{2014}'
4071 | '\u{2015}'
4072 | '\u{2212}'
4073 ) {
4074 '-'
4075 } else {
4076 c
4077 };
4078 normalized.push(normalized_char);
4079 }
4080 if lines.peek().is_some() {
4081 normalized.push('\n');
4082 }
4083 }
4084 normalized
4085}
4086
4087#[cfg(test)]
4088fn fuzzy_find_text(content: &str, old_text: &str) -> FuzzyMatchResult {
4089 fuzzy_find_text_with_normalized(content, old_text, None, None)
4090}
4091
4092fn fuzzy_find_text_with_normalized(
4095 content: &str,
4096 old_text: &str,
4097 precomputed_content: Option<&str>,
4098 precomputed_old: Option<&str>,
4099) -> FuzzyMatchResult {
4100 use std::borrow::Cow;
4101
4102 if let Some(index) = content.find(old_text) {
4104 return FuzzyMatchResult {
4105 found: true,
4106 index,
4107 match_length: old_text.len(),
4108 exact_match: true,
4109 };
4110 }
4111
4112 let normalized_content = precomputed_content.map_or_else(
4114 || Cow::Owned(build_normalized_content(content)),
4115 Cow::Borrowed,
4116 );
4117 let normalized_old_text = precomputed_old.map_or_else(
4118 || Cow::Owned(build_normalized_content(old_text)),
4119 Cow::Borrowed,
4120 );
4121
4122 if let Some(normalized_index) = normalized_content.find(normalized_old_text.as_ref()) {
4124 let (original_start, original_match_len) =
4125 map_normalized_range_to_original(content, normalized_index, normalized_old_text.len());
4126
4127 return FuzzyMatchResult {
4128 found: true,
4129 index: original_start,
4130 match_length: original_match_len,
4131 exact_match: false,
4132 };
4133 }
4134
4135 FuzzyMatchResult {
4136 found: false,
4137 index: 0,
4138 match_length: 0,
4139 exact_match: false,
4140 }
4141}
4142
4143fn count_overlapping_occurrences(haystack: &str, needle: &str) -> usize {
4144 if needle.is_empty() {
4145 return 0;
4146 }
4147
4148 haystack
4149 .char_indices()
4150 .filter(|(idx, _)| haystack[*idx..].starts_with(needle))
4151 .count()
4152}
4153
4154#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4155enum DiffTag {
4156 Equal,
4157 Added,
4158 Removed,
4159}
4160
4161#[derive(Debug, Clone)]
4162struct DiffPart {
4163 tag: DiffTag,
4164 value: String,
4165}
4166
4167fn diff_parts(old_content: &str, new_content: &str) -> Vec<DiffPart> {
4168 use similar::ChangeTag;
4169
4170 let diff = similar::TextDiff::from_lines(old_content, new_content);
4171
4172 let mut parts: Vec<DiffPart> = Vec::new();
4173 let mut current_tag: Option<DiffTag> = None;
4174 let mut current_lines: Vec<&str> = Vec::new();
4175
4176 for change in diff.iter_all_changes() {
4177 let tag = match change.tag() {
4178 ChangeTag::Equal => DiffTag::Equal,
4179 ChangeTag::Insert => DiffTag::Added,
4180 ChangeTag::Delete => DiffTag::Removed,
4181 };
4182
4183 let mut line = change.value();
4184 if let Some(stripped) = line.strip_suffix('\n') {
4185 line = stripped;
4186 }
4187
4188 if current_tag == Some(tag) {
4189 current_lines.push(line);
4190 } else {
4191 if let Some(prev_tag) = current_tag {
4192 parts.push(DiffPart {
4193 tag: prev_tag,
4194 value: current_lines.join("\n"),
4195 });
4196 }
4197 current_tag = Some(tag);
4198 current_lines = vec![line];
4199 }
4200 }
4201
4202 if let Some(tag) = current_tag {
4203 parts.push(DiffPart {
4204 tag,
4205 value: current_lines.join("\n"),
4206 });
4207 }
4208
4209 parts
4210}
4211
4212fn diff_line_num_width(old_content: &str, new_content: &str) -> usize {
4213 let old_line_count = memchr::memchr_iter(b'\n', old_content.as_bytes()).count() + 1;
4215 let new_line_count = memchr::memchr_iter(b'\n', new_content.as_bytes()).count() + 1;
4216 let max_line_num = old_line_count.max(new_line_count).max(1);
4217 max_line_num.ilog10() as usize + 1
4218}
4219
4220fn split_diff_lines(value: &str) -> Vec<&str> {
4221 value.split('\n').collect()
4227}
4228
4229#[inline]
4230const fn is_change_tag(tag: DiffTag) -> bool {
4231 matches!(tag, DiffTag::Added | DiffTag::Removed)
4232}
4233
4234#[derive(Debug)]
4235struct DiffRenderState {
4236 output: String,
4237 old_line_num: usize,
4238 new_line_num: usize,
4239 last_was_change: bool,
4240 first_changed_line: Option<usize>,
4241 line_num_width: usize,
4242 context_lines: usize,
4243}
4244
4245impl DiffRenderState {
4246 const fn new(line_num_width: usize, context_lines: usize) -> Self {
4247 Self {
4248 output: String::new(),
4249 old_line_num: 1,
4250 new_line_num: 1,
4251 last_was_change: false,
4252 first_changed_line: None,
4253 line_num_width,
4254 context_lines,
4255 }
4256 }
4257
4258 #[inline]
4259 fn ensure_line_break(&mut self) {
4260 if !self.output.is_empty() {
4261 self.output.push('\n');
4262 }
4263 }
4264
4265 const fn mark_first_change(&mut self) {
4266 if self.first_changed_line.is_none() {
4267 self.first_changed_line = Some(self.new_line_num);
4268 }
4269 }
4270
4271 fn push_added_line(&mut self, line: &str) {
4272 self.ensure_line_break();
4273 let _ = write!(
4274 self.output,
4275 "+{line_num:>width$} {line}",
4276 line_num = self.new_line_num,
4277 width = self.line_num_width
4278 );
4279 self.new_line_num = self.new_line_num.saturating_add(1);
4280 }
4281
4282 fn push_removed_line(&mut self, line: &str) {
4283 self.ensure_line_break();
4284 let _ = write!(
4285 self.output,
4286 "-{line_num:>width$} {line}",
4287 line_num = self.old_line_num,
4288 width = self.line_num_width
4289 );
4290 self.old_line_num = self.old_line_num.saturating_add(1);
4291 }
4292
4293 fn push_context_line(&mut self, line: &str) {
4294 self.ensure_line_break();
4295 let _ = write!(
4296 self.output,
4297 " {line_num:>width$} {line}",
4298 line_num = self.old_line_num,
4299 width = self.line_num_width
4300 );
4301 self.old_line_num = self.old_line_num.saturating_add(1);
4302 self.new_line_num = self.new_line_num.saturating_add(1);
4303 }
4304
4305 fn push_skip_marker(&mut self, skip: usize) {
4306 if skip == 0 {
4307 return;
4308 }
4309 self.ensure_line_break();
4310 let _ = write!(
4311 self.output,
4312 " {:>width$} ...",
4313 " ",
4314 width = self.line_num_width
4315 );
4316 self.old_line_num = self.old_line_num.saturating_add(skip);
4317 self.new_line_num = self.new_line_num.saturating_add(skip);
4318 }
4319}
4320
4321fn render_changed_part(tag: DiffTag, raw: &[&str], state: &mut DiffRenderState) {
4322 state.mark_first_change();
4323 for line in raw {
4324 match tag {
4325 DiffTag::Added => state.push_added_line(line),
4326 DiffTag::Removed => state.push_removed_line(line),
4327 DiffTag::Equal => {}
4328 }
4329 }
4330 state.last_was_change = true;
4331}
4332
4333fn render_equal_part(raw: &[&str], next_part_is_change: bool, state: &mut DiffRenderState) {
4334 if !(state.last_was_change || next_part_is_change) {
4335 let raw_len = raw.len();
4336 state.old_line_num = state.old_line_num.saturating_add(raw_len);
4337 state.new_line_num = state.new_line_num.saturating_add(raw_len);
4338 state.last_was_change = false;
4339 return;
4340 }
4341
4342 if state.last_was_change
4343 && next_part_is_change
4344 && raw.len() > state.context_lines.saturating_mul(2)
4345 {
4346 for line in raw.iter().take(state.context_lines) {
4347 state.push_context_line(line);
4348 }
4349
4350 let skip = raw.len().saturating_sub(state.context_lines * 2);
4351 state.push_skip_marker(skip);
4352
4353 for line in raw
4354 .iter()
4355 .skip(raw.len().saturating_sub(state.context_lines))
4356 {
4357 state.push_context_line(line);
4358 }
4359 } else {
4360 let start = if state.last_was_change {
4362 0
4363 } else {
4364 raw.len().saturating_sub(state.context_lines)
4365 };
4366 let lines_after_start = raw.len().saturating_sub(start);
4367 let (end, skip_end) = if !next_part_is_change && lines_after_start > state.context_lines {
4368 (
4369 start + state.context_lines,
4370 lines_after_start - state.context_lines,
4371 )
4372 } else {
4373 (raw.len(), 0)
4374 };
4375
4376 state.push_skip_marker(start);
4377 for line in &raw[start..end] {
4378 state.push_context_line(line);
4379 }
4380 state.push_skip_marker(skip_end);
4381 }
4382
4383 state.last_was_change = false;
4384}
4385
4386fn generate_diff_string(old_content: &str, new_content: &str) -> (String, Option<usize>) {
4387 let parts = diff_parts(old_content, new_content);
4388 let mut state = DiffRenderState::new(diff_line_num_width(old_content, new_content), 4);
4389
4390 for (i, part) in parts.iter().enumerate() {
4391 let raw = split_diff_lines(&part.value);
4392 let next_part_is_change = parts.get(i + 1).is_some_and(|next| is_change_tag(next.tag));
4393
4394 match part.tag {
4395 DiffTag::Added | DiffTag::Removed => render_changed_part(part.tag, &raw, &mut state),
4396 DiffTag::Equal => render_equal_part(&raw, next_part_is_change, &mut state),
4397 }
4398 }
4399
4400 (state.output, state.first_changed_line)
4401}
4402
4403#[async_trait]
4404#[allow(clippy::unnecessary_literal_bound)]
4405impl Tool for EditTool {
4406 fn name(&self) -> &str {
4407 "edit"
4408 }
4409 fn label(&self) -> &str {
4410 "edit"
4411 }
4412 fn description(&self) -> &str {
4413 "Edit a file by replacing text. The oldText must match a unique region; matching is exact but normalizes line endings, Unicode spaces/quotes/dashes, and ignores trailing whitespace."
4414 }
4415
4416 fn parameters(&self) -> serde_json::Value {
4417 serde_json::json!({
4418 "type": "object",
4419 "properties": {
4420 "path": {
4421 "type": "string",
4422 "description": "Path to the file to edit (relative or absolute)"
4423 },
4424 "oldText": {
4425 "type": "string",
4426 "minLength": 1,
4427 "description": "Text to find and replace (must match uniquely; matching normalizes line endings, Unicode spaces/quotes/dashes, and ignores trailing whitespace)"
4428 },
4429 "newText": {
4430 "type": "string",
4431 "description": "New text to replace the old text with"
4432 }
4433 },
4434 "required": ["path", "oldText", "newText"]
4435 })
4436 }
4437
4438 #[allow(clippy::too_many_lines)]
4439 async fn execute(
4440 &self,
4441 _tool_call_id: &str,
4442 input: serde_json::Value,
4443 _on_update: Option<Box<dyn Fn(ToolUpdate) + Send + Sync>>,
4444 ) -> Result<ToolOutput> {
4445 let input: EditInput =
4446 serde_json::from_value(input).map_err(|e| Error::validation(e.to_string()))?;
4447
4448 if input.new_text.len() > WRITE_TOOL_MAX_BYTES {
4449 return Err(Error::validation(format!(
4450 "New text size exceeds maximum allowed ({} > {} bytes)",
4451 input.new_text.len(),
4452 WRITE_TOOL_MAX_BYTES
4453 )));
4454 }
4455
4456 let absolute_path = resolve_read_path(&input.path, &self.cwd);
4457 let absolute_path = enforce_cwd_scope(&absolute_path, &self.cwd, "edit")?;
4458
4459 let meta = asupersync::fs::metadata(&absolute_path)
4460 .await
4461 .map_err(|err| {
4462 let message = match err.kind() {
4463 std::io::ErrorKind::NotFound => format!("File not found: {}", input.path),
4464 std::io::ErrorKind::PermissionDenied => {
4465 format!("Permission denied: {}", input.path)
4466 }
4467 _ => format!("Failed to access file {}: {err}", input.path),
4468 };
4469 Error::tool("edit", message)
4470 })?;
4471
4472 if !meta.is_file() {
4473 return Err(Error::tool(
4474 "edit",
4475 format!("Path {} is not a regular file", absolute_path.display()),
4476 ));
4477 }
4478 if meta.len() > READ_TOOL_MAX_BYTES {
4479 return Err(Error::tool(
4480 "edit",
4481 format!(
4482 "File is too large ({} bytes). Max allowed for editing is {} bytes.",
4483 meta.len(),
4484 READ_TOOL_MAX_BYTES
4485 ),
4486 ));
4487 }
4488
4489 if let Err(err) = asupersync::fs::OpenOptions::new()
4490 .read(true)
4491 .write(true)
4492 .open(&absolute_path)
4493 .await
4494 {
4495 let message = match err.kind() {
4496 std::io::ErrorKind::NotFound => format!("File not found: {}", input.path),
4497 std::io::ErrorKind::PermissionDenied => {
4498 format!("Permission denied: {}", input.path)
4499 }
4500 _ => format!("Failed to open file for editing: {err}"),
4501 };
4502 return Err(Error::tool("edit", message));
4503 }
4504
4505 let file = asupersync::fs::File::open(&absolute_path)
4507 .await
4508 .map_err(|e| Error::tool("edit", format!("Failed to open file: {e}")))?;
4509 let mut raw = Vec::new();
4510 let mut limiter = file.take(READ_TOOL_MAX_BYTES.saturating_add(1));
4511 limiter
4512 .read_to_end(&mut raw)
4513 .await
4514 .map_err(|e| Error::tool("edit", format!("Failed to read file: {e}")))?;
4515
4516 if raw.len() > usize::try_from(READ_TOOL_MAX_BYTES).unwrap_or(usize::MAX) {
4517 return Err(Error::tool(
4518 "edit",
4519 format!("File is too large (> {READ_TOOL_MAX_BYTES} bytes)."),
4520 ));
4521 }
4522
4523 let raw_content = String::from_utf8(raw).map_err(|_| {
4524 Error::tool(
4525 "edit",
4526 "File contains invalid UTF-8 characters and cannot be safely edited as text."
4527 .to_string(),
4528 )
4529 })?;
4530
4531 let (content_no_bom, had_bom) = strip_bom(&raw_content);
4533
4534 let original_ending = detect_line_ending(content_no_bom);
4535 let normalized_content = normalize_to_lf(content_no_bom);
4536 let content_for_matching =
4537 if content_no_bom.contains('\r') && !content_no_bom.contains('\n') {
4538 std::borrow::Cow::Owned(content_no_bom.replace('\r', "\n"))
4539 } else {
4540 std::borrow::Cow::Borrowed(content_no_bom)
4541 };
4542 let normalized_old_text = normalize_to_lf(&input.old_text);
4543
4544 if normalized_old_text.is_empty() {
4545 return Err(Error::tool(
4546 "edit",
4547 "The old text cannot be empty. To prepend text, include the first line's content in oldText and newText.".to_string(),
4548 ));
4549 }
4550 if build_normalized_content(&normalized_old_text).is_empty() {
4551 return Err(Error::tool(
4552 "edit",
4553 "The old text must include at least one non-whitespace character.".to_string(),
4554 ));
4555 }
4556
4557 let mut variants = Vec::with_capacity(3);
4564 variants.push(normalized_old_text.clone());
4565
4566 let nfc = normalized_old_text.nfc().collect::<String>();
4567 if nfc != normalized_old_text {
4568 variants.push(nfc);
4569 }
4570
4571 let nfd = normalized_old_text.nfd().collect::<String>();
4572 if nfd != normalized_old_text {
4573 variants.push(nfd);
4574 }
4575
4576 let precomputed_content = build_normalized_content(content_for_matching.as_ref());
4579
4580 let mut best_match: Option<(FuzzyMatchResult, String, String)> = None;
4581
4582 for variant in variants {
4583 let precomputed_variant = build_normalized_content(&variant);
4584 let match_result = fuzzy_find_text_with_normalized(
4585 content_for_matching.as_ref(),
4586 &variant,
4587 Some(precomputed_content.as_str()),
4588 Some(precomputed_variant.as_str()),
4589 );
4590
4591 if match_result.found {
4592 best_match = Some((match_result, precomputed_variant, variant));
4593 break;
4594 }
4595 }
4596
4597 let Some((match_result, normalized_old_text, matched_variant)) = best_match else {
4598 return Err(Error::tool(
4599 "edit",
4600 format!(
4601 "Could not find the exact text in {}. The old text must match exactly including all whitespace and newlines.",
4602 input.path
4603 ),
4604 ));
4605 };
4606
4607 let occurrences = if match_result.exact_match {
4610 count_overlapping_occurrences(content_for_matching.as_ref(), &matched_variant)
4611 } else {
4612 count_overlapping_occurrences(&precomputed_content, &normalized_old_text)
4613 };
4614
4615 if occurrences > 1 {
4616 return Err(Error::tool(
4617 "edit",
4618 format!(
4619 "Found {occurrences} occurrences of the text in {}. The text must be unique. Please provide more context to make it unique.",
4620 input.path
4621 ),
4622 ));
4623 }
4624
4625 let idx = match_result.index;
4628 let match_len = match_result.match_length;
4629
4630 let adapted_new_text =
4634 restore_line_endings(&normalize_to_lf(&input.new_text), original_ending);
4635
4636 let new_len = content_no_bom.len() - match_len + adapted_new_text.len();
4637 let mut new_content = String::with_capacity(new_len);
4638 new_content.push_str(&content_no_bom[..idx]);
4639 new_content.push_str(&adapted_new_text);
4640 new_content.push_str(&content_no_bom[idx + match_len..]);
4641
4642 if content_no_bom.eq(&new_content) {
4643 return Err(Error::tool(
4644 "edit",
4645 format!(
4646 "No changes made to {}. The replacement produced identical content. This might indicate an issue with special characters or the text not existing as expected.",
4647 input.path
4648 ),
4649 ));
4650 }
4651
4652 let new_content_for_diff = normalize_to_lf(&new_content);
4653
4654 let mut final_content = new_content;
4656 if had_bom {
4657 final_content = format!("\u{FEFF}{final_content}");
4658 }
4659
4660 let absolute_path_clone = absolute_path.clone();
4662 let final_content_bytes = final_content.into_bytes();
4663 asupersync::runtime::spawn_blocking_io(move || {
4664 let original_perms = std::fs::metadata(&absolute_path_clone)
4666 .ok()
4667 .map(|m| m.permissions());
4668 let parent = absolute_path_clone
4669 .parent()
4670 .unwrap_or_else(|| Path::new("."));
4671 let mut temp_file = tempfile::NamedTempFile::new_in(parent)?;
4672
4673 temp_file.as_file_mut().write_all(&final_content_bytes)?;
4674 temp_file.as_file_mut().sync_all()?;
4675
4676 if let Some(perms) = original_perms {
4678 let _ = temp_file.as_file().set_permissions(perms);
4679 } else {
4680 #[cfg(unix)]
4682 {
4683 use std::os::unix::fs::PermissionsExt;
4684 let _ = temp_file
4685 .as_file()
4686 .set_permissions(std::fs::Permissions::from_mode(0o644));
4687 }
4688 }
4689
4690 temp_file
4691 .persist(&absolute_path_clone)
4692 .map_err(|e| e.error)?;
4693 sync_parent_dir(&absolute_path_clone)?;
4694 Ok(())
4695 })
4696 .await
4697 .map_err(|e| Error::tool("edit", format!("Failed to write file: {e}")))?;
4698
4699 let (diff, first_changed_line) =
4700 generate_diff_string(&normalized_content, &new_content_for_diff);
4701 let mut details = serde_json::Map::new();
4702 details.insert("diff".to_string(), serde_json::Value::String(diff));
4703 if let Some(line) = first_changed_line {
4704 details.insert(
4705 "firstChangedLine".to_string(),
4706 serde_json::Value::Number(serde_json::Number::from(line)),
4707 );
4708 }
4709
4710 Ok(ToolOutput {
4711 content: vec![ContentBlock::Text(TextContent::new(format!(
4712 "Successfully replaced text in {}.",
4713 input.path
4714 )))],
4715 details: Some(serde_json::Value::Object(details)),
4716 is_error: false,
4717 })
4718 }
4719}
4720
4721#[derive(Debug, Deserialize)]
4727#[serde(rename_all = "camelCase")]
4728struct WriteInput {
4729 path: String,
4730 content: String,
4731}
4732
4733pub struct WriteTool {
4734 cwd: PathBuf,
4735}
4736
4737impl WriteTool {
4738 pub fn new(cwd: &Path) -> Self {
4739 Self {
4740 cwd: cwd.to_path_buf(),
4741 }
4742 }
4743}
4744
4745#[async_trait]
4746#[allow(clippy::unnecessary_literal_bound)]
4747impl Tool for WriteTool {
4748 fn name(&self) -> &str {
4749 "write"
4750 }
4751 fn label(&self) -> &str {
4752 "write"
4753 }
4754 fn description(&self) -> &str {
4755 "Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories."
4756 }
4757
4758 fn parameters(&self) -> serde_json::Value {
4759 serde_json::json!({
4760 "type": "object",
4761 "properties": {
4762 "path": {
4763 "type": "string",
4764 "description": "Path to the file to write (relative or absolute)"
4765 },
4766 "content": {
4767 "type": "string",
4768 "description": "Content to write to the file"
4769 }
4770 },
4771 "required": ["path", "content"]
4772 })
4773 }
4774
4775 #[allow(clippy::too_many_lines)]
4776 async fn execute(
4777 &self,
4778 _tool_call_id: &str,
4779 input: serde_json::Value,
4780 _on_update: Option<Box<dyn Fn(ToolUpdate) + Send + Sync>>,
4781 ) -> Result<ToolOutput> {
4782 let input: WriteInput =
4783 serde_json::from_value(input).map_err(|e| Error::validation(e.to_string()))?;
4784
4785 if input.content.len() > WRITE_TOOL_MAX_BYTES {
4786 return Err(Error::validation(format!(
4787 "Content size exceeds maximum allowed ({} > {} bytes)",
4788 input.content.len(),
4789 WRITE_TOOL_MAX_BYTES
4790 )));
4791 }
4792
4793 let path = resolve_path(&input.path, &self.cwd);
4794 let path = enforce_cwd_scope(&path, &self.cwd, "write")?;
4795
4796 if let Ok(meta) = asupersync::fs::metadata(&path).await {
4797 if !meta.is_file() {
4798 return Err(Error::tool(
4799 "write",
4800 format!("Path {} is not a regular file", path.display()),
4801 ));
4802 }
4803 if let Err(err) = asupersync::fs::OpenOptions::new()
4804 .write(true)
4805 .open(&path)
4806 .await
4807 {
4808 let message = match err.kind() {
4809 std::io::ErrorKind::PermissionDenied => {
4810 format!("Permission denied: {}", input.path)
4811 }
4812 _ => format!("Failed to open file for writing: {err}"),
4813 };
4814 return Err(Error::tool("write", message));
4815 }
4816 }
4817
4818 if let Some(parent) = path.parent() {
4820 asupersync::fs::create_dir_all(parent)
4821 .await
4822 .map_err(|e| Error::tool("write", format!("Failed to create directories: {e}")))?;
4823 }
4824
4825 let bytes_written = input.content.encode_utf16().count();
4827
4828 let path_clone = path.clone();
4830 let content_bytes = input.content.into_bytes();
4831 asupersync::runtime::spawn_blocking_io(move || {
4832 let original_perms = std::fs::metadata(&path_clone).ok().map(|m| m.permissions());
4834 let parent = path_clone.parent().unwrap_or_else(|| Path::new("."));
4835 let mut temp_file = tempfile::NamedTempFile::new_in(parent)?;
4836
4837 temp_file.as_file_mut().write_all(&content_bytes)?;
4838 temp_file.as_file_mut().sync_all()?;
4839
4840 if let Some(perms) = original_perms {
4842 let _ = temp_file.as_file().set_permissions(perms);
4843 } else {
4844 #[cfg(unix)]
4846 {
4847 use std::os::unix::fs::PermissionsExt;
4848 let _ = temp_file
4849 .as_file()
4850 .set_permissions(std::fs::Permissions::from_mode(0o644));
4851 }
4852 }
4853
4854 temp_file.persist(&path_clone).map_err(|e| e.error)?;
4856 sync_parent_dir(&path_clone)?;
4857 Ok(())
4858 })
4859 .await
4860 .map_err(|e| Error::tool("write", format!("Failed to write file: {e}")))?;
4861
4862 Ok(ToolOutput {
4863 content: vec![ContentBlock::Text(TextContent::new(format!(
4864 "Successfully wrote {} bytes to {}",
4865 bytes_written, input.path
4866 )))],
4867 details: None,
4868 is_error: false,
4869 })
4870 }
4871}
4872
4873#[derive(Debug, Deserialize)]
4879#[serde(rename_all = "camelCase")]
4880struct GrepInput {
4881 pattern: String,
4882 path: Option<String>,
4883 glob: Option<String>,
4884 ignore_case: Option<bool>,
4885 literal: Option<bool>,
4886 context: Option<usize>,
4887 limit: Option<usize>,
4888 #[serde(default)]
4889 hashline: bool,
4890}
4891
4892pub struct GrepTool {
4893 cwd: PathBuf,
4894 artifact_root: Option<PathBuf>,
4895}
4896
4897impl GrepTool {
4898 pub fn new(cwd: &Path) -> Self {
4899 Self {
4900 cwd: cwd.to_path_buf(),
4901 artifact_root: None,
4902 }
4903 }
4904
4905 #[cfg(test)]
4906 fn with_artifact_root(cwd: &Path, artifact_root: &Path) -> Self {
4907 Self {
4908 cwd: cwd.to_path_buf(),
4909 artifact_root: Some(artifact_root.to_path_buf()),
4910 }
4911 }
4912}
4913
4914#[derive(Debug, Clone, PartialEq, Eq)]
4916struct TruncateLineResult {
4917 text: String,
4918 was_truncated: bool,
4919}
4920
4921fn truncate_line(line: &str, max_chars: usize) -> TruncateLineResult {
4925 let mut chars = line.chars();
4926 let prefix: String = chars.by_ref().take(max_chars).collect();
4927 if chars.next().is_none() {
4928 return TruncateLineResult {
4929 text: line.to_string(),
4930 was_truncated: false,
4931 };
4932 }
4933
4934 TruncateLineResult {
4935 text: format!("{prefix}... [truncated]"),
4936 was_truncated: true,
4937 }
4938}
4939
4940fn process_rg_json_match_line(
4941 line_res: std::io::Result<String>,
4942 matches: &mut Vec<(PathBuf, usize)>,
4943 match_count: &mut usize,
4944 match_limit_reached: &mut bool,
4945 scan_limit: usize,
4946) {
4947 if *match_limit_reached {
4948 return;
4949 }
4950
4951 let line = match line_res {
4952 Ok(l) => l,
4953 Err(e) => {
4954 tracing::debug!("Skipping ripgrep output line due to read error: {e}");
4955 return;
4956 }
4957 };
4958 if line.trim().is_empty() {
4959 return;
4960 }
4961
4962 let Ok(event) = serde_json::from_str::<serde_json::Value>(&line) else {
4963 return;
4964 };
4965
4966 if event.get("type").and_then(serde_json::Value::as_str) != Some("match") {
4967 return;
4968 }
4969
4970 let file_path = event
4971 .pointer("/data/path/text")
4972 .and_then(serde_json::Value::as_str)
4973 .map(PathBuf::from);
4974 let line_number = event
4975 .pointer("/data/line_number")
4976 .and_then(serde_json::Value::as_u64)
4977 .and_then(|n| usize::try_from(n).ok());
4978
4979 if let (Some(fp), Some(ln)) = (file_path, line_number) {
4980 matches.push((fp, ln));
4981 *match_count += 1;
4982 if *match_count >= scan_limit {
4983 *match_limit_reached = true;
4984 }
4985 }
4986}
4987
4988fn drain_rg_stdout(
4989 stdout_rx: &std::sync::mpsc::Receiver<std::io::Result<String>>,
4990 matches: &mut Vec<(PathBuf, usize)>,
4991 match_count: &mut usize,
4992 match_limit_reached: &mut bool,
4993 scan_limit: usize,
4994) {
4995 while let Ok(line_res) = stdout_rx.try_recv() {
4996 process_rg_json_match_line(
4997 line_res,
4998 matches,
4999 match_count,
5000 match_limit_reached,
5001 scan_limit,
5002 );
5003 if *match_limit_reached {
5004 break;
5005 }
5006 }
5007}
5008
5009fn drain_rg_stderr(
5010 stderr_rx: &std::sync::mpsc::Receiver<std::result::Result<Vec<u8>, String>>,
5011 stderr_bytes: &mut Vec<u8>,
5012) -> Result<()> {
5013 while let Ok(chunk_result) = stderr_rx.try_recv() {
5014 let chunk = chunk_result
5015 .map_err(|err| Error::tool("grep", format!("Failed to read stderr: {err}")))?;
5016 stderr_bytes.extend_from_slice(&chunk);
5017 }
5018 Ok(())
5019}
5020
5021#[async_trait]
5022#[allow(clippy::unnecessary_literal_bound)]
5023impl Tool for GrepTool {
5024 fn name(&self) -> &str {
5025 "grep"
5026 }
5027 fn label(&self) -> &str {
5028 "grep"
5029 }
5030 fn description(&self) -> &str {
5031 "Search file contents for a pattern. Returns matching lines with file paths and line numbers. Respects .gitignore. Output is truncated to 100 matches or 1MB (whichever is hit first). Long lines are truncated to 500 chars. Use hashline=true to get N#AB content-hash tags for use with hashline_edit."
5032 }
5033
5034 fn parameters(&self) -> serde_json::Value {
5035 serde_json::json!({
5036 "type": "object",
5037 "properties": {
5038 "pattern": {
5039 "type": "string",
5040 "description": "Search pattern (regex or literal string)"
5041 },
5042 "path": {
5043 "type": "string",
5044 "description": "Directory or file to search (default: current directory)"
5045 },
5046 "glob": {
5047 "type": "string",
5048 "description": "Filter files by glob pattern, e.g. '*.ts' or '**/*.spec.ts'"
5049 },
5050 "ignoreCase": {
5051 "type": "boolean",
5052 "description": "Case-insensitive search (default: false)"
5053 },
5054 "literal": {
5055 "type": "boolean",
5056 "description": "Treat pattern as literal string instead of regex (default: false)"
5057 },
5058 "context": {
5059 "type": "integer",
5060 "description": "Number of lines to show before and after each match (default: 0)"
5061 },
5062 "limit": {
5063 "type": "integer",
5064 "description": "Maximum number of matches to return (default: 100)"
5065 },
5066 "hashline": {
5067 "type": "boolean",
5068 "description": "When true, output each line as N#AB:content where N is the line number and AB is a content hash. Use with hashline_edit tool for precise edits."
5069 }
5070 },
5071 "required": ["pattern"]
5072 })
5073 }
5074
5075 fn effects(&self) -> ToolEffects {
5076 ToolEffects::read()
5077 }
5078
5079 #[allow(clippy::too_many_lines)]
5080 async fn execute(
5081 &self,
5082 tool_call_id: &str,
5083 input: serde_json::Value,
5084 _on_update: Option<Box<dyn Fn(ToolUpdate) + Send + Sync>>,
5085 ) -> Result<ToolOutput> {
5086 let input_value = input.clone();
5087 let input: GrepInput =
5088 serde_json::from_value(input).map_err(|e| Error::validation(e.to_string()))?;
5089
5090 if matches!(input.limit, Some(0)) {
5091 return Err(Error::validation(
5092 "`limit` must be greater than 0".to_string(),
5093 ));
5094 }
5095
5096 if !rg_available() {
5097 return Err(Error::tool(
5098 "grep",
5099 "ripgrep (rg) is not available (please install ripgrep)".to_string(),
5100 ));
5101 }
5102
5103 let search_dir = input.path.as_deref().unwrap_or(".");
5104 let search_path = resolve_read_path(search_dir, &self.cwd);
5105 let search_path = enforce_cwd_scope(&search_path, &self.cwd, "grep")?;
5106
5107 let is_directory = asupersync::fs::metadata(&search_path)
5108 .await
5109 .map_err(|e| {
5110 Error::tool(
5111 "grep",
5112 format!("Cannot access path {}: {e}", search_path.display()),
5113 )
5114 })?
5115 .is_dir();
5116
5117 let context_value = input.context.unwrap_or(0);
5118 let effective_limit = input.limit.unwrap_or(DEFAULT_GREP_LIMIT).max(1);
5119 let scan_limit = effective_limit.saturating_add(1);
5121 let cache_key = tool_cache_key("grep", &self.cwd, &input_value);
5122 let cache_mode = if is_directory {
5123 ToolCacheFingerprintMode::DirectoryRecursive
5124 } else {
5125 ToolCacheFingerprintMode::FileContent
5126 };
5127 let cache_deps = cache_dependency_for_path(&search_path, cache_mode);
5128 if let Some(output) = cached_tool_output(&cache_key, cache_deps.as_deref()) {
5129 return Ok(output);
5130 }
5131
5132 let mut args: Vec<String> = vec![
5133 "--json".to_string(),
5134 "--line-number".to_string(),
5135 "--color=never".to_string(),
5136 "--hidden".to_string(),
5137 "--max-columns=10000".to_string(),
5139 ];
5140
5141 if input.ignore_case.unwrap_or(false) {
5142 args.push("--ignore-case".to_string());
5143 }
5144 if input.literal.unwrap_or(false) {
5145 args.push("--fixed-strings".to_string());
5146 }
5147 if let Some(glob) = &input.glob {
5148 args.push("--glob".to_string());
5149 args.push(glob.clone());
5150 }
5151
5152 let ignore_root = if is_directory {
5155 search_path.clone()
5156 } else {
5157 search_path
5158 .parent()
5159 .unwrap_or_else(|| Path::new("."))
5160 .to_path_buf()
5161 };
5162 let workspace_gitignore = self.cwd.join(".gitignore");
5168 if workspace_gitignore.exists() {
5169 args.push("--ignore-file".to_string());
5170 args.push(workspace_gitignore.display().to_string());
5171 }
5172 let root_gitignore = ignore_root.join(".gitignore");
5173 if root_gitignore != workspace_gitignore && root_gitignore.exists() {
5174 args.push("--ignore-file".to_string());
5175 args.push(root_gitignore.display().to_string());
5176 }
5177
5178 args.push("--".to_string());
5179 args.push(input.pattern.clone());
5180 args.push(search_path.display().to_string());
5181
5182 let rg_cmd = find_rg_binary().ok_or_else(|| {
5183 Error::tool(
5184 "grep",
5185 "rg is not available (please install ripgrep or rg)".to_string(),
5186 )
5187 })?;
5188
5189 let mut child = command_with_default_sigpipe(rg_cmd)
5190 .map_err(|e| Error::tool("grep", format!("Failed to prepare ripgrep: {e}")))?
5191 .args(args)
5192 .stdout(Stdio::piped())
5193 .stderr(Stdio::piped())
5194 .spawn()
5195 .map_err(|e| Error::tool("grep", format!("Failed to run ripgrep: {e}")))?;
5196
5197 let stdout = child
5198 .stdout
5199 .take()
5200 .ok_or_else(|| Error::tool("grep", "Missing stdout".to_string()))?;
5201 let stderr = child
5202 .stderr
5203 .take()
5204 .ok_or_else(|| Error::tool("grep", "Missing stderr".to_string()))?;
5205
5206 let mut guard = ProcessGuard::new(child, ProcessCleanupMode::ChildOnly);
5207
5208 let (stdout_tx, stdout_rx) = std::sync::mpsc::sync_channel(1024);
5209 let (stderr_tx, stderr_rx) =
5210 std::sync::mpsc::sync_channel::<std::result::Result<Vec<u8>, String>>(1024);
5211
5212 let stdout_thread = std::thread::spawn(move || {
5213 let reader = std::io::BufReader::new(stdout);
5214 for line in reader.lines() {
5215 if stdout_tx.send(line).is_err() {
5216 break;
5217 }
5218 }
5219 });
5220
5221 let stderr_thread = std::thread::spawn(move || {
5222 let reader = std::io::BufReader::new(stderr);
5223 let _ = stderr_tx.send(read_to_end_capped_and_drain(reader, READ_TOOL_MAX_BYTES));
5224 });
5225
5226 let mut matches: Vec<(PathBuf, usize)> = Vec::new();
5227 let mut match_count: usize = 0;
5228 let mut match_scan_limit_reached = false;
5229 let mut stderr_bytes = Vec::new();
5230
5231 let tick = Duration::from_millis(10);
5232 let mut cx_cancelled = false;
5233
5234 let exit_status = loop {
5235 let agent_cx = AgentCx::for_current_or_request();
5236 let cx = agent_cx.cx();
5237 if cx.checkpoint().is_err() {
5238 cx_cancelled = true;
5239 break None;
5240 }
5241
5242 drain_rg_stdout(
5243 &stdout_rx,
5244 &mut matches,
5245 &mut match_count,
5246 &mut match_scan_limit_reached,
5247 scan_limit,
5248 );
5249 drain_rg_stderr(&stderr_rx, &mut stderr_bytes)?;
5250
5251 if match_scan_limit_reached {
5252 break None;
5253 }
5254
5255 match guard.try_wait_child() {
5256 Ok(Some(status)) => break Some(status),
5257 Ok(None) => {
5258 let now = cx.timer_driver().map_or_else(wall_now, |timer| timer.now());
5259 sleep(now, tick).await;
5260 }
5261 Err(e) => return Err(Error::tool("grep", e.to_string())),
5262 }
5263 };
5264
5265 drain_rg_stdout(
5266 &stdout_rx,
5267 &mut matches,
5268 &mut match_count,
5269 &mut match_scan_limit_reached,
5270 scan_limit,
5271 );
5272
5273 let code = if match_scan_limit_reached || cx_cancelled {
5274 let _ = guard.kill();
5278 while stdout_rx.try_recv().is_ok() {}
5280 while stderr_rx.try_recv().is_ok() {}
5281 0
5282 } else {
5283 let status = exit_status.expect("rg exit status");
5284 status.code().unwrap_or(0)
5285 };
5286
5287 while !stdout_thread.is_finished() || !stderr_thread.is_finished() {
5291 if match_scan_limit_reached || cx_cancelled {
5292 while stdout_rx.try_recv().is_ok() {}
5293 } else {
5294 drain_rg_stdout(
5295 &stdout_rx,
5296 &mut matches,
5297 &mut match_count,
5298 &mut match_scan_limit_reached,
5299 scan_limit,
5300 );
5301 }
5302 drain_rg_stderr(&stderr_rx, &mut stderr_bytes)?;
5303 sleep(wall_now(), Duration::from_millis(1)).await;
5304 }
5305
5306 if cx_cancelled {
5307 return Err(Error::tool("grep", "Command cancelled"));
5308 }
5309
5310 stdout_thread
5315 .join()
5316 .map_err(|_| Error::tool("grep", "ripgrep stdout reader thread panicked"))?;
5317 stderr_thread
5318 .join()
5319 .map_err(|_| Error::tool("grep", "ripgrep stderr reader thread panicked"))?;
5320
5321 if match_scan_limit_reached {
5323 while stdout_rx.try_recv().is_ok() {}
5324 } else {
5325 drain_rg_stdout(
5326 &stdout_rx,
5327 &mut matches,
5328 &mut match_count,
5329 &mut match_scan_limit_reached,
5330 scan_limit,
5331 );
5332 }
5333 drain_rg_stderr(&stderr_rx, &mut stderr_bytes)?;
5334
5335 let mut stderr_text = String::from_utf8_lossy(&stderr_bytes).trim().to_string();
5336 if stderr_bytes.len() as u64 > READ_TOOL_MAX_BYTES {
5337 stderr_text.push_str("\n... [stderr truncated] ...");
5338 }
5339 if !match_scan_limit_reached && code != 0 && code != 1 {
5340 let msg = if stderr_text.is_empty() {
5341 format!("ripgrep exited with code {code}")
5342 } else {
5343 stderr_text
5344 };
5345 return Err(Error::tool("grep", msg));
5346 }
5347
5348 let match_limit_reached = match_count > effective_limit;
5349 if match_limit_reached {
5350 matches.truncate(effective_limit);
5351 match_count = effective_limit;
5352 }
5353
5354 if match_count == 0 {
5355 let output = ToolOutput {
5356 content: vec![ContentBlock::Text(TextContent::new("No matches found"))],
5357 details: None,
5358 is_error: false,
5359 };
5360 cache_tool_output(
5361 cache_key,
5362 stable_cache_dependency_for_path(&search_path, cache_mode, cache_deps.as_deref()),
5363 &output,
5364 );
5365 return Ok(output);
5366 }
5367
5368 let mut file_cache: HashMap<PathBuf, Vec<String>> = HashMap::new();
5369 let mut output_builder = HeadTruncatingLineWriter::new(DEFAULT_MAX_BYTES);
5370 let mut artifact_source = String::new();
5371 let mut lines_truncated = false;
5372
5373 let mut file_order: Vec<PathBuf> = Vec::new();
5375 let mut matches_by_file: HashMap<PathBuf, Vec<usize>> = HashMap::new();
5376 for (file_path, line_number) in &matches {
5377 if !matches_by_file.contains_key(file_path) {
5378 file_order.push(file_path.clone());
5379 }
5380 matches_by_file
5381 .entry(file_path.clone())
5382 .or_default()
5383 .push(*line_number);
5384 }
5385
5386 for file_path in file_order {
5387 let Some(mut match_lines) = matches_by_file.remove(&file_path) else {
5388 continue;
5389 };
5390 let relative_path = format_grep_path(&file_path, &self.cwd);
5391 let lines = get_file_lines_async(&file_path, &mut file_cache).await;
5392
5393 if lines.is_empty() {
5394 if let Some(first_match) = match_lines.first() {
5395 let line = format!(
5396 "{relative_path}:{first_match}: (unable to read file or too large)"
5397 );
5398 output_builder.push_line(&line);
5399 append_artifact_source_line(&mut artifact_source, &line);
5400 }
5401 continue;
5402 }
5403
5404 match_lines.sort_unstable();
5405 match_lines.dedup();
5406
5407 let mut blocks: Vec<(usize, usize)> = Vec::new();
5408 for &line_number in &match_lines {
5409 let start = if context_value > 0 {
5410 line_number.saturating_sub(context_value).max(1)
5411 } else {
5412 line_number
5413 };
5414 let end = if context_value > 0 {
5415 line_number.saturating_add(context_value).min(lines.len())
5416 } else {
5417 line_number
5418 };
5419
5420 if let Some(last_block) = blocks.last_mut() {
5421 if start <= last_block.1.saturating_add(1) {
5422 last_block.1 = last_block.1.max(end);
5423 continue;
5424 }
5425 }
5426 blocks.push((start, end));
5427 }
5428
5429 for (i, (start, end)) in blocks.into_iter().enumerate() {
5430 if i > 0 {
5431 output_builder.push_line("--");
5432 append_artifact_source_line(&mut artifact_source, "--");
5433 }
5434 for current in start..=end {
5435 let line_text = lines.get(current - 1).map_or("", String::as_str);
5436 let sanitized = line_text.replace('\r', "");
5437 let truncated = truncate_line(&sanitized, GREP_MAX_LINE_LENGTH);
5438 if truncated.was_truncated {
5439 lines_truncated = true;
5440 }
5441
5442 if input.hashline {
5443 let line_idx = current - 1; let tag = format_hashline_tag(line_idx, &sanitized);
5445 let line = if match_lines.binary_search(¤t).is_ok() {
5446 format!("{relative_path}:{tag}: {}", truncated.text)
5447 } else {
5448 format!("{relative_path}-{tag}- {}", truncated.text)
5449 };
5450 output_builder.push_line(&line);
5451 append_artifact_source_line(&mut artifact_source, &line);
5452 } else if match_lines.binary_search(¤t).is_ok() {
5453 let line = format!("{relative_path}:{current}: {}", truncated.text);
5454 output_builder.push_line(&line);
5455 append_artifact_source_line(&mut artifact_source, &line);
5456 } else {
5457 let line = format!("{relative_path}-{current}- {}", truncated.text);
5458 output_builder.push_line(&line);
5459 append_artifact_source_line(&mut artifact_source, &line);
5460 }
5461 }
5462 }
5463 }
5464
5465 let mut truncation = output_builder.finish();
5467
5468 let mut output = std::mem::take(&mut truncation.content);
5469 let mut notices: Vec<String> = Vec::new();
5470 let mut details_map = serde_json::Map::new();
5471
5472 if match_limit_reached {
5473 notices.push(format!(
5474 "{effective_limit} matches limit reached. Use limit={} for more, or refine pattern",
5475 effective_limit * 2
5476 ));
5477 details_map.insert(
5478 "matchLimitReached".to_string(),
5479 serde_json::Value::Number(serde_json::Number::from(effective_limit)),
5480 );
5481 }
5482
5483 if truncation.truncated {
5484 notices.push(format!("{} limit reached", format_size(DEFAULT_MAX_BYTES)));
5485 details_map.insert("truncation".to_string(), serde_json::to_value(truncation)?);
5486 }
5487
5488 if lines_truncated {
5489 notices.push(format!(
5490 "Some lines truncated to {GREP_MAX_LINE_LENGTH} chars. Use read tool to see full lines"
5491 ));
5492 details_map.insert("linesTruncated".to_string(), serde_json::Value::Bool(true));
5493 }
5494
5495 if !notices.is_empty() {
5496 let _ = write!(output, "\n\n[{}]", notices.join(". "));
5497 }
5498
5499 let mut details = if details_map.is_empty() {
5500 None
5501 } else {
5502 Some(serde_json::Value::Object(details_map))
5503 };
5504
5505 attach_text_artifact_if_needed_with_root(
5506 self.artifact_root.as_deref(),
5507 &mut output,
5508 &mut details,
5509 "grep",
5510 tool_call_id,
5511 "searchResults",
5512 &artifact_source,
5513 );
5514
5515 let output = ToolOutput {
5516 content: vec![ContentBlock::Text(TextContent::new(output))],
5517 details,
5518 is_error: false,
5519 };
5520 cache_tool_output(
5521 cache_key,
5522 stable_cache_dependency_for_path(&search_path, cache_mode, cache_deps.as_deref()),
5523 &output,
5524 );
5525 Ok(output)
5526 }
5527}
5528
5529#[derive(Debug, Deserialize)]
5535#[serde(rename_all = "camelCase")]
5536struct FindInput {
5537 pattern: String,
5538 path: Option<String>,
5539 limit: Option<usize>,
5540}
5541
5542#[derive(Debug)]
5543struct FindEntry {
5544 rel: String,
5545 modified: Option<SystemTime>,
5546}
5547
5548pub struct FindTool {
5549 cwd: PathBuf,
5550 artifact_root: Option<PathBuf>,
5551}
5552
5553impl FindTool {
5554 pub fn new(cwd: &Path) -> Self {
5555 Self {
5556 cwd: cwd.to_path_buf(),
5557 artifact_root: None,
5558 }
5559 }
5560}
5561
5562#[async_trait]
5563#[allow(clippy::unnecessary_literal_bound)]
5564impl Tool for FindTool {
5565 fn name(&self) -> &str {
5566 "find"
5567 }
5568 fn label(&self) -> &str {
5569 "find"
5570 }
5571 fn description(&self) -> &str {
5572 "Search for files by glob pattern. Returns matching file paths relative to the search directory. Sorted by modification time (newest first). Respects .gitignore. Output is truncated to 1000 results or 1MB (whichever is hit first)."
5573 }
5574
5575 fn parameters(&self) -> serde_json::Value {
5576 serde_json::json!({
5577 "type": "object",
5578 "properties": {
5579 "pattern": {
5580 "type": "string",
5581 "description": "Glob pattern to match files, e.g. '*.ts', '**/*.json', or 'src/**/*.spec.ts'"
5582 },
5583 "path": {
5584 "type": "string",
5585 "description": "Directory to search in (default: current directory)"
5586 },
5587 "limit": {
5588 "type": "integer",
5589 "description": "Maximum number of results (default: 1000)"
5590 }
5591 },
5592 "required": ["pattern"]
5593 })
5594 }
5595
5596 fn effects(&self) -> ToolEffects {
5597 ToolEffects::read()
5598 }
5599
5600 #[allow(clippy::too_many_lines)]
5601 async fn execute(
5602 &self,
5603 tool_call_id: &str,
5604 input: serde_json::Value,
5605 _on_update: Option<Box<dyn Fn(ToolUpdate) + Send + Sync>>,
5606 ) -> Result<ToolOutput> {
5607 let input_value = input.clone();
5608 let input: FindInput =
5609 serde_json::from_value(input).map_err(|e| Error::validation(e.to_string()))?;
5610
5611 if matches!(input.limit, Some(0)) {
5612 return Err(Error::validation(
5613 "`limit` must be greater than 0".to_string(),
5614 ));
5615 }
5616
5617 let search_dir = input.path.as_deref().unwrap_or(".");
5618 let search_path = resolve_read_path(search_dir, &self.cwd);
5619 let search_path = enforce_cwd_scope(&search_path, &self.cwd, "find")?;
5620 let search_path = strip_unc_prefix(search_path);
5621 let effective_limit = input.limit.unwrap_or(DEFAULT_FIND_LIMIT);
5622 let scan_limit = effective_limit.saturating_add(1);
5624
5625 if !search_path.exists() {
5626 return Err(Error::tool(
5627 "find",
5628 format!("Path not found: {}", search_path.display()),
5629 ));
5630 }
5631
5632 let cache_key = tool_cache_key("find", &self.cwd, &input_value);
5633 let cache_mode = if search_path.is_dir() {
5634 ToolCacheFingerprintMode::DirectoryRecursive
5635 } else {
5636 ToolCacheFingerprintMode::FileContent
5637 };
5638 let cache_deps = cache_dependency_for_path(&search_path, cache_mode);
5639 if let Some(output) = cached_tool_output(&cache_key, cache_deps.as_deref()) {
5640 return Ok(output);
5641 }
5642
5643 let fd_cmd = find_fd_binary().ok_or_else(|| {
5644 Error::tool(
5645 "find",
5646 "fd is not available (please install fd-find or fd)".to_string(),
5647 )
5648 })?;
5649
5650 let mut args: Vec<String> = vec![
5652 "--glob".to_string(),
5653 "--color=never".to_string(),
5654 "--hidden".to_string(),
5655 "--max-results".to_string(),
5656 scan_limit.to_string(),
5657 ];
5658
5659 let workspace_gitignore = self.cwd.join(".gitignore");
5664 if workspace_gitignore.exists() {
5665 args.push("--ignore-file".to_string());
5666 args.push(workspace_gitignore.display().to_string());
5667 }
5668 let root_gitignore = search_path.join(".gitignore");
5669 if root_gitignore != workspace_gitignore && root_gitignore.exists() {
5670 args.push("--ignore-file".to_string());
5671 args.push(root_gitignore.display().to_string());
5672 }
5673
5674 args.push("--".to_string());
5675 args.push(input.pattern.clone());
5676 args.push(search_path.display().to_string());
5677
5678 let mut child = command_with_default_sigpipe_in_dir(fd_cmd, &self.cwd)
5679 .map_err(|e| Error::tool("find", format!("Failed to prepare fd: {e}")))?
5680 .args(args)
5681 .current_dir(&self.cwd)
5682 .stdin(Stdio::null())
5683 .stdout(Stdio::piped())
5684 .stderr(Stdio::piped())
5685 .spawn()
5686 .map_err(|e| Error::tool("find", format!("Failed to run fd: {e}")))?;
5687
5688 let stdout_pipe = child
5689 .stdout
5690 .take()
5691 .ok_or_else(|| Error::tool("find", "Missing stdout"))?;
5692 let stderr_pipe = child
5693 .stderr
5694 .take()
5695 .ok_or_else(|| Error::tool("find", "Missing stderr"))?;
5696
5697 let mut guard = ProcessGuard::new(child, ProcessCleanupMode::ChildOnly);
5698
5699 let stdout_handle = std::thread::spawn(move || -> std::result::Result<Vec<u8>, String> {
5700 read_to_end_capped_and_drain(stdout_pipe, READ_TOOL_MAX_BYTES)
5701 });
5702
5703 let stderr_handle = std::thread::spawn(move || -> std::result::Result<Vec<u8>, String> {
5704 read_to_end_capped_and_drain(stderr_pipe, READ_TOOL_MAX_BYTES)
5705 });
5706
5707 let tick = Duration::from_millis(10);
5708 let start_time = std::time::Instant::now();
5709 let timeout_ms = 60_000; let mut timed_out = false;
5711 let mut cx_cancelled = false;
5712
5713 let status = loop {
5714 let agent_cx = AgentCx::for_current_or_request();
5715 let cx = agent_cx.cx();
5716 if cx.checkpoint().is_err() {
5717 cx_cancelled = true;
5718 let _ = guard.kill();
5719 break None;
5720 }
5721
5722 match guard.try_wait_child() {
5724 Ok(Some(status)) => break Some(status),
5725 Ok(None) => {
5726 if start_time.elapsed().as_millis() > timeout_ms {
5727 timed_out = true;
5728 let _ = guard.kill();
5729 break None;
5730 }
5731 let now = cx.timer_driver().map_or_else(wall_now, |timer| timer.now());
5732 sleep(now, tick).await;
5733 }
5734 Err(e) => return Err(Error::tool("find", e.to_string())),
5735 }
5736 };
5737
5738 let stdout_bytes = stdout_handle
5739 .join()
5740 .map_err(|_| Error::tool("find", "fd stdout reader thread panicked"))?
5741 .map_err(|err| Error::tool("find", format!("Failed to read fd stdout: {err}")))?;
5742 let stderr_bytes = stderr_handle
5743 .join()
5744 .map_err(|_| Error::tool("find", "fd stderr reader thread panicked"))?
5745 .map_err(|err| Error::tool("find", format!("Failed to read fd stderr: {err}")))?;
5746
5747 if cx_cancelled {
5748 return Err(Error::tool("find", "Command cancelled"));
5749 }
5750 if timed_out {
5751 return Err(Error::tool("find", "Command timed out after 60 seconds"));
5752 }
5753 let status = status.expect("fd exit status after successful completion");
5754
5755 let mut stdout = String::from_utf8_lossy(&stdout_bytes).trim().to_string();
5756 if stdout_bytes.len() as u64 > READ_TOOL_MAX_BYTES {
5757 stdout.push_str("\n... [stdout truncated] ...");
5758 }
5759 let mut stderr = String::from_utf8_lossy(&stderr_bytes).trim().to_string();
5760 if stderr_bytes.len() as u64 > READ_TOOL_MAX_BYTES {
5761 stderr.push_str("\n... [stderr truncated] ...");
5762 }
5763
5764 if !status.success() && stdout.is_empty() {
5765 if status.code() == Some(1) && stderr.is_empty() {
5766 } else {
5768 let code = status.code().unwrap_or(1);
5769 let msg = if stderr.is_empty() {
5770 format!("fd exited with code {code}")
5771 } else {
5772 stderr
5773 };
5774 return Err(Error::tool("find", msg));
5775 }
5776 }
5777
5778 if stdout.is_empty() {
5779 let output = ToolOutput {
5780 content: vec![ContentBlock::Text(TextContent::new(
5781 "No files found matching pattern",
5782 ))],
5783 details: None,
5784 is_error: false,
5785 };
5786 cache_tool_output(
5787 cache_key,
5788 stable_cache_dependency_for_path(&search_path, cache_mode, cache_deps.as_deref()),
5789 &output,
5790 );
5791 return Ok(output);
5792 }
5793
5794 let mut entries: Vec<FindEntry> = Vec::new();
5795 for raw_line in stdout.lines() {
5796 let line = raw_line.trim_end_matches('\r').trim();
5797 if line.is_empty() {
5798 continue;
5799 }
5800
5801 let clean = strip_unc_prefix(PathBuf::from(line));
5804 let line_path = clean.as_path();
5805 let mut rel = if line_path.is_absolute() {
5806 line_path.strip_prefix(&search_path).map_or_else(
5807 |_| line_path.to_string_lossy().to_string(),
5808 |stripped| stripped.to_string_lossy().to_string(),
5809 )
5810 } else {
5811 line_path.to_string_lossy().to_string()
5812 };
5813
5814 let full_path = if line_path.is_absolute() {
5815 line_path.to_path_buf()
5816 } else {
5817 search_path.join(line_path)
5818 };
5819 if full_path.is_dir() && !rel.ends_with('/') {
5820 rel.push('/');
5821 }
5822
5823 let modified = std::fs::metadata(&full_path)
5824 .and_then(|meta| meta.modified())
5825 .ok();
5826 entries.push(FindEntry { rel, modified });
5827 }
5828
5829 entries.sort_by(|a, b| {
5830 let ordering = match (&a.modified, &b.modified) {
5831 (Some(a_time), Some(b_time)) => b_time.cmp(a_time),
5832 (Some(_), None) => Ordering::Less,
5833 (None, Some(_)) => Ordering::Greater,
5834 (None, None) => Ordering::Equal,
5835 };
5836 ordering.then_with(|| {
5837 let a_lower = a.rel.to_lowercase();
5838 let b_lower = b.rel.to_lowercase();
5839 a_lower.cmp(&b_lower).then_with(|| a.rel.cmp(&b.rel))
5840 })
5841 });
5842
5843 if entries.is_empty() {
5844 let output = ToolOutput {
5845 content: vec![ContentBlock::Text(TextContent::new(
5846 "No files found matching pattern",
5847 ))],
5848 details: None,
5849 is_error: false,
5850 };
5851 cache_tool_output(
5852 cache_key,
5853 stable_cache_dependency_for_path(&search_path, cache_mode, cache_deps.as_deref()),
5854 &output,
5855 );
5856 return Ok(output);
5857 }
5858
5859 let result_limit_reached = entries.len() > effective_limit;
5860 let mut output_builder = HeadTruncatingLineWriter::new(DEFAULT_MAX_BYTES);
5861 let mut artifact_source = String::new();
5862 for entry in entries.into_iter().take(effective_limit) {
5863 output_builder.push_line(&entry.rel);
5864 append_artifact_source_line(&mut artifact_source, &entry.rel);
5865 }
5866 let mut truncation = output_builder.finish();
5867
5868 let mut result_output = std::mem::take(&mut truncation.content);
5869 let mut notices: Vec<String> = Vec::new();
5870 let mut details_map = serde_json::Map::new();
5871
5872 if !status.success() {
5873 let code = status.code().unwrap_or(1);
5874 notices.push(format!("fd exited with code {code}"));
5875 }
5876
5877 if result_limit_reached {
5878 notices.push(format!(
5879 "{effective_limit} results limit reached. Use limit={} for more, or refine pattern",
5880 effective_limit * 2
5881 ));
5882 details_map.insert(
5883 "resultLimitReached".to_string(),
5884 serde_json::Value::Number(serde_json::Number::from(effective_limit)),
5885 );
5886 }
5887
5888 if truncation.truncated {
5889 notices.push(format!("{} limit reached", format_size(DEFAULT_MAX_BYTES)));
5890 details_map.insert("truncation".to_string(), serde_json::to_value(truncation)?);
5891 }
5892
5893 if !notices.is_empty() {
5894 let _ = write!(result_output, "\n\n[{}]", notices.join(". "));
5895 }
5896
5897 let mut details = if details_map.is_empty() {
5898 None
5899 } else {
5900 Some(serde_json::Value::Object(details_map))
5901 };
5902
5903 attach_text_artifact_if_needed_with_root(
5904 self.artifact_root.as_deref(),
5905 &mut result_output,
5906 &mut details,
5907 "find",
5908 tool_call_id,
5909 "fileResults",
5910 &artifact_source,
5911 );
5912
5913 let output = ToolOutput {
5914 content: vec![ContentBlock::Text(TextContent::new(result_output))],
5915 details,
5916 is_error: false,
5917 };
5918 cache_tool_output(
5919 cache_key,
5920 stable_cache_dependency_for_path(&search_path, cache_mode, cache_deps.as_deref()),
5921 &output,
5922 );
5923 Ok(output)
5924 }
5925}
5926
5927#[derive(Debug, Deserialize)]
5933#[serde(rename_all = "camelCase")]
5934struct LsInput {
5935 path: Option<String>,
5936 limit: Option<usize>,
5937}
5938
5939pub struct LsTool {
5940 cwd: PathBuf,
5941 artifact_root: Option<PathBuf>,
5942}
5943
5944impl LsTool {
5945 pub fn new(cwd: &Path) -> Self {
5946 Self {
5947 cwd: cwd.to_path_buf(),
5948 artifact_root: None,
5949 }
5950 }
5951
5952 #[cfg(test)]
5953 fn with_artifact_root(cwd: &Path, artifact_root: &Path) -> Self {
5954 Self {
5955 cwd: cwd.to_path_buf(),
5956 artifact_root: Some(artifact_root.to_path_buf()),
5957 }
5958 }
5959}
5960
5961#[async_trait]
5962#[allow(clippy::unnecessary_literal_bound, clippy::too_many_lines)]
5963impl Tool for LsTool {
5964 fn name(&self) -> &str {
5965 "ls"
5966 }
5967 fn label(&self) -> &str {
5968 "ls"
5969 }
5970 fn description(&self) -> &str {
5971 "List directory contents. Returns entries sorted alphabetically, with '/' suffix for directories. Includes dotfiles. Output is truncated to 500 entries or 1MB (whichever is hit first)."
5972 }
5973
5974 fn parameters(&self) -> serde_json::Value {
5975 serde_json::json!({
5976 "type": "object",
5977 "properties": {
5978 "path": {
5979 "type": "string",
5980 "description": "Directory to list (default: current directory)"
5981 },
5982 "limit": {
5983 "type": "integer",
5984 "description": "Maximum number of entries to return (default: 500)"
5985 }
5986 }
5987 })
5988 }
5989
5990 fn effects(&self) -> ToolEffects {
5991 ToolEffects::read()
5992 }
5993
5994 async fn execute(
5995 &self,
5996 tool_call_id: &str,
5997 input: serde_json::Value,
5998 _on_update: Option<Box<dyn Fn(ToolUpdate) + Send + Sync>>,
5999 ) -> Result<ToolOutput> {
6000 let input_value = input.clone();
6001 let input: LsInput =
6002 serde_json::from_value(input).map_err(|e| Error::validation(e.to_string()))?;
6003
6004 if matches!(input.limit, Some(0)) {
6005 return Err(Error::validation(
6006 "`limit` must be greater than 0".to_string(),
6007 ));
6008 }
6009
6010 let dir_path = input
6011 .path
6012 .as_ref()
6013 .map_or_else(|| self.cwd.clone(), |p| resolve_read_path(p, &self.cwd));
6014 let dir_path = enforce_cwd_scope(&dir_path, &self.cwd, "list")?;
6015
6016 let effective_limit = input.limit.unwrap_or(DEFAULT_LS_LIMIT);
6017
6018 if !dir_path.exists() {
6019 return Err(Error::tool(
6020 "ls",
6021 format!("Path not found: {}", dir_path.display()),
6022 ));
6023 }
6024 if !dir_path.is_dir() {
6025 return Err(Error::tool(
6026 "ls",
6027 format!("Not a directory: {}", dir_path.display()),
6028 ));
6029 }
6030
6031 let cache_key = tool_cache_key("ls", &self.cwd, &input_value);
6032 let cache_mode = ToolCacheFingerprintMode::DirectoryImmediate;
6033 let cache_deps = cache_dependency_for_path(&dir_path, cache_mode);
6034 if let Some(output) = cached_tool_output(&cache_key, cache_deps.as_deref()) {
6035 return Ok(output);
6036 }
6037
6038 let mut entries = Vec::new();
6039 let mut read_dir = asupersync::fs::read_dir(&dir_path)
6040 .await
6041 .map_err(|e| Error::tool("ls", format!("Cannot read directory: {e}")))?;
6042
6043 let mut scan_limit_reached = false;
6044 while let Some(entry) = read_dir
6045 .next_entry()
6046 .await
6047 .map_err(|e| Error::tool("ls", format!("Cannot read directory entry: {e}")))?
6048 {
6049 if entries.len() >= LS_SCAN_HARD_LIMIT {
6050 scan_limit_reached = true;
6051 break;
6052 }
6053 let name = entry.file_name().to_string_lossy().to_string();
6054 let is_dir = match entry.file_type().await {
6057 Ok(ft) => {
6058 if ft.is_dir() {
6059 true
6060 } else if ft.is_symlink() {
6061 entry.metadata().await.is_ok_and(|meta| meta.is_dir())
6063 } else {
6064 false
6065 }
6066 }
6067 Err(_) => entry.metadata().await.is_ok_and(|meta| meta.is_dir()),
6068 };
6069 entries.push((name, is_dir));
6070 }
6071
6072 entries.sort_by_cached_key(|(a, _)| a.to_lowercase());
6074
6075 let mut output_builder = HeadTruncatingLineWriter::new(DEFAULT_MAX_BYTES);
6076 let mut artifact_source = String::new();
6077 let mut emitted_entries = 0usize;
6078 let mut entry_limit_reached = false;
6079
6080 for (entry, is_dir) in entries {
6081 if emitted_entries >= effective_limit {
6082 entry_limit_reached = true;
6083 break;
6084 }
6085 let line = if is_dir { format!("{entry}/") } else { entry };
6086 output_builder.push_line(&line);
6087 append_artifact_source_line(&mut artifact_source, &line);
6088 emitted_entries = emitted_entries.saturating_add(1);
6089 }
6090
6091 if emitted_entries == 0 {
6092 let output = ToolOutput {
6093 content: vec![ContentBlock::Text(TextContent::new("(empty directory)"))],
6094 details: None,
6095 is_error: false,
6096 };
6097 cache_tool_output(
6098 cache_key,
6099 stable_cache_dependency_for_path(&dir_path, cache_mode, cache_deps.as_deref()),
6100 &output,
6101 );
6102 return Ok(output);
6103 }
6104
6105 let mut truncation = output_builder.finish();
6107
6108 let mut output = std::mem::take(&mut truncation.content);
6109 let mut details_map = serde_json::Map::new();
6110 let mut notices: Vec<String> = Vec::new();
6111
6112 if entry_limit_reached {
6113 notices.push(format!(
6114 "{effective_limit} entries limit reached. Use limit={} for more",
6115 effective_limit * 2
6116 ));
6117 details_map.insert(
6118 "entryLimitReached".to_string(),
6119 serde_json::Value::Number(serde_json::Number::from(effective_limit)),
6120 );
6121 }
6122
6123 if scan_limit_reached {
6124 notices.push(format!(
6125 "Directory scan limited to {LS_SCAN_HARD_LIMIT} entries to prevent system overload"
6126 ));
6127 details_map.insert(
6128 "scanLimitReached".to_string(),
6129 serde_json::Value::Number(serde_json::Number::from(LS_SCAN_HARD_LIMIT)),
6130 );
6131 }
6132
6133 if truncation.truncated {
6134 notices.push(format!("{} limit reached", format_size(DEFAULT_MAX_BYTES)));
6135 details_map.insert("truncation".to_string(), serde_json::to_value(truncation)?);
6136 }
6137
6138 if !notices.is_empty() {
6139 let _ = write!(output, "\n\n[{}]", notices.join(". "));
6140 }
6141
6142 let mut details = if details_map.is_empty() {
6143 None
6144 } else {
6145 Some(serde_json::Value::Object(details_map))
6146 };
6147
6148 attach_text_artifact_if_needed_with_root(
6149 self.artifact_root.as_deref(),
6150 &mut output,
6151 &mut details,
6152 "ls",
6153 tool_call_id,
6154 "directoryEntries",
6155 &artifact_source,
6156 );
6157
6158 let output = ToolOutput {
6159 content: vec![ContentBlock::Text(TextContent::new(output))],
6160 details,
6161 is_error: false,
6162 };
6163 cache_tool_output(
6164 cache_key,
6165 stable_cache_dependency_for_path(&dir_path, cache_mode, cache_deps.as_deref()),
6166 &output,
6167 );
6168 Ok(output)
6169 }
6170}
6171
6172pub fn cleanup_temp_files() {
6182 std::thread::spawn(|| {
6184 let temp_dir = std::env::temp_dir();
6185 let Ok(entries) = std::fs::read_dir(&temp_dir) else {
6186 return;
6187 };
6188
6189 for entry in entries.flatten() {
6190 let path = entry.path();
6191 if !path.is_file() {
6192 continue;
6193 }
6194
6195 let Some(file_name) = path.file_name().and_then(|n| n.to_str()) else {
6196 continue;
6197 };
6198
6199 if (file_name.starts_with("pi-bash-") || file_name.starts_with("pi-rpc-bash-"))
6201 && std::path::Path::new(file_name)
6202 .extension()
6203 .is_some_and(|ext| ext.eq_ignore_ascii_case("log"))
6204 && let Ok(metadata) = entry.metadata()
6205 && metadata.modified().is_ok_and(|modified| {
6206 modified
6207 .elapsed()
6208 .is_ok_and(|age| age > Duration::from_secs(24 * 60 * 60))
6209 })
6210 && let Err(e) = std::fs::remove_file(&path)
6211 {
6212 tracing::debug!("Failed to remove temp file {}: {}", path.display(), e);
6214 }
6215 }
6216 });
6217}
6218
6219fn rg_available() -> bool {
6224 find_rg_binary().is_some()
6225}
6226
6227fn pump_stream<R: Read + Send + 'static>(
6228 mut reader: R,
6229 stream_name: &'static str,
6230 tx: &mpsc::SyncSender<BashPipeFrame>,
6231) {
6232 let mut buf = vec![0u8; 8192];
6233 loop {
6234 match reader.read(&mut buf) {
6235 Ok(0) => break,
6236 Ok(n) => {
6237 if tx.send(BashPipeFrame::Chunk(buf[..n].to_vec())).is_err() {
6238 break;
6239 }
6240 }
6241 Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => {}
6242 Err(err) => {
6243 let _ = tx.send(BashPipeFrame::Error(format!(
6244 "Failed to read bash {stream_name}: {err}"
6245 )));
6246 break;
6247 }
6248 }
6249 }
6250}
6251
6252async fn ingest_bash_pipe_frame(frame: BashPipeFrame, state: &mut BashOutputState) -> Result<()> {
6253 match frame {
6254 BashPipeFrame::Chunk(chunk) => ingest_bash_chunk(chunk, state).await,
6255 BashPipeFrame::Error(message) => {
6256 let error_message = bash_capture_error_message(&message, state);
6257 state.abandon_spill_file();
6258 Err(Error::tool("bash", error_message))
6259 }
6260 }
6261}
6262
6263fn bash_capture_error_message(message: &str, state: &BashOutputState) -> String {
6264 let raw = concat_chunks(&state.chunks);
6265 if raw.is_empty() {
6266 return message.to_string();
6267 }
6268
6269 let full_text = String::from_utf8_lossy(&raw).into_owned();
6270 let truncation = truncate_tail(full_text, DEFAULT_MAX_LINES, DEFAULT_MAX_BYTES);
6271 let mut error_message = message.to_string();
6272 let partial_output = if truncation.content.is_empty() {
6273 "(no output)".to_string()
6274 } else {
6275 truncation.content
6276 };
6277 let _ = write!(
6278 error_message,
6279 "\n\nPartial output before failure:\n{partial_output}"
6280 );
6281 if truncation.truncated || state.total_bytes > state.chunks_bytes {
6282 let _ = write!(
6283 error_message,
6284 "\n\n[Partial output truncated before failure]"
6285 );
6286 }
6287 error_message
6288}
6289
6290pub(crate) fn read_to_end_capped_and_drain<R: Read>(
6294 mut reader: R,
6295 max_bytes: u64,
6296) -> std::result::Result<Vec<u8>, String> {
6297 let capture_limit = usize::try_from(max_bytes.saturating_add(1)).unwrap_or(usize::MAX);
6298 let mut captured = Vec::with_capacity(capture_limit.min(8192));
6299 let mut chunk = [0u8; 8192];
6300
6301 loop {
6302 match reader.read(&mut chunk) {
6303 Ok(0) => break,
6304 Ok(read) => {
6305 let remaining = capture_limit.saturating_sub(captured.len());
6306 if remaining > 0 {
6307 let keep = remaining.min(read);
6308 captured.extend_from_slice(&chunk[..keep]);
6309 }
6310 }
6311 Err(err) if matches!(err.kind(), std::io::ErrorKind::Interrupted) => {}
6312 Err(err) => return Err(err.to_string()),
6313 }
6314 }
6315
6316 Ok(captured)
6317}
6318
6319#[allow(clippy::needless_pass_by_ref_mut)]
6323#[cfg(test)]
6324async fn drain_bash_output(
6325 rx: &mut mpsc::Receiver<BashPipeFrame>,
6326 bash_output: &mut BashOutputState,
6327 cx: &AgentCx,
6328 drain_deadline: asupersync::Time,
6329 tick: Duration,
6330 allow_cancellation: bool,
6331) -> Result<bool> {
6332 loop {
6333 match rx.try_recv() {
6334 Ok(frame) => ingest_bash_pipe_frame(frame, bash_output).await?,
6335 Err(mpsc::TryRecvError::Empty) => {
6336 let now = cx
6337 .cx()
6338 .timer_driver()
6339 .map_or_else(wall_now, |timer| timer.now());
6340 if now >= drain_deadline {
6341 return Ok(false);
6342 }
6343 if allow_cancellation && cx.checkpoint().is_err() {
6344 return Ok(true);
6345 }
6346 sleep(now, tick).await;
6347 }
6348 Err(mpsc::TryRecvError::Disconnected) => return Ok(false),
6349 }
6350 }
6351}
6352
6353fn concat_chunks(chunks: &VecDeque<Vec<u8>>) -> Vec<u8> {
6354 let total: usize = chunks.iter().map(Vec::len).sum();
6355 let mut out = Vec::with_capacity(total);
6356 for chunk in chunks {
6357 out.extend_from_slice(chunk);
6358 }
6359 out
6360}
6361
6362struct BashOutputState {
6363 total_bytes: usize,
6364 line_count: usize,
6365 last_byte_was_newline: bool,
6366 start_time: std::time::Instant,
6367 timeout_ms: Option<u64>,
6368 temp_file_path: Option<PathBuf>,
6369 temp_file: Option<asupersync::fs::File>,
6370 chunks: VecDeque<Vec<u8>>,
6371 chunks_bytes: usize,
6372 max_chunks_bytes: usize,
6373 spill_failed: bool,
6374}
6375
6376impl BashOutputState {
6377 fn new(max_chunks_bytes: usize) -> Self {
6378 Self {
6379 total_bytes: 0,
6380 line_count: 0,
6381 last_byte_was_newline: false,
6382 start_time: std::time::Instant::now(),
6383 timeout_ms: None,
6384 temp_file_path: None,
6385 temp_file: None,
6386 chunks: VecDeque::new(),
6387 chunks_bytes: 0,
6388 max_chunks_bytes,
6389 spill_failed: false,
6390 }
6391 }
6392
6393 fn abandon_spill_file(&mut self) {
6394 self.spill_failed = true;
6395 self.temp_file = None;
6396 if let Some(path) = self.temp_file_path.take() {
6397 if let Err(e) = std::fs::remove_file(&path)
6398 && e.kind() != std::io::ErrorKind::NotFound
6399 {
6400 tracing::debug!(
6401 "Failed to remove incomplete bash spill file {}: {}",
6402 path.display(),
6403 e
6404 );
6405 }
6406 }
6407 }
6408}
6409
6410#[allow(clippy::too_many_lines)]
6411async fn ingest_bash_chunk(chunk: Vec<u8>, state: &mut BashOutputState) -> Result<()> {
6412 if chunk.is_empty() {
6413 return Ok(());
6414 }
6415
6416 state.last_byte_was_newline = chunk.last().is_some_and(|byte| *byte == b'\n');
6417 state.total_bytes = state.total_bytes.saturating_add(chunk.len());
6418 state.line_count = state
6419 .line_count
6420 .saturating_add(memchr::memchr_iter(b'\n', &chunk).count());
6421
6422 if state.total_bytes > DEFAULT_MAX_BYTES
6423 && state.temp_file.is_none()
6424 && state.temp_file_path.is_none()
6425 && !state.spill_failed
6426 {
6427 let id_full = Uuid::new_v4().simple().to_string();
6428 let id = &id_full[..16];
6429 let path = std::env::temp_dir().join(format!("pi-bash-{id}.log"));
6430
6431 let path_clone = path.clone();
6435 let expected_inode: Option<u64> =
6436 asupersync::runtime::spawn_blocking_io(move || -> std::io::Result<Option<u64>> {
6437 let mut options = std::fs::OpenOptions::new();
6438 options.write(true).create_new(true);
6439
6440 #[cfg(unix)]
6441 {
6442 use std::os::unix::fs::OpenOptionsExt;
6443 options.mode(0o600);
6444 }
6445
6446 match options.open(&path_clone) {
6447 Ok(file) => {
6448 #[cfg(unix)]
6449 {
6450 use std::os::unix::fs::MetadataExt;
6451 Ok(file.metadata().ok().map(|m| m.ino()))
6452 }
6453 #[cfg(not(unix))]
6454 {
6455 drop(file);
6456 Ok(None)
6457 }
6458 }
6459 Err(e) => {
6460 tracing::warn!("Failed to create bash temp file: {e}");
6461 Ok(None)
6462 }
6463 }
6464 })
6465 .await
6466 .unwrap_or(None);
6467
6468 if expected_inode.is_some() || !cfg!(unix) {
6469 match asupersync::fs::OpenOptions::new()
6470 .append(true)
6471 .open(&path)
6472 .await
6473 {
6474 Ok(mut file) => {
6475 #[cfg_attr(not(unix), allow(unused_mut))]
6476 let mut identity_match = true;
6477 #[cfg(unix)]
6478 if let Some(expected) = expected_inode {
6479 use std::os::unix::fs::MetadataExt;
6480 match std::fs::symlink_metadata(&path) {
6485 Ok(meta) => {
6486 if !meta.ino().eq(&expected) {
6487 tracing::warn!(
6488 "Temp file identity mismatch (possible TOCTOU attack)"
6489 );
6490 identity_match = false;
6491 }
6492 }
6493 Err(e) => {
6494 tracing::warn!("Failed to stat temp file: {e}");
6495 identity_match = false;
6496 }
6497 }
6498 }
6499
6500 if identity_match {
6501 let mut failed_flush = false;
6503 for existing in &state.chunks {
6504 if let Err(e) = file.write_all(existing).await {
6505 tracing::warn!("Failed to flush bash chunk to temp file: {e}");
6506 failed_flush = true;
6507 break;
6508 }
6509 }
6510
6511 state.temp_file_path = Some(path);
6512 if failed_flush {
6513 state.abandon_spill_file();
6514 } else {
6515 state.temp_file = Some(file);
6516 }
6517 } else {
6518 state.temp_file_path = Some(path);
6519 state.abandon_spill_file();
6520 }
6521 }
6522 Err(e) => {
6523 tracing::warn!("Failed to open temp file async: {e}");
6524 state.temp_file_path = Some(path);
6525 state.abandon_spill_file();
6526 }
6527 }
6528 } else {
6529 state.spill_failed = true;
6530 }
6531 }
6532
6533 let mut close_spill_file = false;
6534 if let Some(file) = state.temp_file.as_mut() {
6535 let mut abandon_spill_file = false;
6536 if state.total_bytes <= BASH_FILE_LIMIT_BYTES {
6537 if let Err(e) = file.write_all(&chunk).await {
6538 tracing::warn!("Failed to write bash chunk to temp file: {e}");
6539 abandon_spill_file = true;
6540 }
6541 } else {
6542 if !state.spill_failed {
6544 tracing::warn!("Bash output exceeded hard limit; stopping file log");
6545 close_spill_file = true;
6546 }
6547 }
6548 if abandon_spill_file {
6549 state.abandon_spill_file();
6550 }
6551 }
6552 if close_spill_file {
6553 state.temp_file = None;
6554 }
6555
6556 state.chunks_bytes = state.chunks_bytes.saturating_add(chunk.len());
6557 state.chunks.push_back(chunk);
6558 while state.chunks_bytes > state.max_chunks_bytes && state.chunks.len() > 1 {
6559 if let Some(front) = state.chunks.pop_front() {
6560 state.chunks_bytes = state.chunks_bytes.saturating_sub(front.len());
6561 }
6562 }
6563 Ok(())
6564}
6565
6566const fn line_count_from_newline_count(
6567 total_bytes: usize,
6568 newline_count: usize,
6569 last_byte_was_newline: bool,
6570) -> usize {
6571 if total_bytes == 0 {
6572 0
6573 } else if last_byte_was_newline {
6574 newline_count
6575 } else {
6576 newline_count.saturating_add(1)
6577 }
6578}
6579
6580fn emit_bash_update(
6581 state: &BashOutputState,
6582 on_update: Option<&(dyn Fn(ToolUpdate) + Send + Sync)>,
6583) -> Result<()> {
6584 if let Some(callback) = on_update {
6585 let raw = concat_chunks(&state.chunks);
6586 let full_text = String::from_utf8_lossy(&raw);
6587 let truncation =
6588 truncate_tail(full_text.into_owned(), DEFAULT_MAX_LINES, DEFAULT_MAX_BYTES);
6589
6590 let elapsed_ms = state.start_time.elapsed().as_millis();
6595 let line_count = line_count_from_newline_count(
6596 state.total_bytes,
6597 state.line_count,
6598 state.last_byte_was_newline,
6599 );
6600 let mut details = serde_json::json!({
6601 "progress": {
6602 "elapsedMs": elapsed_ms,
6603 "lineCount": line_count,
6604 "byteCount": state.total_bytes
6605 }
6606 });
6607 let Some(details_map) = details.as_object_mut() else {
6608 return Ok(());
6609 };
6610
6611 if let Some(timeout) = state.timeout_ms {
6612 if let Some(progress) = details_map
6613 .get_mut("progress")
6614 .and_then(|v| v.as_object_mut())
6615 {
6616 progress.insert("timeoutMs".into(), serde_json::json!(timeout));
6617 }
6618 }
6619 if truncation.truncated {
6620 details_map.insert("truncation".into(), serde_json::to_value(&truncation)?);
6621 }
6622 if let Some(path) = state.temp_file_path.as_ref() {
6623 details_map.insert(
6624 "fullOutputPath".into(),
6625 serde_json::Value::String(path.display().to_string()),
6626 );
6627 }
6628
6629 callback(ToolUpdate {
6630 content: vec![ContentBlock::Text(TextContent::new(truncation.content))],
6631 details: Some(details),
6632 });
6633 }
6634 Ok(())
6635}
6636
6637pub(crate) struct ProcessGuard {
6638 child: Option<std::process::Child>,
6639 cleanup_mode: ProcessCleanupMode,
6640}
6641
6642#[derive(Clone, Copy, Debug, Eq, PartialEq)]
6643pub(crate) enum ProcessCleanupMode {
6644 ChildOnly,
6645 ProcessGroupTree,
6646}
6647
6648impl ProcessGuard {
6649 pub(crate) const fn new(child: std::process::Child, cleanup_mode: ProcessCleanupMode) -> Self {
6650 Self {
6651 child: Some(child),
6652 cleanup_mode,
6653 }
6654 }
6655
6656 pub(crate) fn try_wait_child(&mut self) -> std::io::Result<Option<std::process::ExitStatus>> {
6657 self.child
6658 .as_mut()
6659 .map_or(Ok(None), std::process::Child::try_wait)
6660 }
6661
6662 pub(crate) fn kill(&mut self) -> Option<std::process::ExitStatus> {
6663 if let Some(mut child) = self.child.take() {
6664 cleanup_child(Some(child.id()), self.cleanup_mode);
6665 let _ = child.kill();
6666 std::thread::spawn(move || {
6667 let _ = child.wait();
6668 });
6669 return None;
6672 }
6673 None
6674 }
6675
6676 pub(crate) fn wait(&mut self) -> std::io::Result<std::process::ExitStatus> {
6677 if let Some(mut child) = self.child.take() {
6678 return child.wait();
6679 }
6680 Err(std::io::Error::other("Already waited"))
6681 }
6682}
6683
6684impl Drop for ProcessGuard {
6685 fn drop(&mut self) {
6686 if let Some(mut child) = self.child.take() {
6687 match child.try_wait() {
6688 Ok(None) => {}
6689 Ok(Some(_)) | Err(_) => return,
6690 }
6691 let cleanup_mode = self.cleanup_mode;
6692 std::thread::spawn(move || {
6693 cleanup_child(Some(child.id()), cleanup_mode);
6694 let _ = child.kill();
6695 let _ = child.wait();
6696 });
6697 }
6698 }
6699}
6700
6701fn cleanup_child(pid: Option<u32>, cleanup_mode: ProcessCleanupMode) {
6702 if cleanup_mode == ProcessCleanupMode::ProcessGroupTree {
6703 kill_process_group_tree(pid);
6704 }
6705}
6706
6707pub fn kill_process_tree(pid: Option<u32>) {
6708 kill_process_tree_with(pid, sysinfo::Signal::Kill, false);
6709}
6710
6711pub(crate) fn kill_process_group_tree(pid: Option<u32>) {
6712 kill_process_tree_with(pid, sysinfo::Signal::Kill, true);
6713}
6714
6715fn terminate_process_group_tree(pid: Option<u32>) {
6716 kill_process_tree_with(pid, sysinfo::Signal::Term, true);
6717}
6718
6719fn kill_process_tree_with(pid: Option<u32>, signal: sysinfo::Signal, include_process_group: bool) {
6720 let Some(pid) = pid else {
6721 return;
6722 };
6723
6724 let root = sysinfo::Pid::from_u32(pid);
6725
6726 let mut sys = sysinfo::System::new();
6727 sys.refresh_processes(sysinfo::ProcessesToUpdate::All, true);
6728
6729 let mut children_map: HashMap<sysinfo::Pid, Vec<sysinfo::Pid>> = HashMap::new();
6730 for (p, proc_) in sys.processes() {
6731 if let Some(parent) = proc_.parent() {
6732 children_map.entry(parent).or_default().push(*p);
6733 }
6734 }
6735
6736 let mut to_kill = Vec::new();
6737 let mut visited = std::collections::HashSet::new();
6738 collect_process_tree(root, &children_map, &mut to_kill, &mut visited);
6739
6740 if include_process_group {
6741 #[cfg(unix)]
6745 {
6746 let sig_num = match signal {
6747 sysinfo::Signal::Kill => "9",
6748 _ => "15",
6749 };
6750 let _ = Command::new("kill")
6751 .arg(format!("-{sig_num}"))
6752 .arg("--")
6753 .arg(format!("-{pid}"))
6754 .stdin(Stdio::null())
6755 .stdout(Stdio::null())
6756 .stderr(Stdio::null())
6757 .status();
6758 }
6759 }
6760
6761 for pid in to_kill.into_iter().rev() {
6763 if let Some(proc_) = sys.process(pid) {
6764 match proc_.kill_with(signal) {
6765 Some(true) => {}
6766 Some(false) | None => {
6767 let _ = proc_.kill();
6768 }
6769 }
6770 }
6771 }
6772}
6773
6774fn collect_process_tree(
6775 pid: sysinfo::Pid,
6776 children_map: &HashMap<sysinfo::Pid, Vec<sysinfo::Pid>>,
6777 out: &mut Vec<sysinfo::Pid>,
6778 visited: &mut std::collections::HashSet<sysinfo::Pid>,
6779) {
6780 if !visited.insert(pid) {
6781 return;
6782 }
6783 out.push(pid);
6784 if let Some(children) = children_map.get(&pid) {
6785 for child in children {
6786 collect_process_tree(*child, children_map, out, visited);
6787 }
6788 }
6789}
6790
6791pub(crate) const SIGPIPE_TRAMPOLINE_EXEC_FAILURE_PREFIX: &str = "pi-sigpipe-reset: exec failed:";
6799
6800pub(crate) fn command_with_default_sigpipe(program: impl AsRef<OsStr>) -> std::io::Result<Command> {
6801 command_with_default_sigpipe_for_cwd(program.as_ref(), None)
6802}
6803
6804pub(crate) fn command_with_default_sigpipe_in_dir(
6807 program: impl AsRef<OsStr>,
6808 cwd: &Path,
6809) -> std::io::Result<Command> {
6810 command_with_default_sigpipe_for_cwd(program.as_ref(), Some(cwd))
6811}
6812
6813#[cfg(unix)]
6814fn command_with_default_sigpipe_for_cwd(
6815 program: &OsStr,
6816 cwd: Option<&Path>,
6817) -> std::io::Result<Command> {
6818 let program = resolve_executable_for_shell_trampoline(program, cwd)?;
6819 let mut command = Command::new("/bin/sh");
6820 command
6821 .arg("-c")
6822 .arg(
6823 "trap - PIPE\n\
6824 exec \"$@\"\n\
6825 status=$?\n\
6826 printf 'pi-sigpipe-reset: exec failed: %s\\n' \"$1\" >&2\n\
6827 exit \"$status\"",
6828 )
6829 .arg("pi-sigpipe-reset")
6830 .arg(program);
6831 Ok(command)
6832}
6833
6834#[cfg(not(unix))]
6835fn command_with_default_sigpipe_for_cwd(
6836 program: &OsStr,
6837 _cwd: Option<&Path>,
6838) -> std::io::Result<Command> {
6839 let command = Command::new(program); Ok(command)
6841}
6842
6843#[cfg(unix)]
6844fn resolve_executable_for_shell_trampoline(
6845 program: &OsStr,
6846 cwd: Option<&Path>,
6847) -> std::io::Result<OsString> {
6848 use std::os::unix::ffi::OsStrExt as _;
6849 use std::os::unix::fs::PermissionsExt as _;
6850
6851 fn executable_candidate(path: &Path) -> std::io::Result<bool> {
6852 let metadata = std::fs::metadata(path)?;
6853 Ok(metadata.is_file() && metadata.permissions().mode() & 0o111 != 0)
6854 }
6855
6856 fn absolutize_candidate(path: &Path, cwd: Option<&Path>) -> std::io::Result<PathBuf> {
6857 if path.is_absolute() {
6858 return Ok(path.to_path_buf());
6859 }
6860
6861 let base = std::env::current_dir()?;
6862 Ok(cwd.map_or_else(|| base.join(path), |cwd| base.join(cwd).join(path)))
6863 }
6864
6865 if program.as_bytes().contains(&b'/') {
6866 let path = Path::new(program);
6867 let candidate = absolutize_candidate(path, cwd)?;
6868 if executable_candidate(&candidate)? {
6869 return Ok(candidate.into_os_string());
6870 }
6871 return Err(std::io::Error::new(
6872 std::io::ErrorKind::PermissionDenied,
6873 format!("not an executable file: {}", candidate.display()),
6874 ));
6875 }
6876
6877 let mut permission_denied = false;
6878 let paths = std::env::var_os("PATH").unwrap_or_else(|| OsString::from("/bin:/usr/bin"));
6879 for dir in std::env::split_paths(&paths) {
6880 let candidate = absolutize_candidate(&dir.join(program), cwd)?;
6881 match executable_candidate(&candidate) {
6882 Ok(true) => return Ok(candidate.into_os_string()),
6883 Ok(false) => permission_denied = true,
6884 Err(err) if matches!(err.kind(), std::io::ErrorKind::NotFound) => {}
6885 Err(err) if matches!(err.kind(), std::io::ErrorKind::PermissionDenied) => {
6886 permission_denied = true;
6887 }
6888 Err(_) => {}
6889 }
6890 }
6891
6892 if permission_denied {
6893 Err(std::io::Error::new(
6894 std::io::ErrorKind::PermissionDenied,
6895 format!("command is not executable: {}", program.to_string_lossy()),
6896 ))
6897 } else {
6898 Err(std::io::Error::new(
6899 std::io::ErrorKind::NotFound,
6900 format!("command not found: {}", program.to_string_lossy()),
6901 ))
6902 }
6903}
6904
6905pub(crate) fn isolate_command_process_group(command: &mut Command) {
6907 #[cfg(unix)]
6908 {
6909 use std::os::unix::process::CommandExt as _;
6910 command.process_group(0);
6911 }
6912
6913 #[cfg(not(unix))]
6914 {
6915 let _ = command;
6916 }
6917}
6918
6919fn format_grep_path(file_path: &Path, cwd: &Path) -> String {
6920 if let Ok(rel) = file_path.strip_prefix(cwd) {
6921 let rel_str = rel.display().to_string().replace('\\', "/");
6922 if !rel_str.is_empty() {
6923 return rel_str;
6924 }
6925 }
6926
6927 let canonical_file = safe_canonicalize(file_path);
6928 let canonical_cwd = safe_canonicalize(cwd);
6929 if let Ok(rel) = canonical_file.strip_prefix(&canonical_cwd) {
6930 let rel_str = rel.display().to_string().replace('\\', "/");
6931 if !rel_str.is_empty() {
6932 return rel_str;
6933 }
6934 }
6935
6936 file_path.display().to_string().replace('\\', "/")
6937}
6938
6939async fn get_file_lines_async<'a>(
6940 path: &Path,
6941 cache: &'a mut HashMap<PathBuf, Vec<String>>,
6942) -> &'a [String] {
6943 if !cache.contains_key(path) {
6944 if let Ok(meta) = asupersync::fs::metadata(path).await {
6946 if !meta.is_file() || meta.len() > 10 * 1024 * 1024 {
6947 cache.insert(path.to_path_buf(), Vec::new());
6948 return &[];
6949 }
6950 } else {
6951 cache.insert(path.to_path_buf(), Vec::new());
6952 return &[];
6953 }
6954
6955 let bytes = match asupersync::fs::read(path).await {
6957 Ok(bytes) => bytes,
6958 Err(err) => {
6959 tracing::debug!("Failed to read grep file {}: {err}", path.display());
6960 cache.insert(path.to_path_buf(), Vec::new());
6961 return &[];
6962 }
6963 };
6964 let content = String::from_utf8_lossy(&bytes);
6965 let mut lines = Vec::new();
6966 for line in content.split('\n') {
6967 let trimmed = line.strip_suffix('\r').unwrap_or(line);
6968 for piece in trimmed.split('\r') {
6969 lines.push(piece.to_string());
6970 }
6971 }
6972 if content.ends_with('\n') && lines.last().is_some_and(std::string::String::is_empty) {
6973 lines.pop();
6974 }
6975 cache.insert(path.to_path_buf(), lines);
6976 }
6977 if let Some(lines) = cache.get(path) {
6978 lines.as_slice()
6979 } else {
6980 &[]
6981 }
6982}
6983
6984fn find_fd_binary() -> Option<&'static str> {
6985 static BINARY: OnceLock<Option<&'static str>> = OnceLock::new();
6986 *BINARY.get_or_init(|| {
6987 if std::process::Command::new("fd")
6988 .arg("--version")
6989 .stdout(Stdio::null())
6990 .stderr(Stdio::null())
6991 .status()
6992 .is_ok()
6993 {
6994 return Some("fd");
6995 }
6996 if std::process::Command::new("fdfind")
6997 .arg("--version")
6998 .stdout(Stdio::null())
6999 .stderr(Stdio::null())
7000 .status()
7001 .is_ok()
7002 {
7003 return Some("fdfind");
7004 }
7005 None
7006 })
7007}
7008
7009fn find_rg_binary() -> Option<&'static str> {
7010 static BINARY: OnceLock<Option<&'static str>> = OnceLock::new();
7011 *BINARY.get_or_init(|| {
7012 if std::process::Command::new("rg")
7013 .arg("--version")
7014 .stdout(Stdio::null())
7015 .stderr(Stdio::null())
7016 .status()
7017 .is_ok()
7018 {
7019 return Some("rg");
7020 }
7021 if std::process::Command::new("ripgrep")
7022 .arg("--version")
7023 .stdout(Stdio::null())
7024 .stderr(Stdio::null())
7025 .status()
7026 .is_ok()
7027 {
7028 return Some("ripgrep");
7029 }
7030 None
7031 })
7032}
7033
7034const NIBBLE_STR: &[u8; 16] = b"ZPMQVRWSNKTXJBYH";
7040
7041static HASHLINE_DICT: OnceLock<[[u8; 2]; 256]> = OnceLock::new();
7044
7045fn hashline_dict() -> &'static [[u8; 2]; 256] {
7046 HASHLINE_DICT.get_or_init(|| {
7047 let mut dict = [[0u8; 2]; 256];
7048 for i in 0..256 {
7049 dict[i] = [NIBBLE_STR[i & 0x0F], NIBBLE_STR[(i >> 4) & 0x0F]];
7050 }
7051 dict
7052 })
7053}
7054
7055fn compute_line_hash(line_idx: usize, line: &str) -> [u8; 2] {
7065 let line = line.strip_suffix('\r').unwrap_or(line);
7066 let significant: String = line.chars().filter(|c| !c.is_whitespace()).collect();
7068 let has_alnum = significant.chars().any(char::is_alphanumeric);
7069 let seed = if has_alnum {
7070 0
7071 } else {
7072 #[allow(clippy::cast_possible_truncation)]
7073 let s = line_idx as u32;
7074 s
7075 };
7076 let hash = xxhash_rust::xxh32::xxh32(significant.as_bytes(), seed);
7077 let byte = (hash & 0xFF) as usize;
7078 hashline_dict()[byte]
7079}
7080
7081fn format_hashline_tag(line_idx: usize, line: &str) -> String {
7083 let h = compute_line_hash(line_idx, line);
7084 format!("{}#{}{}", line_idx + 1, h[0] as char, h[1] as char)
7085}
7086
7087fn format_hashline_tag_with_bom(line_idx: usize, line: &str, had_bom: bool) -> String {
7089 let h = compute_line_hash_with_bom(line_idx, line, had_bom);
7090 format!("{}#{}{}", line_idx + 1, h[0] as char, h[1] as char)
7091}
7092
7093fn compute_line_hash_with_bom(line_idx: usize, line: &str, had_bom: bool) -> [u8; 2] {
7094 if had_bom && line_idx == 0 {
7095 let mut with_bom = String::with_capacity(line.len().saturating_add(1));
7096 with_bom.push('\u{FEFF}');
7097 with_bom.push_str(line);
7098 compute_line_hash(line_idx, &with_bom)
7099 } else {
7100 compute_line_hash(line_idx, line)
7101 }
7102}
7103
7104static HASHLINE_TAG_RE: OnceLock<regex::Regex> = OnceLock::new();
7107
7108fn hashline_tag_regex() -> &'static regex::Regex {
7109 HASHLINE_TAG_RE.get_or_init(|| {
7110 regex::Regex::new(r"^[\s>+\-]*(\d+)\s*#\s*([ZPMQVRWSNKTXJBYH]{2})")
7111 .expect("valid hashline regex")
7112 })
7113}
7114
7115fn parse_hashline_tag(ref_str: &str) -> std::result::Result<(usize, [u8; 2]), String> {
7117 let re = hashline_tag_regex();
7118 let caps = re
7119 .captures(ref_str)
7120 .ok_or_else(|| format!("Invalid hashline reference: {ref_str:?}"))?;
7121 let line_num: usize = caps[1]
7122 .parse()
7123 .map_err(|e| format!("Invalid line number in {ref_str:?}: {e}"))?;
7124 if line_num == 0 {
7125 return Err(format!("Line number must be >= 1, got 0 in {ref_str:?}"));
7126 }
7127 let hash_bytes = caps[2].as_bytes();
7128 Ok((line_num, [hash_bytes[0], hash_bytes[1]]))
7129}
7130
7131static HASHLINE_PREFIX_RE: OnceLock<regex::Regex> = OnceLock::new();
7134
7135fn strip_hashline_prefix(line: &str) -> &str {
7136 let re = HASHLINE_PREFIX_RE.get_or_init(|| {
7137 regex::Regex::new(r"^[\s>+\-]*\d+\s*#\s*[ZPMQVRWSNKTXJBYH]{2}\s*:")
7138 .expect("valid hashline prefix regex")
7139 });
7140 re.find(line).map_or(line, |m| &line[m.end()..])
7141}
7142
7143#[derive(Debug, Deserialize)]
7145#[serde(rename_all = "camelCase")]
7146struct HashlineEditInput {
7147 path: String,
7148 edits: Vec<HashlineOp>,
7149}
7150
7151#[derive(Debug, Clone, Deserialize)]
7153#[serde(rename_all = "camelCase")]
7154struct HashlineOp {
7155 op: String,
7157 pos: Option<String>,
7159 end: Option<String>,
7161 lines: Option<serde_json::Value>,
7163}
7164
7165impl HashlineOp {
7166 fn get_lines(&self) -> Vec<String> {
7168 match &self.lines {
7169 None | Some(serde_json::Value::Null) => vec![],
7170 Some(serde_json::Value::String(s)) => {
7171 normalize_to_lf(s).split('\n').map(String::from).collect()
7172 }
7173 Some(serde_json::Value::Array(arr)) => arr
7174 .iter()
7175 .map(|v| match v {
7176 serde_json::Value::String(s) => normalize_to_lf(s),
7177 other => normalize_to_lf(&other.to_string()),
7178 })
7179 .collect(),
7180 Some(other) => vec![normalize_to_lf(&other.to_string())],
7181 }
7182 }
7183}
7184
7185struct ResolvedEdit<'a> {
7187 op: &'a str,
7188 start: usize,
7190 end: usize,
7192 lines: Vec<String>,
7193}
7194
7195pub struct HashlineEditTool {
7196 cwd: PathBuf,
7197}
7198
7199impl HashlineEditTool {
7200 pub fn new(cwd: &Path) -> Self {
7201 Self {
7202 cwd: cwd.to_path_buf(),
7203 }
7204 }
7205}
7206
7207fn validate_line_ref(
7210 ref_str: &str,
7211 file_lines: &[&str],
7212 had_bom: bool,
7213) -> std::result::Result<usize, String> {
7214 let (line_num, expected_hash) = parse_hashline_tag(ref_str)?;
7215 let line_idx = line_num - 1;
7216 if line_idx >= file_lines.len() {
7217 return Err(format!(
7218 "Line {line_num} out of range (file has {} lines)",
7219 file_lines.len()
7220 ));
7221 }
7222 let actual_hash = compute_line_hash_with_bom(line_idx, file_lines[line_idx], had_bom);
7223 if actual_hash != expected_hash {
7224 let tag = format_hashline_tag_with_bom(line_idx, file_lines[line_idx], had_bom);
7225 return Err(format!(
7226 "Hash mismatch at line {line_num}: expected {}#{}{}, actual is {tag}",
7227 line_num, expected_hash[0] as char, expected_hash[1] as char,
7228 ));
7229 }
7230 Ok(line_idx)
7231}
7232
7233fn mismatch_context(file_lines: &[&str], line_idx: usize, context: usize, had_bom: bool) -> String {
7235 let start = line_idx.saturating_sub(context);
7236 let end = (line_idx + context + 1).min(file_lines.len());
7237 let mut out = String::new();
7238 for (i, &file_line) in file_lines.iter().enumerate().take(end).skip(start) {
7239 let tag = format_hashline_tag_with_bom(i, file_line, had_bom);
7240 if i == line_idx {
7241 let _ = writeln!(out, ">>> {tag}:{file_line}");
7242 } else {
7243 let _ = writeln!(out, " {tag}:{file_line}");
7244 }
7245 }
7246 out
7247}
7248
7249fn collect_mismatches(
7251 edits: &[HashlineOp],
7252 file_lines: &[&str],
7253 had_bom: bool,
7254) -> std::result::Result<(), String> {
7255 let mut errors = Vec::new();
7256 for edit in edits {
7257 if let Some(ref pos) = edit.pos {
7258 if let Err(e) = validate_line_ref(pos, file_lines, had_bom) {
7259 if let Ok((line_num, _)) = parse_hashline_tag(pos) {
7261 let idx = (line_num - 1).min(file_lines.len().saturating_sub(1));
7262 errors.push(format!(
7263 "{e}\n{}",
7264 mismatch_context(file_lines, idx, 2, had_bom)
7265 ));
7266 } else {
7267 errors.push(e);
7268 }
7269 }
7270 }
7271 if let Some(ref end) = edit.end {
7272 if let Err(e) = validate_line_ref(end, file_lines, had_bom) {
7273 if let Ok((line_num, _)) = parse_hashline_tag(end) {
7274 let idx = (line_num - 1).min(file_lines.len().saturating_sub(1));
7275 errors.push(format!(
7276 "{e}\n{}",
7277 mismatch_context(file_lines, idx, 2, had_bom)
7278 ));
7279 } else {
7280 errors.push(e);
7281 }
7282 }
7283 }
7284 }
7285 if errors.is_empty() {
7286 Ok(())
7287 } else {
7288 Err(errors.join("\n"))
7289 }
7290}
7291
7292#[derive(Debug, Clone, PartialEq, Eq, Hash)]
7294struct NormalizedEdit {
7295 op: String,
7296 pos_line: Option<usize>,
7297 end_line: Option<usize>,
7298 lines: Vec<String>,
7299}
7300
7301fn op_precedence(op: &str) -> u8 {
7303 match op {
7304 "replace" => 0,
7305 "append" => 1,
7306 "prepend" => 2,
7307 _ => 3,
7308 }
7309}
7310
7311#[async_trait]
7312#[allow(clippy::unnecessary_literal_bound)]
7313impl Tool for HashlineEditTool {
7314 fn name(&self) -> &str {
7315 "hashline_edit"
7316 }
7317 fn label(&self) -> &str {
7318 "hashline edit"
7319 }
7320 fn description(&self) -> &str {
7321 "Apply precise file edits using LINE#HASH tags from a prior read with hashline=true. \
7322 Each edit specifies an op (replace/prepend/append), a pos anchor (\"N#AB\"), an optional \
7323 end anchor for range replace, and replacement lines. Edits are validated against current \
7324 file hashes and applied bottom-up to avoid index invalidation."
7325 }
7326
7327 fn parameters(&self) -> serde_json::Value {
7328 serde_json::json!({
7329 "type": "object",
7330 "properties": {
7331 "path": {
7332 "type": "string",
7333 "description": "Path to the file to edit (relative or absolute)"
7334 },
7335 "edits": {
7336 "type": "array",
7337 "description": "Array of edit operations to apply",
7338 "items": {
7339 "type": "object",
7340 "properties": {
7341 "op": {
7342 "type": "string",
7343 "enum": ["replace", "prepend", "append"],
7344 "description": "Operation type"
7345 },
7346 "pos": {
7347 "type": "string",
7348 "description": "Anchor line reference in LINE#HASH format (e.g. \"5#KJ\")"
7349 },
7350 "end": {
7351 "type": "string",
7352 "description": "End anchor for range replace (inclusive)"
7353 },
7354 "lines": {
7355 "description": "Replacement/insertion content as array of strings, single string, or null for deletion",
7356 "oneOf": [
7357 { "type": "array", "items": { "type": "string" } },
7358 { "type": "string" },
7359 { "type": "null" }
7360 ]
7361 }
7362 },
7363 "required": ["op"]
7364 }
7365 }
7366 },
7367 "required": ["path", "edits"]
7368 })
7369 }
7370
7371 #[allow(clippy::too_many_lines)]
7372 async fn execute(
7373 &self,
7374 _tool_call_id: &str,
7375 input: serde_json::Value,
7376 _on_update: Option<Box<dyn Fn(ToolUpdate) + Send + Sync>>,
7377 ) -> Result<ToolOutput> {
7378 let input: HashlineEditInput = serde_json::from_value(input)
7379 .map_err(|e| Error::tool("hashline_edit", format!("Invalid input: {e}")))?;
7380
7381 if input.edits.is_empty() {
7382 return Err(Error::tool("hashline_edit", "No edits provided"));
7383 }
7384
7385 let resolved = resolve_read_path(&input.path, &self.cwd);
7387 let absolute_path = enforce_cwd_scope(&resolved, &self.cwd, "hashline_edit")?;
7388
7389 let metadata = asupersync::fs::metadata(&absolute_path)
7391 .await
7392 .map_err(|err| {
7393 let message = match err.kind() {
7394 std::io::ErrorKind::NotFound => format!("File not found: {}", input.path),
7395 std::io::ErrorKind::PermissionDenied => {
7396 format!("Permission denied: {}", input.path)
7397 }
7398 _ => format!("Cannot read file metadata: {err}"),
7399 };
7400 Error::tool("hashline_edit", message)
7401 })?;
7402 if !metadata.is_file() {
7403 return Err(Error::tool(
7404 "hashline_edit",
7405 format!("Path {} is not a regular file", absolute_path.display()),
7406 ));
7407 }
7408 if metadata.len() > READ_TOOL_MAX_BYTES {
7409 return Err(Error::tool(
7410 "hashline_edit",
7411 format!(
7412 "File too large ({} bytes, max {} bytes)",
7413 metadata.len(),
7414 READ_TOOL_MAX_BYTES
7415 ),
7416 ));
7417 }
7418
7419 let file = asupersync::fs::File::open(&absolute_path)
7421 .await
7422 .map_err(|e| Error::tool("hashline_edit", format!("Cannot open file: {e}")))?;
7423 let mut raw = Vec::new();
7424 let mut limiter = file.take(READ_TOOL_MAX_BYTES.saturating_add(1));
7425 limiter
7426 .read_to_end(&mut raw)
7427 .await
7428 .map_err(|e| Error::tool("hashline_edit", format!("Cannot read file: {e}")))?;
7429
7430 if raw.len() as u64 > READ_TOOL_MAX_BYTES {
7431 return Err(Error::tool(
7432 "hashline_edit",
7433 format!("File too large (> {READ_TOOL_MAX_BYTES} bytes)"),
7434 ));
7435 }
7436
7437 let raw_content = String::from_utf8(raw).map_err(|_| {
7438 Error::tool(
7439 "hashline_edit",
7440 "File contains invalid UTF-8 characters and cannot be safely edited as text."
7441 .to_string(),
7442 )
7443 })?;
7444
7445 let (content_no_bom, had_bom) = strip_bom(&raw_content);
7446 let original_ending = detect_line_ending(content_no_bom);
7447 let normalized = normalize_to_lf(content_no_bom);
7448 let file_lines: Vec<&str> = normalized.split('\n').collect();
7449
7450 if let Err(e) = collect_mismatches(&input.edits, &file_lines, had_bom) {
7452 return Err(Error::tool(
7453 "hashline_edit",
7454 format!("Hash validation failed — re-read the file to get current tags.\n\n{e}"),
7455 ));
7456 }
7457
7458 let mut seen = std::collections::HashSet::new();
7460 let mut deduped_edits: Vec<&HashlineOp> = Vec::new();
7461 for edit in &input.edits {
7462 let pos_line = edit
7463 .pos
7464 .as_ref()
7465 .and_then(|p| parse_hashline_tag(p).ok())
7466 .map(|(n, _)| n);
7467 let end_line = edit
7468 .end
7469 .as_ref()
7470 .and_then(|e| parse_hashline_tag(e).ok())
7471 .map(|(n, _)| n);
7472 let key = NormalizedEdit {
7473 op: edit.op.clone(),
7474 pos_line,
7475 end_line,
7476 lines: edit.get_lines(),
7477 };
7478 if seen.insert(key) {
7479 deduped_edits.push(edit);
7480 }
7481 }
7482
7483 let mut resolved: Vec<ResolvedEdit<'_>> = Vec::new();
7485 for edit in &deduped_edits {
7486 let replacement_lines: Vec<String> = edit
7487 .get_lines()
7488 .into_iter()
7489 .map(|l| strip_hashline_prefix(&l).to_string())
7490 .collect();
7491
7492 match edit.op.as_str() {
7493 "replace" => {
7494 let start_idx = match &edit.pos {
7495 Some(pos) => validate_line_ref(pos, &file_lines, had_bom)
7496 .map_err(|e| Error::tool("hashline_edit", e))?,
7497 None => {
7498 return Err(Error::tool(
7499 "hashline_edit",
7500 "replace operation requires a pos anchor",
7501 ));
7502 }
7503 };
7504 let end_idx = match &edit.end {
7505 Some(end) => validate_line_ref(end, &file_lines, had_bom)
7506 .map_err(|e| Error::tool("hashline_edit", e))?,
7507 None => start_idx,
7508 };
7509 if end_idx < start_idx {
7510 return Err(Error::tool(
7511 "hashline_edit",
7512 format!(
7513 "End anchor (line {}) is before start anchor (line {})",
7514 end_idx + 1,
7515 start_idx + 1
7516 ),
7517 ));
7518 }
7519 resolved.push(ResolvedEdit {
7520 op: "replace",
7521 start: start_idx,
7522 end: end_idx,
7523 lines: replacement_lines,
7524 });
7525 }
7526 "prepend" => {
7527 let idx = match &edit.pos {
7528 Some(pos) => validate_line_ref(pos, &file_lines, had_bom)
7529 .map_err(|e| Error::tool("hashline_edit", e))?,
7530 None => 0, };
7532 let end_idx = if file_lines == [""] && edit.pos.is_none() {
7533 0 } else {
7535 idx
7536 };
7537 resolved.push(ResolvedEdit {
7538 op: if file_lines == [""] && edit.pos.is_none() {
7539 "replace"
7540 } else {
7541 "prepend"
7542 },
7543 start: idx,
7544 end: end_idx,
7545 lines: replacement_lines,
7546 });
7547 }
7548 "append" => {
7549 let idx = match &edit.pos {
7550 Some(pos) => validate_line_ref(pos, &file_lines, had_bom)
7551 .map_err(|e| Error::tool("hashline_edit", e))?,
7552 None => {
7553 if file_lines.len() > 1 && file_lines.last() == Some(&"") {
7554 file_lines.len() - 2
7555 } else {
7556 file_lines.len().saturating_sub(1)
7557 }
7558 }
7559 };
7560 let end_idx = if file_lines == [""] && edit.pos.is_none() {
7561 0 } else {
7563 idx
7564 };
7565 resolved.push(ResolvedEdit {
7566 op: if file_lines == [""] && edit.pos.is_none() {
7567 "replace"
7568 } else {
7569 "append"
7570 },
7571 start: idx,
7572 end: end_idx,
7573 lines: replacement_lines,
7574 });
7575 }
7576 other => {
7577 return Err(Error::tool(
7578 "hashline_edit",
7579 format!("Unknown op: {other:?}. Must be replace, prepend, or append."),
7580 ));
7581 }
7582 }
7583 }
7584
7585 resolved.sort_by(|a, b| {
7587 b.start
7588 .cmp(&a.start)
7589 .then_with(|| op_precedence(a.op).cmp(&op_precedence(b.op)))
7590 });
7591
7592 for i in 0..resolved.len() {
7594 for j in (i + 1)..resolved.len() {
7595 let a = &resolved[i];
7596 let b = &resolved[j];
7597 if a.start <= b.end && b.start <= a.end {
7598 return Err(Error::tool(
7599 "hashline_edit",
7600 format!(
7601 "Overlapping edits detected: {} at line {}-{} and {} at line {}-{}. \
7602 Please combine overlapping edits into a single operation.",
7603 a.op,
7604 a.start + 1,
7605 a.end + 1,
7606 b.op,
7607 b.start + 1,
7608 b.end + 1
7609 ),
7610 ));
7611 }
7612 }
7613 }
7614
7615 let mut lines: Vec<String> = file_lines.iter().map(|s| (*s).to_string()).collect();
7617 let mut any_change = false;
7618
7619 for edit in &resolved {
7620 match edit.op {
7621 "replace" => {
7622 let existing: Vec<&str> = lines[edit.start..=edit.end]
7624 .iter()
7625 .map(String::as_str)
7626 .collect();
7627 if existing.eq(&edit.lines.iter().map(String::as_str).collect::<Vec<&str>>()) {
7628 continue; }
7630 lines.splice(edit.start..=edit.end, edit.lines.iter().cloned());
7632 any_change = true;
7633 }
7634 "prepend" => {
7635 lines.splice(edit.start..edit.start, edit.lines.iter().cloned());
7637 if !edit.lines.is_empty() {
7638 any_change = true;
7639 }
7640 }
7641 "append" => {
7642 let insert_at = edit.start + 1;
7644 lines.splice(insert_at..insert_at, edit.lines.iter().cloned());
7645 if !edit.lines.is_empty() {
7646 any_change = true;
7647 }
7648 }
7649 _ => {} }
7651 }
7652
7653 if !any_change {
7654 return Err(Error::tool(
7655 "hashline_edit",
7656 format!(
7657 "No changes made to {}. All edits were no-ops (replacement identical to existing content).",
7658 input.path
7659 ),
7660 ));
7661 }
7662
7663 let new_normalized = lines.join("\n");
7665 let new_content = restore_line_endings(&new_normalized, original_ending);
7666 let mut final_content = new_content;
7667 if had_bom {
7668 final_content = format!("\u{FEFF}{final_content}");
7669 }
7670
7671 let absolute_path_clone = absolute_path.clone();
7673 let final_content_bytes = final_content.into_bytes();
7674 asupersync::runtime::spawn_blocking_io(move || {
7675 let original_perms = std::fs::metadata(&absolute_path_clone)
7676 .ok()
7677 .map(|m| m.permissions());
7678 let parent = absolute_path_clone
7679 .parent()
7680 .unwrap_or_else(|| Path::new("."));
7681 let mut temp_file = tempfile::NamedTempFile::new_in(parent)?;
7682
7683 temp_file.as_file_mut().write_all(&final_content_bytes)?;
7684 temp_file.as_file_mut().sync_all()?;
7685
7686 if let Some(perms) = original_perms {
7687 let _ = temp_file.as_file().set_permissions(perms);
7688 } else {
7689 #[cfg(unix)]
7690 {
7691 use std::os::unix::fs::PermissionsExt;
7692 let _ = temp_file
7693 .as_file()
7694 .set_permissions(std::fs::Permissions::from_mode(0o644));
7695 }
7696 }
7697
7698 temp_file
7699 .persist(&absolute_path_clone)
7700 .map_err(|e| e.error)?;
7701 Ok(())
7702 })
7703 .await
7704 .map_err(|e| Error::tool("hashline_edit", format!("Failed to write file: {e}")))?;
7705
7706 let (diff, first_changed_line) = generate_diff_string(&normalized, &new_normalized);
7708 let mut details = serde_json::Map::new();
7709 details.insert("diff".to_string(), serde_json::Value::String(diff));
7710 if let Some(line) = first_changed_line {
7711 details.insert(
7712 "firstChangedLine".to_string(),
7713 serde_json::Value::Number(serde_json::Number::from(line)),
7714 );
7715 }
7716
7717 Ok(ToolOutput {
7718 content: vec![ContentBlock::Text(TextContent::new(format!(
7719 "Successfully applied hashline edits to {}.",
7720 input.path
7721 )))],
7722 details: Some(serde_json::Value::Object(details)),
7723 is_error: false,
7724 })
7725 }
7726}
7727
7728#[cfg(test)]
7733mod tests {
7734 use super::*;
7735 use proptest::prelude::*;
7736 #[cfg(target_os = "linux")]
7737 use std::time::Duration;
7738
7739 #[test]
7740 fn test_truncate_head() {
7741 let content = "line1\nline2\nline3\nline4\nline5".to_string();
7742 let result = truncate_head(content, 3, 1000);
7743
7744 assert_eq!(result.content, "line1\nline2\nline3\n");
7745 assert!(result.truncated);
7746 assert_eq!(result.truncated_by, Some(TruncatedBy::Lines));
7747 assert_eq!(result.total_lines, 5);
7748 assert_eq!(result.output_lines, 3);
7749 }
7750
7751 #[test]
7752 fn test_truncate_tail() {
7753 let content = "line1\nline2\nline3\nline4\nline5".to_string();
7754 let result = truncate_tail(content, 3, 1000);
7755
7756 assert_eq!(result.content, "line3\nline4\nline5");
7757 assert!(result.truncated);
7758 assert_eq!(result.truncated_by, Some(TruncatedBy::Lines));
7759 assert_eq!(result.total_lines, 5);
7760 assert_eq!(result.output_lines, 3);
7761 }
7762
7763 fn assert_same_head_truncation(actual: &TruncationResult, expected: &TruncationResult) {
7764 assert_eq!(actual.content, expected.content);
7765 assert_eq!(actual.truncated, expected.truncated);
7766 assert_eq!(actual.truncated_by, expected.truncated_by);
7767 assert_eq!(actual.total_lines, expected.total_lines);
7768 assert_eq!(actual.total_bytes, expected.total_bytes);
7769 assert_eq!(actual.output_lines, expected.output_lines);
7770 assert_eq!(actual.output_bytes, expected.output_bytes);
7771 assert_eq!(actual.last_line_partial, expected.last_line_partial);
7772 assert_eq!(
7773 actual.first_line_exceeds_limit,
7774 expected.first_line_exceeds_limit
7775 );
7776 assert_eq!(actual.max_lines, expected.max_lines);
7777 assert_eq!(actual.max_bytes, expected.max_bytes);
7778 }
7779
7780 fn write_lines_with_builder(lines: &[&str], max_bytes: usize) -> TruncationResult {
7781 let mut writer = HeadTruncatingLineWriter::new(max_bytes);
7782 for line in lines {
7783 writer.push_line(line);
7784 }
7785 writer.finish()
7786 }
7787
7788 #[test]
7789 fn head_truncating_line_writer_matches_join_without_truncation() {
7790 let lines = ["alpha", "beta", "gamma"];
7791 let expected = truncate_head(lines.join("\n"), usize::MAX, 1000);
7792 let actual = write_lines_with_builder(&lines, 1000);
7793
7794 assert_same_head_truncation(&actual, &expected);
7795 }
7796
7797 #[test]
7798 fn head_truncating_line_writer_matches_join_at_byte_boundary() {
7799 let lines = ["alpha", "beta", "gamma"];
7800 let expected = truncate_head(lines.join("\n"), usize::MAX, 8);
7801 let actual = write_lines_with_builder(&lines, 8);
7802
7803 assert_same_head_truncation(&actual, &expected);
7804 assert_eq!(actual.content, "alpha\nbe");
7805 }
7806
7807 #[test]
7808 fn head_truncating_line_writer_preserves_utf8_boundary_and_order() {
7809 let lines = ["alpha", "βeta", "gamma"];
7810 let expected = truncate_head(lines.join("\n"), usize::MAX, 8);
7811 let actual = write_lines_with_builder(&lines, 8);
7812
7813 assert_same_head_truncation(&actual, &expected);
7814 assert_eq!(actual.content, "alpha\nβ");
7815 }
7816
7817 fn first_text(output: &ToolOutput) -> &str {
7818 output
7819 .content
7820 .first()
7821 .and_then(|block| match block {
7822 ContentBlock::Text(text) => Some(text.text.as_str()),
7823 _ => None,
7824 })
7825 .unwrap_or("")
7826 }
7827
7828 fn artifact_json(details: Option<&serde_json::Value>) -> &serde_json::Value {
7829 details
7830 .and_then(|value| value.get("artifact"))
7831 .expect("artifact details")
7832 }
7833
7834 fn artifact_str_field<'a>(artifact: &'a serde_json::Value, field: &str) -> &'a str {
7835 artifact
7836 .get(field)
7837 .and_then(serde_json::Value::as_str)
7838 .unwrap_or("")
7839 }
7840
7841 #[test]
7842 fn tool_output_artifact_respects_spill_threshold() {
7843 let tmp = tempfile::tempdir().expect("artifact root");
7844 let mut output = "small preview".to_string();
7845 let mut details = None;
7846 let spilled = attach_text_artifact_if_needed_at_root(
7847 tmp.path(),
7848 &mut output,
7849 &mut details,
7850 "read",
7851 "call-small",
7852 "selectedTextWindow",
7853 "small body",
7854 );
7855
7856 assert!(!spilled);
7857 assert_eq!(output, "small preview");
7858 assert!(details.is_none());
7859 }
7860
7861 #[test]
7862 fn tool_output_artifact_writes_content_addressed_text_and_metadata()
7863 -> std::result::Result<(), Box<dyn std::error::Error>> {
7864 let tmp = tempfile::tempdir().expect("artifact root");
7865 let full = "a".repeat(TOOL_OUTPUT_ARTIFACT_THRESHOLD_BYTES + 1);
7866 let mut output = "bounded preview".to_string();
7867 let mut details = None;
7868 let _session_guard =
7869 register_tool_output_artifact_session("call/text:1", "session/artifacts:one");
7870 let spilled = attach_text_artifact_if_needed_at_root(
7871 tmp.path(),
7872 &mut output,
7873 &mut details,
7874 "read",
7875 "call/text:1",
7876 "selectedTextWindow",
7877 &full,
7878 );
7879
7880 assert!(spilled);
7881 assert!(output.contains("Full tool output artifact:"));
7882 let artifact = artifact_json(details.as_ref());
7883 assert_eq!(artifact["schema"], TOOL_OUTPUT_ARTIFACT_SCHEMA_V1);
7884 assert_eq!(artifact["toolName"], "read");
7885 assert_eq!(artifact["sourceKind"], "selectedTextWindow");
7886 assert_eq!(artifact["sessionId"], "session/artifacts:one");
7887 assert_eq!(
7888 artifact["byteCount"].as_u64().unwrap(),
7889 u64::try_from(full.len()).unwrap()
7890 );
7891
7892 let path_value = artifact_str_field(artifact, "path");
7893 let metadata_path_value = artifact_str_field(artifact, "metadataPath");
7894 assert!(!path_value.is_empty(), "artifact path must be a string");
7895 assert!(
7896 !metadata_path_value.is_empty(),
7897 "artifact metadataPath must be a string"
7898 );
7899 let path = PathBuf::from(path_value);
7900 let metadata_path = PathBuf::from(metadata_path_value);
7901 assert!(path.starts_with(tmp.path().join("session_artifacts_one").join("call_text_1")));
7902 assert_eq!(std::fs::read_to_string(path)?, full);
7903 let metadata_bytes = std::fs::read(metadata_path)?;
7904 let metadata: serde_json::Value = serde_json::from_slice(&metadata_bytes)?;
7905 assert_eq!(metadata["sha256"], artifact["sha256"]);
7906 assert_eq!(
7907 metadata["retentionClass"],
7908 TOOL_OUTPUT_ARTIFACT_RETENTION_CLASS
7909 );
7910 assert_eq!(
7911 metadata["spilloverReason"],
7912 TOOL_OUTPUT_ARTIFACT_SPILLOVER_REASON
7913 );
7914 assert_eq!(metadata["safeDeleteCandidate"], true);
7915 assert_eq!(
7916 metadata["redactionSummary"]["policy"],
7917 TOOL_OUTPUT_ARTIFACT_REDACTION_POLICY_V1
7918 );
7919 assert_eq!(metadata["redactionSummary"]["status"], "clean");
7920 assert_eq!(metadata["redactionSummary"]["rawSecretBytesEmitted"], 0);
7921 Ok(())
7922 }
7923
7924 #[test]
7925 fn tool_output_artifact_redacts_sensitive_text_before_persisting()
7926 -> std::result::Result<(), Box<dyn std::error::Error>> {
7927 let tmp = tempfile::tempdir().expect("artifact root");
7928 let leaked_token = "sk-redactionfixture1234567890";
7929 let leaked_bearer = "ghp_redactionfixture1234567890";
7930 let full = format!(
7931 "API_TOKEN={leaked_token}\nAuthorization: Bearer {leaked_bearer}\n{}",
7932 "x".repeat(TOOL_OUTPUT_ARTIFACT_THRESHOLD_BYTES + 1)
7933 );
7934 let mut output = "bounded preview".to_string();
7935 let mut details = None;
7936
7937 let spilled = attach_text_artifact_if_needed_at_root(
7938 tmp.path(),
7939 &mut output,
7940 &mut details,
7941 "read",
7942 "call-secret",
7943 "selectedTextWindow",
7944 &full,
7945 );
7946
7947 assert!(spilled);
7948 let artifact = artifact_json(details.as_ref());
7949 let path = PathBuf::from(artifact_str_field(artifact, "path"));
7950 let metadata_path = PathBuf::from(artifact_str_field(artifact, "metadataPath"));
7951 let persisted = std::fs::read_to_string(path)?;
7952 let metadata: serde_json::Value = serde_json::from_slice(&std::fs::read(metadata_path)?)?;
7953
7954 assert!(!persisted.contains(leaked_token));
7955 assert!(!persisted.contains(leaked_bearer));
7956 assert!(persisted.contains("API_TOKEN=[REDACTED]"));
7957 assert_eq!(artifact["redactionSummary"]["status"], "redacted");
7958 assert_eq!(artifact["redactionSummary"]["rawSecretBytesEmitted"], 0);
7959 assert_eq!(metadata["redactionSummary"], artifact["redactionSummary"]);
7960 let fields = artifact["redactionSummary"]["fields"]
7961 .as_array()
7962 .expect("redaction fields");
7963 assert!(fields.iter().any(|field| field == "api_token"));
7964 assert!(fields.iter().any(|field| field == "authorization"));
7965 Ok(())
7966 }
7967
7968 #[test]
7969 fn tool_output_artifact_marks_binaryish_payloads_in_lifecycle_manifest() {
7970 let tmp = tempfile::tempdir().expect("artifact root");
7971 let full = format!(
7972 "{}\0{}",
7973 "z".repeat(TOOL_OUTPUT_ARTIFACT_THRESHOLD_BYTES / 2),
7974 "z".repeat(TOOL_OUTPUT_ARTIFACT_THRESHOLD_BYTES / 2 + 2)
7975 );
7976 let mut output = "bounded preview".to_string();
7977 let mut details = None;
7978
7979 let spilled = attach_text_artifact_if_needed_at_root(
7980 tmp.path(),
7981 &mut output,
7982 &mut details,
7983 "read",
7984 "call-binaryish",
7985 "selectedTextWindow",
7986 &full,
7987 );
7988
7989 assert!(spilled);
7990 let artifact = artifact_json(details.as_ref());
7991 assert_eq!(artifact["redactionSummary"]["binarySuspect"], true);
7992 assert_eq!(artifact["redactionSummary"]["rawSecretBytesEmitted"], 0);
7993 assert_eq!(artifact["safeDeleteCandidate"], true);
7994 }
7995
7996 #[test]
7997 fn tool_output_artifact_failure_records_degraded_preview() {
7998 let tmp = tempfile::tempdir().expect("artifact root parent");
7999 let root_file = tmp.path().join("not-a-directory");
8000 std::fs::write(&root_file, "not a directory").expect("root file");
8001 let full = "b".repeat(TOOL_OUTPUT_ARTIFACT_THRESHOLD_BYTES + 1);
8002 let mut output = "bounded preview".to_string();
8003 let mut details = None;
8004
8005 let spilled = attach_text_artifact_if_needed_at_root(
8006 &root_file,
8007 &mut output,
8008 &mut details,
8009 "read",
8010 "call-fail",
8011 "selectedTextWindow",
8012 &full,
8013 );
8014
8015 assert!(!spilled);
8016 assert!(output.contains("Tool output artifact persistence failed"));
8017 assert!(
8018 details
8019 .as_ref()
8020 .and_then(|value| value.get("artifactError"))
8021 .is_some()
8022 );
8023 }
8024
8025 #[test]
8026 fn read_tool_spills_oversized_selected_text_window_to_artifact() {
8027 asupersync::test_utils::run_test(|| async {
8028 let tmp = tempfile::tempdir().expect("workspace");
8029 let artifact_root = tempfile::tempdir().expect("artifact root");
8030
8031 let body = "r".repeat(TOOL_OUTPUT_ARTIFACT_THRESHOLD_BYTES + 8);
8032 std::fs::write(tmp.path().join("large.txt"), &body).expect("large file");
8033 let read_tool = ReadTool::with_artifact_root(tmp.path(), artifact_root.path());
8034 let output = read_tool
8035 .execute(
8036 "read-artifact-call",
8037 serde_json::json!({ "path": "large.txt" }),
8038 None,
8039 )
8040 .await
8041 .expect("read large file");
8042
8043 assert!(first_text(&output).contains("Full tool output artifact:"));
8044 let artifact = artifact_json(output.details.as_ref());
8045 assert_eq!(artifact["toolName"], "read");
8046 assert_eq!(artifact["sourceKind"], "selectedTextWindow");
8047 let path_value = artifact_str_field(artifact, "path");
8048 assert!(!path_value.is_empty(), "artifact path must be a string");
8049 let path = PathBuf::from(path_value);
8050 let spilled = match std::fs::read_to_string(&path) {
8051 Ok(spilled) => spilled,
8052 Err(err) => {
8053 assert!(false, "read spilled artifact {}: {err}", path.display());
8054 return;
8055 }
8056 };
8057 let prefix = " 1→";
8058 assert_eq!(spilled.len(), prefix.len() + DEFAULT_MAX_BYTES);
8059 assert_eq!(
8060 artifact["byteCount"].as_u64().unwrap(),
8061 u64::try_from(spilled.len()).unwrap()
8062 );
8063 assert!(spilled.starts_with(prefix));
8064 assert!(spilled[prefix.len()..].bytes().all(|byte| byte == b'r'));
8065 assert_eq!(
8066 artifact["retentionClass"],
8067 TOOL_OUTPUT_ARTIFACT_RETENTION_CLASS
8068 );
8069 assert_eq!(
8070 artifact["spilloverReason"],
8071 TOOL_OUTPUT_ARTIFACT_SPILLOVER_REASON
8072 );
8073 assert_eq!(artifact["safeDeleteCandidate"], true);
8074 });
8075 }
8076
8077 #[test]
8078 fn bash_tool_spills_truncated_full_output_to_artifact() {
8079 asupersync::test_utils::run_test(|| async {
8080 if !Path::new("/dev/zero").exists() {
8081 return;
8082 }
8083
8084 let tmp = tempfile::tempdir().expect("workspace");
8085 let artifact_root = tempfile::tempdir().expect("artifact root");
8086
8087 let bash_tool = BashTool::with_artifact_root(tmp.path(), artifact_root.path());
8088 let output = bash_tool
8089 .execute(
8090 "bash-artifact-call",
8091 serde_json::json!({
8092 "command": "head -c 1001000 /dev/zero | tr '\\0' x",
8093 "timeout": 10
8094 }),
8095 None,
8096 )
8097 .await
8098 .expect("bash large output");
8099
8100 assert!(first_text(&output).contains("Full tool output artifact:"));
8101 let artifact = artifact_json(output.details.as_ref());
8102 assert_eq!(artifact["toolName"], "bash");
8103 assert_eq!(artifact["sourceKind"], "fullCommandOutput");
8104 let path = PathBuf::from(artifact_str_field(artifact, "path"));
8105 assert_eq!(std::fs::metadata(path).unwrap().len(), 1_001_000);
8106 assert_eq!(artifact["redactionSummary"]["status"], "clean");
8107 assert_eq!(artifact["safeDeleteCandidate"], true);
8108 });
8109 }
8110
8111 #[test]
8112 fn bash_tool_redacts_secret_like_full_output_artifacts() {
8113 asupersync::test_utils::run_test(|| async {
8114 if !Path::new("/dev/zero").exists() {
8115 return;
8116 }
8117
8118 let tmp = tempfile::tempdir().expect("workspace");
8119 let artifact_root = tempfile::tempdir().expect("artifact root");
8120 let leaked_token = "sk-bashredactionfixture1234567890";
8121
8122 let bash_tool = BashTool::with_artifact_root(tmp.path(), artifact_root.path());
8123 let output = bash_tool
8124 .execute(
8125 "bash-secret-artifact-call",
8126 serde_json::json!({
8127 "command": format!("printf 'API_TOKEN={leaked_token}\\n'; head -c 1001000 /dev/zero | tr '\\0' x"),
8128 "timeout": 10
8129 }),
8130 None,
8131 )
8132 .await
8133 .expect("bash large output");
8134
8135 assert!(first_text(&output).contains("Full tool output artifact:"));
8136 let artifact = artifact_json(output.details.as_ref());
8137 assert_eq!(artifact["toolName"], "bash");
8138 assert_eq!(artifact["redactionSummary"]["status"], "redacted");
8139 assert_eq!(artifact["redactionSummary"]["rawSecretBytesEmitted"], 0);
8140 let path = PathBuf::from(artifact_str_field(artifact, "path"));
8141 let persisted = std::fs::read_to_string(path).expect("read redacted bash artifact");
8142 assert!(!persisted.contains(leaked_token));
8143 assert!(persisted.contains("API_TOKEN=[REDACTED]"));
8144 });
8145 }
8146
8147 #[test]
8148 fn grep_tool_spills_large_search_results_with_lifecycle_manifest() {
8149 asupersync::test_utils::run_test(|| async {
8150 if !rg_available() {
8151 return;
8152 }
8153
8154 let tmp = tempfile::tempdir().expect("workspace");
8155 let artifact_root = tempfile::tempdir().expect("artifact root");
8156 let mut body = String::new();
8157 let suffix = "g".repeat(560);
8158 for idx in 0..2200 {
8159 let _ = writeln!(body, "target {idx:04} {suffix}");
8160 }
8161 std::fs::write(tmp.path().join("large-grep.txt"), body).expect("write grep fixture");
8162
8163 let grep_tool = GrepTool::with_artifact_root(tmp.path(), artifact_root.path());
8164 let output = grep_tool
8165 .execute(
8166 "grep-artifact-call",
8167 serde_json::json!({
8168 "pattern": "target",
8169 "path": "large-grep.txt",
8170 "literal": true,
8171 "limit": 2200
8172 }),
8173 None,
8174 )
8175 .await
8176 .expect("grep large output");
8177
8178 assert!(first_text(&output).contains("Full tool output artifact:"));
8179 let artifact = artifact_json(output.details.as_ref());
8180 assert_eq!(artifact["toolName"], "grep");
8181 assert_eq!(artifact["sourceKind"], "searchResults");
8182 assert_eq!(
8183 artifact["retentionClass"],
8184 TOOL_OUTPUT_ARTIFACT_RETENTION_CLASS
8185 );
8186 assert_eq!(artifact["safeDeleteCandidate"], true);
8187 assert_eq!(artifact["redactionSummary"]["status"], "clean");
8188 let path = PathBuf::from(artifact_str_field(artifact, "path"));
8189 let persisted = std::fs::read_to_string(path).expect("read grep artifact");
8190 assert!(persisted.contains("large-grep.txt:1: target 0000"));
8191 assert!(
8192 artifact["byteCount"].as_u64().unwrap()
8193 > u64::try_from(TOOL_OUTPUT_ARTIFACT_THRESHOLD_BYTES).unwrap()
8194 );
8195 });
8196 }
8197
8198 #[test]
8199 fn read_tool_denied_path_does_not_emit_lifecycle_artifact() {
8200 asupersync::test_utils::run_test(|| async {
8201 let cwd = tempfile::tempdir().expect("workspace");
8202 let outside = tempfile::tempdir().expect("outside");
8203 let artifact_root = tempfile::tempdir().expect("artifact root");
8204 let outside_path = outside.path().join("secret.txt");
8205 std::fs::write(&outside_path, "API_TOKEN=sk-deniedpathfixture1234567890")
8206 .expect("outside secret");
8207
8208 let read_tool = ReadTool::with_artifact_root(cwd.path(), artifact_root.path());
8209 let err = read_tool
8210 .execute(
8211 "read-denied-artifact-call",
8212 serde_json::json!({ "path": outside_path }),
8213 None,
8214 )
8215 .await
8216 .expect_err("outside read should be denied");
8217
8218 assert!(
8219 err.to_string()
8220 .contains("Cannot read outside the working directory or agent dir")
8221 );
8222 let mut entries = std::fs::read_dir(artifact_root.path()).expect("artifact root");
8223 assert!(
8224 entries.next().is_none(),
8225 "denied reads must not write artifacts"
8226 );
8227 });
8228 }
8229
8230 #[test]
8231 fn ls_tool_spills_oversized_directory_listing_to_artifact() {
8232 asupersync::test_utils::run_test(|| async {
8233 let tmp = tempfile::tempdir().expect("workspace");
8234 let artifact_root = tempfile::tempdir().expect("artifact root");
8235 let suffix = "x".repeat(224);
8236 for i in 0..4_500 {
8237 let name = format!("entry-{i:04}-{suffix}.txt");
8238 std::fs::write(tmp.path().join(name), "").expect("write listing fixture");
8239 }
8240
8241 let ls_tool = LsTool::with_artifact_root(tmp.path(), artifact_root.path());
8242 let output = ls_tool
8243 .execute(
8244 "ls-artifact-call",
8245 serde_json::json!({ "path": ".", "limit": 4500 }),
8246 None,
8247 )
8248 .await
8249 .expect("ls large directory");
8250
8251 assert!(first_text(&output).contains("Full tool output artifact:"));
8252 let artifact = artifact_json(output.details.as_ref());
8253 assert_eq!(artifact["toolName"], "ls");
8254 assert_eq!(artifact["sourceKind"], "directoryEntries");
8255 assert!(
8256 artifact["byteCount"].as_u64().unwrap()
8257 > u64::try_from(TOOL_OUTPUT_ARTIFACT_THRESHOLD_BYTES).unwrap()
8258 );
8259 let path = PathBuf::from(artifact_str_field(artifact, "path"));
8260 assert!(
8261 std::fs::read_to_string(path)
8262 .unwrap()
8263 .contains("entry-0000-")
8264 );
8265 });
8266 }
8267
8268 async fn assert_read_cache_hit_and_stale(tmp: &Path) {
8269 let note = tmp.join("note.txt");
8270 std::fs::write(¬e, "alpha\n").expect("write note");
8271
8272 let read_tool = ReadTool::new(tmp);
8273 let read_input = serde_json::json!({ "path": "note.txt" });
8274 let first = read_tool
8275 .execute("read-1", read_input.clone(), None)
8276 .await
8277 .expect("first read");
8278 assert!(first_text(&first).contains("alpha"));
8279
8280 let hits_before = tool_output_cache_stats_for_tests().hits;
8281 let second = read_tool
8282 .execute("read-2", read_input.clone(), None)
8283 .await
8284 .expect("cached read");
8285 assert_eq!(first_text(&first), first_text(&second));
8286 assert!(tool_output_cache_stats_for_tests().hits > hits_before);
8287
8288 let invalidations_before = tool_output_cache_stats_for_tests().invalidations;
8289 std::fs::write(¬e, "beta\n").expect("rewrite note");
8290 let third = read_tool
8291 .execute("read-3", read_input.clone(), None)
8292 .await
8293 .expect("invalidated read");
8294 assert!(first_text(&third).contains("beta"));
8295 assert!(!first_text(&third).contains("alpha"));
8296 assert!(tool_output_cache_stats_for_tests().invalidations > invalidations_before);
8297 }
8298
8299 async fn assert_ls_cache_hit_and_stale(tmp: &Path) {
8300 let ls_tool = LsTool::new(tmp);
8301 let ls_input = serde_json::json!({ "path": "." });
8302 let ls_first = ls_tool
8303 .execute("ls-1", ls_input.clone(), None)
8304 .await
8305 .expect("first ls");
8306 assert!(first_text(&ls_first).contains("note.txt"));
8307
8308 let hits_before = tool_output_cache_stats_for_tests().hits;
8309 let ls_second = ls_tool
8310 .execute("ls-2", ls_input.clone(), None)
8311 .await
8312 .expect("cached ls");
8313 assert_eq!(first_text(&ls_first), first_text(&ls_second));
8314 assert!(tool_output_cache_stats_for_tests().hits > hits_before);
8315
8316 let invalidations_before = tool_output_cache_stats_for_tests().invalidations;
8317 std::fs::write(tmp.join("new.txt"), "new\n").expect("write new file");
8318 let ls_third = ls_tool
8319 .execute("ls-3", ls_input.clone(), None)
8320 .await
8321 .expect("invalidated ls");
8322 assert!(first_text(&ls_third).contains("new.txt"));
8323 assert!(tool_output_cache_stats_for_tests().invalidations > invalidations_before);
8324 }
8325
8326 async fn assert_grep_cache_hit_and_stale_when_available(tmp: &Path) {
8327 if find_rg_binary().is_none() {
8328 return;
8329 }
8330
8331 let grep_tool = GrepTool::new(tmp);
8332 let grep_input = serde_json::json!({ "pattern": "needle", "path": "." });
8333 std::fs::write(tmp.join("a.txt"), "needle\n").expect("write grep file");
8334
8335 let grep_first = grep_tool
8336 .execute("grep-1", grep_input.clone(), None)
8337 .await
8338 .expect("first grep");
8339 assert!(first_text(&grep_first).contains("a.txt"));
8340
8341 let hits_before = tool_output_cache_stats_for_tests().hits;
8342 let grep_second = grep_tool
8343 .execute("grep-2", grep_input.clone(), None)
8344 .await
8345 .expect("cached grep");
8346 assert_eq!(first_text(&grep_first), first_text(&grep_second));
8347 assert!(tool_output_cache_stats_for_tests().hits > hits_before);
8348
8349 let invalidations_before = tool_output_cache_stats_for_tests().invalidations;
8350 std::fs::write(tmp.join("b.txt"), "needle\n").expect("write new match");
8351 let grep_third = grep_tool
8352 .execute("grep-3", grep_input.clone(), None)
8353 .await
8354 .expect("invalidated grep");
8355 assert!(first_text(&grep_third).contains("b.txt"));
8356 assert!(tool_output_cache_stats_for_tests().invalidations > invalidations_before);
8357 }
8358
8359 async fn assert_find_cache_hit_and_stale_when_available(tmp: &Path) {
8360 if find_fd_binary().is_none() {
8361 return;
8362 }
8363
8364 let find_tool = FindTool::new(tmp);
8365 let find_input = serde_json::json!({ "pattern": "*find*.txt", "path": "." });
8366 std::fs::write(tmp.join("find-a.txt"), "find\n").expect("write first find file");
8367
8368 let find_first = find_tool
8369 .execute("find-1", find_input.clone(), None)
8370 .await
8371 .expect("first find");
8372 assert!(first_text(&find_first).contains("find-a.txt"));
8373
8374 let hits_before = tool_output_cache_stats_for_tests().hits;
8375 let find_second = find_tool
8376 .execute("find-2", find_input.clone(), None)
8377 .await
8378 .expect("cached find");
8379 assert_eq!(first_text(&find_first), first_text(&find_second));
8380 assert!(tool_output_cache_stats_for_tests().hits > hits_before);
8381
8382 let invalidations_before = tool_output_cache_stats_for_tests().invalidations;
8383 std::fs::write(tmp.join("find-b.txt"), "find\n").expect("write second find file");
8384 let find_third = find_tool
8385 .execute("find-3", find_input.clone(), None)
8386 .await
8387 .expect("invalidated find");
8388 assert!(first_text(&find_third).contains("find-b.txt"));
8389 assert!(tool_output_cache_stats_for_tests().invalidations > invalidations_before);
8390 }
8391
8392 async fn assert_side_effect_tools_remain_uncached(tmp: &Path) {
8393 let side_effect_stats_before = tool_output_cache_stats_for_tests();
8394 let write_tool = WriteTool::new(tmp);
8395 write_tool
8396 .execute(
8397 "write-1",
8398 serde_json::json!({
8399 "path": "side-effect.txt",
8400 "content": "one\n"
8401 }),
8402 None,
8403 )
8404 .await
8405 .expect("write side-effect file");
8406
8407 let edit_tool = EditTool::new(tmp);
8408 edit_tool
8409 .execute(
8410 "edit-1",
8411 serde_json::json!({
8412 "path": "side-effect.txt",
8413 "oldText": "one",
8414 "newText": "two"
8415 }),
8416 None,
8417 )
8418 .await
8419 .expect("edit side-effect file");
8420
8421 let bash_tool = BashTool::new(tmp);
8422 bash_tool
8423 .execute(
8424 "bash-1",
8425 serde_json::json!({
8426 "command": "printf 'cache-uncached\\n'",
8427 "timeout": 5
8428 }),
8429 None,
8430 )
8431 .await
8432 .expect("run uncached bash");
8433
8434 let side_effect_stats_after = tool_output_cache_stats_for_tests();
8435 assert_eq!(
8436 (
8437 side_effect_stats_after.side_effect_accesses,
8438 side_effect_stats_after.side_effect_insert_attempts
8439 ),
8440 (
8441 side_effect_stats_before.side_effect_accesses,
8442 side_effect_stats_before.side_effect_insert_attempts
8443 ),
8444 "write, edit, and bash must not consult or populate the read-only output cache"
8445 );
8446 }
8447
8448 #[test]
8449 fn tool_output_cache_reuses_and_invalidates_read_only_tool_outputs() {
8450 asupersync::test_utils::run_test(|| async {
8451 reset_tool_output_cache_for_tests();
8452
8453 let tmp = tempfile::tempdir().expect("create temp dir");
8454 assert_read_cache_hit_and_stale(tmp.path()).await;
8455 assert_ls_cache_hit_and_stale(tmp.path()).await;
8456 assert_grep_cache_hit_and_stale_when_available(tmp.path()).await;
8457 assert_find_cache_hit_and_stale_when_available(tmp.path()).await;
8458 assert_side_effect_tools_remain_uncached(tmp.path()).await;
8459 });
8460 }
8461
8462 #[test]
8463 fn tool_output_context_cache_evidence_jsonl_covers_required_decisions()
8464 -> std::result::Result<(), String> {
8465 let evidence = include_str!("../docs/evidence/tool-output-context-cache.jsonl");
8466 let mut saw_read_hit = false;
8467 let mut saw_grep_stale = false;
8468 let mut saw_find_stale = false;
8469 let mut saw_ls_stale = false;
8470 let mut saw_write_uncached = false;
8471 let mut saw_edit_uncached = false;
8472 let mut saw_bash_uncached = false;
8473
8474 for (line_number, line) in evidence.lines().enumerate() {
8475 if line.trim().is_empty() {
8476 continue;
8477 }
8478
8479 let event: serde_json::Value = serde_json::from_str(line).map_err(|err| {
8480 format!(
8481 "invalid context-cache JSONL at line {}: {err}",
8482 line_number + 1
8483 )
8484 })?;
8485 assert_eq!(
8486 event.get("schema").and_then(serde_json::Value::as_str),
8487 Some("pi.tool_output_context_cache.evidence.v1")
8488 );
8489 assert_eq!(
8490 event.get("bead").and_then(serde_json::Value::as_str),
8491 Some("bd-dklqn.1")
8492 );
8493 let related_beads = event
8494 .get("related_beads")
8495 .and_then(serde_json::Value::as_array)
8496 .ok_or_else(|| format!("missing related_beads at line {}", line_number + 1))?;
8497 assert!(
8498 related_beads
8499 .iter()
8500 .any(|bead| bead.as_str() == Some("bd-dklqn.2")),
8501 "evidence line {} must cover bd-dklqn.2",
8502 line_number + 1
8503 );
8504
8505 let tool = event
8506 .get("tool")
8507 .and_then(serde_json::Value::as_str)
8508 .expect("tool");
8509 let outcome = event
8510 .get("outcome")
8511 .and_then(serde_json::Value::as_str)
8512 .expect("outcome");
8513 let reason = event
8514 .get("reason")
8515 .and_then(serde_json::Value::as_str)
8516 .expect("reason");
8517
8518 match (tool, outcome, reason) {
8519 ("read", "hit", "unchanged_file_fingerprint") => saw_read_hit = true,
8520 ("grep", "stale", "recursive_directory_fingerprint_changed") => {
8521 saw_grep_stale = true;
8522 }
8523 ("find", "stale", "recursive_directory_fingerprint_changed") => {
8524 saw_find_stale = true;
8525 }
8526 ("ls", "stale", "directory_entry_fingerprint_changed") => saw_ls_stale = true,
8527 ("write", "uncached", "write_effect_tool") => saw_write_uncached = true,
8528 ("edit", "uncached", "write_effect_tool") => saw_edit_uncached = true,
8529 ("bash", "uncached", "process_effect_tool") => saw_bash_uncached = true,
8530 _ => {}
8531 }
8532 }
8533
8534 assert!(saw_read_hit, "evidence must include a read cache hit");
8535 assert!(saw_grep_stale, "evidence must include grep stale bypass");
8536 assert!(saw_find_stale, "evidence must include find stale bypass");
8537 assert!(saw_ls_stale, "evidence must include ls stale bypass");
8538 assert!(saw_write_uncached, "evidence must include write uncached");
8539 assert!(saw_edit_uncached, "evidence must include edit uncached");
8540 assert!(saw_bash_uncached, "evidence must include bash uncached");
8541 Ok(())
8542 }
8543
8544 #[test]
8545 fn test_truncate_tail_zero_lines_returns_empty_output() {
8546 let result = truncate_tail("line1\nline2".to_string(), 0, 1000);
8547
8548 assert!(result.truncated);
8549 assert_eq!(result.truncated_by, Some(TruncatedBy::Lines));
8550 assert_eq!(result.output_lines, 0);
8551 assert_eq!(result.output_bytes, 0);
8552 assert!(result.content.is_empty());
8553 }
8554
8555 #[test]
8556 fn test_line_count_from_newline_count_matches_trailing_newline_semantics() {
8557 assert_eq!(line_count_from_newline_count(0, 0, false), 0);
8558 assert_eq!(line_count_from_newline_count(2, 1, true), 1);
8559 assert_eq!(line_count_from_newline_count(1, 0, false), 1);
8560 assert_eq!(line_count_from_newline_count(3, 1, false), 2);
8561 }
8562
8563 #[test]
8564 fn test_rg_match_requires_path_and_line_number() {
8565 let mut matches = Vec::new();
8566 let mut match_count = 0usize;
8567 let mut match_limit_reached = false;
8568 let scan_limit = 1;
8569
8570 let missing_line =
8571 Ok(r#"{"type":"match","data":{"path":{"text":"file.txt"}}}"#.to_string());
8572 process_rg_json_match_line(
8573 missing_line,
8574 &mut matches,
8575 &mut match_count,
8576 &mut match_limit_reached,
8577 scan_limit,
8578 );
8579 assert!(matches.is_empty());
8580 assert_eq!(match_count, 0);
8581 assert!(!match_limit_reached);
8582
8583 let valid_line = Ok(
8584 r#"{"type":"match","data":{"path":{"text":"file.txt"},"line_number":3}}"#.to_string(),
8585 );
8586 process_rg_json_match_line(
8587 valid_line,
8588 &mut matches,
8589 &mut match_count,
8590 &mut match_limit_reached,
8591 scan_limit,
8592 );
8593 assert_eq!(matches.len(), 1);
8594 assert_eq!(matches[0].1, 3);
8595 assert_eq!(match_count, 1);
8596 assert!(match_limit_reached);
8597 }
8598
8599 #[test]
8600 fn test_truncate_by_bytes() {
8601 let content = "short\nthis is a longer line\nanother".to_string();
8602 let result = truncate_head(content, 100, 15);
8603
8604 assert!(result.truncated);
8605 assert_eq!(result.truncated_by, Some(TruncatedBy::Bytes));
8606 }
8607
8608 #[cfg(any(target_os = "linux", target_os = "freebsd"))]
8609 #[test]
8610 fn test_command_with_default_sigpipe_restores_pipe_disposition() {
8611 #[cfg(target_os = "freebsd")]
8618 let status_dir = {
8619 let probe = format!("/compat/linux/proc/{}/status", std::process::id());
8620 if !std::path::Path::new(&probe).exists() {
8621 eprintln!(
8622 "skipping sigpipe disposition test: linprocfs not mounted \
8623 at /compat/linux/proc — add `linprocfs /compat/linux/proc \
8624 linprocfs rw 0 0` to /etc/fstab and `mount /compat/linux/proc` \
8625 to enable"
8626 );
8627 return;
8628 }
8629 "/compat/linux/proc"
8630 };
8631 #[cfg(not(target_os = "freebsd"))]
8632 let status_dir = "/proc";
8633
8634 let probe_cmd = format!(
8635 "while read name value _; do [ \"$name\" = SigIgn: ] && \
8636 {{ printf '%s' \"$value\"; exit 0; }}; done < {status_dir}/$$/status"
8637 );
8638
8639 let output = command_with_default_sigpipe("sh")
8640 .expect("prepare sigpipe disposition probe")
8641 .args(["-c", &probe_cmd])
8642 .stdout(std::process::Stdio::piped())
8643 .output()
8644 .expect("spawn sigpipe disposition probe");
8645
8646 assert!(output.status.success(), "probe failed: {output:?}");
8647 let sigign = String::from_utf8(output.stdout).expect("SigIgn should be utf8");
8648 let ignored_mask =
8649 u64::from_str_radix(sigign.trim(), 16).expect("SigIgn should be a hex mask");
8650 let sigpipe_bit = 1_u64 << (13 - 1);
8651 assert_eq!(
8652 ignored_mask & sigpipe_bit,
8653 0,
8654 "child should not inherit ignored SIGPIPE: SigIgn={sigign}"
8655 );
8656 }
8657
8658 #[cfg(unix)]
8659 #[test]
8660 fn test_command_with_default_sigpipe_in_dir_resolves_relative_program_after_cwd() {
8661 use std::os::unix::fs::PermissionsExt as _;
8662
8663 let tmp = tempfile::tempdir().expect("create temp dir");
8664 let script = tmp.path().join("relative-probe");
8665 std::fs::write(&script, "#!/bin/sh\nprintf cwd-relative-ok\n").expect("write script");
8666 let mut permissions = std::fs::metadata(&script)
8667 .expect("stat script")
8668 .permissions();
8669 permissions.set_mode(0o755);
8670 std::fs::set_permissions(&script, permissions).expect("make script executable");
8671
8672 let output = command_with_default_sigpipe_in_dir("./relative-probe", tmp.path())
8673 .expect("prepare relative executable")
8674 .current_dir(tmp.path())
8675 .stdout(std::process::Stdio::piped())
8676 .output()
8677 .expect("spawn relative executable");
8678
8679 assert!(output.status.success(), "probe failed: {output:?}");
8680 assert_eq!(
8681 String::from_utf8(output.stdout).expect("probe stdout should be utf8"),
8682 "cwd-relative-ok"
8683 );
8684 }
8685
8686 #[cfg(target_os = "linux")]
8687 #[test]
8688 fn test_read_to_end_capped_and_drain_preserves_writer_exit_status() {
8689 let mut child = std::process::Command::new("dd")
8690 .args(["if=/dev/zero", "bs=1", "count=70000", "status=none"])
8691 .stdout(std::process::Stdio::piped())
8692 .spawn()
8693 .expect("spawn dd");
8694
8695 let stdout = child.stdout.take().expect("dd stdout");
8696 let captured = read_to_end_capped_and_drain(stdout, 1024).expect("capture bounded stdout");
8697 let status = child.wait().expect("wait for dd");
8698
8699 assert!(
8700 status.success(),
8701 "bounded reader should drain to EOF instead of SIGPIPEing the writer: {status:?}"
8702 );
8703 assert_eq!(captured.len(), 1025);
8704 }
8705
8706 #[cfg(unix)]
8707 #[test]
8708 fn test_get_file_lines_async_unreadable_file_returns_empty() {
8709 asupersync::test_utils::run_test(|| async {
8710 use std::os::unix::fs::PermissionsExt;
8711
8712 let tmp = tempfile::tempdir().unwrap();
8713 let path = tmp.path().join("secret.txt");
8714 std::fs::write(&path, "secret\n").unwrap();
8715
8716 let mut perms = std::fs::metadata(&path).unwrap().permissions();
8717 perms.set_mode(0o000);
8718 std::fs::set_permissions(&path, perms).unwrap();
8719
8720 let mut cache = HashMap::new();
8721 let lines = get_file_lines_async(&path, &mut cache).await;
8722 assert!(lines.is_empty());
8723 });
8724 }
8725
8726 #[test]
8727 fn test_resolve_path_absolute() {
8728 let cwd = PathBuf::from("/home/user/project");
8729 let result = resolve_path("/absolute/path", &cwd);
8730 assert_eq!(result, PathBuf::from("/absolute/path"));
8731 }
8732
8733 #[test]
8734 fn test_resolve_path_relative() {
8735 let cwd = PathBuf::from("/home/user/project");
8736 let result = resolve_path("src/main.rs", &cwd);
8737 assert_eq!(result, PathBuf::from("/home/user/project/src/main.rs"));
8738 }
8739
8740 #[test]
8741 fn test_normalize_dot_segments_preserves_root() {
8742 let result = normalize_dot_segments(std::path::Path::new("/../etc/passwd"));
8743 assert_eq!(result, PathBuf::from("/etc/passwd"));
8744 }
8745
8746 #[test]
8747 fn test_normalize_dot_segments_preserves_leading_parent_for_relative() {
8748 let result = normalize_dot_segments(std::path::Path::new("../a/../b"));
8749 assert_eq!(result, PathBuf::from("../b"));
8750 }
8751
8752 #[test]
8753 fn test_detect_supported_image_mime_type_from_bytes() {
8754 assert_eq!(
8755 detect_supported_image_mime_type_from_bytes(b"\x89PNG\r\n\x1A\n"),
8756 Some("image/png")
8757 );
8758 assert_eq!(
8759 detect_supported_image_mime_type_from_bytes(b"\xFF\xD8\xFF"),
8760 Some("image/jpeg")
8761 );
8762 assert_eq!(
8763 detect_supported_image_mime_type_from_bytes(b"GIF89a"),
8764 Some("image/gif")
8765 );
8766 assert_eq!(
8767 detect_supported_image_mime_type_from_bytes(b"RIFF1234WEBP"),
8768 Some("image/webp")
8769 );
8770 assert_eq!(
8771 detect_supported_image_mime_type_from_bytes(b"not an image"),
8772 None
8773 );
8774 }
8775
8776 #[test]
8777 fn test_format_size() {
8778 assert_eq!(format_size(500), "500B");
8779 assert_eq!(format_size(1024), "1.0KB");
8780 assert_eq!(format_size(1536), "1.5KB");
8781 assert_eq!(format_size(1_048_576), "1.0MB");
8782 assert_eq!(format_size(1_073_741_824), "1024.0MB");
8783 }
8784
8785 #[test]
8786 fn test_js_string_length() {
8787 assert_eq!(js_string_length("hello"), 5);
8788 assert_eq!(js_string_length("😀"), 2);
8789 }
8790
8791 #[test]
8792 fn test_truncate_line() {
8793 let short = "short line";
8794 let result = truncate_line(short, 100);
8795 assert_eq!(result.text, "short line");
8796 assert!(!result.was_truncated);
8797
8798 let long = "a".repeat(600);
8799 let result = truncate_line(&long, 500);
8800 assert!(result.was_truncated);
8801 assert!(result.text.ends_with("... [truncated]"));
8802 }
8803
8804 fn get_text(content: &[ContentBlock]) -> String {
8809 content
8810 .iter()
8811 .filter_map(|block| {
8812 if let ContentBlock::Text(text) = block {
8813 Some(text.text.clone())
8814 } else {
8815 None
8816 }
8817 })
8818 .collect::<String>()
8819 }
8820
8821 #[test]
8826 fn test_read_valid_file() {
8827 asupersync::test_utils::run_test(|| async {
8828 let tmp = tempfile::tempdir().unwrap();
8829 std::fs::write(tmp.path().join("hello.txt"), "alpha\nbeta\ngamma").unwrap();
8830
8831 let tool = ReadTool::new(tmp.path());
8832 let out = tool
8833 .execute(
8834 "t",
8835 serde_json::json!({ "path": tmp.path().join("hello.txt").to_string_lossy() }),
8836 None,
8837 )
8838 .await
8839 .unwrap();
8840 let text = get_text(&out.content);
8841 assert!(text.contains("alpha"));
8842 assert!(text.contains("beta"));
8843 assert!(text.contains("gamma"));
8844 assert!(!out.is_error);
8845 });
8846 }
8847
8848 #[test]
8849 fn test_read_nonexistent_file() {
8850 asupersync::test_utils::run_test(|| async {
8851 let tmp = tempfile::tempdir().unwrap();
8852 let tool = ReadTool::new(tmp.path());
8853 let err = tool
8854 .execute(
8855 "t",
8856 serde_json::json!({ "path": tmp.path().join("nope.txt").to_string_lossy() }),
8857 None,
8858 )
8859 .await;
8860 assert!(err.is_err());
8861 });
8862 }
8863
8864 #[test]
8865 fn test_read_rejects_outside_cwd() {
8866 asupersync::test_utils::run_test(|| async {
8867 let cwd = tempfile::tempdir().unwrap();
8868 let outside = tempfile::tempdir().unwrap();
8869 std::fs::write(outside.path().join("secret.txt"), "secret").unwrap();
8870
8871 let tool = ReadTool::new(cwd.path());
8872 let err = tool
8873 .execute(
8874 "t",
8875 serde_json::json!({ "path": outside.path().join("secret.txt").to_string_lossy() }),
8876 None,
8877 )
8878 .await
8879 .unwrap_err();
8880 assert!(err.to_string().contains("outside the working directory"));
8881 });
8882 }
8883
8884 #[test]
8891 fn test_enforce_read_scope_allows_agent_dir_outside_cwd() {
8892 let cwd = tempfile::tempdir().unwrap();
8893 let agent_dir = tempfile::tempdir().unwrap();
8894 let skill_dir = agent_dir.path().join("skills").join("freebsd-jails");
8895 std::fs::create_dir_all(&skill_dir).unwrap();
8896 let skill_path = skill_dir.join("SKILL.md");
8897 std::fs::write(&skill_path, "---\nname: test\n---\n# body\n").unwrap();
8898
8899 let resolved =
8900 enforce_read_scope_with_roots(&skill_path, cwd.path(), agent_dir.path()).unwrap();
8901 assert!(
8902 resolved.starts_with(
8903 agent_dir
8904 .path()
8905 .canonicalize()
8906 .unwrap_or_else(|_| agent_dir.path().to_path_buf())
8907 ),
8908 "agent-dir path must be allowed and returned canonicalised"
8909 );
8910 }
8911
8912 #[test]
8913 fn test_enforce_read_scope_still_rejects_unrelated_paths() {
8914 let cwd = tempfile::tempdir().unwrap();
8916 let agent_dir = tempfile::tempdir().unwrap();
8917 let unrelated = tempfile::tempdir().unwrap();
8918 std::fs::write(unrelated.path().join("secret.txt"), "secret").unwrap();
8919 let secret_path = unrelated.path().join("secret.txt");
8920
8921 let err =
8922 enforce_read_scope_with_roots(&secret_path, cwd.path(), agent_dir.path()).unwrap_err();
8923 let msg = err.to_string();
8924 assert!(
8925 msg.contains("outside the working directory") && msg.contains("agent dir"),
8926 "error must mention both denied roots, got: {msg}"
8927 );
8928 }
8929
8930 #[test]
8931 fn test_enforce_read_scope_prefers_cwd_when_path_is_under_cwd() {
8932 let cwd = tempfile::tempdir().unwrap();
8935 let agent_dir = tempfile::tempdir().unwrap();
8936 std::fs::write(cwd.path().join("a.txt"), "in cwd").unwrap();
8937
8938 let resolved =
8939 enforce_read_scope_with_roots(&cwd.path().join("a.txt"), cwd.path(), agent_dir.path())
8940 .unwrap();
8941 assert!(
8942 resolved.starts_with(
8943 cwd.path()
8944 .canonicalize()
8945 .unwrap_or_else(|_| cwd.path().to_path_buf())
8946 )
8947 );
8948 }
8949
8950 #[test]
8951 fn test_read_empty_file() {
8952 asupersync::test_utils::run_test(|| async {
8953 let tmp = tempfile::tempdir().unwrap();
8954 std::fs::write(tmp.path().join("empty.txt"), "").unwrap();
8955
8956 let tool = ReadTool::new(tmp.path());
8957 let out = tool
8958 .execute(
8959 "t",
8960 serde_json::json!({ "path": tmp.path().join("empty.txt").to_string_lossy() }),
8961 None,
8962 )
8963 .await
8964 .unwrap();
8965 let text = get_text(&out.content);
8966 assert_eq!(text, "");
8967 assert!(!out.is_error);
8968 });
8969 }
8970
8971 #[test]
8972 fn test_read_empty_file_positive_offset_errors() {
8973 asupersync::test_utils::run_test(|| async {
8974 let tmp = tempfile::tempdir().unwrap();
8975 std::fs::write(tmp.path().join("empty.txt"), "").unwrap();
8976
8977 let tool = ReadTool::new(tmp.path());
8978 let err = tool
8979 .execute(
8980 "t",
8981 serde_json::json!({
8982 "path": tmp.path().join("empty.txt").to_string_lossy(),
8983 "offset": 1
8984 }),
8985 None,
8986 )
8987 .await;
8988 assert!(err.is_err());
8989 let msg = err.unwrap_err().to_string();
8990 assert!(msg.contains("beyond end of file"));
8991 });
8992 }
8993
8994 #[test]
8995 fn test_read_rejects_zero_limit() {
8996 asupersync::test_utils::run_test(|| async {
8997 let tmp = tempfile::tempdir().unwrap();
8998 std::fs::write(tmp.path().join("lines.txt"), "a\nb\nc\n").unwrap();
8999
9000 let tool = ReadTool::new(tmp.path());
9001 let err = tool
9002 .execute(
9003 "t",
9004 serde_json::json!({
9005 "path": tmp.path().join("lines.txt").to_string_lossy(),
9006 "limit": 0
9007 }),
9008 None,
9009 )
9010 .await;
9011 assert!(err.is_err());
9012 assert!(
9013 err.unwrap_err()
9014 .to_string()
9015 .contains("`limit` must be greater than 0")
9016 );
9017 });
9018 }
9019
9020 #[test]
9021 fn test_read_offset_and_limit() {
9022 asupersync::test_utils::run_test(|| async {
9023 let tmp = tempfile::tempdir().unwrap();
9024 std::fs::write(
9025 tmp.path().join("lines.txt"),
9026 "L1\nL2\nL3\nL4\nL5\nL6\nL7\nL8\nL9\nL10",
9027 )
9028 .unwrap();
9029
9030 let tool = ReadTool::new(tmp.path());
9031 let out = tool
9032 .execute(
9033 "t",
9034 serde_json::json!({
9035 "path": tmp.path().join("lines.txt").to_string_lossy(),
9036 "offset": 3,
9037 "limit": 2
9038 }),
9039 None,
9040 )
9041 .await
9042 .unwrap();
9043 let text = get_text(&out.content);
9044 assert!(text.contains("L3"));
9045 assert!(text.contains("L4"));
9046 assert!(!text.contains("L2"));
9047 assert!(!text.contains("L5"));
9048 });
9049 }
9050
9051 #[test]
9052 fn test_read_offset_and_limit_with_cr_only_line_endings() {
9053 asupersync::test_utils::run_test(|| async {
9054 let tmp = tempfile::tempdir().unwrap();
9055 std::fs::write(tmp.path().join("lines.txt"), b"L1\rL2\rL3\r").unwrap();
9056
9057 let tool = ReadTool::new(tmp.path());
9058 let out = tool
9059 .execute(
9060 "t",
9061 serde_json::json!({
9062 "path": tmp.path().join("lines.txt").to_string_lossy(),
9063 "offset": 2,
9064 "limit": 1
9065 }),
9066 None,
9067 )
9068 .await
9069 .unwrap();
9070 let text = get_text(&out.content);
9071 assert!(text.contains("L2"));
9072 assert!(!text.contains("L1"));
9073 assert!(!text.contains("L3"));
9074 assert!(text.contains("offset=3"));
9075 assert!(!text.contains('\r'));
9076 });
9077 }
9078
9079 #[test]
9080 fn test_read_offset_and_limit_with_split_crlf_chunk_boundary() {
9081 asupersync::test_utils::run_test(|| async {
9082 let tmp = tempfile::tempdir().unwrap();
9083 let mut content = vec![b'x'; (64 * 1024) - 1];
9084 content.extend_from_slice(b"\r\nSECOND\r\nTHIRD");
9085 std::fs::write(tmp.path().join("lines.txt"), content).unwrap();
9086
9087 let tool = ReadTool::new(tmp.path());
9088 let out = tool
9089 .execute(
9090 "t",
9091 serde_json::json!({
9092 "path": tmp.path().join("lines.txt").to_string_lossy(),
9093 "offset": 2,
9094 "limit": 1
9095 }),
9096 None,
9097 )
9098 .await
9099 .unwrap();
9100 let text = get_text(&out.content);
9101 assert!(text.contains("SECOND"));
9102 assert!(!text.contains("THIRD"));
9103 assert!(!text.contains("xxxx"));
9104 assert!(text.contains("offset=3"));
9105 });
9106 }
9107
9108 #[test]
9109 fn test_read_offset_beyond_eof() {
9110 asupersync::test_utils::run_test(|| async {
9111 let tmp = tempfile::tempdir().unwrap();
9112 std::fs::write(tmp.path().join("short.txt"), "a\nb").unwrap();
9113
9114 let tool = ReadTool::new(tmp.path());
9115 let err = tool
9116 .execute(
9117 "t",
9118 serde_json::json!({
9119 "path": tmp.path().join("short.txt").to_string_lossy(),
9120 "offset": 100
9121 }),
9122 None,
9123 )
9124 .await;
9125 assert!(err.is_err());
9126 let msg = err.unwrap_err().to_string();
9127 assert!(msg.contains("beyond end of file"));
9128 });
9129 }
9130
9131 #[test]
9132 fn test_map_normalized_with_trailing_whitespace() {
9133 let content = "A \nB";
9135 let normalized = build_normalized_content(content);
9136 assert_eq!(normalized, "A\nB");
9137
9138 let (start, len) = map_normalized_range_to_original(content, 0, 1);
9140 assert_eq!(start, 0);
9141 assert_eq!(len, 1);
9142 assert_eq!(&content[start..start + len], "A");
9143
9144 let (start, len) = map_normalized_range_to_original(content, 1, 1);
9146 assert_eq!(start, 4);
9147 assert_eq!(len, 1);
9148 assert_eq!(&content[start..start + len], "\n");
9149
9150 let (start, len) = map_normalized_range_to_original(content, 2, 1);
9152 assert_eq!(start, 5);
9153 assert_eq!(len, 1);
9154 assert_eq!(&content[start..start + len], "B");
9155 }
9156
9157 #[test]
9158 fn test_read_binary_file_lossy() {
9159 asupersync::test_utils::run_test(|| async {
9160 let tmp = tempfile::tempdir().unwrap();
9161 let binary_data: Vec<u8> = (0..=255).collect();
9162 std::fs::write(tmp.path().join("binary.bin"), &binary_data).unwrap();
9163
9164 let tool = ReadTool::new(tmp.path());
9165 let out = tool
9166 .execute(
9167 "t",
9168 serde_json::json!({ "path": tmp.path().join("binary.bin").to_string_lossy() }),
9169 None,
9170 )
9171 .await
9172 .unwrap();
9173 let text = get_text(&out.content);
9175 assert!(!text.is_empty());
9176 assert!(!out.is_error);
9177 });
9178 }
9179
9180 #[test]
9181 fn test_read_image_detection() {
9182 asupersync::test_utils::run_test(|| async {
9183 let tmp = tempfile::tempdir().unwrap();
9184 let png_header: Vec<u8> = vec![
9186 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53,
9190 0xDE, 0x00, 0x00, 0x00, 0x0C, 0x49, 0x44, 0x41, 0x54, 0x08, 0xD7, 0x63, 0xF8, 0xCF, 0xC0, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01, 0xE2, 0x21, 0xBC, 0x33, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82,
9196 ];
9197 std::fs::write(tmp.path().join("test.png"), &png_header).unwrap();
9198
9199 let tool = ReadTool::new(tmp.path());
9200 let out = tool
9201 .execute(
9202 "t",
9203 serde_json::json!({ "path": tmp.path().join("test.png").to_string_lossy() }),
9204 None,
9205 )
9206 .await
9207 .unwrap();
9208
9209 let has_image = out
9211 .content
9212 .iter()
9213 .any(|b| matches!(b, ContentBlock::Image(_)));
9214 assert!(has_image, "expected image content block for PNG file");
9215 });
9216 }
9217
9218 #[cfg(feature = "image-resize")]
9219 #[test]
9220 fn test_read_resizes_large_source_image_before_api_limit_check() {
9221 asupersync::test_utils::run_test(|| async {
9222 use image::codecs::png::PngEncoder;
9223 use image::{ExtendedColorType, ImageEncoder, Rgb, RgbImage};
9224
9225 let tmp = tempfile::tempdir().unwrap();
9226 let image = RgbImage::from_fn(2600, 2600, |x, y| {
9227 let seed = x.wrapping_mul(1_973)
9228 ^ y.wrapping_mul(9_277)
9229 ^ x.rotate_left(7)
9230 ^ y.rotate_left(13);
9231 Rgb([
9232 u8::try_from(seed % 256).unwrap_or(0),
9233 u8::try_from((seed >> 8) % 256).unwrap_or(0),
9234 u8::try_from((seed >> 16) % 256).unwrap_or(0),
9235 ])
9236 });
9237
9238 let mut png_bytes = Vec::new();
9239 PngEncoder::new(&mut png_bytes)
9240 .write_image(
9241 image.as_raw(),
9242 image.width(),
9243 image.height(),
9244 ExtendedColorType::Rgb8,
9245 )
9246 .unwrap();
9247
9248 assert!(
9249 png_bytes.len() > IMAGE_MAX_BYTES,
9250 "fixture must exceed API image limit to exercise resize path"
9251 );
9252 assert!(
9253 png_bytes.len() < usize::try_from(READ_TOOL_MAX_BYTES).unwrap_or(usize::MAX),
9254 "fixture must stay within read-tool input bound"
9255 );
9256
9257 let image_path = tmp.path().join("large.png");
9258 std::fs::write(&image_path, &png_bytes).unwrap();
9259
9260 let tool = ReadTool::new(tmp.path());
9261 let out = tool
9262 .execute(
9263 "t",
9264 serde_json::json!({ "path": image_path.to_string_lossy() }),
9265 None,
9266 )
9267 .await
9268 .unwrap();
9269
9270 assert!(!out.is_error, "resizable large images should succeed");
9271 assert!(
9272 out.content
9273 .iter()
9274 .any(|block| matches!(block, ContentBlock::Image(_))),
9275 "expected an image attachment after resizing"
9276 );
9277
9278 let text = get_text(&out.content);
9279 assert!(text.contains("Read image file"));
9280 assert!(
9281 text.contains("displayed at"),
9282 "expected resize note in read output, got: {text}"
9283 );
9284 });
9285 }
9286
9287 #[test]
9288 fn test_read_blocked_images() {
9289 asupersync::test_utils::run_test(|| async {
9290 let tmp = tempfile::tempdir().unwrap();
9291 let png_header: Vec<u8> =
9292 vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00];
9293 std::fs::write(tmp.path().join("test.png"), &png_header).unwrap();
9294
9295 let tool = ReadTool::with_settings(tmp.path(), false, true);
9296 let err = tool
9297 .execute(
9298 "t",
9299 serde_json::json!({ "path": tmp.path().join("test.png").to_string_lossy() }),
9300 None,
9301 )
9302 .await;
9303 assert!(err.is_err());
9304 assert!(err.unwrap_err().to_string().contains("blocked"));
9305 });
9306 }
9307
9308 #[test]
9309 fn test_read_truncation_at_max_lines() {
9310 asupersync::test_utils::run_test(|| async {
9311 let tmp = tempfile::tempdir().unwrap();
9312 let content: String = (0..DEFAULT_MAX_LINES + 500)
9313 .map(|i| format!("line {i}"))
9314 .collect::<Vec<_>>()
9315 .join("\n");
9316 std::fs::write(tmp.path().join("big.txt"), &content).unwrap();
9317
9318 let tool = ReadTool::new(tmp.path());
9319 let out = tool
9320 .execute(
9321 "t",
9322 serde_json::json!({ "path": tmp.path().join("big.txt").to_string_lossy() }),
9323 None,
9324 )
9325 .await
9326 .unwrap();
9327 assert!(out.details.is_some(), "expected truncation details");
9329 let text = get_text(&out.content);
9330 assert!(text.contains("offset="));
9331 });
9332 }
9333
9334 #[test]
9335 fn test_read_first_line_exceeds_max_bytes() {
9336 asupersync::test_utils::run_test(|| async {
9337 let tmp = tempfile::tempdir().unwrap();
9338 let long_line = "a".repeat(DEFAULT_MAX_BYTES + 128);
9339 std::fs::write(tmp.path().join("too_long.txt"), long_line).unwrap();
9340
9341 let tool = ReadTool::new(tmp.path());
9342 let out = tool
9343 .execute(
9344 "t",
9345 serde_json::json!({ "path": tmp.path().join("too_long.txt").to_string_lossy() }),
9346 None,
9347 )
9348 .await
9349 .unwrap();
9350
9351 let text = get_text(&out.content);
9352 let expected_limit = format!("exceeds {} limit", format_size(DEFAULT_MAX_BYTES));
9353 assert!(
9354 text.contains(&expected_limit),
9355 "expected limit hint '{expected_limit}', got: {text}"
9356 );
9357 let details = out.details.expect("expected truncation details");
9358 assert_eq!(
9359 details
9360 .get("truncation")
9361 .and_then(|v| v.get("firstLineExceedsLimit"))
9362 .and_then(serde_json::Value::as_bool),
9363 Some(true)
9364 );
9365 });
9366 }
9367
9368 #[test]
9369 fn test_read_unicode_content() {
9370 asupersync::test_utils::run_test(|| async {
9371 let tmp = tempfile::tempdir().unwrap();
9372 std::fs::write(tmp.path().join("uni.txt"), "Hello 你好 🌍\nLine 2 café").unwrap();
9373
9374 let tool = ReadTool::new(tmp.path());
9375 let out = tool
9376 .execute(
9377 "t",
9378 serde_json::json!({ "path": tmp.path().join("uni.txt").to_string_lossy() }),
9379 None,
9380 )
9381 .await
9382 .unwrap();
9383 let text = get_text(&out.content);
9384 assert!(text.contains("你好"));
9385 assert!(text.contains("🌍"));
9386 assert!(text.contains("café"));
9387 });
9388 }
9389
9390 #[test]
9395 fn test_write_new_file() {
9396 asupersync::test_utils::run_test(|| async {
9397 let tmp = tempfile::tempdir().unwrap();
9398 let tool = WriteTool::new(tmp.path());
9399 let out = tool
9400 .execute(
9401 "t",
9402 serde_json::json!({
9403 "path": tmp.path().join("new.txt").to_string_lossy(),
9404 "content": "hello world"
9405 }),
9406 None,
9407 )
9408 .await
9409 .unwrap();
9410 assert!(!out.is_error);
9411 let contents = std::fs::read_to_string(tmp.path().join("new.txt")).unwrap();
9412 assert_eq!(contents, "hello world");
9413 });
9414 }
9415
9416 #[test]
9417 fn test_write_overwrite_existing() {
9418 asupersync::test_utils::run_test(|| async {
9419 let tmp = tempfile::tempdir().unwrap();
9420 std::fs::write(tmp.path().join("exist.txt"), "old content").unwrap();
9421
9422 let tool = WriteTool::new(tmp.path());
9423 let out = tool
9424 .execute(
9425 "t",
9426 serde_json::json!({
9427 "path": tmp.path().join("exist.txt").to_string_lossy(),
9428 "content": "new content"
9429 }),
9430 None,
9431 )
9432 .await
9433 .unwrap();
9434 assert!(!out.is_error);
9435 let contents = std::fs::read_to_string(tmp.path().join("exist.txt")).unwrap();
9436 assert_eq!(contents, "new content");
9437 });
9438 }
9439
9440 #[test]
9441 fn test_write_creates_parent_dirs() {
9442 asupersync::test_utils::run_test(|| async {
9443 let tmp = tempfile::tempdir().unwrap();
9444 let tool = WriteTool::new(tmp.path());
9445 let deep_path = tmp.path().join("a/b/c/deep.txt");
9446 let out = tool
9447 .execute(
9448 "t",
9449 serde_json::json!({
9450 "path": deep_path.to_string_lossy(),
9451 "content": "deep file"
9452 }),
9453 None,
9454 )
9455 .await
9456 .unwrap();
9457 assert!(!out.is_error);
9458 assert!(deep_path.exists());
9459 assert_eq!(std::fs::read_to_string(&deep_path).unwrap(), "deep file");
9460 });
9461 }
9462
9463 #[test]
9464 fn test_write_empty_file() {
9465 asupersync::test_utils::run_test(|| async {
9466 let tmp = tempfile::tempdir().unwrap();
9467 let tool = WriteTool::new(tmp.path());
9468 let out = tool
9469 .execute(
9470 "t",
9471 serde_json::json!({
9472 "path": tmp.path().join("empty.txt").to_string_lossy(),
9473 "content": ""
9474 }),
9475 None,
9476 )
9477 .await
9478 .unwrap();
9479 assert!(!out.is_error);
9480 let contents = std::fs::read_to_string(tmp.path().join("empty.txt")).unwrap();
9481 assert_eq!(contents, "");
9482 let text = get_text(&out.content);
9483 assert!(text.contains("Successfully wrote 0 bytes"));
9484 });
9485 }
9486
9487 #[test]
9488 fn test_write_rejects_outside_cwd() {
9489 asupersync::test_utils::run_test(|| async {
9490 let cwd = tempfile::tempdir().unwrap();
9491 let outside = tempfile::tempdir().unwrap();
9492 let tool = WriteTool::new(cwd.path());
9493 let err = tool
9494 .execute(
9495 "t",
9496 serde_json::json!({
9497 "path": outside.path().join("escape.txt").to_string_lossy(),
9498 "content": "nope"
9499 }),
9500 None,
9501 )
9502 .await
9503 .unwrap_err();
9504 assert!(err.to_string().contains("outside the working directory"));
9505
9506 let err = tool
9507 .execute(
9508 "t",
9509 serde_json::json!({
9510 "path": "../escape.txt",
9511 "content": "nope"
9512 }),
9513 None,
9514 )
9515 .await
9516 .unwrap_err();
9517 assert!(err.to_string().contains("outside the working directory"));
9518 });
9519 }
9520
9521 #[test]
9522 fn test_write_unicode_content() {
9523 asupersync::test_utils::run_test(|| async {
9524 let tmp = tempfile::tempdir().unwrap();
9525 let tool = WriteTool::new(tmp.path());
9526 let out = tool
9527 .execute(
9528 "t",
9529 serde_json::json!({
9530 "path": tmp.path().join("unicode.txt").to_string_lossy(),
9531 "content": "日本語 🎉 Ñoño"
9532 }),
9533 None,
9534 )
9535 .await
9536 .unwrap();
9537 assert!(!out.is_error);
9538 let contents = std::fs::read_to_string(tmp.path().join("unicode.txt")).unwrap();
9539 assert_eq!(contents, "日本語 🎉 Ñoño");
9540 });
9541 }
9542
9543 #[test]
9544 #[cfg(unix)]
9545 fn test_write_file_permissions_unix() {
9546 use std::os::unix::fs::PermissionsExt;
9547 asupersync::test_utils::run_test(|| async {
9548 let tmp = tempfile::tempdir().unwrap();
9549 let tool = WriteTool::new(tmp.path());
9550 let path = tmp.path().join("perms.txt");
9551 let out = tool
9552 .execute(
9553 "t",
9554 serde_json::json!({
9555 "path": path.to_string_lossy(),
9556 "content": "check perms"
9557 }),
9558 None,
9559 )
9560 .await
9561 .unwrap();
9562 assert!(!out.is_error);
9563
9564 let meta = std::fs::metadata(&path).unwrap();
9565 let mode = meta.permissions().mode();
9566 assert_eq!(
9567 mode & 0o777,
9568 0o644,
9569 "Expected default 0o644 permissions for new files"
9570 );
9571 });
9572 }
9573
9574 #[test]
9579 fn test_edit_exact_match_replace() {
9580 asupersync::test_utils::run_test(|| async {
9581 let tmp = tempfile::tempdir().unwrap();
9582 std::fs::write(tmp.path().join("code.rs"), "fn foo() { bar() }").unwrap();
9583
9584 let tool = EditTool::new(tmp.path());
9585 let out = tool
9586 .execute(
9587 "t",
9588 serde_json::json!({
9589 "path": tmp.path().join("code.rs").to_string_lossy(),
9590 "oldText": "bar()",
9591 "newText": "baz()"
9592 }),
9593 None,
9594 )
9595 .await
9596 .unwrap();
9597 assert!(!out.is_error);
9598 let contents = std::fs::read_to_string(tmp.path().join("code.rs")).unwrap();
9599 assert_eq!(contents, "fn foo() { baz() }");
9600 });
9601 }
9602
9603 #[test]
9604 fn test_edit_no_match_error() {
9605 asupersync::test_utils::run_test(|| async {
9606 let tmp = tempfile::tempdir().unwrap();
9607 std::fs::write(tmp.path().join("code.rs"), "fn foo() {}").unwrap();
9608
9609 let tool = EditTool::new(tmp.path());
9610 let err = tool
9611 .execute(
9612 "t",
9613 serde_json::json!({
9614 "path": tmp.path().join("code.rs").to_string_lossy(),
9615 "oldText": "NONEXISTENT TEXT",
9616 "newText": "replacement"
9617 }),
9618 None,
9619 )
9620 .await;
9621 assert!(err.is_err());
9622 });
9623 }
9624
9625 #[test]
9626 fn test_edit_empty_old_text_error() {
9627 asupersync::test_utils::run_test(|| async {
9628 let tmp = tempfile::tempdir().unwrap();
9629 let path = tmp.path().join("code.rs");
9630 std::fs::write(&path, "fn foo() {}").unwrap();
9631
9632 let tool = EditTool::new(tmp.path());
9633 let err = tool
9634 .execute(
9635 "t",
9636 serde_json::json!({
9637 "path": path.to_string_lossy(),
9638 "oldText": "",
9639 "newText": "prefix"
9640 }),
9641 None,
9642 )
9643 .await
9644 .expect_err("empty oldText should be rejected");
9645
9646 let msg = err.to_string();
9647 assert!(
9648 msg.contains("old text cannot be empty"),
9649 "unexpected error: {msg}"
9650 );
9651 let after = std::fs::read_to_string(path).unwrap();
9652 assert_eq!(after, "fn foo() {}");
9653 });
9654 }
9655
9656 #[test]
9657 fn test_edit_ambiguous_match_error() {
9658 asupersync::test_utils::run_test(|| async {
9659 let tmp = tempfile::tempdir().unwrap();
9660 std::fs::write(tmp.path().join("dup.txt"), "hello hello hello").unwrap();
9661
9662 let tool = EditTool::new(tmp.path());
9663 let err = tool
9664 .execute(
9665 "t",
9666 serde_json::json!({
9667 "path": tmp.path().join("dup.txt").to_string_lossy(),
9668 "oldText": "hello",
9669 "newText": "world"
9670 }),
9671 None,
9672 )
9673 .await;
9674 assert!(err.is_err(), "expected error for ambiguous match");
9675 });
9676 }
9677
9678 #[test]
9679 fn test_edit_multi_line_replacement() {
9680 asupersync::test_utils::run_test(|| async {
9681 let tmp = tempfile::tempdir().unwrap();
9682 std::fs::write(
9683 tmp.path().join("multi.txt"),
9684 "line 1\nline 2\nline 3\nline 4",
9685 )
9686 .unwrap();
9687
9688 let tool = EditTool::new(tmp.path());
9689 let out = tool
9690 .execute(
9691 "t",
9692 serde_json::json!({
9693 "path": tmp.path().join("multi.txt").to_string_lossy(),
9694 "oldText": "line 2\nline 3",
9695 "newText": "replaced 2\nreplaced 3\nextra line"
9696 }),
9697 None,
9698 )
9699 .await
9700 .unwrap();
9701 assert!(!out.is_error);
9702 let contents = std::fs::read_to_string(tmp.path().join("multi.txt")).unwrap();
9703 assert_eq!(
9704 contents,
9705 "line 1\nreplaced 2\nreplaced 3\nextra line\nline 4"
9706 );
9707 });
9708 }
9709
9710 #[test]
9711 fn test_edit_unicode_content() {
9712 asupersync::test_utils::run_test(|| async {
9713 let tmp = tempfile::tempdir().unwrap();
9714 std::fs::write(tmp.path().join("uni.txt"), "Héllo wörld 🌍").unwrap();
9715
9716 let tool = EditTool::new(tmp.path());
9717 let out = tool
9718 .execute(
9719 "t",
9720 serde_json::json!({
9721 "path": tmp.path().join("uni.txt").to_string_lossy(),
9722 "oldText": "wörld 🌍",
9723 "newText": "Welt 🌎"
9724 }),
9725 None,
9726 )
9727 .await
9728 .unwrap();
9729 assert!(!out.is_error);
9730 let contents = std::fs::read_to_string(tmp.path().join("uni.txt")).unwrap();
9731 assert_eq!(contents, "Héllo Welt 🌎");
9732 });
9733 }
9734
9735 #[test]
9736 fn test_edit_missing_file() {
9737 asupersync::test_utils::run_test(|| async {
9738 let tmp = tempfile::tempdir().unwrap();
9739 let tool = EditTool::new(tmp.path());
9740 let err = tool
9741 .execute(
9742 "t",
9743 serde_json::json!({
9744 "path": tmp.path().join("nope.txt").to_string_lossy(),
9745 "oldText": "foo",
9746 "newText": "bar"
9747 }),
9748 None,
9749 )
9750 .await;
9751 assert!(err.is_err());
9752 });
9753 }
9754
9755 struct FailingReader {
9760 responses: std::collections::VecDeque<std::io::Result<Vec<u8>>>,
9761 }
9762
9763 impl FailingReader {
9764 fn new(responses: impl IntoIterator<Item = std::io::Result<Vec<u8>>>) -> Self {
9765 Self {
9766 responses: responses.into_iter().collect(),
9767 }
9768 }
9769 }
9770
9771 impl Read for FailingReader {
9772 fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
9773 match self.responses.pop_front().unwrap_or_else(|| Ok(Vec::new())) {
9774 Ok(bytes) => {
9775 assert!(
9776 bytes.len() <= buf.len(),
9777 "test reader only supports single-chunk reads"
9778 );
9779 buf[..bytes.len()].copy_from_slice(&bytes);
9780 Ok(bytes.len())
9781 }
9782 Err(err) => Err(err),
9783 }
9784 }
9785 }
9786
9787 #[test]
9788 fn test_bash_simple_command() {
9789 asupersync::test_utils::run_test(|| async {
9790 let tmp = tempfile::tempdir().unwrap();
9791 let tool = BashTool::new(tmp.path());
9792 let out = tool
9793 .execute(
9794 "t",
9795 serde_json::json!({ "command": "echo hello_from_bash" }),
9796 None,
9797 )
9798 .await
9799 .unwrap();
9800 let text = get_text(&out.content);
9801 assert!(text.contains("hello_from_bash"));
9802 assert!(!out.is_error);
9803 });
9804 }
9805
9806 #[test]
9807 fn test_bash_exit_code_nonzero() {
9808 asupersync::test_utils::run_test(|| async {
9809 let tmp = tempfile::tempdir().unwrap();
9810 let tool = BashTool::new(tmp.path());
9811 let out = tool
9812 .execute("t", serde_json::json!({ "command": "exit 42" }), None)
9813 .await
9814 .expect("non-zero exit should return Ok with is_error=true");
9815 assert!(out.is_error, "non-zero exit must set is_error");
9816 let msg = get_text(&out.content);
9817 assert!(
9818 msg.contains("42"),
9819 "expected exit code 42 in output, got: {msg}"
9820 );
9821 });
9822 }
9823
9824 #[cfg(unix)]
9825 #[test]
9826 fn test_bash_signal_termination_is_error() {
9827 asupersync::test_utils::run_test(|| async {
9828 let tmp = tempfile::tempdir().unwrap();
9829 let tool = BashTool::new(tmp.path());
9830 let out = tool
9831 .execute("t", serde_json::json!({ "command": "kill -KILL $$" }), None)
9832 .await
9833 .expect("signal-terminated shell should return Ok with is_error=true");
9834 assert!(
9835 out.is_error,
9836 "signal-terminated shell must be reported as error"
9837 );
9838 let msg = get_text(&out.content);
9839 assert!(
9840 msg.contains("Command exited with code"),
9841 "expected explicit exit-code report, got: {msg}"
9842 );
9843 assert!(
9844 !msg.contains("Command exited with code 0"),
9845 "signal-terminated shell must not appear successful: {msg}"
9846 );
9847 });
9848 }
9849
9850 #[test]
9851 fn test_bash_stderr_capture() {
9852 asupersync::test_utils::run_test(|| async {
9853 let tmp = tempfile::tempdir().unwrap();
9854 let tool = BashTool::new(tmp.path());
9855 let out = tool
9856 .execute(
9857 "t",
9858 serde_json::json!({ "command": "echo stderr_msg >&2" }),
9859 None,
9860 )
9861 .await
9862 .unwrap();
9863 let text = get_text(&out.content);
9864 assert!(
9865 text.contains("stderr_msg"),
9866 "expected stderr output in result, got: {text}"
9867 );
9868 });
9869 }
9870
9871 #[test]
9872 fn test_bash_timeout() {
9873 asupersync::test_utils::run_test(|| async {
9874 let tmp = tempfile::tempdir().unwrap();
9875 let tool = BashTool::new(tmp.path());
9876 let out = tool
9877 .execute(
9878 "t",
9879 serde_json::json!({ "command": "sleep 60", "timeout": 2 }),
9880 None,
9881 )
9882 .await
9883 .expect("timeout should return Ok with is_error=true");
9884 assert!(out.is_error, "timeout must set is_error");
9885 let msg = get_text(&out.content);
9886 assert!(
9887 msg.to_lowercase().contains("timeout") || msg.to_lowercase().contains("timed out"),
9888 "expected timeout indication, got: {msg}"
9889 );
9890 let cancellation = out
9891 .details
9892 .as_ref()
9893 .and_then(|details| details.get("cancellation"))
9894 .expect("timeout should include structured cancellation details");
9895 assert_eq!(cancellation["schema"], BASH_CANCELLATION_SCHEMA_V1);
9896 assert_eq!(cancellation["status"], "cancelled");
9897 assert_eq!(cancellation["reason"], "timeout");
9898 assert_eq!(cancellation["cleanup"], "process_group_tree_terminated");
9899 assert_eq!(cancellation["timeoutMs"], 2000);
9900 });
9901 }
9902
9903 #[cfg(target_os = "linux")]
9904 #[test]
9905 fn test_bash_timeout_kills_process_tree() {
9906 asupersync::test_utils::run_test(|| async {
9907 let tmp = tempfile::tempdir().unwrap();
9908 let marker = tmp.path().join("leaked_child.txt");
9909 let tool = BashTool::new(tmp.path());
9910
9911 let out = tool
9912 .execute(
9913 "t",
9914 serde_json::json!({
9915 "command": "(sleep 3; echo leaked > leaked_child.txt) & sleep 10",
9916 "timeout": 1
9917 }),
9918 None,
9919 )
9920 .await
9921 .expect("timeout should return Ok with is_error=true");
9922
9923 assert!(out.is_error, "timeout must set is_error");
9924 let msg = get_text(&out.content);
9925 assert!(msg.contains("Command timed out"));
9926
9927 std::thread::sleep(Duration::from_secs(4));
9929 assert!(
9930 !marker.exists(),
9931 "background child was not terminated on timeout"
9932 );
9933 });
9934 }
9935
9936 #[cfg(target_os = "linux")]
9937 #[test]
9938 fn test_bash_cancelled_context_kills_process_tree() {
9939 asupersync::test_utils::run_test(|| async {
9940 let tmp = tempfile::tempdir().unwrap();
9941 let marker = tmp.path().join("leaked_child.txt");
9942
9943 let ambient_cx = asupersync::Cx::for_testing();
9944 let cancel_cx = ambient_cx.clone();
9945 let _current = asupersync::Cx::set_current(Some(ambient_cx));
9946
9947 let cancel_thread = std::thread::spawn(move || {
9948 std::thread::sleep(Duration::from_millis(100));
9949 cancel_cx.set_cancel_requested(true);
9950 });
9951
9952 let result = run_bash_command(
9953 tmp.path(),
9954 None,
9955 None,
9956 "(sleep 3; echo leaked > leaked_child.txt) & sleep 10",
9957 Some(30),
9958 None,
9959 )
9960 .await
9961 .expect("cancelled bash should return a result");
9962
9963 cancel_thread.join().expect("cancel thread");
9964
9965 assert!(
9966 result.cancelled,
9967 "expected cancelled bash result: {result:?}"
9968 );
9969 assert_eq!(
9970 result.cancellation_reason,
9971 Some(BashCancellationReason::AmbientCancellation)
9972 );
9973
9974 std::thread::sleep(Duration::from_secs(4));
9975 assert!(
9976 !marker.exists(),
9977 "background child was not terminated on cancellation"
9978 );
9979 });
9980 }
9981
9982 #[test]
9983 fn test_bash_pump_stream_emits_io_error_frame_after_partial_output() {
9984 let reader = FailingReader::new([
9985 Ok(b"partial stdout".to_vec()),
9986 Err(std::io::Error::other("simulated stdout failure")),
9987 ]);
9988 let (tx, rx) = mpsc::sync_channel::<BashPipeFrame>(4);
9989
9990 pump_stream(reader, "stdout", &tx);
9991
9992 match rx.recv().expect("partial chunk") {
9993 BashPipeFrame::Chunk(chunk) => assert_eq!(chunk, b"partial stdout"),
9994 BashPipeFrame::Error(message) => {
9995 unreachable!("expected output chunk before error, got error frame: {message}")
9996 }
9997 }
9998
9999 match rx.recv().expect("io error frame") {
10000 BashPipeFrame::Chunk(chunk) => {
10001 unreachable!("expected io error after partial chunk, got chunk: {chunk:?}")
10002 }
10003 BashPipeFrame::Error(message) => {
10004 assert!(message.contains("Failed to read bash stdout"));
10005 assert!(message.contains("simulated stdout failure"));
10006 }
10007 }
10008
10009 assert!(matches!(rx.try_recv(), Err(mpsc::TryRecvError::Empty)));
10010 }
10011
10012 #[test]
10013 fn test_drain_bash_output_ignores_cancellation_after_process_exit() {
10014 asupersync::test_utils::run_test(|| async {
10015 let (tx, mut rx) = mpsc::sync_channel::<BashPipeFrame>(1);
10016 let mut bash_output = BashOutputState::new(DEFAULT_MAX_BYTES);
10017
10018 let ambient_cx = asupersync::Cx::for_testing();
10019 ambient_cx.set_cancel_requested(true);
10020 let _current = asupersync::Cx::set_current(Some(ambient_cx));
10021 let cx = AgentCx::for_current_or_request();
10022 let now = cx
10023 .cx()
10024 .timer_driver()
10025 .map_or_else(wall_now, |timer| timer.now());
10026
10027 let cancelled = drain_bash_output(
10028 &mut rx,
10029 &mut bash_output,
10030 &cx,
10031 now + std::time::Duration::from_millis(10),
10032 std::time::Duration::from_millis(1),
10033 false,
10034 )
10035 .await
10036 .expect("drain should complete without cancellation");
10037
10038 drop(tx);
10039
10040 assert!(
10041 !cancelled,
10042 "post-exit drain should ignore late ambient cancellation"
10043 );
10044 assert_eq!(bash_output.total_bytes, 0);
10045 });
10046 }
10047
10048 #[test]
10049 fn test_drain_bash_output_returns_pipe_read_error() {
10050 asupersync::test_utils::run_test(|| async {
10051 let (tx, mut rx) = mpsc::sync_channel::<BashPipeFrame>(2);
10052 tx.send(BashPipeFrame::Chunk(b"partial stderr".to_vec()))
10053 .expect("queue partial output");
10054 tx.send(BashPipeFrame::Error(
10055 "Failed to read bash stderr: simulated stderr failure".to_string(),
10056 ))
10057 .expect("queue error frame");
10058 drop(tx);
10059
10060 let mut bash_output = BashOutputState::new(DEFAULT_MAX_BYTES);
10061 let cx = AgentCx::for_current_or_request();
10062 let now = cx
10063 .cx()
10064 .timer_driver()
10065 .map_or_else(wall_now, |timer| timer.now());
10066
10067 let err = drain_bash_output(
10068 &mut rx,
10069 &mut bash_output,
10070 &cx,
10071 now + std::time::Duration::from_millis(10),
10072 std::time::Duration::from_millis(1),
10073 false,
10074 )
10075 .await
10076 .expect_err("pipe read failures must surface as errors");
10077
10078 let message = err.to_string();
10079 assert!(message.contains("Failed to read bash stderr"));
10080 assert!(message.contains("simulated stderr failure"));
10081 assert!(message.contains("Partial output before failure"));
10082 assert!(message.contains("partial stderr"));
10083 assert_eq!(bash_output.total_bytes, "partial stderr".len());
10084 });
10085 }
10086
10087 #[test]
10088 fn test_drain_bash_output_honors_cancellation_while_process_still_active() {
10089 asupersync::test_utils::run_test(|| async {
10090 let (_tx, mut rx) = mpsc::sync_channel::<BashPipeFrame>(1);
10091 let mut bash_output = BashOutputState::new(DEFAULT_MAX_BYTES);
10092
10093 let ambient_cx = asupersync::Cx::for_testing();
10094 ambient_cx.set_cancel_requested(true);
10095 let _current = asupersync::Cx::set_current(Some(ambient_cx));
10096 let cx = AgentCx::for_current_or_request();
10097 let now = cx
10098 .cx()
10099 .timer_driver()
10100 .map_or_else(wall_now, |timer| timer.now());
10101
10102 let cancelled = drain_bash_output(
10103 &mut rx,
10104 &mut bash_output,
10105 &cx,
10106 now + std::time::Duration::from_secs(1),
10107 std::time::Duration::from_millis(1),
10108 true,
10109 )
10110 .await
10111 .expect("drain should complete under cancellation");
10112
10113 assert!(
10114 cancelled,
10115 "active drain should still honor ambient cancellation"
10116 );
10117 assert_eq!(bash_output.total_bytes, 0);
10118 });
10119 }
10120
10121 #[test]
10122 fn test_bash_output_state_abandon_spill_file_clears_path_and_unlinks_file() {
10123 let tmp = tempfile::tempdir().unwrap();
10124 let spill_path = tmp.path().join("partial-bash.log");
10125 std::fs::write(&spill_path, b"partial output").unwrap();
10126
10127 let mut bash_output = BashOutputState::new(DEFAULT_MAX_BYTES);
10128 bash_output.temp_file_path = Some(spill_path.clone());
10129
10130 bash_output.abandon_spill_file();
10131
10132 assert!(bash_output.spill_failed);
10133 assert!(bash_output.temp_file.is_none());
10134 assert!(bash_output.temp_file_path.is_none());
10135 assert!(
10136 !spill_path.exists(),
10137 "abandoned spill files should not be advertised or left behind"
10138 );
10139 }
10140
10141 #[test]
10142 fn test_bash_hard_limit_retains_partial_spill_file() {
10143 asupersync::test_utils::run_test(|| async {
10144 let tmp = tempfile::tempdir().unwrap();
10145 let spill_path = tmp.path().join("hard-limit-bash.log");
10146 std::fs::write(&spill_path, b"partial output").unwrap();
10147
10148 let spill_file = asupersync::fs::OpenOptions::new()
10149 .append(true)
10150 .open(&spill_path)
10151 .await
10152 .unwrap();
10153
10154 let mut bash_output = BashOutputState::new(DEFAULT_MAX_BYTES);
10155 bash_output.total_bytes = BASH_FILE_LIMIT_BYTES;
10156 bash_output.temp_file_path = Some(spill_path.clone());
10157 bash_output.temp_file = Some(spill_file);
10158
10159 ingest_bash_chunk(vec![b'x'], &mut bash_output)
10160 .await
10161 .expect("hard-limit ingestion should still succeed");
10162
10163 assert!(!bash_output.spill_failed);
10164 assert!(bash_output.temp_file.is_none());
10165 assert!(bash_output.temp_file_path.is_some());
10166 assert!(
10167 spill_path.exists(),
10168 "partial spill files must be retained once the hard limit is reached for diagnostics"
10169 );
10170 });
10171 }
10172
10173 #[test]
10174 #[cfg(unix)]
10175 fn test_bash_working_directory() {
10176 asupersync::test_utils::run_test(|| async {
10177 let tmp = tempfile::tempdir().unwrap();
10178 let tool = BashTool::new(tmp.path());
10179 let out = tool
10180 .execute("t", serde_json::json!({ "command": "pwd" }), None)
10181 .await
10182 .unwrap();
10183 let text = get_text(&out.content);
10184 let canonical = tmp.path().canonicalize().unwrap();
10185 assert!(
10186 text.contains(&canonical.to_string_lossy().to_string()),
10187 "expected cwd in output, got: {text}"
10188 );
10189 });
10190 }
10191
10192 #[test]
10193 fn test_bash_multiline_output() {
10194 asupersync::test_utils::run_test(|| async {
10195 let tmp = tempfile::tempdir().unwrap();
10196 let tool = BashTool::new(tmp.path());
10197 let out = tool
10198 .execute(
10199 "t",
10200 serde_json::json!({ "command": "echo line1; echo line2; echo line3" }),
10201 None,
10202 )
10203 .await
10204 .unwrap();
10205 let text = get_text(&out.content);
10206 assert!(text.contains("line1"));
10207 assert!(text.contains("line2"));
10208 assert!(text.contains("line3"));
10209 });
10210 }
10211
10212 #[test]
10217 fn test_grep_basic_pattern() {
10218 asupersync::test_utils::run_test(|| async {
10219 let tmp = tempfile::tempdir().unwrap();
10220 std::fs::write(
10221 tmp.path().join("search.txt"),
10222 "apple\nbanana\napricot\ncherry",
10223 )
10224 .unwrap();
10225
10226 let tool = GrepTool::new(tmp.path());
10227 let out = tool
10228 .execute(
10229 "t",
10230 serde_json::json!({
10231 "pattern": "ap",
10232 "path": tmp.path().join("search.txt").to_string_lossy()
10233 }),
10234 None,
10235 )
10236 .await
10237 .unwrap();
10238 let text = get_text(&out.content);
10239 assert!(text.contains("apple"));
10240 assert!(text.contains("apricot"));
10241 assert!(!text.contains("banana"));
10242 assert!(!text.contains("cherry"));
10243 });
10244 }
10245
10246 #[test]
10247 fn test_grep_rejects_outside_cwd() {
10248 asupersync::test_utils::run_test(|| async {
10249 let cwd = tempfile::tempdir().unwrap();
10250 let outside = tempfile::tempdir().unwrap();
10251 std::fs::write(outside.path().join("secret.txt"), "secret").unwrap();
10252
10253 let tool = GrepTool::new(cwd.path());
10254 let err = tool
10255 .execute(
10256 "t",
10257 serde_json::json!({
10258 "pattern": "secret",
10259 "path": outside.path().join("secret.txt").to_string_lossy()
10260 }),
10261 None,
10262 )
10263 .await
10264 .unwrap_err();
10265 assert!(err.to_string().contains("outside the working directory"));
10266 });
10267 }
10268
10269 #[test]
10270 fn test_grep_rejects_zero_limit() {
10271 asupersync::test_utils::run_test(|| async {
10272 let tmp = tempfile::tempdir().unwrap();
10273 std::fs::write(tmp.path().join("search.txt"), "alpha\nbeta\n").unwrap();
10274
10275 let tool = GrepTool::new(tmp.path());
10276 let err = tool
10277 .execute(
10278 "t",
10279 serde_json::json!({
10280 "pattern": "alpha",
10281 "path": tmp.path().join("search.txt").to_string_lossy(),
10282 "limit": 0
10283 }),
10284 None,
10285 )
10286 .await
10287 .unwrap_err();
10288 assert!(err.to_string().contains("`limit` must be greater than 0"));
10289 });
10290 }
10291
10292 #[test]
10293 #[cfg(unix)]
10294 fn test_grep_formats_paths_relative_to_symlinked_cwd() {
10295 asupersync::test_utils::run_test(|| async {
10296 let real = tempfile::tempdir().unwrap();
10297 let link_parent = tempfile::tempdir().unwrap();
10298 let link = link_parent.path().join("linked-cwd");
10299 std::os::unix::fs::symlink(real.path(), &link).unwrap();
10300 std::fs::write(real.path().join("needle.txt"), "needle\n").unwrap();
10301
10302 let tool = GrepTool::new(&link);
10303 let out = tool
10304 .execute("t", serde_json::json!({ "pattern": "needle" }), None)
10305 .await
10306 .unwrap();
10307
10308 let text = get_text(&out.content);
10309 assert!(
10310 text.contains("needle.txt:1: needle"),
10311 "grep output should use cwd-relative paths for symlinked cwd, got: {text}"
10312 );
10313 assert!(
10314 !text.contains(real.path().to_string_lossy().as_ref()),
10315 "grep output should not leak canonical temp root, got: {text}"
10316 );
10317 });
10318 }
10319
10320 #[test]
10321 fn test_grep_regex_pattern() {
10322 asupersync::test_utils::run_test(|| async {
10323 let tmp = tempfile::tempdir().unwrap();
10324 std::fs::write(
10325 tmp.path().join("regex.txt"),
10326 "foo123\nbar456\nbaz789\nfoo000",
10327 )
10328 .unwrap();
10329
10330 let tool = GrepTool::new(tmp.path());
10331 let out = tool
10332 .execute(
10333 "t",
10334 serde_json::json!({
10335 "pattern": "foo\\d+",
10336 "path": tmp.path().join("regex.txt").to_string_lossy()
10337 }),
10338 None,
10339 )
10340 .await
10341 .unwrap();
10342 let text = get_text(&out.content);
10343 assert!(text.contains("foo123"));
10344 assert!(text.contains("foo000"));
10345 assert!(!text.contains("bar456"));
10346 });
10347 }
10348
10349 #[test]
10350 fn test_grep_case_insensitive() {
10351 asupersync::test_utils::run_test(|| async {
10352 let tmp = tempfile::tempdir().unwrap();
10353 std::fs::write(tmp.path().join("case.txt"), "Hello\nhello\nHELLO").unwrap();
10354
10355 let tool = GrepTool::new(tmp.path());
10356 let out = tool
10357 .execute(
10358 "t",
10359 serde_json::json!({
10360 "pattern": "hello",
10361 "path": tmp.path().join("case.txt").to_string_lossy(),
10362 "ignoreCase": true
10363 }),
10364 None,
10365 )
10366 .await
10367 .unwrap();
10368 let text = get_text(&out.content);
10369 assert!(text.contains("Hello"));
10370 assert!(text.contains("hello"));
10371 assert!(text.contains("HELLO"));
10372 });
10373 }
10374
10375 #[test]
10376 fn test_grep_case_sensitive_by_default() {
10377 asupersync::test_utils::run_test(|| async {
10378 let tmp = tempfile::tempdir().unwrap();
10379 std::fs::write(tmp.path().join("case_sensitive.txt"), "Hello\nHELLO").unwrap();
10380
10381 let tool = GrepTool::new(tmp.path());
10382 let out = tool
10383 .execute(
10384 "t",
10385 serde_json::json!({
10386 "pattern": "hello",
10387 "path": tmp.path().join("case_sensitive.txt").to_string_lossy()
10388 }),
10389 None,
10390 )
10391 .await
10392 .unwrap();
10393 let text = get_text(&out.content);
10394 assert!(
10395 text.contains("No matches found"),
10396 "expected case-sensitive search to find no matches, got: {text}"
10397 );
10398 });
10399 }
10400
10401 #[test]
10402 fn test_grep_append_non_matching_lines_invariant() {
10403 asupersync::test_utils::run_test(|| async {
10404 let tmp = tempfile::tempdir().unwrap();
10405 let file = tmp.path().join("base.txt");
10406 std::fs::write(&file, "needle one\nskip\nneedle two\n").unwrap();
10407
10408 let tool = GrepTool::new(tmp.path());
10409 let base_out = tool
10410 .execute(
10411 "t",
10412 serde_json::json!({
10413 "pattern": "needle",
10414 "path": file.to_string_lossy(),
10415 "limit": 100
10416 }),
10417 None,
10418 )
10419 .await
10420 .unwrap();
10421 let base_text = get_text(&base_out.content);
10422
10423 std::fs::write(&file, "needle one\nskip\nneedle two\nalpha\nbeta\n").unwrap();
10424 let extended_out = tool
10425 .execute(
10426 "t",
10427 serde_json::json!({
10428 "pattern": "needle",
10429 "path": file.to_string_lossy(),
10430 "limit": 100
10431 }),
10432 None,
10433 )
10434 .await
10435 .unwrap();
10436 let extended_text = get_text(&extended_out.content);
10437
10438 assert_eq!(
10439 base_text, extended_text,
10440 "adding non-matching lines should not alter grep output"
10441 );
10442 });
10443 }
10444
10445 #[test]
10446 fn test_grep_no_matches() {
10447 asupersync::test_utils::run_test(|| async {
10448 let tmp = tempfile::tempdir().unwrap();
10449 std::fs::write(tmp.path().join("nothing.txt"), "alpha\nbeta\ngamma").unwrap();
10450
10451 let tool = GrepTool::new(tmp.path());
10452 let out = tool
10453 .execute(
10454 "t",
10455 serde_json::json!({
10456 "pattern": "ZZZZZ_NOMATCH",
10457 "path": tmp.path().join("nothing.txt").to_string_lossy()
10458 }),
10459 None,
10460 )
10461 .await
10462 .unwrap();
10463 let text = get_text(&out.content);
10464 assert!(
10465 text.to_lowercase().contains("no match")
10466 || text.is_empty()
10467 || text.to_lowercase().contains("no results"),
10468 "expected no-match indication, got: {text}"
10469 );
10470 });
10471 }
10472
10473 #[test]
10474 fn test_grep_context_lines() {
10475 asupersync::test_utils::run_test(|| async {
10476 let tmp = tempfile::tempdir().unwrap();
10477 std::fs::write(
10478 tmp.path().join("ctx.txt"),
10479 "aaa\nbbb\nccc\ntarget\nddd\neee\nfff",
10480 )
10481 .unwrap();
10482
10483 let tool = GrepTool::new(tmp.path());
10484 let out = tool
10485 .execute(
10486 "t",
10487 serde_json::json!({
10488 "pattern": "target",
10489 "path": tmp.path().join("ctx.txt").to_string_lossy(),
10490 "context": 1
10491 }),
10492 None,
10493 )
10494 .await
10495 .unwrap();
10496 let text = get_text(&out.content);
10497 assert!(text.contains("target"));
10498 assert!(text.contains("ccc"), "expected context line before match");
10499 assert!(text.contains("ddd"), "expected context line after match");
10500 });
10501 }
10502
10503 #[test]
10504 fn test_grep_limit() {
10505 asupersync::test_utils::run_test(|| async {
10506 let tmp = tempfile::tempdir().unwrap();
10507 let content: String = (0..200)
10508 .map(|i| format!("match_line_{i}"))
10509 .collect::<Vec<_>>()
10510 .join("\n");
10511 std::fs::write(tmp.path().join("many.txt"), &content).unwrap();
10512
10513 let tool = GrepTool::new(tmp.path());
10514 let out = tool
10515 .execute(
10516 "t",
10517 serde_json::json!({
10518 "pattern": "match_line",
10519 "path": tmp.path().join("many.txt").to_string_lossy(),
10520 "limit": 5
10521 }),
10522 None,
10523 )
10524 .await
10525 .unwrap();
10526 let text = get_text(&out.content);
10527 let match_count = text.matches("match_line_").count();
10529 assert!(
10530 match_count <= 5,
10531 "expected at most 5 matches with limit=5, got {match_count}"
10532 );
10533 let details = out.details.expect("expected limit details");
10534 assert_eq!(
10535 details
10536 .get("matchLimitReached")
10537 .and_then(serde_json::Value::as_u64),
10538 Some(5)
10539 );
10540 });
10541 }
10542
10543 #[test]
10544 fn test_grep_exact_limit_does_not_report_limit_reached() {
10545 asupersync::test_utils::run_test(|| async {
10546 let tmp = tempfile::tempdir().unwrap();
10547 let content = (0..5)
10548 .map(|i| format!("match_line_{i}"))
10549 .collect::<Vec<_>>()
10550 .join("\n");
10551 std::fs::write(tmp.path().join("exact.txt"), &content).unwrap();
10552
10553 let tool = GrepTool::new(tmp.path());
10554 let out = tool
10555 .execute(
10556 "t",
10557 serde_json::json!({
10558 "pattern": "match_line",
10559 "path": tmp.path().join("exact.txt").to_string_lossy(),
10560 "limit": 5
10561 }),
10562 None,
10563 )
10564 .await
10565 .unwrap();
10566
10567 let text = get_text(&out.content);
10568 assert_eq!(text.matches("match_line_").count(), 5);
10569 assert!(
10570 !text.contains("matches limit reached"),
10571 "exact-limit grep results should not claim truncation: {text}"
10572 );
10573 assert!(
10574 out.details
10575 .as_ref()
10576 .and_then(|details| details.get("matchLimitReached"))
10577 .is_none(),
10578 "exact-limit grep results should not set matchLimitReached"
10579 );
10580 });
10581 }
10582
10583 #[test]
10584 fn test_grep_large_output_does_not_deadlock_reader_threads() {
10585 asupersync::test_utils::run_test(|| async {
10586 use std::fmt::Write as _;
10587
10588 let tmp = tempfile::tempdir().unwrap();
10589 let mut content = String::with_capacity(80_000);
10590 for i in 0..5000 {
10591 let _ = writeln!(&mut content, "needle_line_{i}");
10592 }
10593 let file = tmp.path().join("large_grep.txt");
10594 std::fs::write(&file, content).unwrap();
10595
10596 let tool = GrepTool::new(tmp.path());
10597 let run = tool.execute(
10598 "t",
10599 serde_json::json!({
10600 "pattern": "needle_line_",
10601 "path": file.to_string_lossy(),
10602 "limit": 6000
10603 }),
10604 None,
10605 );
10606
10607 let out = asupersync::time::timeout(
10608 asupersync::time::wall_now(),
10609 Duration::from_secs(15),
10610 Box::pin(run),
10611 )
10612 .await
10613 .expect("grep timed out; possible stdout/stderr reader deadlock")
10614 .expect("grep should succeed");
10615
10616 let text = get_text(&out.content);
10617 assert!(text.contains("needle_line_0"));
10618 });
10619 }
10620
10621 #[test]
10622 fn test_grep_respects_gitignore() {
10623 asupersync::test_utils::run_test(|| async {
10624 let tmp = tempfile::tempdir().unwrap();
10625 std::fs::write(tmp.path().join(".gitignore"), "ignored.txt\n").unwrap();
10626 std::fs::write(tmp.path().join("ignored.txt"), "needle in ignored file").unwrap();
10627 std::fs::write(tmp.path().join("visible.txt"), "nothing here").unwrap();
10628
10629 let tool = GrepTool::new(tmp.path());
10630 let out = tool
10631 .execute("t", serde_json::json!({ "pattern": "needle" }), None)
10632 .await
10633 .unwrap();
10634
10635 let text = get_text(&out.content);
10636 assert!(
10637 text.contains("No matches found"),
10638 "expected ignored file to be excluded, got: {text}"
10639 );
10640 });
10641 }
10642
10643 #[test]
10644 fn test_grep_literal_mode() {
10645 asupersync::test_utils::run_test(|| async {
10646 let tmp = tempfile::tempdir().unwrap();
10647 std::fs::write(tmp.path().join("literal.txt"), "a+b\na.b\nab\na\\+b").unwrap();
10648
10649 let tool = GrepTool::new(tmp.path());
10650 let out = tool
10651 .execute(
10652 "t",
10653 serde_json::json!({
10654 "pattern": "a+b",
10655 "path": tmp.path().join("literal.txt").to_string_lossy(),
10656 "literal": true
10657 }),
10658 None,
10659 )
10660 .await
10661 .unwrap();
10662 let text = get_text(&out.content);
10663 assert!(text.contains("a+b"), "literal match should find 'a+b'");
10664 });
10665 }
10666
10667 #[test]
10668 fn test_grep_hashline_output() {
10669 asupersync::test_utils::run_test(|| async {
10670 let tmp = tempfile::tempdir().unwrap();
10671 std::fs::write(
10672 tmp.path().join("hash.txt"),
10673 "apple\nbanana\napricot\ncherry",
10674 )
10675 .unwrap();
10676
10677 let tool = GrepTool::new(tmp.path());
10678 let out = tool
10679 .execute(
10680 "t",
10681 serde_json::json!({
10682 "pattern": "ap",
10683 "path": tmp.path().join("hash.txt").to_string_lossy(),
10684 "hashline": true
10685 }),
10686 None,
10687 )
10688 .await
10689 .unwrap();
10690 let text = get_text(&out.content);
10691 assert!(text.contains("apple"), "should contain apple");
10694 assert!(text.contains("apricot"), "should contain apricot");
10695 assert!(
10696 !text.contains("banana"),
10697 "should not contain banana context"
10698 );
10699 let re = regex::Regex::new(r"\d+#[A-Z]{2}").unwrap();
10701 assert!(
10702 re.is_match(&text),
10703 "hashline output should contain N#AB tags, got: {text}"
10704 );
10705 });
10706 }
10707
10708 #[test]
10709 fn test_grep_hashline_with_context() {
10710 asupersync::test_utils::run_test(|| async {
10711 let tmp = tempfile::tempdir().unwrap();
10712 std::fs::write(
10713 tmp.path().join("ctx.txt"),
10714 "line1\nline2\ntarget\nline4\nline5",
10715 )
10716 .unwrap();
10717
10718 let tool = GrepTool::new(tmp.path());
10719 let out = tool
10720 .execute(
10721 "t",
10722 serde_json::json!({
10723 "pattern": "target",
10724 "path": tmp.path().join("ctx.txt").to_string_lossy(),
10725 "hashline": true,
10726 "context": 1
10727 }),
10728 None,
10729 )
10730 .await
10731 .unwrap();
10732 let text = get_text(&out.content);
10733 assert!(text.contains("line2"), "should contain context line2");
10735 assert!(text.contains("target"), "should contain match");
10736 assert!(text.contains("line4"), "should contain context line4");
10737 let re_match = regex::Regex::new(r"\d+#[A-Z]{2}: target").unwrap();
10739 assert!(
10740 re_match.is_match(&text),
10741 "match line should use : separator with hashline tag, got: {text}"
10742 );
10743 let re_ctx = regex::Regex::new(r"\d+#[A-Z]{2}- line").unwrap();
10744 assert!(
10745 re_ctx.is_match(&text),
10746 "context line should use - separator with hashline tag, got: {text}"
10747 );
10748 });
10749 }
10750
10751 #[test]
10756 fn test_find_glob_pattern() {
10757 asupersync::test_utils::run_test(|| async {
10758 if find_fd_binary().is_none() {
10759 return;
10760 }
10761 let tmp = tempfile::tempdir().unwrap();
10762 std::fs::write(tmp.path().join("file1.rs"), "").unwrap();
10763 std::fs::write(tmp.path().join("file2.rs"), "").unwrap();
10764 std::fs::write(tmp.path().join("file3.txt"), "").unwrap();
10765
10766 let tool = FindTool::new(tmp.path());
10767 let out = tool
10768 .execute(
10769 "t",
10770 serde_json::json!({
10771 "pattern": "*.rs",
10772 "path": tmp.path().to_string_lossy()
10773 }),
10774 None,
10775 )
10776 .await
10777 .unwrap();
10778 let text = get_text(&out.content);
10779 assert!(text.contains("file1.rs"));
10780 assert!(text.contains("file2.rs"));
10781 assert!(!text.contains("file3.txt"));
10782 });
10783 }
10784
10785 #[test]
10786 fn test_find_append_non_matching_file_invariant() {
10787 asupersync::test_utils::run_test(|| async {
10788 if find_fd_binary().is_none() {
10789 return;
10790 }
10791 let tmp = tempfile::tempdir().unwrap();
10792 std::fs::write(tmp.path().join("match.txt"), "a").unwrap();
10793
10794 let tool = FindTool::new(tmp.path());
10795 let base_out = tool
10796 .execute(
10797 "t",
10798 serde_json::json!({
10799 "pattern": "*.txt",
10800 "path": tmp.path().to_string_lossy()
10801 }),
10802 None,
10803 )
10804 .await
10805 .unwrap();
10806 let base_text = get_text(&base_out.content);
10807
10808 std::fs::write(tmp.path().join("ignore.md"), "b").unwrap();
10809 let extended_out = tool
10810 .execute(
10811 "t",
10812 serde_json::json!({
10813 "pattern": "*.txt",
10814 "path": tmp.path().to_string_lossy()
10815 }),
10816 None,
10817 )
10818 .await
10819 .unwrap();
10820 let extended_text = get_text(&extended_out.content);
10821
10822 assert_eq!(
10823 base_text, extended_text,
10824 "adding non-matching files should not alter find output"
10825 );
10826 });
10827 }
10828
10829 #[test]
10830 fn test_find_rejects_outside_cwd() {
10831 asupersync::test_utils::run_test(|| async {
10832 let cwd = tempfile::tempdir().unwrap();
10833 let outside = tempfile::tempdir().unwrap();
10834 std::fs::write(outside.path().join("secret.txt"), "secret").unwrap();
10835
10836 let tool = FindTool::new(cwd.path());
10837 let err = tool
10838 .execute(
10839 "t",
10840 serde_json::json!({
10841 "pattern": "*.txt",
10842 "path": outside.path().to_string_lossy()
10843 }),
10844 None,
10845 )
10846 .await
10847 .unwrap_err();
10848 assert!(err.to_string().contains("outside the working directory"));
10849 });
10850 }
10851
10852 #[test]
10853 fn test_find_limit() {
10854 asupersync::test_utils::run_test(|| async {
10855 if find_fd_binary().is_none() {
10856 return;
10857 }
10858 let tmp = tempfile::tempdir().unwrap();
10859 for i in 0..20 {
10860 std::fs::write(tmp.path().join(format!("f{i}.txt")), "").unwrap();
10861 }
10862
10863 let tool = FindTool::new(tmp.path());
10864 let out = tool
10865 .execute(
10866 "t",
10867 serde_json::json!({
10868 "pattern": "*.txt",
10869 "path": tmp.path().to_string_lossy(),
10870 "limit": 5
10871 }),
10872 None,
10873 )
10874 .await
10875 .unwrap();
10876 let text = get_text(&out.content);
10877 let file_count = text.lines().filter(|l| l.contains(".txt")).count();
10878 assert!(
10879 file_count <= 5,
10880 "expected at most 5 files with limit=5, got {file_count}"
10881 );
10882 let details = out.details.expect("expected limit details");
10883 assert_eq!(
10884 details
10885 .get("resultLimitReached")
10886 .and_then(serde_json::Value::as_u64),
10887 Some(5)
10888 );
10889 });
10890 }
10891
10892 #[test]
10893 fn test_find_exact_limit_does_not_report_limit_reached() {
10894 asupersync::test_utils::run_test(|| async {
10895 if find_fd_binary().is_none() {
10896 return;
10897 }
10898 let tmp = tempfile::tempdir().unwrap();
10899 for i in 0..5 {
10900 std::fs::write(tmp.path().join(format!("f{i}.txt")), "").unwrap();
10901 }
10902
10903 let tool = FindTool::new(tmp.path());
10904 let out = tool
10905 .execute(
10906 "t",
10907 serde_json::json!({
10908 "pattern": "*.txt",
10909 "path": tmp.path().to_string_lossy(),
10910 "limit": 5
10911 }),
10912 None,
10913 )
10914 .await
10915 .unwrap();
10916
10917 let text = get_text(&out.content);
10918 assert_eq!(text.lines().filter(|line| line.contains(".txt")).count(), 5);
10919 assert!(
10920 !text.contains("results limit reached"),
10921 "exact-limit find results should not claim truncation: {text}"
10922 );
10923 assert!(
10924 out.details
10925 .as_ref()
10926 .and_then(|details| details.get("resultLimitReached"))
10927 .is_none(),
10928 "exact-limit find results should not set resultLimitReached"
10929 );
10930 });
10931 }
10932
10933 #[test]
10934 fn test_find_zero_limit_is_rejected() {
10935 asupersync::test_utils::run_test(|| async {
10936 if find_fd_binary().is_none() {
10937 return;
10938 }
10939 let tmp = tempfile::tempdir().unwrap();
10940 std::fs::write(tmp.path().join("file.txt"), "").unwrap();
10941
10942 let tool = FindTool::new(tmp.path());
10943 let err = tool
10944 .execute(
10945 "t",
10946 serde_json::json!({
10947 "pattern": "*.txt",
10948 "path": tmp.path().to_string_lossy(),
10949 "limit": 0
10950 }),
10951 None,
10952 )
10953 .await
10954 .expect_err("limit=0 should be rejected");
10955
10956 assert!(
10957 err.to_string().contains("`limit` must be greater than 0"),
10958 "expected validation error, got: {err}"
10959 );
10960 });
10961 }
10962
10963 #[test]
10964 fn test_find_no_matches() {
10965 asupersync::test_utils::run_test(|| async {
10966 if find_fd_binary().is_none() {
10967 return;
10968 }
10969 let tmp = tempfile::tempdir().unwrap();
10970 std::fs::write(tmp.path().join("only.txt"), "").unwrap();
10971
10972 let tool = FindTool::new(tmp.path());
10973 let out = tool
10974 .execute(
10975 "t",
10976 serde_json::json!({
10977 "pattern": "*.rs",
10978 "path": tmp.path().to_string_lossy()
10979 }),
10980 None,
10981 )
10982 .await
10983 .unwrap();
10984 let text = get_text(&out.content);
10985 assert!(
10986 text.to_lowercase().contains("no files found")
10987 || text.to_lowercase().contains("no matches")
10988 || text.is_empty(),
10989 "expected no-match indication, got: {text}"
10990 );
10991 });
10992 }
10993
10994 #[test]
10995 fn test_find_nonexistent_path() {
10996 asupersync::test_utils::run_test(|| async {
10997 if find_fd_binary().is_none() {
10998 return;
10999 }
11000 let tmp = tempfile::tempdir().unwrap();
11001 let tool = FindTool::new(tmp.path());
11002 let err = tool
11003 .execute(
11004 "t",
11005 serde_json::json!({
11006 "pattern": "*.rs",
11007 "path": tmp.path().join("nonexistent").to_string_lossy()
11008 }),
11009 None,
11010 )
11011 .await;
11012 assert!(err.is_err());
11013 });
11014 }
11015
11016 #[test]
11017 fn test_find_nested_directories() {
11018 asupersync::test_utils::run_test(|| async {
11019 if find_fd_binary().is_none() {
11020 return;
11021 }
11022 let tmp = tempfile::tempdir().unwrap();
11023 std::fs::create_dir_all(tmp.path().join("a/b/c")).unwrap();
11024 std::fs::write(tmp.path().join("top.rs"), "").unwrap();
11025 std::fs::write(tmp.path().join("a/mid.rs"), "").unwrap();
11026 std::fs::write(tmp.path().join("a/b/c/deep.rs"), "").unwrap();
11027
11028 let tool = FindTool::new(tmp.path());
11029 let out = tool
11030 .execute(
11031 "t",
11032 serde_json::json!({
11033 "pattern": "*.rs",
11034 "path": tmp.path().to_string_lossy()
11035 }),
11036 None,
11037 )
11038 .await
11039 .unwrap();
11040 let text = get_text(&out.content);
11041 assert!(text.contains("top.rs"));
11042 assert!(text.contains("mid.rs"));
11043 assert!(text.contains("deep.rs"));
11044 });
11045 }
11046
11047 #[test]
11048 fn test_find_results_are_sorted() {
11049 asupersync::test_utils::run_test(|| async {
11052 if find_fd_binary().is_none() {
11053 return;
11054 }
11055 let tmp = tempfile::tempdir().unwrap();
11056
11057 std::fs::write(tmp.path().join("oldest.txt"), "").unwrap();
11060 std::thread::sleep(std::time::Duration::from_millis(50));
11061 std::fs::write(tmp.path().join("middle.txt"), "").unwrap();
11062 std::thread::sleep(std::time::Duration::from_millis(50));
11063 std::fs::write(tmp.path().join("newest.txt"), "").unwrap();
11064
11065 let tool = FindTool::new(tmp.path());
11066 let out = tool
11067 .execute(
11068 "t",
11069 serde_json::json!({
11070 "pattern": "*.txt",
11071 "path": tmp.path().to_string_lossy()
11072 }),
11073 None,
11074 )
11075 .await
11076 .unwrap();
11077 let lines: Vec<String> = get_text(&out.content)
11078 .lines()
11079 .map(str::trim)
11080 .filter(|line| !line.is_empty())
11081 .map(str::to_string)
11082 .collect();
11083
11084 assert_eq!(
11086 lines,
11087 vec!["newest.txt", "middle.txt", "oldest.txt"],
11088 "expected mtime-sorted find output (most recent first)"
11089 );
11090 });
11091 }
11092
11093 #[test]
11094 fn test_find_respects_gitignore() {
11095 asupersync::test_utils::run_test(|| async {
11096 if find_fd_binary().is_none() {
11097 return;
11098 }
11099 let tmp = tempfile::tempdir().unwrap();
11100 std::fs::write(tmp.path().join(".gitignore"), "ignored.txt\n").unwrap();
11101 std::fs::write(tmp.path().join("ignored.txt"), "").unwrap();
11102
11103 let tool = FindTool::new(tmp.path());
11104 let out = tool
11105 .execute(
11106 "t",
11107 serde_json::json!({
11108 "pattern": "*.txt",
11109 "path": tmp.path().to_string_lossy()
11110 }),
11111 None,
11112 )
11113 .await
11114 .unwrap();
11115 let text = get_text(&out.content);
11116 assert!(
11117 text.contains("No files found matching pattern"),
11118 "expected .gitignore'd files to be excluded, got: {text}"
11119 );
11120 });
11121 }
11122
11123 #[test]
11128 fn test_ls_directory_listing() {
11129 asupersync::test_utils::run_test(|| async {
11130 let tmp = tempfile::tempdir().unwrap();
11131 std::fs::write(tmp.path().join("file_a.txt"), "content").unwrap();
11132 std::fs::write(tmp.path().join("file_b.rs"), "fn main() {}").unwrap();
11133 std::fs::create_dir(tmp.path().join("subdir")).unwrap();
11134
11135 let tool = LsTool::new(tmp.path());
11136 let out = tool
11137 .execute(
11138 "t",
11139 serde_json::json!({ "path": tmp.path().to_string_lossy() }),
11140 None,
11141 )
11142 .await
11143 .unwrap();
11144 let text = get_text(&out.content);
11145 assert!(text.contains("file_a.txt"));
11146 assert!(text.contains("file_b.rs"));
11147 assert!(text.contains("subdir"));
11148 });
11149 }
11150
11151 #[test]
11152 fn test_ls_rejects_outside_cwd() {
11153 asupersync::test_utils::run_test(|| async {
11154 let cwd = tempfile::tempdir().unwrap();
11155 let outside = tempfile::tempdir().unwrap();
11156 std::fs::write(outside.path().join("secret.txt"), "secret").unwrap();
11157
11158 let tool = LsTool::new(cwd.path());
11159 let err = tool
11160 .execute(
11161 "t",
11162 serde_json::json!({ "path": outside.path().to_string_lossy() }),
11163 None,
11164 )
11165 .await
11166 .unwrap_err();
11167 assert!(err.to_string().contains("outside the working directory"));
11168 });
11169 }
11170
11171 #[test]
11172 fn test_ls_trailing_slash_for_dirs() {
11173 asupersync::test_utils::run_test(|| async {
11174 let tmp = tempfile::tempdir().unwrap();
11175 std::fs::write(tmp.path().join("file.txt"), "").unwrap();
11176 std::fs::create_dir(tmp.path().join("mydir")).unwrap();
11177
11178 let tool = LsTool::new(tmp.path());
11179 let out = tool
11180 .execute(
11181 "t",
11182 serde_json::json!({ "path": tmp.path().to_string_lossy() }),
11183 None,
11184 )
11185 .await
11186 .unwrap();
11187 let text = get_text(&out.content);
11188 assert!(
11189 text.contains("mydir/"),
11190 "expected trailing slash for directory, got: {text}"
11191 );
11192 });
11193 }
11194
11195 #[test]
11196 fn test_ls_limit() {
11197 asupersync::test_utils::run_test(|| async {
11198 let tmp = tempfile::tempdir().unwrap();
11199 for i in 0..20 {
11200 std::fs::write(tmp.path().join(format!("item_{i:02}.txt")), "").unwrap();
11201 }
11202
11203 let tool = LsTool::new(tmp.path());
11204 let out = tool
11205 .execute(
11206 "t",
11207 serde_json::json!({
11208 "path": tmp.path().to_string_lossy(),
11209 "limit": 5
11210 }),
11211 None,
11212 )
11213 .await
11214 .unwrap();
11215 let text = get_text(&out.content);
11216 let entry_count = text.lines().filter(|l| l.contains("item_")).count();
11217 assert!(
11218 entry_count <= 5,
11219 "expected at most 5 entries, got {entry_count}"
11220 );
11221 let details = out.details.expect("expected limit details");
11222 assert_eq!(
11223 details
11224 .get("entryLimitReached")
11225 .and_then(serde_json::Value::as_u64),
11226 Some(5)
11227 );
11228 });
11229 }
11230
11231 #[test]
11232 fn test_ls_zero_limit_is_rejected() {
11233 asupersync::test_utils::run_test(|| async {
11234 let tmp = tempfile::tempdir().unwrap();
11235 std::fs::write(tmp.path().join("item.txt"), "").unwrap();
11236
11237 let tool = LsTool::new(tmp.path());
11238 let err = tool
11239 .execute(
11240 "t",
11241 serde_json::json!({
11242 "path": tmp.path().to_string_lossy(),
11243 "limit": 0
11244 }),
11245 None,
11246 )
11247 .await
11248 .expect_err("limit=0 should be rejected");
11249
11250 assert!(
11251 err.to_string().contains("`limit` must be greater than 0"),
11252 "expected validation error, got: {err}"
11253 );
11254 });
11255 }
11256
11257 #[test]
11258 fn test_ls_nonexistent_directory() {
11259 asupersync::test_utils::run_test(|| async {
11260 let tmp = tempfile::tempdir().unwrap();
11261 let tool = LsTool::new(tmp.path());
11262 let err = tool
11263 .execute(
11264 "t",
11265 serde_json::json!({ "path": tmp.path().join("nope").to_string_lossy() }),
11266 None,
11267 )
11268 .await;
11269 assert!(err.is_err());
11270 });
11271 }
11272
11273 #[test]
11274 fn test_ls_empty_directory() {
11275 asupersync::test_utils::run_test(|| async {
11276 let tmp = tempfile::tempdir().unwrap();
11277 let empty_dir = tmp.path().join("empty");
11278 std::fs::create_dir(&empty_dir).unwrap();
11279
11280 let tool = LsTool::new(tmp.path());
11281 let out = tool
11282 .execute(
11283 "t",
11284 serde_json::json!({ "path": empty_dir.to_string_lossy() }),
11285 None,
11286 )
11287 .await
11288 .unwrap();
11289 assert!(!out.is_error);
11290 });
11291 }
11292
11293 #[test]
11294 fn test_ls_default_cwd() {
11295 asupersync::test_utils::run_test(|| async {
11296 let tmp = tempfile::tempdir().unwrap();
11297 std::fs::write(tmp.path().join("in_cwd.txt"), "").unwrap();
11298
11299 let tool = LsTool::new(tmp.path());
11300 let out = tool
11301 .execute("t", serde_json::json!({}), None)
11302 .await
11303 .unwrap();
11304 let text = get_text(&out.content);
11305 assert!(
11306 text.contains("in_cwd.txt"),
11307 "expected cwd listing to include the file, got: {text}"
11308 );
11309 });
11310 }
11311
11312 #[test]
11317 fn test_truncate_head_no_truncation() {
11318 let content = "short".to_string();
11319 let result = truncate_head(content, 100, 1000);
11320 assert!(!result.truncated);
11321 assert_eq!(result.content, "short");
11322 assert_eq!(result.truncated_by, None);
11323 }
11324
11325 #[test]
11326 fn test_truncate_tail_no_truncation() {
11327 let content = "short".to_string();
11328 let result = truncate_tail(content, 100, 1000);
11329 assert!(!result.truncated);
11330 assert_eq!(result.content, "short");
11331 }
11332
11333 #[test]
11334 fn test_truncate_head_empty_input() {
11335 let result = truncate_head(String::new(), 100, 1000);
11336 assert!(!result.truncated);
11337 assert_eq!(result.content, "");
11338 }
11339
11340 #[test]
11341 fn test_truncate_tail_empty_input() {
11342 let result = truncate_tail(String::new(), 100, 1000);
11343 assert!(!result.truncated);
11344 assert_eq!(result.content, "");
11345 }
11346
11347 #[test]
11348 fn test_detect_line_ending_crlf() {
11349 assert_eq!(detect_line_ending("hello\r\nworld"), "\r\n");
11350 }
11351
11352 #[test]
11353 fn test_detect_line_ending_cr() {
11354 assert_eq!(detect_line_ending("hello\rworld"), "\r");
11355 }
11356
11357 #[test]
11358 fn test_detect_line_ending_lf() {
11359 assert_eq!(detect_line_ending("hello\nworld"), "\n");
11360 }
11361
11362 #[test]
11363 fn test_detect_line_ending_no_newline() {
11364 assert_eq!(detect_line_ending("hello world"), "\n");
11365 }
11366
11367 #[test]
11368 fn test_normalize_to_lf() {
11369 assert_eq!(normalize_to_lf("a\r\nb\rc\nd"), "a\nb\nc\nd");
11370 }
11371
11372 #[test]
11373 fn test_count_overlapping_occurrences() {
11374 assert_eq!(count_overlapping_occurrences("aaaa", "aa"), 3);
11375 assert_eq!(count_overlapping_occurrences("abababa", "aba"), 3);
11376 assert_eq!(count_overlapping_occurrences("abc", "d"), 0);
11377 assert_eq!(count_overlapping_occurrences("abc", ""), 0);
11378 }
11379
11380 proptest! {
11381 #![proptest_config(ProptestConfig { cases: 64, .. ProptestConfig::default() })]
11382
11383 #[test]
11384 fn proptest_line_ending_roundtrip_invariant(
11385 input in arbitrary_text(),
11386 ending in prop_oneof![
11387 Just("\n".to_string()),
11388 Just("\r\n".to_string()),
11389 Just("\r".to_string()),
11390 ],
11391 ) {
11392 let normalized = normalize_to_lf(&input);
11393 let restored = restore_line_endings(&normalized, &ending);
11394 let renormalized = normalize_to_lf(&restored);
11395 prop_assert_eq!(renormalized, normalized);
11396 }
11397 }
11398
11399 #[test]
11400 fn test_strip_bom_present() {
11401 let (result, had_bom) = strip_bom("\u{FEFF}hello");
11402 assert_eq!(result, "hello");
11403 assert!(had_bom);
11404 }
11405
11406 #[test]
11407 fn test_strip_bom_absent() {
11408 let (result, had_bom) = strip_bom("hello");
11409 assert_eq!(result, "hello");
11410 assert!(!had_bom);
11411 }
11412
11413 #[test]
11414 fn test_resolve_path_tilde_expansion() {
11415 let cwd = PathBuf::from("/home/user/project");
11416 let result = resolve_path("~/file.txt", &cwd);
11417 assert!(!result.to_string_lossy().starts_with("~/"));
11419 }
11420
11421 fn arbitrary_text() -> impl Strategy<Value = String> {
11422 prop::collection::vec(any::<u8>(), 0..512)
11423 .prop_map(|bytes| String::from_utf8_lossy(&bytes).into_owned())
11424 }
11425
11426 fn match_char_strategy() -> impl Strategy<Value = char> {
11427 prop_oneof![
11428 8 => any::<char>(),
11429 1 => Just('\u{00A0}'),
11430 1 => Just('\u{202F}'),
11431 1 => Just('\u{205F}'),
11432 1 => Just('\u{3000}'),
11433 1 => Just('\u{2018}'),
11434 1 => Just('\u{2019}'),
11435 1 => Just('\u{201C}'),
11436 1 => Just('\u{201D}'),
11437 1 => Just('\u{201E}'),
11438 1 => Just('\u{201F}'),
11439 1 => Just('\u{2010}'),
11440 1 => Just('\u{2011}'),
11441 1 => Just('\u{2012}'),
11442 1 => Just('\u{2013}'),
11443 1 => Just('\u{2014}'),
11444 1 => Just('\u{2015}'),
11445 1 => Just('\u{2212}'),
11446 1 => Just('\u{200D}'),
11447 1 => Just('\u{0301}'),
11448 ]
11449 }
11450
11451 fn arbitrary_match_text() -> impl Strategy<Value = String> {
11452 prop_oneof![
11453 9 => prop::collection::vec(match_char_strategy(), 0..2048),
11454 1 => prop::collection::vec(match_char_strategy(), 8192..16384),
11455 ]
11456 .prop_map(|chars| chars.into_iter().collect())
11457 }
11458
11459 fn line_char_strategy() -> impl Strategy<Value = char> {
11460 prop_oneof![
11461 8 => any::<char>().prop_filter("single-line chars only", |c| *c != '\n'),
11462 1 => Just('é'),
11463 1 => Just('你'),
11464 1 => Just('😀'),
11465 ]
11466 }
11467
11468 fn boundary_line_text() -> impl Strategy<Value = String> {
11469 prop_oneof![
11470 Just(0usize),
11471 Just(GREP_MAX_LINE_LENGTH.saturating_sub(1)),
11472 Just(GREP_MAX_LINE_LENGTH),
11473 Just(GREP_MAX_LINE_LENGTH + 1),
11474 0usize..(GREP_MAX_LINE_LENGTH + 128),
11475 ]
11476 .prop_flat_map(|len| {
11477 prop::collection::vec(line_char_strategy(), len)
11478 .prop_map(|chars| chars.into_iter().collect())
11479 })
11480 }
11481
11482 fn safe_relative_segment() -> impl Strategy<Value = String> {
11483 prop_oneof![
11484 proptest::string::string_regex("[A-Za-z0-9._-]{1,12}")
11485 .expect("segment regex should compile"),
11486 Just("emoji😀".to_string()),
11487 Just("accent-é".to_string()),
11488 Just("rtl-עברית".to_string()),
11489 Just("line\nbreak".to_string()),
11490 Just("nul\0byte".to_string()),
11491 ]
11492 .prop_filter("segment cannot be . or ..", |segment| {
11493 segment != "." && segment != ".."
11494 })
11495 }
11496
11497 fn safe_relative_path() -> impl Strategy<Value = String> {
11498 prop::collection::vec(safe_relative_segment(), 1..6).prop_map(|segments| segments.join("/"))
11499 }
11500
11501 fn pathish_input() -> impl Strategy<Value = String> {
11502 prop_oneof![
11503 5 => safe_relative_path(),
11504 2 => safe_relative_path().prop_map(|p| format!("../{p}")),
11505 2 => safe_relative_path().prop_map(|p| format!("../../{p}")),
11506 1 => safe_relative_path().prop_map(|p| format!("/tmp/{p}")),
11507 1 => safe_relative_path().prop_map(|p| format!("~/{p}")),
11508 1 => Just("~".to_string()),
11509 1 => Just(".".to_string()),
11510 1 => Just("..".to_string()),
11511 1 => Just("././nested/../file.txt".to_string()),
11512 ]
11513 }
11514
11515 proptest! {
11516 #![proptest_config(ProptestConfig { cases: 64, .. ProptestConfig::default() })]
11517
11518 #[test]
11519 fn proptest_truncate_head_invariants(
11520 input in arbitrary_text(),
11521 max_lines in 0usize..32,
11522 max_bytes in 0usize..256,
11523 ) {
11524 let result = truncate_head(input.clone(), max_lines, max_bytes);
11525
11526 prop_assert!(result.output_lines <= max_lines);
11527 prop_assert!(result.output_bytes <= max_bytes);
11528 prop_assert_eq!(result.output_bytes, result.content.len());
11529
11530 prop_assert_eq!(result.truncated, result.truncated_by.is_some());
11531 prop_assert!(input.starts_with(&result.content));
11532
11533 let repeat = truncate_head(result.content.clone(), max_lines, max_bytes);
11534 prop_assert_eq!(&repeat.content, &result.content);
11535
11536 if result.truncated {
11537 prop_assert!(result.total_lines > max_lines || result.total_bytes > max_bytes);
11538 } else {
11539 prop_assert_eq!(&result.content, &input);
11540 prop_assert!(result.total_lines <= max_lines);
11541 prop_assert!(result.total_bytes <= max_bytes);
11542 }
11543
11544 if result.first_line_exceeds_limit {
11545 prop_assert!(result.truncated);
11546 prop_assert_eq!(result.truncated_by, Some(TruncatedBy::Bytes));
11547 prop_assert!(result.output_bytes <= max_bytes);
11548 prop_assert!(result.output_lines <= 1);
11549 prop_assert!(input.starts_with(&result.content));
11550 }
11551 }
11552
11553 #[test]
11554 fn proptest_truncate_tail_invariants(
11555 input in arbitrary_text(),
11556 max_lines in 0usize..32,
11557 max_bytes in 0usize..256,
11558 ) {
11559 let result = truncate_tail(input.clone(), max_lines, max_bytes);
11560
11561 prop_assert!(result.output_lines <= max_lines);
11562 prop_assert!(result.output_bytes <= max_bytes);
11563 prop_assert_eq!(result.output_bytes, result.content.len());
11564
11565 prop_assert_eq!(result.truncated, result.truncated_by.is_some());
11566 prop_assert!(input.ends_with(&result.content));
11567
11568 let repeat = truncate_tail(result.content.clone(), max_lines, max_bytes);
11569 prop_assert_eq!(&repeat.content, &result.content);
11570
11571 if result.last_line_partial {
11572 prop_assert!(result.truncated);
11573 prop_assert_eq!(result.truncated_by, Some(TruncatedBy::Bytes));
11574 prop_assert!(result.output_lines >= 1 && result.output_lines <= 2);
11577 let content_trimmed = result.content.trim_end_matches('\n');
11578 prop_assert!(input
11579 .split('\n')
11580 .rev()
11581 .any(|line| line.ends_with(content_trimmed)));
11582 }
11583 }
11584
11585 #[test]
11586 fn proptest_truncate_head_monotonic_limits(
11587 input in arbitrary_text(),
11588 max_lines_a in 0usize..32,
11589 max_lines_b in 0usize..32,
11590 max_bytes_a in 0usize..256,
11591 max_bytes_b in 0usize..256,
11592 ) {
11593 let low_lines = max_lines_a.min(max_lines_b);
11594 let high_lines = max_lines_a.max(max_lines_b);
11595 let low_bytes = max_bytes_a.min(max_bytes_b);
11596 let high_bytes = max_bytes_a.max(max_bytes_b);
11597
11598 let small = truncate_head(input.clone(), low_lines, low_bytes);
11599 let large = truncate_head(input, high_lines, high_bytes);
11600
11601 prop_assert!(large.content.starts_with(&small.content));
11602 prop_assert!(large.output_bytes >= small.output_bytes);
11603 prop_assert!(large.output_lines >= small.output_lines);
11604 }
11605
11606 #[test]
11607 fn proptest_truncate_tail_monotonic_limits(
11608 input in arbitrary_text(),
11609 max_lines_a in 0usize..32,
11610 max_lines_b in 0usize..32,
11611 max_bytes_a in 0usize..256,
11612 max_bytes_b in 0usize..256,
11613 ) {
11614 let low_lines = max_lines_a.min(max_lines_b);
11615 let high_lines = max_lines_a.max(max_lines_b);
11616 let low_bytes = max_bytes_a.min(max_bytes_b);
11617 let high_bytes = max_bytes_a.max(max_bytes_b);
11618
11619 let small = truncate_tail(input.clone(), low_lines, low_bytes);
11620 let large = truncate_tail(input, high_lines, high_bytes);
11621
11622 prop_assert!(large.content.ends_with(&small.content));
11623 prop_assert!(large.output_bytes >= small.output_bytes);
11624 prop_assert!(large.output_lines >= small.output_lines);
11625 }
11626
11627 #[test]
11628 fn proptest_truncate_head_prefix_invariant_under_append(
11629 base in arbitrary_text(),
11630 suffix in arbitrary_text(),
11631 max_lines in 0usize..32,
11632 max_bytes in 0usize..256,
11633 ) {
11634 let base_result = truncate_head(base.clone(), max_lines, max_bytes);
11635 let extended_result = truncate_head(format!("{base}{suffix}"), max_lines, max_bytes);
11636 prop_assert!(extended_result.content.starts_with(&base_result.content));
11637 }
11638
11639 #[test]
11640 fn proptest_truncate_tail_suffix_invariant_under_prepend(
11641 base in arbitrary_text(),
11642 prefix in arbitrary_text(),
11643 max_lines in 0usize..32,
11644 max_bytes in 0usize..256,
11645 ) {
11646 let base_result = truncate_tail(base.clone(), max_lines, max_bytes);
11647 let extended_result = truncate_tail(format!("{prefix}{base}"), max_lines, max_bytes);
11648 prop_assert!(extended_result.content.ends_with(&base_result.content));
11649 }
11650 }
11651
11652 proptest! {
11653 #![proptest_config(ProptestConfig { cases: 128, .. ProptestConfig::default() })]
11654
11655 #[test]
11656 fn proptest_normalize_for_match_invariants(input in arbitrary_match_text()) {
11657 let normalized = normalize_for_match(&input);
11658 let renormalized = normalize_for_match(&normalized);
11659
11660 prop_assert_eq!(&renormalized, &normalized);
11661 prop_assert!(normalized.len() <= input.len());
11662 prop_assert!(
11663 normalized.chars().all(|c| {
11664 !is_special_unicode_space(c)
11665 && !matches!(
11666 c,
11667 '\u{2018}'
11668 | '\u{2019}'
11669 | '\u{201C}'
11670 | '\u{201D}'
11671 | '\u{201E}'
11672 | '\u{201F}'
11673 | '\u{2010}'
11674 | '\u{2011}'
11675 | '\u{2012}'
11676 | '\u{2013}'
11677 | '\u{2014}'
11678 | '\u{2015}'
11679 | '\u{2212}'
11680 )
11681 }),
11682 "normalize_for_match should remove target punctuation/space variants"
11683 );
11684 }
11685
11686 #[test]
11687 fn proptest_truncate_line_boundary_invariants(line in boundary_line_text()) {
11688 const TRUNCATION_SUFFIX: &str = "... [truncated]";
11689
11690 let result = truncate_line(&line, GREP_MAX_LINE_LENGTH);
11691 let line_char_count = line.chars().count();
11692 let suffix_chars = TRUNCATION_SUFFIX.chars().count();
11693
11694 if line_char_count <= GREP_MAX_LINE_LENGTH {
11695 prop_assert!(!result.was_truncated);
11696 prop_assert_eq!(result.text, line);
11697 } else {
11698 prop_assert!(result.was_truncated);
11699 prop_assert!(result.text.ends_with(TRUNCATION_SUFFIX));
11700 let expected_prefix: String = line.chars().take(GREP_MAX_LINE_LENGTH).collect();
11701 let expected = format!("{expected_prefix}{TRUNCATION_SUFFIX}");
11702 prop_assert_eq!(&result.text, &expected);
11703 prop_assert!(result.text.chars().count() <= GREP_MAX_LINE_LENGTH + suffix_chars);
11704 }
11705 }
11706
11707 #[test]
11708 fn proptest_resolve_path_safe_relative_invariants(relative_path in safe_relative_path()) {
11709 let cwd = PathBuf::from("/tmp/pi-agent-rust-tools-proptest");
11710 let resolved = resolve_path(&relative_path, &cwd);
11711 let normalized = normalize_dot_segments(&resolved);
11712
11713 prop_assert_eq!(&resolved, &cwd.join(&relative_path));
11714 prop_assert!(resolved.starts_with(&cwd));
11715 prop_assert!(normalized.starts_with(&cwd));
11716 prop_assert_eq!(normalize_dot_segments(&normalized), normalized);
11717 }
11718
11719 #[test]
11720 fn proptest_normalize_dot_segments_pathish_invariants(path_input in pathish_input()) {
11721 let cwd = PathBuf::from("/tmp/pi-agent-rust-tools-proptest");
11722 let resolved = resolve_path(&path_input, &cwd);
11723 let normalized_once = normalize_dot_segments(&resolved);
11724 let normalized_twice = normalize_dot_segments(&normalized_once);
11725
11726 prop_assert_eq!(&normalized_once, &normalized_twice);
11727 prop_assert!(
11728 normalized_once
11729 .components()
11730 .all(|component| !matches!(component, std::path::Component::CurDir))
11731 );
11732
11733 if std::path::Path::new(&path_input).is_absolute() {
11734 prop_assert!(resolved.is_absolute());
11735 prop_assert!(normalized_once.is_absolute());
11736 }
11737 }
11738 }
11739
11740 fn fuzzy_content_strategy() -> impl Strategy<Value = String> {
11748 prop::collection::vec(
11749 prop_oneof![
11750 8 => any::<char>().prop_filter("no nul", |c| *c != '\0'),
11751 1 => Just('\u{00A0}'),
11752 1 => Just('\u{2019}'),
11753 1 => Just('\u{201C}'),
11754 1 => Just('\u{2014}'),
11755 ],
11756 1..512,
11757 )
11758 .prop_map(|chars| chars.into_iter().collect())
11759 }
11760
11761 fn needle_from_content(content: String) -> impl Strategy<Value = (String, String)> {
11764 let len = content.len();
11765 if len == 0 {
11766 return Just((content, String::new())).boxed();
11767 }
11768 (0..len)
11769 .prop_flat_map(move |start| {
11770 let c = content.clone();
11771 let remaining = c.len() - start;
11772 let max_needle = remaining.min(256);
11773 (Just(c), start..=start + max_needle.saturating_sub(1))
11774 })
11775 .prop_filter_map("valid char boundary", |(c, end)| {
11776 let start_candidates: Vec<usize> =
11778 (0..c.len()).filter(|i| c.is_char_boundary(*i)).collect();
11779 if start_candidates.is_empty() {
11780 return None;
11781 }
11782 let start = *start_candidates
11783 .iter()
11784 .min_by_key(|&&i| i.abs_diff(end.saturating_sub(end / 2)))
11785 .unwrap_or(&0);
11786 let end_clamped = end.min(c.len());
11787 let actual_end = (end_clamped..=c.len())
11789 .find(|i| c.is_char_boundary(*i))
11790 .unwrap_or(c.len());
11791 if start >= actual_end {
11792 return Some((c, String::new()));
11793 }
11794 Some((c.clone(), c[start..actual_end].to_string()))
11795 })
11796 .boxed()
11797 }
11798
11799 proptest! {
11800 #![proptest_config(ProptestConfig { cases: 128, .. ProptestConfig::default() })]
11801
11802 #[test]
11804 fn proptest_fuzzy_find_text_exact_match_invariants(
11805 (content, needle) in fuzzy_content_strategy().prop_flat_map(needle_from_content)
11806 ) {
11807 let result = fuzzy_find_text(&content, &needle);
11808 if needle.is_empty() {
11809 prop_assert!(result.found, "empty needle should always match");
11811 prop_assert_eq!(result.index, 0);
11812 prop_assert_eq!(result.match_length, 0);
11813 } else {
11814 prop_assert!(
11815 result.found,
11816 "exact substring must be found: content len={}, needle len={}",
11817 content.len(),
11818 needle.len()
11819 );
11820 prop_assert!(content.is_char_boundary(result.index));
11822 prop_assert!(content.is_char_boundary(result.index + result.match_length));
11823 let matched = &content[result.index..result.index + result.match_length];
11825 prop_assert_eq!(matched, needle.as_str());
11826 }
11827 }
11828
11829 #[test]
11834 fn proptest_fuzzy_find_text_normalized_match_invariants(
11835 content in arbitrary_match_text()
11836 ) {
11837 let normalized = build_normalized_content(&content);
11839 if normalized.is_empty() {
11840 return Ok(());
11841 }
11842 let needle_end = normalized
11844 .char_indices()
11845 .nth(128.min(normalized.chars().count().saturating_sub(1)))
11846 .map_or(normalized.len(), |(i, _)| i);
11847 let needle_end = (needle_end..=normalized.len())
11849 .find(|i| normalized.is_char_boundary(*i))
11850 .unwrap_or(normalized.len());
11851 let needle = &normalized[..needle_end];
11852 if needle.is_empty() {
11853 return Ok(());
11854 }
11855
11856 let result = fuzzy_find_text(&content, needle);
11857 prop_assert!(
11858 result.found,
11859 "normalized needle should be found via fuzzy match: needle={:?}",
11860 needle
11861 );
11862 prop_assert!(content.is_char_boundary(result.index));
11864 prop_assert!(content.is_char_boundary(result.index + result.match_length));
11865 }
11866
11867 #[test]
11870 fn proptest_build_normalized_content_invariants(input in arbitrary_match_text()) {
11871 let normalized = build_normalized_content(&input);
11872 let renormalized = build_normalized_content(&normalized);
11873
11874 prop_assert_eq!(
11876 &renormalized,
11877 &normalized,
11878 "build_normalized_content should be idempotent"
11879 );
11880
11881 prop_assert!(
11885 normalized.len() <= input.len(),
11886 "normalized should not be larger: {} vs {}",
11887 normalized.len(),
11888 input.len()
11889 );
11890
11891 let input_lines = input.split('\n').count();
11894 let norm_lines = normalized.split('\n').count();
11895 prop_assert_eq!(
11896 norm_lines, input_lines,
11897 "line count must be preserved by normalization"
11898 );
11899
11900 prop_assert!(
11902 normalized.chars().all(|c| {
11903 !is_special_unicode_space(c)
11904 && !matches!(
11905 c,
11906 '\u{2018}'
11907 | '\u{2019}'
11908 | '\u{201C}'
11909 | '\u{201D}'
11910 | '\u{201E}'
11911 | '\u{201F}'
11912 | '\u{2010}'
11913 | '\u{2011}'
11914 | '\u{2012}'
11915 | '\u{2013}'
11916 | '\u{2014}'
11917 | '\u{2015}'
11918 | '\u{2212}'
11919 )
11920 }),
11921 "normalized content should not contain target Unicode chars"
11922 );
11923 }
11924
11925 #[test]
11928 fn proptest_build_normalized_content_trailing_whitespace_invariant(
11929 input in arbitrary_match_text()
11930 ) {
11931 let normalized = build_normalized_content(&input);
11932 let mut with_trailing = String::new();
11933 let mut lines = input.split('\n').peekable();
11934
11935 while let Some(line) = lines.next() {
11936 with_trailing.push_str(line);
11937 with_trailing.push_str(" \t");
11938 if lines.peek().is_some() {
11939 with_trailing.push('\n');
11940 }
11941 }
11942
11943 let normalized_trailing = build_normalized_content(&with_trailing);
11944 prop_assert_eq!(normalized_trailing, normalized);
11945 }
11946
11947 #[test]
11955 fn proptest_map_normalized_range_roundtrip(input in arbitrary_match_text()) {
11956 let normalized = build_normalized_content(&input);
11957 if normalized.is_empty() {
11958 return Ok(());
11959 }
11960
11961 let norm_chars: Vec<(usize, char)> = normalized.char_indices().collect();
11963 let norm_len = norm_chars.len();
11964 if norm_len == 0 {
11965 return Ok(());
11966 }
11967
11968 let end_char = (norm_len / 4).max(1).min(norm_len);
11970 let norm_start = norm_chars[0].0;
11971 let norm_end = if end_char < norm_chars.len() {
11972 norm_chars[end_char].0
11973 } else {
11974 normalized.len()
11975 };
11976 let norm_match_len = norm_end - norm_start;
11977
11978 let (orig_start, orig_len) =
11979 map_normalized_range_to_original(&input, norm_start, norm_match_len);
11980
11981 prop_assert!(
11983 orig_start + orig_len <= input.len(),
11984 "mapped range {orig_start}..{} exceeds input len {}",
11985 orig_start + orig_len,
11986 input.len()
11987 );
11988
11989 prop_assert!(
11991 input.is_char_boundary(orig_start),
11992 "orig_start {} is not a char boundary",
11993 orig_start
11994 );
11995 prop_assert!(
11996 input.is_char_boundary(orig_start + orig_len),
11997 "orig_end {} is not a char boundary",
11998 orig_start + orig_len
11999 );
12000
12001 prop_assert!(
12005 orig_len >= norm_match_len
12006 || orig_len == 0
12007 || norm_match_len == 0,
12008 "original range ({orig_len}) should be >= normalized range ({norm_match_len})"
12009 );
12010
12011 let expected_norm = &normalized[norm_start..norm_end];
12015 if !expected_norm.is_empty() {
12016 let fuzzy_result = fuzzy_find_text(&input, expected_norm);
12017 prop_assert!(
12018 fuzzy_result.found,
12019 "normalized needle should be findable in original content"
12020 );
12021 }
12022 }
12023 }
12024
12025 #[test]
12026 fn test_truncate_head_preserves_newline() {
12027 let content = "Line1\nLine2".to_string();
12029 let result = truncate_head(content, 1, 1000);
12030 assert_eq!(result.content, "Line1\n");
12031
12032 let content = "Line1".to_string();
12034 let result = truncate_head(content, 1, 1000);
12035 assert_eq!(result.content, "Line1");
12036
12037 let content = "Line1\n".to_string();
12039 let result = truncate_head(content, 1, 1000);
12040 assert_eq!(result.content, "Line1\n");
12041 }
12042
12043 #[test]
12044 fn test_edit_crlf_content_correctness() {
12045 asupersync::test_utils::run_test(|| async {
12047 let tmp = tempfile::tempdir().unwrap();
12048 let path = tmp.path().join("crlf.txt");
12049 let content = "line1\r\nline2\r\nline3";
12051 std::fs::write(&path, content).unwrap();
12052
12053 let tool = EditTool::new(tmp.path());
12054
12055 let out = tool
12060 .execute(
12061 "t",
12062 serde_json::json!({
12063 "path": path.to_string_lossy(),
12064 "oldText": "line2",
12065 "newText": "changed"
12066 }),
12067 None,
12068 )
12069 .await
12070 .unwrap();
12071
12072 assert!(!out.is_error);
12073 let new_content = std::fs::read_to_string(&path).unwrap();
12074
12075 assert_eq!(new_content, "line1\r\nchanged\r\nline3");
12077 });
12078 }
12079
12080 #[test]
12081 fn test_edit_cr_content_correctness() {
12082 asupersync::test_utils::run_test(|| async {
12083 let tmp = tempfile::tempdir().unwrap();
12084 let path = tmp.path().join("cr.txt");
12085 std::fs::write(&path, "line1\rline2\rline3").unwrap();
12086
12087 let tool = EditTool::new(tmp.path());
12088 let out = tool
12089 .execute(
12090 "t",
12091 serde_json::json!({
12092 "path": path.to_string_lossy(),
12093 "oldText": "line2",
12094 "newText": "changed"
12095 }),
12096 None,
12097 )
12098 .await
12099 .unwrap();
12100
12101 assert!(!out.is_error);
12102 let new_content = std::fs::read_to_string(&path).unwrap();
12103 assert_eq!(new_content, "line1\rchanged\rline3");
12104 });
12105 }
12106
12107 #[test]
12112 fn test_compute_line_hash_basic() {
12113 let h1 = compute_line_hash(0, "fn main() {");
12115 let h2 = compute_line_hash(0, "fn main() {");
12116 assert_eq!(h1, h2);
12117
12118 let h3 = compute_line_hash(0, "fn foo() {");
12120 assert_ne!(h1, h3);
12122
12123 for &b in &h1 {
12125 assert!(NIBBLE_STR.contains(&b), "hash byte {b} not in NIBBLE_STR");
12126 }
12127 }
12128
12129 #[test]
12130 fn test_compute_line_hash_punctuation_only() {
12131 let h1 = compute_line_hash(0, "}");
12134 let h2 = compute_line_hash(1, "}");
12135 assert_ne!(
12136 h1, h2,
12137 "punctuation-only lines at different indices should differ"
12138 );
12139
12140 let h3 = compute_line_hash(0, "");
12142 let h4 = compute_line_hash(1, "");
12143 assert_ne!(h3, h4);
12144 }
12145
12146 #[test]
12147 fn test_compute_line_hash_whitespace_invariant() {
12148 let h1 = compute_line_hash(0, "return 42;");
12150 let h2 = compute_line_hash(0, " return 42;");
12151 let h3 = compute_line_hash(0, "\treturn 42;");
12152 assert_eq!(h1, h2);
12153 assert_eq!(h1, h3);
12154 }
12155
12156 #[test]
12157 fn test_format_hashline_tag() {
12158 let tag = format_hashline_tag(0, "fn main() {");
12159 assert!(
12161 tag.starts_with("1#"),
12162 "tag should start with 1#, got: {tag}"
12163 );
12164 assert_eq!(tag.len(), 4, "tag should be 4 chars: N#AB");
12165
12166 let tag10 = format_hashline_tag(9, "line 10");
12167 assert!(tag10.starts_with("10#"));
12168 assert_eq!(tag10.len(), 5); }
12170
12171 #[test]
12172 fn test_parse_hashline_tag_valid() {
12173 let (line, hash) = parse_hashline_tag("5#KJ").unwrap();
12175 assert_eq!(line, 5);
12176 assert_eq!(hash, [b'K', b'J']);
12177
12178 let (line, hash) = parse_hashline_tag(" 10 # QR ").unwrap();
12180 assert_eq!(line, 10);
12181 assert_eq!(hash, [b'Q', b'R']);
12182
12183 let (line, hash) = parse_hashline_tag("> + 3#ZZ").unwrap();
12185 assert_eq!(line, 3);
12186 assert_eq!(hash, [b'Z', b'Z']);
12187 }
12188
12189 #[test]
12190 fn test_parse_hashline_tag_invalid() {
12191 assert!(parse_hashline_tag("0#KJ").is_err());
12193 assert!(parse_hashline_tag("5#").is_err());
12195 assert!(parse_hashline_tag("5#AA").is_err()); assert!(parse_hashline_tag("#KJ").is_err());
12199 assert!(parse_hashline_tag("").is_err());
12201 }
12202
12203 #[test]
12204 fn test_strip_hashline_prefix() {
12205 assert_eq!(strip_hashline_prefix("5#KJ:hello world"), "hello world");
12206 assert_eq!(strip_hashline_prefix("100#ZZ:fn main() {"), "fn main() {");
12207 assert_eq!(strip_hashline_prefix(" 5 # KJ:hello world"), "hello world");
12208 assert_eq!(strip_hashline_prefix("> + 5#KJ:hello world"), "hello world");
12209 assert_eq!(strip_hashline_prefix("5#KJ :hello world"), "hello world");
12210 assert_eq!(strip_hashline_prefix("hello world"), "hello world");
12212 assert_eq!(strip_hashline_prefix(""), "");
12213 }
12214
12215 #[test]
12216 fn test_hashline_edit_single_replace() {
12217 asupersync::test_utils::run_test(|| async {
12218 let dir = tempfile::tempdir().unwrap();
12219 let file = dir.path().join("test.txt");
12220 std::fs::write(&file, "line1\nline2\nline3\n").unwrap();
12221
12222 let tool = HashlineEditTool::new(dir.path());
12223
12224 let tag2 = format_hashline_tag(1, "line2");
12226
12227 let input = serde_json::json!({
12228 "path": file.to_str().unwrap(),
12229 "edits": [{
12230 "op": "replace",
12231 "pos": tag2,
12232 "lines": ["changed"]
12233 }]
12234 });
12235
12236 let out = tool.execute("test", input, None).await.unwrap();
12237 assert!(!out.is_error);
12238
12239 let content = std::fs::read_to_string(&file).unwrap();
12240 assert_eq!(content, "line1\nchanged\nline3\n");
12241 });
12242 }
12243
12244 #[test]
12245 fn test_hashline_edit_range_replace() {
12246 asupersync::test_utils::run_test(|| async {
12247 let dir = tempfile::tempdir().unwrap();
12248 let file = dir.path().join("test.txt");
12249 std::fs::write(&file, "a\nb\nc\nd\ne\n").unwrap();
12250
12251 let tool = HashlineEditTool::new(dir.path());
12252
12253 let tag_b = format_hashline_tag(1, "b");
12254 let tag_d = format_hashline_tag(3, "d");
12255
12256 let input = serde_json::json!({
12257 "path": file.to_str().unwrap(),
12258 "edits": [{
12259 "op": "replace",
12260 "pos": tag_b,
12261 "end": tag_d,
12262 "lines": ["X", "Y"]
12263 }]
12264 });
12265
12266 let out = tool.execute("test", input, None).await.unwrap();
12267 assert!(!out.is_error);
12268
12269 let content = std::fs::read_to_string(&file).unwrap();
12270 assert_eq!(content, "a\nX\nY\ne\n");
12271 });
12272 }
12273
12274 #[test]
12275 fn test_hashline_edit_prepend() {
12276 asupersync::test_utils::run_test(|| async {
12277 let dir = tempfile::tempdir().unwrap();
12278 let file = dir.path().join("test.txt");
12279 std::fs::write(&file, "a\nb\nc\n").unwrap();
12280
12281 let tool = HashlineEditTool::new(dir.path());
12282 let tag_b = format_hashline_tag(1, "b");
12283
12284 let input = serde_json::json!({
12285 "path": file.to_str().unwrap(),
12286 "edits": [{
12287 "op": "prepend",
12288 "pos": tag_b,
12289 "lines": ["inserted"]
12290 }]
12291 });
12292
12293 let out = tool.execute("test", input, None).await.unwrap();
12294 assert!(!out.is_error);
12295
12296 let content = std::fs::read_to_string(&file).unwrap();
12297 assert_eq!(content, "a\ninserted\nb\nc\n");
12298 });
12299 }
12300
12301 #[test]
12302 fn test_hashline_edit_append() {
12303 asupersync::test_utils::run_test(|| async {
12304 let dir = tempfile::tempdir().unwrap();
12305 let file = dir.path().join("test.txt");
12306 std::fs::write(&file, "a\nb\nc\n").unwrap();
12307
12308 let tool = HashlineEditTool::new(dir.path());
12309 let tag_b = format_hashline_tag(1, "b");
12310
12311 let input = serde_json::json!({
12312 "path": file.to_str().unwrap(),
12313 "edits": [{
12314 "op": "append",
12315 "pos": tag_b,
12316 "lines": ["inserted"]
12317 }]
12318 });
12319
12320 let out = tool.execute("test", input, None).await.unwrap();
12321 assert!(!out.is_error);
12322
12323 let content = std::fs::read_to_string(&file).unwrap();
12324 assert_eq!(content, "a\nb\ninserted\nc\n");
12325 });
12326 }
12327
12328 #[test]
12329 fn test_hashline_edit_bottom_up_ordering() {
12330 asupersync::test_utils::run_test(|| async {
12331 let dir = tempfile::tempdir().unwrap();
12332 let file = dir.path().join("test.txt");
12333 std::fs::write(&file, "a\nb\nc\nd\n").unwrap();
12334
12335 let tool = HashlineEditTool::new(dir.path());
12336 let tag_b = format_hashline_tag(1, "b");
12337 let tag_d = format_hashline_tag(3, "d");
12338
12339 let input = serde_json::json!({
12341 "path": file.to_str().unwrap(),
12342 "edits": [
12343 { "op": "replace", "pos": tag_b, "lines": ["B"] },
12344 { "op": "replace", "pos": tag_d, "lines": ["D"] }
12345 ]
12346 });
12347
12348 let out = tool.execute("test", input, None).await.unwrap();
12349 assert!(!out.is_error);
12350
12351 let content = std::fs::read_to_string(&file).unwrap();
12352 assert_eq!(content, "a\nB\nc\nD\n");
12353 });
12354 }
12355
12356 #[test]
12357 fn test_hashline_edit_hash_mismatch() {
12358 asupersync::test_utils::run_test(|| async {
12359 let dir = tempfile::tempdir().unwrap();
12360 let file = dir.path().join("test.txt");
12361 std::fs::write(&file, "hello\nworld\n").unwrap();
12362
12363 let tool = HashlineEditTool::new(dir.path());
12364
12365 let input = serde_json::json!({
12367 "path": file.to_str().unwrap(),
12368 "edits": [{
12369 "op": "replace",
12370 "pos": "1#ZZ",
12371 "lines": ["changed"]
12372 }]
12373 });
12374
12375 let result = tool.execute("test", input, None).await;
12376 assert!(result.is_err());
12377 let err_msg = result.unwrap_err().to_string();
12378 assert!(
12379 err_msg.contains("Hash validation failed"),
12380 "error should mention hash validation: {err_msg}"
12381 );
12382 });
12383 }
12384
12385 #[test]
12386 fn test_hashline_edit_dedup() {
12387 asupersync::test_utils::run_test(|| async {
12388 let dir = tempfile::tempdir().unwrap();
12389 let file = dir.path().join("test.txt");
12390 std::fs::write(&file, "a\nb\nc\n").unwrap();
12391
12392 let tool = HashlineEditTool::new(dir.path());
12393 let tag_b = format_hashline_tag(1, "b");
12394
12395 let input = serde_json::json!({
12397 "path": file.to_str().unwrap(),
12398 "edits": [
12399 { "op": "replace", "pos": &tag_b, "lines": ["B"] },
12400 { "op": "replace", "pos": &tag_b, "lines": ["B"] }
12401 ]
12402 });
12403
12404 let out = tool.execute("test", input, None).await.unwrap();
12405 assert!(!out.is_error);
12406
12407 let content = std::fs::read_to_string(&file).unwrap();
12408 assert_eq!(content, "a\nB\nc\n");
12409 });
12410 }
12411
12412 #[test]
12413 fn test_hashline_edit_noop_detection() {
12414 asupersync::test_utils::run_test(|| async {
12415 let dir = tempfile::tempdir().unwrap();
12416 let file = dir.path().join("test.txt");
12417 std::fs::write(&file, "a\nb\nc\n").unwrap();
12418
12419 let tool = HashlineEditTool::new(dir.path());
12420 let tag_b = format_hashline_tag(1, "b");
12421
12422 let input = serde_json::json!({
12424 "path": file.to_str().unwrap(),
12425 "edits": [{
12426 "op": "replace",
12427 "pos": &tag_b,
12428 "lines": ["b"]
12429 }]
12430 });
12431
12432 let result = tool.execute("test", input, None).await;
12433 assert!(result.is_err());
12434 let err_msg = result.unwrap_err().to_string();
12435 assert!(
12436 err_msg.contains("no-ops"),
12437 "error should mention no-ops: {err_msg}"
12438 );
12439 });
12440 }
12441
12442 #[test]
12443 fn test_hashline_read_output_format() {
12444 asupersync::test_utils::run_test(|| async {
12445 let dir = tempfile::tempdir().unwrap();
12446 let file = dir.path().join("test.txt");
12447 std::fs::write(&file, "fn main() {\n println!(\"hello\");\n}\n").unwrap();
12448
12449 let tool = ReadTool::new(dir.path());
12450 let input = serde_json::json!({
12451 "path": file.to_str().unwrap(),
12452 "hashline": true
12453 });
12454
12455 let out = tool.execute("test", input, None).await.unwrap();
12456 assert!(!out.is_error);
12457 let text = get_text(&out.content);
12458
12459 for line in text.lines() {
12461 if line.starts_with('[') || line.is_empty() {
12462 continue; }
12464 assert!(
12465 hashline_tag_regex().is_match(line),
12466 "line should match hashline format: {line:?}"
12467 );
12468 assert!(
12469 line.contains(':'),
12470 "line should contain ':' separator: {line:?}"
12471 );
12472 }
12473
12474 let first_line = text.lines().next().unwrap();
12476 assert!(first_line.starts_with("1#"), "first line: {first_line:?}");
12477 });
12478 }
12479
12480 #[test]
12481 fn test_hashline_edit_prefix_stripping() {
12482 asupersync::test_utils::run_test(|| async {
12483 let dir = tempfile::tempdir().unwrap();
12484 let file = dir.path().join("test.txt");
12485 std::fs::write(&file, "a\nb\nc\n").unwrap();
12486
12487 let tool = HashlineEditTool::new(dir.path());
12488 let tag_b = format_hashline_tag(1, "b");
12489
12490 let input = serde_json::json!({
12492 "path": file.to_str().unwrap(),
12493 "edits": [{
12494 "op": "replace",
12495 "pos": &tag_b,
12496 "lines": ["2#KJ:changed"]
12497 }]
12498 });
12499
12500 let out = tool.execute("test", input, None).await.unwrap();
12501 assert!(!out.is_error);
12502
12503 let content = std::fs::read_to_string(&file).unwrap();
12504 assert_eq!(content, "a\nchanged\nc\n");
12505 });
12506 }
12507
12508 #[test]
12509 fn test_hashline_edit_delete_lines() {
12510 asupersync::test_utils::run_test(|| async {
12511 let dir = tempfile::tempdir().unwrap();
12512 let file = dir.path().join("test.txt");
12513 std::fs::write(&file, "a\nb\nc\nd\n").unwrap();
12514
12515 let tool = HashlineEditTool::new(dir.path());
12516 let tag_b = format_hashline_tag(1, "b");
12517 let tag_c = format_hashline_tag(2, "c");
12518
12519 let input = serde_json::json!({
12521 "path": file.to_str().unwrap(),
12522 "edits": [{
12523 "op": "replace",
12524 "pos": &tag_b,
12525 "end": &tag_c,
12526 "lines": null
12527 }]
12528 });
12529
12530 let out = tool.execute("test", input, None).await.unwrap();
12531 assert!(!out.is_error);
12532
12533 let content = std::fs::read_to_string(&file).unwrap();
12534 assert_eq!(content, "a\nd\n");
12535 });
12536 }
12537
12538 #[test]
12539 fn test_hashline_edit_crlf_preservation() {
12540 asupersync::test_utils::run_test(|| async {
12541 let dir = tempfile::tempdir().unwrap();
12542 let file = dir.path().join("test.txt");
12543 std::fs::write(&file, "line1\r\nline2\r\nline3").unwrap();
12544
12545 let tool = HashlineEditTool::new(dir.path());
12546 let tag2 = format_hashline_tag(1, "line2");
12547
12548 let input = serde_json::json!({
12549 "path": file.to_str().unwrap(),
12550 "edits": [{
12551 "op": "replace",
12552 "pos": tag2,
12553 "lines": ["changed"]
12554 }]
12555 });
12556
12557 let out = tool.execute("test", input, None).await.unwrap();
12558 assert!(!out.is_error);
12559
12560 let content = std::fs::read_to_string(&file).unwrap();
12561 assert_eq!(content, "line1\r\nchanged\r\nline3");
12562 });
12563 }
12564
12565 #[test]
12566 fn test_hashline_edit_cr_preservation() {
12567 asupersync::test_utils::run_test(|| async {
12568 let dir = tempfile::tempdir().unwrap();
12569 let file = dir.path().join("test.txt");
12570 std::fs::write(&file, "line1\rline2\rline3").unwrap();
12571
12572 let tool = HashlineEditTool::new(dir.path());
12573 let tag2 = format_hashline_tag(1, "line2");
12574
12575 let input = serde_json::json!({
12576 "path": file.to_str().unwrap(),
12577 "edits": [{
12578 "op": "replace",
12579 "pos": tag2,
12580 "lines": ["changed"]
12581 }]
12582 });
12583
12584 let out = tool.execute("test", input, None).await.unwrap();
12585 assert!(!out.is_error);
12586
12587 let content = std::fs::read_to_string(&file).unwrap();
12588 assert_eq!(content, "line1\rchanged\rline3");
12589 });
12590 }
12591
12592 #[test]
12593 fn test_hashline_edit_empty_file_append() {
12594 asupersync::test_utils::run_test(|| async {
12595 let dir = tempfile::tempdir().unwrap();
12596 let file = dir.path().join("empty.txt");
12597 std::fs::write(&file, "").unwrap();
12598
12599 let tool = HashlineEditTool::new(dir.path());
12600
12601 let input = serde_json::json!({
12603 "path": file.to_str().unwrap(),
12604 "edits": [{
12605 "op": "append",
12606 "lines": ["new_line"]
12607 }]
12608 });
12609
12610 let out = tool.execute("test", input, None).await.unwrap();
12611 assert!(!out.is_error);
12612
12613 let content = std::fs::read_to_string(&file).unwrap();
12614 assert!(content.contains("new_line"));
12615 });
12616 }
12617
12618 #[test]
12619 fn test_hashline_edit_single_line_no_trailing_newline() {
12620 asupersync::test_utils::run_test(|| async {
12621 let dir = tempfile::tempdir().unwrap();
12622 let file = dir.path().join("single.txt");
12623 std::fs::write(&file, "hello").unwrap();
12624
12625 let tool = HashlineEditTool::new(dir.path());
12626 let tag = format_hashline_tag(0, "hello");
12627
12628 let input = serde_json::json!({
12629 "path": file.to_str().unwrap(),
12630 "edits": [{
12631 "op": "replace",
12632 "pos": tag,
12633 "lines": ["world"]
12634 }]
12635 });
12636
12637 let out = tool.execute("test", input, None).await.unwrap();
12638 assert!(!out.is_error);
12639
12640 let content = std::fs::read_to_string(&file).unwrap();
12641 assert_eq!(content, "world");
12642 });
12643 }
12644
12645 #[test]
12646 fn test_hashline_edit_preserves_bom_hash_validation() {
12647 asupersync::test_utils::run_test(|| async {
12648 let dir = tempfile::tempdir().unwrap();
12649 let file = dir.path().join("bom.txt");
12650 let bom = "\u{FEFF}";
12651 std::fs::write(&file, format!("{bom}alpha\nbeta\n")).unwrap();
12652
12653 let tool = HashlineEditTool::new(dir.path());
12654 let tag1 = format_hashline_tag(0, &format!("{bom}alpha"));
12655
12656 let input = serde_json::json!({
12657 "path": file.to_str().unwrap(),
12658 "edits": [{
12659 "op": "replace",
12660 "pos": tag1,
12661 "lines": ["gamma"]
12662 }]
12663 });
12664
12665 let out = tool.execute("test", input, None).await.unwrap();
12666 assert!(!out.is_error);
12667
12668 let content = std::fs::read_to_string(&file).unwrap();
12669 assert_eq!(content, format!("{bom}gamma\nbeta\n"));
12670 });
12671 }
12672
12673 #[test]
12674 fn test_hashline_edit_bof_prepend_no_pos() {
12675 asupersync::test_utils::run_test(|| async {
12676 let dir = tempfile::tempdir().unwrap();
12677 let file = dir.path().join("test.txt");
12678 std::fs::write(&file, "a\nb\nc\n").unwrap();
12679
12680 let tool = HashlineEditTool::new(dir.path());
12681
12682 let input = serde_json::json!({
12684 "path": file.to_str().unwrap(),
12685 "edits": [{
12686 "op": "prepend",
12687 "lines": ["header"]
12688 }]
12689 });
12690
12691 let out = tool.execute("test", input, None).await.unwrap();
12692 assert!(!out.is_error);
12693
12694 let content = std::fs::read_to_string(&file).unwrap();
12695 assert_eq!(content, "header\na\nb\nc\n");
12696 });
12697 }
12698
12699 #[test]
12700 fn test_hashline_edit_eof_append_no_pos() {
12701 asupersync::test_utils::run_test(|| async {
12702 let dir = tempfile::tempdir().unwrap();
12703 let file = dir.path().join("test.txt");
12704 std::fs::write(&file, "a\nb\nc\n").unwrap();
12705
12706 let tool = HashlineEditTool::new(dir.path());
12707
12708 let input = serde_json::json!({
12710 "path": file.to_str().unwrap(),
12711 "edits": [{
12712 "op": "append",
12713 "lines": ["footer"]
12714 }]
12715 });
12716
12717 let out = tool.execute("test", input, None).await.unwrap();
12718 assert!(!out.is_error);
12719
12720 let content = std::fs::read_to_string(&file).unwrap();
12721 assert!(
12722 content.contains("footer"),
12723 "content should contain footer: {content:?}"
12724 );
12725 });
12726 }
12727
12728 #[test]
12729 fn test_hashline_edit_overlapping_replace_ranges_rejected() {
12730 asupersync::test_utils::run_test(|| async {
12731 let dir = tempfile::tempdir().unwrap();
12732 let file = dir.path().join("test.txt");
12733 std::fs::write(&file, "a\nb\nc\nd\ne\n").unwrap();
12734
12735 let tool = HashlineEditTool::new(dir.path());
12736 let tag_b = format_hashline_tag(1, "b");
12737 let tag_d = format_hashline_tag(3, "d");
12738 let tag_c = format_hashline_tag(2, "c");
12739 let tag_e = format_hashline_tag(4, "e");
12740
12741 let input = serde_json::json!({
12743 "path": file.to_str().unwrap(),
12744 "edits": [
12745 { "op": "replace", "pos": &tag_b, "end": &tag_d, "lines": ["X"] },
12746 { "op": "replace", "pos": &tag_c, "end": &tag_e, "lines": ["Y"] }
12747 ]
12748 });
12749
12750 let result = tool.execute("test", input, None).await;
12751 assert!(result.is_err());
12752 let err_msg = result.unwrap_err().to_string();
12753 assert!(
12754 err_msg.contains("Overlapping"),
12755 "error should mention overlapping: {err_msg}"
12756 );
12757 });
12758 }
12759
12760 #[test]
12761 fn test_hashline_edit_reversed_range_rejected() {
12762 asupersync::test_utils::run_test(|| async {
12763 let dir = tempfile::tempdir().unwrap();
12764 let file = dir.path().join("test.txt");
12765 std::fs::write(&file, "a\nb\nc\nd\n").unwrap();
12766
12767 let tool = HashlineEditTool::new(dir.path());
12768 let tag_b = format_hashline_tag(1, "b");
12769 let tag_d = format_hashline_tag(3, "d");
12770
12771 let input = serde_json::json!({
12773 "path": file.to_str().unwrap(),
12774 "edits": [{
12775 "op": "replace",
12776 "pos": &tag_d,
12777 "end": &tag_b,
12778 "lines": ["X"]
12779 }]
12780 });
12781
12782 let result = tool.execute("test", input, None).await;
12783 assert!(result.is_err());
12784 let err_msg = result.unwrap_err().to_string();
12785 assert!(
12786 err_msg.contains("before start"),
12787 "error should mention before start: {err_msg}"
12788 );
12789 });
12790 }
12791
12792 #[test]
12793 fn test_hashline_edit_trailing_newline_semantics() {
12794 asupersync::test_utils::run_test(|| async {
12795 let dir = tempfile::tempdir().unwrap();
12796 let file = dir.path().join("test.txt");
12797 std::fs::write(&file, "line1\nline2\n").unwrap();
12799
12800 let tool = HashlineEditTool::new(dir.path());
12801 let tag2 = format_hashline_tag(1, "line2");
12802
12803 let input = serde_json::json!({
12805 "path": file.to_str().unwrap(),
12806 "edits": [{
12807 "op": "replace",
12808 "pos": tag2,
12809 "lines": ["changed"]
12810 }]
12811 });
12812
12813 let out = tool.execute("test", input, None).await.unwrap();
12814 assert!(!out.is_error);
12815
12816 let content = std::fs::read_to_string(&file).unwrap();
12817 assert_eq!(content, "line1\nchanged\n");
12818 });
12819 }
12820}