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 file.metadata().await {
6481 Ok(meta) => {
6482 if !meta.ino().eq(&expected) {
6483 tracing::warn!(
6484 "Temp file identity mismatch (possible TOCTOU attack)"
6485 );
6486 identity_match = false;
6487 }
6488 }
6489 Err(e) => {
6490 tracing::warn!("Failed to stat temp file: {e}");
6491 identity_match = false;
6492 }
6493 }
6494 }
6495
6496 if identity_match {
6497 let mut failed_flush = false;
6499 for existing in &state.chunks {
6500 if let Err(e) = file.write_all(existing).await {
6501 tracing::warn!("Failed to flush bash chunk to temp file: {e}");
6502 failed_flush = true;
6503 break;
6504 }
6505 }
6506
6507 state.temp_file_path = Some(path);
6508 if failed_flush {
6509 state.abandon_spill_file();
6510 } else {
6511 state.temp_file = Some(file);
6512 }
6513 } else {
6514 state.temp_file_path = Some(path);
6515 state.abandon_spill_file();
6516 }
6517 }
6518 Err(e) => {
6519 tracing::warn!("Failed to open temp file async: {e}");
6520 state.temp_file_path = Some(path);
6521 state.abandon_spill_file();
6522 }
6523 }
6524 } else {
6525 state.spill_failed = true;
6526 }
6527 }
6528
6529 let mut close_spill_file = false;
6530 if let Some(file) = state.temp_file.as_mut() {
6531 let mut abandon_spill_file = false;
6532 if state.total_bytes <= BASH_FILE_LIMIT_BYTES {
6533 if let Err(e) = file.write_all(&chunk).await {
6534 tracing::warn!("Failed to write bash chunk to temp file: {e}");
6535 abandon_spill_file = true;
6536 }
6537 } else {
6538 if !state.spill_failed {
6540 tracing::warn!("Bash output exceeded hard limit; stopping file log");
6541 close_spill_file = true;
6542 }
6543 }
6544 if abandon_spill_file {
6545 state.abandon_spill_file();
6546 }
6547 }
6548 if close_spill_file {
6549 state.temp_file = None;
6550 }
6551
6552 state.chunks_bytes = state.chunks_bytes.saturating_add(chunk.len());
6553 state.chunks.push_back(chunk);
6554 while state.chunks_bytes > state.max_chunks_bytes && state.chunks.len() > 1 {
6555 if let Some(front) = state.chunks.pop_front() {
6556 state.chunks_bytes = state.chunks_bytes.saturating_sub(front.len());
6557 }
6558 }
6559 Ok(())
6560}
6561
6562const fn line_count_from_newline_count(
6563 total_bytes: usize,
6564 newline_count: usize,
6565 last_byte_was_newline: bool,
6566) -> usize {
6567 if total_bytes == 0 {
6568 0
6569 } else if last_byte_was_newline {
6570 newline_count
6571 } else {
6572 newline_count.saturating_add(1)
6573 }
6574}
6575
6576fn emit_bash_update(
6577 state: &BashOutputState,
6578 on_update: Option<&(dyn Fn(ToolUpdate) + Send + Sync)>,
6579) -> Result<()> {
6580 if let Some(callback) = on_update {
6581 let raw = concat_chunks(&state.chunks);
6582 let full_text = String::from_utf8_lossy(&raw);
6583 let truncation =
6584 truncate_tail(full_text.into_owned(), DEFAULT_MAX_LINES, DEFAULT_MAX_BYTES);
6585
6586 let elapsed_ms = state.start_time.elapsed().as_millis();
6591 let line_count = line_count_from_newline_count(
6592 state.total_bytes,
6593 state.line_count,
6594 state.last_byte_was_newline,
6595 );
6596 let mut details = serde_json::json!({
6597 "progress": {
6598 "elapsedMs": elapsed_ms,
6599 "lineCount": line_count,
6600 "byteCount": state.total_bytes
6601 }
6602 });
6603 let Some(details_map) = details.as_object_mut() else {
6604 return Ok(());
6605 };
6606
6607 if let Some(timeout) = state.timeout_ms {
6608 if let Some(progress) = details_map
6609 .get_mut("progress")
6610 .and_then(|v| v.as_object_mut())
6611 {
6612 progress.insert("timeoutMs".into(), serde_json::json!(timeout));
6613 }
6614 }
6615 if truncation.truncated {
6616 details_map.insert("truncation".into(), serde_json::to_value(&truncation)?);
6617 }
6618 if let Some(path) = state.temp_file_path.as_ref() {
6619 details_map.insert(
6620 "fullOutputPath".into(),
6621 serde_json::Value::String(path.display().to_string()),
6622 );
6623 }
6624
6625 callback(ToolUpdate {
6626 content: vec![ContentBlock::Text(TextContent::new(truncation.content))],
6627 details: Some(details),
6628 });
6629 }
6630 Ok(())
6631}
6632
6633pub(crate) struct ProcessGuard {
6634 child: Option<std::process::Child>,
6635 cleanup_mode: ProcessCleanupMode,
6636}
6637
6638#[derive(Clone, Copy, Debug, Eq, PartialEq)]
6639pub(crate) enum ProcessCleanupMode {
6640 ChildOnly,
6641 ProcessGroupTree,
6642}
6643
6644impl ProcessGuard {
6645 pub(crate) const fn new(child: std::process::Child, cleanup_mode: ProcessCleanupMode) -> Self {
6646 Self {
6647 child: Some(child),
6648 cleanup_mode,
6649 }
6650 }
6651
6652 pub(crate) fn try_wait_child(&mut self) -> std::io::Result<Option<std::process::ExitStatus>> {
6653 self.child
6654 .as_mut()
6655 .map_or(Ok(None), std::process::Child::try_wait)
6656 }
6657
6658 pub(crate) fn kill(&mut self) -> Option<std::process::ExitStatus> {
6659 if let Some(mut child) = self.child.take() {
6660 cleanup_child(Some(child.id()), self.cleanup_mode);
6661 let _ = child.kill();
6662 std::thread::spawn(move || {
6663 let _ = child.wait();
6664 });
6665 return None;
6668 }
6669 None
6670 }
6671
6672 pub(crate) fn wait(&mut self) -> std::io::Result<std::process::ExitStatus> {
6673 if let Some(mut child) = self.child.take() {
6674 return child.wait();
6675 }
6676 Err(std::io::Error::other("Already waited"))
6677 }
6678}
6679
6680impl Drop for ProcessGuard {
6681 fn drop(&mut self) {
6682 if let Some(mut child) = self.child.take() {
6683 match child.try_wait() {
6684 Ok(None) => {}
6685 Ok(Some(_)) | Err(_) => return,
6686 }
6687 let cleanup_mode = self.cleanup_mode;
6688 std::thread::spawn(move || {
6689 cleanup_child(Some(child.id()), cleanup_mode);
6690 let _ = child.kill();
6691 let _ = child.wait();
6692 });
6693 }
6694 }
6695}
6696
6697fn cleanup_child(pid: Option<u32>, cleanup_mode: ProcessCleanupMode) {
6698 if cleanup_mode == ProcessCleanupMode::ProcessGroupTree {
6699 kill_process_group_tree(pid);
6700 }
6701}
6702
6703pub fn kill_process_tree(pid: Option<u32>) {
6704 kill_process_tree_with(pid, sysinfo::Signal::Kill, false);
6705}
6706
6707pub(crate) fn kill_process_group_tree(pid: Option<u32>) {
6708 kill_process_tree_with(pid, sysinfo::Signal::Kill, true);
6709}
6710
6711fn terminate_process_group_tree(pid: Option<u32>) {
6712 kill_process_tree_with(pid, sysinfo::Signal::Term, true);
6713}
6714
6715fn kill_process_tree_with(pid: Option<u32>, signal: sysinfo::Signal, include_process_group: bool) {
6716 let Some(pid) = pid else {
6717 return;
6718 };
6719
6720 let root = sysinfo::Pid::from_u32(pid);
6721
6722 let mut sys = sysinfo::System::new();
6723 sys.refresh_processes(sysinfo::ProcessesToUpdate::All, true);
6724
6725 let mut children_map: HashMap<sysinfo::Pid, Vec<sysinfo::Pid>> = HashMap::new();
6726 for (p, proc_) in sys.processes() {
6727 if let Some(parent) = proc_.parent() {
6728 children_map.entry(parent).or_default().push(*p);
6729 }
6730 }
6731
6732 let mut to_kill = Vec::new();
6733 let mut visited = std::collections::HashSet::new();
6734 collect_process_tree(root, &children_map, &mut to_kill, &mut visited);
6735
6736 if include_process_group {
6737 #[cfg(unix)]
6741 {
6742 let sig_num = match signal {
6743 sysinfo::Signal::Kill => "9",
6744 _ => "15",
6745 };
6746 let _ = Command::new("kill")
6747 .arg(format!("-{sig_num}"))
6748 .arg("--")
6749 .arg(format!("-{pid}"))
6750 .stdin(Stdio::null())
6751 .stdout(Stdio::null())
6752 .stderr(Stdio::null())
6753 .status();
6754 }
6755 }
6756
6757 for pid in to_kill.into_iter().rev() {
6759 if let Some(proc_) = sys.process(pid) {
6760 match proc_.kill_with(signal) {
6761 Some(true) => {}
6762 Some(false) | None => {
6763 let _ = proc_.kill();
6764 }
6765 }
6766 }
6767 }
6768}
6769
6770fn collect_process_tree(
6771 pid: sysinfo::Pid,
6772 children_map: &HashMap<sysinfo::Pid, Vec<sysinfo::Pid>>,
6773 out: &mut Vec<sysinfo::Pid>,
6774 visited: &mut std::collections::HashSet<sysinfo::Pid>,
6775) {
6776 if !visited.insert(pid) {
6777 return;
6778 }
6779 out.push(pid);
6780 if let Some(children) = children_map.get(&pid) {
6781 for child in children {
6782 collect_process_tree(*child, children_map, out, visited);
6783 }
6784 }
6785}
6786
6787pub(crate) const SIGPIPE_TRAMPOLINE_EXEC_FAILURE_PREFIX: &str = "pi-sigpipe-reset: exec failed:";
6795
6796pub(crate) fn command_with_default_sigpipe(program: impl AsRef<OsStr>) -> std::io::Result<Command> {
6797 command_with_default_sigpipe_for_cwd(program.as_ref(), None)
6798}
6799
6800pub(crate) fn command_with_default_sigpipe_in_dir(
6803 program: impl AsRef<OsStr>,
6804 cwd: &Path,
6805) -> std::io::Result<Command> {
6806 command_with_default_sigpipe_for_cwd(program.as_ref(), Some(cwd))
6807}
6808
6809#[cfg(unix)]
6810fn command_with_default_sigpipe_for_cwd(
6811 program: &OsStr,
6812 cwd: Option<&Path>,
6813) -> std::io::Result<Command> {
6814 let program = resolve_executable_for_shell_trampoline(program, cwd)?;
6815 let mut command = Command::new("/bin/sh");
6816 command
6817 .arg("-c")
6818 .arg(
6819 "trap - PIPE\n\
6820 exec \"$@\"\n\
6821 status=$?\n\
6822 printf 'pi-sigpipe-reset: exec failed: %s\\n' \"$1\" >&2\n\
6823 exit \"$status\"",
6824 )
6825 .arg("pi-sigpipe-reset")
6826 .arg(program);
6827 Ok(command)
6828}
6829
6830#[cfg(not(unix))]
6831fn command_with_default_sigpipe_for_cwd(
6832 program: &OsStr,
6833 _cwd: Option<&Path>,
6834) -> std::io::Result<Command> {
6835 let command = Command::new(program); Ok(command)
6837}
6838
6839#[cfg(unix)]
6840fn resolve_executable_for_shell_trampoline(
6841 program: &OsStr,
6842 cwd: Option<&Path>,
6843) -> std::io::Result<OsString> {
6844 use std::os::unix::ffi::OsStrExt as _;
6845 use std::os::unix::fs::PermissionsExt as _;
6846
6847 fn executable_candidate(path: &Path) -> std::io::Result<bool> {
6848 let metadata = std::fs::metadata(path)?;
6849 Ok(metadata.is_file() && metadata.permissions().mode() & 0o111 != 0)
6850 }
6851
6852 fn absolutize_candidate(path: &Path, cwd: Option<&Path>) -> std::io::Result<PathBuf> {
6853 if path.is_absolute() {
6854 return Ok(path.to_path_buf());
6855 }
6856
6857 let base = std::env::current_dir()?;
6858 Ok(cwd.map_or_else(|| base.join(path), |cwd| base.join(cwd).join(path)))
6859 }
6860
6861 if program.as_bytes().contains(&b'/') {
6862 let path = Path::new(program);
6863 let candidate = absolutize_candidate(path, cwd)?;
6864 if executable_candidate(&candidate)? {
6865 return Ok(candidate.into_os_string());
6866 }
6867 return Err(std::io::Error::new(
6868 std::io::ErrorKind::PermissionDenied,
6869 format!("not an executable file: {}", candidate.display()),
6870 ));
6871 }
6872
6873 let mut permission_denied = false;
6874 let paths = std::env::var_os("PATH").unwrap_or_else(|| OsString::from("/bin:/usr/bin"));
6875 for dir in std::env::split_paths(&paths) {
6876 let candidate = absolutize_candidate(&dir.join(program), cwd)?;
6877 match executable_candidate(&candidate) {
6878 Ok(true) => return Ok(candidate.into_os_string()),
6879 Ok(false) => permission_denied = true,
6880 Err(err) if matches!(err.kind(), std::io::ErrorKind::NotFound) => {}
6881 Err(err) if matches!(err.kind(), std::io::ErrorKind::PermissionDenied) => {
6882 permission_denied = true;
6883 }
6884 Err(_) => {}
6885 }
6886 }
6887
6888 if permission_denied {
6889 Err(std::io::Error::new(
6890 std::io::ErrorKind::PermissionDenied,
6891 format!("command is not executable: {}", program.to_string_lossy()),
6892 ))
6893 } else {
6894 Err(std::io::Error::new(
6895 std::io::ErrorKind::NotFound,
6896 format!("command not found: {}", program.to_string_lossy()),
6897 ))
6898 }
6899}
6900
6901pub(crate) fn isolate_command_process_group(command: &mut Command) {
6903 #[cfg(unix)]
6904 {
6905 use std::os::unix::process::CommandExt as _;
6906 command.process_group(0);
6907 }
6908
6909 #[cfg(not(unix))]
6910 {
6911 let _ = command;
6912 }
6913}
6914
6915fn format_grep_path(file_path: &Path, cwd: &Path) -> String {
6916 if let Ok(rel) = file_path.strip_prefix(cwd) {
6917 let rel_str = rel.display().to_string().replace('\\', "/");
6918 if !rel_str.is_empty() {
6919 return rel_str;
6920 }
6921 }
6922
6923 let canonical_file = safe_canonicalize(file_path);
6924 let canonical_cwd = safe_canonicalize(cwd);
6925 if let Ok(rel) = canonical_file.strip_prefix(&canonical_cwd) {
6926 let rel_str = rel.display().to_string().replace('\\', "/");
6927 if !rel_str.is_empty() {
6928 return rel_str;
6929 }
6930 }
6931
6932 file_path.display().to_string().replace('\\', "/")
6933}
6934
6935async fn get_file_lines_async<'a>(
6936 path: &Path,
6937 cache: &'a mut HashMap<PathBuf, Vec<String>>,
6938) -> &'a [String] {
6939 if !cache.contains_key(path) {
6940 if let Ok(meta) = asupersync::fs::metadata(path).await {
6942 if !meta.is_file() || meta.len() > 10 * 1024 * 1024 {
6943 cache.insert(path.to_path_buf(), Vec::new());
6944 return &[];
6945 }
6946 } else {
6947 cache.insert(path.to_path_buf(), Vec::new());
6948 return &[];
6949 }
6950
6951 let bytes = match asupersync::fs::read(path).await {
6953 Ok(bytes) => bytes,
6954 Err(err) => {
6955 tracing::debug!("Failed to read grep file {}: {err}", path.display());
6956 cache.insert(path.to_path_buf(), Vec::new());
6957 return &[];
6958 }
6959 };
6960 let content = String::from_utf8_lossy(&bytes);
6961 let mut lines = Vec::new();
6962 for line in content.split('\n') {
6963 let trimmed = line.strip_suffix('\r').unwrap_or(line);
6964 for piece in trimmed.split('\r') {
6965 lines.push(piece.to_string());
6966 }
6967 }
6968 if content.ends_with('\n') && lines.last().is_some_and(std::string::String::is_empty) {
6969 lines.pop();
6970 }
6971 cache.insert(path.to_path_buf(), lines);
6972 }
6973 if let Some(lines) = cache.get(path) {
6974 lines.as_slice()
6975 } else {
6976 &[]
6977 }
6978}
6979
6980fn find_fd_binary() -> Option<&'static str> {
6981 static BINARY: OnceLock<Option<&'static str>> = OnceLock::new();
6982 *BINARY.get_or_init(|| {
6983 if std::process::Command::new("fd")
6984 .arg("--version")
6985 .stdout(Stdio::null())
6986 .stderr(Stdio::null())
6987 .status()
6988 .is_ok()
6989 {
6990 return Some("fd");
6991 }
6992 if std::process::Command::new("fdfind")
6993 .arg("--version")
6994 .stdout(Stdio::null())
6995 .stderr(Stdio::null())
6996 .status()
6997 .is_ok()
6998 {
6999 return Some("fdfind");
7000 }
7001 None
7002 })
7003}
7004
7005fn find_rg_binary() -> Option<&'static str> {
7006 static BINARY: OnceLock<Option<&'static str>> = OnceLock::new();
7007 *BINARY.get_or_init(|| {
7008 if std::process::Command::new("rg")
7009 .arg("--version")
7010 .stdout(Stdio::null())
7011 .stderr(Stdio::null())
7012 .status()
7013 .is_ok()
7014 {
7015 return Some("rg");
7016 }
7017 if std::process::Command::new("ripgrep")
7018 .arg("--version")
7019 .stdout(Stdio::null())
7020 .stderr(Stdio::null())
7021 .status()
7022 .is_ok()
7023 {
7024 return Some("ripgrep");
7025 }
7026 None
7027 })
7028}
7029
7030const NIBBLE_STR: &[u8; 16] = b"ZPMQVRWSNKTXJBYH";
7036
7037static HASHLINE_DICT: OnceLock<[[u8; 2]; 256]> = OnceLock::new();
7040
7041fn hashline_dict() -> &'static [[u8; 2]; 256] {
7042 HASHLINE_DICT.get_or_init(|| {
7043 let mut dict = [[0u8; 2]; 256];
7044 for i in 0..256 {
7045 dict[i] = [NIBBLE_STR[i & 0x0F], NIBBLE_STR[(i >> 4) & 0x0F]];
7046 }
7047 dict
7048 })
7049}
7050
7051fn compute_line_hash(line_idx: usize, line: &str) -> [u8; 2] {
7061 let line = line.strip_suffix('\r').unwrap_or(line);
7062 let significant: String = line.chars().filter(|c| !c.is_whitespace()).collect();
7064 let has_alnum = significant.chars().any(char::is_alphanumeric);
7065 let seed = if has_alnum {
7066 0
7067 } else {
7068 #[allow(clippy::cast_possible_truncation)]
7069 let s = line_idx as u32;
7070 s
7071 };
7072 let hash = xxhash_rust::xxh32::xxh32(significant.as_bytes(), seed);
7073 let byte = (hash & 0xFF) as usize;
7074 hashline_dict()[byte]
7075}
7076
7077fn format_hashline_tag(line_idx: usize, line: &str) -> String {
7079 let h = compute_line_hash(line_idx, line);
7080 format!("{}#{}{}", line_idx + 1, h[0] as char, h[1] as char)
7081}
7082
7083fn format_hashline_tag_with_bom(line_idx: usize, line: &str, had_bom: bool) -> String {
7085 let h = compute_line_hash_with_bom(line_idx, line, had_bom);
7086 format!("{}#{}{}", line_idx + 1, h[0] as char, h[1] as char)
7087}
7088
7089fn compute_line_hash_with_bom(line_idx: usize, line: &str, had_bom: bool) -> [u8; 2] {
7090 if had_bom && line_idx == 0 {
7091 let mut with_bom = String::with_capacity(line.len().saturating_add(1));
7092 with_bom.push('\u{FEFF}');
7093 with_bom.push_str(line);
7094 compute_line_hash(line_idx, &with_bom)
7095 } else {
7096 compute_line_hash(line_idx, line)
7097 }
7098}
7099
7100static HASHLINE_TAG_RE: OnceLock<regex::Regex> = OnceLock::new();
7103
7104fn hashline_tag_regex() -> &'static regex::Regex {
7105 HASHLINE_TAG_RE.get_or_init(|| {
7106 regex::Regex::new(r"^[\s>+\-]*(\d+)\s*#\s*([ZPMQVRWSNKTXJBYH]{2})")
7107 .expect("valid hashline regex")
7108 })
7109}
7110
7111fn parse_hashline_tag(ref_str: &str) -> std::result::Result<(usize, [u8; 2]), String> {
7113 let re = hashline_tag_regex();
7114 let caps = re
7115 .captures(ref_str)
7116 .ok_or_else(|| format!("Invalid hashline reference: {ref_str:?}"))?;
7117 let line_num: usize = caps[1]
7118 .parse()
7119 .map_err(|e| format!("Invalid line number in {ref_str:?}: {e}"))?;
7120 if line_num == 0 {
7121 return Err(format!("Line number must be >= 1, got 0 in {ref_str:?}"));
7122 }
7123 let hash_bytes = caps[2].as_bytes();
7124 Ok((line_num, [hash_bytes[0], hash_bytes[1]]))
7125}
7126
7127static HASHLINE_PREFIX_RE: OnceLock<regex::Regex> = OnceLock::new();
7130
7131fn strip_hashline_prefix(line: &str) -> &str {
7132 let re = HASHLINE_PREFIX_RE.get_or_init(|| {
7133 regex::Regex::new(r"^[\s>+\-]*\d+\s*#\s*[ZPMQVRWSNKTXJBYH]{2}\s*:")
7134 .expect("valid hashline prefix regex")
7135 });
7136 re.find(line).map_or(line, |m| &line[m.end()..])
7137}
7138
7139#[derive(Debug, Deserialize)]
7141#[serde(rename_all = "camelCase")]
7142struct HashlineEditInput {
7143 path: String,
7144 edits: Vec<HashlineOp>,
7145}
7146
7147#[derive(Debug, Clone, Deserialize)]
7149#[serde(rename_all = "camelCase")]
7150struct HashlineOp {
7151 op: String,
7153 pos: Option<String>,
7155 end: Option<String>,
7157 lines: Option<serde_json::Value>,
7159}
7160
7161impl HashlineOp {
7162 fn get_lines(&self) -> Vec<String> {
7164 match &self.lines {
7165 None | Some(serde_json::Value::Null) => vec![],
7166 Some(serde_json::Value::String(s)) => {
7167 normalize_to_lf(s).split('\n').map(String::from).collect()
7168 }
7169 Some(serde_json::Value::Array(arr)) => arr
7170 .iter()
7171 .map(|v| match v {
7172 serde_json::Value::String(s) => normalize_to_lf(s),
7173 other => normalize_to_lf(&other.to_string()),
7174 })
7175 .collect(),
7176 Some(other) => vec![normalize_to_lf(&other.to_string())],
7177 }
7178 }
7179}
7180
7181struct ResolvedEdit<'a> {
7183 op: &'a str,
7184 start: usize,
7186 end: usize,
7188 lines: Vec<String>,
7189}
7190
7191pub struct HashlineEditTool {
7192 cwd: PathBuf,
7193}
7194
7195impl HashlineEditTool {
7196 pub fn new(cwd: &Path) -> Self {
7197 Self {
7198 cwd: cwd.to_path_buf(),
7199 }
7200 }
7201}
7202
7203fn validate_line_ref(
7206 ref_str: &str,
7207 file_lines: &[&str],
7208 had_bom: bool,
7209) -> std::result::Result<usize, String> {
7210 let (line_num, expected_hash) = parse_hashline_tag(ref_str)?;
7211 let line_idx = line_num - 1;
7212 if line_idx >= file_lines.len() {
7213 return Err(format!(
7214 "Line {line_num} out of range (file has {} lines)",
7215 file_lines.len()
7216 ));
7217 }
7218 let actual_hash = compute_line_hash_with_bom(line_idx, file_lines[line_idx], had_bom);
7219 if actual_hash != expected_hash {
7220 let tag = format_hashline_tag_with_bom(line_idx, file_lines[line_idx], had_bom);
7221 return Err(format!(
7222 "Hash mismatch at line {line_num}: expected {}#{}{}, actual is {tag}",
7223 line_num, expected_hash[0] as char, expected_hash[1] as char,
7224 ));
7225 }
7226 Ok(line_idx)
7227}
7228
7229fn mismatch_context(file_lines: &[&str], line_idx: usize, context: usize, had_bom: bool) -> String {
7231 let start = line_idx.saturating_sub(context);
7232 let end = (line_idx + context + 1).min(file_lines.len());
7233 let mut out = String::new();
7234 for (i, &file_line) in file_lines.iter().enumerate().take(end).skip(start) {
7235 let tag = format_hashline_tag_with_bom(i, file_line, had_bom);
7236 if i == line_idx {
7237 let _ = writeln!(out, ">>> {tag}:{file_line}");
7238 } else {
7239 let _ = writeln!(out, " {tag}:{file_line}");
7240 }
7241 }
7242 out
7243}
7244
7245fn collect_mismatches(
7247 edits: &[HashlineOp],
7248 file_lines: &[&str],
7249 had_bom: bool,
7250) -> std::result::Result<(), String> {
7251 let mut errors = Vec::new();
7252 for edit in edits {
7253 if let Some(ref pos) = edit.pos {
7254 if let Err(e) = validate_line_ref(pos, file_lines, had_bom) {
7255 if let Ok((line_num, _)) = parse_hashline_tag(pos) {
7257 let idx = (line_num - 1).min(file_lines.len().saturating_sub(1));
7258 errors.push(format!(
7259 "{e}\n{}",
7260 mismatch_context(file_lines, idx, 2, had_bom)
7261 ));
7262 } else {
7263 errors.push(e);
7264 }
7265 }
7266 }
7267 if let Some(ref end) = edit.end {
7268 if let Err(e) = validate_line_ref(end, file_lines, had_bom) {
7269 if let Ok((line_num, _)) = parse_hashline_tag(end) {
7270 let idx = (line_num - 1).min(file_lines.len().saturating_sub(1));
7271 errors.push(format!(
7272 "{e}\n{}",
7273 mismatch_context(file_lines, idx, 2, had_bom)
7274 ));
7275 } else {
7276 errors.push(e);
7277 }
7278 }
7279 }
7280 }
7281 if errors.is_empty() {
7282 Ok(())
7283 } else {
7284 Err(errors.join("\n"))
7285 }
7286}
7287
7288#[derive(Debug, Clone, PartialEq, Eq, Hash)]
7290struct NormalizedEdit {
7291 op: String,
7292 pos_line: Option<usize>,
7293 end_line: Option<usize>,
7294 lines: Vec<String>,
7295}
7296
7297fn op_precedence(op: &str) -> u8 {
7299 match op {
7300 "replace" => 0,
7301 "append" => 1,
7302 "prepend" => 2,
7303 _ => 3,
7304 }
7305}
7306
7307#[async_trait]
7308#[allow(clippy::unnecessary_literal_bound)]
7309impl Tool for HashlineEditTool {
7310 fn name(&self) -> &str {
7311 "hashline_edit"
7312 }
7313 fn label(&self) -> &str {
7314 "hashline edit"
7315 }
7316 fn description(&self) -> &str {
7317 "Apply precise file edits using LINE#HASH tags from a prior read with hashline=true. \
7318 Each edit specifies an op (replace/prepend/append), a pos anchor (\"N#AB\"), an optional \
7319 end anchor for range replace, and replacement lines. Edits are validated against current \
7320 file hashes and applied bottom-up to avoid index invalidation."
7321 }
7322
7323 fn parameters(&self) -> serde_json::Value {
7324 serde_json::json!({
7325 "type": "object",
7326 "properties": {
7327 "path": {
7328 "type": "string",
7329 "description": "Path to the file to edit (relative or absolute)"
7330 },
7331 "edits": {
7332 "type": "array",
7333 "description": "Array of edit operations to apply",
7334 "items": {
7335 "type": "object",
7336 "properties": {
7337 "op": {
7338 "type": "string",
7339 "enum": ["replace", "prepend", "append"],
7340 "description": "Operation type"
7341 },
7342 "pos": {
7343 "type": "string",
7344 "description": "Anchor line reference in LINE#HASH format (e.g. \"5#KJ\")"
7345 },
7346 "end": {
7347 "type": "string",
7348 "description": "End anchor for range replace (inclusive)"
7349 },
7350 "lines": {
7351 "description": "Replacement/insertion content as array of strings, single string, or null for deletion",
7352 "oneOf": [
7353 { "type": "array", "items": { "type": "string" } },
7354 { "type": "string" },
7355 { "type": "null" }
7356 ]
7357 }
7358 },
7359 "required": ["op"]
7360 }
7361 }
7362 },
7363 "required": ["path", "edits"]
7364 })
7365 }
7366
7367 #[allow(clippy::too_many_lines)]
7368 async fn execute(
7369 &self,
7370 _tool_call_id: &str,
7371 input: serde_json::Value,
7372 _on_update: Option<Box<dyn Fn(ToolUpdate) + Send + Sync>>,
7373 ) -> Result<ToolOutput> {
7374 let input: HashlineEditInput = serde_json::from_value(input)
7375 .map_err(|e| Error::tool("hashline_edit", format!("Invalid input: {e}")))?;
7376
7377 if input.edits.is_empty() {
7378 return Err(Error::tool("hashline_edit", "No edits provided"));
7379 }
7380
7381 let resolved = resolve_read_path(&input.path, &self.cwd);
7383 let absolute_path = enforce_cwd_scope(&resolved, &self.cwd, "hashline_edit")?;
7384
7385 let metadata = asupersync::fs::metadata(&absolute_path)
7387 .await
7388 .map_err(|err| {
7389 let message = match err.kind() {
7390 std::io::ErrorKind::NotFound => format!("File not found: {}", input.path),
7391 std::io::ErrorKind::PermissionDenied => {
7392 format!("Permission denied: {}", input.path)
7393 }
7394 _ => format!("Cannot read file metadata: {err}"),
7395 };
7396 Error::tool("hashline_edit", message)
7397 })?;
7398 if !metadata.is_file() {
7399 return Err(Error::tool(
7400 "hashline_edit",
7401 format!("Path {} is not a regular file", absolute_path.display()),
7402 ));
7403 }
7404 if metadata.len() > READ_TOOL_MAX_BYTES {
7405 return Err(Error::tool(
7406 "hashline_edit",
7407 format!(
7408 "File too large ({} bytes, max {} bytes)",
7409 metadata.len(),
7410 READ_TOOL_MAX_BYTES
7411 ),
7412 ));
7413 }
7414
7415 let file = asupersync::fs::File::open(&absolute_path)
7417 .await
7418 .map_err(|e| Error::tool("hashline_edit", format!("Cannot open file: {e}")))?;
7419 let mut raw = Vec::new();
7420 let mut limiter = file.take(READ_TOOL_MAX_BYTES.saturating_add(1));
7421 limiter
7422 .read_to_end(&mut raw)
7423 .await
7424 .map_err(|e| Error::tool("hashline_edit", format!("Cannot read file: {e}")))?;
7425
7426 if raw.len() as u64 > READ_TOOL_MAX_BYTES {
7427 return Err(Error::tool(
7428 "hashline_edit",
7429 format!("File too large (> {READ_TOOL_MAX_BYTES} bytes)"),
7430 ));
7431 }
7432
7433 let raw_content = String::from_utf8(raw).map_err(|_| {
7434 Error::tool(
7435 "hashline_edit",
7436 "File contains invalid UTF-8 characters and cannot be safely edited as text."
7437 .to_string(),
7438 )
7439 })?;
7440
7441 let (content_no_bom, had_bom) = strip_bom(&raw_content);
7442 let original_ending = detect_line_ending(content_no_bom);
7443 let normalized = normalize_to_lf(content_no_bom);
7444 let file_lines: Vec<&str> = normalized.split('\n').collect();
7445
7446 if let Err(e) = collect_mismatches(&input.edits, &file_lines, had_bom) {
7448 return Err(Error::tool(
7449 "hashline_edit",
7450 format!("Hash validation failed — re-read the file to get current tags.\n\n{e}"),
7451 ));
7452 }
7453
7454 let mut seen = std::collections::HashSet::new();
7456 let mut deduped_edits: Vec<&HashlineOp> = Vec::new();
7457 for edit in &input.edits {
7458 let pos_line = edit
7459 .pos
7460 .as_ref()
7461 .and_then(|p| parse_hashline_tag(p).ok())
7462 .map(|(n, _)| n);
7463 let end_line = edit
7464 .end
7465 .as_ref()
7466 .and_then(|e| parse_hashline_tag(e).ok())
7467 .map(|(n, _)| n);
7468 let key = NormalizedEdit {
7469 op: edit.op.clone(),
7470 pos_line,
7471 end_line,
7472 lines: edit.get_lines(),
7473 };
7474 if seen.insert(key) {
7475 deduped_edits.push(edit);
7476 }
7477 }
7478
7479 let mut resolved: Vec<ResolvedEdit<'_>> = Vec::new();
7481 for edit in &deduped_edits {
7482 let replacement_lines: Vec<String> = edit
7483 .get_lines()
7484 .into_iter()
7485 .map(|l| strip_hashline_prefix(&l).to_string())
7486 .collect();
7487
7488 match edit.op.as_str() {
7489 "replace" => {
7490 let start_idx = match &edit.pos {
7491 Some(pos) => validate_line_ref(pos, &file_lines, had_bom)
7492 .map_err(|e| Error::tool("hashline_edit", e))?,
7493 None => {
7494 return Err(Error::tool(
7495 "hashline_edit",
7496 "replace operation requires a pos anchor",
7497 ));
7498 }
7499 };
7500 let end_idx = match &edit.end {
7501 Some(end) => validate_line_ref(end, &file_lines, had_bom)
7502 .map_err(|e| Error::tool("hashline_edit", e))?,
7503 None => start_idx,
7504 };
7505 if end_idx < start_idx {
7506 return Err(Error::tool(
7507 "hashline_edit",
7508 format!(
7509 "End anchor (line {}) is before start anchor (line {})",
7510 end_idx + 1,
7511 start_idx + 1
7512 ),
7513 ));
7514 }
7515 resolved.push(ResolvedEdit {
7516 op: "replace",
7517 start: start_idx,
7518 end: end_idx,
7519 lines: replacement_lines,
7520 });
7521 }
7522 "prepend" => {
7523 let idx = match &edit.pos {
7524 Some(pos) => validate_line_ref(pos, &file_lines, had_bom)
7525 .map_err(|e| Error::tool("hashline_edit", e))?,
7526 None => 0, };
7528 let end_idx = if file_lines == [""] && edit.pos.is_none() {
7529 0 } else {
7531 idx
7532 };
7533 resolved.push(ResolvedEdit {
7534 op: if file_lines == [""] && edit.pos.is_none() {
7535 "replace"
7536 } else {
7537 "prepend"
7538 },
7539 start: idx,
7540 end: end_idx,
7541 lines: replacement_lines,
7542 });
7543 }
7544 "append" => {
7545 let idx = match &edit.pos {
7546 Some(pos) => validate_line_ref(pos, &file_lines, had_bom)
7547 .map_err(|e| Error::tool("hashline_edit", e))?,
7548 None => {
7549 if file_lines.len() > 1 && file_lines.last() == Some(&"") {
7550 file_lines.len() - 2
7551 } else {
7552 file_lines.len().saturating_sub(1)
7553 }
7554 }
7555 };
7556 let end_idx = if file_lines == [""] && edit.pos.is_none() {
7557 0 } else {
7559 idx
7560 };
7561 resolved.push(ResolvedEdit {
7562 op: if file_lines == [""] && edit.pos.is_none() {
7563 "replace"
7564 } else {
7565 "append"
7566 },
7567 start: idx,
7568 end: end_idx,
7569 lines: replacement_lines,
7570 });
7571 }
7572 other => {
7573 return Err(Error::tool(
7574 "hashline_edit",
7575 format!("Unknown op: {other:?}. Must be replace, prepend, or append."),
7576 ));
7577 }
7578 }
7579 }
7580
7581 resolved.sort_by(|a, b| {
7583 b.start
7584 .cmp(&a.start)
7585 .then_with(|| op_precedence(a.op).cmp(&op_precedence(b.op)))
7586 });
7587
7588 for i in 0..resolved.len() {
7590 for j in (i + 1)..resolved.len() {
7591 let a = &resolved[i];
7592 let b = &resolved[j];
7593 if a.start <= b.end && b.start <= a.end {
7594 return Err(Error::tool(
7595 "hashline_edit",
7596 format!(
7597 "Overlapping edits detected: {} at line {}-{} and {} at line {}-{}. \
7598 Please combine overlapping edits into a single operation.",
7599 a.op,
7600 a.start + 1,
7601 a.end + 1,
7602 b.op,
7603 b.start + 1,
7604 b.end + 1
7605 ),
7606 ));
7607 }
7608 }
7609 }
7610
7611 let mut lines: Vec<String> = file_lines.iter().map(|s| (*s).to_string()).collect();
7613 let mut any_change = false;
7614
7615 for edit in &resolved {
7616 match edit.op {
7617 "replace" => {
7618 let existing: Vec<&str> = lines[edit.start..=edit.end]
7620 .iter()
7621 .map(String::as_str)
7622 .collect();
7623 if existing.eq(&edit.lines.iter().map(String::as_str).collect::<Vec<&str>>()) {
7624 continue; }
7626 lines.splice(edit.start..=edit.end, edit.lines.iter().cloned());
7628 any_change = true;
7629 }
7630 "prepend" => {
7631 lines.splice(edit.start..edit.start, edit.lines.iter().cloned());
7633 if !edit.lines.is_empty() {
7634 any_change = true;
7635 }
7636 }
7637 "append" => {
7638 let insert_at = edit.start + 1;
7640 lines.splice(insert_at..insert_at, edit.lines.iter().cloned());
7641 if !edit.lines.is_empty() {
7642 any_change = true;
7643 }
7644 }
7645 _ => {} }
7647 }
7648
7649 if !any_change {
7650 return Err(Error::tool(
7651 "hashline_edit",
7652 format!(
7653 "No changes made to {}. All edits were no-ops (replacement identical to existing content).",
7654 input.path
7655 ),
7656 ));
7657 }
7658
7659 let new_normalized = lines.join("\n");
7661 let new_content = restore_line_endings(&new_normalized, original_ending);
7662 let mut final_content = new_content;
7663 if had_bom {
7664 final_content = format!("\u{FEFF}{final_content}");
7665 }
7666
7667 let absolute_path_clone = absolute_path.clone();
7669 let final_content_bytes = final_content.into_bytes();
7670 asupersync::runtime::spawn_blocking_io(move || {
7671 let original_perms = std::fs::metadata(&absolute_path_clone)
7672 .ok()
7673 .map(|m| m.permissions());
7674 let parent = absolute_path_clone
7675 .parent()
7676 .unwrap_or_else(|| Path::new("."));
7677 let mut temp_file = tempfile::NamedTempFile::new_in(parent)?;
7678
7679 temp_file.as_file_mut().write_all(&final_content_bytes)?;
7680 temp_file.as_file_mut().sync_all()?;
7681
7682 if let Some(perms) = original_perms {
7683 let _ = temp_file.as_file().set_permissions(perms);
7684 } else {
7685 #[cfg(unix)]
7686 {
7687 use std::os::unix::fs::PermissionsExt;
7688 let _ = temp_file
7689 .as_file()
7690 .set_permissions(std::fs::Permissions::from_mode(0o644));
7691 }
7692 }
7693
7694 temp_file
7695 .persist(&absolute_path_clone)
7696 .map_err(|e| e.error)?;
7697 Ok(())
7698 })
7699 .await
7700 .map_err(|e| Error::tool("hashline_edit", format!("Failed to write file: {e}")))?;
7701
7702 let (diff, first_changed_line) = generate_diff_string(&normalized, &new_normalized);
7704 let mut details = serde_json::Map::new();
7705 details.insert("diff".to_string(), serde_json::Value::String(diff));
7706 if let Some(line) = first_changed_line {
7707 details.insert(
7708 "firstChangedLine".to_string(),
7709 serde_json::Value::Number(serde_json::Number::from(line)),
7710 );
7711 }
7712
7713 Ok(ToolOutput {
7714 content: vec![ContentBlock::Text(TextContent::new(format!(
7715 "Successfully applied hashline edits to {}.",
7716 input.path
7717 )))],
7718 details: Some(serde_json::Value::Object(details)),
7719 is_error: false,
7720 })
7721 }
7722}
7723
7724#[cfg(test)]
7729mod tests {
7730 use super::*;
7731 use proptest::prelude::*;
7732 #[cfg(target_os = "linux")]
7733 use std::time::Duration;
7734
7735 #[test]
7736 fn test_truncate_head() {
7737 let content = "line1\nline2\nline3\nline4\nline5".to_string();
7738 let result = truncate_head(content, 3, 1000);
7739
7740 assert_eq!(result.content, "line1\nline2\nline3\n");
7741 assert!(result.truncated);
7742 assert_eq!(result.truncated_by, Some(TruncatedBy::Lines));
7743 assert_eq!(result.total_lines, 5);
7744 assert_eq!(result.output_lines, 3);
7745 }
7746
7747 #[test]
7748 fn test_truncate_tail() {
7749 let content = "line1\nline2\nline3\nline4\nline5".to_string();
7750 let result = truncate_tail(content, 3, 1000);
7751
7752 assert_eq!(result.content, "line3\nline4\nline5");
7753 assert!(result.truncated);
7754 assert_eq!(result.truncated_by, Some(TruncatedBy::Lines));
7755 assert_eq!(result.total_lines, 5);
7756 assert_eq!(result.output_lines, 3);
7757 }
7758
7759 fn assert_same_head_truncation(actual: &TruncationResult, expected: &TruncationResult) {
7760 assert_eq!(actual.content, expected.content);
7761 assert_eq!(actual.truncated, expected.truncated);
7762 assert_eq!(actual.truncated_by, expected.truncated_by);
7763 assert_eq!(actual.total_lines, expected.total_lines);
7764 assert_eq!(actual.total_bytes, expected.total_bytes);
7765 assert_eq!(actual.output_lines, expected.output_lines);
7766 assert_eq!(actual.output_bytes, expected.output_bytes);
7767 assert_eq!(actual.last_line_partial, expected.last_line_partial);
7768 assert_eq!(
7769 actual.first_line_exceeds_limit,
7770 expected.first_line_exceeds_limit
7771 );
7772 assert_eq!(actual.max_lines, expected.max_lines);
7773 assert_eq!(actual.max_bytes, expected.max_bytes);
7774 }
7775
7776 fn write_lines_with_builder(lines: &[&str], max_bytes: usize) -> TruncationResult {
7777 let mut writer = HeadTruncatingLineWriter::new(max_bytes);
7778 for line in lines {
7779 writer.push_line(line);
7780 }
7781 writer.finish()
7782 }
7783
7784 #[test]
7785 fn head_truncating_line_writer_matches_join_without_truncation() {
7786 let lines = ["alpha", "beta", "gamma"];
7787 let expected = truncate_head(lines.join("\n"), usize::MAX, 1000);
7788 let actual = write_lines_with_builder(&lines, 1000);
7789
7790 assert_same_head_truncation(&actual, &expected);
7791 }
7792
7793 #[test]
7794 fn head_truncating_line_writer_matches_join_at_byte_boundary() {
7795 let lines = ["alpha", "beta", "gamma"];
7796 let expected = truncate_head(lines.join("\n"), usize::MAX, 8);
7797 let actual = write_lines_with_builder(&lines, 8);
7798
7799 assert_same_head_truncation(&actual, &expected);
7800 assert_eq!(actual.content, "alpha\nbe");
7801 }
7802
7803 #[test]
7804 fn head_truncating_line_writer_preserves_utf8_boundary_and_order() {
7805 let lines = ["alpha", "βeta", "gamma"];
7806 let expected = truncate_head(lines.join("\n"), usize::MAX, 8);
7807 let actual = write_lines_with_builder(&lines, 8);
7808
7809 assert_same_head_truncation(&actual, &expected);
7810 assert_eq!(actual.content, "alpha\nβ");
7811 }
7812
7813 fn first_text(output: &ToolOutput) -> &str {
7814 output
7815 .content
7816 .first()
7817 .and_then(|block| match block {
7818 ContentBlock::Text(text) => Some(text.text.as_str()),
7819 _ => None,
7820 })
7821 .unwrap_or("")
7822 }
7823
7824 fn artifact_json(details: Option<&serde_json::Value>) -> &serde_json::Value {
7825 details
7826 .and_then(|value| value.get("artifact"))
7827 .expect("artifact details")
7828 }
7829
7830 fn artifact_str_field<'a>(artifact: &'a serde_json::Value, field: &str) -> &'a str {
7831 artifact
7832 .get(field)
7833 .and_then(serde_json::Value::as_str)
7834 .unwrap_or("")
7835 }
7836
7837 #[test]
7838 fn tool_output_artifact_respects_spill_threshold() {
7839 let tmp = tempfile::tempdir().expect("artifact root");
7840 let mut output = "small preview".to_string();
7841 let mut details = None;
7842 let spilled = attach_text_artifact_if_needed_at_root(
7843 tmp.path(),
7844 &mut output,
7845 &mut details,
7846 "read",
7847 "call-small",
7848 "selectedTextWindow",
7849 "small body",
7850 );
7851
7852 assert!(!spilled);
7853 assert_eq!(output, "small preview");
7854 assert!(details.is_none());
7855 }
7856
7857 #[test]
7858 fn tool_output_artifact_writes_content_addressed_text_and_metadata()
7859 -> std::result::Result<(), Box<dyn std::error::Error>> {
7860 let tmp = tempfile::tempdir().expect("artifact root");
7861 let full = "a".repeat(TOOL_OUTPUT_ARTIFACT_THRESHOLD_BYTES + 1);
7862 let mut output = "bounded preview".to_string();
7863 let mut details = None;
7864 let _session_guard =
7865 register_tool_output_artifact_session("call/text:1", "session/artifacts:one");
7866 let spilled = attach_text_artifact_if_needed_at_root(
7867 tmp.path(),
7868 &mut output,
7869 &mut details,
7870 "read",
7871 "call/text:1",
7872 "selectedTextWindow",
7873 &full,
7874 );
7875
7876 assert!(spilled);
7877 assert!(output.contains("Full tool output artifact:"));
7878 let artifact = artifact_json(details.as_ref());
7879 assert_eq!(artifact["schema"], TOOL_OUTPUT_ARTIFACT_SCHEMA_V1);
7880 assert_eq!(artifact["toolName"], "read");
7881 assert_eq!(artifact["sourceKind"], "selectedTextWindow");
7882 assert_eq!(artifact["sessionId"], "session/artifacts:one");
7883 assert_eq!(
7884 artifact["byteCount"].as_u64().unwrap(),
7885 u64::try_from(full.len()).unwrap()
7886 );
7887
7888 let path_value = artifact_str_field(artifact, "path");
7889 let metadata_path_value = artifact_str_field(artifact, "metadataPath");
7890 assert!(!path_value.is_empty(), "artifact path must be a string");
7891 assert!(
7892 !metadata_path_value.is_empty(),
7893 "artifact metadataPath must be a string"
7894 );
7895 let path = PathBuf::from(path_value);
7896 let metadata_path = PathBuf::from(metadata_path_value);
7897 assert!(path.starts_with(tmp.path().join("session_artifacts_one").join("call_text_1")));
7898 assert_eq!(std::fs::read_to_string(path)?, full);
7899 let metadata_bytes = std::fs::read(metadata_path)?;
7900 let metadata: serde_json::Value = serde_json::from_slice(&metadata_bytes)?;
7901 assert_eq!(metadata["sha256"], artifact["sha256"]);
7902 assert_eq!(
7903 metadata["retentionClass"],
7904 TOOL_OUTPUT_ARTIFACT_RETENTION_CLASS
7905 );
7906 assert_eq!(
7907 metadata["spilloverReason"],
7908 TOOL_OUTPUT_ARTIFACT_SPILLOVER_REASON
7909 );
7910 assert_eq!(metadata["safeDeleteCandidate"], true);
7911 assert_eq!(
7912 metadata["redactionSummary"]["policy"],
7913 TOOL_OUTPUT_ARTIFACT_REDACTION_POLICY_V1
7914 );
7915 assert_eq!(metadata["redactionSummary"]["status"], "clean");
7916 assert_eq!(metadata["redactionSummary"]["rawSecretBytesEmitted"], 0);
7917 Ok(())
7918 }
7919
7920 #[test]
7921 fn tool_output_artifact_redacts_sensitive_text_before_persisting()
7922 -> std::result::Result<(), Box<dyn std::error::Error>> {
7923 let tmp = tempfile::tempdir().expect("artifact root");
7924 let leaked_token = "sk-redactionfixture1234567890";
7925 let leaked_bearer = "ghp_redactionfixture1234567890";
7926 let full = format!(
7927 "API_TOKEN={leaked_token}\nAuthorization: Bearer {leaked_bearer}\n{}",
7928 "x".repeat(TOOL_OUTPUT_ARTIFACT_THRESHOLD_BYTES + 1)
7929 );
7930 let mut output = "bounded preview".to_string();
7931 let mut details = None;
7932
7933 let spilled = attach_text_artifact_if_needed_at_root(
7934 tmp.path(),
7935 &mut output,
7936 &mut details,
7937 "read",
7938 "call-secret",
7939 "selectedTextWindow",
7940 &full,
7941 );
7942
7943 assert!(spilled);
7944 let artifact = artifact_json(details.as_ref());
7945 let path = PathBuf::from(artifact_str_field(artifact, "path"));
7946 let metadata_path = PathBuf::from(artifact_str_field(artifact, "metadataPath"));
7947 let persisted = std::fs::read_to_string(path)?;
7948 let metadata: serde_json::Value = serde_json::from_slice(&std::fs::read(metadata_path)?)?;
7949
7950 assert!(!persisted.contains(leaked_token));
7951 assert!(!persisted.contains(leaked_bearer));
7952 assert!(persisted.contains("API_TOKEN=[REDACTED]"));
7953 assert_eq!(artifact["redactionSummary"]["status"], "redacted");
7954 assert_eq!(artifact["redactionSummary"]["rawSecretBytesEmitted"], 0);
7955 assert_eq!(metadata["redactionSummary"], artifact["redactionSummary"]);
7956 let fields = artifact["redactionSummary"]["fields"]
7957 .as_array()
7958 .expect("redaction fields");
7959 assert!(fields.iter().any(|field| field == "api_token"));
7960 assert!(fields.iter().any(|field| field == "authorization"));
7961 Ok(())
7962 }
7963
7964 #[test]
7965 fn tool_output_artifact_marks_binaryish_payloads_in_lifecycle_manifest() {
7966 let tmp = tempfile::tempdir().expect("artifact root");
7967 let full = format!(
7968 "{}\0{}",
7969 "z".repeat(TOOL_OUTPUT_ARTIFACT_THRESHOLD_BYTES / 2),
7970 "z".repeat(TOOL_OUTPUT_ARTIFACT_THRESHOLD_BYTES / 2 + 2)
7971 );
7972 let mut output = "bounded preview".to_string();
7973 let mut details = None;
7974
7975 let spilled = attach_text_artifact_if_needed_at_root(
7976 tmp.path(),
7977 &mut output,
7978 &mut details,
7979 "read",
7980 "call-binaryish",
7981 "selectedTextWindow",
7982 &full,
7983 );
7984
7985 assert!(spilled);
7986 let artifact = artifact_json(details.as_ref());
7987 assert_eq!(artifact["redactionSummary"]["binarySuspect"], true);
7988 assert_eq!(artifact["redactionSummary"]["rawSecretBytesEmitted"], 0);
7989 assert_eq!(artifact["safeDeleteCandidate"], true);
7990 }
7991
7992 #[test]
7993 fn tool_output_artifact_failure_records_degraded_preview() {
7994 let tmp = tempfile::tempdir().expect("artifact root parent");
7995 let root_file = tmp.path().join("not-a-directory");
7996 std::fs::write(&root_file, "not a directory").expect("root file");
7997 let full = "b".repeat(TOOL_OUTPUT_ARTIFACT_THRESHOLD_BYTES + 1);
7998 let mut output = "bounded preview".to_string();
7999 let mut details = None;
8000
8001 let spilled = attach_text_artifact_if_needed_at_root(
8002 &root_file,
8003 &mut output,
8004 &mut details,
8005 "read",
8006 "call-fail",
8007 "selectedTextWindow",
8008 &full,
8009 );
8010
8011 assert!(!spilled);
8012 assert!(output.contains("Tool output artifact persistence failed"));
8013 assert!(
8014 details
8015 .as_ref()
8016 .and_then(|value| value.get("artifactError"))
8017 .is_some()
8018 );
8019 }
8020
8021 #[test]
8022 fn read_tool_spills_oversized_selected_text_window_to_artifact() {
8023 asupersync::test_utils::run_test(|| async {
8024 let tmp = tempfile::tempdir().expect("workspace");
8025 let artifact_root = tempfile::tempdir().expect("artifact root");
8026
8027 let body = "r".repeat(TOOL_OUTPUT_ARTIFACT_THRESHOLD_BYTES + 8);
8028 std::fs::write(tmp.path().join("large.txt"), &body).expect("large file");
8029 let read_tool = ReadTool::with_artifact_root(tmp.path(), artifact_root.path());
8030 let output = read_tool
8031 .execute(
8032 "read-artifact-call",
8033 serde_json::json!({ "path": "large.txt" }),
8034 None,
8035 )
8036 .await
8037 .expect("read large file");
8038
8039 assert!(first_text(&output).contains("Full tool output artifact:"));
8040 let artifact = artifact_json(output.details.as_ref());
8041 assert_eq!(artifact["toolName"], "read");
8042 assert_eq!(artifact["sourceKind"], "selectedTextWindow");
8043 let path_value = artifact_str_field(artifact, "path");
8044 assert!(!path_value.is_empty(), "artifact path must be a string");
8045 let path = PathBuf::from(path_value);
8046 let spilled = match std::fs::read_to_string(&path) {
8047 Ok(spilled) => spilled,
8048 Err(err) => {
8049 assert!(false, "read spilled artifact {}: {err}", path.display());
8050 return;
8051 }
8052 };
8053 let prefix = " 1→";
8054 assert_eq!(spilled.len(), prefix.len() + DEFAULT_MAX_BYTES);
8055 assert_eq!(
8056 artifact["byteCount"].as_u64().unwrap(),
8057 u64::try_from(spilled.len()).unwrap()
8058 );
8059 assert!(spilled.starts_with(prefix));
8060 assert!(spilled[prefix.len()..].bytes().all(|byte| byte == b'r'));
8061 assert_eq!(
8062 artifact["retentionClass"],
8063 TOOL_OUTPUT_ARTIFACT_RETENTION_CLASS
8064 );
8065 assert_eq!(
8066 artifact["spilloverReason"],
8067 TOOL_OUTPUT_ARTIFACT_SPILLOVER_REASON
8068 );
8069 assert_eq!(artifact["safeDeleteCandidate"], true);
8070 });
8071 }
8072
8073 #[test]
8074 fn bash_tool_spills_truncated_full_output_to_artifact() {
8075 asupersync::test_utils::run_test(|| async {
8076 if !Path::new("/dev/zero").exists() {
8077 return;
8078 }
8079
8080 let tmp = tempfile::tempdir().expect("workspace");
8081 let artifact_root = tempfile::tempdir().expect("artifact root");
8082
8083 let bash_tool = BashTool::with_artifact_root(tmp.path(), artifact_root.path());
8084 let output = bash_tool
8085 .execute(
8086 "bash-artifact-call",
8087 serde_json::json!({
8088 "command": "head -c 1001000 /dev/zero | tr '\\0' x",
8089 "timeout": 10
8090 }),
8091 None,
8092 )
8093 .await
8094 .expect("bash large output");
8095
8096 assert!(first_text(&output).contains("Full tool output artifact:"));
8097 let artifact = artifact_json(output.details.as_ref());
8098 assert_eq!(artifact["toolName"], "bash");
8099 assert_eq!(artifact["sourceKind"], "fullCommandOutput");
8100 let path = PathBuf::from(artifact_str_field(artifact, "path"));
8101 assert_eq!(std::fs::metadata(path).unwrap().len(), 1_001_000);
8102 assert_eq!(artifact["redactionSummary"]["status"], "clean");
8103 assert_eq!(artifact["safeDeleteCandidate"], true);
8104 });
8105 }
8106
8107 #[test]
8108 fn bash_tool_redacts_secret_like_full_output_artifacts() {
8109 asupersync::test_utils::run_test(|| async {
8110 if !Path::new("/dev/zero").exists() {
8111 return;
8112 }
8113
8114 let tmp = tempfile::tempdir().expect("workspace");
8115 let artifact_root = tempfile::tempdir().expect("artifact root");
8116 let leaked_token = "sk-bashredactionfixture1234567890";
8117
8118 let bash_tool = BashTool::with_artifact_root(tmp.path(), artifact_root.path());
8119 let output = bash_tool
8120 .execute(
8121 "bash-secret-artifact-call",
8122 serde_json::json!({
8123 "command": format!("printf 'API_TOKEN={leaked_token}\\n'; head -c 1001000 /dev/zero | tr '\\0' x"),
8124 "timeout": 10
8125 }),
8126 None,
8127 )
8128 .await
8129 .expect("bash large output");
8130
8131 assert!(first_text(&output).contains("Full tool output artifact:"));
8132 let artifact = artifact_json(output.details.as_ref());
8133 assert_eq!(artifact["toolName"], "bash");
8134 assert_eq!(artifact["redactionSummary"]["status"], "redacted");
8135 assert_eq!(artifact["redactionSummary"]["rawSecretBytesEmitted"], 0);
8136 let path = PathBuf::from(artifact_str_field(artifact, "path"));
8137 let persisted = std::fs::read_to_string(path).expect("read redacted bash artifact");
8138 assert!(!persisted.contains(leaked_token));
8139 assert!(persisted.contains("API_TOKEN=[REDACTED]"));
8140 });
8141 }
8142
8143 #[test]
8144 fn grep_tool_spills_large_search_results_with_lifecycle_manifest() {
8145 asupersync::test_utils::run_test(|| async {
8146 if !rg_available() {
8147 return;
8148 }
8149
8150 let tmp = tempfile::tempdir().expect("workspace");
8151 let artifact_root = tempfile::tempdir().expect("artifact root");
8152 let mut body = String::new();
8153 let suffix = "g".repeat(560);
8154 for idx in 0..2200 {
8155 let _ = writeln!(body, "target {idx:04} {suffix}");
8156 }
8157 std::fs::write(tmp.path().join("large-grep.txt"), body).expect("write grep fixture");
8158
8159 let grep_tool = GrepTool::with_artifact_root(tmp.path(), artifact_root.path());
8160 let output = grep_tool
8161 .execute(
8162 "grep-artifact-call",
8163 serde_json::json!({
8164 "pattern": "target",
8165 "path": "large-grep.txt",
8166 "literal": true,
8167 "limit": 2200
8168 }),
8169 None,
8170 )
8171 .await
8172 .expect("grep large output");
8173
8174 assert!(first_text(&output).contains("Full tool output artifact:"));
8175 let artifact = artifact_json(output.details.as_ref());
8176 assert_eq!(artifact["toolName"], "grep");
8177 assert_eq!(artifact["sourceKind"], "searchResults");
8178 assert_eq!(
8179 artifact["retentionClass"],
8180 TOOL_OUTPUT_ARTIFACT_RETENTION_CLASS
8181 );
8182 assert_eq!(artifact["safeDeleteCandidate"], true);
8183 assert_eq!(artifact["redactionSummary"]["status"], "clean");
8184 let path = PathBuf::from(artifact_str_field(artifact, "path"));
8185 let persisted = std::fs::read_to_string(path).expect("read grep artifact");
8186 assert!(persisted.contains("large-grep.txt:1: target 0000"));
8187 assert!(
8188 artifact["byteCount"].as_u64().unwrap()
8189 > u64::try_from(TOOL_OUTPUT_ARTIFACT_THRESHOLD_BYTES).unwrap()
8190 );
8191 });
8192 }
8193
8194 #[test]
8195 fn read_tool_denied_path_does_not_emit_lifecycle_artifact() {
8196 asupersync::test_utils::run_test(|| async {
8197 let cwd = tempfile::tempdir().expect("workspace");
8198 let outside = tempfile::tempdir().expect("outside");
8199 let artifact_root = tempfile::tempdir().expect("artifact root");
8200 let outside_path = outside.path().join("secret.txt");
8201 std::fs::write(&outside_path, "API_TOKEN=sk-deniedpathfixture1234567890")
8202 .expect("outside secret");
8203
8204 let read_tool = ReadTool::with_artifact_root(cwd.path(), artifact_root.path());
8205 let err = read_tool
8206 .execute(
8207 "read-denied-artifact-call",
8208 serde_json::json!({ "path": outside_path }),
8209 None,
8210 )
8211 .await
8212 .expect_err("outside read should be denied");
8213
8214 assert!(
8215 err.to_string()
8216 .contains("Cannot read outside the working directory or agent dir")
8217 );
8218 let mut entries = std::fs::read_dir(artifact_root.path()).expect("artifact root");
8219 assert!(
8220 entries.next().is_none(),
8221 "denied reads must not write artifacts"
8222 );
8223 });
8224 }
8225
8226 #[test]
8227 fn ls_tool_spills_oversized_directory_listing_to_artifact() {
8228 asupersync::test_utils::run_test(|| async {
8229 let tmp = tempfile::tempdir().expect("workspace");
8230 let artifact_root = tempfile::tempdir().expect("artifact root");
8231 let suffix = "x".repeat(224);
8232 for i in 0..4_500 {
8233 let name = format!("entry-{i:04}-{suffix}.txt");
8234 std::fs::write(tmp.path().join(name), "").expect("write listing fixture");
8235 }
8236
8237 let ls_tool = LsTool::with_artifact_root(tmp.path(), artifact_root.path());
8238 let output = ls_tool
8239 .execute(
8240 "ls-artifact-call",
8241 serde_json::json!({ "path": ".", "limit": 4500 }),
8242 None,
8243 )
8244 .await
8245 .expect("ls large directory");
8246
8247 assert!(first_text(&output).contains("Full tool output artifact:"));
8248 let artifact = artifact_json(output.details.as_ref());
8249 assert_eq!(artifact["toolName"], "ls");
8250 assert_eq!(artifact["sourceKind"], "directoryEntries");
8251 assert!(
8252 artifact["byteCount"].as_u64().unwrap()
8253 > u64::try_from(TOOL_OUTPUT_ARTIFACT_THRESHOLD_BYTES).unwrap()
8254 );
8255 let path = PathBuf::from(artifact_str_field(artifact, "path"));
8256 assert!(
8257 std::fs::read_to_string(path)
8258 .unwrap()
8259 .contains("entry-0000-")
8260 );
8261 });
8262 }
8263
8264 async fn assert_read_cache_hit_and_stale(tmp: &Path) {
8265 let note = tmp.join("note.txt");
8266 std::fs::write(¬e, "alpha\n").expect("write note");
8267
8268 let read_tool = ReadTool::new(tmp);
8269 let read_input = serde_json::json!({ "path": "note.txt" });
8270 let first = read_tool
8271 .execute("read-1", read_input.clone(), None)
8272 .await
8273 .expect("first read");
8274 assert!(first_text(&first).contains("alpha"));
8275
8276 let hits_before = tool_output_cache_stats_for_tests().hits;
8277 let second = read_tool
8278 .execute("read-2", read_input.clone(), None)
8279 .await
8280 .expect("cached read");
8281 assert_eq!(first_text(&first), first_text(&second));
8282 assert!(tool_output_cache_stats_for_tests().hits > hits_before);
8283
8284 let invalidations_before = tool_output_cache_stats_for_tests().invalidations;
8285 std::fs::write(¬e, "beta\n").expect("rewrite note");
8286 let third = read_tool
8287 .execute("read-3", read_input.clone(), None)
8288 .await
8289 .expect("invalidated read");
8290 assert!(first_text(&third).contains("beta"));
8291 assert!(!first_text(&third).contains("alpha"));
8292 assert!(tool_output_cache_stats_for_tests().invalidations > invalidations_before);
8293 }
8294
8295 async fn assert_ls_cache_hit_and_stale(tmp: &Path) {
8296 let ls_tool = LsTool::new(tmp);
8297 let ls_input = serde_json::json!({ "path": "." });
8298 let ls_first = ls_tool
8299 .execute("ls-1", ls_input.clone(), None)
8300 .await
8301 .expect("first ls");
8302 assert!(first_text(&ls_first).contains("note.txt"));
8303
8304 let hits_before = tool_output_cache_stats_for_tests().hits;
8305 let ls_second = ls_tool
8306 .execute("ls-2", ls_input.clone(), None)
8307 .await
8308 .expect("cached ls");
8309 assert_eq!(first_text(&ls_first), first_text(&ls_second));
8310 assert!(tool_output_cache_stats_for_tests().hits > hits_before);
8311
8312 let invalidations_before = tool_output_cache_stats_for_tests().invalidations;
8313 std::fs::write(tmp.join("new.txt"), "new\n").expect("write new file");
8314 let ls_third = ls_tool
8315 .execute("ls-3", ls_input.clone(), None)
8316 .await
8317 .expect("invalidated ls");
8318 assert!(first_text(&ls_third).contains("new.txt"));
8319 assert!(tool_output_cache_stats_for_tests().invalidations > invalidations_before);
8320 }
8321
8322 async fn assert_grep_cache_hit_and_stale_when_available(tmp: &Path) {
8323 if find_rg_binary().is_none() {
8324 return;
8325 }
8326
8327 let grep_tool = GrepTool::new(tmp);
8328 let grep_input = serde_json::json!({ "pattern": "needle", "path": "." });
8329 std::fs::write(tmp.join("a.txt"), "needle\n").expect("write grep file");
8330
8331 let grep_first = grep_tool
8332 .execute("grep-1", grep_input.clone(), None)
8333 .await
8334 .expect("first grep");
8335 assert!(first_text(&grep_first).contains("a.txt"));
8336
8337 let hits_before = tool_output_cache_stats_for_tests().hits;
8338 let grep_second = grep_tool
8339 .execute("grep-2", grep_input.clone(), None)
8340 .await
8341 .expect("cached grep");
8342 assert_eq!(first_text(&grep_first), first_text(&grep_second));
8343 assert!(tool_output_cache_stats_for_tests().hits > hits_before);
8344
8345 let invalidations_before = tool_output_cache_stats_for_tests().invalidations;
8346 std::fs::write(tmp.join("b.txt"), "needle\n").expect("write new match");
8347 let grep_third = grep_tool
8348 .execute("grep-3", grep_input.clone(), None)
8349 .await
8350 .expect("invalidated grep");
8351 assert!(first_text(&grep_third).contains("b.txt"));
8352 assert!(tool_output_cache_stats_for_tests().invalidations > invalidations_before);
8353 }
8354
8355 async fn assert_find_cache_hit_and_stale_when_available(tmp: &Path) {
8356 if find_fd_binary().is_none() {
8357 return;
8358 }
8359
8360 let find_tool = FindTool::new(tmp);
8361 let find_input = serde_json::json!({ "pattern": "*find*.txt", "path": "." });
8362 std::fs::write(tmp.join("find-a.txt"), "find\n").expect("write first find file");
8363
8364 let find_first = find_tool
8365 .execute("find-1", find_input.clone(), None)
8366 .await
8367 .expect("first find");
8368 assert!(first_text(&find_first).contains("find-a.txt"));
8369
8370 let hits_before = tool_output_cache_stats_for_tests().hits;
8371 let find_second = find_tool
8372 .execute("find-2", find_input.clone(), None)
8373 .await
8374 .expect("cached find");
8375 assert_eq!(first_text(&find_first), first_text(&find_second));
8376 assert!(tool_output_cache_stats_for_tests().hits > hits_before);
8377
8378 let invalidations_before = tool_output_cache_stats_for_tests().invalidations;
8379 std::fs::write(tmp.join("find-b.txt"), "find\n").expect("write second find file");
8380 let find_third = find_tool
8381 .execute("find-3", find_input.clone(), None)
8382 .await
8383 .expect("invalidated find");
8384 assert!(first_text(&find_third).contains("find-b.txt"));
8385 assert!(tool_output_cache_stats_for_tests().invalidations > invalidations_before);
8386 }
8387
8388 async fn assert_side_effect_tools_remain_uncached(tmp: &Path) {
8389 let side_effect_stats_before = tool_output_cache_stats_for_tests();
8390 let write_tool = WriteTool::new(tmp);
8391 write_tool
8392 .execute(
8393 "write-1",
8394 serde_json::json!({
8395 "path": "side-effect.txt",
8396 "content": "one\n"
8397 }),
8398 None,
8399 )
8400 .await
8401 .expect("write side-effect file");
8402
8403 let edit_tool = EditTool::new(tmp);
8404 edit_tool
8405 .execute(
8406 "edit-1",
8407 serde_json::json!({
8408 "path": "side-effect.txt",
8409 "oldText": "one",
8410 "newText": "two"
8411 }),
8412 None,
8413 )
8414 .await
8415 .expect("edit side-effect file");
8416
8417 let bash_tool = BashTool::new(tmp);
8418 bash_tool
8419 .execute(
8420 "bash-1",
8421 serde_json::json!({
8422 "command": "printf 'cache-uncached\\n'",
8423 "timeout": 5
8424 }),
8425 None,
8426 )
8427 .await
8428 .expect("run uncached bash");
8429
8430 let side_effect_stats_after = tool_output_cache_stats_for_tests();
8431 assert_eq!(
8432 (
8433 side_effect_stats_after.side_effect_accesses,
8434 side_effect_stats_after.side_effect_insert_attempts
8435 ),
8436 (
8437 side_effect_stats_before.side_effect_accesses,
8438 side_effect_stats_before.side_effect_insert_attempts
8439 ),
8440 "write, edit, and bash must not consult or populate the read-only output cache"
8441 );
8442 }
8443
8444 #[test]
8445 fn tool_output_cache_reuses_and_invalidates_read_only_tool_outputs() {
8446 asupersync::test_utils::run_test(|| async {
8447 reset_tool_output_cache_for_tests();
8448
8449 let tmp = tempfile::tempdir().expect("create temp dir");
8450 assert_read_cache_hit_and_stale(tmp.path()).await;
8451 assert_ls_cache_hit_and_stale(tmp.path()).await;
8452 assert_grep_cache_hit_and_stale_when_available(tmp.path()).await;
8453 assert_find_cache_hit_and_stale_when_available(tmp.path()).await;
8454 assert_side_effect_tools_remain_uncached(tmp.path()).await;
8455 });
8456 }
8457
8458 #[test]
8459 fn tool_output_context_cache_evidence_jsonl_covers_required_decisions()
8460 -> std::result::Result<(), String> {
8461 let evidence = include_str!("../docs/evidence/tool-output-context-cache.jsonl");
8462 let mut saw_read_hit = false;
8463 let mut saw_grep_stale = false;
8464 let mut saw_find_stale = false;
8465 let mut saw_ls_stale = false;
8466 let mut saw_write_uncached = false;
8467 let mut saw_edit_uncached = false;
8468 let mut saw_bash_uncached = false;
8469
8470 for (line_number, line) in evidence.lines().enumerate() {
8471 if line.trim().is_empty() {
8472 continue;
8473 }
8474
8475 let event: serde_json::Value = serde_json::from_str(line).map_err(|err| {
8476 format!(
8477 "invalid context-cache JSONL at line {}: {err}",
8478 line_number + 1
8479 )
8480 })?;
8481 assert_eq!(
8482 event.get("schema").and_then(serde_json::Value::as_str),
8483 Some("pi.tool_output_context_cache.evidence.v1")
8484 );
8485 assert_eq!(
8486 event.get("bead").and_then(serde_json::Value::as_str),
8487 Some("bd-dklqn.1")
8488 );
8489 let related_beads = event
8490 .get("related_beads")
8491 .and_then(serde_json::Value::as_array)
8492 .ok_or_else(|| format!("missing related_beads at line {}", line_number + 1))?;
8493 assert!(
8494 related_beads
8495 .iter()
8496 .any(|bead| bead.as_str() == Some("bd-dklqn.2")),
8497 "evidence line {} must cover bd-dklqn.2",
8498 line_number + 1
8499 );
8500
8501 let tool = event
8502 .get("tool")
8503 .and_then(serde_json::Value::as_str)
8504 .expect("tool");
8505 let outcome = event
8506 .get("outcome")
8507 .and_then(serde_json::Value::as_str)
8508 .expect("outcome");
8509 let reason = event
8510 .get("reason")
8511 .and_then(serde_json::Value::as_str)
8512 .expect("reason");
8513
8514 match (tool, outcome, reason) {
8515 ("read", "hit", "unchanged_file_fingerprint") => saw_read_hit = true,
8516 ("grep", "stale", "recursive_directory_fingerprint_changed") => {
8517 saw_grep_stale = true;
8518 }
8519 ("find", "stale", "recursive_directory_fingerprint_changed") => {
8520 saw_find_stale = true;
8521 }
8522 ("ls", "stale", "directory_entry_fingerprint_changed") => saw_ls_stale = true,
8523 ("write", "uncached", "write_effect_tool") => saw_write_uncached = true,
8524 ("edit", "uncached", "write_effect_tool") => saw_edit_uncached = true,
8525 ("bash", "uncached", "process_effect_tool") => saw_bash_uncached = true,
8526 _ => {}
8527 }
8528 }
8529
8530 assert!(saw_read_hit, "evidence must include a read cache hit");
8531 assert!(saw_grep_stale, "evidence must include grep stale bypass");
8532 assert!(saw_find_stale, "evidence must include find stale bypass");
8533 assert!(saw_ls_stale, "evidence must include ls stale bypass");
8534 assert!(saw_write_uncached, "evidence must include write uncached");
8535 assert!(saw_edit_uncached, "evidence must include edit uncached");
8536 assert!(saw_bash_uncached, "evidence must include bash uncached");
8537 Ok(())
8538 }
8539
8540 #[test]
8541 fn test_truncate_tail_zero_lines_returns_empty_output() {
8542 let result = truncate_tail("line1\nline2".to_string(), 0, 1000);
8543
8544 assert!(result.truncated);
8545 assert_eq!(result.truncated_by, Some(TruncatedBy::Lines));
8546 assert_eq!(result.output_lines, 0);
8547 assert_eq!(result.output_bytes, 0);
8548 assert!(result.content.is_empty());
8549 }
8550
8551 #[test]
8552 fn test_line_count_from_newline_count_matches_trailing_newline_semantics() {
8553 assert_eq!(line_count_from_newline_count(0, 0, false), 0);
8554 assert_eq!(line_count_from_newline_count(2, 1, true), 1);
8555 assert_eq!(line_count_from_newline_count(1, 0, false), 1);
8556 assert_eq!(line_count_from_newline_count(3, 1, false), 2);
8557 }
8558
8559 #[test]
8560 fn test_rg_match_requires_path_and_line_number() {
8561 let mut matches = Vec::new();
8562 let mut match_count = 0usize;
8563 let mut match_limit_reached = false;
8564 let scan_limit = 1;
8565
8566 let missing_line =
8567 Ok(r#"{"type":"match","data":{"path":{"text":"file.txt"}}}"#.to_string());
8568 process_rg_json_match_line(
8569 missing_line,
8570 &mut matches,
8571 &mut match_count,
8572 &mut match_limit_reached,
8573 scan_limit,
8574 );
8575 assert!(matches.is_empty());
8576 assert_eq!(match_count, 0);
8577 assert!(!match_limit_reached);
8578
8579 let valid_line = Ok(
8580 r#"{"type":"match","data":{"path":{"text":"file.txt"},"line_number":3}}"#.to_string(),
8581 );
8582 process_rg_json_match_line(
8583 valid_line,
8584 &mut matches,
8585 &mut match_count,
8586 &mut match_limit_reached,
8587 scan_limit,
8588 );
8589 assert_eq!(matches.len(), 1);
8590 assert_eq!(matches[0].1, 3);
8591 assert_eq!(match_count, 1);
8592 assert!(match_limit_reached);
8593 }
8594
8595 #[test]
8596 fn test_truncate_by_bytes() {
8597 let content = "short\nthis is a longer line\nanother".to_string();
8598 let result = truncate_head(content, 100, 15);
8599
8600 assert!(result.truncated);
8601 assert_eq!(result.truncated_by, Some(TruncatedBy::Bytes));
8602 }
8603
8604 #[cfg(any(target_os = "linux", target_os = "freebsd"))]
8605 #[test]
8606 fn test_command_with_default_sigpipe_restores_pipe_disposition() {
8607 #[cfg(target_os = "freebsd")]
8614 let status_dir = {
8615 let probe = format!("/compat/linux/proc/{}/status", std::process::id());
8616 if !std::path::Path::new(&probe).exists() {
8617 eprintln!(
8618 "skipping sigpipe disposition test: linprocfs not mounted \
8619 at /compat/linux/proc — add `linprocfs /compat/linux/proc \
8620 linprocfs rw 0 0` to /etc/fstab and `mount /compat/linux/proc` \
8621 to enable"
8622 );
8623 return;
8624 }
8625 "/compat/linux/proc"
8626 };
8627 #[cfg(not(target_os = "freebsd"))]
8628 let status_dir = "/proc";
8629
8630 let probe_cmd = format!(
8631 "while read name value _; do [ \"$name\" = SigIgn: ] && \
8632 {{ printf '%s' \"$value\"; exit 0; }}; done < {status_dir}/$$/status"
8633 );
8634
8635 let output = command_with_default_sigpipe("sh")
8636 .expect("prepare sigpipe disposition probe")
8637 .args(["-c", &probe_cmd])
8638 .stdout(std::process::Stdio::piped())
8639 .output()
8640 .expect("spawn sigpipe disposition probe");
8641
8642 assert!(output.status.success(), "probe failed: {output:?}");
8643 let sigign = String::from_utf8(output.stdout).expect("SigIgn should be utf8");
8644 let ignored_mask =
8645 u64::from_str_radix(sigign.trim(), 16).expect("SigIgn should be a hex mask");
8646 let sigpipe_bit = 1_u64 << (13 - 1);
8647 assert_eq!(
8648 ignored_mask & sigpipe_bit,
8649 0,
8650 "child should not inherit ignored SIGPIPE: SigIgn={sigign}"
8651 );
8652 }
8653
8654 #[cfg(unix)]
8655 #[test]
8656 fn test_command_with_default_sigpipe_in_dir_resolves_relative_program_after_cwd() {
8657 use std::os::unix::fs::PermissionsExt as _;
8658
8659 let tmp = tempfile::tempdir().expect("create temp dir");
8660 let script = tmp.path().join("relative-probe");
8661 std::fs::write(&script, "#!/bin/sh\nprintf cwd-relative-ok\n").expect("write script");
8662 let mut permissions = std::fs::metadata(&script)
8663 .expect("stat script")
8664 .permissions();
8665 permissions.set_mode(0o755);
8666 std::fs::set_permissions(&script, permissions).expect("make script executable");
8667
8668 let output = command_with_default_sigpipe_in_dir("./relative-probe", tmp.path())
8669 .expect("prepare relative executable")
8670 .current_dir(tmp.path())
8671 .stdout(std::process::Stdio::piped())
8672 .output()
8673 .expect("spawn relative executable");
8674
8675 assert!(output.status.success(), "probe failed: {output:?}");
8676 assert_eq!(
8677 String::from_utf8(output.stdout).expect("probe stdout should be utf8"),
8678 "cwd-relative-ok"
8679 );
8680 }
8681
8682 #[cfg(target_os = "linux")]
8683 #[test]
8684 fn test_read_to_end_capped_and_drain_preserves_writer_exit_status() {
8685 let mut child = std::process::Command::new("dd")
8686 .args(["if=/dev/zero", "bs=1", "count=70000", "status=none"])
8687 .stdout(std::process::Stdio::piped())
8688 .spawn()
8689 .expect("spawn dd");
8690
8691 let stdout = child.stdout.take().expect("dd stdout");
8692 let captured = read_to_end_capped_and_drain(stdout, 1024).expect("capture bounded stdout");
8693 let status = child.wait().expect("wait for dd");
8694
8695 assert!(
8696 status.success(),
8697 "bounded reader should drain to EOF instead of SIGPIPEing the writer: {status:?}"
8698 );
8699 assert_eq!(captured.len(), 1025);
8700 }
8701
8702 #[cfg(unix)]
8703 #[test]
8704 fn test_get_file_lines_async_unreadable_file_returns_empty() {
8705 asupersync::test_utils::run_test(|| async {
8706 use std::os::unix::fs::PermissionsExt;
8707
8708 let tmp = tempfile::tempdir().unwrap();
8709 let path = tmp.path().join("secret.txt");
8710 std::fs::write(&path, "secret\n").unwrap();
8711
8712 let mut perms = std::fs::metadata(&path).unwrap().permissions();
8713 perms.set_mode(0o000);
8714 std::fs::set_permissions(&path, perms).unwrap();
8715
8716 let mut cache = HashMap::new();
8717 let lines = get_file_lines_async(&path, &mut cache).await;
8718 assert!(lines.is_empty());
8719 });
8720 }
8721
8722 #[test]
8723 fn test_resolve_path_absolute() {
8724 let cwd = PathBuf::from("/home/user/project");
8725 let result = resolve_path("/absolute/path", &cwd);
8726 assert_eq!(result, PathBuf::from("/absolute/path"));
8727 }
8728
8729 #[test]
8730 fn test_resolve_path_relative() {
8731 let cwd = PathBuf::from("/home/user/project");
8732 let result = resolve_path("src/main.rs", &cwd);
8733 assert_eq!(result, PathBuf::from("/home/user/project/src/main.rs"));
8734 }
8735
8736 #[test]
8737 fn test_normalize_dot_segments_preserves_root() {
8738 let result = normalize_dot_segments(std::path::Path::new("/../etc/passwd"));
8739 assert_eq!(result, PathBuf::from("/etc/passwd"));
8740 }
8741
8742 #[test]
8743 fn test_normalize_dot_segments_preserves_leading_parent_for_relative() {
8744 let result = normalize_dot_segments(std::path::Path::new("../a/../b"));
8745 assert_eq!(result, PathBuf::from("../b"));
8746 }
8747
8748 #[test]
8749 fn test_detect_supported_image_mime_type_from_bytes() {
8750 assert_eq!(
8751 detect_supported_image_mime_type_from_bytes(b"\x89PNG\r\n\x1A\n"),
8752 Some("image/png")
8753 );
8754 assert_eq!(
8755 detect_supported_image_mime_type_from_bytes(b"\xFF\xD8\xFF"),
8756 Some("image/jpeg")
8757 );
8758 assert_eq!(
8759 detect_supported_image_mime_type_from_bytes(b"GIF89a"),
8760 Some("image/gif")
8761 );
8762 assert_eq!(
8763 detect_supported_image_mime_type_from_bytes(b"RIFF1234WEBP"),
8764 Some("image/webp")
8765 );
8766 assert_eq!(
8767 detect_supported_image_mime_type_from_bytes(b"not an image"),
8768 None
8769 );
8770 }
8771
8772 #[test]
8773 fn test_format_size() {
8774 assert_eq!(format_size(500), "500B");
8775 assert_eq!(format_size(1024), "1.0KB");
8776 assert_eq!(format_size(1536), "1.5KB");
8777 assert_eq!(format_size(1_048_576), "1.0MB");
8778 assert_eq!(format_size(1_073_741_824), "1024.0MB");
8779 }
8780
8781 #[test]
8782 fn test_js_string_length() {
8783 assert_eq!(js_string_length("hello"), 5);
8784 assert_eq!(js_string_length("😀"), 2);
8785 }
8786
8787 #[test]
8788 fn test_truncate_line() {
8789 let short = "short line";
8790 let result = truncate_line(short, 100);
8791 assert_eq!(result.text, "short line");
8792 assert!(!result.was_truncated);
8793
8794 let long = "a".repeat(600);
8795 let result = truncate_line(&long, 500);
8796 assert!(result.was_truncated);
8797 assert!(result.text.ends_with("... [truncated]"));
8798 }
8799
8800 fn get_text(content: &[ContentBlock]) -> String {
8805 content
8806 .iter()
8807 .filter_map(|block| {
8808 if let ContentBlock::Text(text) = block {
8809 Some(text.text.clone())
8810 } else {
8811 None
8812 }
8813 })
8814 .collect::<String>()
8815 }
8816
8817 #[test]
8822 fn test_read_valid_file() {
8823 asupersync::test_utils::run_test(|| async {
8824 let tmp = tempfile::tempdir().unwrap();
8825 std::fs::write(tmp.path().join("hello.txt"), "alpha\nbeta\ngamma").unwrap();
8826
8827 let tool = ReadTool::new(tmp.path());
8828 let out = tool
8829 .execute(
8830 "t",
8831 serde_json::json!({ "path": tmp.path().join("hello.txt").to_string_lossy() }),
8832 None,
8833 )
8834 .await
8835 .unwrap();
8836 let text = get_text(&out.content);
8837 assert!(text.contains("alpha"));
8838 assert!(text.contains("beta"));
8839 assert!(text.contains("gamma"));
8840 assert!(!out.is_error);
8841 });
8842 }
8843
8844 #[test]
8845 fn test_read_nonexistent_file() {
8846 asupersync::test_utils::run_test(|| async {
8847 let tmp = tempfile::tempdir().unwrap();
8848 let tool = ReadTool::new(tmp.path());
8849 let err = tool
8850 .execute(
8851 "t",
8852 serde_json::json!({ "path": tmp.path().join("nope.txt").to_string_lossy() }),
8853 None,
8854 )
8855 .await;
8856 assert!(err.is_err());
8857 });
8858 }
8859
8860 #[test]
8861 fn test_read_rejects_outside_cwd() {
8862 asupersync::test_utils::run_test(|| async {
8863 let cwd = tempfile::tempdir().unwrap();
8864 let outside = tempfile::tempdir().unwrap();
8865 std::fs::write(outside.path().join("secret.txt"), "secret").unwrap();
8866
8867 let tool = ReadTool::new(cwd.path());
8868 let err = tool
8869 .execute(
8870 "t",
8871 serde_json::json!({ "path": outside.path().join("secret.txt").to_string_lossy() }),
8872 None,
8873 )
8874 .await
8875 .unwrap_err();
8876 assert!(err.to_string().contains("outside the working directory"));
8877 });
8878 }
8879
8880 #[test]
8887 fn test_enforce_read_scope_allows_agent_dir_outside_cwd() {
8888 let cwd = tempfile::tempdir().unwrap();
8889 let agent_dir = tempfile::tempdir().unwrap();
8890 let skill_dir = agent_dir.path().join("skills").join("freebsd-jails");
8891 std::fs::create_dir_all(&skill_dir).unwrap();
8892 let skill_path = skill_dir.join("SKILL.md");
8893 std::fs::write(&skill_path, "---\nname: test\n---\n# body\n").unwrap();
8894
8895 let resolved =
8896 enforce_read_scope_with_roots(&skill_path, cwd.path(), agent_dir.path()).unwrap();
8897 assert!(
8898 resolved.starts_with(
8899 agent_dir
8900 .path()
8901 .canonicalize()
8902 .unwrap_or_else(|_| agent_dir.path().to_path_buf())
8903 ),
8904 "agent-dir path must be allowed and returned canonicalised"
8905 );
8906 }
8907
8908 #[test]
8909 fn test_enforce_read_scope_still_rejects_unrelated_paths() {
8910 let cwd = tempfile::tempdir().unwrap();
8912 let agent_dir = tempfile::tempdir().unwrap();
8913 let unrelated = tempfile::tempdir().unwrap();
8914 std::fs::write(unrelated.path().join("secret.txt"), "secret").unwrap();
8915 let secret_path = unrelated.path().join("secret.txt");
8916
8917 let err =
8918 enforce_read_scope_with_roots(&secret_path, cwd.path(), agent_dir.path()).unwrap_err();
8919 let msg = err.to_string();
8920 assert!(
8921 msg.contains("outside the working directory") && msg.contains("agent dir"),
8922 "error must mention both denied roots, got: {msg}"
8923 );
8924 }
8925
8926 #[test]
8927 fn test_enforce_read_scope_prefers_cwd_when_path_is_under_cwd() {
8928 let cwd = tempfile::tempdir().unwrap();
8931 let agent_dir = tempfile::tempdir().unwrap();
8932 std::fs::write(cwd.path().join("a.txt"), "in cwd").unwrap();
8933
8934 let resolved =
8935 enforce_read_scope_with_roots(&cwd.path().join("a.txt"), cwd.path(), agent_dir.path())
8936 .unwrap();
8937 assert!(
8938 resolved.starts_with(
8939 cwd.path()
8940 .canonicalize()
8941 .unwrap_or_else(|_| cwd.path().to_path_buf())
8942 )
8943 );
8944 }
8945
8946 #[test]
8947 fn test_read_empty_file() {
8948 asupersync::test_utils::run_test(|| async {
8949 let tmp = tempfile::tempdir().unwrap();
8950 std::fs::write(tmp.path().join("empty.txt"), "").unwrap();
8951
8952 let tool = ReadTool::new(tmp.path());
8953 let out = tool
8954 .execute(
8955 "t",
8956 serde_json::json!({ "path": tmp.path().join("empty.txt").to_string_lossy() }),
8957 None,
8958 )
8959 .await
8960 .unwrap();
8961 let text = get_text(&out.content);
8962 assert_eq!(text, "");
8963 assert!(!out.is_error);
8964 });
8965 }
8966
8967 #[test]
8968 fn test_read_empty_file_positive_offset_errors() {
8969 asupersync::test_utils::run_test(|| async {
8970 let tmp = tempfile::tempdir().unwrap();
8971 std::fs::write(tmp.path().join("empty.txt"), "").unwrap();
8972
8973 let tool = ReadTool::new(tmp.path());
8974 let err = tool
8975 .execute(
8976 "t",
8977 serde_json::json!({
8978 "path": tmp.path().join("empty.txt").to_string_lossy(),
8979 "offset": 1
8980 }),
8981 None,
8982 )
8983 .await;
8984 assert!(err.is_err());
8985 let msg = err.unwrap_err().to_string();
8986 assert!(msg.contains("beyond end of file"));
8987 });
8988 }
8989
8990 #[test]
8991 fn test_read_rejects_zero_limit() {
8992 asupersync::test_utils::run_test(|| async {
8993 let tmp = tempfile::tempdir().unwrap();
8994 std::fs::write(tmp.path().join("lines.txt"), "a\nb\nc\n").unwrap();
8995
8996 let tool = ReadTool::new(tmp.path());
8997 let err = tool
8998 .execute(
8999 "t",
9000 serde_json::json!({
9001 "path": tmp.path().join("lines.txt").to_string_lossy(),
9002 "limit": 0
9003 }),
9004 None,
9005 )
9006 .await;
9007 assert!(err.is_err());
9008 assert!(
9009 err.unwrap_err()
9010 .to_string()
9011 .contains("`limit` must be greater than 0")
9012 );
9013 });
9014 }
9015
9016 #[test]
9017 fn test_read_offset_and_limit() {
9018 asupersync::test_utils::run_test(|| async {
9019 let tmp = tempfile::tempdir().unwrap();
9020 std::fs::write(
9021 tmp.path().join("lines.txt"),
9022 "L1\nL2\nL3\nL4\nL5\nL6\nL7\nL8\nL9\nL10",
9023 )
9024 .unwrap();
9025
9026 let tool = ReadTool::new(tmp.path());
9027 let out = tool
9028 .execute(
9029 "t",
9030 serde_json::json!({
9031 "path": tmp.path().join("lines.txt").to_string_lossy(),
9032 "offset": 3,
9033 "limit": 2
9034 }),
9035 None,
9036 )
9037 .await
9038 .unwrap();
9039 let text = get_text(&out.content);
9040 assert!(text.contains("L3"));
9041 assert!(text.contains("L4"));
9042 assert!(!text.contains("L2"));
9043 assert!(!text.contains("L5"));
9044 });
9045 }
9046
9047 #[test]
9048 fn test_read_offset_and_limit_with_cr_only_line_endings() {
9049 asupersync::test_utils::run_test(|| async {
9050 let tmp = tempfile::tempdir().unwrap();
9051 std::fs::write(tmp.path().join("lines.txt"), b"L1\rL2\rL3\r").unwrap();
9052
9053 let tool = ReadTool::new(tmp.path());
9054 let out = tool
9055 .execute(
9056 "t",
9057 serde_json::json!({
9058 "path": tmp.path().join("lines.txt").to_string_lossy(),
9059 "offset": 2,
9060 "limit": 1
9061 }),
9062 None,
9063 )
9064 .await
9065 .unwrap();
9066 let text = get_text(&out.content);
9067 assert!(text.contains("L2"));
9068 assert!(!text.contains("L1"));
9069 assert!(!text.contains("L3"));
9070 assert!(text.contains("offset=3"));
9071 assert!(!text.contains('\r'));
9072 });
9073 }
9074
9075 #[test]
9076 fn test_read_offset_and_limit_with_split_crlf_chunk_boundary() {
9077 asupersync::test_utils::run_test(|| async {
9078 let tmp = tempfile::tempdir().unwrap();
9079 let mut content = vec![b'x'; (64 * 1024) - 1];
9080 content.extend_from_slice(b"\r\nSECOND\r\nTHIRD");
9081 std::fs::write(tmp.path().join("lines.txt"), content).unwrap();
9082
9083 let tool = ReadTool::new(tmp.path());
9084 let out = tool
9085 .execute(
9086 "t",
9087 serde_json::json!({
9088 "path": tmp.path().join("lines.txt").to_string_lossy(),
9089 "offset": 2,
9090 "limit": 1
9091 }),
9092 None,
9093 )
9094 .await
9095 .unwrap();
9096 let text = get_text(&out.content);
9097 assert!(text.contains("SECOND"));
9098 assert!(!text.contains("THIRD"));
9099 assert!(!text.contains("xxxx"));
9100 assert!(text.contains("offset=3"));
9101 });
9102 }
9103
9104 #[test]
9105 fn test_read_offset_beyond_eof() {
9106 asupersync::test_utils::run_test(|| async {
9107 let tmp = tempfile::tempdir().unwrap();
9108 std::fs::write(tmp.path().join("short.txt"), "a\nb").unwrap();
9109
9110 let tool = ReadTool::new(tmp.path());
9111 let err = tool
9112 .execute(
9113 "t",
9114 serde_json::json!({
9115 "path": tmp.path().join("short.txt").to_string_lossy(),
9116 "offset": 100
9117 }),
9118 None,
9119 )
9120 .await;
9121 assert!(err.is_err());
9122 let msg = err.unwrap_err().to_string();
9123 assert!(msg.contains("beyond end of file"));
9124 });
9125 }
9126
9127 #[test]
9128 fn test_map_normalized_with_trailing_whitespace() {
9129 let content = "A \nB";
9131 let normalized = build_normalized_content(content);
9132 assert_eq!(normalized, "A\nB");
9133
9134 let (start, len) = map_normalized_range_to_original(content, 0, 1);
9136 assert_eq!(start, 0);
9137 assert_eq!(len, 1);
9138 assert_eq!(&content[start..start + len], "A");
9139
9140 let (start, len) = map_normalized_range_to_original(content, 1, 1);
9142 assert_eq!(start, 4);
9143 assert_eq!(len, 1);
9144 assert_eq!(&content[start..start + len], "\n");
9145
9146 let (start, len) = map_normalized_range_to_original(content, 2, 1);
9148 assert_eq!(start, 5);
9149 assert_eq!(len, 1);
9150 assert_eq!(&content[start..start + len], "B");
9151 }
9152
9153 #[test]
9154 fn test_read_binary_file_lossy() {
9155 asupersync::test_utils::run_test(|| async {
9156 let tmp = tempfile::tempdir().unwrap();
9157 let binary_data: Vec<u8> = (0..=255).collect();
9158 std::fs::write(tmp.path().join("binary.bin"), &binary_data).unwrap();
9159
9160 let tool = ReadTool::new(tmp.path());
9161 let out = tool
9162 .execute(
9163 "t",
9164 serde_json::json!({ "path": tmp.path().join("binary.bin").to_string_lossy() }),
9165 None,
9166 )
9167 .await
9168 .unwrap();
9169 let text = get_text(&out.content);
9171 assert!(!text.is_empty());
9172 assert!(!out.is_error);
9173 });
9174 }
9175
9176 #[test]
9177 fn test_read_image_detection() {
9178 asupersync::test_utils::run_test(|| async {
9179 let tmp = tempfile::tempdir().unwrap();
9180 let png_header: Vec<u8> = vec![
9182 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,
9186 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,
9192 ];
9193 std::fs::write(tmp.path().join("test.png"), &png_header).unwrap();
9194
9195 let tool = ReadTool::new(tmp.path());
9196 let out = tool
9197 .execute(
9198 "t",
9199 serde_json::json!({ "path": tmp.path().join("test.png").to_string_lossy() }),
9200 None,
9201 )
9202 .await
9203 .unwrap();
9204
9205 let has_image = out
9207 .content
9208 .iter()
9209 .any(|b| matches!(b, ContentBlock::Image(_)));
9210 assert!(has_image, "expected image content block for PNG file");
9211 });
9212 }
9213
9214 #[cfg(feature = "image-resize")]
9215 #[test]
9216 fn test_read_resizes_large_source_image_before_api_limit_check() {
9217 asupersync::test_utils::run_test(|| async {
9218 use image::codecs::png::PngEncoder;
9219 use image::{ExtendedColorType, ImageEncoder, Rgb, RgbImage};
9220
9221 let tmp = tempfile::tempdir().unwrap();
9222 let image = RgbImage::from_fn(2600, 2600, |x, y| {
9223 let seed = x.wrapping_mul(1_973)
9224 ^ y.wrapping_mul(9_277)
9225 ^ x.rotate_left(7)
9226 ^ y.rotate_left(13);
9227 Rgb([
9228 u8::try_from(seed % 256).unwrap_or(0),
9229 u8::try_from((seed >> 8) % 256).unwrap_or(0),
9230 u8::try_from((seed >> 16) % 256).unwrap_or(0),
9231 ])
9232 });
9233
9234 let mut png_bytes = Vec::new();
9235 PngEncoder::new(&mut png_bytes)
9236 .write_image(
9237 image.as_raw(),
9238 image.width(),
9239 image.height(),
9240 ExtendedColorType::Rgb8,
9241 )
9242 .unwrap();
9243
9244 assert!(
9245 png_bytes.len() > IMAGE_MAX_BYTES,
9246 "fixture must exceed API image limit to exercise resize path"
9247 );
9248 assert!(
9249 png_bytes.len() < usize::try_from(READ_TOOL_MAX_BYTES).unwrap_or(usize::MAX),
9250 "fixture must stay within read-tool input bound"
9251 );
9252
9253 let image_path = tmp.path().join("large.png");
9254 std::fs::write(&image_path, &png_bytes).unwrap();
9255
9256 let tool = ReadTool::new(tmp.path());
9257 let out = tool
9258 .execute(
9259 "t",
9260 serde_json::json!({ "path": image_path.to_string_lossy() }),
9261 None,
9262 )
9263 .await
9264 .unwrap();
9265
9266 assert!(!out.is_error, "resizable large images should succeed");
9267 assert!(
9268 out.content
9269 .iter()
9270 .any(|block| matches!(block, ContentBlock::Image(_))),
9271 "expected an image attachment after resizing"
9272 );
9273
9274 let text = get_text(&out.content);
9275 assert!(text.contains("Read image file"));
9276 assert!(
9277 text.contains("displayed at"),
9278 "expected resize note in read output, got: {text}"
9279 );
9280 });
9281 }
9282
9283 #[test]
9284 fn test_read_blocked_images() {
9285 asupersync::test_utils::run_test(|| async {
9286 let tmp = tempfile::tempdir().unwrap();
9287 let png_header: Vec<u8> =
9288 vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00];
9289 std::fs::write(tmp.path().join("test.png"), &png_header).unwrap();
9290
9291 let tool = ReadTool::with_settings(tmp.path(), false, true);
9292 let err = tool
9293 .execute(
9294 "t",
9295 serde_json::json!({ "path": tmp.path().join("test.png").to_string_lossy() }),
9296 None,
9297 )
9298 .await;
9299 assert!(err.is_err());
9300 assert!(err.unwrap_err().to_string().contains("blocked"));
9301 });
9302 }
9303
9304 #[test]
9305 fn test_read_truncation_at_max_lines() {
9306 asupersync::test_utils::run_test(|| async {
9307 let tmp = tempfile::tempdir().unwrap();
9308 let content: String = (0..DEFAULT_MAX_LINES + 500)
9309 .map(|i| format!("line {i}"))
9310 .collect::<Vec<_>>()
9311 .join("\n");
9312 std::fs::write(tmp.path().join("big.txt"), &content).unwrap();
9313
9314 let tool = ReadTool::new(tmp.path());
9315 let out = tool
9316 .execute(
9317 "t",
9318 serde_json::json!({ "path": tmp.path().join("big.txt").to_string_lossy() }),
9319 None,
9320 )
9321 .await
9322 .unwrap();
9323 assert!(out.details.is_some(), "expected truncation details");
9325 let text = get_text(&out.content);
9326 assert!(text.contains("offset="));
9327 });
9328 }
9329
9330 #[test]
9331 fn test_read_first_line_exceeds_max_bytes() {
9332 asupersync::test_utils::run_test(|| async {
9333 let tmp = tempfile::tempdir().unwrap();
9334 let long_line = "a".repeat(DEFAULT_MAX_BYTES + 128);
9335 std::fs::write(tmp.path().join("too_long.txt"), long_line).unwrap();
9336
9337 let tool = ReadTool::new(tmp.path());
9338 let out = tool
9339 .execute(
9340 "t",
9341 serde_json::json!({ "path": tmp.path().join("too_long.txt").to_string_lossy() }),
9342 None,
9343 )
9344 .await
9345 .unwrap();
9346
9347 let text = get_text(&out.content);
9348 let expected_limit = format!("exceeds {} limit", format_size(DEFAULT_MAX_BYTES));
9349 assert!(
9350 text.contains(&expected_limit),
9351 "expected limit hint '{expected_limit}', got: {text}"
9352 );
9353 let details = out.details.expect("expected truncation details");
9354 assert_eq!(
9355 details
9356 .get("truncation")
9357 .and_then(|v| v.get("firstLineExceedsLimit"))
9358 .and_then(serde_json::Value::as_bool),
9359 Some(true)
9360 );
9361 });
9362 }
9363
9364 #[test]
9365 fn test_read_unicode_content() {
9366 asupersync::test_utils::run_test(|| async {
9367 let tmp = tempfile::tempdir().unwrap();
9368 std::fs::write(tmp.path().join("uni.txt"), "Hello 你好 🌍\nLine 2 café").unwrap();
9369
9370 let tool = ReadTool::new(tmp.path());
9371 let out = tool
9372 .execute(
9373 "t",
9374 serde_json::json!({ "path": tmp.path().join("uni.txt").to_string_lossy() }),
9375 None,
9376 )
9377 .await
9378 .unwrap();
9379 let text = get_text(&out.content);
9380 assert!(text.contains("你好"));
9381 assert!(text.contains("🌍"));
9382 assert!(text.contains("café"));
9383 });
9384 }
9385
9386 #[test]
9391 fn test_write_new_file() {
9392 asupersync::test_utils::run_test(|| async {
9393 let tmp = tempfile::tempdir().unwrap();
9394 let tool = WriteTool::new(tmp.path());
9395 let out = tool
9396 .execute(
9397 "t",
9398 serde_json::json!({
9399 "path": tmp.path().join("new.txt").to_string_lossy(),
9400 "content": "hello world"
9401 }),
9402 None,
9403 )
9404 .await
9405 .unwrap();
9406 assert!(!out.is_error);
9407 let contents = std::fs::read_to_string(tmp.path().join("new.txt")).unwrap();
9408 assert_eq!(contents, "hello world");
9409 });
9410 }
9411
9412 #[test]
9413 fn test_write_overwrite_existing() {
9414 asupersync::test_utils::run_test(|| async {
9415 let tmp = tempfile::tempdir().unwrap();
9416 std::fs::write(tmp.path().join("exist.txt"), "old content").unwrap();
9417
9418 let tool = WriteTool::new(tmp.path());
9419 let out = tool
9420 .execute(
9421 "t",
9422 serde_json::json!({
9423 "path": tmp.path().join("exist.txt").to_string_lossy(),
9424 "content": "new content"
9425 }),
9426 None,
9427 )
9428 .await
9429 .unwrap();
9430 assert!(!out.is_error);
9431 let contents = std::fs::read_to_string(tmp.path().join("exist.txt")).unwrap();
9432 assert_eq!(contents, "new content");
9433 });
9434 }
9435
9436 #[test]
9437 fn test_write_creates_parent_dirs() {
9438 asupersync::test_utils::run_test(|| async {
9439 let tmp = tempfile::tempdir().unwrap();
9440 let tool = WriteTool::new(tmp.path());
9441 let deep_path = tmp.path().join("a/b/c/deep.txt");
9442 let out = tool
9443 .execute(
9444 "t",
9445 serde_json::json!({
9446 "path": deep_path.to_string_lossy(),
9447 "content": "deep file"
9448 }),
9449 None,
9450 )
9451 .await
9452 .unwrap();
9453 assert!(!out.is_error);
9454 assert!(deep_path.exists());
9455 assert_eq!(std::fs::read_to_string(&deep_path).unwrap(), "deep file");
9456 });
9457 }
9458
9459 #[test]
9460 fn test_write_empty_file() {
9461 asupersync::test_utils::run_test(|| async {
9462 let tmp = tempfile::tempdir().unwrap();
9463 let tool = WriteTool::new(tmp.path());
9464 let out = tool
9465 .execute(
9466 "t",
9467 serde_json::json!({
9468 "path": tmp.path().join("empty.txt").to_string_lossy(),
9469 "content": ""
9470 }),
9471 None,
9472 )
9473 .await
9474 .unwrap();
9475 assert!(!out.is_error);
9476 let contents = std::fs::read_to_string(tmp.path().join("empty.txt")).unwrap();
9477 assert_eq!(contents, "");
9478 let text = get_text(&out.content);
9479 assert!(text.contains("Successfully wrote 0 bytes"));
9480 });
9481 }
9482
9483 #[test]
9484 fn test_write_rejects_outside_cwd() {
9485 asupersync::test_utils::run_test(|| async {
9486 let cwd = tempfile::tempdir().unwrap();
9487 let outside = tempfile::tempdir().unwrap();
9488 let tool = WriteTool::new(cwd.path());
9489 let err = tool
9490 .execute(
9491 "t",
9492 serde_json::json!({
9493 "path": outside.path().join("escape.txt").to_string_lossy(),
9494 "content": "nope"
9495 }),
9496 None,
9497 )
9498 .await
9499 .unwrap_err();
9500 assert!(err.to_string().contains("outside the working directory"));
9501
9502 let err = tool
9503 .execute(
9504 "t",
9505 serde_json::json!({
9506 "path": "../escape.txt",
9507 "content": "nope"
9508 }),
9509 None,
9510 )
9511 .await
9512 .unwrap_err();
9513 assert!(err.to_string().contains("outside the working directory"));
9514 });
9515 }
9516
9517 #[test]
9518 fn test_write_unicode_content() {
9519 asupersync::test_utils::run_test(|| async {
9520 let tmp = tempfile::tempdir().unwrap();
9521 let tool = WriteTool::new(tmp.path());
9522 let out = tool
9523 .execute(
9524 "t",
9525 serde_json::json!({
9526 "path": tmp.path().join("unicode.txt").to_string_lossy(),
9527 "content": "日本語 🎉 Ñoño"
9528 }),
9529 None,
9530 )
9531 .await
9532 .unwrap();
9533 assert!(!out.is_error);
9534 let contents = std::fs::read_to_string(tmp.path().join("unicode.txt")).unwrap();
9535 assert_eq!(contents, "日本語 🎉 Ñoño");
9536 });
9537 }
9538
9539 #[test]
9540 #[cfg(unix)]
9541 fn test_write_file_permissions_unix() {
9542 use std::os::unix::fs::PermissionsExt;
9543 asupersync::test_utils::run_test(|| async {
9544 let tmp = tempfile::tempdir().unwrap();
9545 let tool = WriteTool::new(tmp.path());
9546 let path = tmp.path().join("perms.txt");
9547 let out = tool
9548 .execute(
9549 "t",
9550 serde_json::json!({
9551 "path": path.to_string_lossy(),
9552 "content": "check perms"
9553 }),
9554 None,
9555 )
9556 .await
9557 .unwrap();
9558 assert!(!out.is_error);
9559
9560 let meta = std::fs::metadata(&path).unwrap();
9561 let mode = meta.permissions().mode();
9562 assert_eq!(
9563 mode & 0o777,
9564 0o644,
9565 "Expected default 0o644 permissions for new files"
9566 );
9567 });
9568 }
9569
9570 #[test]
9575 fn test_edit_exact_match_replace() {
9576 asupersync::test_utils::run_test(|| async {
9577 let tmp = tempfile::tempdir().unwrap();
9578 std::fs::write(tmp.path().join("code.rs"), "fn foo() { bar() }").unwrap();
9579
9580 let tool = EditTool::new(tmp.path());
9581 let out = tool
9582 .execute(
9583 "t",
9584 serde_json::json!({
9585 "path": tmp.path().join("code.rs").to_string_lossy(),
9586 "oldText": "bar()",
9587 "newText": "baz()"
9588 }),
9589 None,
9590 )
9591 .await
9592 .unwrap();
9593 assert!(!out.is_error);
9594 let contents = std::fs::read_to_string(tmp.path().join("code.rs")).unwrap();
9595 assert_eq!(contents, "fn foo() { baz() }");
9596 });
9597 }
9598
9599 #[test]
9600 fn test_edit_no_match_error() {
9601 asupersync::test_utils::run_test(|| async {
9602 let tmp = tempfile::tempdir().unwrap();
9603 std::fs::write(tmp.path().join("code.rs"), "fn foo() {}").unwrap();
9604
9605 let tool = EditTool::new(tmp.path());
9606 let err = tool
9607 .execute(
9608 "t",
9609 serde_json::json!({
9610 "path": tmp.path().join("code.rs").to_string_lossy(),
9611 "oldText": "NONEXISTENT TEXT",
9612 "newText": "replacement"
9613 }),
9614 None,
9615 )
9616 .await;
9617 assert!(err.is_err());
9618 });
9619 }
9620
9621 #[test]
9622 fn test_edit_empty_old_text_error() {
9623 asupersync::test_utils::run_test(|| async {
9624 let tmp = tempfile::tempdir().unwrap();
9625 let path = tmp.path().join("code.rs");
9626 std::fs::write(&path, "fn foo() {}").unwrap();
9627
9628 let tool = EditTool::new(tmp.path());
9629 let err = tool
9630 .execute(
9631 "t",
9632 serde_json::json!({
9633 "path": path.to_string_lossy(),
9634 "oldText": "",
9635 "newText": "prefix"
9636 }),
9637 None,
9638 )
9639 .await
9640 .expect_err("empty oldText should be rejected");
9641
9642 let msg = err.to_string();
9643 assert!(
9644 msg.contains("old text cannot be empty"),
9645 "unexpected error: {msg}"
9646 );
9647 let after = std::fs::read_to_string(path).unwrap();
9648 assert_eq!(after, "fn foo() {}");
9649 });
9650 }
9651
9652 #[test]
9653 fn test_edit_ambiguous_match_error() {
9654 asupersync::test_utils::run_test(|| async {
9655 let tmp = tempfile::tempdir().unwrap();
9656 std::fs::write(tmp.path().join("dup.txt"), "hello hello hello").unwrap();
9657
9658 let tool = EditTool::new(tmp.path());
9659 let err = tool
9660 .execute(
9661 "t",
9662 serde_json::json!({
9663 "path": tmp.path().join("dup.txt").to_string_lossy(),
9664 "oldText": "hello",
9665 "newText": "world"
9666 }),
9667 None,
9668 )
9669 .await;
9670 assert!(err.is_err(), "expected error for ambiguous match");
9671 });
9672 }
9673
9674 #[test]
9675 fn test_edit_multi_line_replacement() {
9676 asupersync::test_utils::run_test(|| async {
9677 let tmp = tempfile::tempdir().unwrap();
9678 std::fs::write(
9679 tmp.path().join("multi.txt"),
9680 "line 1\nline 2\nline 3\nline 4",
9681 )
9682 .unwrap();
9683
9684 let tool = EditTool::new(tmp.path());
9685 let out = tool
9686 .execute(
9687 "t",
9688 serde_json::json!({
9689 "path": tmp.path().join("multi.txt").to_string_lossy(),
9690 "oldText": "line 2\nline 3",
9691 "newText": "replaced 2\nreplaced 3\nextra line"
9692 }),
9693 None,
9694 )
9695 .await
9696 .unwrap();
9697 assert!(!out.is_error);
9698 let contents = std::fs::read_to_string(tmp.path().join("multi.txt")).unwrap();
9699 assert_eq!(
9700 contents,
9701 "line 1\nreplaced 2\nreplaced 3\nextra line\nline 4"
9702 );
9703 });
9704 }
9705
9706 #[test]
9707 fn test_edit_unicode_content() {
9708 asupersync::test_utils::run_test(|| async {
9709 let tmp = tempfile::tempdir().unwrap();
9710 std::fs::write(tmp.path().join("uni.txt"), "Héllo wörld 🌍").unwrap();
9711
9712 let tool = EditTool::new(tmp.path());
9713 let out = tool
9714 .execute(
9715 "t",
9716 serde_json::json!({
9717 "path": tmp.path().join("uni.txt").to_string_lossy(),
9718 "oldText": "wörld 🌍",
9719 "newText": "Welt 🌎"
9720 }),
9721 None,
9722 )
9723 .await
9724 .unwrap();
9725 assert!(!out.is_error);
9726 let contents = std::fs::read_to_string(tmp.path().join("uni.txt")).unwrap();
9727 assert_eq!(contents, "Héllo Welt 🌎");
9728 });
9729 }
9730
9731 #[test]
9732 fn test_edit_missing_file() {
9733 asupersync::test_utils::run_test(|| async {
9734 let tmp = tempfile::tempdir().unwrap();
9735 let tool = EditTool::new(tmp.path());
9736 let err = tool
9737 .execute(
9738 "t",
9739 serde_json::json!({
9740 "path": tmp.path().join("nope.txt").to_string_lossy(),
9741 "oldText": "foo",
9742 "newText": "bar"
9743 }),
9744 None,
9745 )
9746 .await;
9747 assert!(err.is_err());
9748 });
9749 }
9750
9751 struct FailingReader {
9756 responses: std::collections::VecDeque<std::io::Result<Vec<u8>>>,
9757 }
9758
9759 impl FailingReader {
9760 fn new(responses: impl IntoIterator<Item = std::io::Result<Vec<u8>>>) -> Self {
9761 Self {
9762 responses: responses.into_iter().collect(),
9763 }
9764 }
9765 }
9766
9767 impl Read for FailingReader {
9768 fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
9769 match self.responses.pop_front().unwrap_or_else(|| Ok(Vec::new())) {
9770 Ok(bytes) => {
9771 assert!(
9772 bytes.len() <= buf.len(),
9773 "test reader only supports single-chunk reads"
9774 );
9775 buf[..bytes.len()].copy_from_slice(&bytes);
9776 Ok(bytes.len())
9777 }
9778 Err(err) => Err(err),
9779 }
9780 }
9781 }
9782
9783 #[test]
9784 fn test_bash_simple_command() {
9785 asupersync::test_utils::run_test(|| async {
9786 let tmp = tempfile::tempdir().unwrap();
9787 let tool = BashTool::new(tmp.path());
9788 let out = tool
9789 .execute(
9790 "t",
9791 serde_json::json!({ "command": "echo hello_from_bash" }),
9792 None,
9793 )
9794 .await
9795 .unwrap();
9796 let text = get_text(&out.content);
9797 assert!(text.contains("hello_from_bash"));
9798 assert!(!out.is_error);
9799 });
9800 }
9801
9802 #[test]
9803 fn test_bash_exit_code_nonzero() {
9804 asupersync::test_utils::run_test(|| async {
9805 let tmp = tempfile::tempdir().unwrap();
9806 let tool = BashTool::new(tmp.path());
9807 let out = tool
9808 .execute("t", serde_json::json!({ "command": "exit 42" }), None)
9809 .await
9810 .expect("non-zero exit should return Ok with is_error=true");
9811 assert!(out.is_error, "non-zero exit must set is_error");
9812 let msg = get_text(&out.content);
9813 assert!(
9814 msg.contains("42"),
9815 "expected exit code 42 in output, got: {msg}"
9816 );
9817 });
9818 }
9819
9820 #[cfg(unix)]
9821 #[test]
9822 fn test_bash_signal_termination_is_error() {
9823 asupersync::test_utils::run_test(|| async {
9824 let tmp = tempfile::tempdir().unwrap();
9825 let tool = BashTool::new(tmp.path());
9826 let out = tool
9827 .execute("t", serde_json::json!({ "command": "kill -KILL $$" }), None)
9828 .await
9829 .expect("signal-terminated shell should return Ok with is_error=true");
9830 assert!(
9831 out.is_error,
9832 "signal-terminated shell must be reported as error"
9833 );
9834 let msg = get_text(&out.content);
9835 assert!(
9836 msg.contains("Command exited with code"),
9837 "expected explicit exit-code report, got: {msg}"
9838 );
9839 assert!(
9840 !msg.contains("Command exited with code 0"),
9841 "signal-terminated shell must not appear successful: {msg}"
9842 );
9843 });
9844 }
9845
9846 #[test]
9847 fn test_bash_stderr_capture() {
9848 asupersync::test_utils::run_test(|| async {
9849 let tmp = tempfile::tempdir().unwrap();
9850 let tool = BashTool::new(tmp.path());
9851 let out = tool
9852 .execute(
9853 "t",
9854 serde_json::json!({ "command": "echo stderr_msg >&2" }),
9855 None,
9856 )
9857 .await
9858 .unwrap();
9859 let text = get_text(&out.content);
9860 assert!(
9861 text.contains("stderr_msg"),
9862 "expected stderr output in result, got: {text}"
9863 );
9864 });
9865 }
9866
9867 #[test]
9868 fn test_bash_timeout() {
9869 asupersync::test_utils::run_test(|| async {
9870 let tmp = tempfile::tempdir().unwrap();
9871 let tool = BashTool::new(tmp.path());
9872 let out = tool
9873 .execute(
9874 "t",
9875 serde_json::json!({ "command": "sleep 60", "timeout": 2 }),
9876 None,
9877 )
9878 .await
9879 .expect("timeout should return Ok with is_error=true");
9880 assert!(out.is_error, "timeout must set is_error");
9881 let msg = get_text(&out.content);
9882 assert!(
9883 msg.to_lowercase().contains("timeout") || msg.to_lowercase().contains("timed out"),
9884 "expected timeout indication, got: {msg}"
9885 );
9886 let cancellation = out
9887 .details
9888 .as_ref()
9889 .and_then(|details| details.get("cancellation"))
9890 .expect("timeout should include structured cancellation details");
9891 assert_eq!(cancellation["schema"], BASH_CANCELLATION_SCHEMA_V1);
9892 assert_eq!(cancellation["status"], "cancelled");
9893 assert_eq!(cancellation["reason"], "timeout");
9894 assert_eq!(cancellation["cleanup"], "process_group_tree_terminated");
9895 assert_eq!(cancellation["timeoutMs"], 2000);
9896 });
9897 }
9898
9899 #[cfg(target_os = "linux")]
9900 #[test]
9901 fn test_bash_timeout_kills_process_tree() {
9902 asupersync::test_utils::run_test(|| async {
9903 let tmp = tempfile::tempdir().unwrap();
9904 let marker = tmp.path().join("leaked_child.txt");
9905 let tool = BashTool::new(tmp.path());
9906
9907 let out = tool
9908 .execute(
9909 "t",
9910 serde_json::json!({
9911 "command": "(sleep 3; echo leaked > leaked_child.txt) & sleep 10",
9912 "timeout": 1
9913 }),
9914 None,
9915 )
9916 .await
9917 .expect("timeout should return Ok with is_error=true");
9918
9919 assert!(out.is_error, "timeout must set is_error");
9920 let msg = get_text(&out.content);
9921 assert!(msg.contains("Command timed out"));
9922
9923 std::thread::sleep(Duration::from_secs(4));
9925 assert!(
9926 !marker.exists(),
9927 "background child was not terminated on timeout"
9928 );
9929 });
9930 }
9931
9932 #[cfg(target_os = "linux")]
9933 #[test]
9934 fn test_bash_cancelled_context_kills_process_tree() {
9935 asupersync::test_utils::run_test(|| async {
9936 let tmp = tempfile::tempdir().unwrap();
9937 let marker = tmp.path().join("leaked_child.txt");
9938
9939 let ambient_cx = asupersync::Cx::for_testing();
9940 let cancel_cx = ambient_cx.clone();
9941 let _current = asupersync::Cx::set_current(Some(ambient_cx));
9942
9943 let cancel_thread = std::thread::spawn(move || {
9944 std::thread::sleep(Duration::from_millis(100));
9945 cancel_cx.set_cancel_requested(true);
9946 });
9947
9948 let result = run_bash_command(
9949 tmp.path(),
9950 None,
9951 None,
9952 "(sleep 3; echo leaked > leaked_child.txt) & sleep 10",
9953 Some(30),
9954 None,
9955 )
9956 .await
9957 .expect("cancelled bash should return a result");
9958
9959 cancel_thread.join().expect("cancel thread");
9960
9961 assert!(
9962 result.cancelled,
9963 "expected cancelled bash result: {result:?}"
9964 );
9965 assert_eq!(
9966 result.cancellation_reason,
9967 Some(BashCancellationReason::AmbientCancellation)
9968 );
9969
9970 std::thread::sleep(Duration::from_secs(4));
9971 assert!(
9972 !marker.exists(),
9973 "background child was not terminated on cancellation"
9974 );
9975 });
9976 }
9977
9978 #[test]
9979 fn test_bash_pump_stream_emits_io_error_frame_after_partial_output() {
9980 let reader = FailingReader::new([
9981 Ok(b"partial stdout".to_vec()),
9982 Err(std::io::Error::other("simulated stdout failure")),
9983 ]);
9984 let (tx, rx) = mpsc::sync_channel::<BashPipeFrame>(4);
9985
9986 pump_stream(reader, "stdout", &tx);
9987
9988 match rx.recv().expect("partial chunk") {
9989 BashPipeFrame::Chunk(chunk) => assert_eq!(chunk, b"partial stdout"),
9990 BashPipeFrame::Error(message) => {
9991 unreachable!("expected output chunk before error, got error frame: {message}")
9992 }
9993 }
9994
9995 match rx.recv().expect("io error frame") {
9996 BashPipeFrame::Chunk(chunk) => {
9997 unreachable!("expected io error after partial chunk, got chunk: {chunk:?}")
9998 }
9999 BashPipeFrame::Error(message) => {
10000 assert!(message.contains("Failed to read bash stdout"));
10001 assert!(message.contains("simulated stdout failure"));
10002 }
10003 }
10004
10005 assert!(matches!(rx.try_recv(), Err(mpsc::TryRecvError::Empty)));
10006 }
10007
10008 #[test]
10009 fn test_drain_bash_output_ignores_cancellation_after_process_exit() {
10010 asupersync::test_utils::run_test(|| async {
10011 let (tx, mut rx) = mpsc::sync_channel::<BashPipeFrame>(1);
10012 let mut bash_output = BashOutputState::new(DEFAULT_MAX_BYTES);
10013
10014 let ambient_cx = asupersync::Cx::for_testing();
10015 ambient_cx.set_cancel_requested(true);
10016 let _current = asupersync::Cx::set_current(Some(ambient_cx));
10017 let cx = AgentCx::for_current_or_request();
10018 let now = cx
10019 .cx()
10020 .timer_driver()
10021 .map_or_else(wall_now, |timer| timer.now());
10022
10023 let cancelled = drain_bash_output(
10024 &mut rx,
10025 &mut bash_output,
10026 &cx,
10027 now + std::time::Duration::from_millis(10),
10028 std::time::Duration::from_millis(1),
10029 false,
10030 )
10031 .await
10032 .expect("drain should complete without cancellation");
10033
10034 drop(tx);
10035
10036 assert!(
10037 !cancelled,
10038 "post-exit drain should ignore late ambient cancellation"
10039 );
10040 assert_eq!(bash_output.total_bytes, 0);
10041 });
10042 }
10043
10044 #[test]
10045 fn test_drain_bash_output_returns_pipe_read_error() {
10046 asupersync::test_utils::run_test(|| async {
10047 let (tx, mut rx) = mpsc::sync_channel::<BashPipeFrame>(2);
10048 tx.send(BashPipeFrame::Chunk(b"partial stderr".to_vec()))
10049 .expect("queue partial output");
10050 tx.send(BashPipeFrame::Error(
10051 "Failed to read bash stderr: simulated stderr failure".to_string(),
10052 ))
10053 .expect("queue error frame");
10054 drop(tx);
10055
10056 let mut bash_output = BashOutputState::new(DEFAULT_MAX_BYTES);
10057 let cx = AgentCx::for_current_or_request();
10058 let now = cx
10059 .cx()
10060 .timer_driver()
10061 .map_or_else(wall_now, |timer| timer.now());
10062
10063 let err = drain_bash_output(
10064 &mut rx,
10065 &mut bash_output,
10066 &cx,
10067 now + std::time::Duration::from_millis(10),
10068 std::time::Duration::from_millis(1),
10069 false,
10070 )
10071 .await
10072 .expect_err("pipe read failures must surface as errors");
10073
10074 let message = err.to_string();
10075 assert!(message.contains("Failed to read bash stderr"));
10076 assert!(message.contains("simulated stderr failure"));
10077 assert!(message.contains("Partial output before failure"));
10078 assert!(message.contains("partial stderr"));
10079 assert_eq!(bash_output.total_bytes, "partial stderr".len());
10080 });
10081 }
10082
10083 #[test]
10084 fn test_drain_bash_output_honors_cancellation_while_process_still_active() {
10085 asupersync::test_utils::run_test(|| async {
10086 let (_tx, mut rx) = mpsc::sync_channel::<BashPipeFrame>(1);
10087 let mut bash_output = BashOutputState::new(DEFAULT_MAX_BYTES);
10088
10089 let ambient_cx = asupersync::Cx::for_testing();
10090 ambient_cx.set_cancel_requested(true);
10091 let _current = asupersync::Cx::set_current(Some(ambient_cx));
10092 let cx = AgentCx::for_current_or_request();
10093 let now = cx
10094 .cx()
10095 .timer_driver()
10096 .map_or_else(wall_now, |timer| timer.now());
10097
10098 let cancelled = drain_bash_output(
10099 &mut rx,
10100 &mut bash_output,
10101 &cx,
10102 now + std::time::Duration::from_secs(1),
10103 std::time::Duration::from_millis(1),
10104 true,
10105 )
10106 .await
10107 .expect("drain should complete under cancellation");
10108
10109 assert!(
10110 cancelled,
10111 "active drain should still honor ambient cancellation"
10112 );
10113 assert_eq!(bash_output.total_bytes, 0);
10114 });
10115 }
10116
10117 #[test]
10118 fn test_bash_output_state_abandon_spill_file_clears_path_and_unlinks_file() {
10119 let tmp = tempfile::tempdir().unwrap();
10120 let spill_path = tmp.path().join("partial-bash.log");
10121 std::fs::write(&spill_path, b"partial output").unwrap();
10122
10123 let mut bash_output = BashOutputState::new(DEFAULT_MAX_BYTES);
10124 bash_output.temp_file_path = Some(spill_path.clone());
10125
10126 bash_output.abandon_spill_file();
10127
10128 assert!(bash_output.spill_failed);
10129 assert!(bash_output.temp_file.is_none());
10130 assert!(bash_output.temp_file_path.is_none());
10131 assert!(
10132 !spill_path.exists(),
10133 "abandoned spill files should not be advertised or left behind"
10134 );
10135 }
10136
10137 #[test]
10138 fn test_bash_hard_limit_retains_partial_spill_file() {
10139 asupersync::test_utils::run_test(|| async {
10140 let tmp = tempfile::tempdir().unwrap();
10141 let spill_path = tmp.path().join("hard-limit-bash.log");
10142 std::fs::write(&spill_path, b"partial output").unwrap();
10143
10144 let spill_file = asupersync::fs::OpenOptions::new()
10145 .append(true)
10146 .open(&spill_path)
10147 .await
10148 .unwrap();
10149
10150 let mut bash_output = BashOutputState::new(DEFAULT_MAX_BYTES);
10151 bash_output.total_bytes = BASH_FILE_LIMIT_BYTES;
10152 bash_output.temp_file_path = Some(spill_path.clone());
10153 bash_output.temp_file = Some(spill_file);
10154
10155 ingest_bash_chunk(vec![b'x'], &mut bash_output)
10156 .await
10157 .expect("hard-limit ingestion should still succeed");
10158
10159 assert!(!bash_output.spill_failed);
10160 assert!(bash_output.temp_file.is_none());
10161 assert!(bash_output.temp_file_path.is_some());
10162 assert!(
10163 spill_path.exists(),
10164 "partial spill files must be retained once the hard limit is reached for diagnostics"
10165 );
10166 });
10167 }
10168
10169 #[test]
10170 #[cfg(unix)]
10171 fn test_bash_working_directory() {
10172 asupersync::test_utils::run_test(|| async {
10173 let tmp = tempfile::tempdir().unwrap();
10174 let tool = BashTool::new(tmp.path());
10175 let out = tool
10176 .execute("t", serde_json::json!({ "command": "pwd" }), None)
10177 .await
10178 .unwrap();
10179 let text = get_text(&out.content);
10180 let canonical = tmp.path().canonicalize().unwrap();
10181 assert!(
10182 text.contains(&canonical.to_string_lossy().to_string()),
10183 "expected cwd in output, got: {text}"
10184 );
10185 });
10186 }
10187
10188 #[test]
10189 fn test_bash_multiline_output() {
10190 asupersync::test_utils::run_test(|| async {
10191 let tmp = tempfile::tempdir().unwrap();
10192 let tool = BashTool::new(tmp.path());
10193 let out = tool
10194 .execute(
10195 "t",
10196 serde_json::json!({ "command": "echo line1; echo line2; echo line3" }),
10197 None,
10198 )
10199 .await
10200 .unwrap();
10201 let text = get_text(&out.content);
10202 assert!(text.contains("line1"));
10203 assert!(text.contains("line2"));
10204 assert!(text.contains("line3"));
10205 });
10206 }
10207
10208 #[test]
10213 fn test_grep_basic_pattern() {
10214 asupersync::test_utils::run_test(|| async {
10215 let tmp = tempfile::tempdir().unwrap();
10216 std::fs::write(
10217 tmp.path().join("search.txt"),
10218 "apple\nbanana\napricot\ncherry",
10219 )
10220 .unwrap();
10221
10222 let tool = GrepTool::new(tmp.path());
10223 let out = tool
10224 .execute(
10225 "t",
10226 serde_json::json!({
10227 "pattern": "ap",
10228 "path": tmp.path().join("search.txt").to_string_lossy()
10229 }),
10230 None,
10231 )
10232 .await
10233 .unwrap();
10234 let text = get_text(&out.content);
10235 assert!(text.contains("apple"));
10236 assert!(text.contains("apricot"));
10237 assert!(!text.contains("banana"));
10238 assert!(!text.contains("cherry"));
10239 });
10240 }
10241
10242 #[test]
10243 fn test_grep_rejects_outside_cwd() {
10244 asupersync::test_utils::run_test(|| async {
10245 let cwd = tempfile::tempdir().unwrap();
10246 let outside = tempfile::tempdir().unwrap();
10247 std::fs::write(outside.path().join("secret.txt"), "secret").unwrap();
10248
10249 let tool = GrepTool::new(cwd.path());
10250 let err = tool
10251 .execute(
10252 "t",
10253 serde_json::json!({
10254 "pattern": "secret",
10255 "path": outside.path().join("secret.txt").to_string_lossy()
10256 }),
10257 None,
10258 )
10259 .await
10260 .unwrap_err();
10261 assert!(err.to_string().contains("outside the working directory"));
10262 });
10263 }
10264
10265 #[test]
10266 fn test_grep_rejects_zero_limit() {
10267 asupersync::test_utils::run_test(|| async {
10268 let tmp = tempfile::tempdir().unwrap();
10269 std::fs::write(tmp.path().join("search.txt"), "alpha\nbeta\n").unwrap();
10270
10271 let tool = GrepTool::new(tmp.path());
10272 let err = tool
10273 .execute(
10274 "t",
10275 serde_json::json!({
10276 "pattern": "alpha",
10277 "path": tmp.path().join("search.txt").to_string_lossy(),
10278 "limit": 0
10279 }),
10280 None,
10281 )
10282 .await
10283 .unwrap_err();
10284 assert!(err.to_string().contains("`limit` must be greater than 0"));
10285 });
10286 }
10287
10288 #[test]
10289 #[cfg(unix)]
10290 fn test_grep_formats_paths_relative_to_symlinked_cwd() {
10291 asupersync::test_utils::run_test(|| async {
10292 let real = tempfile::tempdir().unwrap();
10293 let link_parent = tempfile::tempdir().unwrap();
10294 let link = link_parent.path().join("linked-cwd");
10295 std::os::unix::fs::symlink(real.path(), &link).unwrap();
10296 std::fs::write(real.path().join("needle.txt"), "needle\n").unwrap();
10297
10298 let tool = GrepTool::new(&link);
10299 let out = tool
10300 .execute("t", serde_json::json!({ "pattern": "needle" }), None)
10301 .await
10302 .unwrap();
10303
10304 let text = get_text(&out.content);
10305 assert!(
10306 text.contains("needle.txt:1: needle"),
10307 "grep output should use cwd-relative paths for symlinked cwd, got: {text}"
10308 );
10309 assert!(
10310 !text.contains(real.path().to_string_lossy().as_ref()),
10311 "grep output should not leak canonical temp root, got: {text}"
10312 );
10313 });
10314 }
10315
10316 #[test]
10317 fn test_grep_regex_pattern() {
10318 asupersync::test_utils::run_test(|| async {
10319 let tmp = tempfile::tempdir().unwrap();
10320 std::fs::write(
10321 tmp.path().join("regex.txt"),
10322 "foo123\nbar456\nbaz789\nfoo000",
10323 )
10324 .unwrap();
10325
10326 let tool = GrepTool::new(tmp.path());
10327 let out = tool
10328 .execute(
10329 "t",
10330 serde_json::json!({
10331 "pattern": "foo\\d+",
10332 "path": tmp.path().join("regex.txt").to_string_lossy()
10333 }),
10334 None,
10335 )
10336 .await
10337 .unwrap();
10338 let text = get_text(&out.content);
10339 assert!(text.contains("foo123"));
10340 assert!(text.contains("foo000"));
10341 assert!(!text.contains("bar456"));
10342 });
10343 }
10344
10345 #[test]
10346 fn test_grep_case_insensitive() {
10347 asupersync::test_utils::run_test(|| async {
10348 let tmp = tempfile::tempdir().unwrap();
10349 std::fs::write(tmp.path().join("case.txt"), "Hello\nhello\nHELLO").unwrap();
10350
10351 let tool = GrepTool::new(tmp.path());
10352 let out = tool
10353 .execute(
10354 "t",
10355 serde_json::json!({
10356 "pattern": "hello",
10357 "path": tmp.path().join("case.txt").to_string_lossy(),
10358 "ignoreCase": true
10359 }),
10360 None,
10361 )
10362 .await
10363 .unwrap();
10364 let text = get_text(&out.content);
10365 assert!(text.contains("Hello"));
10366 assert!(text.contains("hello"));
10367 assert!(text.contains("HELLO"));
10368 });
10369 }
10370
10371 #[test]
10372 fn test_grep_case_sensitive_by_default() {
10373 asupersync::test_utils::run_test(|| async {
10374 let tmp = tempfile::tempdir().unwrap();
10375 std::fs::write(tmp.path().join("case_sensitive.txt"), "Hello\nHELLO").unwrap();
10376
10377 let tool = GrepTool::new(tmp.path());
10378 let out = tool
10379 .execute(
10380 "t",
10381 serde_json::json!({
10382 "pattern": "hello",
10383 "path": tmp.path().join("case_sensitive.txt").to_string_lossy()
10384 }),
10385 None,
10386 )
10387 .await
10388 .unwrap();
10389 let text = get_text(&out.content);
10390 assert!(
10391 text.contains("No matches found"),
10392 "expected case-sensitive search to find no matches, got: {text}"
10393 );
10394 });
10395 }
10396
10397 #[test]
10398 fn test_grep_append_non_matching_lines_invariant() {
10399 asupersync::test_utils::run_test(|| async {
10400 let tmp = tempfile::tempdir().unwrap();
10401 let file = tmp.path().join("base.txt");
10402 std::fs::write(&file, "needle one\nskip\nneedle two\n").unwrap();
10403
10404 let tool = GrepTool::new(tmp.path());
10405 let base_out = tool
10406 .execute(
10407 "t",
10408 serde_json::json!({
10409 "pattern": "needle",
10410 "path": file.to_string_lossy(),
10411 "limit": 100
10412 }),
10413 None,
10414 )
10415 .await
10416 .unwrap();
10417 let base_text = get_text(&base_out.content);
10418
10419 std::fs::write(&file, "needle one\nskip\nneedle two\nalpha\nbeta\n").unwrap();
10420 let extended_out = tool
10421 .execute(
10422 "t",
10423 serde_json::json!({
10424 "pattern": "needle",
10425 "path": file.to_string_lossy(),
10426 "limit": 100
10427 }),
10428 None,
10429 )
10430 .await
10431 .unwrap();
10432 let extended_text = get_text(&extended_out.content);
10433
10434 assert_eq!(
10435 base_text, extended_text,
10436 "adding non-matching lines should not alter grep output"
10437 );
10438 });
10439 }
10440
10441 #[test]
10442 fn test_grep_no_matches() {
10443 asupersync::test_utils::run_test(|| async {
10444 let tmp = tempfile::tempdir().unwrap();
10445 std::fs::write(tmp.path().join("nothing.txt"), "alpha\nbeta\ngamma").unwrap();
10446
10447 let tool = GrepTool::new(tmp.path());
10448 let out = tool
10449 .execute(
10450 "t",
10451 serde_json::json!({
10452 "pattern": "ZZZZZ_NOMATCH",
10453 "path": tmp.path().join("nothing.txt").to_string_lossy()
10454 }),
10455 None,
10456 )
10457 .await
10458 .unwrap();
10459 let text = get_text(&out.content);
10460 assert!(
10461 text.to_lowercase().contains("no match")
10462 || text.is_empty()
10463 || text.to_lowercase().contains("no results"),
10464 "expected no-match indication, got: {text}"
10465 );
10466 });
10467 }
10468
10469 #[test]
10470 fn test_grep_context_lines() {
10471 asupersync::test_utils::run_test(|| async {
10472 let tmp = tempfile::tempdir().unwrap();
10473 std::fs::write(
10474 tmp.path().join("ctx.txt"),
10475 "aaa\nbbb\nccc\ntarget\nddd\neee\nfff",
10476 )
10477 .unwrap();
10478
10479 let tool = GrepTool::new(tmp.path());
10480 let out = tool
10481 .execute(
10482 "t",
10483 serde_json::json!({
10484 "pattern": "target",
10485 "path": tmp.path().join("ctx.txt").to_string_lossy(),
10486 "context": 1
10487 }),
10488 None,
10489 )
10490 .await
10491 .unwrap();
10492 let text = get_text(&out.content);
10493 assert!(text.contains("target"));
10494 assert!(text.contains("ccc"), "expected context line before match");
10495 assert!(text.contains("ddd"), "expected context line after match");
10496 });
10497 }
10498
10499 #[test]
10500 fn test_grep_limit() {
10501 asupersync::test_utils::run_test(|| async {
10502 let tmp = tempfile::tempdir().unwrap();
10503 let content: String = (0..200)
10504 .map(|i| format!("match_line_{i}"))
10505 .collect::<Vec<_>>()
10506 .join("\n");
10507 std::fs::write(tmp.path().join("many.txt"), &content).unwrap();
10508
10509 let tool = GrepTool::new(tmp.path());
10510 let out = tool
10511 .execute(
10512 "t",
10513 serde_json::json!({
10514 "pattern": "match_line",
10515 "path": tmp.path().join("many.txt").to_string_lossy(),
10516 "limit": 5
10517 }),
10518 None,
10519 )
10520 .await
10521 .unwrap();
10522 let text = get_text(&out.content);
10523 let match_count = text.matches("match_line_").count();
10525 assert!(
10526 match_count <= 5,
10527 "expected at most 5 matches with limit=5, got {match_count}"
10528 );
10529 let details = out.details.expect("expected limit details");
10530 assert_eq!(
10531 details
10532 .get("matchLimitReached")
10533 .and_then(serde_json::Value::as_u64),
10534 Some(5)
10535 );
10536 });
10537 }
10538
10539 #[test]
10540 fn test_grep_exact_limit_does_not_report_limit_reached() {
10541 asupersync::test_utils::run_test(|| async {
10542 let tmp = tempfile::tempdir().unwrap();
10543 let content = (0..5)
10544 .map(|i| format!("match_line_{i}"))
10545 .collect::<Vec<_>>()
10546 .join("\n");
10547 std::fs::write(tmp.path().join("exact.txt"), &content).unwrap();
10548
10549 let tool = GrepTool::new(tmp.path());
10550 let out = tool
10551 .execute(
10552 "t",
10553 serde_json::json!({
10554 "pattern": "match_line",
10555 "path": tmp.path().join("exact.txt").to_string_lossy(),
10556 "limit": 5
10557 }),
10558 None,
10559 )
10560 .await
10561 .unwrap();
10562
10563 let text = get_text(&out.content);
10564 assert_eq!(text.matches("match_line_").count(), 5);
10565 assert!(
10566 !text.contains("matches limit reached"),
10567 "exact-limit grep results should not claim truncation: {text}"
10568 );
10569 assert!(
10570 out.details
10571 .as_ref()
10572 .and_then(|details| details.get("matchLimitReached"))
10573 .is_none(),
10574 "exact-limit grep results should not set matchLimitReached"
10575 );
10576 });
10577 }
10578
10579 #[test]
10580 fn test_grep_large_output_does_not_deadlock_reader_threads() {
10581 asupersync::test_utils::run_test(|| async {
10582 use std::fmt::Write as _;
10583
10584 let tmp = tempfile::tempdir().unwrap();
10585 let mut content = String::with_capacity(80_000);
10586 for i in 0..5000 {
10587 let _ = writeln!(&mut content, "needle_line_{i}");
10588 }
10589 let file = tmp.path().join("large_grep.txt");
10590 std::fs::write(&file, content).unwrap();
10591
10592 let tool = GrepTool::new(tmp.path());
10593 let run = tool.execute(
10594 "t",
10595 serde_json::json!({
10596 "pattern": "needle_line_",
10597 "path": file.to_string_lossy(),
10598 "limit": 6000
10599 }),
10600 None,
10601 );
10602
10603 let out = asupersync::time::timeout(
10604 asupersync::time::wall_now(),
10605 Duration::from_secs(15),
10606 Box::pin(run),
10607 )
10608 .await
10609 .expect("grep timed out; possible stdout/stderr reader deadlock")
10610 .expect("grep should succeed");
10611
10612 let text = get_text(&out.content);
10613 assert!(text.contains("needle_line_0"));
10614 });
10615 }
10616
10617 #[test]
10618 fn test_grep_respects_gitignore() {
10619 asupersync::test_utils::run_test(|| async {
10620 let tmp = tempfile::tempdir().unwrap();
10621 std::fs::write(tmp.path().join(".gitignore"), "ignored.txt\n").unwrap();
10622 std::fs::write(tmp.path().join("ignored.txt"), "needle in ignored file").unwrap();
10623 std::fs::write(tmp.path().join("visible.txt"), "nothing here").unwrap();
10624
10625 let tool = GrepTool::new(tmp.path());
10626 let out = tool
10627 .execute("t", serde_json::json!({ "pattern": "needle" }), None)
10628 .await
10629 .unwrap();
10630
10631 let text = get_text(&out.content);
10632 assert!(
10633 text.contains("No matches found"),
10634 "expected ignored file to be excluded, got: {text}"
10635 );
10636 });
10637 }
10638
10639 #[test]
10640 fn test_grep_literal_mode() {
10641 asupersync::test_utils::run_test(|| async {
10642 let tmp = tempfile::tempdir().unwrap();
10643 std::fs::write(tmp.path().join("literal.txt"), "a+b\na.b\nab\na\\+b").unwrap();
10644
10645 let tool = GrepTool::new(tmp.path());
10646 let out = tool
10647 .execute(
10648 "t",
10649 serde_json::json!({
10650 "pattern": "a+b",
10651 "path": tmp.path().join("literal.txt").to_string_lossy(),
10652 "literal": true
10653 }),
10654 None,
10655 )
10656 .await
10657 .unwrap();
10658 let text = get_text(&out.content);
10659 assert!(text.contains("a+b"), "literal match should find 'a+b'");
10660 });
10661 }
10662
10663 #[test]
10664 fn test_grep_hashline_output() {
10665 asupersync::test_utils::run_test(|| async {
10666 let tmp = tempfile::tempdir().unwrap();
10667 std::fs::write(
10668 tmp.path().join("hash.txt"),
10669 "apple\nbanana\napricot\ncherry",
10670 )
10671 .unwrap();
10672
10673 let tool = GrepTool::new(tmp.path());
10674 let out = tool
10675 .execute(
10676 "t",
10677 serde_json::json!({
10678 "pattern": "ap",
10679 "path": tmp.path().join("hash.txt").to_string_lossy(),
10680 "hashline": true
10681 }),
10682 None,
10683 )
10684 .await
10685 .unwrap();
10686 let text = get_text(&out.content);
10687 assert!(text.contains("apple"), "should contain apple");
10690 assert!(text.contains("apricot"), "should contain apricot");
10691 assert!(
10692 !text.contains("banana"),
10693 "should not contain banana context"
10694 );
10695 let re = regex::Regex::new(r"\d+#[A-Z]{2}").unwrap();
10697 assert!(
10698 re.is_match(&text),
10699 "hashline output should contain N#AB tags, got: {text}"
10700 );
10701 });
10702 }
10703
10704 #[test]
10705 fn test_grep_hashline_with_context() {
10706 asupersync::test_utils::run_test(|| async {
10707 let tmp = tempfile::tempdir().unwrap();
10708 std::fs::write(
10709 tmp.path().join("ctx.txt"),
10710 "line1\nline2\ntarget\nline4\nline5",
10711 )
10712 .unwrap();
10713
10714 let tool = GrepTool::new(tmp.path());
10715 let out = tool
10716 .execute(
10717 "t",
10718 serde_json::json!({
10719 "pattern": "target",
10720 "path": tmp.path().join("ctx.txt").to_string_lossy(),
10721 "hashline": true,
10722 "context": 1
10723 }),
10724 None,
10725 )
10726 .await
10727 .unwrap();
10728 let text = get_text(&out.content);
10729 assert!(text.contains("line2"), "should contain context line2");
10731 assert!(text.contains("target"), "should contain match");
10732 assert!(text.contains("line4"), "should contain context line4");
10733 let re_match = regex::Regex::new(r"\d+#[A-Z]{2}: target").unwrap();
10735 assert!(
10736 re_match.is_match(&text),
10737 "match line should use : separator with hashline tag, got: {text}"
10738 );
10739 let re_ctx = regex::Regex::new(r"\d+#[A-Z]{2}- line").unwrap();
10740 assert!(
10741 re_ctx.is_match(&text),
10742 "context line should use - separator with hashline tag, got: {text}"
10743 );
10744 });
10745 }
10746
10747 #[test]
10752 fn test_find_glob_pattern() {
10753 asupersync::test_utils::run_test(|| async {
10754 if find_fd_binary().is_none() {
10755 return;
10756 }
10757 let tmp = tempfile::tempdir().unwrap();
10758 std::fs::write(tmp.path().join("file1.rs"), "").unwrap();
10759 std::fs::write(tmp.path().join("file2.rs"), "").unwrap();
10760 std::fs::write(tmp.path().join("file3.txt"), "").unwrap();
10761
10762 let tool = FindTool::new(tmp.path());
10763 let out = tool
10764 .execute(
10765 "t",
10766 serde_json::json!({
10767 "pattern": "*.rs",
10768 "path": tmp.path().to_string_lossy()
10769 }),
10770 None,
10771 )
10772 .await
10773 .unwrap();
10774 let text = get_text(&out.content);
10775 assert!(text.contains("file1.rs"));
10776 assert!(text.contains("file2.rs"));
10777 assert!(!text.contains("file3.txt"));
10778 });
10779 }
10780
10781 #[test]
10782 fn test_find_append_non_matching_file_invariant() {
10783 asupersync::test_utils::run_test(|| async {
10784 if find_fd_binary().is_none() {
10785 return;
10786 }
10787 let tmp = tempfile::tempdir().unwrap();
10788 std::fs::write(tmp.path().join("match.txt"), "a").unwrap();
10789
10790 let tool = FindTool::new(tmp.path());
10791 let base_out = tool
10792 .execute(
10793 "t",
10794 serde_json::json!({
10795 "pattern": "*.txt",
10796 "path": tmp.path().to_string_lossy()
10797 }),
10798 None,
10799 )
10800 .await
10801 .unwrap();
10802 let base_text = get_text(&base_out.content);
10803
10804 std::fs::write(tmp.path().join("ignore.md"), "b").unwrap();
10805 let extended_out = tool
10806 .execute(
10807 "t",
10808 serde_json::json!({
10809 "pattern": "*.txt",
10810 "path": tmp.path().to_string_lossy()
10811 }),
10812 None,
10813 )
10814 .await
10815 .unwrap();
10816 let extended_text = get_text(&extended_out.content);
10817
10818 assert_eq!(
10819 base_text, extended_text,
10820 "adding non-matching files should not alter find output"
10821 );
10822 });
10823 }
10824
10825 #[test]
10826 fn test_find_rejects_outside_cwd() {
10827 asupersync::test_utils::run_test(|| async {
10828 let cwd = tempfile::tempdir().unwrap();
10829 let outside = tempfile::tempdir().unwrap();
10830 std::fs::write(outside.path().join("secret.txt"), "secret").unwrap();
10831
10832 let tool = FindTool::new(cwd.path());
10833 let err = tool
10834 .execute(
10835 "t",
10836 serde_json::json!({
10837 "pattern": "*.txt",
10838 "path": outside.path().to_string_lossy()
10839 }),
10840 None,
10841 )
10842 .await
10843 .unwrap_err();
10844 assert!(err.to_string().contains("outside the working directory"));
10845 });
10846 }
10847
10848 #[test]
10849 fn test_find_limit() {
10850 asupersync::test_utils::run_test(|| async {
10851 if find_fd_binary().is_none() {
10852 return;
10853 }
10854 let tmp = tempfile::tempdir().unwrap();
10855 for i in 0..20 {
10856 std::fs::write(tmp.path().join(format!("f{i}.txt")), "").unwrap();
10857 }
10858
10859 let tool = FindTool::new(tmp.path());
10860 let out = tool
10861 .execute(
10862 "t",
10863 serde_json::json!({
10864 "pattern": "*.txt",
10865 "path": tmp.path().to_string_lossy(),
10866 "limit": 5
10867 }),
10868 None,
10869 )
10870 .await
10871 .unwrap();
10872 let text = get_text(&out.content);
10873 let file_count = text.lines().filter(|l| l.contains(".txt")).count();
10874 assert!(
10875 file_count <= 5,
10876 "expected at most 5 files with limit=5, got {file_count}"
10877 );
10878 let details = out.details.expect("expected limit details");
10879 assert_eq!(
10880 details
10881 .get("resultLimitReached")
10882 .and_then(serde_json::Value::as_u64),
10883 Some(5)
10884 );
10885 });
10886 }
10887
10888 #[test]
10889 fn test_find_exact_limit_does_not_report_limit_reached() {
10890 asupersync::test_utils::run_test(|| async {
10891 if find_fd_binary().is_none() {
10892 return;
10893 }
10894 let tmp = tempfile::tempdir().unwrap();
10895 for i in 0..5 {
10896 std::fs::write(tmp.path().join(format!("f{i}.txt")), "").unwrap();
10897 }
10898
10899 let tool = FindTool::new(tmp.path());
10900 let out = tool
10901 .execute(
10902 "t",
10903 serde_json::json!({
10904 "pattern": "*.txt",
10905 "path": tmp.path().to_string_lossy(),
10906 "limit": 5
10907 }),
10908 None,
10909 )
10910 .await
10911 .unwrap();
10912
10913 let text = get_text(&out.content);
10914 assert_eq!(text.lines().filter(|line| line.contains(".txt")).count(), 5);
10915 assert!(
10916 !text.contains("results limit reached"),
10917 "exact-limit find results should not claim truncation: {text}"
10918 );
10919 assert!(
10920 out.details
10921 .as_ref()
10922 .and_then(|details| details.get("resultLimitReached"))
10923 .is_none(),
10924 "exact-limit find results should not set resultLimitReached"
10925 );
10926 });
10927 }
10928
10929 #[test]
10930 fn test_find_zero_limit_is_rejected() {
10931 asupersync::test_utils::run_test(|| async {
10932 if find_fd_binary().is_none() {
10933 return;
10934 }
10935 let tmp = tempfile::tempdir().unwrap();
10936 std::fs::write(tmp.path().join("file.txt"), "").unwrap();
10937
10938 let tool = FindTool::new(tmp.path());
10939 let err = tool
10940 .execute(
10941 "t",
10942 serde_json::json!({
10943 "pattern": "*.txt",
10944 "path": tmp.path().to_string_lossy(),
10945 "limit": 0
10946 }),
10947 None,
10948 )
10949 .await
10950 .expect_err("limit=0 should be rejected");
10951
10952 assert!(
10953 err.to_string().contains("`limit` must be greater than 0"),
10954 "expected validation error, got: {err}"
10955 );
10956 });
10957 }
10958
10959 #[test]
10960 fn test_find_no_matches() {
10961 asupersync::test_utils::run_test(|| async {
10962 if find_fd_binary().is_none() {
10963 return;
10964 }
10965 let tmp = tempfile::tempdir().unwrap();
10966 std::fs::write(tmp.path().join("only.txt"), "").unwrap();
10967
10968 let tool = FindTool::new(tmp.path());
10969 let out = tool
10970 .execute(
10971 "t",
10972 serde_json::json!({
10973 "pattern": "*.rs",
10974 "path": tmp.path().to_string_lossy()
10975 }),
10976 None,
10977 )
10978 .await
10979 .unwrap();
10980 let text = get_text(&out.content);
10981 assert!(
10982 text.to_lowercase().contains("no files found")
10983 || text.to_lowercase().contains("no matches")
10984 || text.is_empty(),
10985 "expected no-match indication, got: {text}"
10986 );
10987 });
10988 }
10989
10990 #[test]
10991 fn test_find_nonexistent_path() {
10992 asupersync::test_utils::run_test(|| async {
10993 if find_fd_binary().is_none() {
10994 return;
10995 }
10996 let tmp = tempfile::tempdir().unwrap();
10997 let tool = FindTool::new(tmp.path());
10998 let err = tool
10999 .execute(
11000 "t",
11001 serde_json::json!({
11002 "pattern": "*.rs",
11003 "path": tmp.path().join("nonexistent").to_string_lossy()
11004 }),
11005 None,
11006 )
11007 .await;
11008 assert!(err.is_err());
11009 });
11010 }
11011
11012 #[test]
11013 fn test_find_nested_directories() {
11014 asupersync::test_utils::run_test(|| async {
11015 if find_fd_binary().is_none() {
11016 return;
11017 }
11018 let tmp = tempfile::tempdir().unwrap();
11019 std::fs::create_dir_all(tmp.path().join("a/b/c")).unwrap();
11020 std::fs::write(tmp.path().join("top.rs"), "").unwrap();
11021 std::fs::write(tmp.path().join("a/mid.rs"), "").unwrap();
11022 std::fs::write(tmp.path().join("a/b/c/deep.rs"), "").unwrap();
11023
11024 let tool = FindTool::new(tmp.path());
11025 let out = tool
11026 .execute(
11027 "t",
11028 serde_json::json!({
11029 "pattern": "*.rs",
11030 "path": tmp.path().to_string_lossy()
11031 }),
11032 None,
11033 )
11034 .await
11035 .unwrap();
11036 let text = get_text(&out.content);
11037 assert!(text.contains("top.rs"));
11038 assert!(text.contains("mid.rs"));
11039 assert!(text.contains("deep.rs"));
11040 });
11041 }
11042
11043 #[test]
11044 fn test_find_results_are_sorted() {
11045 asupersync::test_utils::run_test(|| async {
11048 if find_fd_binary().is_none() {
11049 return;
11050 }
11051 let tmp = tempfile::tempdir().unwrap();
11052
11053 std::fs::write(tmp.path().join("oldest.txt"), "").unwrap();
11056 std::thread::sleep(std::time::Duration::from_millis(50));
11057 std::fs::write(tmp.path().join("middle.txt"), "").unwrap();
11058 std::thread::sleep(std::time::Duration::from_millis(50));
11059 std::fs::write(tmp.path().join("newest.txt"), "").unwrap();
11060
11061 let tool = FindTool::new(tmp.path());
11062 let out = tool
11063 .execute(
11064 "t",
11065 serde_json::json!({
11066 "pattern": "*.txt",
11067 "path": tmp.path().to_string_lossy()
11068 }),
11069 None,
11070 )
11071 .await
11072 .unwrap();
11073 let lines: Vec<String> = get_text(&out.content)
11074 .lines()
11075 .map(str::trim)
11076 .filter(|line| !line.is_empty())
11077 .map(str::to_string)
11078 .collect();
11079
11080 assert_eq!(
11082 lines,
11083 vec!["newest.txt", "middle.txt", "oldest.txt"],
11084 "expected mtime-sorted find output (most recent first)"
11085 );
11086 });
11087 }
11088
11089 #[test]
11090 fn test_find_respects_gitignore() {
11091 asupersync::test_utils::run_test(|| async {
11092 if find_fd_binary().is_none() {
11093 return;
11094 }
11095 let tmp = tempfile::tempdir().unwrap();
11096 std::fs::write(tmp.path().join(".gitignore"), "ignored.txt\n").unwrap();
11097 std::fs::write(tmp.path().join("ignored.txt"), "").unwrap();
11098
11099 let tool = FindTool::new(tmp.path());
11100 let out = tool
11101 .execute(
11102 "t",
11103 serde_json::json!({
11104 "pattern": "*.txt",
11105 "path": tmp.path().to_string_lossy()
11106 }),
11107 None,
11108 )
11109 .await
11110 .unwrap();
11111 let text = get_text(&out.content);
11112 assert!(
11113 text.contains("No files found matching pattern"),
11114 "expected .gitignore'd files to be excluded, got: {text}"
11115 );
11116 });
11117 }
11118
11119 #[test]
11124 fn test_ls_directory_listing() {
11125 asupersync::test_utils::run_test(|| async {
11126 let tmp = tempfile::tempdir().unwrap();
11127 std::fs::write(tmp.path().join("file_a.txt"), "content").unwrap();
11128 std::fs::write(tmp.path().join("file_b.rs"), "fn main() {}").unwrap();
11129 std::fs::create_dir(tmp.path().join("subdir")).unwrap();
11130
11131 let tool = LsTool::new(tmp.path());
11132 let out = tool
11133 .execute(
11134 "t",
11135 serde_json::json!({ "path": tmp.path().to_string_lossy() }),
11136 None,
11137 )
11138 .await
11139 .unwrap();
11140 let text = get_text(&out.content);
11141 assert!(text.contains("file_a.txt"));
11142 assert!(text.contains("file_b.rs"));
11143 assert!(text.contains("subdir"));
11144 });
11145 }
11146
11147 #[test]
11148 fn test_ls_rejects_outside_cwd() {
11149 asupersync::test_utils::run_test(|| async {
11150 let cwd = tempfile::tempdir().unwrap();
11151 let outside = tempfile::tempdir().unwrap();
11152 std::fs::write(outside.path().join("secret.txt"), "secret").unwrap();
11153
11154 let tool = LsTool::new(cwd.path());
11155 let err = tool
11156 .execute(
11157 "t",
11158 serde_json::json!({ "path": outside.path().to_string_lossy() }),
11159 None,
11160 )
11161 .await
11162 .unwrap_err();
11163 assert!(err.to_string().contains("outside the working directory"));
11164 });
11165 }
11166
11167 #[test]
11168 fn test_ls_trailing_slash_for_dirs() {
11169 asupersync::test_utils::run_test(|| async {
11170 let tmp = tempfile::tempdir().unwrap();
11171 std::fs::write(tmp.path().join("file.txt"), "").unwrap();
11172 std::fs::create_dir(tmp.path().join("mydir")).unwrap();
11173
11174 let tool = LsTool::new(tmp.path());
11175 let out = tool
11176 .execute(
11177 "t",
11178 serde_json::json!({ "path": tmp.path().to_string_lossy() }),
11179 None,
11180 )
11181 .await
11182 .unwrap();
11183 let text = get_text(&out.content);
11184 assert!(
11185 text.contains("mydir/"),
11186 "expected trailing slash for directory, got: {text}"
11187 );
11188 });
11189 }
11190
11191 #[test]
11192 fn test_ls_limit() {
11193 asupersync::test_utils::run_test(|| async {
11194 let tmp = tempfile::tempdir().unwrap();
11195 for i in 0..20 {
11196 std::fs::write(tmp.path().join(format!("item_{i:02}.txt")), "").unwrap();
11197 }
11198
11199 let tool = LsTool::new(tmp.path());
11200 let out = tool
11201 .execute(
11202 "t",
11203 serde_json::json!({
11204 "path": tmp.path().to_string_lossy(),
11205 "limit": 5
11206 }),
11207 None,
11208 )
11209 .await
11210 .unwrap();
11211 let text = get_text(&out.content);
11212 let entry_count = text.lines().filter(|l| l.contains("item_")).count();
11213 assert!(
11214 entry_count <= 5,
11215 "expected at most 5 entries, got {entry_count}"
11216 );
11217 let details = out.details.expect("expected limit details");
11218 assert_eq!(
11219 details
11220 .get("entryLimitReached")
11221 .and_then(serde_json::Value::as_u64),
11222 Some(5)
11223 );
11224 });
11225 }
11226
11227 #[test]
11228 fn test_ls_zero_limit_is_rejected() {
11229 asupersync::test_utils::run_test(|| async {
11230 let tmp = tempfile::tempdir().unwrap();
11231 std::fs::write(tmp.path().join("item.txt"), "").unwrap();
11232
11233 let tool = LsTool::new(tmp.path());
11234 let err = tool
11235 .execute(
11236 "t",
11237 serde_json::json!({
11238 "path": tmp.path().to_string_lossy(),
11239 "limit": 0
11240 }),
11241 None,
11242 )
11243 .await
11244 .expect_err("limit=0 should be rejected");
11245
11246 assert!(
11247 err.to_string().contains("`limit` must be greater than 0"),
11248 "expected validation error, got: {err}"
11249 );
11250 });
11251 }
11252
11253 #[test]
11254 fn test_ls_nonexistent_directory() {
11255 asupersync::test_utils::run_test(|| async {
11256 let tmp = tempfile::tempdir().unwrap();
11257 let tool = LsTool::new(tmp.path());
11258 let err = tool
11259 .execute(
11260 "t",
11261 serde_json::json!({ "path": tmp.path().join("nope").to_string_lossy() }),
11262 None,
11263 )
11264 .await;
11265 assert!(err.is_err());
11266 });
11267 }
11268
11269 #[test]
11270 fn test_ls_empty_directory() {
11271 asupersync::test_utils::run_test(|| async {
11272 let tmp = tempfile::tempdir().unwrap();
11273 let empty_dir = tmp.path().join("empty");
11274 std::fs::create_dir(&empty_dir).unwrap();
11275
11276 let tool = LsTool::new(tmp.path());
11277 let out = tool
11278 .execute(
11279 "t",
11280 serde_json::json!({ "path": empty_dir.to_string_lossy() }),
11281 None,
11282 )
11283 .await
11284 .unwrap();
11285 assert!(!out.is_error);
11286 });
11287 }
11288
11289 #[test]
11290 fn test_ls_default_cwd() {
11291 asupersync::test_utils::run_test(|| async {
11292 let tmp = tempfile::tempdir().unwrap();
11293 std::fs::write(tmp.path().join("in_cwd.txt"), "").unwrap();
11294
11295 let tool = LsTool::new(tmp.path());
11296 let out = tool
11297 .execute("t", serde_json::json!({}), None)
11298 .await
11299 .unwrap();
11300 let text = get_text(&out.content);
11301 assert!(
11302 text.contains("in_cwd.txt"),
11303 "expected cwd listing to include the file, got: {text}"
11304 );
11305 });
11306 }
11307
11308 #[test]
11313 fn test_truncate_head_no_truncation() {
11314 let content = "short".to_string();
11315 let result = truncate_head(content, 100, 1000);
11316 assert!(!result.truncated);
11317 assert_eq!(result.content, "short");
11318 assert_eq!(result.truncated_by, None);
11319 }
11320
11321 #[test]
11322 fn test_truncate_tail_no_truncation() {
11323 let content = "short".to_string();
11324 let result = truncate_tail(content, 100, 1000);
11325 assert!(!result.truncated);
11326 assert_eq!(result.content, "short");
11327 }
11328
11329 #[test]
11330 fn test_truncate_head_empty_input() {
11331 let result = truncate_head(String::new(), 100, 1000);
11332 assert!(!result.truncated);
11333 assert_eq!(result.content, "");
11334 }
11335
11336 #[test]
11337 fn test_truncate_tail_empty_input() {
11338 let result = truncate_tail(String::new(), 100, 1000);
11339 assert!(!result.truncated);
11340 assert_eq!(result.content, "");
11341 }
11342
11343 #[test]
11344 fn test_detect_line_ending_crlf() {
11345 assert_eq!(detect_line_ending("hello\r\nworld"), "\r\n");
11346 }
11347
11348 #[test]
11349 fn test_detect_line_ending_cr() {
11350 assert_eq!(detect_line_ending("hello\rworld"), "\r");
11351 }
11352
11353 #[test]
11354 fn test_detect_line_ending_lf() {
11355 assert_eq!(detect_line_ending("hello\nworld"), "\n");
11356 }
11357
11358 #[test]
11359 fn test_detect_line_ending_no_newline() {
11360 assert_eq!(detect_line_ending("hello world"), "\n");
11361 }
11362
11363 #[test]
11364 fn test_normalize_to_lf() {
11365 assert_eq!(normalize_to_lf("a\r\nb\rc\nd"), "a\nb\nc\nd");
11366 }
11367
11368 #[test]
11369 fn test_count_overlapping_occurrences() {
11370 assert_eq!(count_overlapping_occurrences("aaaa", "aa"), 3);
11371 assert_eq!(count_overlapping_occurrences("abababa", "aba"), 3);
11372 assert_eq!(count_overlapping_occurrences("abc", "d"), 0);
11373 assert_eq!(count_overlapping_occurrences("abc", ""), 0);
11374 }
11375
11376 proptest! {
11377 #![proptest_config(ProptestConfig { cases: 64, .. ProptestConfig::default() })]
11378
11379 #[test]
11380 fn proptest_line_ending_roundtrip_invariant(
11381 input in arbitrary_text(),
11382 ending in prop_oneof![
11383 Just("\n".to_string()),
11384 Just("\r\n".to_string()),
11385 Just("\r".to_string()),
11386 ],
11387 ) {
11388 let normalized = normalize_to_lf(&input);
11389 let restored = restore_line_endings(&normalized, &ending);
11390 let renormalized = normalize_to_lf(&restored);
11391 prop_assert_eq!(renormalized, normalized);
11392 }
11393 }
11394
11395 #[test]
11396 fn test_strip_bom_present() {
11397 let (result, had_bom) = strip_bom("\u{FEFF}hello");
11398 assert_eq!(result, "hello");
11399 assert!(had_bom);
11400 }
11401
11402 #[test]
11403 fn test_strip_bom_absent() {
11404 let (result, had_bom) = strip_bom("hello");
11405 assert_eq!(result, "hello");
11406 assert!(!had_bom);
11407 }
11408
11409 #[test]
11410 fn test_resolve_path_tilde_expansion() {
11411 let cwd = PathBuf::from("/home/user/project");
11412 let result = resolve_path("~/file.txt", &cwd);
11413 assert!(!result.to_string_lossy().starts_with("~/"));
11415 }
11416
11417 fn arbitrary_text() -> impl Strategy<Value = String> {
11418 prop::collection::vec(any::<u8>(), 0..512)
11419 .prop_map(|bytes| String::from_utf8_lossy(&bytes).into_owned())
11420 }
11421
11422 fn match_char_strategy() -> impl Strategy<Value = char> {
11423 prop_oneof![
11424 8 => any::<char>(),
11425 1 => Just('\u{00A0}'),
11426 1 => Just('\u{202F}'),
11427 1 => Just('\u{205F}'),
11428 1 => Just('\u{3000}'),
11429 1 => Just('\u{2018}'),
11430 1 => Just('\u{2019}'),
11431 1 => Just('\u{201C}'),
11432 1 => Just('\u{201D}'),
11433 1 => Just('\u{201E}'),
11434 1 => Just('\u{201F}'),
11435 1 => Just('\u{2010}'),
11436 1 => Just('\u{2011}'),
11437 1 => Just('\u{2012}'),
11438 1 => Just('\u{2013}'),
11439 1 => Just('\u{2014}'),
11440 1 => Just('\u{2015}'),
11441 1 => Just('\u{2212}'),
11442 1 => Just('\u{200D}'),
11443 1 => Just('\u{0301}'),
11444 ]
11445 }
11446
11447 fn arbitrary_match_text() -> impl Strategy<Value = String> {
11448 prop_oneof![
11449 9 => prop::collection::vec(match_char_strategy(), 0..2048),
11450 1 => prop::collection::vec(match_char_strategy(), 8192..16384),
11451 ]
11452 .prop_map(|chars| chars.into_iter().collect())
11453 }
11454
11455 fn line_char_strategy() -> impl Strategy<Value = char> {
11456 prop_oneof![
11457 8 => any::<char>().prop_filter("single-line chars only", |c| *c != '\n'),
11458 1 => Just('é'),
11459 1 => Just('你'),
11460 1 => Just('😀'),
11461 ]
11462 }
11463
11464 fn boundary_line_text() -> impl Strategy<Value = String> {
11465 prop_oneof![
11466 Just(0usize),
11467 Just(GREP_MAX_LINE_LENGTH.saturating_sub(1)),
11468 Just(GREP_MAX_LINE_LENGTH),
11469 Just(GREP_MAX_LINE_LENGTH + 1),
11470 0usize..(GREP_MAX_LINE_LENGTH + 128),
11471 ]
11472 .prop_flat_map(|len| {
11473 prop::collection::vec(line_char_strategy(), len)
11474 .prop_map(|chars| chars.into_iter().collect())
11475 })
11476 }
11477
11478 fn safe_relative_segment() -> impl Strategy<Value = String> {
11479 prop_oneof![
11480 proptest::string::string_regex("[A-Za-z0-9._-]{1,12}")
11481 .expect("segment regex should compile"),
11482 Just("emoji😀".to_string()),
11483 Just("accent-é".to_string()),
11484 Just("rtl-עברית".to_string()),
11485 Just("line\nbreak".to_string()),
11486 Just("nul\0byte".to_string()),
11487 ]
11488 .prop_filter("segment cannot be . or ..", |segment| {
11489 segment != "." && segment != ".."
11490 })
11491 }
11492
11493 fn safe_relative_path() -> impl Strategy<Value = String> {
11494 prop::collection::vec(safe_relative_segment(), 1..6).prop_map(|segments| segments.join("/"))
11495 }
11496
11497 fn pathish_input() -> impl Strategy<Value = String> {
11498 prop_oneof![
11499 5 => safe_relative_path(),
11500 2 => safe_relative_path().prop_map(|p| format!("../{p}")),
11501 2 => safe_relative_path().prop_map(|p| format!("../../{p}")),
11502 1 => safe_relative_path().prop_map(|p| format!("/tmp/{p}")),
11503 1 => safe_relative_path().prop_map(|p| format!("~/{p}")),
11504 1 => Just("~".to_string()),
11505 1 => Just(".".to_string()),
11506 1 => Just("..".to_string()),
11507 1 => Just("././nested/../file.txt".to_string()),
11508 ]
11509 }
11510
11511 proptest! {
11512 #![proptest_config(ProptestConfig { cases: 64, .. ProptestConfig::default() })]
11513
11514 #[test]
11515 fn proptest_truncate_head_invariants(
11516 input in arbitrary_text(),
11517 max_lines in 0usize..32,
11518 max_bytes in 0usize..256,
11519 ) {
11520 let result = truncate_head(input.clone(), max_lines, max_bytes);
11521
11522 prop_assert!(result.output_lines <= max_lines);
11523 prop_assert!(result.output_bytes <= max_bytes);
11524 prop_assert_eq!(result.output_bytes, result.content.len());
11525
11526 prop_assert_eq!(result.truncated, result.truncated_by.is_some());
11527 prop_assert!(input.starts_with(&result.content));
11528
11529 let repeat = truncate_head(result.content.clone(), max_lines, max_bytes);
11530 prop_assert_eq!(&repeat.content, &result.content);
11531
11532 if result.truncated {
11533 prop_assert!(result.total_lines > max_lines || result.total_bytes > max_bytes);
11534 } else {
11535 prop_assert_eq!(&result.content, &input);
11536 prop_assert!(result.total_lines <= max_lines);
11537 prop_assert!(result.total_bytes <= max_bytes);
11538 }
11539
11540 if result.first_line_exceeds_limit {
11541 prop_assert!(result.truncated);
11542 prop_assert_eq!(result.truncated_by, Some(TruncatedBy::Bytes));
11543 prop_assert!(result.output_bytes <= max_bytes);
11544 prop_assert!(result.output_lines <= 1);
11545 prop_assert!(input.starts_with(&result.content));
11546 }
11547 }
11548
11549 #[test]
11550 fn proptest_truncate_tail_invariants(
11551 input in arbitrary_text(),
11552 max_lines in 0usize..32,
11553 max_bytes in 0usize..256,
11554 ) {
11555 let result = truncate_tail(input.clone(), max_lines, max_bytes);
11556
11557 prop_assert!(result.output_lines <= max_lines);
11558 prop_assert!(result.output_bytes <= max_bytes);
11559 prop_assert_eq!(result.output_bytes, result.content.len());
11560
11561 prop_assert_eq!(result.truncated, result.truncated_by.is_some());
11562 prop_assert!(input.ends_with(&result.content));
11563
11564 let repeat = truncate_tail(result.content.clone(), max_lines, max_bytes);
11565 prop_assert_eq!(&repeat.content, &result.content);
11566
11567 if result.last_line_partial {
11568 prop_assert!(result.truncated);
11569 prop_assert_eq!(result.truncated_by, Some(TruncatedBy::Bytes));
11570 prop_assert!(result.output_lines >= 1 && result.output_lines <= 2);
11573 let content_trimmed = result.content.trim_end_matches('\n');
11574 prop_assert!(input
11575 .split('\n')
11576 .rev()
11577 .any(|line| line.ends_with(content_trimmed)));
11578 }
11579 }
11580
11581 #[test]
11582 fn proptest_truncate_head_monotonic_limits(
11583 input in arbitrary_text(),
11584 max_lines_a in 0usize..32,
11585 max_lines_b in 0usize..32,
11586 max_bytes_a in 0usize..256,
11587 max_bytes_b in 0usize..256,
11588 ) {
11589 let low_lines = max_lines_a.min(max_lines_b);
11590 let high_lines = max_lines_a.max(max_lines_b);
11591 let low_bytes = max_bytes_a.min(max_bytes_b);
11592 let high_bytes = max_bytes_a.max(max_bytes_b);
11593
11594 let small = truncate_head(input.clone(), low_lines, low_bytes);
11595 let large = truncate_head(input, high_lines, high_bytes);
11596
11597 prop_assert!(large.content.starts_with(&small.content));
11598 prop_assert!(large.output_bytes >= small.output_bytes);
11599 prop_assert!(large.output_lines >= small.output_lines);
11600 }
11601
11602 #[test]
11603 fn proptest_truncate_tail_monotonic_limits(
11604 input in arbitrary_text(),
11605 max_lines_a in 0usize..32,
11606 max_lines_b in 0usize..32,
11607 max_bytes_a in 0usize..256,
11608 max_bytes_b in 0usize..256,
11609 ) {
11610 let low_lines = max_lines_a.min(max_lines_b);
11611 let high_lines = max_lines_a.max(max_lines_b);
11612 let low_bytes = max_bytes_a.min(max_bytes_b);
11613 let high_bytes = max_bytes_a.max(max_bytes_b);
11614
11615 let small = truncate_tail(input.clone(), low_lines, low_bytes);
11616 let large = truncate_tail(input, high_lines, high_bytes);
11617
11618 prop_assert!(large.content.ends_with(&small.content));
11619 prop_assert!(large.output_bytes >= small.output_bytes);
11620 prop_assert!(large.output_lines >= small.output_lines);
11621 }
11622
11623 #[test]
11624 fn proptest_truncate_head_prefix_invariant_under_append(
11625 base in arbitrary_text(),
11626 suffix in arbitrary_text(),
11627 max_lines in 0usize..32,
11628 max_bytes in 0usize..256,
11629 ) {
11630 let base_result = truncate_head(base.clone(), max_lines, max_bytes);
11631 let extended_result = truncate_head(format!("{base}{suffix}"), max_lines, max_bytes);
11632 prop_assert!(extended_result.content.starts_with(&base_result.content));
11633 }
11634
11635 #[test]
11636 fn proptest_truncate_tail_suffix_invariant_under_prepend(
11637 base in arbitrary_text(),
11638 prefix in arbitrary_text(),
11639 max_lines in 0usize..32,
11640 max_bytes in 0usize..256,
11641 ) {
11642 let base_result = truncate_tail(base.clone(), max_lines, max_bytes);
11643 let extended_result = truncate_tail(format!("{prefix}{base}"), max_lines, max_bytes);
11644 prop_assert!(extended_result.content.ends_with(&base_result.content));
11645 }
11646 }
11647
11648 proptest! {
11649 #![proptest_config(ProptestConfig { cases: 128, .. ProptestConfig::default() })]
11650
11651 #[test]
11652 fn proptest_normalize_for_match_invariants(input in arbitrary_match_text()) {
11653 let normalized = normalize_for_match(&input);
11654 let renormalized = normalize_for_match(&normalized);
11655
11656 prop_assert_eq!(&renormalized, &normalized);
11657 prop_assert!(normalized.len() <= input.len());
11658 prop_assert!(
11659 normalized.chars().all(|c| {
11660 !is_special_unicode_space(c)
11661 && !matches!(
11662 c,
11663 '\u{2018}'
11664 | '\u{2019}'
11665 | '\u{201C}'
11666 | '\u{201D}'
11667 | '\u{201E}'
11668 | '\u{201F}'
11669 | '\u{2010}'
11670 | '\u{2011}'
11671 | '\u{2012}'
11672 | '\u{2013}'
11673 | '\u{2014}'
11674 | '\u{2015}'
11675 | '\u{2212}'
11676 )
11677 }),
11678 "normalize_for_match should remove target punctuation/space variants"
11679 );
11680 }
11681
11682 #[test]
11683 fn proptest_truncate_line_boundary_invariants(line in boundary_line_text()) {
11684 const TRUNCATION_SUFFIX: &str = "... [truncated]";
11685
11686 let result = truncate_line(&line, GREP_MAX_LINE_LENGTH);
11687 let line_char_count = line.chars().count();
11688 let suffix_chars = TRUNCATION_SUFFIX.chars().count();
11689
11690 if line_char_count <= GREP_MAX_LINE_LENGTH {
11691 prop_assert!(!result.was_truncated);
11692 prop_assert_eq!(result.text, line);
11693 } else {
11694 prop_assert!(result.was_truncated);
11695 prop_assert!(result.text.ends_with(TRUNCATION_SUFFIX));
11696 let expected_prefix: String = line.chars().take(GREP_MAX_LINE_LENGTH).collect();
11697 let expected = format!("{expected_prefix}{TRUNCATION_SUFFIX}");
11698 prop_assert_eq!(&result.text, &expected);
11699 prop_assert!(result.text.chars().count() <= GREP_MAX_LINE_LENGTH + suffix_chars);
11700 }
11701 }
11702
11703 #[test]
11704 fn proptest_resolve_path_safe_relative_invariants(relative_path in safe_relative_path()) {
11705 let cwd = PathBuf::from("/tmp/pi-agent-rust-tools-proptest");
11706 let resolved = resolve_path(&relative_path, &cwd);
11707 let normalized = normalize_dot_segments(&resolved);
11708
11709 prop_assert_eq!(&resolved, &cwd.join(&relative_path));
11710 prop_assert!(resolved.starts_with(&cwd));
11711 prop_assert!(normalized.starts_with(&cwd));
11712 prop_assert_eq!(normalize_dot_segments(&normalized), normalized);
11713 }
11714
11715 #[test]
11716 fn proptest_normalize_dot_segments_pathish_invariants(path_input in pathish_input()) {
11717 let cwd = PathBuf::from("/tmp/pi-agent-rust-tools-proptest");
11718 let resolved = resolve_path(&path_input, &cwd);
11719 let normalized_once = normalize_dot_segments(&resolved);
11720 let normalized_twice = normalize_dot_segments(&normalized_once);
11721
11722 prop_assert_eq!(&normalized_once, &normalized_twice);
11723 prop_assert!(
11724 normalized_once
11725 .components()
11726 .all(|component| !matches!(component, std::path::Component::CurDir))
11727 );
11728
11729 if std::path::Path::new(&path_input).is_absolute() {
11730 prop_assert!(resolved.is_absolute());
11731 prop_assert!(normalized_once.is_absolute());
11732 }
11733 }
11734 }
11735
11736 fn fuzzy_content_strategy() -> impl Strategy<Value = String> {
11744 prop::collection::vec(
11745 prop_oneof![
11746 8 => any::<char>().prop_filter("no nul", |c| *c != '\0'),
11747 1 => Just('\u{00A0}'),
11748 1 => Just('\u{2019}'),
11749 1 => Just('\u{201C}'),
11750 1 => Just('\u{2014}'),
11751 ],
11752 1..512,
11753 )
11754 .prop_map(|chars| chars.into_iter().collect())
11755 }
11756
11757 fn needle_from_content(content: String) -> impl Strategy<Value = (String, String)> {
11760 let len = content.len();
11761 if len == 0 {
11762 return Just((content, String::new())).boxed();
11763 }
11764 (0..len)
11765 .prop_flat_map(move |start| {
11766 let c = content.clone();
11767 let remaining = c.len() - start;
11768 let max_needle = remaining.min(256);
11769 (Just(c), start..=start + max_needle.saturating_sub(1))
11770 })
11771 .prop_filter_map("valid char boundary", |(c, end)| {
11772 let start_candidates: Vec<usize> =
11774 (0..c.len()).filter(|i| c.is_char_boundary(*i)).collect();
11775 if start_candidates.is_empty() {
11776 return None;
11777 }
11778 let start = *start_candidates
11779 .iter()
11780 .min_by_key(|&&i| i.abs_diff(end.saturating_sub(end / 2)))
11781 .unwrap_or(&0);
11782 let end_clamped = end.min(c.len());
11783 let actual_end = (end_clamped..=c.len())
11785 .find(|i| c.is_char_boundary(*i))
11786 .unwrap_or(c.len());
11787 if start >= actual_end {
11788 return Some((c, String::new()));
11789 }
11790 Some((c.clone(), c[start..actual_end].to_string()))
11791 })
11792 .boxed()
11793 }
11794
11795 proptest! {
11796 #![proptest_config(ProptestConfig { cases: 128, .. ProptestConfig::default() })]
11797
11798 #[test]
11800 fn proptest_fuzzy_find_text_exact_match_invariants(
11801 (content, needle) in fuzzy_content_strategy().prop_flat_map(needle_from_content)
11802 ) {
11803 let result = fuzzy_find_text(&content, &needle);
11804 if needle.is_empty() {
11805 prop_assert!(result.found, "empty needle should always match");
11807 prop_assert_eq!(result.index, 0);
11808 prop_assert_eq!(result.match_length, 0);
11809 } else {
11810 prop_assert!(
11811 result.found,
11812 "exact substring must be found: content len={}, needle len={}",
11813 content.len(),
11814 needle.len()
11815 );
11816 prop_assert!(content.is_char_boundary(result.index));
11818 prop_assert!(content.is_char_boundary(result.index + result.match_length));
11819 let matched = &content[result.index..result.index + result.match_length];
11821 prop_assert_eq!(matched, needle.as_str());
11822 }
11823 }
11824
11825 #[test]
11830 fn proptest_fuzzy_find_text_normalized_match_invariants(
11831 content in arbitrary_match_text()
11832 ) {
11833 let normalized = build_normalized_content(&content);
11835 if normalized.is_empty() {
11836 return Ok(());
11837 }
11838 let needle_end = normalized
11840 .char_indices()
11841 .nth(128.min(normalized.chars().count().saturating_sub(1)))
11842 .map_or(normalized.len(), |(i, _)| i);
11843 let needle_end = (needle_end..=normalized.len())
11845 .find(|i| normalized.is_char_boundary(*i))
11846 .unwrap_or(normalized.len());
11847 let needle = &normalized[..needle_end];
11848 if needle.is_empty() {
11849 return Ok(());
11850 }
11851
11852 let result = fuzzy_find_text(&content, needle);
11853 prop_assert!(
11854 result.found,
11855 "normalized needle should be found via fuzzy match: needle={:?}",
11856 needle
11857 );
11858 prop_assert!(content.is_char_boundary(result.index));
11860 prop_assert!(content.is_char_boundary(result.index + result.match_length));
11861 }
11862
11863 #[test]
11866 fn proptest_build_normalized_content_invariants(input in arbitrary_match_text()) {
11867 let normalized = build_normalized_content(&input);
11868 let renormalized = build_normalized_content(&normalized);
11869
11870 prop_assert_eq!(
11872 &renormalized,
11873 &normalized,
11874 "build_normalized_content should be idempotent"
11875 );
11876
11877 prop_assert!(
11881 normalized.len() <= input.len(),
11882 "normalized should not be larger: {} vs {}",
11883 normalized.len(),
11884 input.len()
11885 );
11886
11887 let input_lines = input.split('\n').count();
11890 let norm_lines = normalized.split('\n').count();
11891 prop_assert_eq!(
11892 norm_lines, input_lines,
11893 "line count must be preserved by normalization"
11894 );
11895
11896 prop_assert!(
11898 normalized.chars().all(|c| {
11899 !is_special_unicode_space(c)
11900 && !matches!(
11901 c,
11902 '\u{2018}'
11903 | '\u{2019}'
11904 | '\u{201C}'
11905 | '\u{201D}'
11906 | '\u{201E}'
11907 | '\u{201F}'
11908 | '\u{2010}'
11909 | '\u{2011}'
11910 | '\u{2012}'
11911 | '\u{2013}'
11912 | '\u{2014}'
11913 | '\u{2015}'
11914 | '\u{2212}'
11915 )
11916 }),
11917 "normalized content should not contain target Unicode chars"
11918 );
11919 }
11920
11921 #[test]
11924 fn proptest_build_normalized_content_trailing_whitespace_invariant(
11925 input in arbitrary_match_text()
11926 ) {
11927 let normalized = build_normalized_content(&input);
11928 let mut with_trailing = String::new();
11929 let mut lines = input.split('\n').peekable();
11930
11931 while let Some(line) = lines.next() {
11932 with_trailing.push_str(line);
11933 with_trailing.push_str(" \t");
11934 if lines.peek().is_some() {
11935 with_trailing.push('\n');
11936 }
11937 }
11938
11939 let normalized_trailing = build_normalized_content(&with_trailing);
11940 prop_assert_eq!(normalized_trailing, normalized);
11941 }
11942
11943 #[test]
11951 fn proptest_map_normalized_range_roundtrip(input in arbitrary_match_text()) {
11952 let normalized = build_normalized_content(&input);
11953 if normalized.is_empty() {
11954 return Ok(());
11955 }
11956
11957 let norm_chars: Vec<(usize, char)> = normalized.char_indices().collect();
11959 let norm_len = norm_chars.len();
11960 if norm_len == 0 {
11961 return Ok(());
11962 }
11963
11964 let end_char = (norm_len / 4).max(1).min(norm_len);
11966 let norm_start = norm_chars[0].0;
11967 let norm_end = if end_char < norm_chars.len() {
11968 norm_chars[end_char].0
11969 } else {
11970 normalized.len()
11971 };
11972 let norm_match_len = norm_end - norm_start;
11973
11974 let (orig_start, orig_len) =
11975 map_normalized_range_to_original(&input, norm_start, norm_match_len);
11976
11977 prop_assert!(
11979 orig_start + orig_len <= input.len(),
11980 "mapped range {orig_start}..{} exceeds input len {}",
11981 orig_start + orig_len,
11982 input.len()
11983 );
11984
11985 prop_assert!(
11987 input.is_char_boundary(orig_start),
11988 "orig_start {} is not a char boundary",
11989 orig_start
11990 );
11991 prop_assert!(
11992 input.is_char_boundary(orig_start + orig_len),
11993 "orig_end {} is not a char boundary",
11994 orig_start + orig_len
11995 );
11996
11997 prop_assert!(
12001 orig_len >= norm_match_len
12002 || orig_len == 0
12003 || norm_match_len == 0,
12004 "original range ({orig_len}) should be >= normalized range ({norm_match_len})"
12005 );
12006
12007 let expected_norm = &normalized[norm_start..norm_end];
12011 if !expected_norm.is_empty() {
12012 let fuzzy_result = fuzzy_find_text(&input, expected_norm);
12013 prop_assert!(
12014 fuzzy_result.found,
12015 "normalized needle should be findable in original content"
12016 );
12017 }
12018 }
12019 }
12020
12021 #[test]
12022 fn test_truncate_head_preserves_newline() {
12023 let content = "Line1\nLine2".to_string();
12025 let result = truncate_head(content, 1, 1000);
12026 assert_eq!(result.content, "Line1\n");
12027
12028 let content = "Line1".to_string();
12030 let result = truncate_head(content, 1, 1000);
12031 assert_eq!(result.content, "Line1");
12032
12033 let content = "Line1\n".to_string();
12035 let result = truncate_head(content, 1, 1000);
12036 assert_eq!(result.content, "Line1\n");
12037 }
12038
12039 #[test]
12040 fn test_edit_crlf_content_correctness() {
12041 asupersync::test_utils::run_test(|| async {
12043 let tmp = tempfile::tempdir().unwrap();
12044 let path = tmp.path().join("crlf.txt");
12045 let content = "line1\r\nline2\r\nline3";
12047 std::fs::write(&path, content).unwrap();
12048
12049 let tool = EditTool::new(tmp.path());
12050
12051 let out = tool
12056 .execute(
12057 "t",
12058 serde_json::json!({
12059 "path": path.to_string_lossy(),
12060 "oldText": "line2",
12061 "newText": "changed"
12062 }),
12063 None,
12064 )
12065 .await
12066 .unwrap();
12067
12068 assert!(!out.is_error);
12069 let new_content = std::fs::read_to_string(&path).unwrap();
12070
12071 assert_eq!(new_content, "line1\r\nchanged\r\nline3");
12073 });
12074 }
12075
12076 #[test]
12077 fn test_edit_cr_content_correctness() {
12078 asupersync::test_utils::run_test(|| async {
12079 let tmp = tempfile::tempdir().unwrap();
12080 let path = tmp.path().join("cr.txt");
12081 std::fs::write(&path, "line1\rline2\rline3").unwrap();
12082
12083 let tool = EditTool::new(tmp.path());
12084 let out = tool
12085 .execute(
12086 "t",
12087 serde_json::json!({
12088 "path": path.to_string_lossy(),
12089 "oldText": "line2",
12090 "newText": "changed"
12091 }),
12092 None,
12093 )
12094 .await
12095 .unwrap();
12096
12097 assert!(!out.is_error);
12098 let new_content = std::fs::read_to_string(&path).unwrap();
12099 assert_eq!(new_content, "line1\rchanged\rline3");
12100 });
12101 }
12102
12103 #[test]
12108 fn test_compute_line_hash_basic() {
12109 let h1 = compute_line_hash(0, "fn main() {");
12111 let h2 = compute_line_hash(0, "fn main() {");
12112 assert_eq!(h1, h2);
12113
12114 let h3 = compute_line_hash(0, "fn foo() {");
12116 assert_ne!(h1, h3);
12118
12119 for &b in &h1 {
12121 assert!(NIBBLE_STR.contains(&b), "hash byte {b} not in NIBBLE_STR");
12122 }
12123 }
12124
12125 #[test]
12126 fn test_compute_line_hash_punctuation_only() {
12127 let h1 = compute_line_hash(0, "}");
12130 let h2 = compute_line_hash(1, "}");
12131 assert_ne!(
12132 h1, h2,
12133 "punctuation-only lines at different indices should differ"
12134 );
12135
12136 let h3 = compute_line_hash(0, "");
12138 let h4 = compute_line_hash(1, "");
12139 assert_ne!(h3, h4);
12140 }
12141
12142 #[test]
12143 fn test_compute_line_hash_whitespace_invariant() {
12144 let h1 = compute_line_hash(0, "return 42;");
12146 let h2 = compute_line_hash(0, " return 42;");
12147 let h3 = compute_line_hash(0, "\treturn 42;");
12148 assert_eq!(h1, h2);
12149 assert_eq!(h1, h3);
12150 }
12151
12152 #[test]
12153 fn test_format_hashline_tag() {
12154 let tag = format_hashline_tag(0, "fn main() {");
12155 assert!(
12157 tag.starts_with("1#"),
12158 "tag should start with 1#, got: {tag}"
12159 );
12160 assert_eq!(tag.len(), 4, "tag should be 4 chars: N#AB");
12161
12162 let tag10 = format_hashline_tag(9, "line 10");
12163 assert!(tag10.starts_with("10#"));
12164 assert_eq!(tag10.len(), 5); }
12166
12167 #[test]
12168 fn test_parse_hashline_tag_valid() {
12169 let (line, hash) = parse_hashline_tag("5#KJ").unwrap();
12171 assert_eq!(line, 5);
12172 assert_eq!(hash, [b'K', b'J']);
12173
12174 let (line, hash) = parse_hashline_tag(" 10 # QR ").unwrap();
12176 assert_eq!(line, 10);
12177 assert_eq!(hash, [b'Q', b'R']);
12178
12179 let (line, hash) = parse_hashline_tag("> + 3#ZZ").unwrap();
12181 assert_eq!(line, 3);
12182 assert_eq!(hash, [b'Z', b'Z']);
12183 }
12184
12185 #[test]
12186 fn test_parse_hashline_tag_invalid() {
12187 assert!(parse_hashline_tag("0#KJ").is_err());
12189 assert!(parse_hashline_tag("5#").is_err());
12191 assert!(parse_hashline_tag("5#AA").is_err()); assert!(parse_hashline_tag("#KJ").is_err());
12195 assert!(parse_hashline_tag("").is_err());
12197 }
12198
12199 #[test]
12200 fn test_strip_hashline_prefix() {
12201 assert_eq!(strip_hashline_prefix("5#KJ:hello world"), "hello world");
12202 assert_eq!(strip_hashline_prefix("100#ZZ:fn main() {"), "fn main() {");
12203 assert_eq!(strip_hashline_prefix(" 5 # KJ:hello world"), "hello world");
12204 assert_eq!(strip_hashline_prefix("> + 5#KJ:hello world"), "hello world");
12205 assert_eq!(strip_hashline_prefix("5#KJ :hello world"), "hello world");
12206 assert_eq!(strip_hashline_prefix("hello world"), "hello world");
12208 assert_eq!(strip_hashline_prefix(""), "");
12209 }
12210
12211 #[test]
12212 fn test_hashline_edit_single_replace() {
12213 asupersync::test_utils::run_test(|| async {
12214 let dir = tempfile::tempdir().unwrap();
12215 let file = dir.path().join("test.txt");
12216 std::fs::write(&file, "line1\nline2\nline3\n").unwrap();
12217
12218 let tool = HashlineEditTool::new(dir.path());
12219
12220 let tag2 = format_hashline_tag(1, "line2");
12222
12223 let input = serde_json::json!({
12224 "path": file.to_str().unwrap(),
12225 "edits": [{
12226 "op": "replace",
12227 "pos": tag2,
12228 "lines": ["changed"]
12229 }]
12230 });
12231
12232 let out = tool.execute("test", input, None).await.unwrap();
12233 assert!(!out.is_error);
12234
12235 let content = std::fs::read_to_string(&file).unwrap();
12236 assert_eq!(content, "line1\nchanged\nline3\n");
12237 });
12238 }
12239
12240 #[test]
12241 fn test_hashline_edit_range_replace() {
12242 asupersync::test_utils::run_test(|| async {
12243 let dir = tempfile::tempdir().unwrap();
12244 let file = dir.path().join("test.txt");
12245 std::fs::write(&file, "a\nb\nc\nd\ne\n").unwrap();
12246
12247 let tool = HashlineEditTool::new(dir.path());
12248
12249 let tag_b = format_hashline_tag(1, "b");
12250 let tag_d = format_hashline_tag(3, "d");
12251
12252 let input = serde_json::json!({
12253 "path": file.to_str().unwrap(),
12254 "edits": [{
12255 "op": "replace",
12256 "pos": tag_b,
12257 "end": tag_d,
12258 "lines": ["X", "Y"]
12259 }]
12260 });
12261
12262 let out = tool.execute("test", input, None).await.unwrap();
12263 assert!(!out.is_error);
12264
12265 let content = std::fs::read_to_string(&file).unwrap();
12266 assert_eq!(content, "a\nX\nY\ne\n");
12267 });
12268 }
12269
12270 #[test]
12271 fn test_hashline_edit_prepend() {
12272 asupersync::test_utils::run_test(|| async {
12273 let dir = tempfile::tempdir().unwrap();
12274 let file = dir.path().join("test.txt");
12275 std::fs::write(&file, "a\nb\nc\n").unwrap();
12276
12277 let tool = HashlineEditTool::new(dir.path());
12278 let tag_b = format_hashline_tag(1, "b");
12279
12280 let input = serde_json::json!({
12281 "path": file.to_str().unwrap(),
12282 "edits": [{
12283 "op": "prepend",
12284 "pos": tag_b,
12285 "lines": ["inserted"]
12286 }]
12287 });
12288
12289 let out = tool.execute("test", input, None).await.unwrap();
12290 assert!(!out.is_error);
12291
12292 let content = std::fs::read_to_string(&file).unwrap();
12293 assert_eq!(content, "a\ninserted\nb\nc\n");
12294 });
12295 }
12296
12297 #[test]
12298 fn test_hashline_edit_append() {
12299 asupersync::test_utils::run_test(|| async {
12300 let dir = tempfile::tempdir().unwrap();
12301 let file = dir.path().join("test.txt");
12302 std::fs::write(&file, "a\nb\nc\n").unwrap();
12303
12304 let tool = HashlineEditTool::new(dir.path());
12305 let tag_b = format_hashline_tag(1, "b");
12306
12307 let input = serde_json::json!({
12308 "path": file.to_str().unwrap(),
12309 "edits": [{
12310 "op": "append",
12311 "pos": tag_b,
12312 "lines": ["inserted"]
12313 }]
12314 });
12315
12316 let out = tool.execute("test", input, None).await.unwrap();
12317 assert!(!out.is_error);
12318
12319 let content = std::fs::read_to_string(&file).unwrap();
12320 assert_eq!(content, "a\nb\ninserted\nc\n");
12321 });
12322 }
12323
12324 #[test]
12325 fn test_hashline_edit_bottom_up_ordering() {
12326 asupersync::test_utils::run_test(|| async {
12327 let dir = tempfile::tempdir().unwrap();
12328 let file = dir.path().join("test.txt");
12329 std::fs::write(&file, "a\nb\nc\nd\n").unwrap();
12330
12331 let tool = HashlineEditTool::new(dir.path());
12332 let tag_b = format_hashline_tag(1, "b");
12333 let tag_d = format_hashline_tag(3, "d");
12334
12335 let input = serde_json::json!({
12337 "path": file.to_str().unwrap(),
12338 "edits": [
12339 { "op": "replace", "pos": tag_b, "lines": ["B"] },
12340 { "op": "replace", "pos": tag_d, "lines": ["D"] }
12341 ]
12342 });
12343
12344 let out = tool.execute("test", input, None).await.unwrap();
12345 assert!(!out.is_error);
12346
12347 let content = std::fs::read_to_string(&file).unwrap();
12348 assert_eq!(content, "a\nB\nc\nD\n");
12349 });
12350 }
12351
12352 #[test]
12353 fn test_hashline_edit_hash_mismatch() {
12354 asupersync::test_utils::run_test(|| async {
12355 let dir = tempfile::tempdir().unwrap();
12356 let file = dir.path().join("test.txt");
12357 std::fs::write(&file, "hello\nworld\n").unwrap();
12358
12359 let tool = HashlineEditTool::new(dir.path());
12360
12361 let input = serde_json::json!({
12363 "path": file.to_str().unwrap(),
12364 "edits": [{
12365 "op": "replace",
12366 "pos": "1#ZZ",
12367 "lines": ["changed"]
12368 }]
12369 });
12370
12371 let result = tool.execute("test", input, None).await;
12372 assert!(result.is_err());
12373 let err_msg = result.unwrap_err().to_string();
12374 assert!(
12375 err_msg.contains("Hash validation failed"),
12376 "error should mention hash validation: {err_msg}"
12377 );
12378 });
12379 }
12380
12381 #[test]
12382 fn test_hashline_edit_dedup() {
12383 asupersync::test_utils::run_test(|| async {
12384 let dir = tempfile::tempdir().unwrap();
12385 let file = dir.path().join("test.txt");
12386 std::fs::write(&file, "a\nb\nc\n").unwrap();
12387
12388 let tool = HashlineEditTool::new(dir.path());
12389 let tag_b = format_hashline_tag(1, "b");
12390
12391 let input = serde_json::json!({
12393 "path": file.to_str().unwrap(),
12394 "edits": [
12395 { "op": "replace", "pos": &tag_b, "lines": ["B"] },
12396 { "op": "replace", "pos": &tag_b, "lines": ["B"] }
12397 ]
12398 });
12399
12400 let out = tool.execute("test", input, None).await.unwrap();
12401 assert!(!out.is_error);
12402
12403 let content = std::fs::read_to_string(&file).unwrap();
12404 assert_eq!(content, "a\nB\nc\n");
12405 });
12406 }
12407
12408 #[test]
12409 fn test_hashline_edit_noop_detection() {
12410 asupersync::test_utils::run_test(|| async {
12411 let dir = tempfile::tempdir().unwrap();
12412 let file = dir.path().join("test.txt");
12413 std::fs::write(&file, "a\nb\nc\n").unwrap();
12414
12415 let tool = HashlineEditTool::new(dir.path());
12416 let tag_b = format_hashline_tag(1, "b");
12417
12418 let input = serde_json::json!({
12420 "path": file.to_str().unwrap(),
12421 "edits": [{
12422 "op": "replace",
12423 "pos": &tag_b,
12424 "lines": ["b"]
12425 }]
12426 });
12427
12428 let result = tool.execute("test", input, None).await;
12429 assert!(result.is_err());
12430 let err_msg = result.unwrap_err().to_string();
12431 assert!(
12432 err_msg.contains("no-ops"),
12433 "error should mention no-ops: {err_msg}"
12434 );
12435 });
12436 }
12437
12438 #[test]
12439 fn test_hashline_read_output_format() {
12440 asupersync::test_utils::run_test(|| async {
12441 let dir = tempfile::tempdir().unwrap();
12442 let file = dir.path().join("test.txt");
12443 std::fs::write(&file, "fn main() {\n println!(\"hello\");\n}\n").unwrap();
12444
12445 let tool = ReadTool::new(dir.path());
12446 let input = serde_json::json!({
12447 "path": file.to_str().unwrap(),
12448 "hashline": true
12449 });
12450
12451 let out = tool.execute("test", input, None).await.unwrap();
12452 assert!(!out.is_error);
12453 let text = get_text(&out.content);
12454
12455 for line in text.lines() {
12457 if line.starts_with('[') || line.is_empty() {
12458 continue; }
12460 assert!(
12461 hashline_tag_regex().is_match(line),
12462 "line should match hashline format: {line:?}"
12463 );
12464 assert!(
12465 line.contains(':'),
12466 "line should contain ':' separator: {line:?}"
12467 );
12468 }
12469
12470 let first_line = text.lines().next().unwrap();
12472 assert!(first_line.starts_with("1#"), "first line: {first_line:?}");
12473 });
12474 }
12475
12476 #[test]
12477 fn test_hashline_edit_prefix_stripping() {
12478 asupersync::test_utils::run_test(|| async {
12479 let dir = tempfile::tempdir().unwrap();
12480 let file = dir.path().join("test.txt");
12481 std::fs::write(&file, "a\nb\nc\n").unwrap();
12482
12483 let tool = HashlineEditTool::new(dir.path());
12484 let tag_b = format_hashline_tag(1, "b");
12485
12486 let input = serde_json::json!({
12488 "path": file.to_str().unwrap(),
12489 "edits": [{
12490 "op": "replace",
12491 "pos": &tag_b,
12492 "lines": ["2#KJ:changed"]
12493 }]
12494 });
12495
12496 let out = tool.execute("test", input, None).await.unwrap();
12497 assert!(!out.is_error);
12498
12499 let content = std::fs::read_to_string(&file).unwrap();
12500 assert_eq!(content, "a\nchanged\nc\n");
12501 });
12502 }
12503
12504 #[test]
12505 fn test_hashline_edit_delete_lines() {
12506 asupersync::test_utils::run_test(|| async {
12507 let dir = tempfile::tempdir().unwrap();
12508 let file = dir.path().join("test.txt");
12509 std::fs::write(&file, "a\nb\nc\nd\n").unwrap();
12510
12511 let tool = HashlineEditTool::new(dir.path());
12512 let tag_b = format_hashline_tag(1, "b");
12513 let tag_c = format_hashline_tag(2, "c");
12514
12515 let input = serde_json::json!({
12517 "path": file.to_str().unwrap(),
12518 "edits": [{
12519 "op": "replace",
12520 "pos": &tag_b,
12521 "end": &tag_c,
12522 "lines": null
12523 }]
12524 });
12525
12526 let out = tool.execute("test", input, None).await.unwrap();
12527 assert!(!out.is_error);
12528
12529 let content = std::fs::read_to_string(&file).unwrap();
12530 assert_eq!(content, "a\nd\n");
12531 });
12532 }
12533
12534 #[test]
12535 fn test_hashline_edit_crlf_preservation() {
12536 asupersync::test_utils::run_test(|| async {
12537 let dir = tempfile::tempdir().unwrap();
12538 let file = dir.path().join("test.txt");
12539 std::fs::write(&file, "line1\r\nline2\r\nline3").unwrap();
12540
12541 let tool = HashlineEditTool::new(dir.path());
12542 let tag2 = format_hashline_tag(1, "line2");
12543
12544 let input = serde_json::json!({
12545 "path": file.to_str().unwrap(),
12546 "edits": [{
12547 "op": "replace",
12548 "pos": tag2,
12549 "lines": ["changed"]
12550 }]
12551 });
12552
12553 let out = tool.execute("test", input, None).await.unwrap();
12554 assert!(!out.is_error);
12555
12556 let content = std::fs::read_to_string(&file).unwrap();
12557 assert_eq!(content, "line1\r\nchanged\r\nline3");
12558 });
12559 }
12560
12561 #[test]
12562 fn test_hashline_edit_cr_preservation() {
12563 asupersync::test_utils::run_test(|| async {
12564 let dir = tempfile::tempdir().unwrap();
12565 let file = dir.path().join("test.txt");
12566 std::fs::write(&file, "line1\rline2\rline3").unwrap();
12567
12568 let tool = HashlineEditTool::new(dir.path());
12569 let tag2 = format_hashline_tag(1, "line2");
12570
12571 let input = serde_json::json!({
12572 "path": file.to_str().unwrap(),
12573 "edits": [{
12574 "op": "replace",
12575 "pos": tag2,
12576 "lines": ["changed"]
12577 }]
12578 });
12579
12580 let out = tool.execute("test", input, None).await.unwrap();
12581 assert!(!out.is_error);
12582
12583 let content = std::fs::read_to_string(&file).unwrap();
12584 assert_eq!(content, "line1\rchanged\rline3");
12585 });
12586 }
12587
12588 #[test]
12589 fn test_hashline_edit_empty_file_append() {
12590 asupersync::test_utils::run_test(|| async {
12591 let dir = tempfile::tempdir().unwrap();
12592 let file = dir.path().join("empty.txt");
12593 std::fs::write(&file, "").unwrap();
12594
12595 let tool = HashlineEditTool::new(dir.path());
12596
12597 let input = serde_json::json!({
12599 "path": file.to_str().unwrap(),
12600 "edits": [{
12601 "op": "append",
12602 "lines": ["new_line"]
12603 }]
12604 });
12605
12606 let out = tool.execute("test", input, None).await.unwrap();
12607 assert!(!out.is_error);
12608
12609 let content = std::fs::read_to_string(&file).unwrap();
12610 assert!(content.contains("new_line"));
12611 });
12612 }
12613
12614 #[test]
12615 fn test_hashline_edit_single_line_no_trailing_newline() {
12616 asupersync::test_utils::run_test(|| async {
12617 let dir = tempfile::tempdir().unwrap();
12618 let file = dir.path().join("single.txt");
12619 std::fs::write(&file, "hello").unwrap();
12620
12621 let tool = HashlineEditTool::new(dir.path());
12622 let tag = format_hashline_tag(0, "hello");
12623
12624 let input = serde_json::json!({
12625 "path": file.to_str().unwrap(),
12626 "edits": [{
12627 "op": "replace",
12628 "pos": tag,
12629 "lines": ["world"]
12630 }]
12631 });
12632
12633 let out = tool.execute("test", input, None).await.unwrap();
12634 assert!(!out.is_error);
12635
12636 let content = std::fs::read_to_string(&file).unwrap();
12637 assert_eq!(content, "world");
12638 });
12639 }
12640
12641 #[test]
12642 fn test_hashline_edit_preserves_bom_hash_validation() {
12643 asupersync::test_utils::run_test(|| async {
12644 let dir = tempfile::tempdir().unwrap();
12645 let file = dir.path().join("bom.txt");
12646 let bom = "\u{FEFF}";
12647 std::fs::write(&file, format!("{bom}alpha\nbeta\n")).unwrap();
12648
12649 let tool = HashlineEditTool::new(dir.path());
12650 let tag1 = format_hashline_tag(0, &format!("{bom}alpha"));
12651
12652 let input = serde_json::json!({
12653 "path": file.to_str().unwrap(),
12654 "edits": [{
12655 "op": "replace",
12656 "pos": tag1,
12657 "lines": ["gamma"]
12658 }]
12659 });
12660
12661 let out = tool.execute("test", input, None).await.unwrap();
12662 assert!(!out.is_error);
12663
12664 let content = std::fs::read_to_string(&file).unwrap();
12665 assert_eq!(content, format!("{bom}gamma\nbeta\n"));
12666 });
12667 }
12668
12669 #[test]
12670 fn test_hashline_edit_bof_prepend_no_pos() {
12671 asupersync::test_utils::run_test(|| async {
12672 let dir = tempfile::tempdir().unwrap();
12673 let file = dir.path().join("test.txt");
12674 std::fs::write(&file, "a\nb\nc\n").unwrap();
12675
12676 let tool = HashlineEditTool::new(dir.path());
12677
12678 let input = serde_json::json!({
12680 "path": file.to_str().unwrap(),
12681 "edits": [{
12682 "op": "prepend",
12683 "lines": ["header"]
12684 }]
12685 });
12686
12687 let out = tool.execute("test", input, None).await.unwrap();
12688 assert!(!out.is_error);
12689
12690 let content = std::fs::read_to_string(&file).unwrap();
12691 assert_eq!(content, "header\na\nb\nc\n");
12692 });
12693 }
12694
12695 #[test]
12696 fn test_hashline_edit_eof_append_no_pos() {
12697 asupersync::test_utils::run_test(|| async {
12698 let dir = tempfile::tempdir().unwrap();
12699 let file = dir.path().join("test.txt");
12700 std::fs::write(&file, "a\nb\nc\n").unwrap();
12701
12702 let tool = HashlineEditTool::new(dir.path());
12703
12704 let input = serde_json::json!({
12706 "path": file.to_str().unwrap(),
12707 "edits": [{
12708 "op": "append",
12709 "lines": ["footer"]
12710 }]
12711 });
12712
12713 let out = tool.execute("test", input, None).await.unwrap();
12714 assert!(!out.is_error);
12715
12716 let content = std::fs::read_to_string(&file).unwrap();
12717 assert!(
12718 content.contains("footer"),
12719 "content should contain footer: {content:?}"
12720 );
12721 });
12722 }
12723
12724 #[test]
12725 fn test_hashline_edit_overlapping_replace_ranges_rejected() {
12726 asupersync::test_utils::run_test(|| async {
12727 let dir = tempfile::tempdir().unwrap();
12728 let file = dir.path().join("test.txt");
12729 std::fs::write(&file, "a\nb\nc\nd\ne\n").unwrap();
12730
12731 let tool = HashlineEditTool::new(dir.path());
12732 let tag_b = format_hashline_tag(1, "b");
12733 let tag_d = format_hashline_tag(3, "d");
12734 let tag_c = format_hashline_tag(2, "c");
12735 let tag_e = format_hashline_tag(4, "e");
12736
12737 let input = serde_json::json!({
12739 "path": file.to_str().unwrap(),
12740 "edits": [
12741 { "op": "replace", "pos": &tag_b, "end": &tag_d, "lines": ["X"] },
12742 { "op": "replace", "pos": &tag_c, "end": &tag_e, "lines": ["Y"] }
12743 ]
12744 });
12745
12746 let result = tool.execute("test", input, None).await;
12747 assert!(result.is_err());
12748 let err_msg = result.unwrap_err().to_string();
12749 assert!(
12750 err_msg.contains("Overlapping"),
12751 "error should mention overlapping: {err_msg}"
12752 );
12753 });
12754 }
12755
12756 #[test]
12757 fn test_hashline_edit_reversed_range_rejected() {
12758 asupersync::test_utils::run_test(|| async {
12759 let dir = tempfile::tempdir().unwrap();
12760 let file = dir.path().join("test.txt");
12761 std::fs::write(&file, "a\nb\nc\nd\n").unwrap();
12762
12763 let tool = HashlineEditTool::new(dir.path());
12764 let tag_b = format_hashline_tag(1, "b");
12765 let tag_d = format_hashline_tag(3, "d");
12766
12767 let input = serde_json::json!({
12769 "path": file.to_str().unwrap(),
12770 "edits": [{
12771 "op": "replace",
12772 "pos": &tag_d,
12773 "end": &tag_b,
12774 "lines": ["X"]
12775 }]
12776 });
12777
12778 let result = tool.execute("test", input, None).await;
12779 assert!(result.is_err());
12780 let err_msg = result.unwrap_err().to_string();
12781 assert!(
12782 err_msg.contains("before start"),
12783 "error should mention before start: {err_msg}"
12784 );
12785 });
12786 }
12787
12788 #[test]
12789 fn test_hashline_edit_trailing_newline_semantics() {
12790 asupersync::test_utils::run_test(|| async {
12791 let dir = tempfile::tempdir().unwrap();
12792 let file = dir.path().join("test.txt");
12793 std::fs::write(&file, "line1\nline2\n").unwrap();
12795
12796 let tool = HashlineEditTool::new(dir.path());
12797 let tag2 = format_hashline_tag(1, "line2");
12798
12799 let input = serde_json::json!({
12801 "path": file.to_str().unwrap(),
12802 "edits": [{
12803 "op": "replace",
12804 "pos": tag2,
12805 "lines": ["changed"]
12806 }]
12807 });
12808
12809 let out = tool.execute("test", input, None).await.unwrap();
12810 assert!(!out.is_error);
12811
12812 let content = std::fs::read_to_string(&file).unwrap();
12813 assert_eq!(content, "line1\nchanged\n");
12814 });
12815 }
12816}