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 tolerate_fsync_refusal(file.sync_all(), "artifact file", path)?;
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
2166fn is_fsync_refused(err: &std::io::Error) -> bool {
2180 matches!(err.raw_os_error(), Some(9 | 22)) || err.kind() == std::io::ErrorKind::Unsupported
2184}
2185
2186fn tolerate_fsync_refusal(
2190 result: std::io::Result<()>,
2191 what: &str,
2192 path: &Path,
2193) -> std::io::Result<()> {
2194 match result {
2195 Ok(()) => Ok(()),
2196 Err(err) if is_fsync_refused(&err) => {
2197 tracing::warn!(
2198 path = %path.display(),
2199 error = %err,
2200 "{what} fsync refused by filesystem (non-POSIX durability semantics); \
2201 data already written, continuing without a durability barrier"
2202 );
2203 Ok(())
2204 }
2205 Err(err) => Err(err),
2206 }
2207}
2208
2209#[cfg(unix)]
2210fn sync_parent_dir(path: &Path) -> std::io::Result<()> {
2211 let Some(parent) = path.parent() else {
2212 return Ok(());
2213 };
2214 let parent = if parent.as_os_str().is_empty() {
2215 Path::new(".")
2216 } else {
2217 parent
2218 };
2219 tolerate_fsync_refusal(
2223 std::fs::File::open(parent).and_then(|dir| dir.sync_all()),
2224 "parent directory",
2225 parent,
2226 )
2227}
2228
2229#[cfg(not(unix))]
2230fn sync_parent_dir(_path: &Path) -> std::io::Result<()> {
2231 Ok(())
2232}
2233
2234fn escape_file_tag_attribute(value: &str) -> String {
2235 let mut escaped = String::with_capacity(value.len());
2236 for ch in value.chars() {
2237 match ch {
2238 '&' => escaped.push_str("&"),
2239 '"' => escaped.push_str("""),
2240 '<' => escaped.push_str("<"),
2241 '>' => escaped.push_str(">"),
2242 '\n' => escaped.push_str(" "),
2243 '\r' => escaped.push_str(" "),
2244 '\t' => escaped.push_str("	"),
2245 _ => escaped.push(ch),
2246 }
2247 }
2248 escaped
2249}
2250
2251fn escaped_file_tag_name(path: &Path) -> String {
2252 escape_file_tag_attribute(&path.display().to_string())
2253}
2254
2255fn append_file_notice_block(out: &mut String, path: &Path, notice: &str) {
2256 let path_str = escaped_file_tag_name(path);
2257 let _ = writeln!(out, "<file name=\"{path_str}\">\n{notice}\n</file>");
2258}
2259
2260fn append_image_file_ref(out: &mut String, path: &Path, note: Option<&str>) {
2261 let path_str = escaped_file_tag_name(path);
2262 match note {
2263 Some(text) => {
2264 let _ = writeln!(out, "<file name=\"{path_str}\">{text}</file>");
2265 }
2266 None => {
2267 let _ = writeln!(out, "<file name=\"{path_str}\"></file>");
2268 }
2269 }
2270}
2271
2272fn append_text_file_block(out: &mut String, path: &Path, bytes: &[u8]) {
2273 let content = String::from_utf8_lossy(bytes);
2274 let path_str = escaped_file_tag_name(path);
2275 let _ = writeln!(out, "<file name=\"{path_str}\">");
2276
2277 let truncation = truncate_head(content.into_owned(), DEFAULT_MAX_LINES, DEFAULT_MAX_BYTES);
2278 let needs_trailing_newline = !truncation.truncated && !truncation.content.ends_with('\n');
2279 out.push_str(&truncation.content);
2280
2281 if truncation.truncated {
2282 let _ = write!(
2283 out,
2284 "\n... [Truncated: showing {}/{} lines, {}/{} bytes]",
2285 truncation.output_lines,
2286 truncation.total_lines,
2287 format_size(truncation.output_bytes),
2288 format_size(truncation.total_bytes)
2289 );
2290 } else if needs_trailing_newline {
2291 out.push('\n');
2292 }
2293 let _ = writeln!(out, "</file>");
2294}
2295
2296fn maybe_append_image_argument(
2297 out: &mut ProcessedFiles,
2298 absolute_path: &Path,
2299 bytes: &[u8],
2300 auto_resize_images: bool,
2301) -> Result<bool> {
2302 let Some(mime_type) = detect_supported_image_mime_type_from_bytes(bytes) else {
2303 return Ok(false);
2304 };
2305
2306 let resized = if auto_resize_images {
2307 resize_image_if_needed(bytes, mime_type)?
2308 } else {
2309 ResizedImage::original(bytes.to_vec(), mime_type)
2310 };
2311
2312 if resized.bytes.len() > IMAGE_MAX_BYTES {
2313 let msg = if resized.resized {
2314 format!(
2315 "[Image is too large ({} bytes) after resizing. Max allowed is {} bytes.]",
2316 resized.bytes.len(),
2317 IMAGE_MAX_BYTES
2318 )
2319 } else {
2320 format!(
2321 "[Image is too large ({} bytes). Max allowed is {} bytes.]",
2322 resized.bytes.len(),
2323 IMAGE_MAX_BYTES
2324 )
2325 };
2326 append_file_notice_block(&mut out.text, absolute_path, &msg);
2327 return Ok(true);
2328 }
2329
2330 let base64_data =
2331 base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &resized.bytes);
2332 out.images.push(ImageContent {
2333 data: base64_data,
2334 mime_type: resized.mime_type.to_string(),
2335 });
2336
2337 let note = if resized.resized {
2338 if let (Some(ow), Some(oh), Some(w), Some(h)) = (
2339 resized.original_width,
2340 resized.original_height,
2341 resized.width,
2342 resized.height,
2343 ) {
2344 if w > 0 {
2345 let scale = f64::from(ow) / f64::from(w);
2346 Some(format!(
2347 "[Image: original {ow}x{oh}, displayed at {w}x{h}. Multiply coordinates by {scale:.2} to map to original image.]"
2348 ))
2349 } else {
2350 Some(format!(
2351 "[Image: original {ow}x{oh}, displayed at {w}x{h}.]"
2352 ))
2353 }
2354 } else {
2355 None
2356 }
2357 } else {
2358 None
2359 };
2360 append_image_file_ref(&mut out.text, absolute_path, note.as_deref());
2361 Ok(true)
2362}
2363
2364pub fn process_file_arguments(
2372 file_args: &[String],
2373 cwd: &Path,
2374 auto_resize_images: bool,
2375) -> Result<ProcessedFiles> {
2376 let mut out = ProcessedFiles::default();
2377
2378 for file_arg in file_args {
2379 let resolved = resolve_read_path(file_arg, cwd);
2380 let absolute_path = normalize_dot_segments(&resolved);
2381 let absolute_path = enforce_read_scope(&absolute_path, cwd)?;
2382
2383 let meta = std::fs::metadata(&absolute_path).map_err(|e| {
2384 Error::tool(
2385 "read",
2386 format!("Cannot access file {}: {e}", absolute_path.display()),
2387 )
2388 })?;
2389 if meta.is_dir() {
2390 append_file_notice_block(
2391 &mut out.text,
2392 &absolute_path,
2393 "[Path is a directory, not a file. Use the list tool to view its contents.]",
2394 );
2395 continue;
2396 }
2397
2398 if meta.len() == 0 {
2399 continue;
2400 }
2401
2402 if meta.len() > READ_TOOL_MAX_BYTES {
2403 append_file_notice_block(
2404 &mut out.text,
2405 &absolute_path,
2406 &format!(
2407 "[File is too large ({} bytes). Max allowed is {} bytes.]",
2408 meta.len(),
2409 READ_TOOL_MAX_BYTES
2410 ),
2411 );
2412 continue;
2413 }
2414
2415 let bytes = std::fs::read(&absolute_path).map_err(|e| {
2416 Error::tool(
2417 "read",
2418 format!("Could not read file {}: {e}", absolute_path.display()),
2419 )
2420 })?;
2421
2422 if maybe_append_image_argument(&mut out, &absolute_path, &bytes, auto_resize_images)? {
2423 continue;
2424 }
2425
2426 append_text_file_block(&mut out.text, &absolute_path, &bytes);
2427 }
2428
2429 Ok(out)
2430}
2431
2432fn resolve_path(file_path: &str, cwd: &Path) -> PathBuf {
2435 normalize_dot_segments(&resolve_to_cwd(file_path, cwd))
2436}
2437
2438#[cfg(feature = "fuzzing")]
2439pub fn fuzz_resolve_path(file_path: &str, cwd: &Path) -> PathBuf {
2440 resolve_path(file_path, cwd)
2441}
2442
2443pub(crate) fn detect_supported_image_mime_type_from_bytes(bytes: &[u8]) -> Option<&'static str> {
2444 if bytes.len() >= 8 && bytes.starts_with(b"\x89PNG\r\n\x1A\n") {
2446 return Some("image/png");
2447 }
2448 if bytes.len() >= 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF {
2449 return Some("image/jpeg");
2450 }
2451 if bytes.len() >= 6 && (bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a")) {
2452 return Some("image/gif");
2453 }
2454 if bytes.len() >= 12 && bytes.starts_with(b"RIFF") && &bytes[8..12] == b"WEBP" {
2455 return Some("image/webp");
2456 }
2457 None
2458}
2459
2460#[derive(Debug, Clone)]
2461pub(crate) struct ResizedImage {
2462 pub(crate) bytes: Vec<u8>,
2463 pub(crate) mime_type: &'static str,
2464 pub(crate) resized: bool,
2465 pub(crate) width: Option<u32>,
2466 pub(crate) height: Option<u32>,
2467 pub(crate) original_width: Option<u32>,
2468 pub(crate) original_height: Option<u32>,
2469}
2470
2471impl ResizedImage {
2472 pub(crate) const fn original(bytes: Vec<u8>, mime_type: &'static str) -> Self {
2473 Self {
2474 bytes,
2475 mime_type,
2476 resized: false,
2477 width: None,
2478 height: None,
2479 original_width: None,
2480 original_height: None,
2481 }
2482 }
2483}
2484
2485#[cfg(feature = "image-resize")]
2486#[allow(clippy::too_many_lines)]
2487pub(crate) fn resize_image_if_needed(
2488 bytes: &[u8],
2489 mime_type: &'static str,
2490) -> Result<ResizedImage> {
2491 use image::codecs::jpeg::JpegEncoder;
2502 use image::codecs::png::PngEncoder;
2503 use image::imageops::FilterType;
2504 use image::{GenericImageView, ImageEncoder, ImageReader, Limits};
2505 use std::io::Cursor;
2506
2507 const MAX_WIDTH: u32 = 2000;
2508 const MAX_HEIGHT: u32 = 2000;
2509 const DEFAULT_JPEG_QUALITY: u8 = 80;
2510 const QUALITY_STEPS: [u8; 4] = [85, 70, 55, 40];
2511 const SCALE_STEPS: [f64; 5] = [1.0, 0.75, 0.5, 0.35, 0.25];
2512
2513 fn scale_u32(value: u32, numerator: u32, denominator: u32) -> u32 {
2514 let den = u64::from(denominator).max(1);
2515 let num = u64::from(value) * u64::from(numerator);
2516 let rounded = (num + den / 2) / den;
2517 u32::try_from(rounded).unwrap_or(u32::MAX)
2518 }
2519
2520 fn encode_png(img: &image::DynamicImage) -> Result<Vec<u8>> {
2521 let rgba = img.to_rgba8();
2522 let mut out = Vec::new();
2523 PngEncoder::new(&mut out)
2524 .write_image(
2525 rgba.as_raw(),
2526 rgba.width(),
2527 rgba.height(),
2528 image::ExtendedColorType::Rgba8,
2529 )
2530 .map_err(|e| Error::tool("read", format!("Failed to encode PNG: {e}")))?;
2531 Ok(out)
2532 }
2533
2534 fn encode_jpeg(img: &image::DynamicImage, quality: u8) -> Result<Vec<u8>> {
2535 let rgb = img.to_rgb8();
2536 let mut out = Vec::new();
2537 JpegEncoder::new_with_quality(&mut out, quality)
2538 .write_image(
2539 rgb.as_raw(),
2540 rgb.width(),
2541 rgb.height(),
2542 image::ExtendedColorType::Rgb8,
2543 )
2544 .map_err(|e| Error::tool("read", format!("Failed to encode JPEG: {e}")))?;
2545 Ok(out)
2546 }
2547
2548 fn try_both_formats(
2549 img: &image::DynamicImage,
2550 width: u32,
2551 height: u32,
2552 jpeg_quality: u8,
2553 ) -> Result<(Vec<u8>, &'static str)> {
2554 let resized = img.resize_exact(width, height, FilterType::Lanczos3);
2555 let png = encode_png(&resized)?;
2556 let jpeg = encode_jpeg(&resized, jpeg_quality)?;
2557 if png.len() <= jpeg.len() {
2558 Ok((png, "image/png"))
2559 } else {
2560 Ok((jpeg, "image/jpeg"))
2561 }
2562 }
2563
2564 let mut limits = Limits::default();
2567 limits.max_alloc = Some(128 * 1024 * 1024);
2568
2569 let reader = ImageReader::new(Cursor::new(bytes))
2570 .with_guessed_format()
2571 .map_err(|e| Error::tool("read", format!("Failed to detect image format: {e}")))?;
2572
2573 let mut reader = reader;
2574 reader.limits(limits);
2575
2576 let Ok(img) = reader.decode() else {
2578 return Ok(ResizedImage::original(bytes.to_vec(), mime_type));
2579 };
2580
2581 let (original_width, original_height) = img.dimensions();
2582 let original_size = bytes.len();
2583
2584 if original_width <= MAX_WIDTH
2585 && original_height <= MAX_HEIGHT
2586 && original_size <= IMAGE_MAX_BYTES
2587 {
2588 return Ok(ResizedImage {
2589 bytes: bytes.to_vec(),
2590 mime_type,
2591 resized: false,
2592 width: Some(original_width),
2593 height: Some(original_height),
2594 original_width: Some(original_width),
2595 original_height: Some(original_height),
2596 });
2597 }
2598
2599 let mut target_width = original_width;
2600 let mut target_height = original_height;
2601
2602 if target_width > MAX_WIDTH {
2603 target_height = scale_u32(target_height, MAX_WIDTH, target_width);
2604 target_width = MAX_WIDTH;
2605 }
2606 if target_height > MAX_HEIGHT {
2607 target_width = scale_u32(target_width, MAX_HEIGHT, target_height);
2608 target_height = MAX_HEIGHT;
2609 }
2610
2611 let mut best = try_both_formats(&img, target_width, target_height, DEFAULT_JPEG_QUALITY)?;
2612 let mut final_width = target_width;
2613 let mut final_height = target_height;
2614
2615 if best.0.len() <= IMAGE_MAX_BYTES {
2616 return Ok(ResizedImage {
2617 bytes: best.0,
2618 mime_type: best.1,
2619 resized: true,
2620 width: Some(final_width),
2621 height: Some(final_height),
2622 original_width: Some(original_width),
2623 original_height: Some(original_height),
2624 });
2625 }
2626
2627 for quality in QUALITY_STEPS {
2628 best = try_both_formats(&img, target_width, target_height, quality)?;
2629 if best.0.len() <= IMAGE_MAX_BYTES {
2630 return Ok(ResizedImage {
2631 bytes: best.0,
2632 mime_type: best.1,
2633 resized: true,
2634 width: Some(final_width),
2635 height: Some(final_height),
2636 original_width: Some(original_width),
2637 original_height: Some(original_height),
2638 });
2639 }
2640 }
2641
2642 for scale in SCALE_STEPS {
2643 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
2644 {
2645 final_width = (f64::from(target_width) * scale).round() as u32;
2646 final_height = (f64::from(target_height) * scale).round() as u32;
2647 }
2648
2649 if final_width < 100 || final_height < 100 {
2650 break;
2651 }
2652
2653 for quality in QUALITY_STEPS {
2654 best = try_both_formats(&img, final_width, final_height, quality)?;
2655 if best.0.len() <= IMAGE_MAX_BYTES {
2656 return Ok(ResizedImage {
2657 bytes: best.0,
2658 mime_type: best.1,
2659 resized: true,
2660 width: Some(final_width),
2661 height: Some(final_height),
2662 original_width: Some(original_width),
2663 original_height: Some(original_height),
2664 });
2665 }
2666 }
2667 }
2668
2669 Ok(ResizedImage {
2670 bytes: best.0,
2671 mime_type: best.1,
2672 resized: true,
2673 width: Some(final_width),
2674 height: Some(final_height),
2675 original_width: Some(original_width),
2676 original_height: Some(original_height),
2677 })
2678}
2679
2680#[cfg(not(feature = "image-resize"))]
2681#[expect(
2682 clippy::unnecessary_wraps,
2683 reason = "The no-feature stub preserves the feature-enabled Result API at shared call sites."
2684)]
2685pub(crate) fn resize_image_if_needed(
2686 bytes: &[u8],
2687 mime_type: &'static str,
2688) -> Result<ResizedImage> {
2689 Ok(ResizedImage::original(bytes.to_vec(), mime_type))
2690}
2691
2692pub struct ToolRegistry {
2702 tools: Vec<Box<dyn Tool>>,
2703}
2704
2705impl ToolRegistry {
2706 pub fn new(enabled: &[&str], cwd: &Path, config: Option<&Config>) -> Self {
2708 let mut tools: Vec<Box<dyn Tool>> = Vec::new();
2709 let shell_path = config.and_then(|c| c.shell_path.clone());
2710 let shell_command_prefix = config.and_then(|c| c.shell_command_prefix.clone());
2711 let image_auto_resize = config.is_none_or(Config::image_auto_resize);
2712 let block_images = config
2713 .and_then(|c| c.images.as_ref().and_then(|i| i.block_images))
2714 .unwrap_or(false);
2715
2716 for name in enabled {
2717 match *name {
2718 "read" => tools.push(Box::new(ReadTool::with_settings(
2719 cwd,
2720 image_auto_resize,
2721 block_images,
2722 ))),
2723 "bash" => tools.push(Box::new(BashTool::with_shell(
2724 cwd,
2725 shell_path.clone(),
2726 shell_command_prefix.clone(),
2727 ))),
2728 "edit" => tools.push(Box::new(EditTool::new(cwd))),
2729 "write" => tools.push(Box::new(WriteTool::new(cwd))),
2730 "grep" => tools.push(Box::new(GrepTool::new(cwd))),
2731 "find" => tools.push(Box::new(FindTool::new(cwd))),
2732 "ls" => tools.push(Box::new(LsTool::new(cwd))),
2733 "hashline_edit" => tools.push(Box::new(HashlineEditTool::new(cwd))),
2734 _ => {}
2735 }
2736 }
2737
2738 Self { tools }
2739 }
2740
2741 pub fn from_tools(tools: Vec<Box<dyn Tool>>) -> Self {
2743 Self { tools }
2744 }
2745
2746 pub fn into_tools(self) -> Vec<Box<dyn Tool>> {
2748 self.tools
2749 }
2750
2751 pub fn push(&mut self, tool: Box<dyn Tool>) {
2753 self.tools.push(tool);
2754 }
2755
2756 pub fn extend<I>(&mut self, tools: I)
2758 where
2759 I: IntoIterator<Item = Box<dyn Tool>>,
2760 {
2761 self.tools.extend(tools);
2762 }
2763
2764 pub fn tools(&self) -> &[Box<dyn Tool>] {
2766 &self.tools
2767 }
2768
2769 pub fn get(&self, name: &str) -> Option<&dyn Tool> {
2771 self.tools
2772 .iter()
2773 .find(|t| t.name() == name)
2774 .map(std::convert::AsRef::as_ref)
2775 }
2776}
2777
2778#[derive(Debug, Deserialize)]
2784#[serde(rename_all = "camelCase")]
2785struct ReadInput {
2786 path: String,
2787 offset: Option<i64>,
2788 limit: Option<i64>,
2789 #[serde(default)]
2790 hashline: bool,
2791}
2792
2793pub struct ReadTool {
2794 cwd: PathBuf,
2795 auto_resize: bool,
2797 block_images: bool,
2798 artifact_root: Option<PathBuf>,
2799}
2800
2801impl ReadTool {
2802 pub fn new(cwd: &Path) -> Self {
2803 Self {
2804 cwd: cwd.to_path_buf(),
2805 auto_resize: true,
2806 block_images: false,
2807 artifact_root: None,
2808 }
2809 }
2810
2811 pub fn with_settings(cwd: &Path, auto_resize: bool, block_images: bool) -> Self {
2812 Self {
2813 cwd: cwd.to_path_buf(),
2814 auto_resize,
2815 block_images,
2816 artifact_root: None,
2817 }
2818 }
2819
2820 #[cfg(test)]
2821 fn with_artifact_root(cwd: &Path, artifact_root: &Path) -> Self {
2822 Self {
2823 cwd: cwd.to_path_buf(),
2824 auto_resize: true,
2825 block_images: false,
2826 artifact_root: Some(artifact_root.to_path_buf()),
2827 }
2828 }
2829}
2830
2831async fn read_some<R>(reader: &mut R, dst: &mut [u8]) -> std::io::Result<usize>
2832where
2833 R: AsyncRead + Unpin,
2834{
2835 if dst.is_empty() {
2836 return Ok(0);
2837 }
2838
2839 futures::future::poll_fn(|cx| {
2840 let mut read_buf = ReadBuf::new(dst);
2841 match std::pin::Pin::new(&mut *reader).poll_read(cx, &mut read_buf) {
2842 std::task::Poll::Ready(Ok(())) => std::task::Poll::Ready(Ok(read_buf.filled().len())),
2843 std::task::Poll::Ready(Err(err)) => std::task::Poll::Ready(Err(err)),
2844 std::task::Poll::Pending => std::task::Poll::Pending,
2845 }
2846 })
2847 .await
2848}
2849
2850#[async_trait]
2851#[allow(clippy::unnecessary_literal_bound)]
2852impl Tool for ReadTool {
2853 fn name(&self) -> &str {
2854 "read"
2855 }
2856 fn label(&self) -> &str {
2857 "read"
2858 }
2859 fn description(&self) -> &str {
2860 "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."
2861 }
2862
2863 fn parameters(&self) -> serde_json::Value {
2864 serde_json::json!({
2865 "type": "object",
2866 "properties": {
2867 "path": {
2868 "type": "string",
2869 "description": "Path to the file to read (relative or absolute)"
2870 },
2871 "offset": {
2872 "type": "integer",
2873 "description": "Line number to start reading from (1-indexed)"
2874 },
2875 "limit": {
2876 "type": "integer",
2877 "description": "Maximum number of lines to read"
2878 },
2879 "hashline": {
2880 "type": "boolean",
2881 "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."
2882 }
2883 },
2884 "required": ["path"]
2885 })
2886 }
2887
2888 fn effects(&self) -> ToolEffects {
2889 ToolEffects::read()
2890 }
2891
2892 #[allow(clippy::too_many_lines)]
2893 async fn execute(
2894 &self,
2895 tool_call_id: &str,
2896 input: serde_json::Value,
2897 _on_update: Option<Box<dyn Fn(ToolUpdate) + Send + Sync>>,
2898 ) -> Result<ToolOutput> {
2899 let input_value = input.clone();
2900 let input: ReadInput =
2901 serde_json::from_value(input).map_err(|e| Error::validation(e.to_string()))?;
2902
2903 if matches!(input.limit, Some(limit) if limit <= 0) {
2904 return Err(Error::validation(
2905 "`limit` must be greater than 0".to_string(),
2906 ));
2907 }
2908 if matches!(input.offset, Some(offset) if offset < 0) {
2909 return Err(Error::validation(
2910 "`offset` must be non-negative".to_string(),
2911 ));
2912 }
2913
2914 let path = resolve_read_path(&input.path, &self.cwd);
2915 let path = enforce_read_scope(&path, &self.cwd)?;
2916
2917 let meta = asupersync::fs::metadata(&path).await.ok();
2918 if let Some(meta) = &meta {
2919 if !meta.is_file() {
2920 return Err(Error::tool(
2921 "read",
2922 format!("Path {} is not a regular file", path.display()),
2923 ));
2924 }
2925 }
2926
2927 let cache_key = tool_cache_key("read", &self.cwd, &input_value);
2928 let cache_mode = ToolCacheFingerprintMode::FileContent;
2929 let cache_deps = cache_dependency_for_path(&path, cache_mode);
2930 if let Some(output) = cached_tool_output(&cache_key, cache_deps.as_deref()) {
2931 return Ok(output);
2932 }
2933
2934 let mut file = asupersync::fs::File::open(&path)
2935 .await
2936 .map_err(|e| Error::tool("read", e.to_string()))?;
2937
2938 let mut buffer = [0u8; 8192];
2940 let mut initial_read = 0;
2941 loop {
2942 let n = read_some(&mut file, &mut buffer[initial_read..])
2943 .await
2944 .map_err(|e| Error::tool("read", format!("Failed to read file: {e}")))?;
2945 if n == 0 {
2946 break;
2947 }
2948 initial_read += n;
2949 if initial_read == buffer.len() {
2950 break;
2951 }
2952 }
2953 let initial_bytes = &buffer[..initial_read];
2954
2955 if let Some(mime_type) = detect_supported_image_mime_type_from_bytes(initial_bytes) {
2956 if self.block_images {
2957 return Err(Error::tool(
2958 "read",
2959 "Images are blocked by configuration".to_string(),
2960 ));
2961 }
2962
2963 let max_image_input_bytes = usize::try_from(READ_TOOL_MAX_BYTES).unwrap_or(usize::MAX);
2967 if let Some(meta) = &meta {
2968 if meta.len() > READ_TOOL_MAX_BYTES {
2969 return Err(Error::tool(
2970 "read",
2971 format!(
2972 "Image is too large ({} bytes). Max allowed is {} bytes.",
2973 meta.len(),
2974 READ_TOOL_MAX_BYTES
2975 ),
2976 ));
2977 }
2978 }
2979 let mut all_bytes = Vec::with_capacity(initial_read);
2980 all_bytes.extend_from_slice(initial_bytes);
2981
2982 let remaining_limit = max_image_input_bytes.saturating_sub(initial_read);
2983 let mut limiter = file.take((remaining_limit as u64).saturating_add(1));
2984 limiter
2985 .read_to_end(&mut all_bytes)
2986 .await
2987 .map_err(|e| Error::tool("read", format!("Failed to read image: {e}")))?;
2988
2989 if all_bytes.len() > max_image_input_bytes {
2990 return Err(Error::tool(
2991 "read",
2992 format!(
2993 "Image is too large ({} bytes). Max allowed is {} bytes.",
2994 all_bytes.len(),
2995 READ_TOOL_MAX_BYTES
2996 ),
2997 ));
2998 }
2999
3000 let resized = if self.auto_resize {
3001 resize_image_if_needed(&all_bytes, mime_type)?
3002 } else {
3003 ResizedImage::original(all_bytes, mime_type)
3004 };
3005
3006 if resized.bytes.len() > IMAGE_MAX_BYTES {
3007 let message = if resized.resized {
3008 format!(
3009 "Image is too large ({} bytes) after resizing. Max allowed is {} bytes.",
3010 resized.bytes.len(),
3011 IMAGE_MAX_BYTES
3012 )
3013 } else {
3014 format!(
3015 "Image is too large ({} bytes). Max allowed is {} bytes.",
3016 resized.bytes.len(),
3017 IMAGE_MAX_BYTES
3018 )
3019 };
3020 return Err(Error::tool("read", message));
3021 }
3022
3023 let base64_data =
3024 base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &resized.bytes);
3025
3026 let mut note = format!("Read image file [{}]", resized.mime_type);
3027 if resized.resized {
3028 if let (Some(ow), Some(oh), Some(w), Some(h)) = (
3029 resized.original_width,
3030 resized.original_height,
3031 resized.width,
3032 resized.height,
3033 ) {
3034 if w > 0 {
3035 let scale = f64::from(ow) / f64::from(w);
3036 let _ = write!(
3037 note,
3038 "\n[Image: original {ow}x{oh}, displayed at {w}x{h}. Multiply coordinates by {scale:.2} to map to original image.]"
3039 );
3040 } else {
3041 let _ =
3042 write!(note, "\n[Image: original {ow}x{oh}, displayed at {w}x{h}.]");
3043 }
3044 }
3045 }
3046
3047 return Ok(ToolOutput {
3048 content: vec![
3049 ContentBlock::Text(TextContent::new(note)),
3050 ContentBlock::Image(ImageContent {
3051 data: base64_data,
3052 mime_type: resized.mime_type.to_string(),
3053 }),
3054 ],
3055 details: None,
3056 is_error: false,
3057 });
3058 }
3059
3060 if initial_read > 0 {
3067 file.seek(SeekFrom::Start(0))
3068 .await
3069 .map_err(|e| Error::tool("read", format!("Failed to seek: {e}")))?;
3070 }
3071
3072 let mut raw_content = Vec::new();
3073 let mut newlines_seen = 0usize;
3074
3075 let start_line_idx = match input.offset {
3077 Some(n) if n > 0 => n.saturating_sub(1).try_into().unwrap_or(usize::MAX),
3078 _ => 0,
3079 };
3080 let limit_lines = input
3081 .limit
3082 .map_or(usize::MAX, |l| l.try_into().unwrap_or(usize::MAX));
3083 let end_line_idx = start_line_idx.saturating_add(limit_lines);
3084
3085 let mut collecting = start_line_idx == 0;
3086 let mut buf = vec![0u8; 64 * 1024].into_boxed_slice(); let mut last_byte_was_newline = false;
3088 let mut pending_cr = false;
3089
3090 let mut total_bytes_read = 0u64;
3094
3095 loop {
3096 let n = read_some(&mut file, &mut buf)
3097 .await
3098 .map_err(|e| Error::tool("read", e.to_string()))?;
3099 if n == 0 {
3100 break;
3101 }
3102 total_bytes_read = total_bytes_read.saturating_add(n as u64);
3103
3104 let chunk = normalize_line_endings_chunk(&buf[..n], &mut pending_cr);
3105 if chunk.is_empty() {
3106 continue;
3107 }
3108 last_byte_was_newline = chunk.last().is_some_and(|byte| *byte == b'\n');
3109 let mut chunk_cursor = 0;
3110
3111 for pos in memchr::memchr_iter(b'\n', &chunk) {
3112 if collecting {
3114 if newlines_seen + 1 == end_line_idx {
3116 if raw_content.len() < DEFAULT_MAX_BYTES {
3118 let remaining = DEFAULT_MAX_BYTES - raw_content.len();
3119 let slice_len = (pos + 1 - chunk_cursor).min(remaining);
3120 raw_content
3121 .extend_from_slice(&chunk[chunk_cursor..chunk_cursor + slice_len]);
3122 }
3123 collecting = false;
3124 chunk_cursor = pos + 1;
3125 }
3126 }
3127
3128 newlines_seen += 1;
3129
3130 if !collecting && newlines_seen == start_line_idx {
3132 collecting = true;
3133 chunk_cursor = pos + 1;
3134 }
3135 }
3136
3137 if collecting && chunk_cursor < chunk.len() && raw_content.len() < DEFAULT_MAX_BYTES {
3139 let remaining = DEFAULT_MAX_BYTES - raw_content.len();
3140 let slice_len = (chunk.len() - chunk_cursor).min(remaining);
3141 raw_content.extend_from_slice(&chunk[chunk_cursor..chunk_cursor + slice_len]);
3142 }
3143 }
3144
3145 if pending_cr {
3146 last_byte_was_newline = true;
3147 if collecting && raw_content.len() < DEFAULT_MAX_BYTES {
3148 raw_content.push(b'\n');
3149 }
3150 newlines_seen += 1;
3151 }
3152
3153 let total_lines = if total_bytes_read == 0 {
3156 0
3157 } else if last_byte_was_newline {
3158 newlines_seen
3159 } else {
3160 newlines_seen + 1
3161 };
3162 let text_content = String::from_utf8_lossy(&raw_content).into_owned();
3163
3164 if total_lines == 0 {
3167 if input.offset.unwrap_or(0) > 0 {
3168 let offset_display = input.offset.unwrap_or(0);
3169 return Err(Error::tool(
3170 "read",
3171 format!(
3172 "Offset {offset_display} is beyond end of file ({total_lines} lines total)"
3173 ),
3174 ));
3175 }
3176 let output = ToolOutput {
3177 content: vec![ContentBlock::Text(TextContent::new(""))],
3178 details: None,
3179 is_error: false,
3180 };
3181 cache_tool_output(
3182 cache_key,
3183 stable_cache_dependency_for_path(&path, cache_mode, cache_deps.as_deref()),
3184 &output,
3185 );
3186 return Ok(output);
3187 }
3188
3189 let start_line = start_line_idx;
3193 let start_line_display = start_line.saturating_add(1);
3194
3195 if start_line >= total_lines {
3196 let offset_display = input.offset.unwrap_or(0);
3197 return Err(Error::tool(
3198 "read",
3199 format!(
3200 "Offset {offset_display} is beyond end of file ({total_lines} lines total)"
3201 ),
3202 ));
3203 }
3204
3205 let max_lines_for_truncation = input
3206 .limit
3207 .and_then(|l| usize::try_from(l).ok())
3208 .unwrap_or(DEFAULT_MAX_LINES);
3209 let display_limit = max_lines_for_truncation.saturating_add(1);
3210
3211 let lines_to_take = limit_lines.min(display_limit);
3215
3216 let mut selected_content = String::new();
3217 let line_iter = text_content.split('\n');
3218
3219 let effective_iter = if text_content.ends_with('\n') {
3221 line_iter.take(lines_to_take)
3222 } else {
3223 line_iter.take(usize::MAX)
3224 };
3225
3226 let max_line_num = start_line.saturating_add(lines_to_take).min(total_lines);
3227 let line_num_width = max_line_num.to_string().len().max(5);
3228
3229 for (i, line) in effective_iter.enumerate() {
3230 if i >= lines_to_take || start_line + i >= total_lines {
3231 break;
3232 }
3233 if i > 0 {
3234 selected_content.push('\n');
3235 }
3236 let line_idx = start_line + i; let line = line.strip_suffix('\r').unwrap_or(line);
3238 if input.hashline {
3239 let tag = format_hashline_tag(line_idx, line);
3240 let _ = write!(selected_content, "{tag}:{line}");
3241 } else {
3242 let line_num = line_idx + 1;
3243 let _ = write!(selected_content, "{line_num:>line_num_width$}→{line}");
3244 }
3245
3246 if selected_content.len() > DEFAULT_MAX_BYTES * 2 {
3247 break;
3248 }
3249 }
3250
3251 let artifact_source = (selected_content.len() > TOOL_OUTPUT_ARTIFACT_THRESHOLD_BYTES)
3252 .then(|| selected_content.clone());
3253
3254 let mut truncation = truncate_head(
3255 selected_content,
3256 max_lines_for_truncation,
3257 DEFAULT_MAX_BYTES,
3258 );
3259 truncation.total_lines = total_lines;
3260
3261 let mut output_text = std::mem::take(&mut truncation.content);
3262 let mut details: Option<serde_json::Value> = None;
3263
3264 if truncation.first_line_exceeds_limit {
3265 let first_line = text_content.split('\n').next().unwrap_or("");
3266 let first_line = first_line.strip_suffix('\r').unwrap_or(first_line);
3267 let first_line_size = format_size(first_line.len());
3268 output_text = format!(
3269 "[Line {start_line_display} is {first_line_size}, exceeds {} limit. Use bash: sed -n '{start_line_display}p' '{}' | head -c {DEFAULT_MAX_BYTES}]",
3270 format_size(DEFAULT_MAX_BYTES),
3271 input.path.replace('\'', "'\\''")
3272 );
3273 details = Some(serde_json::json!({ "truncation": truncation }));
3274 } else if truncation.truncated {
3275 let end_line_display = start_line_display
3276 .saturating_add(truncation.output_lines)
3277 .saturating_sub(1);
3278 let next_offset = end_line_display.saturating_add(1);
3279
3280 if truncation.truncated_by == Some(TruncatedBy::Lines) {
3281 let _ = write!(
3282 output_text,
3283 "\n\n[Showing lines {start_line_display}-{end_line_display} of {total_lines}. Use offset={next_offset} to continue.]"
3284 );
3285 } else {
3286 let _ = write!(
3287 output_text,
3288 "\n\n[Showing lines {start_line_display}-{end_line_display} of {total_lines} ({} limit). Use offset={next_offset} to continue.]",
3289 format_size(DEFAULT_MAX_BYTES)
3290 );
3291 }
3292
3293 details = Some(serde_json::json!({ "truncation": truncation }));
3294 } else {
3295 let displayed_lines = truncation.output_lines;
3297 let end_line_display = start_line_display
3298 .saturating_add(displayed_lines)
3299 .saturating_sub(1);
3300
3301 if end_line_display < total_lines {
3302 let remaining = total_lines.saturating_sub(end_line_display);
3303 let next_offset = end_line_display.saturating_add(1);
3304 let _ = write!(
3305 output_text,
3306 "\n\n[{remaining} more lines in file. Use offset={next_offset} to continue.]"
3307 );
3308 }
3309 }
3310
3311 if let Some(artifact_source) = artifact_source.as_deref() {
3312 attach_text_artifact_if_needed_with_root(
3313 self.artifact_root.as_deref(),
3314 &mut output_text,
3315 &mut details,
3316 "read",
3317 tool_call_id,
3318 "selectedTextWindow",
3319 artifact_source,
3320 );
3321 }
3322
3323 let output = ToolOutput {
3324 content: vec![ContentBlock::Text(TextContent::new(output_text))],
3325 details,
3326 is_error: false,
3327 };
3328 cache_tool_output(
3329 cache_key,
3330 stable_cache_dependency_for_path(&path, cache_mode, cache_deps.as_deref()),
3331 &output,
3332 );
3333 Ok(output)
3334 }
3335}
3336
3337#[derive(Debug, Deserialize)]
3343#[serde(rename_all = "camelCase")]
3344struct BashInput {
3345 command: String,
3346 timeout: Option<u64>,
3347}
3348
3349pub struct BashTool {
3350 cwd: PathBuf,
3351 shell_path: Option<String>,
3352 command_prefix: Option<String>,
3353 artifact_root: Option<PathBuf>,
3354}
3355
3356#[derive(Debug, Clone)]
3357pub struct BashRunResult {
3358 pub output: String,
3359 pub exit_code: i32,
3360 pub cancelled: bool,
3361 pub cancellation_reason: Option<BashCancellationReason>,
3362 pub timeout_ms: Option<u64>,
3363 pub truncated: bool,
3364 pub full_output_path: Option<String>,
3365 pub truncation: Option<TruncationResult>,
3366}
3367
3368#[derive(Debug)]
3369enum BashPipeFrame {
3370 Chunk(Vec<u8>),
3371 Error(String),
3372}
3373
3374#[allow(clippy::unnecessary_lazy_evaluations)] fn exit_status_code(status: std::process::ExitStatus) -> i32 {
3376 status.code().unwrap_or_else(|| {
3377 #[cfg(unix)]
3378 {
3379 use std::os::unix::process::ExitStatusExt as _;
3380 status.signal().map_or(-1, |signal| -signal)
3381 }
3382 #[cfg(not(unix))]
3383 {
3384 -1
3385 }
3386 })
3387}
3388
3389fn bash_cancellation_details(
3390 reason: BashCancellationReason,
3391 timeout_ms: Option<u64>,
3392 exit_code: i32,
3393) -> serde_json::Value {
3394 serde_json::json!({
3395 "schema": BASH_CANCELLATION_SCHEMA_V1,
3396 "status": "cancelled",
3397 "reason": reason.as_str(),
3398 "cleanup": "process_group_tree_terminated",
3399 "exitCode": exit_code,
3400 "timeoutMs": timeout_ms,
3401 })
3402}
3403
3404#[allow(clippy::too_many_lines)]
3405pub(crate) async fn run_bash_command(
3406 cwd: &Path,
3407 shell_path: Option<&str>,
3408 command_prefix: Option<&str>,
3409 command: &str,
3410 timeout_secs: Option<u64>,
3411 on_update: Option<&(dyn Fn(ToolUpdate) + Send + Sync)>,
3412) -> Result<BashRunResult> {
3413 let timeout_secs = match timeout_secs {
3414 None => Some(DEFAULT_BASH_TIMEOUT_SECS),
3415 Some(0) => None,
3416 Some(value) => Some(value),
3417 };
3418 let command = command_prefix.filter(|p| !p.trim().is_empty()).map_or_else(
3419 || command.to_string(),
3420 |prefix| format!("{prefix}\n{command}"),
3421 );
3422 let command = format!("trap 'code=$?; wait; exit $code' EXIT\n{command}");
3423
3424 if !cwd.exists() {
3425 return Err(Error::tool(
3426 "bash",
3427 format!(
3428 "Working directory does not exist: {}\nCannot execute bash commands.",
3429 cwd.display()
3430 ),
3431 ));
3432 }
3433
3434 let shell = shell_path.unwrap_or_else(|| {
3435 for path in ["/bin/bash", "/usr/bin/bash", "/usr/local/bin/bash"] {
3436 if Path::new(path).exists() {
3437 return path;
3438 }
3439 }
3440 "sh"
3441 });
3442
3443 let mut cmd = command_with_default_sigpipe_in_dir(shell, cwd)
3444 .map_err(|e| Error::tool("bash", format!("Failed to prepare shell: {e}")))?;
3445 cmd.arg("-c")
3446 .arg(&command)
3447 .current_dir(cwd)
3448 .stdin(Stdio::null())
3449 .stdout(Stdio::piped())
3450 .stderr(Stdio::piped());
3451
3452 isolate_command_process_group(&mut cmd);
3455
3456 let mut child = cmd
3457 .spawn()
3458 .map_err(|e| Error::tool("bash", format!("Failed to spawn shell: {e}")))?;
3459
3460 let stdout = child
3461 .stdout
3462 .take()
3463 .ok_or_else(|| Error::tool("bash", "Missing stdout".to_string()))?;
3464 let stderr = child
3465 .stderr
3466 .take()
3467 .ok_or_else(|| Error::tool("bash", "Missing stderr".to_string()))?;
3468
3469 let mut guard = ProcessGuard::new(child, ProcessCleanupMode::ProcessGroupTree);
3471
3472 let (tx, rx) = mpsc::sync_channel::<BashPipeFrame>(1024);
3478 let tx_stdout = tx.clone();
3479
3480 let stdout_thread = thread::spawn(move || pump_stream(stdout, "stdout", &tx_stdout));
3488 let stderr_thread = thread::spawn(move || pump_stream(stderr, "stderr", &tx));
3489
3490 let max_chunks_bytes = DEFAULT_MAX_BYTES.saturating_mul(2);
3491 let mut bash_output = BashOutputState::new(max_chunks_bytes);
3492 bash_output.timeout_ms = timeout_secs.map(|s| s.saturating_mul(1000));
3493
3494 let cx = AgentCx::for_current_or_request();
3495 let mut timed_out = false;
3496 let mut cancelled = false;
3497 let mut cancellation_reason: Option<BashCancellationReason> = None;
3498 let mut exit_code: Option<i32> = None;
3499 let start = cx
3500 .cx()
3501 .timer_driver()
3502 .map_or_else(wall_now, |timer| timer.now());
3503 let timeout = timeout_secs.map(Duration::from_secs);
3504 let mut terminate_deadline: Option<asupersync::Time> = None;
3505
3506 let tick = Duration::from_millis(10);
3507 loop {
3508 let mut updated = false;
3509 while let Ok(frame) = rx.try_recv() {
3510 if let Err(err) = ingest_bash_pipe_frame(frame, &mut bash_output).await {
3511 let _ = guard.kill();
3512 return Err(err);
3513 }
3514 updated = true;
3515 }
3516
3517 if updated {
3518 emit_bash_update(&bash_output, on_update)?;
3519 }
3520
3521 match guard.try_wait_child() {
3522 Ok(Some(status)) => {
3523 exit_code = Some(exit_status_code(status));
3524 break;
3525 }
3526 Ok(None) => {}
3527 Err(err) => return Err(Error::tool("bash", err.to_string())),
3528 }
3529
3530 let now = cx
3531 .cx()
3532 .timer_driver()
3533 .map_or_else(wall_now, |timer| timer.now());
3534
3535 if let Some(deadline) = terminate_deadline {
3536 if now >= deadline {
3537 if let Some(status) = guard.kill() {
3538 exit_code = Some(exit_status_code(status));
3539 }
3540 break; }
3542 } else if let Some(timeout) = timeout {
3543 let elapsed = std::time::Duration::from_nanos(now.duration_since(start));
3544 if elapsed >= timeout {
3545 timed_out = true;
3546 cancellation_reason = Some(BashCancellationReason::Timeout);
3547 let pid = guard.child.as_ref().map(std::process::Child::id);
3548 terminate_process_group_tree(pid);
3549 terminate_deadline = Some(now + Duration::from_secs(BASH_TERMINATE_GRACE_SECS));
3550 }
3551 }
3552
3553 if terminate_deadline.is_none() && cx.checkpoint().is_err() {
3554 cancelled = true;
3555 cancellation_reason = Some(BashCancellationReason::AmbientCancellation);
3556 let _ = guard.kill();
3557 exit_code = Some(-1);
3558 break;
3559 }
3560
3561 sleep(now, tick).await;
3562 }
3563
3564 {
3571 let drain_start = cx
3572 .cx()
3573 .timer_driver()
3574 .map_or_else(wall_now, |timer| timer.now());
3575 let drain_deadline = drain_start + Duration::from_secs(5);
3576 let allow_drain_cancellation = !cancelled && !timed_out && exit_code.is_none();
3577 loop {
3578 let mut got_data = false;
3580 while let Ok(frame) = rx.try_recv() {
3581 if let Err(err) = ingest_bash_pipe_frame(frame, &mut bash_output).await {
3582 let _ = guard.kill();
3583 return Err(err);
3584 }
3585 got_data = true;
3586 }
3587 if got_data {
3588 emit_bash_update(&bash_output, on_update)?;
3589 }
3590
3591 if stdout_thread.is_finished() && stderr_thread.is_finished() {
3594 while let Ok(frame) = rx.try_recv() {
3597 if let Err(err) = ingest_bash_pipe_frame(frame, &mut bash_output).await {
3598 let _ = guard.kill();
3599 return Err(err);
3600 }
3601 }
3602 break;
3603 }
3604
3605 let now = cx
3606 .cx()
3607 .timer_driver()
3608 .map_or_else(wall_now, |timer| timer.now());
3609 if now >= drain_deadline {
3610 break;
3611 }
3612 if allow_drain_cancellation && cx.checkpoint().is_err() {
3613 cancelled = true;
3614 cancellation_reason.get_or_insert(BashCancellationReason::AmbientCancellation);
3615 break;
3616 }
3617 sleep(now, tick).await;
3618 }
3619 }
3620
3621 if guard.child.is_some() {
3627 if let Ok(status) = guard.wait() {
3628 exit_code.get_or_insert_with(|| exit_status_code(status));
3629 }
3630 }
3631
3632 drop(bash_output.temp_file.take());
3633
3634 let raw_output = concat_chunks(&bash_output.chunks);
3635 let full_output = String::from_utf8_lossy(&raw_output).into_owned();
3636 let full_output_last_line_len = full_output.split('\n').next_back().map_or(0, str::len);
3637
3638 let mut truncation = truncate_tail(full_output, DEFAULT_MAX_LINES, DEFAULT_MAX_BYTES);
3639 if bash_output.total_bytes > bash_output.chunks_bytes {
3640 truncation.truncated = true;
3641 truncation.truncated_by = Some(TruncatedBy::Bytes);
3642 truncation.total_bytes = bash_output.total_bytes;
3643 truncation.total_lines = line_count_from_newline_count(
3644 bash_output.total_bytes,
3645 bash_output.line_count,
3646 bash_output.last_byte_was_newline,
3647 );
3648 }
3649
3650 let mut output_text = if truncation.content.is_empty() {
3651 "(no output)".to_string()
3652 } else {
3653 std::mem::take(&mut truncation.content)
3654 };
3655
3656 let mut full_output_path = None;
3657 if truncation.truncated {
3658 if let Some(path) = bash_output.temp_file_path.as_ref() {
3659 full_output_path = Some(path.display().to_string());
3660 }
3661
3662 let start_line = truncation
3663 .total_lines
3664 .saturating_sub(truncation.output_lines)
3665 .saturating_add(1);
3666 let end_line = truncation.total_lines;
3667
3668 let display_path = full_output_path.as_deref().unwrap_or("undefined");
3669 let file_limit_hit = bash_output.total_bytes > BASH_FILE_LIMIT_BYTES;
3670 let output_qualifier = if file_limit_hit {
3671 format!(
3672 "Partial output (capped at {})",
3673 format_size(BASH_FILE_LIMIT_BYTES)
3674 )
3675 } else {
3676 "Full output".to_string()
3677 };
3678
3679 if truncation.last_line_partial {
3680 let last_line_size = format_size(full_output_last_line_len);
3681 let _ = write!(
3682 output_text,
3683 "\n\n[Showing last {} of line {end_line} (line is {last_line_size}). {output_qualifier}: {display_path}]",
3684 format_size(truncation.output_bytes)
3685 );
3686 } else if truncation.truncated_by == Some(TruncatedBy::Lines) {
3687 let _ = write!(
3688 output_text,
3689 "\n\n[Showing lines {start_line}-{end_line} of {}. {output_qualifier}: {display_path}]",
3690 truncation.total_lines
3691 );
3692 } else {
3693 let _ = write!(
3694 output_text,
3695 "\n\n[Showing lines {start_line}-{end_line} of {} ({} limit). {output_qualifier}: {display_path}]",
3696 truncation.total_lines,
3697 format_size(DEFAULT_MAX_BYTES)
3698 );
3699 }
3700 }
3701
3702 if timed_out {
3703 cancelled = true;
3704 if !output_text.is_empty() {
3705 output_text.push_str("\n\n");
3706 }
3707 let timeout_display = timeout_secs.unwrap_or(0);
3708 let _ = write!(
3709 output_text,
3710 "Command timed out after {timeout_display} seconds"
3711 );
3712 }
3713
3714 let exit_code = exit_code.unwrap_or(-1);
3715 if !cancelled && exit_code != 0 {
3716 let _ = write!(output_text, "\n\nCommand exited with code {exit_code}");
3717 }
3718
3719 Ok(BashRunResult {
3720 output: output_text,
3721 exit_code,
3722 cancelled,
3723 cancellation_reason,
3724 timeout_ms: timeout_secs.map(|s| s.saturating_mul(1000)),
3725 truncated: truncation.truncated,
3726 full_output_path,
3727 truncation: if truncation.truncated {
3728 Some(truncation)
3729 } else {
3730 None
3731 },
3732 })
3733}
3734
3735impl BashTool {
3736 pub fn new(cwd: &Path) -> Self {
3737 Self {
3738 cwd: cwd.to_path_buf(),
3739 shell_path: None,
3740 command_prefix: None,
3741 artifact_root: None,
3742 }
3743 }
3744
3745 pub fn with_shell(
3746 cwd: &Path,
3747 shell_path: Option<String>,
3748 command_prefix: Option<String>,
3749 ) -> Self {
3750 Self {
3751 cwd: cwd.to_path_buf(),
3752 shell_path,
3753 command_prefix,
3754 artifact_root: None,
3755 }
3756 }
3757
3758 #[cfg(test)]
3759 fn with_artifact_root(cwd: &Path, artifact_root: &Path) -> Self {
3760 Self {
3761 cwd: cwd.to_path_buf(),
3762 shell_path: None,
3763 command_prefix: None,
3764 artifact_root: Some(artifact_root.to_path_buf()),
3765 }
3766 }
3767}
3768
3769#[async_trait]
3770#[allow(clippy::unnecessary_literal_bound)]
3771impl Tool for BashTool {
3772 fn name(&self) -> &str {
3773 "bash"
3774 }
3775 fn label(&self) -> &str {
3776 "bash"
3777 }
3778 fn description(&self) -> &str {
3779 "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."
3780 }
3781
3782 fn parameters(&self) -> serde_json::Value {
3783 serde_json::json!({
3784 "type": "object",
3785 "properties": {
3786 "command": {
3787 "type": "string",
3788 "description": "Bash command to execute"
3789 },
3790 "timeout": {
3791 "type": "integer",
3792 "description": "Timeout in seconds (default 120; set 0 to disable)"
3793 }
3794 },
3795 "required": ["command"]
3796 })
3797 }
3798
3799 fn effects(&self) -> ToolEffects {
3800 ToolEffects::process().union(ToolEffects::write())
3801 }
3802
3803 #[allow(clippy::too_many_lines)]
3804 async fn execute(
3805 &self,
3806 tool_call_id: &str,
3807 input: serde_json::Value,
3808 on_update: Option<Box<dyn Fn(ToolUpdate) + Send + Sync>>,
3809 ) -> Result<ToolOutput> {
3810 let input: BashInput =
3811 serde_json::from_value(input).map_err(|e| Error::validation(e.to_string()))?;
3812
3813 let result = run_bash_command(
3814 &self.cwd,
3815 self.shell_path.as_deref(),
3816 self.command_prefix.as_deref(),
3817 &input.command,
3818 input.timeout,
3819 on_update.as_deref(),
3820 )
3821 .await?;
3822
3823 let mut details_map = serde_json::Map::new();
3824 if let Some(truncation) = result.truncation.as_ref() {
3825 details_map.insert("truncation".to_string(), serde_json::to_value(truncation)?);
3826 }
3827 if let Some(path) = result.full_output_path.as_ref() {
3828 details_map.insert(
3829 "fullOutputPath".to_string(),
3830 serde_json::Value::String(path.clone()),
3831 );
3832 }
3833 if let Some(reason) = result.cancellation_reason {
3834 details_map.insert(
3835 "cancellation".to_string(),
3836 bash_cancellation_details(reason, result.timeout_ms, result.exit_code),
3837 );
3838 }
3839
3840 let details = if details_map.is_empty() {
3841 None
3842 } else {
3843 Some(serde_json::Value::Object(details_map))
3844 };
3845 let mut details = details;
3846 let mut output_text = result.output;
3847
3848 if let Some(path) = result.full_output_path.as_deref() {
3849 attach_text_artifact_from_path_if_needed_with_root(
3850 self.artifact_root.as_deref(),
3851 &mut output_text,
3852 &mut details,
3853 "bash",
3854 tool_call_id,
3855 "fullCommandOutput",
3856 Path::new(path),
3857 );
3858 }
3859
3860 let is_error = result.cancelled || result.exit_code != 0;
3861
3862 Ok(ToolOutput {
3863 content: vec![ContentBlock::Text(TextContent::new(output_text))],
3864 details,
3865 is_error,
3866 })
3867 }
3868}
3869
3870#[derive(Debug, Deserialize)]
3876#[serde(rename_all = "camelCase")]
3877struct EditInput {
3878 path: String,
3879 old_text: String,
3880 new_text: String,
3881}
3882
3883pub struct EditTool {
3884 cwd: PathBuf,
3885}
3886
3887impl EditTool {
3888 pub fn new(cwd: &Path) -> Self {
3889 Self {
3890 cwd: cwd.to_path_buf(),
3891 }
3892 }
3893}
3894
3895fn strip_bom(s: &str) -> (&str, bool) {
3896 s.strip_prefix('\u{FEFF}')
3897 .map_or_else(|| (s, false), |stripped| (stripped, true))
3898}
3899
3900fn detect_line_ending(content: &str) -> &'static str {
3901 let bytes = content.as_bytes();
3902 let mut idx = 0;
3903 while idx < bytes.len() {
3904 match bytes[idx] {
3905 b'\r' => {
3906 return if bytes.get(idx + 1) == Some(&b'\n') {
3907 "\r\n"
3908 } else {
3909 "\r"
3910 };
3911 }
3912 b'\n' => return "\n",
3913 _ => idx += 1,
3914 }
3915 }
3916 "\n"
3917}
3918
3919fn normalize_to_lf(text: &str) -> String {
3920 if !text.contains('\r') {
3921 return text.to_string();
3922 }
3923 let mut out = String::with_capacity(text.len());
3924 let mut chars = text.chars().peekable();
3925 while let Some(c) = chars.next() {
3926 if c == '\r' {
3927 out.push('\n');
3928 if chars.peek() == Some(&'\n') {
3929 chars.next();
3930 }
3931 } else {
3932 out.push(c);
3933 }
3934 }
3935 out
3936}
3937
3938fn normalize_line_endings_chunk<'a>(
3939 chunk: &'a [u8],
3940 pending_cr: &mut bool,
3941) -> std::borrow::Cow<'a, [u8]> {
3942 if !*pending_cr && memchr::memchr(b'\r', chunk).is_none() {
3943 return std::borrow::Cow::Borrowed(chunk);
3944 }
3945
3946 let mut normalized = Vec::with_capacity(chunk.len().saturating_add(usize::from(*pending_cr)));
3947 let mut idx = 0;
3948
3949 if *pending_cr {
3950 normalized.push(b'\n');
3951 if chunk.first() == Some(&b'\n') {
3952 idx = 1;
3953 }
3954 *pending_cr = false;
3955 }
3956
3957 while idx < chunk.len() {
3958 match chunk[idx] {
3959 b'\r' => {
3960 if chunk.get(idx + 1) == Some(&b'\n') {
3961 normalized.push(b'\n');
3962 idx += 2;
3963 } else if idx + 1 < chunk.len() {
3964 normalized.push(b'\n');
3965 idx += 1;
3966 } else {
3967 *pending_cr = true;
3968 idx += 1;
3969 }
3970 }
3971 byte => {
3972 normalized.push(byte);
3973 idx += 1;
3974 }
3975 }
3976 }
3977
3978 std::borrow::Cow::Owned(normalized)
3979}
3980
3981fn restore_line_endings(text: &str, ending: &str) -> String {
3982 match ending {
3983 "\r\n" => text.replace('\n', "\r\n"),
3984 "\r" => text.replace('\n', "\r"),
3985 _ => text.to_string(),
3986 }
3987}
3988
3989#[derive(Debug, Clone)]
3990struct FuzzyMatchResult {
3991 found: bool,
3992 index: usize,
3993 match_length: usize,
3994 exact_match: bool,
3995}
3996
3997fn map_normalized_range_to_original(
4001 content: &str,
4002 norm_match_start: usize,
4003 norm_match_len: usize,
4004) -> (usize, usize) {
4005 let mut norm_idx = 0;
4006 let mut orig_idx = 0;
4007 let mut match_start = None;
4008 let mut match_end = None;
4009 let norm_match_end = norm_match_start + norm_match_len;
4010 let mut last_trimmed_end = 0;
4011 let mut last_has_newline = false;
4012
4013 for line in content.split_inclusive('\n') {
4014 let line_content = line.strip_suffix('\n').unwrap_or(line);
4015 let has_newline = line.ends_with('\n');
4016 let trimmed_len = line_content
4017 .trim_end_matches(|c: char| c.is_whitespace() || is_special_unicode_space(c))
4018 .len();
4019 let trimmed_end = orig_idx + trimmed_len;
4020 last_trimmed_end = trimmed_end;
4021 last_has_newline = has_newline;
4022
4023 for (char_offset, c) in line_content.char_indices() {
4024 if norm_idx == norm_match_end && match_end.is_none() {
4027 match_end = Some(orig_idx + char_offset);
4028 }
4029
4030 if char_offset >= trimmed_len {
4031 continue;
4032 }
4033
4034 if norm_idx == norm_match_start && match_start.is_none() {
4039 match_start = Some(orig_idx + char_offset);
4040 }
4041 if match_start.is_some() && match_end.is_some() {
4042 break;
4043 }
4044
4045 let normalized_char = if is_special_unicode_space(c) {
4046 ' '
4047 } else if matches!(c, '\u{2018}' | '\u{2019}') {
4048 '\''
4049 } else if matches!(c, '\u{201C}' | '\u{201D}' | '\u{201E}' | '\u{201F}') {
4050 '"'
4051 } else if matches!(
4052 c,
4053 '\u{2010}'
4054 | '\u{2011}'
4055 | '\u{2012}'
4056 | '\u{2013}'
4057 | '\u{2014}'
4058 | '\u{2015}'
4059 | '\u{2212}'
4060 ) {
4061 '-'
4062 } else {
4063 c
4064 };
4065
4066 norm_idx += normalized_char.len_utf8();
4067 }
4068
4069 orig_idx += line_content.len();
4070
4071 if has_newline {
4072 if norm_idx == norm_match_start && match_start.is_none() {
4073 match_start = Some(orig_idx);
4074 }
4075 if norm_idx == norm_match_end && match_end.is_none() {
4076 match_end = Some(trimmed_end);
4077 }
4078
4079 norm_idx += 1;
4080 orig_idx += 1;
4081 }
4082
4083 if match_start.is_some() && match_end.is_some() {
4084 break;
4085 }
4086 }
4087
4088 if norm_idx == norm_match_end && match_end.is_none() {
4089 match_end = Some(if last_has_newline {
4090 orig_idx
4091 } else {
4092 last_trimmed_end
4093 });
4094 }
4095
4096 let start = match_start.unwrap_or(0);
4097 let end = match_end.unwrap_or(content.len());
4098 (start, end.saturating_sub(start))
4099}
4100
4101fn build_normalized_content(content: &str) -> String {
4102 let mut normalized = String::with_capacity(content.len());
4103 let mut lines = content.split('\n').peekable();
4104
4105 while let Some(line) = lines.next() {
4106 let trimmed_len = line
4107 .trim_end_matches(|c: char| c.is_whitespace() || is_special_unicode_space(c))
4108 .len();
4109 for (char_offset, c) in line.char_indices() {
4110 if char_offset >= trimmed_len {
4111 continue;
4112 }
4113 let normalized_char = if is_special_unicode_space(c) {
4114 ' '
4115 } else if matches!(c, '\u{2018}' | '\u{2019}') {
4116 '\''
4117 } else if matches!(c, '\u{201C}' | '\u{201D}' | '\u{201E}' | '\u{201F}') {
4118 '"'
4119 } else if matches!(
4120 c,
4121 '\u{2010}'
4122 | '\u{2011}'
4123 | '\u{2012}'
4124 | '\u{2013}'
4125 | '\u{2014}'
4126 | '\u{2015}'
4127 | '\u{2212}'
4128 ) {
4129 '-'
4130 } else {
4131 c
4132 };
4133 normalized.push(normalized_char);
4134 }
4135 if lines.peek().is_some() {
4136 normalized.push('\n');
4137 }
4138 }
4139 normalized
4140}
4141
4142#[cfg(test)]
4143fn fuzzy_find_text(content: &str, old_text: &str) -> FuzzyMatchResult {
4144 fuzzy_find_text_with_normalized(content, old_text, None, None)
4145}
4146
4147fn fuzzy_find_text_with_normalized(
4150 content: &str,
4151 old_text: &str,
4152 precomputed_content: Option<&str>,
4153 precomputed_old: Option<&str>,
4154) -> FuzzyMatchResult {
4155 use std::borrow::Cow;
4156
4157 if let Some(index) = content.find(old_text) {
4159 return FuzzyMatchResult {
4160 found: true,
4161 index,
4162 match_length: old_text.len(),
4163 exact_match: true,
4164 };
4165 }
4166
4167 let normalized_content = precomputed_content.map_or_else(
4169 || Cow::Owned(build_normalized_content(content)),
4170 Cow::Borrowed,
4171 );
4172 let normalized_old_text = precomputed_old.map_or_else(
4173 || Cow::Owned(build_normalized_content(old_text)),
4174 Cow::Borrowed,
4175 );
4176
4177 if let Some(normalized_index) = normalized_content.find(normalized_old_text.as_ref()) {
4179 let (original_start, original_match_len) =
4180 map_normalized_range_to_original(content, normalized_index, normalized_old_text.len());
4181
4182 return FuzzyMatchResult {
4183 found: true,
4184 index: original_start,
4185 match_length: original_match_len,
4186 exact_match: false,
4187 };
4188 }
4189
4190 FuzzyMatchResult {
4191 found: false,
4192 index: 0,
4193 match_length: 0,
4194 exact_match: false,
4195 }
4196}
4197
4198fn count_overlapping_occurrences(haystack: &str, needle: &str) -> usize {
4199 if needle.is_empty() {
4200 return 0;
4201 }
4202
4203 haystack
4204 .char_indices()
4205 .filter(|(idx, _)| haystack[*idx..].starts_with(needle))
4206 .count()
4207}
4208
4209#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4210enum DiffTag {
4211 Equal,
4212 Added,
4213 Removed,
4214}
4215
4216#[derive(Debug, Clone)]
4217struct DiffPart {
4218 tag: DiffTag,
4219 value: String,
4220}
4221
4222fn diff_parts(old_content: &str, new_content: &str) -> Vec<DiffPart> {
4223 use similar::ChangeTag;
4224
4225 let diff = similar::TextDiff::from_lines(old_content, new_content);
4226
4227 let mut parts: Vec<DiffPart> = Vec::new();
4228 let mut current_tag: Option<DiffTag> = None;
4229 let mut current_lines: Vec<&str> = Vec::new();
4230
4231 for change in diff.iter_all_changes() {
4232 let tag = match change.tag() {
4233 ChangeTag::Equal => DiffTag::Equal,
4234 ChangeTag::Insert => DiffTag::Added,
4235 ChangeTag::Delete => DiffTag::Removed,
4236 };
4237
4238 let mut line = change.value();
4239 if let Some(stripped) = line.strip_suffix('\n') {
4240 line = stripped;
4241 }
4242
4243 if current_tag == Some(tag) {
4244 current_lines.push(line);
4245 } else {
4246 if let Some(prev_tag) = current_tag {
4247 parts.push(DiffPart {
4248 tag: prev_tag,
4249 value: current_lines.join("\n"),
4250 });
4251 }
4252 current_tag = Some(tag);
4253 current_lines = vec![line];
4254 }
4255 }
4256
4257 if let Some(tag) = current_tag {
4258 parts.push(DiffPart {
4259 tag,
4260 value: current_lines.join("\n"),
4261 });
4262 }
4263
4264 parts
4265}
4266
4267fn diff_line_num_width(old_content: &str, new_content: &str) -> usize {
4268 let old_line_count = memchr::memchr_iter(b'\n', old_content.as_bytes()).count() + 1;
4270 let new_line_count = memchr::memchr_iter(b'\n', new_content.as_bytes()).count() + 1;
4271 let max_line_num = old_line_count.max(new_line_count).max(1);
4272 max_line_num.ilog10() as usize + 1
4273}
4274
4275fn split_diff_lines(value: &str) -> Vec<&str> {
4276 value.split('\n').collect()
4282}
4283
4284#[inline]
4285const fn is_change_tag(tag: DiffTag) -> bool {
4286 matches!(tag, DiffTag::Added | DiffTag::Removed)
4287}
4288
4289#[derive(Debug)]
4290struct DiffRenderState {
4291 output: String,
4292 old_line_num: usize,
4293 new_line_num: usize,
4294 last_was_change: bool,
4295 first_changed_line: Option<usize>,
4296 line_num_width: usize,
4297 context_lines: usize,
4298}
4299
4300impl DiffRenderState {
4301 const fn new(line_num_width: usize, context_lines: usize) -> Self {
4302 Self {
4303 output: String::new(),
4304 old_line_num: 1,
4305 new_line_num: 1,
4306 last_was_change: false,
4307 first_changed_line: None,
4308 line_num_width,
4309 context_lines,
4310 }
4311 }
4312
4313 #[inline]
4314 fn ensure_line_break(&mut self) {
4315 if !self.output.is_empty() {
4316 self.output.push('\n');
4317 }
4318 }
4319
4320 const fn mark_first_change(&mut self) {
4321 if self.first_changed_line.is_none() {
4322 self.first_changed_line = Some(self.new_line_num);
4323 }
4324 }
4325
4326 fn push_added_line(&mut self, line: &str) {
4327 self.ensure_line_break();
4328 let _ = write!(
4329 self.output,
4330 "+{line_num:>width$} {line}",
4331 line_num = self.new_line_num,
4332 width = self.line_num_width
4333 );
4334 self.new_line_num = self.new_line_num.saturating_add(1);
4335 }
4336
4337 fn push_removed_line(&mut self, line: &str) {
4338 self.ensure_line_break();
4339 let _ = write!(
4340 self.output,
4341 "-{line_num:>width$} {line}",
4342 line_num = self.old_line_num,
4343 width = self.line_num_width
4344 );
4345 self.old_line_num = self.old_line_num.saturating_add(1);
4346 }
4347
4348 fn push_context_line(&mut self, line: &str) {
4349 self.ensure_line_break();
4350 let _ = write!(
4351 self.output,
4352 " {line_num:>width$} {line}",
4353 line_num = self.old_line_num,
4354 width = self.line_num_width
4355 );
4356 self.old_line_num = self.old_line_num.saturating_add(1);
4357 self.new_line_num = self.new_line_num.saturating_add(1);
4358 }
4359
4360 fn push_skip_marker(&mut self, skip: usize) {
4361 if skip == 0 {
4362 return;
4363 }
4364 self.ensure_line_break();
4365 let _ = write!(
4366 self.output,
4367 " {:>width$} ...",
4368 " ",
4369 width = self.line_num_width
4370 );
4371 self.old_line_num = self.old_line_num.saturating_add(skip);
4372 self.new_line_num = self.new_line_num.saturating_add(skip);
4373 }
4374}
4375
4376fn render_changed_part(tag: DiffTag, raw: &[&str], state: &mut DiffRenderState) {
4377 state.mark_first_change();
4378 for line in raw {
4379 match tag {
4380 DiffTag::Added => state.push_added_line(line),
4381 DiffTag::Removed => state.push_removed_line(line),
4382 DiffTag::Equal => {}
4383 }
4384 }
4385 state.last_was_change = true;
4386}
4387
4388fn render_equal_part(raw: &[&str], next_part_is_change: bool, state: &mut DiffRenderState) {
4389 if !(state.last_was_change || next_part_is_change) {
4390 let raw_len = raw.len();
4391 state.old_line_num = state.old_line_num.saturating_add(raw_len);
4392 state.new_line_num = state.new_line_num.saturating_add(raw_len);
4393 state.last_was_change = false;
4394 return;
4395 }
4396
4397 if state.last_was_change
4398 && next_part_is_change
4399 && raw.len() > state.context_lines.saturating_mul(2)
4400 {
4401 for line in raw.iter().take(state.context_lines) {
4402 state.push_context_line(line);
4403 }
4404
4405 let skip = raw.len().saturating_sub(state.context_lines * 2);
4406 state.push_skip_marker(skip);
4407
4408 for line in raw
4409 .iter()
4410 .skip(raw.len().saturating_sub(state.context_lines))
4411 {
4412 state.push_context_line(line);
4413 }
4414 } else {
4415 let start = if state.last_was_change {
4417 0
4418 } else {
4419 raw.len().saturating_sub(state.context_lines)
4420 };
4421 let lines_after_start = raw.len().saturating_sub(start);
4422 let (end, skip_end) = if !next_part_is_change && lines_after_start > state.context_lines {
4423 (
4424 start + state.context_lines,
4425 lines_after_start - state.context_lines,
4426 )
4427 } else {
4428 (raw.len(), 0)
4429 };
4430
4431 state.push_skip_marker(start);
4432 for line in &raw[start..end] {
4433 state.push_context_line(line);
4434 }
4435 state.push_skip_marker(skip_end);
4436 }
4437
4438 state.last_was_change = false;
4439}
4440
4441fn generate_diff_string(old_content: &str, new_content: &str) -> (String, Option<usize>) {
4442 let parts = diff_parts(old_content, new_content);
4443 let mut state = DiffRenderState::new(diff_line_num_width(old_content, new_content), 4);
4444
4445 for (i, part) in parts.iter().enumerate() {
4446 let raw = split_diff_lines(&part.value);
4447 let next_part_is_change = parts.get(i + 1).is_some_and(|next| is_change_tag(next.tag));
4448
4449 match part.tag {
4450 DiffTag::Added | DiffTag::Removed => render_changed_part(part.tag, &raw, &mut state),
4451 DiffTag::Equal => render_equal_part(&raw, next_part_is_change, &mut state),
4452 }
4453 }
4454
4455 (state.output, state.first_changed_line)
4456}
4457
4458#[async_trait]
4459#[allow(clippy::unnecessary_literal_bound)]
4460impl Tool for EditTool {
4461 fn name(&self) -> &str {
4462 "edit"
4463 }
4464 fn label(&self) -> &str {
4465 "edit"
4466 }
4467 fn description(&self) -> &str {
4468 "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."
4469 }
4470
4471 fn parameters(&self) -> serde_json::Value {
4472 serde_json::json!({
4473 "type": "object",
4474 "properties": {
4475 "path": {
4476 "type": "string",
4477 "description": "Path to the file to edit (relative or absolute)"
4478 },
4479 "oldText": {
4480 "type": "string",
4481 "minLength": 1,
4482 "description": "Text to find and replace (must match uniquely; matching normalizes line endings, Unicode spaces/quotes/dashes, and ignores trailing whitespace)"
4483 },
4484 "newText": {
4485 "type": "string",
4486 "description": "New text to replace the old text with"
4487 }
4488 },
4489 "required": ["path", "oldText", "newText"]
4490 })
4491 }
4492
4493 #[allow(clippy::too_many_lines)]
4494 async fn execute(
4495 &self,
4496 _tool_call_id: &str,
4497 input: serde_json::Value,
4498 _on_update: Option<Box<dyn Fn(ToolUpdate) + Send + Sync>>,
4499 ) -> Result<ToolOutput> {
4500 let input: EditInput =
4501 serde_json::from_value(input).map_err(|e| Error::validation(e.to_string()))?;
4502
4503 if input.new_text.len() > WRITE_TOOL_MAX_BYTES {
4504 return Err(Error::validation(format!(
4505 "New text size exceeds maximum allowed ({} > {} bytes)",
4506 input.new_text.len(),
4507 WRITE_TOOL_MAX_BYTES
4508 )));
4509 }
4510
4511 let absolute_path = resolve_read_path(&input.path, &self.cwd);
4512 let absolute_path = enforce_cwd_scope(&absolute_path, &self.cwd, "edit")?;
4513
4514 let meta = asupersync::fs::metadata(&absolute_path)
4515 .await
4516 .map_err(|err| {
4517 let message = match err.kind() {
4518 std::io::ErrorKind::NotFound => format!("File not found: {}", input.path),
4519 std::io::ErrorKind::PermissionDenied => {
4520 format!("Permission denied: {}", input.path)
4521 }
4522 _ => format!("Failed to access file {}: {err}", input.path),
4523 };
4524 Error::tool("edit", message)
4525 })?;
4526
4527 if !meta.is_file() {
4528 return Err(Error::tool(
4529 "edit",
4530 format!("Path {} is not a regular file", absolute_path.display()),
4531 ));
4532 }
4533 if meta.len() > READ_TOOL_MAX_BYTES {
4534 return Err(Error::tool(
4535 "edit",
4536 format!(
4537 "File is too large ({} bytes). Max allowed for editing is {} bytes.",
4538 meta.len(),
4539 READ_TOOL_MAX_BYTES
4540 ),
4541 ));
4542 }
4543
4544 if let Err(err) = asupersync::fs::OpenOptions::new()
4545 .read(true)
4546 .write(true)
4547 .open(&absolute_path)
4548 .await
4549 {
4550 let message = match err.kind() {
4551 std::io::ErrorKind::NotFound => format!("File not found: {}", input.path),
4552 std::io::ErrorKind::PermissionDenied => {
4553 format!("Permission denied: {}", input.path)
4554 }
4555 _ => format!("Failed to open file for editing: {err}"),
4556 };
4557 return Err(Error::tool("edit", message));
4558 }
4559
4560 let file = asupersync::fs::File::open(&absolute_path)
4562 .await
4563 .map_err(|e| Error::tool("edit", format!("Failed to open file: {e}")))?;
4564 let mut raw = Vec::new();
4565 let mut limiter = file.take(READ_TOOL_MAX_BYTES.saturating_add(1));
4566 limiter
4567 .read_to_end(&mut raw)
4568 .await
4569 .map_err(|e| Error::tool("edit", format!("Failed to read file: {e}")))?;
4570
4571 if raw.len() > usize::try_from(READ_TOOL_MAX_BYTES).unwrap_or(usize::MAX) {
4572 return Err(Error::tool(
4573 "edit",
4574 format!("File is too large (> {READ_TOOL_MAX_BYTES} bytes)."),
4575 ));
4576 }
4577
4578 let raw_content = String::from_utf8(raw).map_err(|_| {
4579 Error::tool(
4580 "edit",
4581 "File contains invalid UTF-8 characters and cannot be safely edited as text."
4582 .to_string(),
4583 )
4584 })?;
4585
4586 let (content_no_bom, had_bom) = strip_bom(&raw_content);
4588
4589 let original_ending = detect_line_ending(content_no_bom);
4590 let normalized_content = normalize_to_lf(content_no_bom);
4591 let content_for_matching =
4592 if content_no_bom.contains('\r') && !content_no_bom.contains('\n') {
4593 std::borrow::Cow::Owned(content_no_bom.replace('\r', "\n"))
4594 } else {
4595 std::borrow::Cow::Borrowed(content_no_bom)
4596 };
4597 let normalized_old_text = normalize_to_lf(&input.old_text);
4598
4599 if normalized_old_text.is_empty() {
4600 return Err(Error::tool(
4601 "edit",
4602 "The old text cannot be empty. To prepend text, include the first line's content in oldText and newText.".to_string(),
4603 ));
4604 }
4605 if build_normalized_content(&normalized_old_text).is_empty() {
4606 return Err(Error::tool(
4607 "edit",
4608 "The old text must include at least one non-whitespace character.".to_string(),
4609 ));
4610 }
4611
4612 let mut variants = Vec::with_capacity(3);
4619 variants.push(normalized_old_text.clone());
4620
4621 let nfc = normalized_old_text.nfc().collect::<String>();
4622 if nfc != normalized_old_text {
4623 variants.push(nfc);
4624 }
4625
4626 let nfd = normalized_old_text.nfd().collect::<String>();
4627 if nfd != normalized_old_text {
4628 variants.push(nfd);
4629 }
4630
4631 let precomputed_content = build_normalized_content(content_for_matching.as_ref());
4634
4635 let mut best_match: Option<(FuzzyMatchResult, String, String)> = None;
4636
4637 for variant in variants {
4638 let precomputed_variant = build_normalized_content(&variant);
4639 let match_result = fuzzy_find_text_with_normalized(
4640 content_for_matching.as_ref(),
4641 &variant,
4642 Some(precomputed_content.as_str()),
4643 Some(precomputed_variant.as_str()),
4644 );
4645
4646 if match_result.found {
4647 best_match = Some((match_result, precomputed_variant, variant));
4648 break;
4649 }
4650 }
4651
4652 let Some((match_result, normalized_old_text, matched_variant)) = best_match else {
4653 return Err(Error::tool(
4654 "edit",
4655 format!(
4656 "Could not find the exact text in {}. The old text must match exactly including all whitespace and newlines.",
4657 input.path
4658 ),
4659 ));
4660 };
4661
4662 let occurrences = if match_result.exact_match {
4665 count_overlapping_occurrences(content_for_matching.as_ref(), &matched_variant)
4666 } else {
4667 count_overlapping_occurrences(&precomputed_content, &normalized_old_text)
4668 };
4669
4670 if occurrences > 1 {
4671 return Err(Error::tool(
4672 "edit",
4673 format!(
4674 "Found {occurrences} occurrences of the text in {}. The text must be unique. Please provide more context to make it unique.",
4675 input.path
4676 ),
4677 ));
4678 }
4679
4680 let idx = match_result.index;
4683 let match_len = match_result.match_length;
4684
4685 let adapted_new_text =
4689 restore_line_endings(&normalize_to_lf(&input.new_text), original_ending);
4690
4691 let new_len = content_no_bom.len() - match_len + adapted_new_text.len();
4692 let mut new_content = String::with_capacity(new_len);
4693 new_content.push_str(&content_no_bom[..idx]);
4694 new_content.push_str(&adapted_new_text);
4695 new_content.push_str(&content_no_bom[idx + match_len..]);
4696
4697 if content_no_bom.eq(&new_content) {
4698 return Err(Error::tool(
4699 "edit",
4700 format!(
4701 "No changes made to {}. The replacement produced identical content. This might indicate an issue with special characters or the text not existing as expected.",
4702 input.path
4703 ),
4704 ));
4705 }
4706
4707 let new_content_for_diff = normalize_to_lf(&new_content);
4708
4709 let mut final_content = new_content;
4711 if had_bom {
4712 final_content = format!("\u{FEFF}{final_content}");
4713 }
4714
4715 let absolute_path_clone = absolute_path.clone();
4717 let final_content_bytes = final_content.into_bytes();
4718 asupersync::runtime::spawn_blocking_io(move || {
4719 let original_perms = std::fs::metadata(&absolute_path_clone)
4721 .ok()
4722 .map(|m| m.permissions());
4723 let parent = absolute_path_clone
4724 .parent()
4725 .unwrap_or_else(|| Path::new("."));
4726 let mut temp_file = tempfile::NamedTempFile::new_in(parent)?;
4727
4728 temp_file.as_file_mut().write_all(&final_content_bytes)?;
4729 tolerate_fsync_refusal(
4730 temp_file.as_file_mut().sync_all(),
4731 "temp file",
4732 &absolute_path_clone,
4733 )?;
4734
4735 if let Some(perms) = original_perms {
4737 let _ = temp_file.as_file().set_permissions(perms);
4738 } else {
4739 #[cfg(unix)]
4741 {
4742 use std::os::unix::fs::PermissionsExt;
4743 let _ = temp_file
4744 .as_file()
4745 .set_permissions(std::fs::Permissions::from_mode(0o644));
4746 }
4747 }
4748
4749 temp_file
4750 .persist(&absolute_path_clone)
4751 .map_err(|e| e.error)?;
4752 sync_parent_dir(&absolute_path_clone)?;
4753 Ok(())
4754 })
4755 .await
4756 .map_err(|e| Error::tool("edit", format!("Failed to write file: {e}")))?;
4757
4758 let (diff, first_changed_line) =
4759 generate_diff_string(&normalized_content, &new_content_for_diff);
4760 let mut details = serde_json::Map::new();
4761 details.insert("diff".to_string(), serde_json::Value::String(diff));
4762 if let Some(line) = first_changed_line {
4763 details.insert(
4764 "firstChangedLine".to_string(),
4765 serde_json::Value::Number(serde_json::Number::from(line)),
4766 );
4767 }
4768
4769 Ok(ToolOutput {
4770 content: vec![ContentBlock::Text(TextContent::new(format!(
4771 "Successfully replaced text in {}.",
4772 input.path
4773 )))],
4774 details: Some(serde_json::Value::Object(details)),
4775 is_error: false,
4776 })
4777 }
4778}
4779
4780#[derive(Debug, Deserialize)]
4786#[serde(rename_all = "camelCase")]
4787struct WriteInput {
4788 path: String,
4789 content: String,
4790}
4791
4792pub struct WriteTool {
4793 cwd: PathBuf,
4794}
4795
4796impl WriteTool {
4797 pub fn new(cwd: &Path) -> Self {
4798 Self {
4799 cwd: cwd.to_path_buf(),
4800 }
4801 }
4802}
4803
4804#[async_trait]
4805#[allow(clippy::unnecessary_literal_bound)]
4806impl Tool for WriteTool {
4807 fn name(&self) -> &str {
4808 "write"
4809 }
4810 fn label(&self) -> &str {
4811 "write"
4812 }
4813 fn description(&self) -> &str {
4814 "Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories."
4815 }
4816
4817 fn parameters(&self) -> serde_json::Value {
4818 serde_json::json!({
4819 "type": "object",
4820 "properties": {
4821 "path": {
4822 "type": "string",
4823 "description": "Path to the file to write (relative or absolute)"
4824 },
4825 "content": {
4826 "type": "string",
4827 "description": "Content to write to the file"
4828 }
4829 },
4830 "required": ["path", "content"]
4831 })
4832 }
4833
4834 #[allow(clippy::too_many_lines)]
4835 async fn execute(
4836 &self,
4837 _tool_call_id: &str,
4838 input: serde_json::Value,
4839 _on_update: Option<Box<dyn Fn(ToolUpdate) + Send + Sync>>,
4840 ) -> Result<ToolOutput> {
4841 let input: WriteInput =
4842 serde_json::from_value(input).map_err(|e| Error::validation(e.to_string()))?;
4843
4844 if input.content.len() > WRITE_TOOL_MAX_BYTES {
4845 return Err(Error::validation(format!(
4846 "Content size exceeds maximum allowed ({} > {} bytes)",
4847 input.content.len(),
4848 WRITE_TOOL_MAX_BYTES
4849 )));
4850 }
4851
4852 let path = resolve_path(&input.path, &self.cwd);
4853 let path = enforce_cwd_scope(&path, &self.cwd, "write")?;
4854
4855 if let Ok(meta) = asupersync::fs::metadata(&path).await {
4856 if !meta.is_file() {
4857 return Err(Error::tool(
4858 "write",
4859 format!("Path {} is not a regular file", path.display()),
4860 ));
4861 }
4862 if let Err(err) = asupersync::fs::OpenOptions::new()
4863 .write(true)
4864 .open(&path)
4865 .await
4866 {
4867 let message = match err.kind() {
4868 std::io::ErrorKind::PermissionDenied => {
4869 format!("Permission denied: {}", input.path)
4870 }
4871 _ => format!("Failed to open file for writing: {err}"),
4872 };
4873 return Err(Error::tool("write", message));
4874 }
4875 }
4876
4877 if let Some(parent) = path.parent() {
4879 asupersync::fs::create_dir_all(parent)
4880 .await
4881 .map_err(|e| Error::tool("write", format!("Failed to create directories: {e}")))?;
4882 }
4883
4884 let bytes_written = input.content.encode_utf16().count();
4886
4887 let path_clone = path.clone();
4889 let content_bytes = input.content.into_bytes();
4890 asupersync::runtime::spawn_blocking_io(move || {
4891 let original_perms = std::fs::metadata(&path_clone).ok().map(|m| m.permissions());
4893 let parent = path_clone.parent().unwrap_or_else(|| Path::new("."));
4894 let mut temp_file = tempfile::NamedTempFile::new_in(parent)?;
4895
4896 temp_file.as_file_mut().write_all(&content_bytes)?;
4897 tolerate_fsync_refusal(temp_file.as_file_mut().sync_all(), "temp file", &path_clone)?;
4898
4899 if let Some(perms) = original_perms {
4901 let _ = temp_file.as_file().set_permissions(perms);
4902 } else {
4903 #[cfg(unix)]
4905 {
4906 use std::os::unix::fs::PermissionsExt;
4907 let _ = temp_file
4908 .as_file()
4909 .set_permissions(std::fs::Permissions::from_mode(0o644));
4910 }
4911 }
4912
4913 temp_file.persist(&path_clone).map_err(|e| e.error)?;
4915 sync_parent_dir(&path_clone)?;
4916 Ok(())
4917 })
4918 .await
4919 .map_err(|e| Error::tool("write", format!("Failed to write file: {e}")))?;
4920
4921 Ok(ToolOutput {
4922 content: vec![ContentBlock::Text(TextContent::new(format!(
4923 "Successfully wrote {} bytes to {}",
4924 bytes_written, input.path
4925 )))],
4926 details: None,
4927 is_error: false,
4928 })
4929 }
4930}
4931
4932#[derive(Debug, Deserialize)]
4938#[serde(rename_all = "camelCase")]
4939struct GrepInput {
4940 pattern: String,
4941 path: Option<String>,
4942 glob: Option<String>,
4943 ignore_case: Option<bool>,
4944 literal: Option<bool>,
4945 context: Option<usize>,
4946 limit: Option<usize>,
4947 #[serde(default)]
4948 hashline: bool,
4949}
4950
4951pub struct GrepTool {
4952 cwd: PathBuf,
4953 artifact_root: Option<PathBuf>,
4954}
4955
4956impl GrepTool {
4957 pub fn new(cwd: &Path) -> Self {
4958 Self {
4959 cwd: cwd.to_path_buf(),
4960 artifact_root: None,
4961 }
4962 }
4963
4964 #[cfg(test)]
4965 fn with_artifact_root(cwd: &Path, artifact_root: &Path) -> Self {
4966 Self {
4967 cwd: cwd.to_path_buf(),
4968 artifact_root: Some(artifact_root.to_path_buf()),
4969 }
4970 }
4971}
4972
4973#[derive(Debug, Clone, PartialEq, Eq)]
4975struct TruncateLineResult {
4976 text: String,
4977 was_truncated: bool,
4978}
4979
4980fn truncate_line(line: &str, max_chars: usize) -> TruncateLineResult {
4984 let mut chars = line.chars();
4985 let prefix: String = chars.by_ref().take(max_chars).collect();
4986 if chars.next().is_none() {
4987 return TruncateLineResult {
4988 text: line.to_string(),
4989 was_truncated: false,
4990 };
4991 }
4992
4993 TruncateLineResult {
4994 text: format!("{prefix}... [truncated]"),
4995 was_truncated: true,
4996 }
4997}
4998
4999fn process_rg_json_match_line(
5000 line_res: std::io::Result<String>,
5001 matches: &mut Vec<(PathBuf, usize)>,
5002 match_count: &mut usize,
5003 match_limit_reached: &mut bool,
5004 scan_limit: usize,
5005) {
5006 if *match_limit_reached {
5007 return;
5008 }
5009
5010 let line = match line_res {
5011 Ok(l) => l,
5012 Err(e) => {
5013 tracing::debug!("Skipping ripgrep output line due to read error: {e}");
5014 return;
5015 }
5016 };
5017 if line.trim().is_empty() {
5018 return;
5019 }
5020
5021 let Ok(event) = serde_json::from_str::<serde_json::Value>(&line) else {
5022 return;
5023 };
5024
5025 if event.get("type").and_then(serde_json::Value::as_str) != Some("match") {
5026 return;
5027 }
5028
5029 let file_path = event
5030 .pointer("/data/path/text")
5031 .and_then(serde_json::Value::as_str)
5032 .map(PathBuf::from);
5033 let line_number = event
5034 .pointer("/data/line_number")
5035 .and_then(serde_json::Value::as_u64)
5036 .and_then(|n| usize::try_from(n).ok());
5037
5038 if let (Some(fp), Some(ln)) = (file_path, line_number) {
5039 matches.push((fp, ln));
5040 *match_count += 1;
5041 if *match_count >= scan_limit {
5042 *match_limit_reached = true;
5043 }
5044 }
5045}
5046
5047fn drain_rg_stdout(
5048 stdout_rx: &std::sync::mpsc::Receiver<std::io::Result<String>>,
5049 matches: &mut Vec<(PathBuf, usize)>,
5050 match_count: &mut usize,
5051 match_limit_reached: &mut bool,
5052 scan_limit: usize,
5053) {
5054 while let Ok(line_res) = stdout_rx.try_recv() {
5055 process_rg_json_match_line(
5056 line_res,
5057 matches,
5058 match_count,
5059 match_limit_reached,
5060 scan_limit,
5061 );
5062 if *match_limit_reached {
5063 break;
5064 }
5065 }
5066}
5067
5068fn drain_rg_stderr(
5069 stderr_rx: &std::sync::mpsc::Receiver<std::result::Result<Vec<u8>, String>>,
5070 stderr_bytes: &mut Vec<u8>,
5071) -> Result<()> {
5072 while let Ok(chunk_result) = stderr_rx.try_recv() {
5073 let chunk = chunk_result
5074 .map_err(|err| Error::tool("grep", format!("Failed to read stderr: {err}")))?;
5075 stderr_bytes.extend_from_slice(&chunk);
5076 }
5077 Ok(())
5078}
5079
5080#[async_trait]
5081#[allow(clippy::unnecessary_literal_bound)]
5082impl Tool for GrepTool {
5083 fn name(&self) -> &str {
5084 "grep"
5085 }
5086 fn label(&self) -> &str {
5087 "grep"
5088 }
5089 fn description(&self) -> &str {
5090 "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."
5091 }
5092
5093 fn parameters(&self) -> serde_json::Value {
5094 serde_json::json!({
5095 "type": "object",
5096 "properties": {
5097 "pattern": {
5098 "type": "string",
5099 "description": "Search pattern (regex or literal string)"
5100 },
5101 "path": {
5102 "type": "string",
5103 "description": "Directory or file to search (default: current directory)"
5104 },
5105 "glob": {
5106 "type": "string",
5107 "description": "Filter files by glob pattern, e.g. '*.ts' or '**/*.spec.ts'"
5108 },
5109 "ignoreCase": {
5110 "type": "boolean",
5111 "description": "Case-insensitive search (default: false)"
5112 },
5113 "literal": {
5114 "type": "boolean",
5115 "description": "Treat pattern as literal string instead of regex (default: false)"
5116 },
5117 "context": {
5118 "type": "integer",
5119 "description": "Number of lines to show before and after each match (default: 0)"
5120 },
5121 "limit": {
5122 "type": "integer",
5123 "description": "Maximum number of matches to return (default: 100)"
5124 },
5125 "hashline": {
5126 "type": "boolean",
5127 "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."
5128 }
5129 },
5130 "required": ["pattern"]
5131 })
5132 }
5133
5134 fn effects(&self) -> ToolEffects {
5135 ToolEffects::read()
5136 }
5137
5138 #[allow(clippy::too_many_lines)]
5139 async fn execute(
5140 &self,
5141 tool_call_id: &str,
5142 input: serde_json::Value,
5143 _on_update: Option<Box<dyn Fn(ToolUpdate) + Send + Sync>>,
5144 ) -> Result<ToolOutput> {
5145 let input_value = input.clone();
5146 let input: GrepInput =
5147 serde_json::from_value(input).map_err(|e| Error::validation(e.to_string()))?;
5148
5149 if matches!(input.limit, Some(0)) {
5150 return Err(Error::validation(
5151 "`limit` must be greater than 0".to_string(),
5152 ));
5153 }
5154
5155 if !rg_available() {
5156 return Err(Error::tool(
5157 "grep",
5158 "ripgrep (rg) is not available (please install ripgrep)".to_string(),
5159 ));
5160 }
5161
5162 let search_dir = input.path.as_deref().unwrap_or(".");
5163 let search_path = resolve_read_path(search_dir, &self.cwd);
5164 let search_path = enforce_cwd_scope(&search_path, &self.cwd, "grep")?;
5165
5166 let is_directory = asupersync::fs::metadata(&search_path)
5167 .await
5168 .map_err(|e| {
5169 Error::tool(
5170 "grep",
5171 format!("Cannot access path {}: {e}", search_path.display()),
5172 )
5173 })?
5174 .is_dir();
5175
5176 let context_value = input.context.unwrap_or(0);
5177 let effective_limit = input.limit.unwrap_or(DEFAULT_GREP_LIMIT).max(1);
5178 let scan_limit = effective_limit.saturating_add(1);
5180 let cache_key = tool_cache_key("grep", &self.cwd, &input_value);
5181 let cache_mode = if is_directory {
5182 ToolCacheFingerprintMode::DirectoryRecursive
5183 } else {
5184 ToolCacheFingerprintMode::FileContent
5185 };
5186 let cache_deps = cache_dependency_for_path(&search_path, cache_mode);
5187 if let Some(output) = cached_tool_output(&cache_key, cache_deps.as_deref()) {
5188 return Ok(output);
5189 }
5190
5191 let mut args: Vec<String> = vec![
5192 "--json".to_string(),
5193 "--line-number".to_string(),
5194 "--color=never".to_string(),
5195 "--hidden".to_string(),
5196 "--max-columns=10000".to_string(),
5198 ];
5199
5200 if input.ignore_case.unwrap_or(false) {
5201 args.push("--ignore-case".to_string());
5202 }
5203 if input.literal.unwrap_or(false) {
5204 args.push("--fixed-strings".to_string());
5205 }
5206 if let Some(glob) = &input.glob {
5207 args.push("--glob".to_string());
5208 args.push(glob.clone());
5209 }
5210
5211 let ignore_root = if is_directory {
5214 search_path.clone()
5215 } else {
5216 search_path
5217 .parent()
5218 .unwrap_or_else(|| Path::new("."))
5219 .to_path_buf()
5220 };
5221 let workspace_gitignore = self.cwd.join(".gitignore");
5227 if workspace_gitignore.exists() {
5228 args.push("--ignore-file".to_string());
5229 args.push(workspace_gitignore.display().to_string());
5230 }
5231 let root_gitignore = ignore_root.join(".gitignore");
5232 if root_gitignore != workspace_gitignore && root_gitignore.exists() {
5233 args.push("--ignore-file".to_string());
5234 args.push(root_gitignore.display().to_string());
5235 }
5236
5237 args.push("--".to_string());
5238 args.push(input.pattern.clone());
5239 args.push(search_path.display().to_string());
5240
5241 let rg_cmd = find_rg_binary().ok_or_else(|| {
5242 Error::tool(
5243 "grep",
5244 "rg is not available (please install ripgrep or rg)".to_string(),
5245 )
5246 })?;
5247
5248 let mut child = command_with_default_sigpipe(rg_cmd)
5249 .map_err(|e| Error::tool("grep", format!("Failed to prepare ripgrep: {e}")))?
5250 .args(args)
5251 .stdout(Stdio::piped())
5252 .stderr(Stdio::piped())
5253 .spawn()
5254 .map_err(|e| Error::tool("grep", format!("Failed to run ripgrep: {e}")))?;
5255
5256 let stdout = child
5257 .stdout
5258 .take()
5259 .ok_or_else(|| Error::tool("grep", "Missing stdout".to_string()))?;
5260 let stderr = child
5261 .stderr
5262 .take()
5263 .ok_or_else(|| Error::tool("grep", "Missing stderr".to_string()))?;
5264
5265 let mut guard = ProcessGuard::new(child, ProcessCleanupMode::ChildOnly);
5266
5267 let (stdout_tx, stdout_rx) = std::sync::mpsc::sync_channel(1024);
5268 let (stderr_tx, stderr_rx) =
5269 std::sync::mpsc::sync_channel::<std::result::Result<Vec<u8>, String>>(1024);
5270
5271 let stdout_thread = std::thread::spawn(move || {
5272 let reader = std::io::BufReader::new(stdout);
5273 for line in reader.lines() {
5274 if stdout_tx.send(line).is_err() {
5275 break;
5276 }
5277 }
5278 });
5279
5280 let stderr_thread = std::thread::spawn(move || {
5281 let reader = std::io::BufReader::new(stderr);
5282 let _ = stderr_tx.send(read_to_end_capped_and_drain(reader, READ_TOOL_MAX_BYTES));
5283 });
5284
5285 let mut matches: Vec<(PathBuf, usize)> = Vec::new();
5286 let mut match_count: usize = 0;
5287 let mut match_scan_limit_reached = false;
5288 let mut stderr_bytes = Vec::new();
5289
5290 let tick = Duration::from_millis(10);
5291 let mut cx_cancelled = false;
5292
5293 let exit_status = loop {
5294 let agent_cx = AgentCx::for_current_or_request();
5295 let cx = agent_cx.cx();
5296 if cx.checkpoint().is_err() {
5297 cx_cancelled = true;
5298 break None;
5299 }
5300
5301 drain_rg_stdout(
5302 &stdout_rx,
5303 &mut matches,
5304 &mut match_count,
5305 &mut match_scan_limit_reached,
5306 scan_limit,
5307 );
5308 drain_rg_stderr(&stderr_rx, &mut stderr_bytes)?;
5309
5310 if match_scan_limit_reached {
5311 break None;
5312 }
5313
5314 match guard.try_wait_child() {
5315 Ok(Some(status)) => break Some(status),
5316 Ok(None) => {
5317 let now = cx.timer_driver().map_or_else(wall_now, |timer| timer.now());
5318 sleep(now, tick).await;
5319 }
5320 Err(e) => return Err(Error::tool("grep", e.to_string())),
5321 }
5322 };
5323
5324 drain_rg_stdout(
5325 &stdout_rx,
5326 &mut matches,
5327 &mut match_count,
5328 &mut match_scan_limit_reached,
5329 scan_limit,
5330 );
5331
5332 let code = if match_scan_limit_reached || cx_cancelled {
5333 let _ = guard.kill();
5337 while stdout_rx.try_recv().is_ok() {}
5339 while stderr_rx.try_recv().is_ok() {}
5340 0
5341 } else {
5342 let status = exit_status.expect("rg exit status");
5343 status.code().unwrap_or(0)
5344 };
5345
5346 while !stdout_thread.is_finished() || !stderr_thread.is_finished() {
5350 if match_scan_limit_reached || cx_cancelled {
5351 while stdout_rx.try_recv().is_ok() {}
5352 } else {
5353 drain_rg_stdout(
5354 &stdout_rx,
5355 &mut matches,
5356 &mut match_count,
5357 &mut match_scan_limit_reached,
5358 scan_limit,
5359 );
5360 }
5361 drain_rg_stderr(&stderr_rx, &mut stderr_bytes)?;
5362 sleep(wall_now(), Duration::from_millis(1)).await;
5363 }
5364
5365 if cx_cancelled {
5366 return Err(Error::tool("grep", "Command cancelled"));
5367 }
5368
5369 stdout_thread
5374 .join()
5375 .map_err(|_| Error::tool("grep", "ripgrep stdout reader thread panicked"))?;
5376 stderr_thread
5377 .join()
5378 .map_err(|_| Error::tool("grep", "ripgrep stderr reader thread panicked"))?;
5379
5380 if match_scan_limit_reached {
5382 while stdout_rx.try_recv().is_ok() {}
5383 } else {
5384 drain_rg_stdout(
5385 &stdout_rx,
5386 &mut matches,
5387 &mut match_count,
5388 &mut match_scan_limit_reached,
5389 scan_limit,
5390 );
5391 }
5392 drain_rg_stderr(&stderr_rx, &mut stderr_bytes)?;
5393
5394 let mut stderr_text = String::from_utf8_lossy(&stderr_bytes).trim().to_string();
5395 if stderr_bytes.len() as u64 > READ_TOOL_MAX_BYTES {
5396 stderr_text.push_str("\n... [stderr truncated] ...");
5397 }
5398 if !match_scan_limit_reached && code != 0 && code != 1 {
5399 let msg = if stderr_text.is_empty() {
5400 format!("ripgrep exited with code {code}")
5401 } else {
5402 stderr_text
5403 };
5404 return Err(Error::tool("grep", msg));
5405 }
5406
5407 let match_limit_reached = match_count > effective_limit;
5408 if match_limit_reached {
5409 matches.truncate(effective_limit);
5410 match_count = effective_limit;
5411 }
5412
5413 if match_count == 0 {
5414 let output = ToolOutput {
5415 content: vec![ContentBlock::Text(TextContent::new("No matches found"))],
5416 details: None,
5417 is_error: false,
5418 };
5419 cache_tool_output(
5420 cache_key,
5421 stable_cache_dependency_for_path(&search_path, cache_mode, cache_deps.as_deref()),
5422 &output,
5423 );
5424 return Ok(output);
5425 }
5426
5427 let mut file_cache: HashMap<PathBuf, Vec<String>> = HashMap::new();
5428 let mut output_builder = HeadTruncatingLineWriter::new(DEFAULT_MAX_BYTES);
5429 let mut artifact_source = String::new();
5430 let mut lines_truncated = false;
5431
5432 let mut file_order: Vec<PathBuf> = Vec::new();
5434 let mut matches_by_file: HashMap<PathBuf, Vec<usize>> = HashMap::new();
5435 for (file_path, line_number) in &matches {
5436 if !matches_by_file.contains_key(file_path) {
5437 file_order.push(file_path.clone());
5438 }
5439 matches_by_file
5440 .entry(file_path.clone())
5441 .or_default()
5442 .push(*line_number);
5443 }
5444
5445 for file_path in file_order {
5446 let Some(mut match_lines) = matches_by_file.remove(&file_path) else {
5447 continue;
5448 };
5449 let relative_path = format_grep_path(&file_path, &self.cwd);
5450 let lines = get_file_lines_async(&file_path, &mut file_cache).await;
5451
5452 if lines.is_empty() {
5453 if let Some(first_match) = match_lines.first() {
5454 let line = format!(
5455 "{relative_path}:{first_match}: (unable to read file or too large)"
5456 );
5457 output_builder.push_line(&line);
5458 append_artifact_source_line(&mut artifact_source, &line);
5459 }
5460 continue;
5461 }
5462
5463 match_lines.sort_unstable();
5464 match_lines.dedup();
5465
5466 let mut blocks: Vec<(usize, usize)> = Vec::new();
5467 for &line_number in &match_lines {
5468 let start = if context_value > 0 {
5469 line_number.saturating_sub(context_value).max(1)
5470 } else {
5471 line_number
5472 };
5473 let end = if context_value > 0 {
5474 line_number.saturating_add(context_value).min(lines.len())
5475 } else {
5476 line_number
5477 };
5478
5479 if let Some(last_block) = blocks.last_mut() {
5480 if start <= last_block.1.saturating_add(1) {
5481 last_block.1 = last_block.1.max(end);
5482 continue;
5483 }
5484 }
5485 blocks.push((start, end));
5486 }
5487
5488 for (i, (start, end)) in blocks.into_iter().enumerate() {
5489 if i > 0 {
5490 output_builder.push_line("--");
5491 append_artifact_source_line(&mut artifact_source, "--");
5492 }
5493 for current in start..=end {
5494 let line_text = lines.get(current - 1).map_or("", String::as_str);
5495 let sanitized = line_text.replace('\r', "");
5496 let truncated = truncate_line(&sanitized, GREP_MAX_LINE_LENGTH);
5497 if truncated.was_truncated {
5498 lines_truncated = true;
5499 }
5500
5501 if input.hashline {
5502 let line_idx = current - 1; let tag = format_hashline_tag(line_idx, &sanitized);
5504 let line = if match_lines.binary_search(¤t).is_ok() {
5505 format!("{relative_path}:{tag}: {}", truncated.text)
5506 } else {
5507 format!("{relative_path}-{tag}- {}", truncated.text)
5508 };
5509 output_builder.push_line(&line);
5510 append_artifact_source_line(&mut artifact_source, &line);
5511 } else if match_lines.binary_search(¤t).is_ok() {
5512 let line = format!("{relative_path}:{current}: {}", truncated.text);
5513 output_builder.push_line(&line);
5514 append_artifact_source_line(&mut artifact_source, &line);
5515 } else {
5516 let line = format!("{relative_path}-{current}- {}", truncated.text);
5517 output_builder.push_line(&line);
5518 append_artifact_source_line(&mut artifact_source, &line);
5519 }
5520 }
5521 }
5522 }
5523
5524 let mut truncation = output_builder.finish();
5526
5527 let mut output = std::mem::take(&mut truncation.content);
5528 let mut notices: Vec<String> = Vec::new();
5529 let mut details_map = serde_json::Map::new();
5530
5531 if match_limit_reached {
5532 notices.push(format!(
5533 "{effective_limit} matches limit reached. Use limit={} for more, or refine pattern",
5534 effective_limit * 2
5535 ));
5536 details_map.insert(
5537 "matchLimitReached".to_string(),
5538 serde_json::Value::Number(serde_json::Number::from(effective_limit)),
5539 );
5540 }
5541
5542 if truncation.truncated {
5543 notices.push(format!("{} limit reached", format_size(DEFAULT_MAX_BYTES)));
5544 details_map.insert("truncation".to_string(), serde_json::to_value(truncation)?);
5545 }
5546
5547 if lines_truncated {
5548 notices.push(format!(
5549 "Some lines truncated to {GREP_MAX_LINE_LENGTH} chars. Use read tool to see full lines"
5550 ));
5551 details_map.insert("linesTruncated".to_string(), serde_json::Value::Bool(true));
5552 }
5553
5554 if !notices.is_empty() {
5555 let _ = write!(output, "\n\n[{}]", notices.join(". "));
5556 }
5557
5558 let mut details = if details_map.is_empty() {
5559 None
5560 } else {
5561 Some(serde_json::Value::Object(details_map))
5562 };
5563
5564 attach_text_artifact_if_needed_with_root(
5565 self.artifact_root.as_deref(),
5566 &mut output,
5567 &mut details,
5568 "grep",
5569 tool_call_id,
5570 "searchResults",
5571 &artifact_source,
5572 );
5573
5574 let output = ToolOutput {
5575 content: vec![ContentBlock::Text(TextContent::new(output))],
5576 details,
5577 is_error: false,
5578 };
5579 cache_tool_output(
5580 cache_key,
5581 stable_cache_dependency_for_path(&search_path, cache_mode, cache_deps.as_deref()),
5582 &output,
5583 );
5584 Ok(output)
5585 }
5586}
5587
5588#[derive(Debug, Deserialize)]
5594#[serde(rename_all = "camelCase")]
5595struct FindInput {
5596 pattern: String,
5597 path: Option<String>,
5598 limit: Option<usize>,
5599}
5600
5601#[derive(Debug)]
5602struct FindEntry {
5603 rel: String,
5604 modified: Option<SystemTime>,
5605}
5606
5607pub struct FindTool {
5608 cwd: PathBuf,
5609 artifact_root: Option<PathBuf>,
5610}
5611
5612impl FindTool {
5613 pub fn new(cwd: &Path) -> Self {
5614 Self {
5615 cwd: cwd.to_path_buf(),
5616 artifact_root: None,
5617 }
5618 }
5619}
5620
5621#[async_trait]
5622#[allow(clippy::unnecessary_literal_bound)]
5623impl Tool for FindTool {
5624 fn name(&self) -> &str {
5625 "find"
5626 }
5627 fn label(&self) -> &str {
5628 "find"
5629 }
5630 fn description(&self) -> &str {
5631 "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)."
5632 }
5633
5634 fn parameters(&self) -> serde_json::Value {
5635 serde_json::json!({
5636 "type": "object",
5637 "properties": {
5638 "pattern": {
5639 "type": "string",
5640 "description": "Glob pattern to match files, e.g. '*.ts', '**/*.json', or 'src/**/*.spec.ts'"
5641 },
5642 "path": {
5643 "type": "string",
5644 "description": "Directory to search in (default: current directory)"
5645 },
5646 "limit": {
5647 "type": "integer",
5648 "description": "Maximum number of results (default: 1000)"
5649 }
5650 },
5651 "required": ["pattern"]
5652 })
5653 }
5654
5655 fn effects(&self) -> ToolEffects {
5656 ToolEffects::read()
5657 }
5658
5659 #[allow(clippy::too_many_lines)]
5660 async fn execute(
5661 &self,
5662 tool_call_id: &str,
5663 input: serde_json::Value,
5664 _on_update: Option<Box<dyn Fn(ToolUpdate) + Send + Sync>>,
5665 ) -> Result<ToolOutput> {
5666 let input_value = input.clone();
5667 let input: FindInput =
5668 serde_json::from_value(input).map_err(|e| Error::validation(e.to_string()))?;
5669
5670 if matches!(input.limit, Some(0)) {
5671 return Err(Error::validation(
5672 "`limit` must be greater than 0".to_string(),
5673 ));
5674 }
5675
5676 let search_dir = input.path.as_deref().unwrap_or(".");
5677 let search_path = resolve_read_path(search_dir, &self.cwd);
5678 let search_path = enforce_cwd_scope(&search_path, &self.cwd, "find")?;
5679 let search_path = strip_unc_prefix(search_path);
5680 let effective_limit = input.limit.unwrap_or(DEFAULT_FIND_LIMIT);
5681 let scan_limit = effective_limit.saturating_add(1);
5683
5684 if !search_path.exists() {
5685 return Err(Error::tool(
5686 "find",
5687 format!("Path not found: {}", search_path.display()),
5688 ));
5689 }
5690
5691 let cache_key = tool_cache_key("find", &self.cwd, &input_value);
5692 let cache_mode = if search_path.is_dir() {
5693 ToolCacheFingerprintMode::DirectoryRecursive
5694 } else {
5695 ToolCacheFingerprintMode::FileContent
5696 };
5697 let cache_deps = cache_dependency_for_path(&search_path, cache_mode);
5698 if let Some(output) = cached_tool_output(&cache_key, cache_deps.as_deref()) {
5699 return Ok(output);
5700 }
5701
5702 let fd_cmd = find_fd_binary().ok_or_else(|| {
5703 Error::tool(
5704 "find",
5705 "fd is not available (please install fd-find or fd)".to_string(),
5706 )
5707 })?;
5708
5709 let mut args: Vec<String> = vec![
5711 "--glob".to_string(),
5712 "--color=never".to_string(),
5713 "--hidden".to_string(),
5714 "--max-results".to_string(),
5715 scan_limit.to_string(),
5716 ];
5717
5718 let workspace_gitignore = self.cwd.join(".gitignore");
5723 if workspace_gitignore.exists() {
5724 args.push("--ignore-file".to_string());
5725 args.push(workspace_gitignore.display().to_string());
5726 }
5727 let root_gitignore = search_path.join(".gitignore");
5728 if root_gitignore != workspace_gitignore && root_gitignore.exists() {
5729 args.push("--ignore-file".to_string());
5730 args.push(root_gitignore.display().to_string());
5731 }
5732
5733 args.push("--".to_string());
5734 args.push(input.pattern.clone());
5735 args.push(search_path.display().to_string());
5736
5737 let mut child = command_with_default_sigpipe_in_dir(fd_cmd, &self.cwd)
5738 .map_err(|e| Error::tool("find", format!("Failed to prepare fd: {e}")))?
5739 .args(args)
5740 .current_dir(&self.cwd)
5741 .stdin(Stdio::null())
5742 .stdout(Stdio::piped())
5743 .stderr(Stdio::piped())
5744 .spawn()
5745 .map_err(|e| Error::tool("find", format!("Failed to run fd: {e}")))?;
5746
5747 let stdout_pipe = child
5748 .stdout
5749 .take()
5750 .ok_or_else(|| Error::tool("find", "Missing stdout"))?;
5751 let stderr_pipe = child
5752 .stderr
5753 .take()
5754 .ok_or_else(|| Error::tool("find", "Missing stderr"))?;
5755
5756 let mut guard = ProcessGuard::new(child, ProcessCleanupMode::ChildOnly);
5757
5758 let stdout_handle = std::thread::spawn(move || -> std::result::Result<Vec<u8>, String> {
5759 read_to_end_capped_and_drain(stdout_pipe, READ_TOOL_MAX_BYTES)
5760 });
5761
5762 let stderr_handle = std::thread::spawn(move || -> std::result::Result<Vec<u8>, String> {
5763 read_to_end_capped_and_drain(stderr_pipe, READ_TOOL_MAX_BYTES)
5764 });
5765
5766 let tick = Duration::from_millis(10);
5767 let start_time = std::time::Instant::now();
5768 let timeout_ms = 60_000; let mut timed_out = false;
5770 let mut cx_cancelled = false;
5771
5772 let status = loop {
5773 let agent_cx = AgentCx::for_current_or_request();
5774 let cx = agent_cx.cx();
5775 if cx.checkpoint().is_err() {
5776 cx_cancelled = true;
5777 let _ = guard.kill();
5778 break None;
5779 }
5780
5781 match guard.try_wait_child() {
5783 Ok(Some(status)) => break Some(status),
5784 Ok(None) => {
5785 if start_time.elapsed().as_millis() > timeout_ms {
5786 timed_out = true;
5787 let _ = guard.kill();
5788 break None;
5789 }
5790 let now = cx.timer_driver().map_or_else(wall_now, |timer| timer.now());
5791 sleep(now, tick).await;
5792 }
5793 Err(e) => return Err(Error::tool("find", e.to_string())),
5794 }
5795 };
5796
5797 let stdout_bytes = stdout_handle
5798 .join()
5799 .map_err(|_| Error::tool("find", "fd stdout reader thread panicked"))?
5800 .map_err(|err| Error::tool("find", format!("Failed to read fd stdout: {err}")))?;
5801 let stderr_bytes = stderr_handle
5802 .join()
5803 .map_err(|_| Error::tool("find", "fd stderr reader thread panicked"))?
5804 .map_err(|err| Error::tool("find", format!("Failed to read fd stderr: {err}")))?;
5805
5806 if cx_cancelled {
5807 return Err(Error::tool("find", "Command cancelled"));
5808 }
5809 if timed_out {
5810 return Err(Error::tool("find", "Command timed out after 60 seconds"));
5811 }
5812 let status = status.expect("fd exit status after successful completion");
5813
5814 let mut stdout = String::from_utf8_lossy(&stdout_bytes).trim().to_string();
5815 if stdout_bytes.len() as u64 > READ_TOOL_MAX_BYTES {
5816 stdout.push_str("\n... [stdout truncated] ...");
5817 }
5818 let mut stderr = String::from_utf8_lossy(&stderr_bytes).trim().to_string();
5819 if stderr_bytes.len() as u64 > READ_TOOL_MAX_BYTES {
5820 stderr.push_str("\n... [stderr truncated] ...");
5821 }
5822
5823 if !status.success() && stdout.is_empty() {
5824 if status.code() == Some(1) && stderr.is_empty() {
5825 } else {
5827 let code = status.code().unwrap_or(1);
5828 let msg = if stderr.is_empty() {
5829 format!("fd exited with code {code}")
5830 } else {
5831 stderr
5832 };
5833 return Err(Error::tool("find", msg));
5834 }
5835 }
5836
5837 if stdout.is_empty() {
5838 let output = ToolOutput {
5839 content: vec![ContentBlock::Text(TextContent::new(
5840 "No files found matching pattern",
5841 ))],
5842 details: None,
5843 is_error: false,
5844 };
5845 cache_tool_output(
5846 cache_key,
5847 stable_cache_dependency_for_path(&search_path, cache_mode, cache_deps.as_deref()),
5848 &output,
5849 );
5850 return Ok(output);
5851 }
5852
5853 let mut entries: Vec<FindEntry> = Vec::new();
5854 for raw_line in stdout.lines() {
5855 let line = raw_line.trim_end_matches('\r').trim();
5856 if line.is_empty() {
5857 continue;
5858 }
5859
5860 let clean = strip_unc_prefix(PathBuf::from(line));
5863 let line_path = clean.as_path();
5864 let mut rel = if line_path.is_absolute() {
5865 line_path.strip_prefix(&search_path).map_or_else(
5866 |_| line_path.to_string_lossy().to_string(),
5867 |stripped| stripped.to_string_lossy().to_string(),
5868 )
5869 } else {
5870 line_path.to_string_lossy().to_string()
5871 };
5872
5873 let full_path = if line_path.is_absolute() {
5874 line_path.to_path_buf()
5875 } else {
5876 search_path.join(line_path)
5877 };
5878 if full_path.is_dir() && !rel.ends_with('/') {
5879 rel.push('/');
5880 }
5881
5882 let modified = std::fs::metadata(&full_path)
5883 .and_then(|meta| meta.modified())
5884 .ok();
5885 entries.push(FindEntry { rel, modified });
5886 }
5887
5888 entries.sort_by(|a, b| {
5889 let ordering = match (&a.modified, &b.modified) {
5890 (Some(a_time), Some(b_time)) => b_time.cmp(a_time),
5891 (Some(_), None) => Ordering::Less,
5892 (None, Some(_)) => Ordering::Greater,
5893 (None, None) => Ordering::Equal,
5894 };
5895 ordering.then_with(|| {
5896 let a_lower = a.rel.to_lowercase();
5897 let b_lower = b.rel.to_lowercase();
5898 a_lower.cmp(&b_lower).then_with(|| a.rel.cmp(&b.rel))
5899 })
5900 });
5901
5902 if entries.is_empty() {
5903 let output = ToolOutput {
5904 content: vec![ContentBlock::Text(TextContent::new(
5905 "No files found matching pattern",
5906 ))],
5907 details: None,
5908 is_error: false,
5909 };
5910 cache_tool_output(
5911 cache_key,
5912 stable_cache_dependency_for_path(&search_path, cache_mode, cache_deps.as_deref()),
5913 &output,
5914 );
5915 return Ok(output);
5916 }
5917
5918 let result_limit_reached = entries.len() > effective_limit;
5919 let mut output_builder = HeadTruncatingLineWriter::new(DEFAULT_MAX_BYTES);
5920 let mut artifact_source = String::new();
5921 for entry in entries.into_iter().take(effective_limit) {
5922 output_builder.push_line(&entry.rel);
5923 append_artifact_source_line(&mut artifact_source, &entry.rel);
5924 }
5925 let mut truncation = output_builder.finish();
5926
5927 let mut result_output = std::mem::take(&mut truncation.content);
5928 let mut notices: Vec<String> = Vec::new();
5929 let mut details_map = serde_json::Map::new();
5930
5931 if !status.success() {
5932 let code = status.code().unwrap_or(1);
5933 notices.push(format!("fd exited with code {code}"));
5934 }
5935
5936 if result_limit_reached {
5937 notices.push(format!(
5938 "{effective_limit} results limit reached. Use limit={} for more, or refine pattern",
5939 effective_limit * 2
5940 ));
5941 details_map.insert(
5942 "resultLimitReached".to_string(),
5943 serde_json::Value::Number(serde_json::Number::from(effective_limit)),
5944 );
5945 }
5946
5947 if truncation.truncated {
5948 notices.push(format!("{} limit reached", format_size(DEFAULT_MAX_BYTES)));
5949 details_map.insert("truncation".to_string(), serde_json::to_value(truncation)?);
5950 }
5951
5952 if !notices.is_empty() {
5953 let _ = write!(result_output, "\n\n[{}]", notices.join(". "));
5954 }
5955
5956 let mut details = if details_map.is_empty() {
5957 None
5958 } else {
5959 Some(serde_json::Value::Object(details_map))
5960 };
5961
5962 attach_text_artifact_if_needed_with_root(
5963 self.artifact_root.as_deref(),
5964 &mut result_output,
5965 &mut details,
5966 "find",
5967 tool_call_id,
5968 "fileResults",
5969 &artifact_source,
5970 );
5971
5972 let output = ToolOutput {
5973 content: vec![ContentBlock::Text(TextContent::new(result_output))],
5974 details,
5975 is_error: false,
5976 };
5977 cache_tool_output(
5978 cache_key,
5979 stable_cache_dependency_for_path(&search_path, cache_mode, cache_deps.as_deref()),
5980 &output,
5981 );
5982 Ok(output)
5983 }
5984}
5985
5986#[derive(Debug, Deserialize)]
5992#[serde(rename_all = "camelCase")]
5993struct LsInput {
5994 path: Option<String>,
5995 limit: Option<usize>,
5996}
5997
5998pub struct LsTool {
5999 cwd: PathBuf,
6000 artifact_root: Option<PathBuf>,
6001}
6002
6003impl LsTool {
6004 pub fn new(cwd: &Path) -> Self {
6005 Self {
6006 cwd: cwd.to_path_buf(),
6007 artifact_root: None,
6008 }
6009 }
6010
6011 #[cfg(test)]
6012 fn with_artifact_root(cwd: &Path, artifact_root: &Path) -> Self {
6013 Self {
6014 cwd: cwd.to_path_buf(),
6015 artifact_root: Some(artifact_root.to_path_buf()),
6016 }
6017 }
6018}
6019
6020#[async_trait]
6021#[allow(clippy::unnecessary_literal_bound, clippy::too_many_lines)]
6022impl Tool for LsTool {
6023 fn name(&self) -> &str {
6024 "ls"
6025 }
6026 fn label(&self) -> &str {
6027 "ls"
6028 }
6029 fn description(&self) -> &str {
6030 "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)."
6031 }
6032
6033 fn parameters(&self) -> serde_json::Value {
6034 serde_json::json!({
6035 "type": "object",
6036 "properties": {
6037 "path": {
6038 "type": "string",
6039 "description": "Directory to list (default: current directory)"
6040 },
6041 "limit": {
6042 "type": "integer",
6043 "description": "Maximum number of entries to return (default: 500)"
6044 }
6045 }
6046 })
6047 }
6048
6049 fn effects(&self) -> ToolEffects {
6050 ToolEffects::read()
6051 }
6052
6053 async fn execute(
6054 &self,
6055 tool_call_id: &str,
6056 input: serde_json::Value,
6057 _on_update: Option<Box<dyn Fn(ToolUpdate) + Send + Sync>>,
6058 ) -> Result<ToolOutput> {
6059 let input_value = input.clone();
6060 let input: LsInput =
6061 serde_json::from_value(input).map_err(|e| Error::validation(e.to_string()))?;
6062
6063 if matches!(input.limit, Some(0)) {
6064 return Err(Error::validation(
6065 "`limit` must be greater than 0".to_string(),
6066 ));
6067 }
6068
6069 let dir_path = input
6070 .path
6071 .as_ref()
6072 .map_or_else(|| self.cwd.clone(), |p| resolve_read_path(p, &self.cwd));
6073 let dir_path = enforce_cwd_scope(&dir_path, &self.cwd, "list")?;
6074
6075 let effective_limit = input.limit.unwrap_or(DEFAULT_LS_LIMIT);
6076
6077 if !dir_path.exists() {
6078 return Err(Error::tool(
6079 "ls",
6080 format!("Path not found: {}", dir_path.display()),
6081 ));
6082 }
6083 if !dir_path.is_dir() {
6084 return Err(Error::tool(
6085 "ls",
6086 format!("Not a directory: {}", dir_path.display()),
6087 ));
6088 }
6089
6090 let cache_key = tool_cache_key("ls", &self.cwd, &input_value);
6091 let cache_mode = ToolCacheFingerprintMode::DirectoryImmediate;
6092 let cache_deps = cache_dependency_for_path(&dir_path, cache_mode);
6093 if let Some(output) = cached_tool_output(&cache_key, cache_deps.as_deref()) {
6094 return Ok(output);
6095 }
6096
6097 let mut entries = Vec::new();
6098 let mut read_dir = asupersync::fs::read_dir(&dir_path)
6099 .await
6100 .map_err(|e| Error::tool("ls", format!("Cannot read directory: {e}")))?;
6101
6102 let mut scan_limit_reached = false;
6103 while let Some(entry) = read_dir
6104 .next_entry()
6105 .await
6106 .map_err(|e| Error::tool("ls", format!("Cannot read directory entry: {e}")))?
6107 {
6108 if entries.len() >= LS_SCAN_HARD_LIMIT {
6109 scan_limit_reached = true;
6110 break;
6111 }
6112 let name = entry.file_name().to_string_lossy().to_string();
6113 let is_dir = match entry.file_type().await {
6116 Ok(ft) => {
6117 if ft.is_dir() {
6118 true
6119 } else if ft.is_symlink() {
6120 entry.metadata().await.is_ok_and(|meta| meta.is_dir())
6122 } else {
6123 false
6124 }
6125 }
6126 Err(_) => entry.metadata().await.is_ok_and(|meta| meta.is_dir()),
6127 };
6128 entries.push((name, is_dir));
6129 }
6130
6131 entries.sort_by_cached_key(|(a, _)| a.to_lowercase());
6133
6134 let mut output_builder = HeadTruncatingLineWriter::new(DEFAULT_MAX_BYTES);
6135 let mut artifact_source = String::new();
6136 let mut emitted_entries = 0usize;
6137 let mut entry_limit_reached = false;
6138
6139 for (entry, is_dir) in entries {
6140 if emitted_entries >= effective_limit {
6141 entry_limit_reached = true;
6142 break;
6143 }
6144 let line = if is_dir { format!("{entry}/") } else { entry };
6145 output_builder.push_line(&line);
6146 append_artifact_source_line(&mut artifact_source, &line);
6147 emitted_entries = emitted_entries.saturating_add(1);
6148 }
6149
6150 if emitted_entries == 0 {
6151 let output = ToolOutput {
6152 content: vec![ContentBlock::Text(TextContent::new("(empty directory)"))],
6153 details: None,
6154 is_error: false,
6155 };
6156 cache_tool_output(
6157 cache_key,
6158 stable_cache_dependency_for_path(&dir_path, cache_mode, cache_deps.as_deref()),
6159 &output,
6160 );
6161 return Ok(output);
6162 }
6163
6164 let mut truncation = output_builder.finish();
6166
6167 let mut output = std::mem::take(&mut truncation.content);
6168 let mut details_map = serde_json::Map::new();
6169 let mut notices: Vec<String> = Vec::new();
6170
6171 if entry_limit_reached {
6172 notices.push(format!(
6173 "{effective_limit} entries limit reached. Use limit={} for more",
6174 effective_limit * 2
6175 ));
6176 details_map.insert(
6177 "entryLimitReached".to_string(),
6178 serde_json::Value::Number(serde_json::Number::from(effective_limit)),
6179 );
6180 }
6181
6182 if scan_limit_reached {
6183 notices.push(format!(
6184 "Directory scan limited to {LS_SCAN_HARD_LIMIT} entries to prevent system overload"
6185 ));
6186 details_map.insert(
6187 "scanLimitReached".to_string(),
6188 serde_json::Value::Number(serde_json::Number::from(LS_SCAN_HARD_LIMIT)),
6189 );
6190 }
6191
6192 if truncation.truncated {
6193 notices.push(format!("{} limit reached", format_size(DEFAULT_MAX_BYTES)));
6194 details_map.insert("truncation".to_string(), serde_json::to_value(truncation)?);
6195 }
6196
6197 if !notices.is_empty() {
6198 let _ = write!(output, "\n\n[{}]", notices.join(". "));
6199 }
6200
6201 let mut details = if details_map.is_empty() {
6202 None
6203 } else {
6204 Some(serde_json::Value::Object(details_map))
6205 };
6206
6207 attach_text_artifact_if_needed_with_root(
6208 self.artifact_root.as_deref(),
6209 &mut output,
6210 &mut details,
6211 "ls",
6212 tool_call_id,
6213 "directoryEntries",
6214 &artifact_source,
6215 );
6216
6217 let output = ToolOutput {
6218 content: vec![ContentBlock::Text(TextContent::new(output))],
6219 details,
6220 is_error: false,
6221 };
6222 cache_tool_output(
6223 cache_key,
6224 stable_cache_dependency_for_path(&dir_path, cache_mode, cache_deps.as_deref()),
6225 &output,
6226 );
6227 Ok(output)
6228 }
6229}
6230
6231pub fn cleanup_temp_files() {
6241 std::thread::spawn(|| {
6243 let temp_dir = std::env::temp_dir();
6244 let Ok(entries) = std::fs::read_dir(&temp_dir) else {
6245 return;
6246 };
6247
6248 for entry in entries.flatten() {
6249 let path = entry.path();
6250 if !path.is_file() {
6251 continue;
6252 }
6253
6254 let Some(file_name) = path.file_name().and_then(|n| n.to_str()) else {
6255 continue;
6256 };
6257
6258 if (file_name.starts_with("pi-bash-") || file_name.starts_with("pi-rpc-bash-"))
6260 && std::path::Path::new(file_name)
6261 .extension()
6262 .is_some_and(|ext| ext.eq_ignore_ascii_case("log"))
6263 && let Ok(metadata) = entry.metadata()
6264 && metadata.modified().is_ok_and(|modified| {
6265 modified
6266 .elapsed()
6267 .is_ok_and(|age| age > Duration::from_secs(24 * 60 * 60))
6268 })
6269 && let Err(e) = std::fs::remove_file(&path)
6270 {
6271 tracing::debug!("Failed to remove temp file {}: {}", path.display(), e);
6273 }
6274 }
6275 });
6276}
6277
6278fn rg_available() -> bool {
6283 find_rg_binary().is_some()
6284}
6285
6286fn pump_stream<R: Read + Send + 'static>(
6287 mut reader: R,
6288 stream_name: &'static str,
6289 tx: &mpsc::SyncSender<BashPipeFrame>,
6290) {
6291 let mut buf = vec![0u8; 8192];
6292 loop {
6293 match reader.read(&mut buf) {
6294 Ok(0) => break,
6295 Ok(n) => {
6296 if tx.send(BashPipeFrame::Chunk(buf[..n].to_vec())).is_err() {
6297 break;
6298 }
6299 }
6300 Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => {}
6301 Err(err) => {
6302 let _ = tx.send(BashPipeFrame::Error(format!(
6303 "Failed to read bash {stream_name}: {err}"
6304 )));
6305 break;
6306 }
6307 }
6308 }
6309}
6310
6311async fn ingest_bash_pipe_frame(frame: BashPipeFrame, state: &mut BashOutputState) -> Result<()> {
6312 match frame {
6313 BashPipeFrame::Chunk(chunk) => ingest_bash_chunk(chunk, state).await,
6314 BashPipeFrame::Error(message) => {
6315 let error_message = bash_capture_error_message(&message, state);
6316 state.abandon_spill_file();
6317 Err(Error::tool("bash", error_message))
6318 }
6319 }
6320}
6321
6322fn bash_capture_error_message(message: &str, state: &BashOutputState) -> String {
6323 let raw = concat_chunks(&state.chunks);
6324 if raw.is_empty() {
6325 return message.to_string();
6326 }
6327
6328 let full_text = String::from_utf8_lossy(&raw).into_owned();
6329 let truncation = truncate_tail(full_text, DEFAULT_MAX_LINES, DEFAULT_MAX_BYTES);
6330 let mut error_message = message.to_string();
6331 let partial_output = if truncation.content.is_empty() {
6332 "(no output)".to_string()
6333 } else {
6334 truncation.content
6335 };
6336 let _ = write!(
6337 error_message,
6338 "\n\nPartial output before failure:\n{partial_output}"
6339 );
6340 if truncation.truncated || state.total_bytes > state.chunks_bytes {
6341 let _ = write!(
6342 error_message,
6343 "\n\n[Partial output truncated before failure]"
6344 );
6345 }
6346 error_message
6347}
6348
6349pub(crate) fn read_to_end_capped_and_drain<R: Read>(
6353 mut reader: R,
6354 max_bytes: u64,
6355) -> std::result::Result<Vec<u8>, String> {
6356 let capture_limit = usize::try_from(max_bytes.saturating_add(1)).unwrap_or(usize::MAX);
6357 let mut captured = Vec::with_capacity(capture_limit.min(8192));
6358 let mut chunk = [0u8; 8192];
6359
6360 loop {
6361 match reader.read(&mut chunk) {
6362 Ok(0) => break,
6363 Ok(read) => {
6364 let remaining = capture_limit.saturating_sub(captured.len());
6365 if remaining > 0 {
6366 let keep = remaining.min(read);
6367 captured.extend_from_slice(&chunk[..keep]);
6368 }
6369 }
6370 Err(err) if matches!(err.kind(), std::io::ErrorKind::Interrupted) => {}
6371 Err(err) => return Err(err.to_string()),
6372 }
6373 }
6374
6375 Ok(captured)
6376}
6377
6378#[allow(clippy::needless_pass_by_ref_mut)]
6382#[cfg(test)]
6383async fn drain_bash_output(
6384 rx: &mut mpsc::Receiver<BashPipeFrame>,
6385 bash_output: &mut BashOutputState,
6386 cx: &AgentCx,
6387 drain_deadline: asupersync::Time,
6388 tick: Duration,
6389 allow_cancellation: bool,
6390) -> Result<bool> {
6391 loop {
6392 match rx.try_recv() {
6393 Ok(frame) => ingest_bash_pipe_frame(frame, bash_output).await?,
6394 Err(mpsc::TryRecvError::Empty) => {
6395 let now = cx
6396 .cx()
6397 .timer_driver()
6398 .map_or_else(wall_now, |timer| timer.now());
6399 if now >= drain_deadline {
6400 return Ok(false);
6401 }
6402 if allow_cancellation && cx.checkpoint().is_err() {
6403 return Ok(true);
6404 }
6405 sleep(now, tick).await;
6406 }
6407 Err(mpsc::TryRecvError::Disconnected) => return Ok(false),
6408 }
6409 }
6410}
6411
6412fn concat_chunks(chunks: &VecDeque<Vec<u8>>) -> Vec<u8> {
6413 let total: usize = chunks.iter().map(Vec::len).sum();
6414 let mut out = Vec::with_capacity(total);
6415 for chunk in chunks {
6416 out.extend_from_slice(chunk);
6417 }
6418 out
6419}
6420
6421struct BashOutputState {
6422 total_bytes: usize,
6423 line_count: usize,
6424 last_byte_was_newline: bool,
6425 start_time: std::time::Instant,
6426 timeout_ms: Option<u64>,
6427 temp_file_path: Option<PathBuf>,
6428 temp_file: Option<asupersync::fs::File>,
6429 chunks: VecDeque<Vec<u8>>,
6430 chunks_bytes: usize,
6431 max_chunks_bytes: usize,
6432 spill_failed: bool,
6433}
6434
6435impl BashOutputState {
6436 fn new(max_chunks_bytes: usize) -> Self {
6437 Self {
6438 total_bytes: 0,
6439 line_count: 0,
6440 last_byte_was_newline: false,
6441 start_time: std::time::Instant::now(),
6442 timeout_ms: None,
6443 temp_file_path: None,
6444 temp_file: None,
6445 chunks: VecDeque::new(),
6446 chunks_bytes: 0,
6447 max_chunks_bytes,
6448 spill_failed: false,
6449 }
6450 }
6451
6452 fn abandon_spill_file(&mut self) {
6453 self.spill_failed = true;
6454 self.temp_file = None;
6455 if let Some(path) = self.temp_file_path.take() {
6456 if let Err(e) = std::fs::remove_file(&path)
6457 && e.kind() != std::io::ErrorKind::NotFound
6458 {
6459 tracing::debug!(
6460 "Failed to remove incomplete bash spill file {}: {}",
6461 path.display(),
6462 e
6463 );
6464 }
6465 }
6466 }
6467}
6468
6469#[allow(clippy::too_many_lines)]
6470async fn ingest_bash_chunk(chunk: Vec<u8>, state: &mut BashOutputState) -> Result<()> {
6471 if chunk.is_empty() {
6472 return Ok(());
6473 }
6474
6475 state.last_byte_was_newline = chunk.last().is_some_and(|byte| *byte == b'\n');
6476 state.total_bytes = state.total_bytes.saturating_add(chunk.len());
6477 state.line_count = state
6478 .line_count
6479 .saturating_add(memchr::memchr_iter(b'\n', &chunk).count());
6480
6481 if state.total_bytes > DEFAULT_MAX_BYTES
6482 && state.temp_file.is_none()
6483 && state.temp_file_path.is_none()
6484 && !state.spill_failed
6485 {
6486 let id_full = Uuid::new_v4().simple().to_string();
6487 let id = &id_full[..16];
6488 let path = std::env::temp_dir().join(format!("pi-bash-{id}.log"));
6489
6490 let path_clone = path.clone();
6494 let expected_inode: Option<u64> =
6495 asupersync::runtime::spawn_blocking_io(move || -> std::io::Result<Option<u64>> {
6496 let mut options = std::fs::OpenOptions::new();
6497 options.write(true).create_new(true);
6498
6499 #[cfg(unix)]
6500 {
6501 use std::os::unix::fs::OpenOptionsExt;
6502 options.mode(0o600);
6503 }
6504
6505 match options.open(&path_clone) {
6506 Ok(file) => {
6507 #[cfg(unix)]
6508 {
6509 use std::os::unix::fs::MetadataExt;
6510 Ok(file.metadata().ok().map(|m| m.ino()))
6511 }
6512 #[cfg(not(unix))]
6513 {
6514 drop(file);
6515 Ok(None)
6516 }
6517 }
6518 Err(e) => {
6519 tracing::warn!("Failed to create bash temp file: {e}");
6520 Ok(None)
6521 }
6522 }
6523 })
6524 .await
6525 .unwrap_or(None);
6526
6527 if expected_inode.is_some() || !cfg!(unix) {
6528 match asupersync::fs::OpenOptions::new()
6529 .append(true)
6530 .open(&path)
6531 .await
6532 {
6533 Ok(mut file) => {
6534 #[cfg_attr(not(unix), allow(unused_mut))]
6535 let mut identity_match = true;
6536 #[cfg(unix)]
6537 if let Some(expected) = expected_inode {
6538 use std::os::unix::fs::MetadataExt;
6539 match std::fs::symlink_metadata(&path) {
6544 Ok(meta) => {
6545 if !meta.ino().eq(&expected) {
6546 tracing::warn!(
6547 "Temp file identity mismatch (possible TOCTOU attack)"
6548 );
6549 identity_match = false;
6550 }
6551 }
6552 Err(e) => {
6553 tracing::warn!("Failed to stat temp file: {e}");
6554 identity_match = false;
6555 }
6556 }
6557 }
6558
6559 if identity_match {
6560 let mut failed_flush = false;
6562 for existing in &state.chunks {
6563 if let Err(e) = file.write_all(existing).await {
6564 tracing::warn!("Failed to flush bash chunk to temp file: {e}");
6565 failed_flush = true;
6566 break;
6567 }
6568 }
6569
6570 state.temp_file_path = Some(path);
6571 if failed_flush {
6572 state.abandon_spill_file();
6573 } else {
6574 state.temp_file = Some(file);
6575 }
6576 } else {
6577 state.temp_file_path = Some(path);
6578 state.abandon_spill_file();
6579 }
6580 }
6581 Err(e) => {
6582 tracing::warn!("Failed to open temp file async: {e}");
6583 state.temp_file_path = Some(path);
6584 state.abandon_spill_file();
6585 }
6586 }
6587 } else {
6588 state.spill_failed = true;
6589 }
6590 }
6591
6592 let mut close_spill_file = false;
6593 if let Some(file) = state.temp_file.as_mut() {
6594 let mut abandon_spill_file = false;
6595 if state.total_bytes <= BASH_FILE_LIMIT_BYTES {
6596 if let Err(e) = file.write_all(&chunk).await {
6597 tracing::warn!("Failed to write bash chunk to temp file: {e}");
6598 abandon_spill_file = true;
6599 }
6600 } else {
6601 if !state.spill_failed {
6603 tracing::warn!("Bash output exceeded hard limit; stopping file log");
6604 close_spill_file = true;
6605 }
6606 }
6607 if abandon_spill_file {
6608 state.abandon_spill_file();
6609 }
6610 }
6611 if close_spill_file {
6612 state.temp_file = None;
6613 }
6614
6615 state.chunks_bytes = state.chunks_bytes.saturating_add(chunk.len());
6616 state.chunks.push_back(chunk);
6617 while state.chunks_bytes > state.max_chunks_bytes && state.chunks.len() > 1 {
6618 if let Some(front) = state.chunks.pop_front() {
6619 state.chunks_bytes = state.chunks_bytes.saturating_sub(front.len());
6620 }
6621 }
6622 Ok(())
6623}
6624
6625const fn line_count_from_newline_count(
6626 total_bytes: usize,
6627 newline_count: usize,
6628 last_byte_was_newline: bool,
6629) -> usize {
6630 if total_bytes == 0 {
6631 0
6632 } else if last_byte_was_newline {
6633 newline_count
6634 } else {
6635 newline_count.saturating_add(1)
6636 }
6637}
6638
6639fn emit_bash_update(
6640 state: &BashOutputState,
6641 on_update: Option<&(dyn Fn(ToolUpdate) + Send + Sync)>,
6642) -> Result<()> {
6643 if let Some(callback) = on_update {
6644 let raw = concat_chunks(&state.chunks);
6645 let full_text = String::from_utf8_lossy(&raw);
6646 let truncation =
6647 truncate_tail(full_text.into_owned(), DEFAULT_MAX_LINES, DEFAULT_MAX_BYTES);
6648
6649 let elapsed_ms = state.start_time.elapsed().as_millis();
6654 let line_count = line_count_from_newline_count(
6655 state.total_bytes,
6656 state.line_count,
6657 state.last_byte_was_newline,
6658 );
6659 let mut details = serde_json::json!({
6660 "progress": {
6661 "elapsedMs": elapsed_ms,
6662 "lineCount": line_count,
6663 "byteCount": state.total_bytes
6664 }
6665 });
6666 let Some(details_map) = details.as_object_mut() else {
6667 return Ok(());
6668 };
6669
6670 if let Some(timeout) = state.timeout_ms {
6671 if let Some(progress) = details_map
6672 .get_mut("progress")
6673 .and_then(|v| v.as_object_mut())
6674 {
6675 progress.insert("timeoutMs".into(), serde_json::json!(timeout));
6676 }
6677 }
6678 if truncation.truncated {
6679 details_map.insert("truncation".into(), serde_json::to_value(&truncation)?);
6680 }
6681 if let Some(path) = state.temp_file_path.as_ref() {
6682 details_map.insert(
6683 "fullOutputPath".into(),
6684 serde_json::Value::String(path.display().to_string()),
6685 );
6686 }
6687
6688 callback(ToolUpdate {
6689 content: vec![ContentBlock::Text(TextContent::new(truncation.content))],
6690 details: Some(details),
6691 });
6692 }
6693 Ok(())
6694}
6695
6696pub(crate) struct ProcessGuard {
6697 child: Option<std::process::Child>,
6698 cleanup_mode: ProcessCleanupMode,
6699}
6700
6701#[derive(Clone, Copy, Debug, Eq, PartialEq)]
6702pub(crate) enum ProcessCleanupMode {
6703 ChildOnly,
6704 ProcessGroupTree,
6705}
6706
6707impl ProcessGuard {
6708 pub(crate) const fn new(child: std::process::Child, cleanup_mode: ProcessCleanupMode) -> Self {
6709 Self {
6710 child: Some(child),
6711 cleanup_mode,
6712 }
6713 }
6714
6715 pub(crate) fn try_wait_child(&mut self) -> std::io::Result<Option<std::process::ExitStatus>> {
6716 self.child
6717 .as_mut()
6718 .map_or(Ok(None), std::process::Child::try_wait)
6719 }
6720
6721 pub(crate) fn kill(&mut self) -> Option<std::process::ExitStatus> {
6722 if let Some(mut child) = self.child.take() {
6723 cleanup_child(Some(child.id()), self.cleanup_mode);
6724 let _ = child.kill();
6725 std::thread::spawn(move || {
6726 let _ = child.wait();
6727 });
6728 return None;
6731 }
6732 None
6733 }
6734
6735 pub(crate) fn wait(&mut self) -> std::io::Result<std::process::ExitStatus> {
6736 if let Some(mut child) = self.child.take() {
6737 return child.wait();
6738 }
6739 Err(std::io::Error::other("Already waited"))
6740 }
6741}
6742
6743impl Drop for ProcessGuard {
6744 fn drop(&mut self) {
6745 if let Some(mut child) = self.child.take() {
6746 match child.try_wait() {
6747 Ok(None) => {}
6748 Ok(Some(_)) | Err(_) => return,
6749 }
6750 let cleanup_mode = self.cleanup_mode;
6751 std::thread::spawn(move || {
6752 cleanup_child(Some(child.id()), cleanup_mode);
6753 let _ = child.kill();
6754 let _ = child.wait();
6755 });
6756 }
6757 }
6758}
6759
6760fn cleanup_child(pid: Option<u32>, cleanup_mode: ProcessCleanupMode) {
6761 if cleanup_mode == ProcessCleanupMode::ProcessGroupTree {
6762 kill_process_group_tree(pid);
6763 }
6764}
6765
6766pub fn kill_process_tree(pid: Option<u32>) {
6767 kill_process_tree_with(pid, sysinfo::Signal::Kill, false);
6768}
6769
6770pub(crate) fn kill_process_group_tree(pid: Option<u32>) {
6771 kill_process_tree_with(pid, sysinfo::Signal::Kill, true);
6772}
6773
6774fn terminate_process_group_tree(pid: Option<u32>) {
6775 kill_process_tree_with(pid, sysinfo::Signal::Term, true);
6776}
6777
6778fn kill_process_tree_with(pid: Option<u32>, signal: sysinfo::Signal, include_process_group: bool) {
6779 let Some(pid) = pid else {
6780 return;
6781 };
6782
6783 let root = sysinfo::Pid::from_u32(pid);
6784
6785 let mut sys = sysinfo::System::new();
6786 sys.refresh_processes(sysinfo::ProcessesToUpdate::All, true);
6787
6788 let mut children_map: HashMap<sysinfo::Pid, Vec<sysinfo::Pid>> = HashMap::new();
6789 for (p, proc_) in sys.processes() {
6790 if let Some(parent) = proc_.parent() {
6791 children_map.entry(parent).or_default().push(*p);
6792 }
6793 }
6794
6795 let mut to_kill = Vec::new();
6796 let mut visited = std::collections::HashSet::new();
6797 collect_process_tree(root, &children_map, &mut to_kill, &mut visited);
6798
6799 if include_process_group {
6800 #[cfg(unix)]
6804 {
6805 let sig_num = match signal {
6806 sysinfo::Signal::Kill => "9",
6807 _ => "15",
6808 };
6809 let _ = Command::new("kill")
6810 .arg(format!("-{sig_num}"))
6811 .arg("--")
6812 .arg(format!("-{pid}"))
6813 .stdin(Stdio::null())
6814 .stdout(Stdio::null())
6815 .stderr(Stdio::null())
6816 .status();
6817 }
6818 }
6819
6820 for pid in to_kill.into_iter().rev() {
6822 if let Some(proc_) = sys.process(pid) {
6823 match proc_.kill_with(signal) {
6824 Some(true) => {}
6825 Some(false) | None => {
6826 let _ = proc_.kill();
6827 }
6828 }
6829 }
6830 }
6831}
6832
6833fn collect_process_tree(
6834 pid: sysinfo::Pid,
6835 children_map: &HashMap<sysinfo::Pid, Vec<sysinfo::Pid>>,
6836 out: &mut Vec<sysinfo::Pid>,
6837 visited: &mut std::collections::HashSet<sysinfo::Pid>,
6838) {
6839 if !visited.insert(pid) {
6840 return;
6841 }
6842 out.push(pid);
6843 if let Some(children) = children_map.get(&pid) {
6844 for child in children {
6845 collect_process_tree(*child, children_map, out, visited);
6846 }
6847 }
6848}
6849
6850pub(crate) const SIGPIPE_TRAMPOLINE_EXEC_FAILURE_PREFIX: &str = "pi-sigpipe-reset: exec failed:";
6858
6859pub(crate) fn command_with_default_sigpipe(program: impl AsRef<OsStr>) -> std::io::Result<Command> {
6860 command_with_default_sigpipe_for_cwd(program.as_ref(), None)
6861}
6862
6863pub(crate) fn command_with_default_sigpipe_in_dir(
6866 program: impl AsRef<OsStr>,
6867 cwd: &Path,
6868) -> std::io::Result<Command> {
6869 command_with_default_sigpipe_for_cwd(program.as_ref(), Some(cwd))
6870}
6871
6872#[cfg(unix)]
6873fn command_with_default_sigpipe_for_cwd(
6874 program: &OsStr,
6875 cwd: Option<&Path>,
6876) -> std::io::Result<Command> {
6877 let program = resolve_executable_for_shell_trampoline(program, cwd)?;
6878 let mut command = Command::new("/bin/sh");
6879 command
6880 .arg("-c")
6881 .arg(
6882 "trap - PIPE\n\
6883 exec \"$@\"\n\
6884 status=$?\n\
6885 printf 'pi-sigpipe-reset: exec failed: %s\\n' \"$1\" >&2\n\
6886 exit \"$status\"",
6887 )
6888 .arg("pi-sigpipe-reset")
6889 .arg(program);
6890 Ok(command)
6891}
6892
6893#[cfg(not(unix))]
6894fn command_with_default_sigpipe_for_cwd(
6895 program: &OsStr,
6896 _cwd: Option<&Path>,
6897) -> std::io::Result<Command> {
6898 let command = Command::new(program); Ok(command)
6900}
6901
6902#[cfg(unix)]
6903fn resolve_executable_for_shell_trampoline(
6904 program: &OsStr,
6905 cwd: Option<&Path>,
6906) -> std::io::Result<OsString> {
6907 use std::os::unix::ffi::OsStrExt as _;
6908 use std::os::unix::fs::PermissionsExt as _;
6909
6910 fn executable_candidate(path: &Path) -> std::io::Result<bool> {
6911 let metadata = std::fs::metadata(path)?;
6912 Ok(metadata.is_file() && metadata.permissions().mode() & 0o111 != 0)
6913 }
6914
6915 fn absolutize_candidate(path: &Path, cwd: Option<&Path>) -> std::io::Result<PathBuf> {
6916 if path.is_absolute() {
6917 return Ok(path.to_path_buf());
6918 }
6919
6920 let base = std::env::current_dir()?;
6921 Ok(cwd.map_or_else(|| base.join(path), |cwd| base.join(cwd).join(path)))
6922 }
6923
6924 if program.as_bytes().contains(&b'/') {
6925 let path = Path::new(program);
6926 let candidate = absolutize_candidate(path, cwd)?;
6927 if executable_candidate(&candidate)? {
6928 return Ok(candidate.into_os_string());
6929 }
6930 return Err(std::io::Error::new(
6931 std::io::ErrorKind::PermissionDenied,
6932 format!("not an executable file: {}", candidate.display()),
6933 ));
6934 }
6935
6936 let mut permission_denied = false;
6937 let paths = std::env::var_os("PATH").unwrap_or_else(|| OsString::from("/bin:/usr/bin"));
6938 for dir in std::env::split_paths(&paths) {
6939 let candidate = absolutize_candidate(&dir.join(program), cwd)?;
6940 match executable_candidate(&candidate) {
6941 Ok(true) => return Ok(candidate.into_os_string()),
6942 Ok(false) => permission_denied = true,
6943 Err(err) if matches!(err.kind(), std::io::ErrorKind::NotFound) => {}
6944 Err(err) if matches!(err.kind(), std::io::ErrorKind::PermissionDenied) => {
6945 permission_denied = true;
6946 }
6947 Err(_) => {}
6948 }
6949 }
6950
6951 if permission_denied {
6952 Err(std::io::Error::new(
6953 std::io::ErrorKind::PermissionDenied,
6954 format!("command is not executable: {}", program.to_string_lossy()),
6955 ))
6956 } else {
6957 Err(std::io::Error::new(
6958 std::io::ErrorKind::NotFound,
6959 format!("command not found: {}", program.to_string_lossy()),
6960 ))
6961 }
6962}
6963
6964pub(crate) fn isolate_command_process_group(command: &mut Command) {
6966 #[cfg(unix)]
6967 {
6968 use std::os::unix::process::CommandExt as _;
6969 command.process_group(0);
6970 }
6971
6972 #[cfg(not(unix))]
6973 {
6974 let _ = command;
6975 }
6976}
6977
6978fn format_grep_path(file_path: &Path, cwd: &Path) -> String {
6979 if let Ok(rel) = file_path.strip_prefix(cwd) {
6980 let rel_str = rel.display().to_string().replace('\\', "/");
6981 if !rel_str.is_empty() {
6982 return rel_str;
6983 }
6984 }
6985
6986 let canonical_file = safe_canonicalize(file_path);
6987 let canonical_cwd = safe_canonicalize(cwd);
6988 if let Ok(rel) = canonical_file.strip_prefix(&canonical_cwd) {
6989 let rel_str = rel.display().to_string().replace('\\', "/");
6990 if !rel_str.is_empty() {
6991 return rel_str;
6992 }
6993 }
6994
6995 file_path.display().to_string().replace('\\', "/")
6996}
6997
6998async fn get_file_lines_async<'a>(
6999 path: &Path,
7000 cache: &'a mut HashMap<PathBuf, Vec<String>>,
7001) -> &'a [String] {
7002 if !cache.contains_key(path) {
7003 if let Ok(meta) = asupersync::fs::metadata(path).await {
7005 if !meta.is_file() || meta.len() > 10 * 1024 * 1024 {
7006 cache.insert(path.to_path_buf(), Vec::new());
7007 return &[];
7008 }
7009 } else {
7010 cache.insert(path.to_path_buf(), Vec::new());
7011 return &[];
7012 }
7013
7014 let bytes = match asupersync::fs::read(path).await {
7016 Ok(bytes) => bytes,
7017 Err(err) => {
7018 tracing::debug!("Failed to read grep file {}: {err}", path.display());
7019 cache.insert(path.to_path_buf(), Vec::new());
7020 return &[];
7021 }
7022 };
7023 let content = String::from_utf8_lossy(&bytes);
7024 let mut lines = Vec::new();
7025 for line in content.split('\n') {
7026 let trimmed = line.strip_suffix('\r').unwrap_or(line);
7027 for piece in trimmed.split('\r') {
7028 lines.push(piece.to_string());
7029 }
7030 }
7031 if content.ends_with('\n') && lines.last().is_some_and(std::string::String::is_empty) {
7032 lines.pop();
7033 }
7034 cache.insert(path.to_path_buf(), lines);
7035 }
7036 if let Some(lines) = cache.get(path) {
7037 lines.as_slice()
7038 } else {
7039 &[]
7040 }
7041}
7042
7043fn find_fd_binary() -> Option<&'static str> {
7044 static BINARY: OnceLock<Option<&'static str>> = OnceLock::new();
7045 *BINARY.get_or_init(|| {
7046 if std::process::Command::new("fd")
7047 .arg("--version")
7048 .stdout(Stdio::null())
7049 .stderr(Stdio::null())
7050 .status()
7051 .is_ok()
7052 {
7053 return Some("fd");
7054 }
7055 if std::process::Command::new("fdfind")
7056 .arg("--version")
7057 .stdout(Stdio::null())
7058 .stderr(Stdio::null())
7059 .status()
7060 .is_ok()
7061 {
7062 return Some("fdfind");
7063 }
7064 None
7065 })
7066}
7067
7068fn find_rg_binary() -> Option<&'static str> {
7069 static BINARY: OnceLock<Option<&'static str>> = OnceLock::new();
7070 *BINARY.get_or_init(|| {
7071 if std::process::Command::new("rg")
7072 .arg("--version")
7073 .stdout(Stdio::null())
7074 .stderr(Stdio::null())
7075 .status()
7076 .is_ok()
7077 {
7078 return Some("rg");
7079 }
7080 if std::process::Command::new("ripgrep")
7081 .arg("--version")
7082 .stdout(Stdio::null())
7083 .stderr(Stdio::null())
7084 .status()
7085 .is_ok()
7086 {
7087 return Some("ripgrep");
7088 }
7089 None
7090 })
7091}
7092
7093const NIBBLE_STR: &[u8; 16] = b"ZPMQVRWSNKTXJBYH";
7099
7100static HASHLINE_DICT: OnceLock<[[u8; 2]; 256]> = OnceLock::new();
7103
7104fn hashline_dict() -> &'static [[u8; 2]; 256] {
7105 HASHLINE_DICT.get_or_init(|| {
7106 let mut dict = [[0u8; 2]; 256];
7107 for i in 0..256 {
7108 dict[i] = [NIBBLE_STR[i & 0x0F], NIBBLE_STR[(i >> 4) & 0x0F]];
7109 }
7110 dict
7111 })
7112}
7113
7114fn compute_line_hash(line_idx: usize, line: &str) -> [u8; 2] {
7124 let line = line.strip_suffix('\r').unwrap_or(line);
7125 let significant: String = line.chars().filter(|c| !c.is_whitespace()).collect();
7127 let has_alnum = significant.chars().any(char::is_alphanumeric);
7128 let seed = if has_alnum {
7129 0
7130 } else {
7131 #[allow(clippy::cast_possible_truncation)]
7132 let s = line_idx as u32;
7133 s
7134 };
7135 let hash = xxhash_rust::xxh32::xxh32(significant.as_bytes(), seed);
7136 let byte = (hash & 0xFF) as usize;
7137 hashline_dict()[byte]
7138}
7139
7140fn format_hashline_tag(line_idx: usize, line: &str) -> String {
7142 let h = compute_line_hash(line_idx, line);
7143 format!("{}#{}{}", line_idx + 1, h[0] as char, h[1] as char)
7144}
7145
7146fn format_hashline_tag_with_bom(line_idx: usize, line: &str, had_bom: bool) -> String {
7148 let h = compute_line_hash_with_bom(line_idx, line, had_bom);
7149 format!("{}#{}{}", line_idx + 1, h[0] as char, h[1] as char)
7150}
7151
7152fn compute_line_hash_with_bom(line_idx: usize, line: &str, had_bom: bool) -> [u8; 2] {
7153 if had_bom && line_idx == 0 {
7154 let mut with_bom = String::with_capacity(line.len().saturating_add(1));
7155 with_bom.push('\u{FEFF}');
7156 with_bom.push_str(line);
7157 compute_line_hash(line_idx, &with_bom)
7158 } else {
7159 compute_line_hash(line_idx, line)
7160 }
7161}
7162
7163static HASHLINE_TAG_RE: OnceLock<regex::Regex> = OnceLock::new();
7166
7167fn hashline_tag_regex() -> &'static regex::Regex {
7168 HASHLINE_TAG_RE.get_or_init(|| {
7169 regex::Regex::new(r"^[\s>+\-]*(\d+)\s*#\s*([ZPMQVRWSNKTXJBYH]{2})")
7170 .expect("valid hashline regex")
7171 })
7172}
7173
7174fn parse_hashline_tag(ref_str: &str) -> std::result::Result<(usize, [u8; 2]), String> {
7176 let re = hashline_tag_regex();
7177 let caps = re
7178 .captures(ref_str)
7179 .ok_or_else(|| format!("Invalid hashline reference: {ref_str:?}"))?;
7180 let line_num: usize = caps[1]
7181 .parse()
7182 .map_err(|e| format!("Invalid line number in {ref_str:?}: {e}"))?;
7183 if line_num == 0 {
7184 return Err(format!("Line number must be >= 1, got 0 in {ref_str:?}"));
7185 }
7186 let hash_bytes = caps[2].as_bytes();
7187 Ok((line_num, [hash_bytes[0], hash_bytes[1]]))
7188}
7189
7190static HASHLINE_PREFIX_RE: OnceLock<regex::Regex> = OnceLock::new();
7193
7194fn strip_hashline_prefix(line: &str) -> &str {
7195 let re = HASHLINE_PREFIX_RE.get_or_init(|| {
7196 regex::Regex::new(r"^[\s>+\-]*\d+\s*#\s*[ZPMQVRWSNKTXJBYH]{2}\s*:")
7197 .expect("valid hashline prefix regex")
7198 });
7199 re.find(line).map_or(line, |m| &line[m.end()..])
7200}
7201
7202#[derive(Debug, Deserialize)]
7204#[serde(rename_all = "camelCase")]
7205struct HashlineEditInput {
7206 path: String,
7207 edits: Vec<HashlineOp>,
7208}
7209
7210#[derive(Debug, Clone, Deserialize)]
7212#[serde(rename_all = "camelCase")]
7213struct HashlineOp {
7214 op: String,
7216 pos: Option<String>,
7218 end: Option<String>,
7220 lines: Option<serde_json::Value>,
7222}
7223
7224impl HashlineOp {
7225 fn get_lines(&self) -> Vec<String> {
7227 match &self.lines {
7228 None | Some(serde_json::Value::Null) => vec![],
7229 Some(serde_json::Value::String(s)) => {
7230 normalize_to_lf(s).split('\n').map(String::from).collect()
7231 }
7232 Some(serde_json::Value::Array(arr)) => arr
7233 .iter()
7234 .map(|v| match v {
7235 serde_json::Value::String(s) => normalize_to_lf(s),
7236 other => normalize_to_lf(&other.to_string()),
7237 })
7238 .collect(),
7239 Some(other) => vec![normalize_to_lf(&other.to_string())],
7240 }
7241 }
7242}
7243
7244struct ResolvedEdit<'a> {
7246 op: &'a str,
7247 start: usize,
7249 end: usize,
7251 lines: Vec<String>,
7252}
7253
7254pub struct HashlineEditTool {
7255 cwd: PathBuf,
7256}
7257
7258impl HashlineEditTool {
7259 pub fn new(cwd: &Path) -> Self {
7260 Self {
7261 cwd: cwd.to_path_buf(),
7262 }
7263 }
7264}
7265
7266fn validate_line_ref(
7269 ref_str: &str,
7270 file_lines: &[&str],
7271 had_bom: bool,
7272) -> std::result::Result<usize, String> {
7273 let (line_num, expected_hash) = parse_hashline_tag(ref_str)?;
7274 let line_idx = line_num - 1;
7275 if line_idx >= file_lines.len() {
7276 return Err(format!(
7277 "Line {line_num} out of range (file has {} lines)",
7278 file_lines.len()
7279 ));
7280 }
7281 let actual_hash = compute_line_hash_with_bom(line_idx, file_lines[line_idx], had_bom);
7282 if actual_hash != expected_hash {
7283 let tag = format_hashline_tag_with_bom(line_idx, file_lines[line_idx], had_bom);
7284 return Err(format!(
7285 "Hash mismatch at line {line_num}: expected {}#{}{}, actual is {tag}",
7286 line_num, expected_hash[0] as char, expected_hash[1] as char,
7287 ));
7288 }
7289 Ok(line_idx)
7290}
7291
7292fn mismatch_context(file_lines: &[&str], line_idx: usize, context: usize, had_bom: bool) -> String {
7294 let start = line_idx.saturating_sub(context);
7295 let end = (line_idx + context + 1).min(file_lines.len());
7296 let mut out = String::new();
7297 for (i, &file_line) in file_lines.iter().enumerate().take(end).skip(start) {
7298 let tag = format_hashline_tag_with_bom(i, file_line, had_bom);
7299 if i == line_idx {
7300 let _ = writeln!(out, ">>> {tag}:{file_line}");
7301 } else {
7302 let _ = writeln!(out, " {tag}:{file_line}");
7303 }
7304 }
7305 out
7306}
7307
7308fn collect_mismatches(
7310 edits: &[HashlineOp],
7311 file_lines: &[&str],
7312 had_bom: bool,
7313) -> std::result::Result<(), String> {
7314 let mut errors = Vec::new();
7315 for edit in edits {
7316 if let Some(ref pos) = edit.pos {
7317 if let Err(e) = validate_line_ref(pos, file_lines, had_bom) {
7318 if let Ok((line_num, _)) = parse_hashline_tag(pos) {
7320 let idx = (line_num - 1).min(file_lines.len().saturating_sub(1));
7321 errors.push(format!(
7322 "{e}\n{}",
7323 mismatch_context(file_lines, idx, 2, had_bom)
7324 ));
7325 } else {
7326 errors.push(e);
7327 }
7328 }
7329 }
7330 if let Some(ref end) = edit.end {
7331 if let Err(e) = validate_line_ref(end, file_lines, had_bom) {
7332 if let Ok((line_num, _)) = parse_hashline_tag(end) {
7333 let idx = (line_num - 1).min(file_lines.len().saturating_sub(1));
7334 errors.push(format!(
7335 "{e}\n{}",
7336 mismatch_context(file_lines, idx, 2, had_bom)
7337 ));
7338 } else {
7339 errors.push(e);
7340 }
7341 }
7342 }
7343 }
7344 if errors.is_empty() {
7345 Ok(())
7346 } else {
7347 Err(errors.join("\n"))
7348 }
7349}
7350
7351#[derive(Debug, Clone, PartialEq, Eq, Hash)]
7353struct NormalizedEdit {
7354 op: String,
7355 pos_line: Option<usize>,
7356 end_line: Option<usize>,
7357 lines: Vec<String>,
7358}
7359
7360fn op_precedence(op: &str) -> u8 {
7362 match op {
7363 "replace" => 0,
7364 "append" => 1,
7365 "prepend" => 2,
7366 _ => 3,
7367 }
7368}
7369
7370#[async_trait]
7371#[allow(clippy::unnecessary_literal_bound)]
7372impl Tool for HashlineEditTool {
7373 fn name(&self) -> &str {
7374 "hashline_edit"
7375 }
7376 fn label(&self) -> &str {
7377 "hashline edit"
7378 }
7379 fn description(&self) -> &str {
7380 "Apply precise file edits using LINE#HASH tags from a prior read with hashline=true. \
7381 Each edit specifies an op (replace/prepend/append), a pos anchor (\"N#AB\"), an optional \
7382 end anchor for range replace, and replacement lines. Edits are validated against current \
7383 file hashes and applied bottom-up to avoid index invalidation."
7384 }
7385
7386 fn parameters(&self) -> serde_json::Value {
7387 serde_json::json!({
7388 "type": "object",
7389 "properties": {
7390 "path": {
7391 "type": "string",
7392 "description": "Path to the file to edit (relative or absolute)"
7393 },
7394 "edits": {
7395 "type": "array",
7396 "description": "Array of edit operations to apply",
7397 "items": {
7398 "type": "object",
7399 "properties": {
7400 "op": {
7401 "type": "string",
7402 "enum": ["replace", "prepend", "append"],
7403 "description": "Operation type"
7404 },
7405 "pos": {
7406 "type": "string",
7407 "description": "Anchor line reference in LINE#HASH format (e.g. \"5#KJ\")"
7408 },
7409 "end": {
7410 "type": "string",
7411 "description": "End anchor for range replace (inclusive)"
7412 },
7413 "lines": {
7414 "description": "Replacement/insertion content as array of strings, single string, or null for deletion",
7415 "oneOf": [
7416 { "type": "array", "items": { "type": "string" } },
7417 { "type": "string" },
7418 { "type": "null" }
7419 ]
7420 }
7421 },
7422 "required": ["op"]
7423 }
7424 }
7425 },
7426 "required": ["path", "edits"]
7427 })
7428 }
7429
7430 #[allow(clippy::too_many_lines)]
7431 async fn execute(
7432 &self,
7433 _tool_call_id: &str,
7434 input: serde_json::Value,
7435 _on_update: Option<Box<dyn Fn(ToolUpdate) + Send + Sync>>,
7436 ) -> Result<ToolOutput> {
7437 let input: HashlineEditInput = serde_json::from_value(input)
7438 .map_err(|e| Error::tool("hashline_edit", format!("Invalid input: {e}")))?;
7439
7440 if input.edits.is_empty() {
7441 return Err(Error::tool("hashline_edit", "No edits provided"));
7442 }
7443
7444 let resolved = resolve_read_path(&input.path, &self.cwd);
7446 let absolute_path = enforce_cwd_scope(&resolved, &self.cwd, "hashline_edit")?;
7447
7448 let metadata = asupersync::fs::metadata(&absolute_path)
7450 .await
7451 .map_err(|err| {
7452 let message = match err.kind() {
7453 std::io::ErrorKind::NotFound => format!("File not found: {}", input.path),
7454 std::io::ErrorKind::PermissionDenied => {
7455 format!("Permission denied: {}", input.path)
7456 }
7457 _ => format!("Cannot read file metadata: {err}"),
7458 };
7459 Error::tool("hashline_edit", message)
7460 })?;
7461 if !metadata.is_file() {
7462 return Err(Error::tool(
7463 "hashline_edit",
7464 format!("Path {} is not a regular file", absolute_path.display()),
7465 ));
7466 }
7467 if metadata.len() > READ_TOOL_MAX_BYTES {
7468 return Err(Error::tool(
7469 "hashline_edit",
7470 format!(
7471 "File too large ({} bytes, max {} bytes)",
7472 metadata.len(),
7473 READ_TOOL_MAX_BYTES
7474 ),
7475 ));
7476 }
7477
7478 let file = asupersync::fs::File::open(&absolute_path)
7480 .await
7481 .map_err(|e| Error::tool("hashline_edit", format!("Cannot open file: {e}")))?;
7482 let mut raw = Vec::new();
7483 let mut limiter = file.take(READ_TOOL_MAX_BYTES.saturating_add(1));
7484 limiter
7485 .read_to_end(&mut raw)
7486 .await
7487 .map_err(|e| Error::tool("hashline_edit", format!("Cannot read file: {e}")))?;
7488
7489 if raw.len() as u64 > READ_TOOL_MAX_BYTES {
7490 return Err(Error::tool(
7491 "hashline_edit",
7492 format!("File too large (> {READ_TOOL_MAX_BYTES} bytes)"),
7493 ));
7494 }
7495
7496 let raw_content = String::from_utf8(raw).map_err(|_| {
7497 Error::tool(
7498 "hashline_edit",
7499 "File contains invalid UTF-8 characters and cannot be safely edited as text."
7500 .to_string(),
7501 )
7502 })?;
7503
7504 let (content_no_bom, had_bom) = strip_bom(&raw_content);
7505 let original_ending = detect_line_ending(content_no_bom);
7506 let normalized = normalize_to_lf(content_no_bom);
7507 let file_lines: Vec<&str> = normalized.split('\n').collect();
7508
7509 if let Err(e) = collect_mismatches(&input.edits, &file_lines, had_bom) {
7511 return Err(Error::tool(
7512 "hashline_edit",
7513 format!("Hash validation failed — re-read the file to get current tags.\n\n{e}"),
7514 ));
7515 }
7516
7517 let mut seen = std::collections::HashSet::new();
7519 let mut deduped_edits: Vec<&HashlineOp> = Vec::new();
7520 for edit in &input.edits {
7521 let pos_line = edit
7522 .pos
7523 .as_ref()
7524 .and_then(|p| parse_hashline_tag(p).ok())
7525 .map(|(n, _)| n);
7526 let end_line = edit
7527 .end
7528 .as_ref()
7529 .and_then(|e| parse_hashline_tag(e).ok())
7530 .map(|(n, _)| n);
7531 let key = NormalizedEdit {
7532 op: edit.op.clone(),
7533 pos_line,
7534 end_line,
7535 lines: edit.get_lines(),
7536 };
7537 if seen.insert(key) {
7538 deduped_edits.push(edit);
7539 }
7540 }
7541
7542 let mut resolved: Vec<ResolvedEdit<'_>> = Vec::new();
7544 for edit in &deduped_edits {
7545 let replacement_lines: Vec<String> = edit
7546 .get_lines()
7547 .into_iter()
7548 .map(|l| strip_hashline_prefix(&l).to_string())
7549 .collect();
7550
7551 match edit.op.as_str() {
7552 "replace" => {
7553 let start_idx = match &edit.pos {
7554 Some(pos) => validate_line_ref(pos, &file_lines, had_bom)
7555 .map_err(|e| Error::tool("hashline_edit", e))?,
7556 None => {
7557 return Err(Error::tool(
7558 "hashline_edit",
7559 "replace operation requires a pos anchor",
7560 ));
7561 }
7562 };
7563 let end_idx = match &edit.end {
7564 Some(end) => validate_line_ref(end, &file_lines, had_bom)
7565 .map_err(|e| Error::tool("hashline_edit", e))?,
7566 None => start_idx,
7567 };
7568 if end_idx < start_idx {
7569 return Err(Error::tool(
7570 "hashline_edit",
7571 format!(
7572 "End anchor (line {}) is before start anchor (line {})",
7573 end_idx + 1,
7574 start_idx + 1
7575 ),
7576 ));
7577 }
7578 resolved.push(ResolvedEdit {
7579 op: "replace",
7580 start: start_idx,
7581 end: end_idx,
7582 lines: replacement_lines,
7583 });
7584 }
7585 "prepend" => {
7586 let idx = match &edit.pos {
7587 Some(pos) => validate_line_ref(pos, &file_lines, had_bom)
7588 .map_err(|e| Error::tool("hashline_edit", e))?,
7589 None => 0, };
7591 let end_idx = if file_lines == [""] && edit.pos.is_none() {
7592 0 } else {
7594 idx
7595 };
7596 resolved.push(ResolvedEdit {
7597 op: if file_lines == [""] && edit.pos.is_none() {
7598 "replace"
7599 } else {
7600 "prepend"
7601 },
7602 start: idx,
7603 end: end_idx,
7604 lines: replacement_lines,
7605 });
7606 }
7607 "append" => {
7608 let idx = match &edit.pos {
7609 Some(pos) => validate_line_ref(pos, &file_lines, had_bom)
7610 .map_err(|e| Error::tool("hashline_edit", e))?,
7611 None => {
7612 if file_lines.len() > 1 && file_lines.last() == Some(&"") {
7613 file_lines.len() - 2
7614 } else {
7615 file_lines.len().saturating_sub(1)
7616 }
7617 }
7618 };
7619 let end_idx = if file_lines == [""] && edit.pos.is_none() {
7620 0 } else {
7622 idx
7623 };
7624 resolved.push(ResolvedEdit {
7625 op: if file_lines == [""] && edit.pos.is_none() {
7626 "replace"
7627 } else {
7628 "append"
7629 },
7630 start: idx,
7631 end: end_idx,
7632 lines: replacement_lines,
7633 });
7634 }
7635 other => {
7636 return Err(Error::tool(
7637 "hashline_edit",
7638 format!("Unknown op: {other:?}. Must be replace, prepend, or append."),
7639 ));
7640 }
7641 }
7642 }
7643
7644 resolved.sort_by(|a, b| {
7646 b.start
7647 .cmp(&a.start)
7648 .then_with(|| op_precedence(a.op).cmp(&op_precedence(b.op)))
7649 });
7650
7651 for i in 0..resolved.len() {
7653 for j in (i + 1)..resolved.len() {
7654 let a = &resolved[i];
7655 let b = &resolved[j];
7656 if a.start <= b.end && b.start <= a.end {
7657 return Err(Error::tool(
7658 "hashline_edit",
7659 format!(
7660 "Overlapping edits detected: {} at line {}-{} and {} at line {}-{}. \
7661 Please combine overlapping edits into a single operation.",
7662 a.op,
7663 a.start + 1,
7664 a.end + 1,
7665 b.op,
7666 b.start + 1,
7667 b.end + 1
7668 ),
7669 ));
7670 }
7671 }
7672 }
7673
7674 let mut lines: Vec<String> = file_lines.iter().map(|s| (*s).to_string()).collect();
7676 let mut any_change = false;
7677
7678 for edit in &resolved {
7679 match edit.op {
7680 "replace" => {
7681 let existing: Vec<&str> = lines[edit.start..=edit.end]
7683 .iter()
7684 .map(String::as_str)
7685 .collect();
7686 if existing.eq(&edit.lines.iter().map(String::as_str).collect::<Vec<&str>>()) {
7687 continue; }
7689 lines.splice(edit.start..=edit.end, edit.lines.iter().cloned());
7691 any_change = true;
7692 }
7693 "prepend" => {
7694 lines.splice(edit.start..edit.start, edit.lines.iter().cloned());
7696 if !edit.lines.is_empty() {
7697 any_change = true;
7698 }
7699 }
7700 "append" => {
7701 let insert_at = edit.start + 1;
7703 lines.splice(insert_at..insert_at, edit.lines.iter().cloned());
7704 if !edit.lines.is_empty() {
7705 any_change = true;
7706 }
7707 }
7708 _ => {} }
7710 }
7711
7712 if !any_change {
7713 return Err(Error::tool(
7714 "hashline_edit",
7715 format!(
7716 "No changes made to {}. All edits were no-ops (replacement identical to existing content).",
7717 input.path
7718 ),
7719 ));
7720 }
7721
7722 let new_normalized = lines.join("\n");
7724 let new_content = restore_line_endings(&new_normalized, original_ending);
7725 let mut final_content = new_content;
7726 if had_bom {
7727 final_content = format!("\u{FEFF}{final_content}");
7728 }
7729
7730 let absolute_path_clone = absolute_path.clone();
7732 let final_content_bytes = final_content.into_bytes();
7733 asupersync::runtime::spawn_blocking_io(move || {
7734 let original_perms = std::fs::metadata(&absolute_path_clone)
7735 .ok()
7736 .map(|m| m.permissions());
7737 let parent = absolute_path_clone
7738 .parent()
7739 .unwrap_or_else(|| Path::new("."));
7740 let mut temp_file = tempfile::NamedTempFile::new_in(parent)?;
7741
7742 temp_file.as_file_mut().write_all(&final_content_bytes)?;
7743 temp_file.as_file_mut().sync_all()?;
7744
7745 if let Some(perms) = original_perms {
7746 let _ = temp_file.as_file().set_permissions(perms);
7747 } else {
7748 #[cfg(unix)]
7749 {
7750 use std::os::unix::fs::PermissionsExt;
7751 let _ = temp_file
7752 .as_file()
7753 .set_permissions(std::fs::Permissions::from_mode(0o644));
7754 }
7755 }
7756
7757 temp_file
7758 .persist(&absolute_path_clone)
7759 .map_err(|e| e.error)?;
7760 Ok(())
7761 })
7762 .await
7763 .map_err(|e| Error::tool("hashline_edit", format!("Failed to write file: {e}")))?;
7764
7765 let (diff, first_changed_line) = generate_diff_string(&normalized, &new_normalized);
7767 let mut details = serde_json::Map::new();
7768 details.insert("diff".to_string(), serde_json::Value::String(diff));
7769 if let Some(line) = first_changed_line {
7770 details.insert(
7771 "firstChangedLine".to_string(),
7772 serde_json::Value::Number(serde_json::Number::from(line)),
7773 );
7774 }
7775
7776 Ok(ToolOutput {
7777 content: vec![ContentBlock::Text(TextContent::new(format!(
7778 "Successfully applied hashline edits to {}.",
7779 input.path
7780 )))],
7781 details: Some(serde_json::Value::Object(details)),
7782 is_error: false,
7783 })
7784 }
7785}
7786
7787#[cfg(test)]
7792mod tests {
7793 use super::*;
7794 use proptest::prelude::*;
7795 #[cfg(target_os = "linux")]
7796 use std::time::Duration;
7797
7798 #[test]
7799 fn fsync_refusal_classifies_non_posix_durability_errors() {
7800 use std::io::{Error, ErrorKind};
7801
7802 assert!(is_fsync_refused(&Error::from_raw_os_error(9))); assert!(is_fsync_refused(&Error::from_raw_os_error(22))); assert!(is_fsync_refused(&Error::new(
7808 ErrorKind::Unsupported,
7809 "nope"
7810 )));
7811
7812 assert!(!is_fsync_refused(&Error::from_raw_os_error(5))); assert!(!is_fsync_refused(&Error::from_raw_os_error(28))); assert!(!is_fsync_refused(&Error::new(
7817 ErrorKind::PermissionDenied,
7818 "no"
7819 )));
7820 }
7821
7822 #[test]
7823 fn tolerate_fsync_refusal_downgrades_refusals_but_propagates_real_errors() {
7824 use std::io::{Error, ErrorKind};
7825 use std::path::Path;
7826
7827 let p = Path::new("/tmp/does-not-matter");
7828 assert!(tolerate_fsync_refusal(Ok(()), "x", p).is_ok());
7831 assert!(tolerate_fsync_refusal(Err(Error::from_raw_os_error(9)), "temp file", p).is_ok());
7832 assert!(tolerate_fsync_refusal(Err(Error::from_raw_os_error(5)), "temp file", p).is_err());
7834 assert!(
7835 tolerate_fsync_refusal(Err(Error::new(ErrorKind::PermissionDenied, "no")), "x", p)
7836 .is_err()
7837 );
7838 }
7839
7840 #[test]
7841 fn test_truncate_head() {
7842 let content = "line1\nline2\nline3\nline4\nline5".to_string();
7843 let result = truncate_head(content, 3, 1000);
7844
7845 assert_eq!(result.content, "line1\nline2\nline3\n");
7846 assert!(result.truncated);
7847 assert_eq!(result.truncated_by, Some(TruncatedBy::Lines));
7848 assert_eq!(result.total_lines, 5);
7849 assert_eq!(result.output_lines, 3);
7850 }
7851
7852 #[test]
7853 fn test_truncate_tail() {
7854 let content = "line1\nline2\nline3\nline4\nline5".to_string();
7855 let result = truncate_tail(content, 3, 1000);
7856
7857 assert_eq!(result.content, "line3\nline4\nline5");
7858 assert!(result.truncated);
7859 assert_eq!(result.truncated_by, Some(TruncatedBy::Lines));
7860 assert_eq!(result.total_lines, 5);
7861 assert_eq!(result.output_lines, 3);
7862 }
7863
7864 fn assert_same_head_truncation(actual: &TruncationResult, expected: &TruncationResult) {
7865 assert_eq!(actual.content, expected.content);
7866 assert_eq!(actual.truncated, expected.truncated);
7867 assert_eq!(actual.truncated_by, expected.truncated_by);
7868 assert_eq!(actual.total_lines, expected.total_lines);
7869 assert_eq!(actual.total_bytes, expected.total_bytes);
7870 assert_eq!(actual.output_lines, expected.output_lines);
7871 assert_eq!(actual.output_bytes, expected.output_bytes);
7872 assert_eq!(actual.last_line_partial, expected.last_line_partial);
7873 assert_eq!(
7874 actual.first_line_exceeds_limit,
7875 expected.first_line_exceeds_limit
7876 );
7877 assert_eq!(actual.max_lines, expected.max_lines);
7878 assert_eq!(actual.max_bytes, expected.max_bytes);
7879 }
7880
7881 fn write_lines_with_builder(lines: &[&str], max_bytes: usize) -> TruncationResult {
7882 let mut writer = HeadTruncatingLineWriter::new(max_bytes);
7883 for line in lines {
7884 writer.push_line(line);
7885 }
7886 writer.finish()
7887 }
7888
7889 #[test]
7890 fn head_truncating_line_writer_matches_join_without_truncation() {
7891 let lines = ["alpha", "beta", "gamma"];
7892 let expected = truncate_head(lines.join("\n"), usize::MAX, 1000);
7893 let actual = write_lines_with_builder(&lines, 1000);
7894
7895 assert_same_head_truncation(&actual, &expected);
7896 }
7897
7898 #[test]
7899 fn head_truncating_line_writer_matches_join_at_byte_boundary() {
7900 let lines = ["alpha", "beta", "gamma"];
7901 let expected = truncate_head(lines.join("\n"), usize::MAX, 8);
7902 let actual = write_lines_with_builder(&lines, 8);
7903
7904 assert_same_head_truncation(&actual, &expected);
7905 assert_eq!(actual.content, "alpha\nbe");
7906 }
7907
7908 #[test]
7909 fn head_truncating_line_writer_preserves_utf8_boundary_and_order() {
7910 let lines = ["alpha", "βeta", "gamma"];
7911 let expected = truncate_head(lines.join("\n"), usize::MAX, 8);
7912 let actual = write_lines_with_builder(&lines, 8);
7913
7914 assert_same_head_truncation(&actual, &expected);
7915 assert_eq!(actual.content, "alpha\nβ");
7916 }
7917
7918 fn first_text(output: &ToolOutput) -> &str {
7919 output
7920 .content
7921 .first()
7922 .and_then(|block| match block {
7923 ContentBlock::Text(text) => Some(text.text.as_str()),
7924 _ => None,
7925 })
7926 .unwrap_or("")
7927 }
7928
7929 fn artifact_json(details: Option<&serde_json::Value>) -> &serde_json::Value {
7930 details
7931 .and_then(|value| value.get("artifact"))
7932 .expect("artifact details")
7933 }
7934
7935 fn artifact_str_field<'a>(artifact: &'a serde_json::Value, field: &str) -> &'a str {
7936 artifact
7937 .get(field)
7938 .and_then(serde_json::Value::as_str)
7939 .unwrap_or("")
7940 }
7941
7942 #[test]
7943 fn tool_output_artifact_respects_spill_threshold() {
7944 let tmp = tempfile::tempdir().expect("artifact root");
7945 let mut output = "small preview".to_string();
7946 let mut details = None;
7947 let spilled = attach_text_artifact_if_needed_at_root(
7948 tmp.path(),
7949 &mut output,
7950 &mut details,
7951 "read",
7952 "call-small",
7953 "selectedTextWindow",
7954 "small body",
7955 );
7956
7957 assert!(!spilled);
7958 assert_eq!(output, "small preview");
7959 assert!(details.is_none());
7960 }
7961
7962 #[test]
7963 fn tool_output_artifact_writes_content_addressed_text_and_metadata()
7964 -> std::result::Result<(), Box<dyn std::error::Error>> {
7965 let tmp = tempfile::tempdir().expect("artifact root");
7966 let full = "a".repeat(TOOL_OUTPUT_ARTIFACT_THRESHOLD_BYTES + 1);
7967 let mut output = "bounded preview".to_string();
7968 let mut details = None;
7969 let _session_guard =
7970 register_tool_output_artifact_session("call/text:1", "session/artifacts:one");
7971 let spilled = attach_text_artifact_if_needed_at_root(
7972 tmp.path(),
7973 &mut output,
7974 &mut details,
7975 "read",
7976 "call/text:1",
7977 "selectedTextWindow",
7978 &full,
7979 );
7980
7981 assert!(spilled);
7982 assert!(output.contains("Full tool output artifact:"));
7983 let artifact = artifact_json(details.as_ref());
7984 assert_eq!(artifact["schema"], TOOL_OUTPUT_ARTIFACT_SCHEMA_V1);
7985 assert_eq!(artifact["toolName"], "read");
7986 assert_eq!(artifact["sourceKind"], "selectedTextWindow");
7987 assert_eq!(artifact["sessionId"], "session/artifacts:one");
7988 assert_eq!(
7989 artifact["byteCount"].as_u64().unwrap(),
7990 u64::try_from(full.len()).unwrap()
7991 );
7992
7993 let path_value = artifact_str_field(artifact, "path");
7994 let metadata_path_value = artifact_str_field(artifact, "metadataPath");
7995 assert!(!path_value.is_empty(), "artifact path must be a string");
7996 assert!(
7997 !metadata_path_value.is_empty(),
7998 "artifact metadataPath must be a string"
7999 );
8000 let path = PathBuf::from(path_value);
8001 let metadata_path = PathBuf::from(metadata_path_value);
8002 assert!(path.starts_with(tmp.path().join("session_artifacts_one").join("call_text_1")));
8003 assert_eq!(std::fs::read_to_string(path)?, full);
8004 let metadata_bytes = std::fs::read(metadata_path)?;
8005 let metadata: serde_json::Value = serde_json::from_slice(&metadata_bytes)?;
8006 assert_eq!(metadata["sha256"], artifact["sha256"]);
8007 assert_eq!(
8008 metadata["retentionClass"],
8009 TOOL_OUTPUT_ARTIFACT_RETENTION_CLASS
8010 );
8011 assert_eq!(
8012 metadata["spilloverReason"],
8013 TOOL_OUTPUT_ARTIFACT_SPILLOVER_REASON
8014 );
8015 assert_eq!(metadata["safeDeleteCandidate"], true);
8016 assert_eq!(
8017 metadata["redactionSummary"]["policy"],
8018 TOOL_OUTPUT_ARTIFACT_REDACTION_POLICY_V1
8019 );
8020 assert_eq!(metadata["redactionSummary"]["status"], "clean");
8021 assert_eq!(metadata["redactionSummary"]["rawSecretBytesEmitted"], 0);
8022 Ok(())
8023 }
8024
8025 #[test]
8026 fn tool_output_artifact_redacts_sensitive_text_before_persisting()
8027 -> std::result::Result<(), Box<dyn std::error::Error>> {
8028 let tmp = tempfile::tempdir().expect("artifact root");
8029 let leaked_token = "sk-redactionfixture1234567890";
8030 let leaked_bearer = "ghp_redactionfixture1234567890";
8031 let full = format!(
8032 "API_TOKEN={leaked_token}\nAuthorization: Bearer {leaked_bearer}\n{}",
8033 "x".repeat(TOOL_OUTPUT_ARTIFACT_THRESHOLD_BYTES + 1)
8034 );
8035 let mut output = "bounded preview".to_string();
8036 let mut details = None;
8037
8038 let spilled = attach_text_artifact_if_needed_at_root(
8039 tmp.path(),
8040 &mut output,
8041 &mut details,
8042 "read",
8043 "call-secret",
8044 "selectedTextWindow",
8045 &full,
8046 );
8047
8048 assert!(spilled);
8049 let artifact = artifact_json(details.as_ref());
8050 let path = PathBuf::from(artifact_str_field(artifact, "path"));
8051 let metadata_path = PathBuf::from(artifact_str_field(artifact, "metadataPath"));
8052 let persisted = std::fs::read_to_string(path)?;
8053 let metadata: serde_json::Value = serde_json::from_slice(&std::fs::read(metadata_path)?)?;
8054
8055 assert!(!persisted.contains(leaked_token));
8056 assert!(!persisted.contains(leaked_bearer));
8057 assert!(persisted.contains("API_TOKEN=[REDACTED]"));
8058 assert_eq!(artifact["redactionSummary"]["status"], "redacted");
8059 assert_eq!(artifact["redactionSummary"]["rawSecretBytesEmitted"], 0);
8060 assert_eq!(metadata["redactionSummary"], artifact["redactionSummary"]);
8061 let fields = artifact["redactionSummary"]["fields"]
8062 .as_array()
8063 .expect("redaction fields");
8064 assert!(fields.iter().any(|field| field == "api_token"));
8065 assert!(fields.iter().any(|field| field == "authorization"));
8066 Ok(())
8067 }
8068
8069 #[test]
8070 fn tool_output_artifact_marks_binaryish_payloads_in_lifecycle_manifest() {
8071 let tmp = tempfile::tempdir().expect("artifact root");
8072 let full = format!(
8073 "{}\0{}",
8074 "z".repeat(TOOL_OUTPUT_ARTIFACT_THRESHOLD_BYTES / 2),
8075 "z".repeat(TOOL_OUTPUT_ARTIFACT_THRESHOLD_BYTES / 2 + 2)
8076 );
8077 let mut output = "bounded preview".to_string();
8078 let mut details = None;
8079
8080 let spilled = attach_text_artifact_if_needed_at_root(
8081 tmp.path(),
8082 &mut output,
8083 &mut details,
8084 "read",
8085 "call-binaryish",
8086 "selectedTextWindow",
8087 &full,
8088 );
8089
8090 assert!(spilled);
8091 let artifact = artifact_json(details.as_ref());
8092 assert_eq!(artifact["redactionSummary"]["binarySuspect"], true);
8093 assert_eq!(artifact["redactionSummary"]["rawSecretBytesEmitted"], 0);
8094 assert_eq!(artifact["safeDeleteCandidate"], true);
8095 }
8096
8097 #[test]
8098 fn tool_output_artifact_failure_records_degraded_preview() {
8099 let tmp = tempfile::tempdir().expect("artifact root parent");
8100 let root_file = tmp.path().join("not-a-directory");
8101 std::fs::write(&root_file, "not a directory").expect("root file");
8102 let full = "b".repeat(TOOL_OUTPUT_ARTIFACT_THRESHOLD_BYTES + 1);
8103 let mut output = "bounded preview".to_string();
8104 let mut details = None;
8105
8106 let spilled = attach_text_artifact_if_needed_at_root(
8107 &root_file,
8108 &mut output,
8109 &mut details,
8110 "read",
8111 "call-fail",
8112 "selectedTextWindow",
8113 &full,
8114 );
8115
8116 assert!(!spilled);
8117 assert!(output.contains("Tool output artifact persistence failed"));
8118 assert!(
8119 details
8120 .as_ref()
8121 .and_then(|value| value.get("artifactError"))
8122 .is_some()
8123 );
8124 }
8125
8126 #[test]
8127 fn read_tool_spills_oversized_selected_text_window_to_artifact() {
8128 asupersync::test_utils::run_test(|| async {
8129 let tmp = tempfile::tempdir().expect("workspace");
8130 let artifact_root = tempfile::tempdir().expect("artifact root");
8131
8132 let body = "r".repeat(TOOL_OUTPUT_ARTIFACT_THRESHOLD_BYTES + 8);
8133 std::fs::write(tmp.path().join("large.txt"), &body).expect("large file");
8134 let read_tool = ReadTool::with_artifact_root(tmp.path(), artifact_root.path());
8135 let output = read_tool
8136 .execute(
8137 "read-artifact-call",
8138 serde_json::json!({ "path": "large.txt" }),
8139 None,
8140 )
8141 .await
8142 .expect("read large file");
8143
8144 assert!(first_text(&output).contains("Full tool output artifact:"));
8145 let artifact = artifact_json(output.details.as_ref());
8146 assert_eq!(artifact["toolName"], "read");
8147 assert_eq!(artifact["sourceKind"], "selectedTextWindow");
8148 let path_value = artifact_str_field(artifact, "path");
8149 assert!(!path_value.is_empty(), "artifact path must be a string");
8150 let path = PathBuf::from(path_value);
8151 let spilled = match std::fs::read_to_string(&path) {
8152 Ok(spilled) => spilled,
8153 Err(err) => {
8154 assert!(false, "read spilled artifact {}: {err}", path.display());
8155 return;
8156 }
8157 };
8158 let prefix = " 1→";
8159 assert_eq!(spilled.len(), prefix.len() + DEFAULT_MAX_BYTES);
8160 assert_eq!(
8161 artifact["byteCount"].as_u64().unwrap(),
8162 u64::try_from(spilled.len()).unwrap()
8163 );
8164 assert!(spilled.starts_with(prefix));
8165 assert!(spilled[prefix.len()..].bytes().all(|byte| byte == b'r'));
8166 assert_eq!(
8167 artifact["retentionClass"],
8168 TOOL_OUTPUT_ARTIFACT_RETENTION_CLASS
8169 );
8170 assert_eq!(
8171 artifact["spilloverReason"],
8172 TOOL_OUTPUT_ARTIFACT_SPILLOVER_REASON
8173 );
8174 assert_eq!(artifact["safeDeleteCandidate"], true);
8175 });
8176 }
8177
8178 #[test]
8179 fn bash_tool_spills_truncated_full_output_to_artifact() {
8180 asupersync::test_utils::run_test(|| async {
8181 if !Path::new("/dev/zero").exists() {
8182 return;
8183 }
8184
8185 let tmp = tempfile::tempdir().expect("workspace");
8186 let artifact_root = tempfile::tempdir().expect("artifact root");
8187
8188 let bash_tool = BashTool::with_artifact_root(tmp.path(), artifact_root.path());
8189 let output = bash_tool
8190 .execute(
8191 "bash-artifact-call",
8192 serde_json::json!({
8193 "command": "head -c 1001000 /dev/zero | tr '\\0' x",
8194 "timeout": 10
8195 }),
8196 None,
8197 )
8198 .await
8199 .expect("bash large output");
8200
8201 assert!(first_text(&output).contains("Full tool output artifact:"));
8202 let artifact = artifact_json(output.details.as_ref());
8203 assert_eq!(artifact["toolName"], "bash");
8204 assert_eq!(artifact["sourceKind"], "fullCommandOutput");
8205 let path = PathBuf::from(artifact_str_field(artifact, "path"));
8206 assert_eq!(std::fs::metadata(path).unwrap().len(), 1_001_000);
8207 assert_eq!(artifact["redactionSummary"]["status"], "clean");
8208 assert_eq!(artifact["safeDeleteCandidate"], true);
8209 });
8210 }
8211
8212 #[test]
8213 fn bash_tool_redacts_secret_like_full_output_artifacts() {
8214 asupersync::test_utils::run_test(|| async {
8215 if !Path::new("/dev/zero").exists() {
8216 return;
8217 }
8218
8219 let tmp = tempfile::tempdir().expect("workspace");
8220 let artifact_root = tempfile::tempdir().expect("artifact root");
8221 let leaked_token = "sk-bashredactionfixture1234567890";
8222
8223 let bash_tool = BashTool::with_artifact_root(tmp.path(), artifact_root.path());
8224 let output = bash_tool
8225 .execute(
8226 "bash-secret-artifact-call",
8227 serde_json::json!({
8228 "command": format!("printf 'API_TOKEN={leaked_token}\\n'; head -c 1001000 /dev/zero | tr '\\0' x"),
8229 "timeout": 10
8230 }),
8231 None,
8232 )
8233 .await
8234 .expect("bash large output");
8235
8236 assert!(first_text(&output).contains("Full tool output artifact:"));
8237 let artifact = artifact_json(output.details.as_ref());
8238 assert_eq!(artifact["toolName"], "bash");
8239 assert_eq!(artifact["redactionSummary"]["status"], "redacted");
8240 assert_eq!(artifact["redactionSummary"]["rawSecretBytesEmitted"], 0);
8241 let path = PathBuf::from(artifact_str_field(artifact, "path"));
8242 let persisted = std::fs::read_to_string(path).expect("read redacted bash artifact");
8243 assert!(!persisted.contains(leaked_token));
8244 assert!(persisted.contains("API_TOKEN=[REDACTED]"));
8245 });
8246 }
8247
8248 #[test]
8249 fn grep_tool_spills_large_search_results_with_lifecycle_manifest() {
8250 asupersync::test_utils::run_test(|| async {
8251 if !rg_available() {
8252 return;
8253 }
8254
8255 let tmp = tempfile::tempdir().expect("workspace");
8256 let artifact_root = tempfile::tempdir().expect("artifact root");
8257 let mut body = String::new();
8258 let suffix = "g".repeat(560);
8259 for idx in 0..2200 {
8260 let _ = writeln!(body, "target {idx:04} {suffix}");
8261 }
8262 std::fs::write(tmp.path().join("large-grep.txt"), body).expect("write grep fixture");
8263
8264 let grep_tool = GrepTool::with_artifact_root(tmp.path(), artifact_root.path());
8265 let output = grep_tool
8266 .execute(
8267 "grep-artifact-call",
8268 serde_json::json!({
8269 "pattern": "target",
8270 "path": "large-grep.txt",
8271 "literal": true,
8272 "limit": 2200
8273 }),
8274 None,
8275 )
8276 .await
8277 .expect("grep large output");
8278
8279 assert!(first_text(&output).contains("Full tool output artifact:"));
8280 let artifact = artifact_json(output.details.as_ref());
8281 assert_eq!(artifact["toolName"], "grep");
8282 assert_eq!(artifact["sourceKind"], "searchResults");
8283 assert_eq!(
8284 artifact["retentionClass"],
8285 TOOL_OUTPUT_ARTIFACT_RETENTION_CLASS
8286 );
8287 assert_eq!(artifact["safeDeleteCandidate"], true);
8288 assert_eq!(artifact["redactionSummary"]["status"], "clean");
8289 let path = PathBuf::from(artifact_str_field(artifact, "path"));
8290 let persisted = std::fs::read_to_string(path).expect("read grep artifact");
8291 assert!(persisted.contains("large-grep.txt:1: target 0000"));
8292 assert!(
8293 artifact["byteCount"].as_u64().unwrap()
8294 > u64::try_from(TOOL_OUTPUT_ARTIFACT_THRESHOLD_BYTES).unwrap()
8295 );
8296 });
8297 }
8298
8299 #[test]
8300 fn read_tool_denied_path_does_not_emit_lifecycle_artifact() {
8301 asupersync::test_utils::run_test(|| async {
8302 let cwd = tempfile::tempdir().expect("workspace");
8303 let outside = tempfile::tempdir().expect("outside");
8304 let artifact_root = tempfile::tempdir().expect("artifact root");
8305 let outside_path = outside.path().join("secret.txt");
8306 std::fs::write(&outside_path, "API_TOKEN=sk-deniedpathfixture1234567890")
8307 .expect("outside secret");
8308
8309 let read_tool = ReadTool::with_artifact_root(cwd.path(), artifact_root.path());
8310 let err = read_tool
8311 .execute(
8312 "read-denied-artifact-call",
8313 serde_json::json!({ "path": outside_path }),
8314 None,
8315 )
8316 .await
8317 .expect_err("outside read should be denied");
8318
8319 assert!(
8320 err.to_string()
8321 .contains("Cannot read outside the working directory or agent dir")
8322 );
8323 let mut entries = std::fs::read_dir(artifact_root.path()).expect("artifact root");
8324 assert!(
8325 entries.next().is_none(),
8326 "denied reads must not write artifacts"
8327 );
8328 });
8329 }
8330
8331 #[test]
8332 fn ls_tool_spills_oversized_directory_listing_to_artifact() {
8333 asupersync::test_utils::run_test(|| async {
8334 let tmp = tempfile::tempdir().expect("workspace");
8335 let artifact_root = tempfile::tempdir().expect("artifact root");
8336 let suffix = "x".repeat(224);
8337 for i in 0..4_500 {
8338 let name = format!("entry-{i:04}-{suffix}.txt");
8339 std::fs::write(tmp.path().join(name), "").expect("write listing fixture");
8340 }
8341
8342 let ls_tool = LsTool::with_artifact_root(tmp.path(), artifact_root.path());
8343 let output = ls_tool
8344 .execute(
8345 "ls-artifact-call",
8346 serde_json::json!({ "path": ".", "limit": 4500 }),
8347 None,
8348 )
8349 .await
8350 .expect("ls large directory");
8351
8352 assert!(first_text(&output).contains("Full tool output artifact:"));
8353 let artifact = artifact_json(output.details.as_ref());
8354 assert_eq!(artifact["toolName"], "ls");
8355 assert_eq!(artifact["sourceKind"], "directoryEntries");
8356 assert!(
8357 artifact["byteCount"].as_u64().unwrap()
8358 > u64::try_from(TOOL_OUTPUT_ARTIFACT_THRESHOLD_BYTES).unwrap()
8359 );
8360 let path = PathBuf::from(artifact_str_field(artifact, "path"));
8361 assert!(
8362 std::fs::read_to_string(path)
8363 .unwrap()
8364 .contains("entry-0000-")
8365 );
8366 });
8367 }
8368
8369 async fn assert_read_cache_hit_and_stale(tmp: &Path) {
8370 let note = tmp.join("note.txt");
8371 std::fs::write(¬e, "alpha\n").expect("write note");
8372
8373 let read_tool = ReadTool::new(tmp);
8374 let read_input = serde_json::json!({ "path": "note.txt" });
8375 let first = read_tool
8376 .execute("read-1", read_input.clone(), None)
8377 .await
8378 .expect("first read");
8379 assert!(first_text(&first).contains("alpha"));
8380
8381 let hits_before = tool_output_cache_stats_for_tests().hits;
8382 let second = read_tool
8383 .execute("read-2", read_input.clone(), None)
8384 .await
8385 .expect("cached read");
8386 assert_eq!(first_text(&first), first_text(&second));
8387 assert!(tool_output_cache_stats_for_tests().hits > hits_before);
8388
8389 let invalidations_before = tool_output_cache_stats_for_tests().invalidations;
8390 std::fs::write(¬e, "beta\n").expect("rewrite note");
8391 let third = read_tool
8392 .execute("read-3", read_input.clone(), None)
8393 .await
8394 .expect("invalidated read");
8395 assert!(first_text(&third).contains("beta"));
8396 assert!(!first_text(&third).contains("alpha"));
8397 assert!(tool_output_cache_stats_for_tests().invalidations > invalidations_before);
8398 }
8399
8400 async fn assert_ls_cache_hit_and_stale(tmp: &Path) {
8401 let ls_tool = LsTool::new(tmp);
8402 let ls_input = serde_json::json!({ "path": "." });
8403 let ls_first = ls_tool
8404 .execute("ls-1", ls_input.clone(), None)
8405 .await
8406 .expect("first ls");
8407 assert!(first_text(&ls_first).contains("note.txt"));
8408
8409 let hits_before = tool_output_cache_stats_for_tests().hits;
8410 let ls_second = ls_tool
8411 .execute("ls-2", ls_input.clone(), None)
8412 .await
8413 .expect("cached ls");
8414 assert_eq!(first_text(&ls_first), first_text(&ls_second));
8415 assert!(tool_output_cache_stats_for_tests().hits > hits_before);
8416
8417 let invalidations_before = tool_output_cache_stats_for_tests().invalidations;
8418 std::fs::write(tmp.join("new.txt"), "new\n").expect("write new file");
8419 let ls_third = ls_tool
8420 .execute("ls-3", ls_input.clone(), None)
8421 .await
8422 .expect("invalidated ls");
8423 assert!(first_text(&ls_third).contains("new.txt"));
8424 assert!(tool_output_cache_stats_for_tests().invalidations > invalidations_before);
8425 }
8426
8427 async fn assert_grep_cache_hit_and_stale_when_available(tmp: &Path) {
8428 if find_rg_binary().is_none() {
8429 return;
8430 }
8431
8432 let grep_tool = GrepTool::new(tmp);
8433 let grep_input = serde_json::json!({ "pattern": "needle", "path": "." });
8434 std::fs::write(tmp.join("a.txt"), "needle\n").expect("write grep file");
8435
8436 let grep_first = grep_tool
8437 .execute("grep-1", grep_input.clone(), None)
8438 .await
8439 .expect("first grep");
8440 assert!(first_text(&grep_first).contains("a.txt"));
8441
8442 let hits_before = tool_output_cache_stats_for_tests().hits;
8443 let grep_second = grep_tool
8444 .execute("grep-2", grep_input.clone(), None)
8445 .await
8446 .expect("cached grep");
8447 assert_eq!(first_text(&grep_first), first_text(&grep_second));
8448 assert!(tool_output_cache_stats_for_tests().hits > hits_before);
8449
8450 let invalidations_before = tool_output_cache_stats_for_tests().invalidations;
8451 std::fs::write(tmp.join("b.txt"), "needle\n").expect("write new match");
8452 let grep_third = grep_tool
8453 .execute("grep-3", grep_input.clone(), None)
8454 .await
8455 .expect("invalidated grep");
8456 assert!(first_text(&grep_third).contains("b.txt"));
8457 assert!(tool_output_cache_stats_for_tests().invalidations > invalidations_before);
8458 }
8459
8460 async fn assert_find_cache_hit_and_stale_when_available(tmp: &Path) {
8461 if find_fd_binary().is_none() {
8462 return;
8463 }
8464
8465 let find_tool = FindTool::new(tmp);
8466 let find_input = serde_json::json!({ "pattern": "*find*.txt", "path": "." });
8467 std::fs::write(tmp.join("find-a.txt"), "find\n").expect("write first find file");
8468
8469 let find_first = find_tool
8470 .execute("find-1", find_input.clone(), None)
8471 .await
8472 .expect("first find");
8473 assert!(first_text(&find_first).contains("find-a.txt"));
8474
8475 let hits_before = tool_output_cache_stats_for_tests().hits;
8476 let find_second = find_tool
8477 .execute("find-2", find_input.clone(), None)
8478 .await
8479 .expect("cached find");
8480 assert_eq!(first_text(&find_first), first_text(&find_second));
8481 assert!(tool_output_cache_stats_for_tests().hits > hits_before);
8482
8483 let invalidations_before = tool_output_cache_stats_for_tests().invalidations;
8484 std::fs::write(tmp.join("find-b.txt"), "find\n").expect("write second find file");
8485 let find_third = find_tool
8486 .execute("find-3", find_input.clone(), None)
8487 .await
8488 .expect("invalidated find");
8489 assert!(first_text(&find_third).contains("find-b.txt"));
8490 assert!(tool_output_cache_stats_for_tests().invalidations > invalidations_before);
8491 }
8492
8493 async fn assert_side_effect_tools_remain_uncached(tmp: &Path) {
8494 let side_effect_stats_before = tool_output_cache_stats_for_tests();
8495 let write_tool = WriteTool::new(tmp);
8496 write_tool
8497 .execute(
8498 "write-1",
8499 serde_json::json!({
8500 "path": "side-effect.txt",
8501 "content": "one\n"
8502 }),
8503 None,
8504 )
8505 .await
8506 .expect("write side-effect file");
8507
8508 let edit_tool = EditTool::new(tmp);
8509 edit_tool
8510 .execute(
8511 "edit-1",
8512 serde_json::json!({
8513 "path": "side-effect.txt",
8514 "oldText": "one",
8515 "newText": "two"
8516 }),
8517 None,
8518 )
8519 .await
8520 .expect("edit side-effect file");
8521
8522 let bash_tool = BashTool::new(tmp);
8523 bash_tool
8524 .execute(
8525 "bash-1",
8526 serde_json::json!({
8527 "command": "printf 'cache-uncached\\n'",
8528 "timeout": 5
8529 }),
8530 None,
8531 )
8532 .await
8533 .expect("run uncached bash");
8534
8535 let side_effect_stats_after = tool_output_cache_stats_for_tests();
8536 assert_eq!(
8537 (
8538 side_effect_stats_after.side_effect_accesses,
8539 side_effect_stats_after.side_effect_insert_attempts
8540 ),
8541 (
8542 side_effect_stats_before.side_effect_accesses,
8543 side_effect_stats_before.side_effect_insert_attempts
8544 ),
8545 "write, edit, and bash must not consult or populate the read-only output cache"
8546 );
8547 }
8548
8549 #[test]
8550 fn tool_output_cache_reuses_and_invalidates_read_only_tool_outputs() {
8551 asupersync::test_utils::run_test(|| async {
8552 reset_tool_output_cache_for_tests();
8553
8554 let tmp = tempfile::tempdir().expect("create temp dir");
8555 assert_read_cache_hit_and_stale(tmp.path()).await;
8556 assert_ls_cache_hit_and_stale(tmp.path()).await;
8557 assert_grep_cache_hit_and_stale_when_available(tmp.path()).await;
8558 assert_find_cache_hit_and_stale_when_available(tmp.path()).await;
8559 assert_side_effect_tools_remain_uncached(tmp.path()).await;
8560 });
8561 }
8562
8563 #[test]
8564 fn tool_output_context_cache_evidence_jsonl_covers_required_decisions()
8565 -> std::result::Result<(), String> {
8566 let evidence = include_str!("../docs/evidence/tool-output-context-cache.jsonl");
8567 let mut saw_read_hit = false;
8568 let mut saw_grep_stale = false;
8569 let mut saw_find_stale = false;
8570 let mut saw_ls_stale = false;
8571 let mut saw_write_uncached = false;
8572 let mut saw_edit_uncached = false;
8573 let mut saw_bash_uncached = false;
8574
8575 for (line_number, line) in evidence.lines().enumerate() {
8576 if line.trim().is_empty() {
8577 continue;
8578 }
8579
8580 let event: serde_json::Value = serde_json::from_str(line).map_err(|err| {
8581 format!(
8582 "invalid context-cache JSONL at line {}: {err}",
8583 line_number + 1
8584 )
8585 })?;
8586 assert_eq!(
8587 event.get("schema").and_then(serde_json::Value::as_str),
8588 Some("pi.tool_output_context_cache.evidence.v1")
8589 );
8590 assert_eq!(
8591 event.get("bead").and_then(serde_json::Value::as_str),
8592 Some("bd-dklqn.1")
8593 );
8594 let related_beads = event
8595 .get("related_beads")
8596 .and_then(serde_json::Value::as_array)
8597 .ok_or_else(|| format!("missing related_beads at line {}", line_number + 1))?;
8598 assert!(
8599 related_beads
8600 .iter()
8601 .any(|bead| bead.as_str() == Some("bd-dklqn.2")),
8602 "evidence line {} must cover bd-dklqn.2",
8603 line_number + 1
8604 );
8605
8606 let tool = event
8607 .get("tool")
8608 .and_then(serde_json::Value::as_str)
8609 .expect("tool");
8610 let outcome = event
8611 .get("outcome")
8612 .and_then(serde_json::Value::as_str)
8613 .expect("outcome");
8614 let reason = event
8615 .get("reason")
8616 .and_then(serde_json::Value::as_str)
8617 .expect("reason");
8618
8619 match (tool, outcome, reason) {
8620 ("read", "hit", "unchanged_file_fingerprint") => saw_read_hit = true,
8621 ("grep", "stale", "recursive_directory_fingerprint_changed") => {
8622 saw_grep_stale = true;
8623 }
8624 ("find", "stale", "recursive_directory_fingerprint_changed") => {
8625 saw_find_stale = true;
8626 }
8627 ("ls", "stale", "directory_entry_fingerprint_changed") => saw_ls_stale = true,
8628 ("write", "uncached", "write_effect_tool") => saw_write_uncached = true,
8629 ("edit", "uncached", "write_effect_tool") => saw_edit_uncached = true,
8630 ("bash", "uncached", "process_effect_tool") => saw_bash_uncached = true,
8631 _ => {}
8632 }
8633 }
8634
8635 assert!(saw_read_hit, "evidence must include a read cache hit");
8636 assert!(saw_grep_stale, "evidence must include grep stale bypass");
8637 assert!(saw_find_stale, "evidence must include find stale bypass");
8638 assert!(saw_ls_stale, "evidence must include ls stale bypass");
8639 assert!(saw_write_uncached, "evidence must include write uncached");
8640 assert!(saw_edit_uncached, "evidence must include edit uncached");
8641 assert!(saw_bash_uncached, "evidence must include bash uncached");
8642 Ok(())
8643 }
8644
8645 #[test]
8646 fn test_truncate_tail_zero_lines_returns_empty_output() {
8647 let result = truncate_tail("line1\nline2".to_string(), 0, 1000);
8648
8649 assert!(result.truncated);
8650 assert_eq!(result.truncated_by, Some(TruncatedBy::Lines));
8651 assert_eq!(result.output_lines, 0);
8652 assert_eq!(result.output_bytes, 0);
8653 assert!(result.content.is_empty());
8654 }
8655
8656 #[test]
8657 fn test_line_count_from_newline_count_matches_trailing_newline_semantics() {
8658 assert_eq!(line_count_from_newline_count(0, 0, false), 0);
8659 assert_eq!(line_count_from_newline_count(2, 1, true), 1);
8660 assert_eq!(line_count_from_newline_count(1, 0, false), 1);
8661 assert_eq!(line_count_from_newline_count(3, 1, false), 2);
8662 }
8663
8664 #[test]
8665 fn test_rg_match_requires_path_and_line_number() {
8666 let mut matches = Vec::new();
8667 let mut match_count = 0usize;
8668 let mut match_limit_reached = false;
8669 let scan_limit = 1;
8670
8671 let missing_line =
8672 Ok(r#"{"type":"match","data":{"path":{"text":"file.txt"}}}"#.to_string());
8673 process_rg_json_match_line(
8674 missing_line,
8675 &mut matches,
8676 &mut match_count,
8677 &mut match_limit_reached,
8678 scan_limit,
8679 );
8680 assert!(matches.is_empty());
8681 assert_eq!(match_count, 0);
8682 assert!(!match_limit_reached);
8683
8684 let valid_line = Ok(
8685 r#"{"type":"match","data":{"path":{"text":"file.txt"},"line_number":3}}"#.to_string(),
8686 );
8687 process_rg_json_match_line(
8688 valid_line,
8689 &mut matches,
8690 &mut match_count,
8691 &mut match_limit_reached,
8692 scan_limit,
8693 );
8694 assert_eq!(matches.len(), 1);
8695 assert_eq!(matches[0].1, 3);
8696 assert_eq!(match_count, 1);
8697 assert!(match_limit_reached);
8698 }
8699
8700 #[test]
8701 fn test_truncate_by_bytes() {
8702 let content = "short\nthis is a longer line\nanother".to_string();
8703 let result = truncate_head(content, 100, 15);
8704
8705 assert!(result.truncated);
8706 assert_eq!(result.truncated_by, Some(TruncatedBy::Bytes));
8707 }
8708
8709 #[cfg(any(target_os = "linux", target_os = "freebsd"))]
8710 #[test]
8711 fn test_command_with_default_sigpipe_restores_pipe_disposition() {
8712 #[cfg(target_os = "freebsd")]
8719 let status_dir = {
8720 let probe = format!("/compat/linux/proc/{}/status", std::process::id());
8721 if !std::path::Path::new(&probe).exists() {
8722 eprintln!(
8723 "skipping sigpipe disposition test: linprocfs not mounted \
8724 at /compat/linux/proc — add `linprocfs /compat/linux/proc \
8725 linprocfs rw 0 0` to /etc/fstab and `mount /compat/linux/proc` \
8726 to enable"
8727 );
8728 return;
8729 }
8730 "/compat/linux/proc"
8731 };
8732 #[cfg(not(target_os = "freebsd"))]
8733 let status_dir = "/proc";
8734
8735 let probe_cmd = format!(
8736 "while read name value _; do [ \"$name\" = SigIgn: ] && \
8737 {{ printf '%s' \"$value\"; exit 0; }}; done < {status_dir}/$$/status"
8738 );
8739
8740 let output = command_with_default_sigpipe("sh")
8741 .expect("prepare sigpipe disposition probe")
8742 .args(["-c", &probe_cmd])
8743 .stdout(std::process::Stdio::piped())
8744 .output()
8745 .expect("spawn sigpipe disposition probe");
8746
8747 assert!(output.status.success(), "probe failed: {output:?}");
8748 let sigign = String::from_utf8(output.stdout).expect("SigIgn should be utf8");
8749 let ignored_mask =
8750 u64::from_str_radix(sigign.trim(), 16).expect("SigIgn should be a hex mask");
8751 let sigpipe_bit = 1_u64 << (13 - 1);
8752 assert_eq!(
8753 ignored_mask & sigpipe_bit,
8754 0,
8755 "child should not inherit ignored SIGPIPE: SigIgn={sigign}"
8756 );
8757 }
8758
8759 #[cfg(unix)]
8760 #[test]
8761 fn test_command_with_default_sigpipe_in_dir_resolves_relative_program_after_cwd() {
8762 use std::os::unix::fs::PermissionsExt as _;
8763
8764 let tmp = tempfile::tempdir().expect("create temp dir");
8765 let script = tmp.path().join("relative-probe");
8766 std::fs::write(&script, "#!/bin/sh\nprintf cwd-relative-ok\n").expect("write script");
8767 let mut permissions = std::fs::metadata(&script)
8768 .expect("stat script")
8769 .permissions();
8770 permissions.set_mode(0o755);
8771 std::fs::set_permissions(&script, permissions).expect("make script executable");
8772
8773 let output = command_with_default_sigpipe_in_dir("./relative-probe", tmp.path())
8774 .expect("prepare relative executable")
8775 .current_dir(tmp.path())
8776 .stdout(std::process::Stdio::piped())
8777 .output()
8778 .expect("spawn relative executable");
8779
8780 assert!(output.status.success(), "probe failed: {output:?}");
8781 assert_eq!(
8782 String::from_utf8(output.stdout).expect("probe stdout should be utf8"),
8783 "cwd-relative-ok"
8784 );
8785 }
8786
8787 #[cfg(target_os = "linux")]
8788 #[test]
8789 fn test_read_to_end_capped_and_drain_preserves_writer_exit_status() {
8790 let mut child = std::process::Command::new("dd")
8791 .args(["if=/dev/zero", "bs=1", "count=70000", "status=none"])
8792 .stdout(std::process::Stdio::piped())
8793 .spawn()
8794 .expect("spawn dd");
8795
8796 let stdout = child.stdout.take().expect("dd stdout");
8797 let captured = read_to_end_capped_and_drain(stdout, 1024).expect("capture bounded stdout");
8798 let status = child.wait().expect("wait for dd");
8799
8800 assert!(
8801 status.success(),
8802 "bounded reader should drain to EOF instead of SIGPIPEing the writer: {status:?}"
8803 );
8804 assert_eq!(captured.len(), 1025);
8805 }
8806
8807 #[cfg(unix)]
8808 #[test]
8809 fn test_get_file_lines_async_unreadable_file_returns_empty() {
8810 asupersync::test_utils::run_test(|| async {
8811 use std::os::unix::fs::PermissionsExt;
8812
8813 let tmp = tempfile::tempdir().unwrap();
8814 let path = tmp.path().join("secret.txt");
8815 std::fs::write(&path, "secret\n").unwrap();
8816
8817 let mut perms = std::fs::metadata(&path).unwrap().permissions();
8818 perms.set_mode(0o000);
8819 std::fs::set_permissions(&path, perms).unwrap();
8820
8821 let mut cache = HashMap::new();
8822 let lines = get_file_lines_async(&path, &mut cache).await;
8823 assert!(lines.is_empty());
8824 });
8825 }
8826
8827 #[test]
8828 fn test_resolve_path_absolute() {
8829 let cwd = PathBuf::from("/home/user/project");
8830 let result = resolve_path("/absolute/path", &cwd);
8831 assert_eq!(result, PathBuf::from("/absolute/path"));
8832 }
8833
8834 #[test]
8835 fn test_resolve_path_relative() {
8836 let cwd = PathBuf::from("/home/user/project");
8837 let result = resolve_path("src/main.rs", &cwd);
8838 assert_eq!(result, PathBuf::from("/home/user/project/src/main.rs"));
8839 }
8840
8841 #[test]
8842 fn test_normalize_dot_segments_preserves_root() {
8843 let result = normalize_dot_segments(std::path::Path::new("/../etc/passwd"));
8844 assert_eq!(result, PathBuf::from("/etc/passwd"));
8845 }
8846
8847 #[test]
8848 fn test_normalize_dot_segments_preserves_leading_parent_for_relative() {
8849 let result = normalize_dot_segments(std::path::Path::new("../a/../b"));
8850 assert_eq!(result, PathBuf::from("../b"));
8851 }
8852
8853 #[test]
8854 fn test_detect_supported_image_mime_type_from_bytes() {
8855 assert_eq!(
8856 detect_supported_image_mime_type_from_bytes(b"\x89PNG\r\n\x1A\n"),
8857 Some("image/png")
8858 );
8859 assert_eq!(
8860 detect_supported_image_mime_type_from_bytes(b"\xFF\xD8\xFF"),
8861 Some("image/jpeg")
8862 );
8863 assert_eq!(
8864 detect_supported_image_mime_type_from_bytes(b"GIF89a"),
8865 Some("image/gif")
8866 );
8867 assert_eq!(
8868 detect_supported_image_mime_type_from_bytes(b"RIFF1234WEBP"),
8869 Some("image/webp")
8870 );
8871 assert_eq!(
8872 detect_supported_image_mime_type_from_bytes(b"not an image"),
8873 None
8874 );
8875 }
8876
8877 #[test]
8878 fn test_format_size() {
8879 assert_eq!(format_size(500), "500B");
8880 assert_eq!(format_size(1024), "1.0KB");
8881 assert_eq!(format_size(1536), "1.5KB");
8882 assert_eq!(format_size(1_048_576), "1.0MB");
8883 assert_eq!(format_size(1_073_741_824), "1024.0MB");
8884 }
8885
8886 #[test]
8887 fn test_js_string_length() {
8888 assert_eq!(js_string_length("hello"), 5);
8889 assert_eq!(js_string_length("😀"), 2);
8890 }
8891
8892 #[test]
8893 fn test_truncate_line() {
8894 let short = "short line";
8895 let result = truncate_line(short, 100);
8896 assert_eq!(result.text, "short line");
8897 assert!(!result.was_truncated);
8898
8899 let long = "a".repeat(600);
8900 let result = truncate_line(&long, 500);
8901 assert!(result.was_truncated);
8902 assert!(result.text.ends_with("... [truncated]"));
8903 }
8904
8905 fn get_text(content: &[ContentBlock]) -> String {
8910 content
8911 .iter()
8912 .filter_map(|block| {
8913 if let ContentBlock::Text(text) = block {
8914 Some(text.text.clone())
8915 } else {
8916 None
8917 }
8918 })
8919 .collect::<String>()
8920 }
8921
8922 #[test]
8927 fn test_read_valid_file() {
8928 asupersync::test_utils::run_test(|| async {
8929 let tmp = tempfile::tempdir().unwrap();
8930 std::fs::write(tmp.path().join("hello.txt"), "alpha\nbeta\ngamma").unwrap();
8931
8932 let tool = ReadTool::new(tmp.path());
8933 let out = tool
8934 .execute(
8935 "t",
8936 serde_json::json!({ "path": tmp.path().join("hello.txt").to_string_lossy() }),
8937 None,
8938 )
8939 .await
8940 .unwrap();
8941 let text = get_text(&out.content);
8942 assert!(text.contains("alpha"));
8943 assert!(text.contains("beta"));
8944 assert!(text.contains("gamma"));
8945 assert!(!out.is_error);
8946 });
8947 }
8948
8949 #[test]
8950 fn test_read_nonexistent_file() {
8951 asupersync::test_utils::run_test(|| async {
8952 let tmp = tempfile::tempdir().unwrap();
8953 let tool = ReadTool::new(tmp.path());
8954 let err = tool
8955 .execute(
8956 "t",
8957 serde_json::json!({ "path": tmp.path().join("nope.txt").to_string_lossy() }),
8958 None,
8959 )
8960 .await;
8961 assert!(err.is_err());
8962 });
8963 }
8964
8965 #[test]
8966 fn test_read_rejects_outside_cwd() {
8967 asupersync::test_utils::run_test(|| async {
8968 let cwd = tempfile::tempdir().unwrap();
8969 let outside = tempfile::tempdir().unwrap();
8970 std::fs::write(outside.path().join("secret.txt"), "secret").unwrap();
8971
8972 let tool = ReadTool::new(cwd.path());
8973 let err = tool
8974 .execute(
8975 "t",
8976 serde_json::json!({ "path": outside.path().join("secret.txt").to_string_lossy() }),
8977 None,
8978 )
8979 .await
8980 .unwrap_err();
8981 assert!(err.to_string().contains("outside the working directory"));
8982 });
8983 }
8984
8985 #[test]
8992 fn test_enforce_read_scope_allows_agent_dir_outside_cwd() {
8993 let cwd = tempfile::tempdir().unwrap();
8994 let agent_dir = tempfile::tempdir().unwrap();
8995 let skill_dir = agent_dir.path().join("skills").join("freebsd-jails");
8996 std::fs::create_dir_all(&skill_dir).unwrap();
8997 let skill_path = skill_dir.join("SKILL.md");
8998 std::fs::write(&skill_path, "---\nname: test\n---\n# body\n").unwrap();
8999
9000 let resolved =
9001 enforce_read_scope_with_roots(&skill_path, cwd.path(), agent_dir.path()).unwrap();
9002 assert!(
9003 resolved.starts_with(
9004 agent_dir
9005 .path()
9006 .canonicalize()
9007 .unwrap_or_else(|_| agent_dir.path().to_path_buf())
9008 ),
9009 "agent-dir path must be allowed and returned canonicalised"
9010 );
9011 }
9012
9013 #[test]
9014 fn test_enforce_read_scope_still_rejects_unrelated_paths() {
9015 let cwd = tempfile::tempdir().unwrap();
9017 let agent_dir = tempfile::tempdir().unwrap();
9018 let unrelated = tempfile::tempdir().unwrap();
9019 std::fs::write(unrelated.path().join("secret.txt"), "secret").unwrap();
9020 let secret_path = unrelated.path().join("secret.txt");
9021
9022 let err =
9023 enforce_read_scope_with_roots(&secret_path, cwd.path(), agent_dir.path()).unwrap_err();
9024 let msg = err.to_string();
9025 assert!(
9026 msg.contains("outside the working directory") && msg.contains("agent dir"),
9027 "error must mention both denied roots, got: {msg}"
9028 );
9029 }
9030
9031 #[test]
9032 fn test_enforce_read_scope_prefers_cwd_when_path_is_under_cwd() {
9033 let cwd = tempfile::tempdir().unwrap();
9036 let agent_dir = tempfile::tempdir().unwrap();
9037 std::fs::write(cwd.path().join("a.txt"), "in cwd").unwrap();
9038
9039 let resolved =
9040 enforce_read_scope_with_roots(&cwd.path().join("a.txt"), cwd.path(), agent_dir.path())
9041 .unwrap();
9042 assert!(
9043 resolved.starts_with(
9044 cwd.path()
9045 .canonicalize()
9046 .unwrap_or_else(|_| cwd.path().to_path_buf())
9047 )
9048 );
9049 }
9050
9051 #[test]
9052 fn test_read_empty_file() {
9053 asupersync::test_utils::run_test(|| async {
9054 let tmp = tempfile::tempdir().unwrap();
9055 std::fs::write(tmp.path().join("empty.txt"), "").unwrap();
9056
9057 let tool = ReadTool::new(tmp.path());
9058 let out = tool
9059 .execute(
9060 "t",
9061 serde_json::json!({ "path": tmp.path().join("empty.txt").to_string_lossy() }),
9062 None,
9063 )
9064 .await
9065 .unwrap();
9066 let text = get_text(&out.content);
9067 assert_eq!(text, "");
9068 assert!(!out.is_error);
9069 });
9070 }
9071
9072 #[test]
9073 fn test_read_empty_file_positive_offset_errors() {
9074 asupersync::test_utils::run_test(|| async {
9075 let tmp = tempfile::tempdir().unwrap();
9076 std::fs::write(tmp.path().join("empty.txt"), "").unwrap();
9077
9078 let tool = ReadTool::new(tmp.path());
9079 let err = tool
9080 .execute(
9081 "t",
9082 serde_json::json!({
9083 "path": tmp.path().join("empty.txt").to_string_lossy(),
9084 "offset": 1
9085 }),
9086 None,
9087 )
9088 .await;
9089 assert!(err.is_err());
9090 let msg = err.unwrap_err().to_string();
9091 assert!(msg.contains("beyond end of file"));
9092 });
9093 }
9094
9095 #[test]
9096 fn test_read_rejects_zero_limit() {
9097 asupersync::test_utils::run_test(|| async {
9098 let tmp = tempfile::tempdir().unwrap();
9099 std::fs::write(tmp.path().join("lines.txt"), "a\nb\nc\n").unwrap();
9100
9101 let tool = ReadTool::new(tmp.path());
9102 let err = tool
9103 .execute(
9104 "t",
9105 serde_json::json!({
9106 "path": tmp.path().join("lines.txt").to_string_lossy(),
9107 "limit": 0
9108 }),
9109 None,
9110 )
9111 .await;
9112 assert!(err.is_err());
9113 assert!(
9114 err.unwrap_err()
9115 .to_string()
9116 .contains("`limit` must be greater than 0")
9117 );
9118 });
9119 }
9120
9121 #[test]
9122 fn test_read_offset_and_limit() {
9123 asupersync::test_utils::run_test(|| async {
9124 let tmp = tempfile::tempdir().unwrap();
9125 std::fs::write(
9126 tmp.path().join("lines.txt"),
9127 "L1\nL2\nL3\nL4\nL5\nL6\nL7\nL8\nL9\nL10",
9128 )
9129 .unwrap();
9130
9131 let tool = ReadTool::new(tmp.path());
9132 let out = tool
9133 .execute(
9134 "t",
9135 serde_json::json!({
9136 "path": tmp.path().join("lines.txt").to_string_lossy(),
9137 "offset": 3,
9138 "limit": 2
9139 }),
9140 None,
9141 )
9142 .await
9143 .unwrap();
9144 let text = get_text(&out.content);
9145 assert!(text.contains("L3"));
9146 assert!(text.contains("L4"));
9147 assert!(!text.contains("L2"));
9148 assert!(!text.contains("L5"));
9149 });
9150 }
9151
9152 #[test]
9153 fn test_read_offset_and_limit_with_cr_only_line_endings() {
9154 asupersync::test_utils::run_test(|| async {
9155 let tmp = tempfile::tempdir().unwrap();
9156 std::fs::write(tmp.path().join("lines.txt"), b"L1\rL2\rL3\r").unwrap();
9157
9158 let tool = ReadTool::new(tmp.path());
9159 let out = tool
9160 .execute(
9161 "t",
9162 serde_json::json!({
9163 "path": tmp.path().join("lines.txt").to_string_lossy(),
9164 "offset": 2,
9165 "limit": 1
9166 }),
9167 None,
9168 )
9169 .await
9170 .unwrap();
9171 let text = get_text(&out.content);
9172 assert!(text.contains("L2"));
9173 assert!(!text.contains("L1"));
9174 assert!(!text.contains("L3"));
9175 assert!(text.contains("offset=3"));
9176 assert!(!text.contains('\r'));
9177 });
9178 }
9179
9180 #[test]
9181 fn test_read_offset_and_limit_with_split_crlf_chunk_boundary() {
9182 asupersync::test_utils::run_test(|| async {
9183 let tmp = tempfile::tempdir().unwrap();
9184 let mut content = vec![b'x'; (64 * 1024) - 1];
9185 content.extend_from_slice(b"\r\nSECOND\r\nTHIRD");
9186 std::fs::write(tmp.path().join("lines.txt"), content).unwrap();
9187
9188 let tool = ReadTool::new(tmp.path());
9189 let out = tool
9190 .execute(
9191 "t",
9192 serde_json::json!({
9193 "path": tmp.path().join("lines.txt").to_string_lossy(),
9194 "offset": 2,
9195 "limit": 1
9196 }),
9197 None,
9198 )
9199 .await
9200 .unwrap();
9201 let text = get_text(&out.content);
9202 assert!(text.contains("SECOND"));
9203 assert!(!text.contains("THIRD"));
9204 assert!(!text.contains("xxxx"));
9205 assert!(text.contains("offset=3"));
9206 });
9207 }
9208
9209 #[test]
9210 fn test_read_offset_beyond_eof() {
9211 asupersync::test_utils::run_test(|| async {
9212 let tmp = tempfile::tempdir().unwrap();
9213 std::fs::write(tmp.path().join("short.txt"), "a\nb").unwrap();
9214
9215 let tool = ReadTool::new(tmp.path());
9216 let err = tool
9217 .execute(
9218 "t",
9219 serde_json::json!({
9220 "path": tmp.path().join("short.txt").to_string_lossy(),
9221 "offset": 100
9222 }),
9223 None,
9224 )
9225 .await;
9226 assert!(err.is_err());
9227 let msg = err.unwrap_err().to_string();
9228 assert!(msg.contains("beyond end of file"));
9229 });
9230 }
9231
9232 #[test]
9233 fn test_map_normalized_with_trailing_whitespace() {
9234 let content = "A \nB";
9236 let normalized = build_normalized_content(content);
9237 assert_eq!(normalized, "A\nB");
9238
9239 let (start, len) = map_normalized_range_to_original(content, 0, 1);
9241 assert_eq!(start, 0);
9242 assert_eq!(len, 1);
9243 assert_eq!(&content[start..start + len], "A");
9244
9245 let (start, len) = map_normalized_range_to_original(content, 1, 1);
9247 assert_eq!(start, 4);
9248 assert_eq!(len, 1);
9249 assert_eq!(&content[start..start + len], "\n");
9250
9251 let (start, len) = map_normalized_range_to_original(content, 2, 1);
9253 assert_eq!(start, 5);
9254 assert_eq!(len, 1);
9255 assert_eq!(&content[start..start + len], "B");
9256 }
9257
9258 #[test]
9259 fn test_read_binary_file_lossy() {
9260 asupersync::test_utils::run_test(|| async {
9261 let tmp = tempfile::tempdir().unwrap();
9262 let binary_data: Vec<u8> = (0..=255).collect();
9263 std::fs::write(tmp.path().join("binary.bin"), &binary_data).unwrap();
9264
9265 let tool = ReadTool::new(tmp.path());
9266 let out = tool
9267 .execute(
9268 "t",
9269 serde_json::json!({ "path": tmp.path().join("binary.bin").to_string_lossy() }),
9270 None,
9271 )
9272 .await
9273 .unwrap();
9274 let text = get_text(&out.content);
9276 assert!(!text.is_empty());
9277 assert!(!out.is_error);
9278 });
9279 }
9280
9281 #[test]
9282 fn test_read_image_detection() {
9283 asupersync::test_utils::run_test(|| async {
9284 let tmp = tempfile::tempdir().unwrap();
9285 let png_header: Vec<u8> = vec![
9287 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,
9291 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,
9297 ];
9298 std::fs::write(tmp.path().join("test.png"), &png_header).unwrap();
9299
9300 let tool = ReadTool::new(tmp.path());
9301 let out = tool
9302 .execute(
9303 "t",
9304 serde_json::json!({ "path": tmp.path().join("test.png").to_string_lossy() }),
9305 None,
9306 )
9307 .await
9308 .unwrap();
9309
9310 let has_image = out
9312 .content
9313 .iter()
9314 .any(|b| matches!(b, ContentBlock::Image(_)));
9315 assert!(has_image, "expected image content block for PNG file");
9316 });
9317 }
9318
9319 #[cfg(feature = "image-resize")]
9320 #[test]
9321 fn test_read_resizes_large_source_image_before_api_limit_check() {
9322 asupersync::test_utils::run_test(|| async {
9323 use image::codecs::png::PngEncoder;
9324 use image::{ExtendedColorType, ImageEncoder, Rgb, RgbImage};
9325
9326 let tmp = tempfile::tempdir().unwrap();
9327 let image = RgbImage::from_fn(2600, 2600, |x, y| {
9328 let seed = x.wrapping_mul(1_973)
9329 ^ y.wrapping_mul(9_277)
9330 ^ x.rotate_left(7)
9331 ^ y.rotate_left(13);
9332 Rgb([
9333 u8::try_from(seed % 256).unwrap_or(0),
9334 u8::try_from((seed >> 8) % 256).unwrap_or(0),
9335 u8::try_from((seed >> 16) % 256).unwrap_or(0),
9336 ])
9337 });
9338
9339 let mut png_bytes = Vec::new();
9340 PngEncoder::new(&mut png_bytes)
9341 .write_image(
9342 image.as_raw(),
9343 image.width(),
9344 image.height(),
9345 ExtendedColorType::Rgb8,
9346 )
9347 .unwrap();
9348
9349 assert!(
9350 png_bytes.len() > IMAGE_MAX_BYTES,
9351 "fixture must exceed API image limit to exercise resize path"
9352 );
9353 assert!(
9354 png_bytes.len() < usize::try_from(READ_TOOL_MAX_BYTES).unwrap_or(usize::MAX),
9355 "fixture must stay within read-tool input bound"
9356 );
9357
9358 let image_path = tmp.path().join("large.png");
9359 std::fs::write(&image_path, &png_bytes).unwrap();
9360
9361 let tool = ReadTool::new(tmp.path());
9362 let out = tool
9363 .execute(
9364 "t",
9365 serde_json::json!({ "path": image_path.to_string_lossy() }),
9366 None,
9367 )
9368 .await
9369 .unwrap();
9370
9371 assert!(!out.is_error, "resizable large images should succeed");
9372 assert!(
9373 out.content
9374 .iter()
9375 .any(|block| matches!(block, ContentBlock::Image(_))),
9376 "expected an image attachment after resizing"
9377 );
9378
9379 let text = get_text(&out.content);
9380 assert!(text.contains("Read image file"));
9381 assert!(
9382 text.contains("displayed at"),
9383 "expected resize note in read output, got: {text}"
9384 );
9385 });
9386 }
9387
9388 #[test]
9389 fn test_read_blocked_images() {
9390 asupersync::test_utils::run_test(|| async {
9391 let tmp = tempfile::tempdir().unwrap();
9392 let png_header: Vec<u8> =
9393 vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00];
9394 std::fs::write(tmp.path().join("test.png"), &png_header).unwrap();
9395
9396 let tool = ReadTool::with_settings(tmp.path(), false, true);
9397 let err = tool
9398 .execute(
9399 "t",
9400 serde_json::json!({ "path": tmp.path().join("test.png").to_string_lossy() }),
9401 None,
9402 )
9403 .await;
9404 assert!(err.is_err());
9405 assert!(err.unwrap_err().to_string().contains("blocked"));
9406 });
9407 }
9408
9409 #[test]
9410 fn test_read_truncation_at_max_lines() {
9411 asupersync::test_utils::run_test(|| async {
9412 let tmp = tempfile::tempdir().unwrap();
9413 let content: String = (0..DEFAULT_MAX_LINES + 500)
9414 .map(|i| format!("line {i}"))
9415 .collect::<Vec<_>>()
9416 .join("\n");
9417 std::fs::write(tmp.path().join("big.txt"), &content).unwrap();
9418
9419 let tool = ReadTool::new(tmp.path());
9420 let out = tool
9421 .execute(
9422 "t",
9423 serde_json::json!({ "path": tmp.path().join("big.txt").to_string_lossy() }),
9424 None,
9425 )
9426 .await
9427 .unwrap();
9428 assert!(out.details.is_some(), "expected truncation details");
9430 let text = get_text(&out.content);
9431 assert!(text.contains("offset="));
9432 });
9433 }
9434
9435 #[test]
9436 fn test_read_first_line_exceeds_max_bytes() {
9437 asupersync::test_utils::run_test(|| async {
9438 let tmp = tempfile::tempdir().unwrap();
9439 let long_line = "a".repeat(DEFAULT_MAX_BYTES + 128);
9440 std::fs::write(tmp.path().join("too_long.txt"), long_line).unwrap();
9441
9442 let tool = ReadTool::new(tmp.path());
9443 let out = tool
9444 .execute(
9445 "t",
9446 serde_json::json!({ "path": tmp.path().join("too_long.txt").to_string_lossy() }),
9447 None,
9448 )
9449 .await
9450 .unwrap();
9451
9452 let text = get_text(&out.content);
9453 let expected_limit = format!("exceeds {} limit", format_size(DEFAULT_MAX_BYTES));
9454 assert!(
9455 text.contains(&expected_limit),
9456 "expected limit hint '{expected_limit}', got: {text}"
9457 );
9458 let details = out.details.expect("expected truncation details");
9459 assert_eq!(
9460 details
9461 .get("truncation")
9462 .and_then(|v| v.get("firstLineExceedsLimit"))
9463 .and_then(serde_json::Value::as_bool),
9464 Some(true)
9465 );
9466 });
9467 }
9468
9469 #[test]
9470 fn test_read_unicode_content() {
9471 asupersync::test_utils::run_test(|| async {
9472 let tmp = tempfile::tempdir().unwrap();
9473 std::fs::write(tmp.path().join("uni.txt"), "Hello 你好 🌍\nLine 2 café").unwrap();
9474
9475 let tool = ReadTool::new(tmp.path());
9476 let out = tool
9477 .execute(
9478 "t",
9479 serde_json::json!({ "path": tmp.path().join("uni.txt").to_string_lossy() }),
9480 None,
9481 )
9482 .await
9483 .unwrap();
9484 let text = get_text(&out.content);
9485 assert!(text.contains("你好"));
9486 assert!(text.contains("🌍"));
9487 assert!(text.contains("café"));
9488 });
9489 }
9490
9491 #[test]
9496 fn test_write_new_file() {
9497 asupersync::test_utils::run_test(|| async {
9498 let tmp = tempfile::tempdir().unwrap();
9499 let tool = WriteTool::new(tmp.path());
9500 let out = tool
9501 .execute(
9502 "t",
9503 serde_json::json!({
9504 "path": tmp.path().join("new.txt").to_string_lossy(),
9505 "content": "hello world"
9506 }),
9507 None,
9508 )
9509 .await
9510 .unwrap();
9511 assert!(!out.is_error);
9512 let contents = std::fs::read_to_string(tmp.path().join("new.txt")).unwrap();
9513 assert_eq!(contents, "hello world");
9514 });
9515 }
9516
9517 #[test]
9518 fn test_write_overwrite_existing() {
9519 asupersync::test_utils::run_test(|| async {
9520 let tmp = tempfile::tempdir().unwrap();
9521 std::fs::write(tmp.path().join("exist.txt"), "old content").unwrap();
9522
9523 let tool = WriteTool::new(tmp.path());
9524 let out = tool
9525 .execute(
9526 "t",
9527 serde_json::json!({
9528 "path": tmp.path().join("exist.txt").to_string_lossy(),
9529 "content": "new content"
9530 }),
9531 None,
9532 )
9533 .await
9534 .unwrap();
9535 assert!(!out.is_error);
9536 let contents = std::fs::read_to_string(tmp.path().join("exist.txt")).unwrap();
9537 assert_eq!(contents, "new content");
9538 });
9539 }
9540
9541 #[test]
9542 fn test_write_creates_parent_dirs() {
9543 asupersync::test_utils::run_test(|| async {
9544 let tmp = tempfile::tempdir().unwrap();
9545 let tool = WriteTool::new(tmp.path());
9546 let deep_path = tmp.path().join("a/b/c/deep.txt");
9547 let out = tool
9548 .execute(
9549 "t",
9550 serde_json::json!({
9551 "path": deep_path.to_string_lossy(),
9552 "content": "deep file"
9553 }),
9554 None,
9555 )
9556 .await
9557 .unwrap();
9558 assert!(!out.is_error);
9559 assert!(deep_path.exists());
9560 assert_eq!(std::fs::read_to_string(&deep_path).unwrap(), "deep file");
9561 });
9562 }
9563
9564 #[test]
9565 fn test_write_empty_file() {
9566 asupersync::test_utils::run_test(|| async {
9567 let tmp = tempfile::tempdir().unwrap();
9568 let tool = WriteTool::new(tmp.path());
9569 let out = tool
9570 .execute(
9571 "t",
9572 serde_json::json!({
9573 "path": tmp.path().join("empty.txt").to_string_lossy(),
9574 "content": ""
9575 }),
9576 None,
9577 )
9578 .await
9579 .unwrap();
9580 assert!(!out.is_error);
9581 let contents = std::fs::read_to_string(tmp.path().join("empty.txt")).unwrap();
9582 assert_eq!(contents, "");
9583 let text = get_text(&out.content);
9584 assert!(text.contains("Successfully wrote 0 bytes"));
9585 });
9586 }
9587
9588 #[test]
9589 fn test_write_rejects_outside_cwd() {
9590 asupersync::test_utils::run_test(|| async {
9591 let cwd = tempfile::tempdir().unwrap();
9592 let outside = tempfile::tempdir().unwrap();
9593 let tool = WriteTool::new(cwd.path());
9594 let err = tool
9595 .execute(
9596 "t",
9597 serde_json::json!({
9598 "path": outside.path().join("escape.txt").to_string_lossy(),
9599 "content": "nope"
9600 }),
9601 None,
9602 )
9603 .await
9604 .unwrap_err();
9605 assert!(err.to_string().contains("outside the working directory"));
9606
9607 let err = tool
9608 .execute(
9609 "t",
9610 serde_json::json!({
9611 "path": "../escape.txt",
9612 "content": "nope"
9613 }),
9614 None,
9615 )
9616 .await
9617 .unwrap_err();
9618 assert!(err.to_string().contains("outside the working directory"));
9619 });
9620 }
9621
9622 #[test]
9623 fn test_write_unicode_content() {
9624 asupersync::test_utils::run_test(|| async {
9625 let tmp = tempfile::tempdir().unwrap();
9626 let tool = WriteTool::new(tmp.path());
9627 let out = tool
9628 .execute(
9629 "t",
9630 serde_json::json!({
9631 "path": tmp.path().join("unicode.txt").to_string_lossy(),
9632 "content": "日本語 🎉 Ñoño"
9633 }),
9634 None,
9635 )
9636 .await
9637 .unwrap();
9638 assert!(!out.is_error);
9639 let contents = std::fs::read_to_string(tmp.path().join("unicode.txt")).unwrap();
9640 assert_eq!(contents, "日本語 🎉 Ñoño");
9641 });
9642 }
9643
9644 #[test]
9645 #[cfg(unix)]
9646 fn test_write_file_permissions_unix() {
9647 use std::os::unix::fs::PermissionsExt;
9648 asupersync::test_utils::run_test(|| async {
9649 let tmp = tempfile::tempdir().unwrap();
9650 let tool = WriteTool::new(tmp.path());
9651 let path = tmp.path().join("perms.txt");
9652 let out = tool
9653 .execute(
9654 "t",
9655 serde_json::json!({
9656 "path": path.to_string_lossy(),
9657 "content": "check perms"
9658 }),
9659 None,
9660 )
9661 .await
9662 .unwrap();
9663 assert!(!out.is_error);
9664
9665 let meta = std::fs::metadata(&path).unwrap();
9666 let mode = meta.permissions().mode();
9667 assert_eq!(
9668 mode & 0o777,
9669 0o644,
9670 "Expected default 0o644 permissions for new files"
9671 );
9672 });
9673 }
9674
9675 #[test]
9680 fn test_edit_exact_match_replace() {
9681 asupersync::test_utils::run_test(|| async {
9682 let tmp = tempfile::tempdir().unwrap();
9683 std::fs::write(tmp.path().join("code.rs"), "fn foo() { bar() }").unwrap();
9684
9685 let tool = EditTool::new(tmp.path());
9686 let out = tool
9687 .execute(
9688 "t",
9689 serde_json::json!({
9690 "path": tmp.path().join("code.rs").to_string_lossy(),
9691 "oldText": "bar()",
9692 "newText": "baz()"
9693 }),
9694 None,
9695 )
9696 .await
9697 .unwrap();
9698 assert!(!out.is_error);
9699 let contents = std::fs::read_to_string(tmp.path().join("code.rs")).unwrap();
9700 assert_eq!(contents, "fn foo() { baz() }");
9701 });
9702 }
9703
9704 #[test]
9705 fn test_edit_no_match_error() {
9706 asupersync::test_utils::run_test(|| async {
9707 let tmp = tempfile::tempdir().unwrap();
9708 std::fs::write(tmp.path().join("code.rs"), "fn foo() {}").unwrap();
9709
9710 let tool = EditTool::new(tmp.path());
9711 let err = tool
9712 .execute(
9713 "t",
9714 serde_json::json!({
9715 "path": tmp.path().join("code.rs").to_string_lossy(),
9716 "oldText": "NONEXISTENT TEXT",
9717 "newText": "replacement"
9718 }),
9719 None,
9720 )
9721 .await;
9722 assert!(err.is_err());
9723 });
9724 }
9725
9726 #[test]
9727 fn test_edit_empty_old_text_error() {
9728 asupersync::test_utils::run_test(|| async {
9729 let tmp = tempfile::tempdir().unwrap();
9730 let path = tmp.path().join("code.rs");
9731 std::fs::write(&path, "fn foo() {}").unwrap();
9732
9733 let tool = EditTool::new(tmp.path());
9734 let err = tool
9735 .execute(
9736 "t",
9737 serde_json::json!({
9738 "path": path.to_string_lossy(),
9739 "oldText": "",
9740 "newText": "prefix"
9741 }),
9742 None,
9743 )
9744 .await
9745 .expect_err("empty oldText should be rejected");
9746
9747 let msg = err.to_string();
9748 assert!(
9749 msg.contains("old text cannot be empty"),
9750 "unexpected error: {msg}"
9751 );
9752 let after = std::fs::read_to_string(path).unwrap();
9753 assert_eq!(after, "fn foo() {}");
9754 });
9755 }
9756
9757 #[test]
9758 fn test_edit_ambiguous_match_error() {
9759 asupersync::test_utils::run_test(|| async {
9760 let tmp = tempfile::tempdir().unwrap();
9761 std::fs::write(tmp.path().join("dup.txt"), "hello hello hello").unwrap();
9762
9763 let tool = EditTool::new(tmp.path());
9764 let err = tool
9765 .execute(
9766 "t",
9767 serde_json::json!({
9768 "path": tmp.path().join("dup.txt").to_string_lossy(),
9769 "oldText": "hello",
9770 "newText": "world"
9771 }),
9772 None,
9773 )
9774 .await;
9775 assert!(err.is_err(), "expected error for ambiguous match");
9776 });
9777 }
9778
9779 #[test]
9780 fn test_edit_multi_line_replacement() {
9781 asupersync::test_utils::run_test(|| async {
9782 let tmp = tempfile::tempdir().unwrap();
9783 std::fs::write(
9784 tmp.path().join("multi.txt"),
9785 "line 1\nline 2\nline 3\nline 4",
9786 )
9787 .unwrap();
9788
9789 let tool = EditTool::new(tmp.path());
9790 let out = tool
9791 .execute(
9792 "t",
9793 serde_json::json!({
9794 "path": tmp.path().join("multi.txt").to_string_lossy(),
9795 "oldText": "line 2\nline 3",
9796 "newText": "replaced 2\nreplaced 3\nextra line"
9797 }),
9798 None,
9799 )
9800 .await
9801 .unwrap();
9802 assert!(!out.is_error);
9803 let contents = std::fs::read_to_string(tmp.path().join("multi.txt")).unwrap();
9804 assert_eq!(
9805 contents,
9806 "line 1\nreplaced 2\nreplaced 3\nextra line\nline 4"
9807 );
9808 });
9809 }
9810
9811 #[test]
9812 fn test_edit_unicode_content() {
9813 asupersync::test_utils::run_test(|| async {
9814 let tmp = tempfile::tempdir().unwrap();
9815 std::fs::write(tmp.path().join("uni.txt"), "Héllo wörld 🌍").unwrap();
9816
9817 let tool = EditTool::new(tmp.path());
9818 let out = tool
9819 .execute(
9820 "t",
9821 serde_json::json!({
9822 "path": tmp.path().join("uni.txt").to_string_lossy(),
9823 "oldText": "wörld 🌍",
9824 "newText": "Welt 🌎"
9825 }),
9826 None,
9827 )
9828 .await
9829 .unwrap();
9830 assert!(!out.is_error);
9831 let contents = std::fs::read_to_string(tmp.path().join("uni.txt")).unwrap();
9832 assert_eq!(contents, "Héllo Welt 🌎");
9833 });
9834 }
9835
9836 #[test]
9837 fn test_edit_missing_file() {
9838 asupersync::test_utils::run_test(|| async {
9839 let tmp = tempfile::tempdir().unwrap();
9840 let tool = EditTool::new(tmp.path());
9841 let err = tool
9842 .execute(
9843 "t",
9844 serde_json::json!({
9845 "path": tmp.path().join("nope.txt").to_string_lossy(),
9846 "oldText": "foo",
9847 "newText": "bar"
9848 }),
9849 None,
9850 )
9851 .await;
9852 assert!(err.is_err());
9853 });
9854 }
9855
9856 struct FailingReader {
9861 responses: std::collections::VecDeque<std::io::Result<Vec<u8>>>,
9862 }
9863
9864 impl FailingReader {
9865 fn new(responses: impl IntoIterator<Item = std::io::Result<Vec<u8>>>) -> Self {
9866 Self {
9867 responses: responses.into_iter().collect(),
9868 }
9869 }
9870 }
9871
9872 impl Read for FailingReader {
9873 fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
9874 match self.responses.pop_front().unwrap_or_else(|| Ok(Vec::new())) {
9875 Ok(bytes) => {
9876 assert!(
9877 bytes.len() <= buf.len(),
9878 "test reader only supports single-chunk reads"
9879 );
9880 buf[..bytes.len()].copy_from_slice(&bytes);
9881 Ok(bytes.len())
9882 }
9883 Err(err) => Err(err),
9884 }
9885 }
9886 }
9887
9888 #[test]
9889 fn test_bash_simple_command() {
9890 asupersync::test_utils::run_test(|| async {
9891 let tmp = tempfile::tempdir().unwrap();
9892 let tool = BashTool::new(tmp.path());
9893 let out = tool
9894 .execute(
9895 "t",
9896 serde_json::json!({ "command": "echo hello_from_bash" }),
9897 None,
9898 )
9899 .await
9900 .unwrap();
9901 let text = get_text(&out.content);
9902 assert!(text.contains("hello_from_bash"));
9903 assert!(!out.is_error);
9904 });
9905 }
9906
9907 #[test]
9908 fn test_bash_exit_code_nonzero() {
9909 asupersync::test_utils::run_test(|| async {
9910 let tmp = tempfile::tempdir().unwrap();
9911 let tool = BashTool::new(tmp.path());
9912 let out = tool
9913 .execute("t", serde_json::json!({ "command": "exit 42" }), None)
9914 .await
9915 .expect("non-zero exit should return Ok with is_error=true");
9916 assert!(out.is_error, "non-zero exit must set is_error");
9917 let msg = get_text(&out.content);
9918 assert!(
9919 msg.contains("42"),
9920 "expected exit code 42 in output, got: {msg}"
9921 );
9922 });
9923 }
9924
9925 #[cfg(unix)]
9926 #[test]
9927 fn test_bash_signal_termination_is_error() {
9928 asupersync::test_utils::run_test(|| async {
9929 let tmp = tempfile::tempdir().unwrap();
9930 let tool = BashTool::new(tmp.path());
9931 let out = tool
9932 .execute("t", serde_json::json!({ "command": "kill -KILL $$" }), None)
9933 .await
9934 .expect("signal-terminated shell should return Ok with is_error=true");
9935 assert!(
9936 out.is_error,
9937 "signal-terminated shell must be reported as error"
9938 );
9939 let msg = get_text(&out.content);
9940 assert!(
9941 msg.contains("Command exited with code"),
9942 "expected explicit exit-code report, got: {msg}"
9943 );
9944 assert!(
9945 !msg.contains("Command exited with code 0"),
9946 "signal-terminated shell must not appear successful: {msg}"
9947 );
9948 });
9949 }
9950
9951 #[test]
9952 fn test_bash_stderr_capture() {
9953 asupersync::test_utils::run_test(|| async {
9954 let tmp = tempfile::tempdir().unwrap();
9955 let tool = BashTool::new(tmp.path());
9956 let out = tool
9957 .execute(
9958 "t",
9959 serde_json::json!({ "command": "echo stderr_msg >&2" }),
9960 None,
9961 )
9962 .await
9963 .unwrap();
9964 let text = get_text(&out.content);
9965 assert!(
9966 text.contains("stderr_msg"),
9967 "expected stderr output in result, got: {text}"
9968 );
9969 });
9970 }
9971
9972 #[test]
9973 fn test_bash_timeout() {
9974 asupersync::test_utils::run_test(|| async {
9975 let tmp = tempfile::tempdir().unwrap();
9976 let tool = BashTool::new(tmp.path());
9977 let out = tool
9978 .execute(
9979 "t",
9980 serde_json::json!({ "command": "sleep 60", "timeout": 2 }),
9981 None,
9982 )
9983 .await
9984 .expect("timeout should return Ok with is_error=true");
9985 assert!(out.is_error, "timeout must set is_error");
9986 let msg = get_text(&out.content);
9987 assert!(
9988 msg.to_lowercase().contains("timeout") || msg.to_lowercase().contains("timed out"),
9989 "expected timeout indication, got: {msg}"
9990 );
9991 let cancellation = out
9992 .details
9993 .as_ref()
9994 .and_then(|details| details.get("cancellation"))
9995 .expect("timeout should include structured cancellation details");
9996 assert_eq!(cancellation["schema"], BASH_CANCELLATION_SCHEMA_V1);
9997 assert_eq!(cancellation["status"], "cancelled");
9998 assert_eq!(cancellation["reason"], "timeout");
9999 assert_eq!(cancellation["cleanup"], "process_group_tree_terminated");
10000 assert_eq!(cancellation["timeoutMs"], 2000);
10001 });
10002 }
10003
10004 #[cfg(target_os = "linux")]
10005 #[test]
10006 fn test_bash_timeout_kills_process_tree() {
10007 asupersync::test_utils::run_test(|| async {
10008 let tmp = tempfile::tempdir().unwrap();
10009 let marker = tmp.path().join("leaked_child.txt");
10010 let tool = BashTool::new(tmp.path());
10011
10012 let out = tool
10013 .execute(
10014 "t",
10015 serde_json::json!({
10016 "command": "(sleep 3; echo leaked > leaked_child.txt) & sleep 10",
10017 "timeout": 1
10018 }),
10019 None,
10020 )
10021 .await
10022 .expect("timeout should return Ok with is_error=true");
10023
10024 assert!(out.is_error, "timeout must set is_error");
10025 let msg = get_text(&out.content);
10026 assert!(msg.contains("Command timed out"));
10027
10028 std::thread::sleep(Duration::from_secs(4));
10030 assert!(
10031 !marker.exists(),
10032 "background child was not terminated on timeout"
10033 );
10034 });
10035 }
10036
10037 #[cfg(target_os = "linux")]
10038 #[test]
10039 fn test_bash_cancelled_context_kills_process_tree() {
10040 asupersync::test_utils::run_test(|| async {
10041 let tmp = tempfile::tempdir().unwrap();
10042 let marker = tmp.path().join("leaked_child.txt");
10043
10044 let ambient_cx = asupersync::Cx::for_testing();
10045 let cancel_cx = ambient_cx.clone();
10046 let _current = asupersync::Cx::set_current(Some(ambient_cx));
10047
10048 let cancel_thread = std::thread::spawn(move || {
10049 std::thread::sleep(Duration::from_millis(100));
10050 cancel_cx.set_cancel_requested(true);
10051 });
10052
10053 let result = run_bash_command(
10054 tmp.path(),
10055 None,
10056 None,
10057 "(sleep 3; echo leaked > leaked_child.txt) & sleep 10",
10058 Some(30),
10059 None,
10060 )
10061 .await
10062 .expect("cancelled bash should return a result");
10063
10064 cancel_thread.join().expect("cancel thread");
10065
10066 assert!(
10067 result.cancelled,
10068 "expected cancelled bash result: {result:?}"
10069 );
10070 assert_eq!(
10071 result.cancellation_reason,
10072 Some(BashCancellationReason::AmbientCancellation)
10073 );
10074
10075 std::thread::sleep(Duration::from_secs(4));
10076 assert!(
10077 !marker.exists(),
10078 "background child was not terminated on cancellation"
10079 );
10080 });
10081 }
10082
10083 #[test]
10084 fn test_bash_pump_stream_emits_io_error_frame_after_partial_output() {
10085 let reader = FailingReader::new([
10086 Ok(b"partial stdout".to_vec()),
10087 Err(std::io::Error::other("simulated stdout failure")),
10088 ]);
10089 let (tx, rx) = mpsc::sync_channel::<BashPipeFrame>(4);
10090
10091 pump_stream(reader, "stdout", &tx);
10092
10093 match rx.recv().expect("partial chunk") {
10094 BashPipeFrame::Chunk(chunk) => assert_eq!(chunk, b"partial stdout"),
10095 BashPipeFrame::Error(message) => {
10096 unreachable!("expected output chunk before error, got error frame: {message}")
10097 }
10098 }
10099
10100 match rx.recv().expect("io error frame") {
10101 BashPipeFrame::Chunk(chunk) => {
10102 unreachable!("expected io error after partial chunk, got chunk: {chunk:?}")
10103 }
10104 BashPipeFrame::Error(message) => {
10105 assert!(message.contains("Failed to read bash stdout"));
10106 assert!(message.contains("simulated stdout failure"));
10107 }
10108 }
10109
10110 assert!(matches!(rx.try_recv(), Err(mpsc::TryRecvError::Empty)));
10111 }
10112
10113 #[test]
10114 fn test_drain_bash_output_ignores_cancellation_after_process_exit() {
10115 asupersync::test_utils::run_test(|| async {
10116 let (tx, mut rx) = mpsc::sync_channel::<BashPipeFrame>(1);
10117 let mut bash_output = BashOutputState::new(DEFAULT_MAX_BYTES);
10118
10119 let ambient_cx = asupersync::Cx::for_testing();
10120 ambient_cx.set_cancel_requested(true);
10121 let _current = asupersync::Cx::set_current(Some(ambient_cx));
10122 let cx = AgentCx::for_current_or_request();
10123 let now = cx
10124 .cx()
10125 .timer_driver()
10126 .map_or_else(wall_now, |timer| timer.now());
10127
10128 let cancelled = drain_bash_output(
10129 &mut rx,
10130 &mut bash_output,
10131 &cx,
10132 now + std::time::Duration::from_millis(10),
10133 std::time::Duration::from_millis(1),
10134 false,
10135 )
10136 .await
10137 .expect("drain should complete without cancellation");
10138
10139 drop(tx);
10140
10141 assert!(
10142 !cancelled,
10143 "post-exit drain should ignore late ambient cancellation"
10144 );
10145 assert_eq!(bash_output.total_bytes, 0);
10146 });
10147 }
10148
10149 #[test]
10150 fn test_drain_bash_output_returns_pipe_read_error() {
10151 asupersync::test_utils::run_test(|| async {
10152 let (tx, mut rx) = mpsc::sync_channel::<BashPipeFrame>(2);
10153 tx.send(BashPipeFrame::Chunk(b"partial stderr".to_vec()))
10154 .expect("queue partial output");
10155 tx.send(BashPipeFrame::Error(
10156 "Failed to read bash stderr: simulated stderr failure".to_string(),
10157 ))
10158 .expect("queue error frame");
10159 drop(tx);
10160
10161 let mut bash_output = BashOutputState::new(DEFAULT_MAX_BYTES);
10162 let cx = AgentCx::for_current_or_request();
10163 let now = cx
10164 .cx()
10165 .timer_driver()
10166 .map_or_else(wall_now, |timer| timer.now());
10167
10168 let err = drain_bash_output(
10169 &mut rx,
10170 &mut bash_output,
10171 &cx,
10172 now + std::time::Duration::from_millis(10),
10173 std::time::Duration::from_millis(1),
10174 false,
10175 )
10176 .await
10177 .expect_err("pipe read failures must surface as errors");
10178
10179 let message = err.to_string();
10180 assert!(message.contains("Failed to read bash stderr"));
10181 assert!(message.contains("simulated stderr failure"));
10182 assert!(message.contains("Partial output before failure"));
10183 assert!(message.contains("partial stderr"));
10184 assert_eq!(bash_output.total_bytes, "partial stderr".len());
10185 });
10186 }
10187
10188 #[test]
10189 fn test_drain_bash_output_honors_cancellation_while_process_still_active() {
10190 asupersync::test_utils::run_test(|| async {
10191 let (_tx, mut rx) = mpsc::sync_channel::<BashPipeFrame>(1);
10192 let mut bash_output = BashOutputState::new(DEFAULT_MAX_BYTES);
10193
10194 let ambient_cx = asupersync::Cx::for_testing();
10195 ambient_cx.set_cancel_requested(true);
10196 let _current = asupersync::Cx::set_current(Some(ambient_cx));
10197 let cx = AgentCx::for_current_or_request();
10198 let now = cx
10199 .cx()
10200 .timer_driver()
10201 .map_or_else(wall_now, |timer| timer.now());
10202
10203 let cancelled = drain_bash_output(
10204 &mut rx,
10205 &mut bash_output,
10206 &cx,
10207 now + std::time::Duration::from_secs(1),
10208 std::time::Duration::from_millis(1),
10209 true,
10210 )
10211 .await
10212 .expect("drain should complete under cancellation");
10213
10214 assert!(
10215 cancelled,
10216 "active drain should still honor ambient cancellation"
10217 );
10218 assert_eq!(bash_output.total_bytes, 0);
10219 });
10220 }
10221
10222 #[test]
10223 fn test_bash_output_state_abandon_spill_file_clears_path_and_unlinks_file() {
10224 let tmp = tempfile::tempdir().unwrap();
10225 let spill_path = tmp.path().join("partial-bash.log");
10226 std::fs::write(&spill_path, b"partial output").unwrap();
10227
10228 let mut bash_output = BashOutputState::new(DEFAULT_MAX_BYTES);
10229 bash_output.temp_file_path = Some(spill_path.clone());
10230
10231 bash_output.abandon_spill_file();
10232
10233 assert!(bash_output.spill_failed);
10234 assert!(bash_output.temp_file.is_none());
10235 assert!(bash_output.temp_file_path.is_none());
10236 assert!(
10237 !spill_path.exists(),
10238 "abandoned spill files should not be advertised or left behind"
10239 );
10240 }
10241
10242 #[test]
10243 fn test_bash_hard_limit_retains_partial_spill_file() {
10244 asupersync::test_utils::run_test(|| async {
10245 let tmp = tempfile::tempdir().unwrap();
10246 let spill_path = tmp.path().join("hard-limit-bash.log");
10247 std::fs::write(&spill_path, b"partial output").unwrap();
10248
10249 let spill_file = asupersync::fs::OpenOptions::new()
10250 .append(true)
10251 .open(&spill_path)
10252 .await
10253 .unwrap();
10254
10255 let mut bash_output = BashOutputState::new(DEFAULT_MAX_BYTES);
10256 bash_output.total_bytes = BASH_FILE_LIMIT_BYTES;
10257 bash_output.temp_file_path = Some(spill_path.clone());
10258 bash_output.temp_file = Some(spill_file);
10259
10260 ingest_bash_chunk(vec![b'x'], &mut bash_output)
10261 .await
10262 .expect("hard-limit ingestion should still succeed");
10263
10264 assert!(!bash_output.spill_failed);
10265 assert!(bash_output.temp_file.is_none());
10266 assert!(bash_output.temp_file_path.is_some());
10267 assert!(
10268 spill_path.exists(),
10269 "partial spill files must be retained once the hard limit is reached for diagnostics"
10270 );
10271 });
10272 }
10273
10274 #[test]
10275 #[cfg(unix)]
10276 fn test_bash_working_directory() {
10277 asupersync::test_utils::run_test(|| async {
10278 let tmp = tempfile::tempdir().unwrap();
10279 let tool = BashTool::new(tmp.path());
10280 let out = tool
10281 .execute("t", serde_json::json!({ "command": "pwd" }), None)
10282 .await
10283 .unwrap();
10284 let text = get_text(&out.content);
10285 let canonical = tmp.path().canonicalize().unwrap();
10286 assert!(
10287 text.contains(&canonical.to_string_lossy().to_string()),
10288 "expected cwd in output, got: {text}"
10289 );
10290 });
10291 }
10292
10293 #[test]
10294 fn test_bash_multiline_output() {
10295 asupersync::test_utils::run_test(|| async {
10296 let tmp = tempfile::tempdir().unwrap();
10297 let tool = BashTool::new(tmp.path());
10298 let out = tool
10299 .execute(
10300 "t",
10301 serde_json::json!({ "command": "echo line1; echo line2; echo line3" }),
10302 None,
10303 )
10304 .await
10305 .unwrap();
10306 let text = get_text(&out.content);
10307 assert!(text.contains("line1"));
10308 assert!(text.contains("line2"));
10309 assert!(text.contains("line3"));
10310 });
10311 }
10312
10313 #[test]
10318 fn test_grep_basic_pattern() {
10319 asupersync::test_utils::run_test(|| async {
10320 let tmp = tempfile::tempdir().unwrap();
10321 std::fs::write(
10322 tmp.path().join("search.txt"),
10323 "apple\nbanana\napricot\ncherry",
10324 )
10325 .unwrap();
10326
10327 let tool = GrepTool::new(tmp.path());
10328 let out = tool
10329 .execute(
10330 "t",
10331 serde_json::json!({
10332 "pattern": "ap",
10333 "path": tmp.path().join("search.txt").to_string_lossy()
10334 }),
10335 None,
10336 )
10337 .await
10338 .unwrap();
10339 let text = get_text(&out.content);
10340 assert!(text.contains("apple"));
10341 assert!(text.contains("apricot"));
10342 assert!(!text.contains("banana"));
10343 assert!(!text.contains("cherry"));
10344 });
10345 }
10346
10347 #[test]
10348 fn test_grep_rejects_outside_cwd() {
10349 asupersync::test_utils::run_test(|| async {
10350 let cwd = tempfile::tempdir().unwrap();
10351 let outside = tempfile::tempdir().unwrap();
10352 std::fs::write(outside.path().join("secret.txt"), "secret").unwrap();
10353
10354 let tool = GrepTool::new(cwd.path());
10355 let err = tool
10356 .execute(
10357 "t",
10358 serde_json::json!({
10359 "pattern": "secret",
10360 "path": outside.path().join("secret.txt").to_string_lossy()
10361 }),
10362 None,
10363 )
10364 .await
10365 .unwrap_err();
10366 assert!(err.to_string().contains("outside the working directory"));
10367 });
10368 }
10369
10370 #[test]
10371 fn test_grep_rejects_zero_limit() {
10372 asupersync::test_utils::run_test(|| async {
10373 let tmp = tempfile::tempdir().unwrap();
10374 std::fs::write(tmp.path().join("search.txt"), "alpha\nbeta\n").unwrap();
10375
10376 let tool = GrepTool::new(tmp.path());
10377 let err = tool
10378 .execute(
10379 "t",
10380 serde_json::json!({
10381 "pattern": "alpha",
10382 "path": tmp.path().join("search.txt").to_string_lossy(),
10383 "limit": 0
10384 }),
10385 None,
10386 )
10387 .await
10388 .unwrap_err();
10389 assert!(err.to_string().contains("`limit` must be greater than 0"));
10390 });
10391 }
10392
10393 #[test]
10394 #[cfg(unix)]
10395 fn test_grep_formats_paths_relative_to_symlinked_cwd() {
10396 asupersync::test_utils::run_test(|| async {
10397 let real = tempfile::tempdir().unwrap();
10398 let link_parent = tempfile::tempdir().unwrap();
10399 let link = link_parent.path().join("linked-cwd");
10400 std::os::unix::fs::symlink(real.path(), &link).unwrap();
10401 std::fs::write(real.path().join("needle.txt"), "needle\n").unwrap();
10402
10403 let tool = GrepTool::new(&link);
10404 let out = tool
10405 .execute("t", serde_json::json!({ "pattern": "needle" }), None)
10406 .await
10407 .unwrap();
10408
10409 let text = get_text(&out.content);
10410 assert!(
10411 text.contains("needle.txt:1: needle"),
10412 "grep output should use cwd-relative paths for symlinked cwd, got: {text}"
10413 );
10414 assert!(
10415 !text.contains(real.path().to_string_lossy().as_ref()),
10416 "grep output should not leak canonical temp root, got: {text}"
10417 );
10418 });
10419 }
10420
10421 #[test]
10422 fn test_grep_regex_pattern() {
10423 asupersync::test_utils::run_test(|| async {
10424 let tmp = tempfile::tempdir().unwrap();
10425 std::fs::write(
10426 tmp.path().join("regex.txt"),
10427 "foo123\nbar456\nbaz789\nfoo000",
10428 )
10429 .unwrap();
10430
10431 let tool = GrepTool::new(tmp.path());
10432 let out = tool
10433 .execute(
10434 "t",
10435 serde_json::json!({
10436 "pattern": "foo\\d+",
10437 "path": tmp.path().join("regex.txt").to_string_lossy()
10438 }),
10439 None,
10440 )
10441 .await
10442 .unwrap();
10443 let text = get_text(&out.content);
10444 assert!(text.contains("foo123"));
10445 assert!(text.contains("foo000"));
10446 assert!(!text.contains("bar456"));
10447 });
10448 }
10449
10450 #[test]
10451 fn test_grep_case_insensitive() {
10452 asupersync::test_utils::run_test(|| async {
10453 let tmp = tempfile::tempdir().unwrap();
10454 std::fs::write(tmp.path().join("case.txt"), "Hello\nhello\nHELLO").unwrap();
10455
10456 let tool = GrepTool::new(tmp.path());
10457 let out = tool
10458 .execute(
10459 "t",
10460 serde_json::json!({
10461 "pattern": "hello",
10462 "path": tmp.path().join("case.txt").to_string_lossy(),
10463 "ignoreCase": true
10464 }),
10465 None,
10466 )
10467 .await
10468 .unwrap();
10469 let text = get_text(&out.content);
10470 assert!(text.contains("Hello"));
10471 assert!(text.contains("hello"));
10472 assert!(text.contains("HELLO"));
10473 });
10474 }
10475
10476 #[test]
10477 fn test_grep_case_sensitive_by_default() {
10478 asupersync::test_utils::run_test(|| async {
10479 let tmp = tempfile::tempdir().unwrap();
10480 std::fs::write(tmp.path().join("case_sensitive.txt"), "Hello\nHELLO").unwrap();
10481
10482 let tool = GrepTool::new(tmp.path());
10483 let out = tool
10484 .execute(
10485 "t",
10486 serde_json::json!({
10487 "pattern": "hello",
10488 "path": tmp.path().join("case_sensitive.txt").to_string_lossy()
10489 }),
10490 None,
10491 )
10492 .await
10493 .unwrap();
10494 let text = get_text(&out.content);
10495 assert!(
10496 text.contains("No matches found"),
10497 "expected case-sensitive search to find no matches, got: {text}"
10498 );
10499 });
10500 }
10501
10502 #[test]
10503 fn test_grep_append_non_matching_lines_invariant() {
10504 asupersync::test_utils::run_test(|| async {
10505 let tmp = tempfile::tempdir().unwrap();
10506 let file = tmp.path().join("base.txt");
10507 std::fs::write(&file, "needle one\nskip\nneedle two\n").unwrap();
10508
10509 let tool = GrepTool::new(tmp.path());
10510 let base_out = tool
10511 .execute(
10512 "t",
10513 serde_json::json!({
10514 "pattern": "needle",
10515 "path": file.to_string_lossy(),
10516 "limit": 100
10517 }),
10518 None,
10519 )
10520 .await
10521 .unwrap();
10522 let base_text = get_text(&base_out.content);
10523
10524 std::fs::write(&file, "needle one\nskip\nneedle two\nalpha\nbeta\n").unwrap();
10525 let extended_out = tool
10526 .execute(
10527 "t",
10528 serde_json::json!({
10529 "pattern": "needle",
10530 "path": file.to_string_lossy(),
10531 "limit": 100
10532 }),
10533 None,
10534 )
10535 .await
10536 .unwrap();
10537 let extended_text = get_text(&extended_out.content);
10538
10539 assert_eq!(
10540 base_text, extended_text,
10541 "adding non-matching lines should not alter grep output"
10542 );
10543 });
10544 }
10545
10546 #[test]
10547 fn test_grep_no_matches() {
10548 asupersync::test_utils::run_test(|| async {
10549 let tmp = tempfile::tempdir().unwrap();
10550 std::fs::write(tmp.path().join("nothing.txt"), "alpha\nbeta\ngamma").unwrap();
10551
10552 let tool = GrepTool::new(tmp.path());
10553 let out = tool
10554 .execute(
10555 "t",
10556 serde_json::json!({
10557 "pattern": "ZZZZZ_NOMATCH",
10558 "path": tmp.path().join("nothing.txt").to_string_lossy()
10559 }),
10560 None,
10561 )
10562 .await
10563 .unwrap();
10564 let text = get_text(&out.content);
10565 assert!(
10566 text.to_lowercase().contains("no match")
10567 || text.is_empty()
10568 || text.to_lowercase().contains("no results"),
10569 "expected no-match indication, got: {text}"
10570 );
10571 });
10572 }
10573
10574 #[test]
10575 fn test_grep_context_lines() {
10576 asupersync::test_utils::run_test(|| async {
10577 let tmp = tempfile::tempdir().unwrap();
10578 std::fs::write(
10579 tmp.path().join("ctx.txt"),
10580 "aaa\nbbb\nccc\ntarget\nddd\neee\nfff",
10581 )
10582 .unwrap();
10583
10584 let tool = GrepTool::new(tmp.path());
10585 let out = tool
10586 .execute(
10587 "t",
10588 serde_json::json!({
10589 "pattern": "target",
10590 "path": tmp.path().join("ctx.txt").to_string_lossy(),
10591 "context": 1
10592 }),
10593 None,
10594 )
10595 .await
10596 .unwrap();
10597 let text = get_text(&out.content);
10598 assert!(text.contains("target"));
10599 assert!(text.contains("ccc"), "expected context line before match");
10600 assert!(text.contains("ddd"), "expected context line after match");
10601 });
10602 }
10603
10604 #[test]
10605 fn test_grep_limit() {
10606 asupersync::test_utils::run_test(|| async {
10607 let tmp = tempfile::tempdir().unwrap();
10608 let content: String = (0..200)
10609 .map(|i| format!("match_line_{i}"))
10610 .collect::<Vec<_>>()
10611 .join("\n");
10612 std::fs::write(tmp.path().join("many.txt"), &content).unwrap();
10613
10614 let tool = GrepTool::new(tmp.path());
10615 let out = tool
10616 .execute(
10617 "t",
10618 serde_json::json!({
10619 "pattern": "match_line",
10620 "path": tmp.path().join("many.txt").to_string_lossy(),
10621 "limit": 5
10622 }),
10623 None,
10624 )
10625 .await
10626 .unwrap();
10627 let text = get_text(&out.content);
10628 let match_count = text.matches("match_line_").count();
10630 assert!(
10631 match_count <= 5,
10632 "expected at most 5 matches with limit=5, got {match_count}"
10633 );
10634 let details = out.details.expect("expected limit details");
10635 assert_eq!(
10636 details
10637 .get("matchLimitReached")
10638 .and_then(serde_json::Value::as_u64),
10639 Some(5)
10640 );
10641 });
10642 }
10643
10644 #[test]
10645 fn test_grep_exact_limit_does_not_report_limit_reached() {
10646 asupersync::test_utils::run_test(|| async {
10647 let tmp = tempfile::tempdir().unwrap();
10648 let content = (0..5)
10649 .map(|i| format!("match_line_{i}"))
10650 .collect::<Vec<_>>()
10651 .join("\n");
10652 std::fs::write(tmp.path().join("exact.txt"), &content).unwrap();
10653
10654 let tool = GrepTool::new(tmp.path());
10655 let out = tool
10656 .execute(
10657 "t",
10658 serde_json::json!({
10659 "pattern": "match_line",
10660 "path": tmp.path().join("exact.txt").to_string_lossy(),
10661 "limit": 5
10662 }),
10663 None,
10664 )
10665 .await
10666 .unwrap();
10667
10668 let text = get_text(&out.content);
10669 assert_eq!(text.matches("match_line_").count(), 5);
10670 assert!(
10671 !text.contains("matches limit reached"),
10672 "exact-limit grep results should not claim truncation: {text}"
10673 );
10674 assert!(
10675 out.details
10676 .as_ref()
10677 .and_then(|details| details.get("matchLimitReached"))
10678 .is_none(),
10679 "exact-limit grep results should not set matchLimitReached"
10680 );
10681 });
10682 }
10683
10684 #[test]
10685 fn test_grep_large_output_does_not_deadlock_reader_threads() {
10686 asupersync::test_utils::run_test(|| async {
10687 use std::fmt::Write as _;
10688
10689 let tmp = tempfile::tempdir().unwrap();
10690 let mut content = String::with_capacity(80_000);
10691 for i in 0..5000 {
10692 let _ = writeln!(&mut content, "needle_line_{i}");
10693 }
10694 let file = tmp.path().join("large_grep.txt");
10695 std::fs::write(&file, content).unwrap();
10696
10697 let tool = GrepTool::new(tmp.path());
10698 let run = tool.execute(
10699 "t",
10700 serde_json::json!({
10701 "pattern": "needle_line_",
10702 "path": file.to_string_lossy(),
10703 "limit": 6000
10704 }),
10705 None,
10706 );
10707
10708 let out = asupersync::time::timeout(
10709 asupersync::time::wall_now(),
10710 Duration::from_secs(15),
10711 Box::pin(run),
10712 )
10713 .await
10714 .expect("grep timed out; possible stdout/stderr reader deadlock")
10715 .expect("grep should succeed");
10716
10717 let text = get_text(&out.content);
10718 assert!(text.contains("needle_line_0"));
10719 });
10720 }
10721
10722 #[test]
10723 fn test_grep_respects_gitignore() {
10724 asupersync::test_utils::run_test(|| async {
10725 let tmp = tempfile::tempdir().unwrap();
10726 std::fs::write(tmp.path().join(".gitignore"), "ignored.txt\n").unwrap();
10727 std::fs::write(tmp.path().join("ignored.txt"), "needle in ignored file").unwrap();
10728 std::fs::write(tmp.path().join("visible.txt"), "nothing here").unwrap();
10729
10730 let tool = GrepTool::new(tmp.path());
10731 let out = tool
10732 .execute("t", serde_json::json!({ "pattern": "needle" }), None)
10733 .await
10734 .unwrap();
10735
10736 let text = get_text(&out.content);
10737 assert!(
10738 text.contains("No matches found"),
10739 "expected ignored file to be excluded, got: {text}"
10740 );
10741 });
10742 }
10743
10744 #[test]
10745 fn test_grep_literal_mode() {
10746 asupersync::test_utils::run_test(|| async {
10747 let tmp = tempfile::tempdir().unwrap();
10748 std::fs::write(tmp.path().join("literal.txt"), "a+b\na.b\nab\na\\+b").unwrap();
10749
10750 let tool = GrepTool::new(tmp.path());
10751 let out = tool
10752 .execute(
10753 "t",
10754 serde_json::json!({
10755 "pattern": "a+b",
10756 "path": tmp.path().join("literal.txt").to_string_lossy(),
10757 "literal": true
10758 }),
10759 None,
10760 )
10761 .await
10762 .unwrap();
10763 let text = get_text(&out.content);
10764 assert!(text.contains("a+b"), "literal match should find 'a+b'");
10765 });
10766 }
10767
10768 #[test]
10769 fn test_grep_hashline_output() {
10770 asupersync::test_utils::run_test(|| async {
10771 let tmp = tempfile::tempdir().unwrap();
10772 std::fs::write(
10773 tmp.path().join("hash.txt"),
10774 "apple\nbanana\napricot\ncherry",
10775 )
10776 .unwrap();
10777
10778 let tool = GrepTool::new(tmp.path());
10779 let out = tool
10780 .execute(
10781 "t",
10782 serde_json::json!({
10783 "pattern": "ap",
10784 "path": tmp.path().join("hash.txt").to_string_lossy(),
10785 "hashline": true
10786 }),
10787 None,
10788 )
10789 .await
10790 .unwrap();
10791 let text = get_text(&out.content);
10792 assert!(text.contains("apple"), "should contain apple");
10795 assert!(text.contains("apricot"), "should contain apricot");
10796 assert!(
10797 !text.contains("banana"),
10798 "should not contain banana context"
10799 );
10800 let re = regex::Regex::new(r"\d+#[A-Z]{2}").unwrap();
10802 assert!(
10803 re.is_match(&text),
10804 "hashline output should contain N#AB tags, got: {text}"
10805 );
10806 });
10807 }
10808
10809 #[test]
10810 fn test_grep_hashline_with_context() {
10811 asupersync::test_utils::run_test(|| async {
10812 let tmp = tempfile::tempdir().unwrap();
10813 std::fs::write(
10814 tmp.path().join("ctx.txt"),
10815 "line1\nline2\ntarget\nline4\nline5",
10816 )
10817 .unwrap();
10818
10819 let tool = GrepTool::new(tmp.path());
10820 let out = tool
10821 .execute(
10822 "t",
10823 serde_json::json!({
10824 "pattern": "target",
10825 "path": tmp.path().join("ctx.txt").to_string_lossy(),
10826 "hashline": true,
10827 "context": 1
10828 }),
10829 None,
10830 )
10831 .await
10832 .unwrap();
10833 let text = get_text(&out.content);
10834 assert!(text.contains("line2"), "should contain context line2");
10836 assert!(text.contains("target"), "should contain match");
10837 assert!(text.contains("line4"), "should contain context line4");
10838 let re_match = regex::Regex::new(r"\d+#[A-Z]{2}: target").unwrap();
10840 assert!(
10841 re_match.is_match(&text),
10842 "match line should use : separator with hashline tag, got: {text}"
10843 );
10844 let re_ctx = regex::Regex::new(r"\d+#[A-Z]{2}- line").unwrap();
10845 assert!(
10846 re_ctx.is_match(&text),
10847 "context line should use - separator with hashline tag, got: {text}"
10848 );
10849 });
10850 }
10851
10852 #[test]
10857 fn test_find_glob_pattern() {
10858 asupersync::test_utils::run_test(|| async {
10859 if find_fd_binary().is_none() {
10860 return;
10861 }
10862 let tmp = tempfile::tempdir().unwrap();
10863 std::fs::write(tmp.path().join("file1.rs"), "").unwrap();
10864 std::fs::write(tmp.path().join("file2.rs"), "").unwrap();
10865 std::fs::write(tmp.path().join("file3.txt"), "").unwrap();
10866
10867 let tool = FindTool::new(tmp.path());
10868 let out = tool
10869 .execute(
10870 "t",
10871 serde_json::json!({
10872 "pattern": "*.rs",
10873 "path": tmp.path().to_string_lossy()
10874 }),
10875 None,
10876 )
10877 .await
10878 .unwrap();
10879 let text = get_text(&out.content);
10880 assert!(text.contains("file1.rs"));
10881 assert!(text.contains("file2.rs"));
10882 assert!(!text.contains("file3.txt"));
10883 });
10884 }
10885
10886 #[test]
10887 fn test_find_append_non_matching_file_invariant() {
10888 asupersync::test_utils::run_test(|| async {
10889 if find_fd_binary().is_none() {
10890 return;
10891 }
10892 let tmp = tempfile::tempdir().unwrap();
10893 std::fs::write(tmp.path().join("match.txt"), "a").unwrap();
10894
10895 let tool = FindTool::new(tmp.path());
10896 let base_out = tool
10897 .execute(
10898 "t",
10899 serde_json::json!({
10900 "pattern": "*.txt",
10901 "path": tmp.path().to_string_lossy()
10902 }),
10903 None,
10904 )
10905 .await
10906 .unwrap();
10907 let base_text = get_text(&base_out.content);
10908
10909 std::fs::write(tmp.path().join("ignore.md"), "b").unwrap();
10910 let extended_out = tool
10911 .execute(
10912 "t",
10913 serde_json::json!({
10914 "pattern": "*.txt",
10915 "path": tmp.path().to_string_lossy()
10916 }),
10917 None,
10918 )
10919 .await
10920 .unwrap();
10921 let extended_text = get_text(&extended_out.content);
10922
10923 assert_eq!(
10924 base_text, extended_text,
10925 "adding non-matching files should not alter find output"
10926 );
10927 });
10928 }
10929
10930 #[test]
10931 fn test_find_rejects_outside_cwd() {
10932 asupersync::test_utils::run_test(|| async {
10933 let cwd = tempfile::tempdir().unwrap();
10934 let outside = tempfile::tempdir().unwrap();
10935 std::fs::write(outside.path().join("secret.txt"), "secret").unwrap();
10936
10937 let tool = FindTool::new(cwd.path());
10938 let err = tool
10939 .execute(
10940 "t",
10941 serde_json::json!({
10942 "pattern": "*.txt",
10943 "path": outside.path().to_string_lossy()
10944 }),
10945 None,
10946 )
10947 .await
10948 .unwrap_err();
10949 assert!(err.to_string().contains("outside the working directory"));
10950 });
10951 }
10952
10953 #[test]
10954 fn test_find_limit() {
10955 asupersync::test_utils::run_test(|| async {
10956 if find_fd_binary().is_none() {
10957 return;
10958 }
10959 let tmp = tempfile::tempdir().unwrap();
10960 for i in 0..20 {
10961 std::fs::write(tmp.path().join(format!("f{i}.txt")), "").unwrap();
10962 }
10963
10964 let tool = FindTool::new(tmp.path());
10965 let out = tool
10966 .execute(
10967 "t",
10968 serde_json::json!({
10969 "pattern": "*.txt",
10970 "path": tmp.path().to_string_lossy(),
10971 "limit": 5
10972 }),
10973 None,
10974 )
10975 .await
10976 .unwrap();
10977 let text = get_text(&out.content);
10978 let file_count = text.lines().filter(|l| l.contains(".txt")).count();
10979 assert!(
10980 file_count <= 5,
10981 "expected at most 5 files with limit=5, got {file_count}"
10982 );
10983 let details = out.details.expect("expected limit details");
10984 assert_eq!(
10985 details
10986 .get("resultLimitReached")
10987 .and_then(serde_json::Value::as_u64),
10988 Some(5)
10989 );
10990 });
10991 }
10992
10993 #[test]
10994 fn test_find_exact_limit_does_not_report_limit_reached() {
10995 asupersync::test_utils::run_test(|| async {
10996 if find_fd_binary().is_none() {
10997 return;
10998 }
10999 let tmp = tempfile::tempdir().unwrap();
11000 for i in 0..5 {
11001 std::fs::write(tmp.path().join(format!("f{i}.txt")), "").unwrap();
11002 }
11003
11004 let tool = FindTool::new(tmp.path());
11005 let out = tool
11006 .execute(
11007 "t",
11008 serde_json::json!({
11009 "pattern": "*.txt",
11010 "path": tmp.path().to_string_lossy(),
11011 "limit": 5
11012 }),
11013 None,
11014 )
11015 .await
11016 .unwrap();
11017
11018 let text = get_text(&out.content);
11019 assert_eq!(text.lines().filter(|line| line.contains(".txt")).count(), 5);
11020 assert!(
11021 !text.contains("results limit reached"),
11022 "exact-limit find results should not claim truncation: {text}"
11023 );
11024 assert!(
11025 out.details
11026 .as_ref()
11027 .and_then(|details| details.get("resultLimitReached"))
11028 .is_none(),
11029 "exact-limit find results should not set resultLimitReached"
11030 );
11031 });
11032 }
11033
11034 #[test]
11035 fn test_find_zero_limit_is_rejected() {
11036 asupersync::test_utils::run_test(|| async {
11037 if find_fd_binary().is_none() {
11038 return;
11039 }
11040 let tmp = tempfile::tempdir().unwrap();
11041 std::fs::write(tmp.path().join("file.txt"), "").unwrap();
11042
11043 let tool = FindTool::new(tmp.path());
11044 let err = tool
11045 .execute(
11046 "t",
11047 serde_json::json!({
11048 "pattern": "*.txt",
11049 "path": tmp.path().to_string_lossy(),
11050 "limit": 0
11051 }),
11052 None,
11053 )
11054 .await
11055 .expect_err("limit=0 should be rejected");
11056
11057 assert!(
11058 err.to_string().contains("`limit` must be greater than 0"),
11059 "expected validation error, got: {err}"
11060 );
11061 });
11062 }
11063
11064 #[test]
11065 fn test_find_no_matches() {
11066 asupersync::test_utils::run_test(|| async {
11067 if find_fd_binary().is_none() {
11068 return;
11069 }
11070 let tmp = tempfile::tempdir().unwrap();
11071 std::fs::write(tmp.path().join("only.txt"), "").unwrap();
11072
11073 let tool = FindTool::new(tmp.path());
11074 let out = tool
11075 .execute(
11076 "t",
11077 serde_json::json!({
11078 "pattern": "*.rs",
11079 "path": tmp.path().to_string_lossy()
11080 }),
11081 None,
11082 )
11083 .await
11084 .unwrap();
11085 let text = get_text(&out.content);
11086 assert!(
11087 text.to_lowercase().contains("no files found")
11088 || text.to_lowercase().contains("no matches")
11089 || text.is_empty(),
11090 "expected no-match indication, got: {text}"
11091 );
11092 });
11093 }
11094
11095 #[test]
11096 fn test_find_nonexistent_path() {
11097 asupersync::test_utils::run_test(|| async {
11098 if find_fd_binary().is_none() {
11099 return;
11100 }
11101 let tmp = tempfile::tempdir().unwrap();
11102 let tool = FindTool::new(tmp.path());
11103 let err = tool
11104 .execute(
11105 "t",
11106 serde_json::json!({
11107 "pattern": "*.rs",
11108 "path": tmp.path().join("nonexistent").to_string_lossy()
11109 }),
11110 None,
11111 )
11112 .await;
11113 assert!(err.is_err());
11114 });
11115 }
11116
11117 #[test]
11118 fn test_find_nested_directories() {
11119 asupersync::test_utils::run_test(|| async {
11120 if find_fd_binary().is_none() {
11121 return;
11122 }
11123 let tmp = tempfile::tempdir().unwrap();
11124 std::fs::create_dir_all(tmp.path().join("a/b/c")).unwrap();
11125 std::fs::write(tmp.path().join("top.rs"), "").unwrap();
11126 std::fs::write(tmp.path().join("a/mid.rs"), "").unwrap();
11127 std::fs::write(tmp.path().join("a/b/c/deep.rs"), "").unwrap();
11128
11129 let tool = FindTool::new(tmp.path());
11130 let out = tool
11131 .execute(
11132 "t",
11133 serde_json::json!({
11134 "pattern": "*.rs",
11135 "path": tmp.path().to_string_lossy()
11136 }),
11137 None,
11138 )
11139 .await
11140 .unwrap();
11141 let text = get_text(&out.content);
11142 assert!(text.contains("top.rs"));
11143 assert!(text.contains("mid.rs"));
11144 assert!(text.contains("deep.rs"));
11145 });
11146 }
11147
11148 #[test]
11149 fn test_find_results_are_sorted() {
11150 asupersync::test_utils::run_test(|| async {
11153 if find_fd_binary().is_none() {
11154 return;
11155 }
11156 let tmp = tempfile::tempdir().unwrap();
11157
11158 std::fs::write(tmp.path().join("oldest.txt"), "").unwrap();
11161 std::thread::sleep(std::time::Duration::from_millis(50));
11162 std::fs::write(tmp.path().join("middle.txt"), "").unwrap();
11163 std::thread::sleep(std::time::Duration::from_millis(50));
11164 std::fs::write(tmp.path().join("newest.txt"), "").unwrap();
11165
11166 let tool = FindTool::new(tmp.path());
11167 let out = tool
11168 .execute(
11169 "t",
11170 serde_json::json!({
11171 "pattern": "*.txt",
11172 "path": tmp.path().to_string_lossy()
11173 }),
11174 None,
11175 )
11176 .await
11177 .unwrap();
11178 let lines: Vec<String> = get_text(&out.content)
11179 .lines()
11180 .map(str::trim)
11181 .filter(|line| !line.is_empty())
11182 .map(str::to_string)
11183 .collect();
11184
11185 assert_eq!(
11187 lines,
11188 vec!["newest.txt", "middle.txt", "oldest.txt"],
11189 "expected mtime-sorted find output (most recent first)"
11190 );
11191 });
11192 }
11193
11194 #[test]
11195 fn test_find_respects_gitignore() {
11196 asupersync::test_utils::run_test(|| async {
11197 if find_fd_binary().is_none() {
11198 return;
11199 }
11200 let tmp = tempfile::tempdir().unwrap();
11201 std::fs::write(tmp.path().join(".gitignore"), "ignored.txt\n").unwrap();
11202 std::fs::write(tmp.path().join("ignored.txt"), "").unwrap();
11203
11204 let tool = FindTool::new(tmp.path());
11205 let out = tool
11206 .execute(
11207 "t",
11208 serde_json::json!({
11209 "pattern": "*.txt",
11210 "path": tmp.path().to_string_lossy()
11211 }),
11212 None,
11213 )
11214 .await
11215 .unwrap();
11216 let text = get_text(&out.content);
11217 assert!(
11218 text.contains("No files found matching pattern"),
11219 "expected .gitignore'd files to be excluded, got: {text}"
11220 );
11221 });
11222 }
11223
11224 #[test]
11229 fn test_ls_directory_listing() {
11230 asupersync::test_utils::run_test(|| async {
11231 let tmp = tempfile::tempdir().unwrap();
11232 std::fs::write(tmp.path().join("file_a.txt"), "content").unwrap();
11233 std::fs::write(tmp.path().join("file_b.rs"), "fn main() {}").unwrap();
11234 std::fs::create_dir(tmp.path().join("subdir")).unwrap();
11235
11236 let tool = LsTool::new(tmp.path());
11237 let out = tool
11238 .execute(
11239 "t",
11240 serde_json::json!({ "path": tmp.path().to_string_lossy() }),
11241 None,
11242 )
11243 .await
11244 .unwrap();
11245 let text = get_text(&out.content);
11246 assert!(text.contains("file_a.txt"));
11247 assert!(text.contains("file_b.rs"));
11248 assert!(text.contains("subdir"));
11249 });
11250 }
11251
11252 #[test]
11253 fn test_ls_rejects_outside_cwd() {
11254 asupersync::test_utils::run_test(|| async {
11255 let cwd = tempfile::tempdir().unwrap();
11256 let outside = tempfile::tempdir().unwrap();
11257 std::fs::write(outside.path().join("secret.txt"), "secret").unwrap();
11258
11259 let tool = LsTool::new(cwd.path());
11260 let err = tool
11261 .execute(
11262 "t",
11263 serde_json::json!({ "path": outside.path().to_string_lossy() }),
11264 None,
11265 )
11266 .await
11267 .unwrap_err();
11268 assert!(err.to_string().contains("outside the working directory"));
11269 });
11270 }
11271
11272 #[test]
11273 fn test_ls_trailing_slash_for_dirs() {
11274 asupersync::test_utils::run_test(|| async {
11275 let tmp = tempfile::tempdir().unwrap();
11276 std::fs::write(tmp.path().join("file.txt"), "").unwrap();
11277 std::fs::create_dir(tmp.path().join("mydir")).unwrap();
11278
11279 let tool = LsTool::new(tmp.path());
11280 let out = tool
11281 .execute(
11282 "t",
11283 serde_json::json!({ "path": tmp.path().to_string_lossy() }),
11284 None,
11285 )
11286 .await
11287 .unwrap();
11288 let text = get_text(&out.content);
11289 assert!(
11290 text.contains("mydir/"),
11291 "expected trailing slash for directory, got: {text}"
11292 );
11293 });
11294 }
11295
11296 #[test]
11297 fn test_ls_limit() {
11298 asupersync::test_utils::run_test(|| async {
11299 let tmp = tempfile::tempdir().unwrap();
11300 for i in 0..20 {
11301 std::fs::write(tmp.path().join(format!("item_{i:02}.txt")), "").unwrap();
11302 }
11303
11304 let tool = LsTool::new(tmp.path());
11305 let out = tool
11306 .execute(
11307 "t",
11308 serde_json::json!({
11309 "path": tmp.path().to_string_lossy(),
11310 "limit": 5
11311 }),
11312 None,
11313 )
11314 .await
11315 .unwrap();
11316 let text = get_text(&out.content);
11317 let entry_count = text.lines().filter(|l| l.contains("item_")).count();
11318 assert!(
11319 entry_count <= 5,
11320 "expected at most 5 entries, got {entry_count}"
11321 );
11322 let details = out.details.expect("expected limit details");
11323 assert_eq!(
11324 details
11325 .get("entryLimitReached")
11326 .and_then(serde_json::Value::as_u64),
11327 Some(5)
11328 );
11329 });
11330 }
11331
11332 #[test]
11333 fn test_ls_zero_limit_is_rejected() {
11334 asupersync::test_utils::run_test(|| async {
11335 let tmp = tempfile::tempdir().unwrap();
11336 std::fs::write(tmp.path().join("item.txt"), "").unwrap();
11337
11338 let tool = LsTool::new(tmp.path());
11339 let err = tool
11340 .execute(
11341 "t",
11342 serde_json::json!({
11343 "path": tmp.path().to_string_lossy(),
11344 "limit": 0
11345 }),
11346 None,
11347 )
11348 .await
11349 .expect_err("limit=0 should be rejected");
11350
11351 assert!(
11352 err.to_string().contains("`limit` must be greater than 0"),
11353 "expected validation error, got: {err}"
11354 );
11355 });
11356 }
11357
11358 #[test]
11359 fn test_ls_nonexistent_directory() {
11360 asupersync::test_utils::run_test(|| async {
11361 let tmp = tempfile::tempdir().unwrap();
11362 let tool = LsTool::new(tmp.path());
11363 let err = tool
11364 .execute(
11365 "t",
11366 serde_json::json!({ "path": tmp.path().join("nope").to_string_lossy() }),
11367 None,
11368 )
11369 .await;
11370 assert!(err.is_err());
11371 });
11372 }
11373
11374 #[test]
11375 fn test_ls_empty_directory() {
11376 asupersync::test_utils::run_test(|| async {
11377 let tmp = tempfile::tempdir().unwrap();
11378 let empty_dir = tmp.path().join("empty");
11379 std::fs::create_dir(&empty_dir).unwrap();
11380
11381 let tool = LsTool::new(tmp.path());
11382 let out = tool
11383 .execute(
11384 "t",
11385 serde_json::json!({ "path": empty_dir.to_string_lossy() }),
11386 None,
11387 )
11388 .await
11389 .unwrap();
11390 assert!(!out.is_error);
11391 });
11392 }
11393
11394 #[test]
11395 fn test_ls_default_cwd() {
11396 asupersync::test_utils::run_test(|| async {
11397 let tmp = tempfile::tempdir().unwrap();
11398 std::fs::write(tmp.path().join("in_cwd.txt"), "").unwrap();
11399
11400 let tool = LsTool::new(tmp.path());
11401 let out = tool
11402 .execute("t", serde_json::json!({}), None)
11403 .await
11404 .unwrap();
11405 let text = get_text(&out.content);
11406 assert!(
11407 text.contains("in_cwd.txt"),
11408 "expected cwd listing to include the file, got: {text}"
11409 );
11410 });
11411 }
11412
11413 #[test]
11418 fn test_truncate_head_no_truncation() {
11419 let content = "short".to_string();
11420 let result = truncate_head(content, 100, 1000);
11421 assert!(!result.truncated);
11422 assert_eq!(result.content, "short");
11423 assert_eq!(result.truncated_by, None);
11424 }
11425
11426 #[test]
11427 fn test_truncate_tail_no_truncation() {
11428 let content = "short".to_string();
11429 let result = truncate_tail(content, 100, 1000);
11430 assert!(!result.truncated);
11431 assert_eq!(result.content, "short");
11432 }
11433
11434 #[test]
11435 fn test_truncate_head_empty_input() {
11436 let result = truncate_head(String::new(), 100, 1000);
11437 assert!(!result.truncated);
11438 assert_eq!(result.content, "");
11439 }
11440
11441 #[test]
11442 fn test_truncate_tail_empty_input() {
11443 let result = truncate_tail(String::new(), 100, 1000);
11444 assert!(!result.truncated);
11445 assert_eq!(result.content, "");
11446 }
11447
11448 #[test]
11449 fn test_detect_line_ending_crlf() {
11450 assert_eq!(detect_line_ending("hello\r\nworld"), "\r\n");
11451 }
11452
11453 #[test]
11454 fn test_detect_line_ending_cr() {
11455 assert_eq!(detect_line_ending("hello\rworld"), "\r");
11456 }
11457
11458 #[test]
11459 fn test_detect_line_ending_lf() {
11460 assert_eq!(detect_line_ending("hello\nworld"), "\n");
11461 }
11462
11463 #[test]
11464 fn test_detect_line_ending_no_newline() {
11465 assert_eq!(detect_line_ending("hello world"), "\n");
11466 }
11467
11468 #[test]
11469 fn test_normalize_to_lf() {
11470 assert_eq!(normalize_to_lf("a\r\nb\rc\nd"), "a\nb\nc\nd");
11471 }
11472
11473 #[test]
11474 fn test_count_overlapping_occurrences() {
11475 assert_eq!(count_overlapping_occurrences("aaaa", "aa"), 3);
11476 assert_eq!(count_overlapping_occurrences("abababa", "aba"), 3);
11477 assert_eq!(count_overlapping_occurrences("abc", "d"), 0);
11478 assert_eq!(count_overlapping_occurrences("abc", ""), 0);
11479 }
11480
11481 proptest! {
11482 #![proptest_config(ProptestConfig { cases: 64, .. ProptestConfig::default() })]
11483
11484 #[test]
11485 fn proptest_line_ending_roundtrip_invariant(
11486 input in arbitrary_text(),
11487 ending in prop_oneof![
11488 Just("\n".to_string()),
11489 Just("\r\n".to_string()),
11490 Just("\r".to_string()),
11491 ],
11492 ) {
11493 let normalized = normalize_to_lf(&input);
11494 let restored = restore_line_endings(&normalized, &ending);
11495 let renormalized = normalize_to_lf(&restored);
11496 prop_assert_eq!(renormalized, normalized);
11497 }
11498 }
11499
11500 #[test]
11501 fn test_strip_bom_present() {
11502 let (result, had_bom) = strip_bom("\u{FEFF}hello");
11503 assert_eq!(result, "hello");
11504 assert!(had_bom);
11505 }
11506
11507 #[test]
11508 fn test_strip_bom_absent() {
11509 let (result, had_bom) = strip_bom("hello");
11510 assert_eq!(result, "hello");
11511 assert!(!had_bom);
11512 }
11513
11514 #[test]
11515 fn test_resolve_path_tilde_expansion() {
11516 let cwd = PathBuf::from("/home/user/project");
11517 let result = resolve_path("~/file.txt", &cwd);
11518 assert!(!result.to_string_lossy().starts_with("~/"));
11520 }
11521
11522 fn arbitrary_text() -> impl Strategy<Value = String> {
11523 prop::collection::vec(any::<u8>(), 0..512)
11524 .prop_map(|bytes| String::from_utf8_lossy(&bytes).into_owned())
11525 }
11526
11527 fn match_char_strategy() -> impl Strategy<Value = char> {
11528 prop_oneof![
11529 8 => any::<char>(),
11530 1 => Just('\u{00A0}'),
11531 1 => Just('\u{202F}'),
11532 1 => Just('\u{205F}'),
11533 1 => Just('\u{3000}'),
11534 1 => Just('\u{2018}'),
11535 1 => Just('\u{2019}'),
11536 1 => Just('\u{201C}'),
11537 1 => Just('\u{201D}'),
11538 1 => Just('\u{201E}'),
11539 1 => Just('\u{201F}'),
11540 1 => Just('\u{2010}'),
11541 1 => Just('\u{2011}'),
11542 1 => Just('\u{2012}'),
11543 1 => Just('\u{2013}'),
11544 1 => Just('\u{2014}'),
11545 1 => Just('\u{2015}'),
11546 1 => Just('\u{2212}'),
11547 1 => Just('\u{200D}'),
11548 1 => Just('\u{0301}'),
11549 ]
11550 }
11551
11552 fn arbitrary_match_text() -> impl Strategy<Value = String> {
11553 prop_oneof![
11554 9 => prop::collection::vec(match_char_strategy(), 0..2048),
11555 1 => prop::collection::vec(match_char_strategy(), 8192..16384),
11556 ]
11557 .prop_map(|chars| chars.into_iter().collect())
11558 }
11559
11560 fn line_char_strategy() -> impl Strategy<Value = char> {
11561 prop_oneof![
11562 8 => any::<char>().prop_filter("single-line chars only", |c| *c != '\n'),
11563 1 => Just('é'),
11564 1 => Just('你'),
11565 1 => Just('😀'),
11566 ]
11567 }
11568
11569 fn boundary_line_text() -> impl Strategy<Value = String> {
11570 prop_oneof![
11571 Just(0usize),
11572 Just(GREP_MAX_LINE_LENGTH.saturating_sub(1)),
11573 Just(GREP_MAX_LINE_LENGTH),
11574 Just(GREP_MAX_LINE_LENGTH + 1),
11575 0usize..(GREP_MAX_LINE_LENGTH + 128),
11576 ]
11577 .prop_flat_map(|len| {
11578 prop::collection::vec(line_char_strategy(), len)
11579 .prop_map(|chars| chars.into_iter().collect())
11580 })
11581 }
11582
11583 fn safe_relative_segment() -> impl Strategy<Value = String> {
11584 prop_oneof![
11585 proptest::string::string_regex("[A-Za-z0-9._-]{1,12}")
11586 .expect("segment regex should compile"),
11587 Just("emoji😀".to_string()),
11588 Just("accent-é".to_string()),
11589 Just("rtl-עברית".to_string()),
11590 Just("line\nbreak".to_string()),
11591 Just("nul\0byte".to_string()),
11592 ]
11593 .prop_filter("segment cannot be . or ..", |segment| {
11594 segment != "." && segment != ".."
11595 })
11596 }
11597
11598 fn safe_relative_path() -> impl Strategy<Value = String> {
11599 prop::collection::vec(safe_relative_segment(), 1..6).prop_map(|segments| segments.join("/"))
11600 }
11601
11602 fn pathish_input() -> impl Strategy<Value = String> {
11603 prop_oneof![
11604 5 => safe_relative_path(),
11605 2 => safe_relative_path().prop_map(|p| format!("../{p}")),
11606 2 => safe_relative_path().prop_map(|p| format!("../../{p}")),
11607 1 => safe_relative_path().prop_map(|p| format!("/tmp/{p}")),
11608 1 => safe_relative_path().prop_map(|p| format!("~/{p}")),
11609 1 => Just("~".to_string()),
11610 1 => Just(".".to_string()),
11611 1 => Just("..".to_string()),
11612 1 => Just("././nested/../file.txt".to_string()),
11613 ]
11614 }
11615
11616 proptest! {
11617 #![proptest_config(ProptestConfig { cases: 64, .. ProptestConfig::default() })]
11618
11619 #[test]
11620 fn proptest_truncate_head_invariants(
11621 input in arbitrary_text(),
11622 max_lines in 0usize..32,
11623 max_bytes in 0usize..256,
11624 ) {
11625 let result = truncate_head(input.clone(), max_lines, max_bytes);
11626
11627 prop_assert!(result.output_lines <= max_lines);
11628 prop_assert!(result.output_bytes <= max_bytes);
11629 prop_assert_eq!(result.output_bytes, result.content.len());
11630
11631 prop_assert_eq!(result.truncated, result.truncated_by.is_some());
11632 prop_assert!(input.starts_with(&result.content));
11633
11634 let repeat = truncate_head(result.content.clone(), max_lines, max_bytes);
11635 prop_assert_eq!(&repeat.content, &result.content);
11636
11637 if result.truncated {
11638 prop_assert!(result.total_lines > max_lines || result.total_bytes > max_bytes);
11639 } else {
11640 prop_assert_eq!(&result.content, &input);
11641 prop_assert!(result.total_lines <= max_lines);
11642 prop_assert!(result.total_bytes <= max_bytes);
11643 }
11644
11645 if result.first_line_exceeds_limit {
11646 prop_assert!(result.truncated);
11647 prop_assert_eq!(result.truncated_by, Some(TruncatedBy::Bytes));
11648 prop_assert!(result.output_bytes <= max_bytes);
11649 prop_assert!(result.output_lines <= 1);
11650 prop_assert!(input.starts_with(&result.content));
11651 }
11652 }
11653
11654 #[test]
11655 fn proptest_truncate_tail_invariants(
11656 input in arbitrary_text(),
11657 max_lines in 0usize..32,
11658 max_bytes in 0usize..256,
11659 ) {
11660 let result = truncate_tail(input.clone(), max_lines, max_bytes);
11661
11662 prop_assert!(result.output_lines <= max_lines);
11663 prop_assert!(result.output_bytes <= max_bytes);
11664 prop_assert_eq!(result.output_bytes, result.content.len());
11665
11666 prop_assert_eq!(result.truncated, result.truncated_by.is_some());
11667 prop_assert!(input.ends_with(&result.content));
11668
11669 let repeat = truncate_tail(result.content.clone(), max_lines, max_bytes);
11670 prop_assert_eq!(&repeat.content, &result.content);
11671
11672 if result.last_line_partial {
11673 prop_assert!(result.truncated);
11674 prop_assert_eq!(result.truncated_by, Some(TruncatedBy::Bytes));
11675 prop_assert!(result.output_lines >= 1 && result.output_lines <= 2);
11678 let content_trimmed = result.content.trim_end_matches('\n');
11679 prop_assert!(input
11680 .split('\n')
11681 .rev()
11682 .any(|line| line.ends_with(content_trimmed)));
11683 }
11684 }
11685
11686 #[test]
11687 fn proptest_truncate_head_monotonic_limits(
11688 input in arbitrary_text(),
11689 max_lines_a in 0usize..32,
11690 max_lines_b in 0usize..32,
11691 max_bytes_a in 0usize..256,
11692 max_bytes_b in 0usize..256,
11693 ) {
11694 let low_lines = max_lines_a.min(max_lines_b);
11695 let high_lines = max_lines_a.max(max_lines_b);
11696 let low_bytes = max_bytes_a.min(max_bytes_b);
11697 let high_bytes = max_bytes_a.max(max_bytes_b);
11698
11699 let small = truncate_head(input.clone(), low_lines, low_bytes);
11700 let large = truncate_head(input, high_lines, high_bytes);
11701
11702 prop_assert!(large.content.starts_with(&small.content));
11703 prop_assert!(large.output_bytes >= small.output_bytes);
11704 prop_assert!(large.output_lines >= small.output_lines);
11705 }
11706
11707 #[test]
11708 fn proptest_truncate_tail_monotonic_limits(
11709 input in arbitrary_text(),
11710 max_lines_a in 0usize..32,
11711 max_lines_b in 0usize..32,
11712 max_bytes_a in 0usize..256,
11713 max_bytes_b in 0usize..256,
11714 ) {
11715 let low_lines = max_lines_a.min(max_lines_b);
11716 let high_lines = max_lines_a.max(max_lines_b);
11717 let low_bytes = max_bytes_a.min(max_bytes_b);
11718 let high_bytes = max_bytes_a.max(max_bytes_b);
11719
11720 let small = truncate_tail(input.clone(), low_lines, low_bytes);
11721 let large = truncate_tail(input, high_lines, high_bytes);
11722
11723 prop_assert!(large.content.ends_with(&small.content));
11724 prop_assert!(large.output_bytes >= small.output_bytes);
11725 prop_assert!(large.output_lines >= small.output_lines);
11726 }
11727
11728 #[test]
11729 fn proptest_truncate_head_prefix_invariant_under_append(
11730 base in arbitrary_text(),
11731 suffix in arbitrary_text(),
11732 max_lines in 0usize..32,
11733 max_bytes in 0usize..256,
11734 ) {
11735 let base_result = truncate_head(base.clone(), max_lines, max_bytes);
11736 let extended_result = truncate_head(format!("{base}{suffix}"), max_lines, max_bytes);
11737 prop_assert!(extended_result.content.starts_with(&base_result.content));
11738 }
11739
11740 #[test]
11741 fn proptest_truncate_tail_suffix_invariant_under_prepend(
11742 base in arbitrary_text(),
11743 prefix in arbitrary_text(),
11744 max_lines in 0usize..32,
11745 max_bytes in 0usize..256,
11746 ) {
11747 let base_result = truncate_tail(base.clone(), max_lines, max_bytes);
11748 let extended_result = truncate_tail(format!("{prefix}{base}"), max_lines, max_bytes);
11749 prop_assert!(extended_result.content.ends_with(&base_result.content));
11750 }
11751 }
11752
11753 proptest! {
11754 #![proptest_config(ProptestConfig { cases: 128, .. ProptestConfig::default() })]
11755
11756 #[test]
11757 fn proptest_normalize_for_match_invariants(input in arbitrary_match_text()) {
11758 let normalized = normalize_for_match(&input);
11759 let renormalized = normalize_for_match(&normalized);
11760
11761 prop_assert_eq!(&renormalized, &normalized);
11762 prop_assert!(normalized.len() <= input.len());
11763 prop_assert!(
11764 normalized.chars().all(|c| {
11765 !is_special_unicode_space(c)
11766 && !matches!(
11767 c,
11768 '\u{2018}'
11769 | '\u{2019}'
11770 | '\u{201C}'
11771 | '\u{201D}'
11772 | '\u{201E}'
11773 | '\u{201F}'
11774 | '\u{2010}'
11775 | '\u{2011}'
11776 | '\u{2012}'
11777 | '\u{2013}'
11778 | '\u{2014}'
11779 | '\u{2015}'
11780 | '\u{2212}'
11781 )
11782 }),
11783 "normalize_for_match should remove target punctuation/space variants"
11784 );
11785 }
11786
11787 #[test]
11788 fn proptest_truncate_line_boundary_invariants(line in boundary_line_text()) {
11789 const TRUNCATION_SUFFIX: &str = "... [truncated]";
11790
11791 let result = truncate_line(&line, GREP_MAX_LINE_LENGTH);
11792 let line_char_count = line.chars().count();
11793 let suffix_chars = TRUNCATION_SUFFIX.chars().count();
11794
11795 if line_char_count <= GREP_MAX_LINE_LENGTH {
11796 prop_assert!(!result.was_truncated);
11797 prop_assert_eq!(result.text, line);
11798 } else {
11799 prop_assert!(result.was_truncated);
11800 prop_assert!(result.text.ends_with(TRUNCATION_SUFFIX));
11801 let expected_prefix: String = line.chars().take(GREP_MAX_LINE_LENGTH).collect();
11802 let expected = format!("{expected_prefix}{TRUNCATION_SUFFIX}");
11803 prop_assert_eq!(&result.text, &expected);
11804 prop_assert!(result.text.chars().count() <= GREP_MAX_LINE_LENGTH + suffix_chars);
11805 }
11806 }
11807
11808 #[test]
11809 fn proptest_resolve_path_safe_relative_invariants(relative_path in safe_relative_path()) {
11810 let cwd = PathBuf::from("/tmp/pi-agent-rust-tools-proptest");
11811 let resolved = resolve_path(&relative_path, &cwd);
11812 let normalized = normalize_dot_segments(&resolved);
11813
11814 prop_assert_eq!(&resolved, &cwd.join(&relative_path));
11815 prop_assert!(resolved.starts_with(&cwd));
11816 prop_assert!(normalized.starts_with(&cwd));
11817 prop_assert_eq!(normalize_dot_segments(&normalized), normalized);
11818 }
11819
11820 #[test]
11821 fn proptest_normalize_dot_segments_pathish_invariants(path_input in pathish_input()) {
11822 let cwd = PathBuf::from("/tmp/pi-agent-rust-tools-proptest");
11823 let resolved = resolve_path(&path_input, &cwd);
11824 let normalized_once = normalize_dot_segments(&resolved);
11825 let normalized_twice = normalize_dot_segments(&normalized_once);
11826
11827 prop_assert_eq!(&normalized_once, &normalized_twice);
11828 prop_assert!(
11829 normalized_once
11830 .components()
11831 .all(|component| !matches!(component, std::path::Component::CurDir))
11832 );
11833
11834 if std::path::Path::new(&path_input).is_absolute() {
11835 prop_assert!(resolved.is_absolute());
11836 prop_assert!(normalized_once.is_absolute());
11837 }
11838 }
11839 }
11840
11841 fn fuzzy_content_strategy() -> impl Strategy<Value = String> {
11849 prop::collection::vec(
11850 prop_oneof![
11851 8 => any::<char>().prop_filter("no nul", |c| *c != '\0'),
11852 1 => Just('\u{00A0}'),
11853 1 => Just('\u{2019}'),
11854 1 => Just('\u{201C}'),
11855 1 => Just('\u{2014}'),
11856 ],
11857 1..512,
11858 )
11859 .prop_map(|chars| chars.into_iter().collect())
11860 }
11861
11862 fn needle_from_content(content: String) -> impl Strategy<Value = (String, String)> {
11865 let len = content.len();
11866 if len == 0 {
11867 return Just((content, String::new())).boxed();
11868 }
11869 (0..len)
11870 .prop_flat_map(move |start| {
11871 let c = content.clone();
11872 let remaining = c.len() - start;
11873 let max_needle = remaining.min(256);
11874 (Just(c), start..=start + max_needle.saturating_sub(1))
11875 })
11876 .prop_filter_map("valid char boundary", |(c, end)| {
11877 let start_candidates: Vec<usize> =
11879 (0..c.len()).filter(|i| c.is_char_boundary(*i)).collect();
11880 if start_candidates.is_empty() {
11881 return None;
11882 }
11883 let start = *start_candidates
11884 .iter()
11885 .min_by_key(|&&i| i.abs_diff(end.saturating_sub(end / 2)))
11886 .unwrap_or(&0);
11887 let end_clamped = end.min(c.len());
11888 let actual_end = (end_clamped..=c.len())
11890 .find(|i| c.is_char_boundary(*i))
11891 .unwrap_or(c.len());
11892 if start >= actual_end {
11893 return Some((c, String::new()));
11894 }
11895 Some((c.clone(), c[start..actual_end].to_string()))
11896 })
11897 .boxed()
11898 }
11899
11900 proptest! {
11901 #![proptest_config(ProptestConfig { cases: 128, .. ProptestConfig::default() })]
11902
11903 #[test]
11905 fn proptest_fuzzy_find_text_exact_match_invariants(
11906 (content, needle) in fuzzy_content_strategy().prop_flat_map(needle_from_content)
11907 ) {
11908 let result = fuzzy_find_text(&content, &needle);
11909 if needle.is_empty() {
11910 prop_assert!(result.found, "empty needle should always match");
11912 prop_assert_eq!(result.index, 0);
11913 prop_assert_eq!(result.match_length, 0);
11914 } else {
11915 prop_assert!(
11916 result.found,
11917 "exact substring must be found: content len={}, needle len={}",
11918 content.len(),
11919 needle.len()
11920 );
11921 prop_assert!(content.is_char_boundary(result.index));
11923 prop_assert!(content.is_char_boundary(result.index + result.match_length));
11924 let matched = &content[result.index..result.index + result.match_length];
11926 prop_assert_eq!(matched, needle.as_str());
11927 }
11928 }
11929
11930 #[test]
11935 fn proptest_fuzzy_find_text_normalized_match_invariants(
11936 content in arbitrary_match_text()
11937 ) {
11938 let normalized = build_normalized_content(&content);
11940 if normalized.is_empty() {
11941 return Ok(());
11942 }
11943 let needle_end = normalized
11945 .char_indices()
11946 .nth(128.min(normalized.chars().count().saturating_sub(1)))
11947 .map_or(normalized.len(), |(i, _)| i);
11948 let needle_end = (needle_end..=normalized.len())
11950 .find(|i| normalized.is_char_boundary(*i))
11951 .unwrap_or(normalized.len());
11952 let needle = &normalized[..needle_end];
11953 if needle.is_empty() {
11954 return Ok(());
11955 }
11956
11957 let result = fuzzy_find_text(&content, needle);
11958 prop_assert!(
11959 result.found,
11960 "normalized needle should be found via fuzzy match: needle={:?}",
11961 needle
11962 );
11963 prop_assert!(content.is_char_boundary(result.index));
11965 prop_assert!(content.is_char_boundary(result.index + result.match_length));
11966 }
11967
11968 #[test]
11971 fn proptest_build_normalized_content_invariants(input in arbitrary_match_text()) {
11972 let normalized = build_normalized_content(&input);
11973 let renormalized = build_normalized_content(&normalized);
11974
11975 prop_assert_eq!(
11977 &renormalized,
11978 &normalized,
11979 "build_normalized_content should be idempotent"
11980 );
11981
11982 prop_assert!(
11986 normalized.len() <= input.len(),
11987 "normalized should not be larger: {} vs {}",
11988 normalized.len(),
11989 input.len()
11990 );
11991
11992 let input_lines = input.split('\n').count();
11995 let norm_lines = normalized.split('\n').count();
11996 prop_assert_eq!(
11997 norm_lines, input_lines,
11998 "line count must be preserved by normalization"
11999 );
12000
12001 prop_assert!(
12003 normalized.chars().all(|c| {
12004 !is_special_unicode_space(c)
12005 && !matches!(
12006 c,
12007 '\u{2018}'
12008 | '\u{2019}'
12009 | '\u{201C}'
12010 | '\u{201D}'
12011 | '\u{201E}'
12012 | '\u{201F}'
12013 | '\u{2010}'
12014 | '\u{2011}'
12015 | '\u{2012}'
12016 | '\u{2013}'
12017 | '\u{2014}'
12018 | '\u{2015}'
12019 | '\u{2212}'
12020 )
12021 }),
12022 "normalized content should not contain target Unicode chars"
12023 );
12024 }
12025
12026 #[test]
12029 fn proptest_build_normalized_content_trailing_whitespace_invariant(
12030 input in arbitrary_match_text()
12031 ) {
12032 let normalized = build_normalized_content(&input);
12033 let mut with_trailing = String::new();
12034 let mut lines = input.split('\n').peekable();
12035
12036 while let Some(line) = lines.next() {
12037 with_trailing.push_str(line);
12038 with_trailing.push_str(" \t");
12039 if lines.peek().is_some() {
12040 with_trailing.push('\n');
12041 }
12042 }
12043
12044 let normalized_trailing = build_normalized_content(&with_trailing);
12045 prop_assert_eq!(normalized_trailing, normalized);
12046 }
12047
12048 #[test]
12056 fn proptest_map_normalized_range_roundtrip(input in arbitrary_match_text()) {
12057 let normalized = build_normalized_content(&input);
12058 if normalized.is_empty() {
12059 return Ok(());
12060 }
12061
12062 let norm_chars: Vec<(usize, char)> = normalized.char_indices().collect();
12064 let norm_len = norm_chars.len();
12065 if norm_len == 0 {
12066 return Ok(());
12067 }
12068
12069 let end_char = (norm_len / 4).max(1).min(norm_len);
12071 let norm_start = norm_chars[0].0;
12072 let norm_end = if end_char < norm_chars.len() {
12073 norm_chars[end_char].0
12074 } else {
12075 normalized.len()
12076 };
12077 let norm_match_len = norm_end - norm_start;
12078
12079 let (orig_start, orig_len) =
12080 map_normalized_range_to_original(&input, norm_start, norm_match_len);
12081
12082 prop_assert!(
12084 orig_start + orig_len <= input.len(),
12085 "mapped range {orig_start}..{} exceeds input len {}",
12086 orig_start + orig_len,
12087 input.len()
12088 );
12089
12090 prop_assert!(
12092 input.is_char_boundary(orig_start),
12093 "orig_start {} is not a char boundary",
12094 orig_start
12095 );
12096 prop_assert!(
12097 input.is_char_boundary(orig_start + orig_len),
12098 "orig_end {} is not a char boundary",
12099 orig_start + orig_len
12100 );
12101
12102 prop_assert!(
12106 orig_len >= norm_match_len
12107 || orig_len == 0
12108 || norm_match_len == 0,
12109 "original range ({orig_len}) should be >= normalized range ({norm_match_len})"
12110 );
12111
12112 let expected_norm = &normalized[norm_start..norm_end];
12116 if !expected_norm.is_empty() {
12117 let fuzzy_result = fuzzy_find_text(&input, expected_norm);
12118 prop_assert!(
12119 fuzzy_result.found,
12120 "normalized needle should be findable in original content"
12121 );
12122 }
12123 }
12124 }
12125
12126 #[test]
12127 fn test_truncate_head_preserves_newline() {
12128 let content = "Line1\nLine2".to_string();
12130 let result = truncate_head(content, 1, 1000);
12131 assert_eq!(result.content, "Line1\n");
12132
12133 let content = "Line1".to_string();
12135 let result = truncate_head(content, 1, 1000);
12136 assert_eq!(result.content, "Line1");
12137
12138 let content = "Line1\n".to_string();
12140 let result = truncate_head(content, 1, 1000);
12141 assert_eq!(result.content, "Line1\n");
12142 }
12143
12144 #[test]
12145 fn test_edit_crlf_content_correctness() {
12146 asupersync::test_utils::run_test(|| async {
12148 let tmp = tempfile::tempdir().unwrap();
12149 let path = tmp.path().join("crlf.txt");
12150 let content = "line1\r\nline2\r\nline3";
12152 std::fs::write(&path, content).unwrap();
12153
12154 let tool = EditTool::new(tmp.path());
12155
12156 let out = tool
12161 .execute(
12162 "t",
12163 serde_json::json!({
12164 "path": path.to_string_lossy(),
12165 "oldText": "line2",
12166 "newText": "changed"
12167 }),
12168 None,
12169 )
12170 .await
12171 .unwrap();
12172
12173 assert!(!out.is_error);
12174 let new_content = std::fs::read_to_string(&path).unwrap();
12175
12176 assert_eq!(new_content, "line1\r\nchanged\r\nline3");
12178 });
12179 }
12180
12181 #[test]
12182 fn test_edit_cr_content_correctness() {
12183 asupersync::test_utils::run_test(|| async {
12184 let tmp = tempfile::tempdir().unwrap();
12185 let path = tmp.path().join("cr.txt");
12186 std::fs::write(&path, "line1\rline2\rline3").unwrap();
12187
12188 let tool = EditTool::new(tmp.path());
12189 let out = tool
12190 .execute(
12191 "t",
12192 serde_json::json!({
12193 "path": path.to_string_lossy(),
12194 "oldText": "line2",
12195 "newText": "changed"
12196 }),
12197 None,
12198 )
12199 .await
12200 .unwrap();
12201
12202 assert!(!out.is_error);
12203 let new_content = std::fs::read_to_string(&path).unwrap();
12204 assert_eq!(new_content, "line1\rchanged\rline3");
12205 });
12206 }
12207
12208 #[test]
12213 fn test_compute_line_hash_basic() {
12214 let h1 = compute_line_hash(0, "fn main() {");
12216 let h2 = compute_line_hash(0, "fn main() {");
12217 assert_eq!(h1, h2);
12218
12219 let h3 = compute_line_hash(0, "fn foo() {");
12221 assert_ne!(h1, h3);
12223
12224 for &b in &h1 {
12226 assert!(NIBBLE_STR.contains(&b), "hash byte {b} not in NIBBLE_STR");
12227 }
12228 }
12229
12230 #[test]
12231 fn test_compute_line_hash_punctuation_only() {
12232 let h1 = compute_line_hash(0, "}");
12235 let h2 = compute_line_hash(1, "}");
12236 assert_ne!(
12237 h1, h2,
12238 "punctuation-only lines at different indices should differ"
12239 );
12240
12241 let h3 = compute_line_hash(0, "");
12243 let h4 = compute_line_hash(1, "");
12244 assert_ne!(h3, h4);
12245 }
12246
12247 #[test]
12248 fn test_compute_line_hash_whitespace_invariant() {
12249 let h1 = compute_line_hash(0, "return 42;");
12251 let h2 = compute_line_hash(0, " return 42;");
12252 let h3 = compute_line_hash(0, "\treturn 42;");
12253 assert_eq!(h1, h2);
12254 assert_eq!(h1, h3);
12255 }
12256
12257 #[test]
12258 fn test_format_hashline_tag() {
12259 let tag = format_hashline_tag(0, "fn main() {");
12260 assert!(
12262 tag.starts_with("1#"),
12263 "tag should start with 1#, got: {tag}"
12264 );
12265 assert_eq!(tag.len(), 4, "tag should be 4 chars: N#AB");
12266
12267 let tag10 = format_hashline_tag(9, "line 10");
12268 assert!(tag10.starts_with("10#"));
12269 assert_eq!(tag10.len(), 5); }
12271
12272 #[test]
12273 fn test_parse_hashline_tag_valid() {
12274 let (line, hash) = parse_hashline_tag("5#KJ").unwrap();
12276 assert_eq!(line, 5);
12277 assert_eq!(hash, [b'K', b'J']);
12278
12279 let (line, hash) = parse_hashline_tag(" 10 # QR ").unwrap();
12281 assert_eq!(line, 10);
12282 assert_eq!(hash, [b'Q', b'R']);
12283
12284 let (line, hash) = parse_hashline_tag("> + 3#ZZ").unwrap();
12286 assert_eq!(line, 3);
12287 assert_eq!(hash, [b'Z', b'Z']);
12288 }
12289
12290 #[test]
12291 fn test_parse_hashline_tag_invalid() {
12292 assert!(parse_hashline_tag("0#KJ").is_err());
12294 assert!(parse_hashline_tag("5#").is_err());
12296 assert!(parse_hashline_tag("5#AA").is_err()); assert!(parse_hashline_tag("#KJ").is_err());
12300 assert!(parse_hashline_tag("").is_err());
12302 }
12303
12304 #[test]
12305 fn test_strip_hashline_prefix() {
12306 assert_eq!(strip_hashline_prefix("5#KJ:hello world"), "hello world");
12307 assert_eq!(strip_hashline_prefix("100#ZZ:fn main() {"), "fn main() {");
12308 assert_eq!(strip_hashline_prefix(" 5 # KJ:hello world"), "hello world");
12309 assert_eq!(strip_hashline_prefix("> + 5#KJ:hello world"), "hello world");
12310 assert_eq!(strip_hashline_prefix("5#KJ :hello world"), "hello world");
12311 assert_eq!(strip_hashline_prefix("hello world"), "hello world");
12313 assert_eq!(strip_hashline_prefix(""), "");
12314 }
12315
12316 #[test]
12317 fn test_hashline_edit_single_replace() {
12318 asupersync::test_utils::run_test(|| async {
12319 let dir = tempfile::tempdir().unwrap();
12320 let file = dir.path().join("test.txt");
12321 std::fs::write(&file, "line1\nline2\nline3\n").unwrap();
12322
12323 let tool = HashlineEditTool::new(dir.path());
12324
12325 let tag2 = format_hashline_tag(1, "line2");
12327
12328 let input = serde_json::json!({
12329 "path": file.to_str().unwrap(),
12330 "edits": [{
12331 "op": "replace",
12332 "pos": tag2,
12333 "lines": ["changed"]
12334 }]
12335 });
12336
12337 let out = tool.execute("test", input, None).await.unwrap();
12338 assert!(!out.is_error);
12339
12340 let content = std::fs::read_to_string(&file).unwrap();
12341 assert_eq!(content, "line1\nchanged\nline3\n");
12342 });
12343 }
12344
12345 #[test]
12346 fn test_hashline_edit_range_replace() {
12347 asupersync::test_utils::run_test(|| async {
12348 let dir = tempfile::tempdir().unwrap();
12349 let file = dir.path().join("test.txt");
12350 std::fs::write(&file, "a\nb\nc\nd\ne\n").unwrap();
12351
12352 let tool = HashlineEditTool::new(dir.path());
12353
12354 let tag_b = format_hashline_tag(1, "b");
12355 let tag_d = format_hashline_tag(3, "d");
12356
12357 let input = serde_json::json!({
12358 "path": file.to_str().unwrap(),
12359 "edits": [{
12360 "op": "replace",
12361 "pos": tag_b,
12362 "end": tag_d,
12363 "lines": ["X", "Y"]
12364 }]
12365 });
12366
12367 let out = tool.execute("test", input, None).await.unwrap();
12368 assert!(!out.is_error);
12369
12370 let content = std::fs::read_to_string(&file).unwrap();
12371 assert_eq!(content, "a\nX\nY\ne\n");
12372 });
12373 }
12374
12375 #[test]
12376 fn test_hashline_edit_prepend() {
12377 asupersync::test_utils::run_test(|| async {
12378 let dir = tempfile::tempdir().unwrap();
12379 let file = dir.path().join("test.txt");
12380 std::fs::write(&file, "a\nb\nc\n").unwrap();
12381
12382 let tool = HashlineEditTool::new(dir.path());
12383 let tag_b = format_hashline_tag(1, "b");
12384
12385 let input = serde_json::json!({
12386 "path": file.to_str().unwrap(),
12387 "edits": [{
12388 "op": "prepend",
12389 "pos": tag_b,
12390 "lines": ["inserted"]
12391 }]
12392 });
12393
12394 let out = tool.execute("test", input, None).await.unwrap();
12395 assert!(!out.is_error);
12396
12397 let content = std::fs::read_to_string(&file).unwrap();
12398 assert_eq!(content, "a\ninserted\nb\nc\n");
12399 });
12400 }
12401
12402 #[test]
12403 fn test_hashline_edit_append() {
12404 asupersync::test_utils::run_test(|| async {
12405 let dir = tempfile::tempdir().unwrap();
12406 let file = dir.path().join("test.txt");
12407 std::fs::write(&file, "a\nb\nc\n").unwrap();
12408
12409 let tool = HashlineEditTool::new(dir.path());
12410 let tag_b = format_hashline_tag(1, "b");
12411
12412 let input = serde_json::json!({
12413 "path": file.to_str().unwrap(),
12414 "edits": [{
12415 "op": "append",
12416 "pos": tag_b,
12417 "lines": ["inserted"]
12418 }]
12419 });
12420
12421 let out = tool.execute("test", input, None).await.unwrap();
12422 assert!(!out.is_error);
12423
12424 let content = std::fs::read_to_string(&file).unwrap();
12425 assert_eq!(content, "a\nb\ninserted\nc\n");
12426 });
12427 }
12428
12429 #[test]
12430 fn test_hashline_edit_bottom_up_ordering() {
12431 asupersync::test_utils::run_test(|| async {
12432 let dir = tempfile::tempdir().unwrap();
12433 let file = dir.path().join("test.txt");
12434 std::fs::write(&file, "a\nb\nc\nd\n").unwrap();
12435
12436 let tool = HashlineEditTool::new(dir.path());
12437 let tag_b = format_hashline_tag(1, "b");
12438 let tag_d = format_hashline_tag(3, "d");
12439
12440 let input = serde_json::json!({
12442 "path": file.to_str().unwrap(),
12443 "edits": [
12444 { "op": "replace", "pos": tag_b, "lines": ["B"] },
12445 { "op": "replace", "pos": tag_d, "lines": ["D"] }
12446 ]
12447 });
12448
12449 let out = tool.execute("test", input, None).await.unwrap();
12450 assert!(!out.is_error);
12451
12452 let content = std::fs::read_to_string(&file).unwrap();
12453 assert_eq!(content, "a\nB\nc\nD\n");
12454 });
12455 }
12456
12457 #[test]
12458 fn test_hashline_edit_hash_mismatch() {
12459 asupersync::test_utils::run_test(|| async {
12460 let dir = tempfile::tempdir().unwrap();
12461 let file = dir.path().join("test.txt");
12462 std::fs::write(&file, "hello\nworld\n").unwrap();
12463
12464 let tool = HashlineEditTool::new(dir.path());
12465
12466 let input = serde_json::json!({
12468 "path": file.to_str().unwrap(),
12469 "edits": [{
12470 "op": "replace",
12471 "pos": "1#ZZ",
12472 "lines": ["changed"]
12473 }]
12474 });
12475
12476 let result = tool.execute("test", input, None).await;
12477 assert!(result.is_err());
12478 let err_msg = result.unwrap_err().to_string();
12479 assert!(
12480 err_msg.contains("Hash validation failed"),
12481 "error should mention hash validation: {err_msg}"
12482 );
12483 });
12484 }
12485
12486 #[test]
12487 fn test_hashline_edit_dedup() {
12488 asupersync::test_utils::run_test(|| async {
12489 let dir = tempfile::tempdir().unwrap();
12490 let file = dir.path().join("test.txt");
12491 std::fs::write(&file, "a\nb\nc\n").unwrap();
12492
12493 let tool = HashlineEditTool::new(dir.path());
12494 let tag_b = format_hashline_tag(1, "b");
12495
12496 let input = serde_json::json!({
12498 "path": file.to_str().unwrap(),
12499 "edits": [
12500 { "op": "replace", "pos": &tag_b, "lines": ["B"] },
12501 { "op": "replace", "pos": &tag_b, "lines": ["B"] }
12502 ]
12503 });
12504
12505 let out = tool.execute("test", input, None).await.unwrap();
12506 assert!(!out.is_error);
12507
12508 let content = std::fs::read_to_string(&file).unwrap();
12509 assert_eq!(content, "a\nB\nc\n");
12510 });
12511 }
12512
12513 #[test]
12514 fn test_hashline_edit_noop_detection() {
12515 asupersync::test_utils::run_test(|| async {
12516 let dir = tempfile::tempdir().unwrap();
12517 let file = dir.path().join("test.txt");
12518 std::fs::write(&file, "a\nb\nc\n").unwrap();
12519
12520 let tool = HashlineEditTool::new(dir.path());
12521 let tag_b = format_hashline_tag(1, "b");
12522
12523 let input = serde_json::json!({
12525 "path": file.to_str().unwrap(),
12526 "edits": [{
12527 "op": "replace",
12528 "pos": &tag_b,
12529 "lines": ["b"]
12530 }]
12531 });
12532
12533 let result = tool.execute("test", input, None).await;
12534 assert!(result.is_err());
12535 let err_msg = result.unwrap_err().to_string();
12536 assert!(
12537 err_msg.contains("no-ops"),
12538 "error should mention no-ops: {err_msg}"
12539 );
12540 });
12541 }
12542
12543 #[test]
12544 fn test_hashline_read_output_format() {
12545 asupersync::test_utils::run_test(|| async {
12546 let dir = tempfile::tempdir().unwrap();
12547 let file = dir.path().join("test.txt");
12548 std::fs::write(&file, "fn main() {\n println!(\"hello\");\n}\n").unwrap();
12549
12550 let tool = ReadTool::new(dir.path());
12551 let input = serde_json::json!({
12552 "path": file.to_str().unwrap(),
12553 "hashline": true
12554 });
12555
12556 let out = tool.execute("test", input, None).await.unwrap();
12557 assert!(!out.is_error);
12558 let text = get_text(&out.content);
12559
12560 for line in text.lines() {
12562 if line.starts_with('[') || line.is_empty() {
12563 continue; }
12565 assert!(
12566 hashline_tag_regex().is_match(line),
12567 "line should match hashline format: {line:?}"
12568 );
12569 assert!(
12570 line.contains(':'),
12571 "line should contain ':' separator: {line:?}"
12572 );
12573 }
12574
12575 let first_line = text.lines().next().unwrap();
12577 assert!(first_line.starts_with("1#"), "first line: {first_line:?}");
12578 });
12579 }
12580
12581 #[test]
12582 fn test_hashline_edit_prefix_stripping() {
12583 asupersync::test_utils::run_test(|| async {
12584 let dir = tempfile::tempdir().unwrap();
12585 let file = dir.path().join("test.txt");
12586 std::fs::write(&file, "a\nb\nc\n").unwrap();
12587
12588 let tool = HashlineEditTool::new(dir.path());
12589 let tag_b = format_hashline_tag(1, "b");
12590
12591 let input = serde_json::json!({
12593 "path": file.to_str().unwrap(),
12594 "edits": [{
12595 "op": "replace",
12596 "pos": &tag_b,
12597 "lines": ["2#KJ:changed"]
12598 }]
12599 });
12600
12601 let out = tool.execute("test", input, None).await.unwrap();
12602 assert!(!out.is_error);
12603
12604 let content = std::fs::read_to_string(&file).unwrap();
12605 assert_eq!(content, "a\nchanged\nc\n");
12606 });
12607 }
12608
12609 #[test]
12610 fn test_hashline_edit_delete_lines() {
12611 asupersync::test_utils::run_test(|| async {
12612 let dir = tempfile::tempdir().unwrap();
12613 let file = dir.path().join("test.txt");
12614 std::fs::write(&file, "a\nb\nc\nd\n").unwrap();
12615
12616 let tool = HashlineEditTool::new(dir.path());
12617 let tag_b = format_hashline_tag(1, "b");
12618 let tag_c = format_hashline_tag(2, "c");
12619
12620 let input = serde_json::json!({
12622 "path": file.to_str().unwrap(),
12623 "edits": [{
12624 "op": "replace",
12625 "pos": &tag_b,
12626 "end": &tag_c,
12627 "lines": null
12628 }]
12629 });
12630
12631 let out = tool.execute("test", input, None).await.unwrap();
12632 assert!(!out.is_error);
12633
12634 let content = std::fs::read_to_string(&file).unwrap();
12635 assert_eq!(content, "a\nd\n");
12636 });
12637 }
12638
12639 #[test]
12640 fn test_hashline_edit_crlf_preservation() {
12641 asupersync::test_utils::run_test(|| async {
12642 let dir = tempfile::tempdir().unwrap();
12643 let file = dir.path().join("test.txt");
12644 std::fs::write(&file, "line1\r\nline2\r\nline3").unwrap();
12645
12646 let tool = HashlineEditTool::new(dir.path());
12647 let tag2 = format_hashline_tag(1, "line2");
12648
12649 let input = serde_json::json!({
12650 "path": file.to_str().unwrap(),
12651 "edits": [{
12652 "op": "replace",
12653 "pos": tag2,
12654 "lines": ["changed"]
12655 }]
12656 });
12657
12658 let out = tool.execute("test", input, None).await.unwrap();
12659 assert!(!out.is_error);
12660
12661 let content = std::fs::read_to_string(&file).unwrap();
12662 assert_eq!(content, "line1\r\nchanged\r\nline3");
12663 });
12664 }
12665
12666 #[test]
12667 fn test_hashline_edit_cr_preservation() {
12668 asupersync::test_utils::run_test(|| async {
12669 let dir = tempfile::tempdir().unwrap();
12670 let file = dir.path().join("test.txt");
12671 std::fs::write(&file, "line1\rline2\rline3").unwrap();
12672
12673 let tool = HashlineEditTool::new(dir.path());
12674 let tag2 = format_hashline_tag(1, "line2");
12675
12676 let input = serde_json::json!({
12677 "path": file.to_str().unwrap(),
12678 "edits": [{
12679 "op": "replace",
12680 "pos": tag2,
12681 "lines": ["changed"]
12682 }]
12683 });
12684
12685 let out = tool.execute("test", input, None).await.unwrap();
12686 assert!(!out.is_error);
12687
12688 let content = std::fs::read_to_string(&file).unwrap();
12689 assert_eq!(content, "line1\rchanged\rline3");
12690 });
12691 }
12692
12693 #[test]
12694 fn test_hashline_edit_empty_file_append() {
12695 asupersync::test_utils::run_test(|| async {
12696 let dir = tempfile::tempdir().unwrap();
12697 let file = dir.path().join("empty.txt");
12698 std::fs::write(&file, "").unwrap();
12699
12700 let tool = HashlineEditTool::new(dir.path());
12701
12702 let input = serde_json::json!({
12704 "path": file.to_str().unwrap(),
12705 "edits": [{
12706 "op": "append",
12707 "lines": ["new_line"]
12708 }]
12709 });
12710
12711 let out = tool.execute("test", input, None).await.unwrap();
12712 assert!(!out.is_error);
12713
12714 let content = std::fs::read_to_string(&file).unwrap();
12715 assert!(content.contains("new_line"));
12716 });
12717 }
12718
12719 #[test]
12720 fn test_hashline_edit_single_line_no_trailing_newline() {
12721 asupersync::test_utils::run_test(|| async {
12722 let dir = tempfile::tempdir().unwrap();
12723 let file = dir.path().join("single.txt");
12724 std::fs::write(&file, "hello").unwrap();
12725
12726 let tool = HashlineEditTool::new(dir.path());
12727 let tag = format_hashline_tag(0, "hello");
12728
12729 let input = serde_json::json!({
12730 "path": file.to_str().unwrap(),
12731 "edits": [{
12732 "op": "replace",
12733 "pos": tag,
12734 "lines": ["world"]
12735 }]
12736 });
12737
12738 let out = tool.execute("test", input, None).await.unwrap();
12739 assert!(!out.is_error);
12740
12741 let content = std::fs::read_to_string(&file).unwrap();
12742 assert_eq!(content, "world");
12743 });
12744 }
12745
12746 #[test]
12747 fn test_hashline_edit_preserves_bom_hash_validation() {
12748 asupersync::test_utils::run_test(|| async {
12749 let dir = tempfile::tempdir().unwrap();
12750 let file = dir.path().join("bom.txt");
12751 let bom = "\u{FEFF}";
12752 std::fs::write(&file, format!("{bom}alpha\nbeta\n")).unwrap();
12753
12754 let tool = HashlineEditTool::new(dir.path());
12755 let tag1 = format_hashline_tag(0, &format!("{bom}alpha"));
12756
12757 let input = serde_json::json!({
12758 "path": file.to_str().unwrap(),
12759 "edits": [{
12760 "op": "replace",
12761 "pos": tag1,
12762 "lines": ["gamma"]
12763 }]
12764 });
12765
12766 let out = tool.execute("test", input, None).await.unwrap();
12767 assert!(!out.is_error);
12768
12769 let content = std::fs::read_to_string(&file).unwrap();
12770 assert_eq!(content, format!("{bom}gamma\nbeta\n"));
12771 });
12772 }
12773
12774 #[test]
12775 fn test_hashline_edit_bof_prepend_no_pos() {
12776 asupersync::test_utils::run_test(|| async {
12777 let dir = tempfile::tempdir().unwrap();
12778 let file = dir.path().join("test.txt");
12779 std::fs::write(&file, "a\nb\nc\n").unwrap();
12780
12781 let tool = HashlineEditTool::new(dir.path());
12782
12783 let input = serde_json::json!({
12785 "path": file.to_str().unwrap(),
12786 "edits": [{
12787 "op": "prepend",
12788 "lines": ["header"]
12789 }]
12790 });
12791
12792 let out = tool.execute("test", input, None).await.unwrap();
12793 assert!(!out.is_error);
12794
12795 let content = std::fs::read_to_string(&file).unwrap();
12796 assert_eq!(content, "header\na\nb\nc\n");
12797 });
12798 }
12799
12800 #[test]
12801 fn test_hashline_edit_eof_append_no_pos() {
12802 asupersync::test_utils::run_test(|| async {
12803 let dir = tempfile::tempdir().unwrap();
12804 let file = dir.path().join("test.txt");
12805 std::fs::write(&file, "a\nb\nc\n").unwrap();
12806
12807 let tool = HashlineEditTool::new(dir.path());
12808
12809 let input = serde_json::json!({
12811 "path": file.to_str().unwrap(),
12812 "edits": [{
12813 "op": "append",
12814 "lines": ["footer"]
12815 }]
12816 });
12817
12818 let out = tool.execute("test", input, None).await.unwrap();
12819 assert!(!out.is_error);
12820
12821 let content = std::fs::read_to_string(&file).unwrap();
12822 assert!(
12823 content.contains("footer"),
12824 "content should contain footer: {content:?}"
12825 );
12826 });
12827 }
12828
12829 #[test]
12830 fn test_hashline_edit_overlapping_replace_ranges_rejected() {
12831 asupersync::test_utils::run_test(|| async {
12832 let dir = tempfile::tempdir().unwrap();
12833 let file = dir.path().join("test.txt");
12834 std::fs::write(&file, "a\nb\nc\nd\ne\n").unwrap();
12835
12836 let tool = HashlineEditTool::new(dir.path());
12837 let tag_b = format_hashline_tag(1, "b");
12838 let tag_d = format_hashline_tag(3, "d");
12839 let tag_c = format_hashline_tag(2, "c");
12840 let tag_e = format_hashline_tag(4, "e");
12841
12842 let input = serde_json::json!({
12844 "path": file.to_str().unwrap(),
12845 "edits": [
12846 { "op": "replace", "pos": &tag_b, "end": &tag_d, "lines": ["X"] },
12847 { "op": "replace", "pos": &tag_c, "end": &tag_e, "lines": ["Y"] }
12848 ]
12849 });
12850
12851 let result = tool.execute("test", input, None).await;
12852 assert!(result.is_err());
12853 let err_msg = result.unwrap_err().to_string();
12854 assert!(
12855 err_msg.contains("Overlapping"),
12856 "error should mention overlapping: {err_msg}"
12857 );
12858 });
12859 }
12860
12861 #[test]
12862 fn test_hashline_edit_reversed_range_rejected() {
12863 asupersync::test_utils::run_test(|| async {
12864 let dir = tempfile::tempdir().unwrap();
12865 let file = dir.path().join("test.txt");
12866 std::fs::write(&file, "a\nb\nc\nd\n").unwrap();
12867
12868 let tool = HashlineEditTool::new(dir.path());
12869 let tag_b = format_hashline_tag(1, "b");
12870 let tag_d = format_hashline_tag(3, "d");
12871
12872 let input = serde_json::json!({
12874 "path": file.to_str().unwrap(),
12875 "edits": [{
12876 "op": "replace",
12877 "pos": &tag_d,
12878 "end": &tag_b,
12879 "lines": ["X"]
12880 }]
12881 });
12882
12883 let result = tool.execute("test", input, None).await;
12884 assert!(result.is_err());
12885 let err_msg = result.unwrap_err().to_string();
12886 assert!(
12887 err_msg.contains("before start"),
12888 "error should mention before start: {err_msg}"
12889 );
12890 });
12891 }
12892
12893 #[test]
12894 fn test_hashline_edit_trailing_newline_semantics() {
12895 asupersync::test_utils::run_test(|| async {
12896 let dir = tempfile::tempdir().unwrap();
12897 let file = dir.path().join("test.txt");
12898 std::fs::write(&file, "line1\nline2\n").unwrap();
12900
12901 let tool = HashlineEditTool::new(dir.path());
12902 let tag2 = format_hashline_tag(1, "line2");
12903
12904 let input = serde_json::json!({
12906 "path": file.to_str().unwrap(),
12907 "edits": [{
12908 "op": "replace",
12909 "pos": tag2,
12910 "lines": ["changed"]
12911 }]
12912 });
12913
12914 let out = tool.execute("test", input, None).await.unwrap();
12915 assert!(!out.is_error);
12916
12917 let content = std::fs::read_to_string(&file).unwrap();
12918 assert_eq!(content, "line1\nchanged\n");
12919 });
12920 }
12921}