1use crate::agent_cx::AgentCx;
10use crate::config::Config;
11use crate::error::{Error, Result};
12use crate::extensions::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 std::collections::{HashMap, VecDeque};
19use std::fmt::Write as _;
20use std::io::{BufRead, Read, Write};
21use std::path::{Path, PathBuf};
22use std::process::{Command, Stdio};
23use std::sync::{OnceLock, mpsc};
24use std::thread;
25use std::time::{Duration, Instant};
26use unicode_normalization::UnicodeNormalization;
27use uuid::Uuid;
28
29#[async_trait]
35pub trait Tool: Send + Sync {
36 fn name(&self) -> &str;
38
39 fn label(&self) -> &str;
41
42 fn description(&self) -> &str;
44
45 fn parameters(&self) -> serde_json::Value;
47
48 async fn execute(
54 &self,
55 tool_call_id: &str,
56 input: serde_json::Value,
57 on_update: Option<Box<dyn Fn(ToolUpdate) + Send + Sync>>,
58 ) -> Result<ToolOutput>;
59
60 fn is_read_only(&self) -> bool {
64 false
65 }
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize)]
70#[serde(rename_all = "camelCase")]
71pub struct ToolOutput {
72 pub content: Vec<ContentBlock>,
73 pub details: Option<serde_json::Value>,
74 #[serde(default, skip_serializing_if = "is_false")]
75 pub is_error: bool,
76}
77
78#[allow(clippy::trivially_copy_pass_by_ref)] const fn is_false(value: &bool) -> bool {
80 !*value
81}
82
83#[derive(Debug, Clone, Serialize)]
85#[serde(rename_all = "camelCase")]
86pub struct ToolUpdate {
87 pub content: Vec<ContentBlock>,
88 pub details: Option<serde_json::Value>,
89}
90
91pub const DEFAULT_MAX_LINES: usize = 2000;
97
98pub const DEFAULT_MAX_BYTES: usize = 50 * 1024; pub const GREP_MAX_LINE_LENGTH: usize = 500;
103
104pub const DEFAULT_GREP_LIMIT: usize = 100;
106
107pub const DEFAULT_FIND_LIMIT: usize = 1000;
109
110pub const DEFAULT_LS_LIMIT: usize = 500;
112
113pub const LS_SCAN_HARD_LIMIT: usize = 20_000;
115
116pub const READ_TOOL_MAX_BYTES: u64 = 100 * 1024 * 1024;
118
119pub const WRITE_TOOL_MAX_BYTES: usize = 100 * 1024 * 1024;
121
122pub const IMAGE_MAX_BYTES: usize = 4_718_592;
124
125pub const DEFAULT_BASH_TIMEOUT_SECS: u64 = 120;
127
128const BASH_TERMINATE_GRACE_SECS: u64 = 5;
129
130pub(crate) const BASH_FILE_LIMIT_BYTES: usize = 100 * 1024 * 1024;
132
133#[derive(Debug, Clone, Serialize)]
135#[serde(rename_all = "camelCase")]
136pub struct TruncationResult {
137 pub content: String,
138 pub truncated: bool,
139 #[serde(skip_serializing_if = "Option::is_none")]
140 pub truncated_by: Option<TruncatedBy>,
141 pub total_lines: usize,
142 pub total_bytes: usize,
143 pub output_lines: usize,
144 pub output_bytes: usize,
145 pub last_line_partial: bool,
146 pub first_line_exceeds_limit: bool,
147 pub max_lines: usize,
148 pub max_bytes: usize,
149}
150
151#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
152#[serde(rename_all = "camelCase")]
153pub enum TruncatedBy {
154 Lines,
155 Bytes,
156}
157
158#[allow(clippy::too_many_lines)]
165pub fn truncate_head(
166 content: impl Into<String>,
167 max_lines: usize,
168 max_bytes: usize,
169) -> TruncationResult {
170 let mut content = content.into();
171 let total_bytes = content.len();
172 let total_lines = {
175 let nl = memchr::memchr_iter(b'\n', content.as_bytes()).count();
176 if content.is_empty() {
177 0
178 } else if content.ends_with('\n') {
179 nl
180 } else {
181 nl + 1
182 }
183 };
184
185 if max_lines == 0 {
187 let truncated = !content.is_empty();
188 return TruncationResult {
189 content: String::new(),
190 truncated,
191 truncated_by: if truncated {
192 Some(TruncatedBy::Lines)
193 } else {
194 None
195 },
196 total_lines,
197 total_bytes,
198 output_lines: 0,
199 output_bytes: 0,
200 last_line_partial: false,
201 first_line_exceeds_limit: false,
202 max_lines,
203 max_bytes,
204 };
205 }
206
207 if total_lines <= max_lines && total_bytes <= max_bytes {
209 return TruncationResult {
210 content,
211 truncated: false,
212 truncated_by: None,
213 total_lines,
214 total_bytes,
215 output_lines: total_lines,
216 output_bytes: total_bytes,
217 last_line_partial: false,
218 first_line_exceeds_limit: false,
219 max_lines,
220 max_bytes,
221 };
222 }
223
224 let first_newline = memchr::memchr(b'\n', content.as_bytes());
226 let first_line_bytes = first_newline.unwrap_or(content.len());
227 if first_line_bytes > max_bytes {
228 let mut limit = max_bytes;
229 while limit > 0 && !content.is_char_boundary(limit) {
230 limit -= 1;
231 }
232 content.truncate(limit);
233 return TruncationResult {
234 content,
235 truncated: true,
236 truncated_by: Some(TruncatedBy::Bytes),
237 total_lines,
238 total_bytes,
239 output_lines: 1,
240 output_bytes: limit,
241 last_line_partial: true,
242 first_line_exceeds_limit: true,
243 max_lines,
244 max_bytes,
245 };
246 }
247
248 let mut line_count = 0;
250 let mut byte_count: usize = 0;
251 let mut truncated_by = None;
252
253 let mut iter = content.split('\n').peekable();
254 let mut i = 0;
255 while let Some(line) = iter.next() {
256 if i >= max_lines {
257 truncated_by = Some(TruncatedBy::Lines);
258 break;
259 }
260
261 let has_newline = iter.peek().is_some();
263 let line_len = line.len() + usize::from(has_newline);
264
265 if byte_count + line_len > max_bytes {
266 truncated_by = Some(TruncatedBy::Bytes);
267 break;
268 }
269
270 line_count += 1;
271 byte_count += line_len;
272 i += 1;
273 }
274
275 content.truncate(byte_count);
277
278 TruncationResult {
279 truncated: truncated_by.is_some(),
280 truncated_by,
281 total_lines,
282 total_bytes,
283 output_lines: line_count,
284 output_bytes: byte_count,
285 last_line_partial: false,
286 first_line_exceeds_limit: false,
287 max_lines,
288 max_bytes,
289 content,
290 }
291}
292
293#[allow(clippy::too_many_lines)]
299pub fn truncate_tail(
300 content: impl Into<String>,
301 max_lines: usize,
302 max_bytes: usize,
303) -> TruncationResult {
304 let mut content = content.into();
305 let total_bytes = content.len();
306
307 let mut total_lines = memchr::memchr_iter(b'\n', content.as_bytes()).count();
310 if !content.ends_with('\n') && !content.is_empty() {
311 total_lines += 1;
312 }
313 if content.is_empty() {
314 total_lines = 0;
315 }
316
317 if max_lines == 0 {
320 let truncated = !content.is_empty();
321 return TruncationResult {
322 content: String::new(),
323 truncated,
324 truncated_by: if truncated {
325 Some(TruncatedBy::Lines)
326 } else {
327 None
328 },
329 total_lines,
330 total_bytes,
331 output_lines: 0,
332 output_bytes: 0,
333 last_line_partial: false,
334 first_line_exceeds_limit: false,
335 max_lines,
336 max_bytes,
337 };
338 }
339
340 if total_lines <= max_lines && total_bytes <= max_bytes {
342 return TruncationResult {
343 content,
344 truncated: false,
345 truncated_by: None,
346 total_lines,
347 total_bytes,
348 output_lines: total_lines,
349 output_bytes: total_bytes,
350 last_line_partial: false,
351 first_line_exceeds_limit: false,
352 max_lines,
353 max_bytes,
354 };
355 }
356
357 let mut line_count = 0usize;
358 let mut byte_count = 0usize;
359 let mut start_idx = content.len();
360 let mut partial_output: Option<String> = None;
361 let mut truncated_by = None;
362 let mut last_line_partial = false;
363
364 {
366 let bytes = content.as_bytes();
367 let mut search_limit = bytes.len();
371 if search_limit > 0 && bytes[search_limit - 1] == b'\n' {
372 search_limit -= 1;
373 }
374
375 loop {
376 let prev_newline = memchr::memrchr(b'\n', &bytes[..search_limit]);
378 let line_start = prev_newline.map_or(0, |idx| idx + 1);
379
380 let added_bytes = start_idx - line_start;
384
385 if byte_count + added_bytes > max_bytes {
386 let remaining = max_bytes.saturating_sub(byte_count);
389 if remaining > 0 && line_count == 0 {
390 let chunk = &content[line_start..start_idx];
391 let truncated_chunk = truncate_string_to_bytes_from_end(chunk, remaining);
392 if !truncated_chunk.is_empty() {
393 partial_output = Some(truncated_chunk);
394 last_line_partial = true;
395 }
396 }
397 truncated_by = Some(TruncatedBy::Bytes);
398 break;
399 }
400
401 line_count += 1;
402 byte_count += added_bytes;
403 start_idx = line_start;
404
405 if line_count >= max_lines {
406 truncated_by = Some(TruncatedBy::Lines);
407 break;
408 }
409
410 if line_start == 0 {
411 break;
412 }
413
414 search_limit = line_start - 1;
420 }
421 } let partial_suffix = if last_line_partial {
426 Some(content[start_idx..].to_string())
427 } else {
428 None
429 };
430
431 let mut output = partial_output.unwrap_or_else(|| {
432 drop(content.drain(..start_idx));
433 content
434 });
435
436 if let Some(suffix) = partial_suffix {
451 output.push_str(&suffix);
457 let mut count = memchr::memchr_iter(b'\n', output.as_bytes()).count();
460 if !output.ends_with('\n') && !output.is_empty() {
461 count += 1;
462 }
463 if output.is_empty() {
464 count = 0;
465 }
466 line_count = count;
467 }
468
469 let output_bytes = output.len();
470
471 TruncationResult {
472 content: output,
473 truncated: truncated_by.is_some(),
474 truncated_by,
475 total_lines,
476 total_bytes,
477 output_lines: line_count,
478 output_bytes,
479 last_line_partial,
480 first_line_exceeds_limit: false,
481 max_lines,
482 max_bytes,
483 }
484}
485
486fn truncate_string_to_bytes_from_end(s: &str, max_bytes: usize) -> String {
488 let bytes = s.as_bytes();
489 if bytes.len() <= max_bytes {
490 return s.to_string();
491 }
492
493 let mut start = bytes.len().saturating_sub(max_bytes);
494 while start < bytes.len() && (bytes[start] & 0b1100_0000) == 0b1000_0000 {
495 start += 1;
496 }
497
498 std::str::from_utf8(&bytes[start..])
499 .map(str::to_string)
500 .unwrap_or_default()
501}
502
503#[allow(clippy::cast_precision_loss)]
505fn format_size(bytes: usize) -> String {
506 const KB: usize = 1024;
507 const MB: usize = 1024 * 1024;
508
509 if bytes >= MB {
510 format!("{:.1}MB", bytes as f64 / MB as f64)
511 } else if bytes >= KB {
512 format!("{:.1}KB", bytes as f64 / KB as f64)
513 } else {
514 format!("{bytes}B")
515 }
516}
517
518fn js_string_length(s: &str) -> usize {
519 s.encode_utf16().count()
521}
522
523fn is_special_unicode_space(c: char) -> bool {
528 matches!(c, '\u{00A0}' | '\u{202F}' | '\u{205F}' | '\u{3000}')
529 || ('\u{2000}'..='\u{200A}').contains(&c)
530}
531
532fn normalize_unicode_spaces(s: &str) -> String {
533 s.chars()
534 .map(|c| if is_special_unicode_space(c) { ' ' } else { c })
535 .collect()
536}
537
538fn normalize_quotes(s: &str) -> String {
539 s.replace(['\u{2018}', '\u{2019}'], "'")
540 .replace(['\u{201C}', '\u{201D}', '\u{201E}', '\u{201F}'], "\"")
541}
542
543fn normalize_dashes(s: &str) -> String {
544 s.replace(
545 [
546 '\u{2010}', '\u{2011}', '\u{2012}', '\u{2013}', '\u{2014}', '\u{2015}', '\u{2212}',
547 ],
548 "-",
549 )
550}
551
552fn normalize_for_match(s: &str) -> String {
553 let mut out = String::with_capacity(s.len());
556 for c in s.chars() {
557 match c {
558 c if is_special_unicode_space(c) => out.push(' '),
560 '\u{2018}' | '\u{2019}' => out.push('\''),
562 '\u{201C}' | '\u{201D}' | '\u{201E}' | '\u{201F}' => out.push('"'),
564 '\u{2010}' | '\u{2011}' | '\u{2012}' | '\u{2013}' | '\u{2014}' | '\u{2015}'
566 | '\u{2212}' => out.push('-'),
567 c => out.push(c),
569 }
570 }
571 out
572}
573
574fn normalize_line_for_match(line: &str) -> String {
575 normalize_for_match(line.trim_end())
576}
577
578fn expand_path(file_path: &str) -> String {
579 let normalized = normalize_unicode_spaces(file_path);
580 if normalized == "~" {
581 return dirs::home_dir()
582 .unwrap_or_else(|| PathBuf::from("~"))
583 .to_string_lossy()
584 .to_string();
585 }
586 if let Some(rest) = normalized.strip_prefix("~/") {
587 let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("~"));
588 return home.join(rest).to_string_lossy().to_string();
589 }
590 normalized
591}
592
593fn resolve_to_cwd(file_path: &str, cwd: &Path) -> PathBuf {
595 let expanded = expand_path(file_path);
596 let expanded_path = PathBuf::from(expanded);
597 if expanded_path.is_absolute() {
598 expanded_path
599 } else {
600 cwd.join(expanded_path)
601 }
602}
603
604fn try_mac_os_screenshot_path(file_path: &str) -> String {
605 file_path
607 .replace(" AM.", "\u{202F}AM.")
608 .replace(" PM.", "\u{202F}PM.")
609}
610
611fn try_curly_quote_variant(file_path: &str) -> String {
612 file_path.replace('\'', "\u{2019}")
614}
615
616fn try_nfd_variant(file_path: &str) -> String {
617 use unicode_normalization::UnicodeNormalization;
620 file_path.nfd().collect::<String>()
621}
622
623fn file_exists(path: &Path) -> bool {
624 std::fs::metadata(path).is_ok()
625}
626
627pub(crate) fn resolve_read_path(file_path: &str, cwd: &Path) -> PathBuf {
629 let resolved = resolve_to_cwd(file_path, cwd);
630 if file_exists(&resolved) {
631 return resolved;
632 }
633
634 let Some(resolved_str) = resolved.to_str() else {
635 return resolved;
636 };
637
638 let am_pm_variant = try_mac_os_screenshot_path(resolved_str);
639 if am_pm_variant != resolved_str && file_exists(Path::new(&am_pm_variant)) {
640 return PathBuf::from(am_pm_variant);
641 }
642
643 let nfd_variant = try_nfd_variant(resolved_str);
644 if nfd_variant != resolved_str && file_exists(Path::new(&nfd_variant)) {
645 return PathBuf::from(nfd_variant);
646 }
647
648 let curly_variant = try_curly_quote_variant(resolved_str);
649 if curly_variant != resolved_str && file_exists(Path::new(&curly_variant)) {
650 return PathBuf::from(curly_variant);
651 }
652
653 let nfd_curly_variant = try_curly_quote_variant(&nfd_variant);
654 if nfd_curly_variant != resolved_str && file_exists(Path::new(&nfd_curly_variant)) {
655 return PathBuf::from(nfd_curly_variant);
656 }
657
658 resolved
659}
660
661#[derive(Debug, Clone, Default)]
667pub struct ProcessedFiles {
668 pub text: String,
669 pub images: Vec<ImageContent>,
670}
671
672fn normalize_dot_segments(path: &Path) -> PathBuf {
673 use std::ffi::{OsStr, OsString};
674 use std::path::Component;
675
676 let mut out = PathBuf::new();
677 let mut normals: Vec<OsString> = Vec::new();
678 let mut has_prefix = false;
679 let mut has_root = false;
680
681 for component in path.components() {
682 match component {
683 Component::Prefix(prefix) => {
684 out.push(prefix.as_os_str());
685 has_prefix = true;
686 }
687 Component::RootDir => {
688 out.push(component.as_os_str());
689 has_root = true;
690 }
691 Component::CurDir => {}
692 Component::ParentDir => match normals.last() {
693 Some(last) if last.as_os_str() != OsStr::new("..") => {
694 normals.pop();
695 }
696 _ => {
697 if !has_root && !has_prefix {
698 normals.push(OsString::from(".."));
699 }
700 }
701 },
702 Component::Normal(part) => normals.push(part.to_os_string()),
703 }
704 }
705
706 for part in normals {
707 out.push(part);
708 }
709
710 out
711}
712
713#[cfg(feature = "fuzzing")]
714pub fn fuzz_normalize_dot_segments(path: &Path) -> PathBuf {
715 normalize_dot_segments(path)
716}
717
718fn escape_file_tag_attribute(value: &str) -> String {
719 let mut escaped = String::with_capacity(value.len());
720 for ch in value.chars() {
721 match ch {
722 '&' => escaped.push_str("&"),
723 '"' => escaped.push_str("""),
724 '<' => escaped.push_str("<"),
725 '>' => escaped.push_str(">"),
726 '\n' => escaped.push_str(" "),
727 '\r' => escaped.push_str(" "),
728 '\t' => escaped.push_str("	"),
729 _ => escaped.push(ch),
730 }
731 }
732 escaped
733}
734
735fn escaped_file_tag_name(path: &Path) -> String {
736 escape_file_tag_attribute(&path.display().to_string())
737}
738
739fn append_file_notice_block(out: &mut String, path: &Path, notice: &str) {
740 let path_str = escaped_file_tag_name(path);
741 let _ = writeln!(out, "<file name=\"{path_str}\">\n{notice}\n</file>");
742}
743
744fn append_image_file_ref(out: &mut String, path: &Path, note: Option<&str>) {
745 let path_str = escaped_file_tag_name(path);
746 match note {
747 Some(text) => {
748 let _ = writeln!(out, "<file name=\"{path_str}\">{text}</file>");
749 }
750 None => {
751 let _ = writeln!(out, "<file name=\"{path_str}\"></file>");
752 }
753 }
754}
755
756fn append_text_file_block(out: &mut String, path: &Path, bytes: &[u8]) {
757 let content = String::from_utf8_lossy(bytes);
758 let path_str = escaped_file_tag_name(path);
759 let _ = writeln!(out, "<file name=\"{path_str}\">");
760
761 let truncation = truncate_head(content.into_owned(), DEFAULT_MAX_LINES, DEFAULT_MAX_BYTES);
762 let needs_trailing_newline = !truncation.truncated && !truncation.content.ends_with('\n');
763 out.push_str(&truncation.content);
764
765 if truncation.truncated {
766 let _ = write!(
767 out,
768 "\n... [Truncated: showing {}/{} lines, {}/{} bytes]",
769 truncation.output_lines,
770 truncation.total_lines,
771 format_size(truncation.output_bytes),
772 format_size(truncation.total_bytes)
773 );
774 } else if needs_trailing_newline {
775 out.push('\n');
776 }
777 let _ = writeln!(out, "</file>");
778}
779
780fn maybe_append_image_argument(
781 out: &mut ProcessedFiles,
782 absolute_path: &Path,
783 bytes: &[u8],
784 auto_resize_images: bool,
785) -> Result<bool> {
786 let Some(mime_type) = detect_supported_image_mime_type_from_bytes(bytes) else {
787 return Ok(false);
788 };
789
790 let resized = if auto_resize_images {
791 resize_image_if_needed(bytes, mime_type)?
792 } else {
793 ResizedImage::original(bytes.to_vec(), mime_type)
794 };
795
796 if resized.bytes.len() > IMAGE_MAX_BYTES {
797 let msg = if resized.resized {
798 format!(
799 "[Image is too large ({} bytes) after resizing. Max allowed is {} bytes.]",
800 resized.bytes.len(),
801 IMAGE_MAX_BYTES
802 )
803 } else {
804 format!(
805 "[Image is too large ({} bytes). Max allowed is {} bytes.]",
806 resized.bytes.len(),
807 IMAGE_MAX_BYTES
808 )
809 };
810 append_file_notice_block(&mut out.text, absolute_path, &msg);
811 return Ok(true);
812 }
813
814 let base64_data =
815 base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &resized.bytes);
816 out.images.push(ImageContent {
817 data: base64_data,
818 mime_type: resized.mime_type.to_string(),
819 });
820
821 let note = if resized.resized {
822 if let (Some(ow), Some(oh), Some(w), Some(h)) = (
823 resized.original_width,
824 resized.original_height,
825 resized.width,
826 resized.height,
827 ) {
828 let scale = f64::from(ow) / f64::from(w);
829 Some(format!(
830 "[Image: original {ow}x{oh}, displayed at {w}x{h}. Multiply coordinates by {scale:.2} to map to original image.]"
831 ))
832 } else {
833 None
834 }
835 } else {
836 None
837 };
838 append_image_file_ref(&mut out.text, absolute_path, note.as_deref());
839 Ok(true)
840}
841
842pub fn process_file_arguments(
850 file_args: &[String],
851 cwd: &Path,
852 auto_resize_images: bool,
853) -> Result<ProcessedFiles> {
854 let mut out = ProcessedFiles::default();
855
856 for file_arg in file_args {
857 let resolved = resolve_read_path(file_arg, cwd);
858 let absolute_path = normalize_dot_segments(&resolved);
859
860 let meta = std::fs::metadata(&absolute_path).map_err(|e| {
861 Error::tool(
862 "read",
863 format!("Cannot access file {}: {e}", absolute_path.display()),
864 )
865 })?;
866 if meta.len() == 0 {
867 continue;
868 }
869
870 if meta.len() > READ_TOOL_MAX_BYTES {
871 append_file_notice_block(
872 &mut out.text,
873 &absolute_path,
874 &format!(
875 "[File is too large ({} bytes). Max allowed is {} bytes.]",
876 meta.len(),
877 READ_TOOL_MAX_BYTES
878 ),
879 );
880 continue;
881 }
882
883 let bytes = std::fs::read(&absolute_path).map_err(|e| {
884 Error::tool(
885 "read",
886 format!("Could not read file {}: {e}", absolute_path.display()),
887 )
888 })?;
889
890 if maybe_append_image_argument(&mut out, &absolute_path, &bytes, auto_resize_images)? {
891 continue;
892 }
893
894 append_text_file_block(&mut out.text, &absolute_path, &bytes);
895 }
896
897 Ok(out)
898}
899
900fn resolve_path(file_path: &str, cwd: &Path) -> PathBuf {
903 resolve_to_cwd(file_path, cwd)
904}
905
906#[cfg(feature = "fuzzing")]
907pub fn fuzz_resolve_path(file_path: &str, cwd: &Path) -> PathBuf {
908 resolve_path(file_path, cwd)
909}
910
911pub(crate) fn detect_supported_image_mime_type_from_bytes(bytes: &[u8]) -> Option<&'static str> {
912 if bytes.len() >= 8 && bytes.starts_with(b"\x89PNG\r\n\x1A\n") {
914 return Some("image/png");
915 }
916 if bytes.len() >= 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF {
917 return Some("image/jpeg");
918 }
919 if bytes.len() >= 6 && (bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a")) {
920 return Some("image/gif");
921 }
922 if bytes.len() >= 12 && bytes.starts_with(b"RIFF") && &bytes[8..12] == b"WEBP" {
923 return Some("image/webp");
924 }
925 None
926}
927
928#[derive(Debug, Clone)]
929pub(crate) struct ResizedImage {
930 pub(crate) bytes: Vec<u8>,
931 pub(crate) mime_type: &'static str,
932 pub(crate) resized: bool,
933 pub(crate) width: Option<u32>,
934 pub(crate) height: Option<u32>,
935 pub(crate) original_width: Option<u32>,
936 pub(crate) original_height: Option<u32>,
937}
938
939impl ResizedImage {
940 pub(crate) const fn original(bytes: Vec<u8>, mime_type: &'static str) -> Self {
941 Self {
942 bytes,
943 mime_type,
944 resized: false,
945 width: None,
946 height: None,
947 original_width: None,
948 original_height: None,
949 }
950 }
951}
952
953#[cfg(feature = "image-resize")]
954#[allow(clippy::too_many_lines)]
955pub(crate) fn resize_image_if_needed(
956 bytes: &[u8],
957 mime_type: &'static str,
958) -> Result<ResizedImage> {
959 use image::codecs::jpeg::JpegEncoder;
970 use image::codecs::png::PngEncoder;
971 use image::imageops::FilterType;
972 use image::{GenericImageView, ImageEncoder, ImageReader, Limits};
973 use std::io::Cursor;
974
975 const MAX_WIDTH: u32 = 2000;
976 const MAX_HEIGHT: u32 = 2000;
977 const DEFAULT_JPEG_QUALITY: u8 = 80;
978 const QUALITY_STEPS: [u8; 4] = [85, 70, 55, 40];
979 const SCALE_STEPS: [f64; 5] = [1.0, 0.75, 0.5, 0.35, 0.25];
980
981 fn scale_u32(value: u32, numerator: u32, denominator: u32) -> u32 {
982 let den = u64::from(denominator).max(1);
983 let num = u64::from(value) * u64::from(numerator);
984 let rounded = (num + den / 2) / den;
985 u32::try_from(rounded).unwrap_or(u32::MAX)
986 }
987
988 fn encode_png(img: &image::DynamicImage) -> Result<Vec<u8>> {
989 let rgba = img.to_rgba8();
990 let mut out = Vec::new();
991 PngEncoder::new(&mut out)
992 .write_image(
993 rgba.as_raw(),
994 rgba.width(),
995 rgba.height(),
996 image::ExtendedColorType::Rgba8,
997 )
998 .map_err(|e| Error::tool("read", format!("Failed to encode PNG: {e}")))?;
999 Ok(out)
1000 }
1001
1002 fn encode_jpeg(img: &image::DynamicImage, quality: u8) -> Result<Vec<u8>> {
1003 let rgb = img.to_rgb8();
1004 let mut out = Vec::new();
1005 JpegEncoder::new_with_quality(&mut out, quality)
1006 .write_image(
1007 rgb.as_raw(),
1008 rgb.width(),
1009 rgb.height(),
1010 image::ExtendedColorType::Rgb8,
1011 )
1012 .map_err(|e| Error::tool("read", format!("Failed to encode JPEG: {e}")))?;
1013 Ok(out)
1014 }
1015
1016 fn try_both_formats(
1017 img: &image::DynamicImage,
1018 width: u32,
1019 height: u32,
1020 jpeg_quality: u8,
1021 ) -> Result<(Vec<u8>, &'static str)> {
1022 let resized = img.resize_exact(width, height, FilterType::Lanczos3);
1023 let png = encode_png(&resized)?;
1024 let jpeg = encode_jpeg(&resized, jpeg_quality)?;
1025 if png.len() <= jpeg.len() {
1026 Ok((png, "image/png"))
1027 } else {
1028 Ok((jpeg, "image/jpeg"))
1029 }
1030 }
1031
1032 let mut limits = Limits::default();
1035 limits.max_alloc = Some(128 * 1024 * 1024);
1036
1037 let reader = ImageReader::new(Cursor::new(bytes))
1038 .with_guessed_format()
1039 .map_err(|e| Error::tool("read", format!("Failed to detect image format: {e}")))?;
1040
1041 let mut reader = reader;
1042 reader.limits(limits);
1043
1044 let Ok(img) = reader.decode() else {
1045 return Ok(ResizedImage::original(bytes.to_vec(), mime_type));
1046 };
1047
1048 let (original_width, original_height) = img.dimensions();
1049 let original_size = bytes.len();
1050
1051 if original_width <= MAX_WIDTH
1052 && original_height <= MAX_HEIGHT
1053 && original_size <= IMAGE_MAX_BYTES
1054 {
1055 return Ok(ResizedImage {
1056 bytes: bytes.to_vec(),
1057 mime_type,
1058 resized: false,
1059 width: Some(original_width),
1060 height: Some(original_height),
1061 original_width: Some(original_width),
1062 original_height: Some(original_height),
1063 });
1064 }
1065
1066 let mut target_width = original_width;
1067 let mut target_height = original_height;
1068
1069 if target_width > MAX_WIDTH {
1070 target_height = scale_u32(target_height, MAX_WIDTH, target_width);
1071 target_width = MAX_WIDTH;
1072 }
1073 if target_height > MAX_HEIGHT {
1074 target_width = scale_u32(target_width, MAX_HEIGHT, target_height);
1075 target_height = MAX_HEIGHT;
1076 }
1077
1078 let mut best = try_both_formats(&img, target_width, target_height, DEFAULT_JPEG_QUALITY)?;
1079 let mut final_width = target_width;
1080 let mut final_height = target_height;
1081
1082 if best.0.len() <= IMAGE_MAX_BYTES {
1083 return Ok(ResizedImage {
1084 bytes: best.0,
1085 mime_type: best.1,
1086 resized: true,
1087 width: Some(final_width),
1088 height: Some(final_height),
1089 original_width: Some(original_width),
1090 original_height: Some(original_height),
1091 });
1092 }
1093
1094 for quality in QUALITY_STEPS {
1095 best = try_both_formats(&img, target_width, target_height, quality)?;
1096 if best.0.len() <= IMAGE_MAX_BYTES {
1097 return Ok(ResizedImage {
1098 bytes: best.0,
1099 mime_type: best.1,
1100 resized: true,
1101 width: Some(final_width),
1102 height: Some(final_height),
1103 original_width: Some(original_width),
1104 original_height: Some(original_height),
1105 });
1106 }
1107 }
1108
1109 for scale in SCALE_STEPS {
1110 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
1111 {
1112 final_width = (f64::from(target_width) * scale).round() as u32;
1113 final_height = (f64::from(target_height) * scale).round() as u32;
1114 }
1115
1116 if final_width < 100 || final_height < 100 {
1117 break;
1118 }
1119
1120 for quality in QUALITY_STEPS {
1121 best = try_both_formats(&img, final_width, final_height, quality)?;
1122 if best.0.len() <= IMAGE_MAX_BYTES {
1123 return Ok(ResizedImage {
1124 bytes: best.0,
1125 mime_type: best.1,
1126 resized: true,
1127 width: Some(final_width),
1128 height: Some(final_height),
1129 original_width: Some(original_width),
1130 original_height: Some(original_height),
1131 });
1132 }
1133 }
1134 }
1135
1136 Ok(ResizedImage {
1137 bytes: best.0,
1138 mime_type: best.1,
1139 resized: true,
1140 width: Some(final_width),
1141 height: Some(final_height),
1142 original_width: Some(original_width),
1143 original_height: Some(original_height),
1144 })
1145}
1146
1147#[cfg(not(feature = "image-resize"))]
1148pub(crate) fn resize_image_if_needed(
1149 bytes: &[u8],
1150 mime_type: &'static str,
1151) -> Result<ResizedImage> {
1152 Ok(ResizedImage::original(bytes.to_vec(), mime_type))
1153}
1154
1155pub struct ToolRegistry {
1165 tools: Vec<Box<dyn Tool>>,
1166}
1167
1168impl ToolRegistry {
1169 pub fn new(enabled: &[&str], cwd: &Path, config: Option<&Config>) -> Self {
1171 let mut tools: Vec<Box<dyn Tool>> = Vec::new();
1172 let shell_path = config.and_then(|c| c.shell_path.clone());
1173 let shell_command_prefix = config.and_then(|c| c.shell_command_prefix.clone());
1174 let image_auto_resize = config.is_none_or(Config::image_auto_resize);
1175 let block_images = config
1176 .and_then(|c| c.images.as_ref().and_then(|i| i.block_images))
1177 .unwrap_or(false);
1178
1179 for name in enabled {
1180 match *name {
1181 "read" => tools.push(Box::new(ReadTool::with_settings(
1182 cwd,
1183 image_auto_resize,
1184 block_images,
1185 ))),
1186 "bash" => tools.push(Box::new(BashTool::with_shell(
1187 cwd,
1188 shell_path.clone(),
1189 shell_command_prefix.clone(),
1190 ))),
1191 "edit" => tools.push(Box::new(EditTool::new(cwd))),
1192 "write" => tools.push(Box::new(WriteTool::new(cwd))),
1193 "grep" => tools.push(Box::new(GrepTool::new(cwd))),
1194 "find" => tools.push(Box::new(FindTool::new(cwd))),
1195 "ls" => tools.push(Box::new(LsTool::new(cwd))),
1196 _ => {}
1197 }
1198 }
1199
1200 Self { tools }
1201 }
1202
1203 pub fn from_tools(tools: Vec<Box<dyn Tool>>) -> Self {
1205 Self { tools }
1206 }
1207
1208 pub fn into_tools(self) -> Vec<Box<dyn Tool>> {
1210 self.tools
1211 }
1212
1213 pub fn push(&mut self, tool: Box<dyn Tool>) {
1215 self.tools.push(tool);
1216 }
1217
1218 pub fn extend<I>(&mut self, tools: I)
1220 where
1221 I: IntoIterator<Item = Box<dyn Tool>>,
1222 {
1223 self.tools.extend(tools);
1224 }
1225
1226 pub fn tools(&self) -> &[Box<dyn Tool>] {
1228 &self.tools
1229 }
1230
1231 pub fn get(&self, name: &str) -> Option<&dyn Tool> {
1233 self.tools
1234 .iter()
1235 .find(|t| t.name() == name)
1236 .map(std::convert::AsRef::as_ref)
1237 }
1238}
1239
1240#[derive(Debug, Deserialize)]
1246#[serde(rename_all = "camelCase")]
1247struct ReadInput {
1248 path: String,
1249 offset: Option<i64>,
1250 limit: Option<i64>,
1251}
1252
1253pub struct ReadTool {
1254 cwd: PathBuf,
1255 auto_resize: bool,
1257 block_images: bool,
1258}
1259
1260impl ReadTool {
1261 pub fn new(cwd: &Path) -> Self {
1262 Self {
1263 cwd: cwd.to_path_buf(),
1264 auto_resize: true,
1265 block_images: false,
1266 }
1267 }
1268
1269 pub fn with_settings(cwd: &Path, auto_resize: bool, block_images: bool) -> Self {
1270 Self {
1271 cwd: cwd.to_path_buf(),
1272 auto_resize,
1273 block_images,
1274 }
1275 }
1276}
1277
1278async fn read_some<R>(reader: &mut R, dst: &mut [u8]) -> std::io::Result<usize>
1279where
1280 R: AsyncRead + Unpin,
1281{
1282 if dst.is_empty() {
1283 return Ok(0);
1284 }
1285
1286 futures::future::poll_fn(|cx| {
1287 let mut read_buf = ReadBuf::new(dst);
1288 match std::pin::Pin::new(&mut *reader).poll_read(cx, &mut read_buf) {
1289 std::task::Poll::Ready(Ok(())) => std::task::Poll::Ready(Ok(read_buf.filled().len())),
1290 std::task::Poll::Ready(Err(err)) => std::task::Poll::Ready(Err(err)),
1291 std::task::Poll::Pending => std::task::Poll::Pending,
1292 }
1293 })
1294 .await
1295}
1296
1297#[async_trait]
1298#[allow(clippy::unnecessary_literal_bound)]
1299impl Tool for ReadTool {
1300 fn name(&self) -> &str {
1301 "read"
1302 }
1303 fn label(&self) -> &str {
1304 "read"
1305 }
1306 fn description(&self) -> &str {
1307 "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 50KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete."
1308 }
1309
1310 fn parameters(&self) -> serde_json::Value {
1311 serde_json::json!({
1312 "type": "object",
1313 "properties": {
1314 "path": {
1315 "type": "string",
1316 "description": "Path to the file to read (relative or absolute)"
1317 },
1318 "offset": {
1319 "type": "integer",
1320 "description": "Line number to start reading from (1-indexed)"
1321 },
1322 "limit": {
1323 "type": "integer",
1324 "description": "Maximum number of lines to read"
1325 }
1326 },
1327 "required": ["path"]
1328 })
1329 }
1330
1331 fn is_read_only(&self) -> bool {
1332 true
1333 }
1334
1335 #[allow(clippy::too_many_lines)]
1336 async fn execute(
1337 &self,
1338 _tool_call_id: &str,
1339 input: serde_json::Value,
1340 _on_update: Option<Box<dyn Fn(ToolUpdate) + Send + Sync>>,
1341 ) -> Result<ToolOutput> {
1342 let input: ReadInput =
1343 serde_json::from_value(input).map_err(|e| Error::validation(e.to_string()))?;
1344
1345 if matches!(input.limit, Some(limit) if limit <= 0) {
1346 return Err(Error::validation(
1347 "`limit` must be greater than 0".to_string(),
1348 ));
1349 }
1350 if matches!(input.offset, Some(offset) if offset < 0) {
1351 return Err(Error::validation(
1352 "`offset` must be non-negative".to_string(),
1353 ));
1354 }
1355
1356 let path = resolve_read_path(&input.path, &self.cwd);
1357
1358 if let Ok(meta) = asupersync::fs::metadata(&path).await {
1359 if meta.len() > READ_TOOL_MAX_BYTES {
1360 return Err(Error::tool(
1361 "read",
1362 format!(
1363 "File is too large ({} bytes). Max allowed is {} bytes. For large files, use `bash` with `grep`, `head`, `tail`, or `sed`.",
1364 meta.len(),
1365 READ_TOOL_MAX_BYTES
1366 ),
1367 ));
1368 }
1369 }
1370
1371 let mut file = asupersync::fs::File::open(&path)
1372 .await
1373 .map_err(|e| Error::tool("read", e.to_string()))?;
1374
1375 let mut buffer = [0u8; 8192];
1377 let mut initial_read = 0;
1378 loop {
1379 let n = read_some(&mut file, &mut buffer[initial_read..])
1380 .await
1381 .map_err(|e| Error::tool("read", format!("Failed to read file: {e}")))?;
1382 if n == 0 {
1383 break;
1384 }
1385 initial_read += n;
1386 if initial_read == buffer.len() {
1387 break;
1388 }
1389 }
1390 let initial_bytes = &buffer[..initial_read];
1391
1392 if let Some(mime_type) = detect_supported_image_mime_type_from_bytes(initial_bytes) {
1393 if self.block_images {
1394 return Err(Error::tool(
1395 "read",
1396 "Images are blocked by configuration".to_string(),
1397 ));
1398 }
1399
1400 let mut all_bytes = Vec::with_capacity(initial_read);
1405 all_bytes.extend_from_slice(initial_bytes);
1406
1407 let remaining_limit = IMAGE_MAX_BYTES.saturating_sub(initial_read);
1408 let mut limiter = file.take((remaining_limit as u64).saturating_add(1));
1409 limiter
1410 .read_to_end(&mut all_bytes)
1411 .await
1412 .map_err(|e| Error::tool("read", format!("Failed to read image: {e}")))?;
1413
1414 if all_bytes.len() > IMAGE_MAX_BYTES {
1415 return Err(Error::tool(
1416 "read",
1417 format!(
1418 "Image is too large ({} bytes). Max allowed is {} bytes.",
1419 all_bytes.len(),
1420 IMAGE_MAX_BYTES
1421 ),
1422 ));
1423 }
1424
1425 let resized = if self.auto_resize {
1426 resize_image_if_needed(&all_bytes, mime_type)?
1427 } else {
1428 ResizedImage::original(all_bytes, mime_type)
1429 };
1430
1431 let base64_data =
1432 base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &resized.bytes);
1433
1434 let mut note = format!("Read image file [{}]", resized.mime_type);
1435 if resized.resized {
1436 if let (Some(ow), Some(oh), Some(w), Some(h)) = (
1437 resized.original_width,
1438 resized.original_height,
1439 resized.width,
1440 resized.height,
1441 ) {
1442 let scale = f64::from(ow) / f64::from(w);
1443 let _ = write!(
1444 note,
1445 "\n[Image: original {ow}x{oh}, displayed at {w}x{h}. Multiply coordinates by {scale:.2} to map to original image.]"
1446 );
1447 }
1448 }
1449
1450 return Ok(ToolOutput {
1451 content: vec![
1452 ContentBlock::Text(TextContent::new(note)),
1453 ContentBlock::Image(ImageContent {
1454 data: base64_data,
1455 mime_type: resized.mime_type.to_string(),
1456 }),
1457 ],
1458 details: None,
1459 is_error: false,
1460 });
1461 }
1462
1463 if initial_read > 0 {
1470 file.seek(SeekFrom::Start(0))
1471 .await
1472 .map_err(|e| Error::tool("read", format!("Failed to seek: {e}")))?;
1473 }
1474
1475 let mut raw_content = Vec::new();
1476 let mut newlines_seen = 0usize;
1477
1478 let start_line_idx = match input.offset {
1480 Some(n) if n > 0 => n.saturating_sub(1).try_into().unwrap_or(usize::MAX),
1481 _ => 0,
1482 };
1483 let limit_lines = input
1484 .limit
1485 .map_or(usize::MAX, |l| l.try_into().unwrap_or(usize::MAX));
1486 let end_line_idx = start_line_idx.saturating_add(limit_lines);
1487
1488 let mut collecting = start_line_idx == 0;
1489 let mut buf = vec![0u8; 64 * 1024].into_boxed_slice(); let mut last_byte_was_newline = false;
1491
1492 let mut total_bytes_read = 0u64;
1496
1497 loop {
1498 let n = read_some(&mut file, &mut buf)
1499 .await
1500 .map_err(|e| Error::tool("read", e.to_string()))?;
1501 if n == 0 {
1502 break;
1503 }
1504 total_bytes_read = total_bytes_read.saturating_add(n as u64);
1505 if total_bytes_read > READ_TOOL_MAX_BYTES {
1506 return Err(Error::tool(
1507 "read",
1508 format!(
1509 "File grew beyond limit during read ({total_bytes_read} bytes). Max allowed is {READ_TOOL_MAX_BYTES} bytes."
1510 ),
1511 ));
1512 }
1513
1514 let chunk = &buf[..n];
1515 last_byte_was_newline = chunk[n - 1] == b'\n';
1516 let mut chunk_cursor = 0;
1517
1518 for pos in memchr::memchr_iter(b'\n', chunk) {
1519 if collecting {
1521 if newlines_seen + 1 == end_line_idx {
1523 if raw_content.len() < DEFAULT_MAX_BYTES {
1525 let remaining = DEFAULT_MAX_BYTES - raw_content.len();
1526 let slice_len = (pos + 1 - chunk_cursor).min(remaining);
1527 raw_content
1528 .extend_from_slice(&chunk[chunk_cursor..chunk_cursor + slice_len]);
1529 }
1530 collecting = false;
1531 chunk_cursor = pos + 1;
1532 }
1533 }
1534
1535 newlines_seen += 1;
1536
1537 if !collecting && newlines_seen == start_line_idx {
1539 collecting = true;
1540 chunk_cursor = pos + 1;
1541 }
1542 }
1543
1544 if collecting && chunk_cursor < chunk.len() && raw_content.len() < DEFAULT_MAX_BYTES {
1546 let remaining = DEFAULT_MAX_BYTES - raw_content.len();
1547 let slice_len = (chunk.len() - chunk_cursor).min(remaining);
1548 raw_content.extend_from_slice(&chunk[chunk_cursor..chunk_cursor + slice_len]);
1549 }
1550 }
1551
1552 let total_lines = if total_bytes_read == 0 {
1555 0
1556 } else if last_byte_was_newline {
1557 newlines_seen
1558 } else {
1559 newlines_seen + 1
1560 };
1561 let text_content = String::from_utf8_lossy(&raw_content).into_owned();
1562
1563 if total_lines == 0 {
1566 if input.offset.unwrap_or(0) > 0 {
1567 let offset_display = input.offset.unwrap_or(0);
1568 return Err(Error::tool(
1569 "read",
1570 format!(
1571 "Offset {offset_display} is beyond end of file ({total_lines} lines total)"
1572 ),
1573 ));
1574 }
1575 return Ok(ToolOutput {
1576 content: vec![ContentBlock::Text(TextContent::new(""))],
1577 details: None,
1578 is_error: false,
1579 });
1580 }
1581
1582 let start_line = start_line_idx;
1586 let start_line_display = start_line.saturating_add(1);
1587
1588 if start_line >= total_lines {
1589 let offset_display = input.offset.unwrap_or(0);
1590 return Err(Error::tool(
1591 "read",
1592 format!(
1593 "Offset {offset_display} is beyond end of file ({total_lines} lines total)"
1594 ),
1595 ));
1596 }
1597
1598 let max_lines_for_truncation = input
1599 .limit
1600 .and_then(|l| usize::try_from(l).ok())
1601 .unwrap_or(DEFAULT_MAX_LINES);
1602 let display_limit = max_lines_for_truncation.saturating_add(1);
1603
1604 let lines_to_take = limit_lines.min(display_limit);
1608
1609 let mut selected_content = String::new();
1610 let line_iter = text_content.split('\n');
1611
1612 let effective_iter = if text_content.ends_with('\n') {
1614 line_iter.take(lines_to_take)
1615 } else {
1616 line_iter.take(usize::MAX)
1617 };
1618
1619 let max_line_num = start_line.saturating_add(lines_to_take).min(total_lines);
1620 let line_num_width = max_line_num.to_string().len().max(5);
1621
1622 for (i, line) in effective_iter.enumerate() {
1623 if i >= lines_to_take || start_line + i >= total_lines {
1624 break;
1625 }
1626 if i > 0 {
1627 selected_content.push('\n');
1628 }
1629 let line_num = start_line + i + 1;
1630 let line = line.strip_suffix('\r').unwrap_or(line);
1631 let _ = write!(selected_content, "{line_num:>line_num_width$}→{line}");
1632
1633 if selected_content.len() > DEFAULT_MAX_BYTES * 2 {
1634 break;
1635 }
1636 }
1637
1638 let mut truncation = truncate_head(
1639 selected_content,
1640 max_lines_for_truncation,
1641 DEFAULT_MAX_BYTES,
1642 );
1643 truncation.total_lines = total_lines;
1644
1645 let mut output_text = std::mem::take(&mut truncation.content);
1646 let mut details: Option<serde_json::Value> = None;
1647
1648 if truncation.first_line_exceeds_limit {
1649 let first_line = text_content.split('\n').next().unwrap_or("");
1650 let first_line = first_line.strip_suffix('\r').unwrap_or(first_line);
1651 let first_line_size = format_size(first_line.len());
1652 output_text = format!(
1653 "[Line {start_line_display} is {first_line_size}, exceeds {} limit. Use bash: sed -n '{start_line_display}p' \"{}\" | head -c {DEFAULT_MAX_BYTES}]",
1654 format_size(DEFAULT_MAX_BYTES),
1655 input.path.replace('"', "\\\"")
1656 );
1657 details = Some(serde_json::json!({ "truncation": truncation }));
1658 } else if truncation.truncated {
1659 let end_line_display = start_line_display
1660 .saturating_add(truncation.output_lines)
1661 .saturating_sub(1);
1662 let next_offset = end_line_display.saturating_add(1);
1663
1664 if truncation.truncated_by == Some(TruncatedBy::Lines) {
1665 let _ = write!(
1666 output_text,
1667 "\n\n[Showing lines {start_line_display}-{end_line_display} of {total_lines}. Use offset={next_offset} to continue.]"
1668 );
1669 } else {
1670 let _ = write!(
1671 output_text,
1672 "\n\n[Showing lines {start_line_display}-{end_line_display} of {total_lines} ({} limit). Use offset={next_offset} to continue.]",
1673 format_size(DEFAULT_MAX_BYTES)
1674 );
1675 }
1676
1677 details = Some(serde_json::json!({ "truncation": truncation }));
1678 } else {
1679 let displayed_lines = text_content
1681 .split('\n')
1682 .count()
1683 .saturating_sub(usize::from(text_content.ends_with('\n')));
1684 let end_line_display = start_line_display
1685 .saturating_add(displayed_lines)
1686 .saturating_sub(1);
1687
1688 if end_line_display < total_lines {
1689 let remaining = total_lines.saturating_sub(end_line_display);
1690 let next_offset = end_line_display.saturating_add(1);
1691 let _ = write!(
1692 output_text,
1693 "\n\n[{remaining} more lines in file. Use offset={next_offset} to continue.]"
1694 );
1695 }
1696 }
1697
1698 Ok(ToolOutput {
1699 content: vec![ContentBlock::Text(TextContent::new(output_text))],
1700 details,
1701 is_error: false,
1702 })
1703 }
1704}
1705
1706#[derive(Debug, Deserialize)]
1712#[serde(rename_all = "camelCase")]
1713struct BashInput {
1714 command: String,
1715 timeout: Option<u64>,
1716}
1717
1718pub struct BashTool {
1719 cwd: PathBuf,
1720 shell_path: Option<String>,
1721 command_prefix: Option<String>,
1722}
1723
1724#[derive(Debug, Clone)]
1725pub struct BashRunResult {
1726 pub output: String,
1727 pub exit_code: i32,
1728 pub cancelled: bool,
1729 pub truncated: bool,
1730 pub full_output_path: Option<String>,
1731 pub truncation: Option<TruncationResult>,
1732}
1733
1734#[allow(clippy::unnecessary_lazy_evaluations)] fn exit_status_code(status: std::process::ExitStatus) -> i32 {
1736 status.code().unwrap_or_else(|| {
1737 #[cfg(unix)]
1738 {
1739 use std::os::unix::process::ExitStatusExt as _;
1740 status.signal().map_or(-1, |signal| -signal)
1741 }
1742 #[cfg(not(unix))]
1743 {
1744 -1
1745 }
1746 })
1747}
1748
1749#[allow(clippy::too_many_lines)]
1750pub(crate) async fn run_bash_command(
1751 cwd: &Path,
1752 shell_path: Option<&str>,
1753 command_prefix: Option<&str>,
1754 command: &str,
1755 timeout_secs: Option<u64>,
1756 on_update: Option<&(dyn Fn(ToolUpdate) + Send + Sync)>,
1757) -> Result<BashRunResult> {
1758 let timeout_secs = match timeout_secs {
1759 None => Some(DEFAULT_BASH_TIMEOUT_SECS),
1760 Some(0) => None,
1761 Some(value) => Some(value),
1762 };
1763 let command = command_prefix.filter(|p| !p.trim().is_empty()).map_or_else(
1764 || command.to_string(),
1765 |prefix| format!("{prefix}\n{command}"),
1766 );
1767 let command = format!("trap 'code=$?; wait; exit $code' EXIT\n{command}");
1768
1769 if !cwd.exists() {
1770 return Err(Error::tool(
1771 "bash",
1772 format!(
1773 "Working directory does not exist: {}\nCannot execute bash commands.",
1774 cwd.display()
1775 ),
1776 ));
1777 }
1778
1779 let shell = shell_path.unwrap_or_else(|| {
1780 for path in ["/bin/bash", "/usr/bin/bash", "/usr/local/bin/bash"] {
1781 if Path::new(path).exists() {
1782 return path;
1783 }
1784 }
1785 "sh"
1786 });
1787
1788 let mut child = Command::new(shell)
1789 .arg("-c")
1790 .arg(&command)
1791 .current_dir(cwd)
1792 .stdin(Stdio::null())
1793 .stdout(Stdio::piped())
1794 .stderr(Stdio::piped())
1795 .spawn()
1796 .map_err(|e| Error::tool("bash", format!("Failed to spawn shell: {e}")))?;
1797
1798 let stdout = child
1799 .stdout
1800 .take()
1801 .ok_or_else(|| Error::tool("bash", "Missing stdout".to_string()))?;
1802 let stderr = child
1803 .stderr
1804 .take()
1805 .ok_or_else(|| Error::tool("bash", "Missing stderr".to_string()))?;
1806
1807 let mut guard = ProcessGuard::new(child, true);
1809
1810 let (tx, rx) = mpsc::sync_channel::<Vec<u8>>(128);
1811 let tx_stdout = tx.clone();
1812 thread::spawn(move || pump_stream(stdout, &tx_stdout));
1813 thread::spawn(move || pump_stream(stderr, &tx));
1814
1815 let max_chunks_bytes = DEFAULT_MAX_BYTES.saturating_mul(2);
1816 let mut bash_output = BashOutputState::new(max_chunks_bytes);
1817 bash_output.timeout_ms = timeout_secs.map(|s| s.saturating_mul(1000));
1818
1819 let mut timed_out = false;
1820 let mut exit_code: Option<i32> = None;
1821 let start = Instant::now();
1822 let timeout = timeout_secs.map(Duration::from_secs);
1823 let mut terminate_deadline: Option<Instant> = None;
1824
1825 let tick = Duration::from_millis(10);
1826 loop {
1827 let mut updated = false;
1828 while let Ok(chunk) = rx.try_recv() {
1829 ingest_bash_chunk(chunk, &mut bash_output).await?;
1830 updated = true;
1831 }
1832
1833 if updated {
1834 emit_bash_update(&bash_output, on_update)?;
1835 }
1836
1837 match guard.try_wait_child() {
1838 Ok(Some(status)) => {
1839 exit_code = Some(exit_status_code(status));
1840 break;
1841 }
1842 Ok(None) => {}
1843 Err(err) => return Err(Error::tool("bash", err.to_string())),
1844 }
1845
1846 if let Some(deadline) = terminate_deadline {
1847 if Instant::now() >= deadline {
1848 if let Some(status) = guard
1849 .kill()
1850 .map_err(|err| Error::tool("bash", format!("Failed to kill process: {err}")))?
1851 {
1852 exit_code = Some(exit_status_code(status));
1853 }
1854 break; }
1856 } else if let Some(timeout) = timeout {
1857 if start.elapsed() >= timeout {
1858 timed_out = true;
1859 let pid = guard.child.as_ref().map(std::process::Child::id);
1860 terminate_process_tree(pid);
1861 terminate_deadline =
1862 Some(Instant::now() + Duration::from_secs(BASH_TERMINATE_GRACE_SECS));
1863 }
1864 }
1865
1866 let now = AgentCx::for_current_or_request()
1869 .cx()
1870 .timer_driver()
1871 .map_or_else(wall_now, |timer| timer.now());
1872 sleep(now, tick).await;
1873 }
1874
1875 let drain_deadline = Instant::now() + Duration::from_secs(2);
1876 loop {
1877 match rx.try_recv() {
1878 Ok(chunk) => ingest_bash_chunk(chunk, &mut bash_output).await?,
1879 Err(mpsc::TryRecvError::Empty) => {
1880 if Instant::now() >= drain_deadline {
1881 break;
1882 }
1883 let now = AgentCx::for_current_or_request()
1884 .cx()
1885 .timer_driver()
1886 .map_or_else(wall_now, |timer| timer.now());
1887 sleep(now, tick).await;
1888 }
1889 Err(mpsc::TryRecvError::Disconnected) => break,
1890 }
1891 }
1892
1893 drop(bash_output.temp_file.take());
1894
1895 let raw_output = concat_chunks(&bash_output.chunks);
1896 let full_output = String::from_utf8_lossy(&raw_output).into_owned();
1897 let full_output_last_line_len = full_output.split('\n').next_back().map_or(0, str::len);
1898
1899 let mut truncation = truncate_tail(full_output, DEFAULT_MAX_LINES, DEFAULT_MAX_BYTES);
1900 if bash_output.total_bytes > bash_output.chunks_bytes {
1901 truncation.truncated = true;
1902 truncation.truncated_by = Some(TruncatedBy::Bytes);
1903 truncation.total_bytes = bash_output.total_bytes;
1904 truncation.total_lines = line_count_from_newline_count(
1905 bash_output.total_bytes,
1906 bash_output.line_count,
1907 bash_output.last_byte_was_newline,
1908 );
1909 }
1910
1911 let mut output_text = if truncation.content.is_empty() {
1912 "(no output)".to_string()
1913 } else {
1914 std::mem::take(&mut truncation.content)
1915 };
1916
1917 let mut full_output_path = None;
1918 if truncation.truncated {
1919 if let Some(path) = bash_output.temp_file_path.as_ref() {
1920 full_output_path = Some(path.display().to_string());
1921 }
1922
1923 let start_line = truncation
1924 .total_lines
1925 .saturating_sub(truncation.output_lines)
1926 .saturating_add(1);
1927 let end_line = truncation.total_lines;
1928
1929 let display_path = full_output_path.as_deref().unwrap_or("undefined");
1930
1931 if truncation.last_line_partial {
1932 let last_line_size = format_size(full_output_last_line_len);
1933 let _ = write!(
1934 output_text,
1935 "\n\n[Showing last {} of line {end_line} (line is {last_line_size}). Full output: {display_path}]",
1936 format_size(truncation.output_bytes)
1937 );
1938 } else if truncation.truncated_by == Some(TruncatedBy::Lines) {
1939 let _ = write!(
1940 output_text,
1941 "\n\n[Showing lines {start_line}-{end_line} of {}. Full output: {display_path}]",
1942 truncation.total_lines
1943 );
1944 } else {
1945 let _ = write!(
1946 output_text,
1947 "\n\n[Showing lines {start_line}-{end_line} of {} ({} limit). Full output: {display_path}]",
1948 truncation.total_lines,
1949 format_size(DEFAULT_MAX_BYTES)
1950 );
1951 }
1952 }
1953
1954 let mut cancelled = false;
1955 if timed_out {
1956 cancelled = true;
1957 if !output_text.is_empty() {
1958 output_text.push_str("\n\n");
1959 }
1960 let timeout_display = timeout_secs.unwrap_or(0);
1961 let _ = write!(
1962 output_text,
1963 "Command timed out after {timeout_display} seconds"
1964 );
1965 }
1966
1967 let exit_code = exit_code.unwrap_or(-1);
1968 if !cancelled && exit_code != 0 {
1969 let _ = write!(output_text, "\n\nCommand exited with code {exit_code}");
1970 }
1971
1972 Ok(BashRunResult {
1973 output: output_text,
1974 exit_code,
1975 cancelled,
1976 truncated: truncation.truncated,
1977 full_output_path,
1978 truncation: if truncation.truncated {
1979 Some(truncation)
1980 } else {
1981 None
1982 },
1983 })
1984}
1985
1986impl BashTool {
1987 pub fn new(cwd: &Path) -> Self {
1988 Self {
1989 cwd: cwd.to_path_buf(),
1990 shell_path: None,
1991 command_prefix: None,
1992 }
1993 }
1994
1995 pub fn with_shell(
1996 cwd: &Path,
1997 shell_path: Option<String>,
1998 command_prefix: Option<String>,
1999 ) -> Self {
2000 Self {
2001 cwd: cwd.to_path_buf(),
2002 shell_path,
2003 command_prefix,
2004 }
2005 }
2006}
2007
2008#[async_trait]
2009#[allow(clippy::unnecessary_literal_bound)]
2010impl Tool for BashTool {
2011 fn name(&self) -> &str {
2012 "bash"
2013 }
2014 fn label(&self) -> &str {
2015 "bash"
2016 }
2017 fn description(&self) -> &str {
2018 "Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last 2000 lines or 50KB (whichever is hit first). If truncated, full output is saved to a temp file. `timeout` defaults to 120 seconds; set `timeout: 0` to disable."
2019 }
2020
2021 fn parameters(&self) -> serde_json::Value {
2022 serde_json::json!({
2023 "type": "object",
2024 "properties": {
2025 "command": {
2026 "type": "string",
2027 "description": "Bash command to execute"
2028 },
2029 "timeout": {
2030 "type": "integer",
2031 "description": "Timeout in seconds (default 120; set 0 to disable)"
2032 }
2033 },
2034 "required": ["command"]
2035 })
2036 }
2037
2038 #[allow(clippy::too_many_lines)]
2039 async fn execute(
2040 &self,
2041 _tool_call_id: &str,
2042 input: serde_json::Value,
2043 on_update: Option<Box<dyn Fn(ToolUpdate) + Send + Sync>>,
2044 ) -> Result<ToolOutput> {
2045 let input: BashInput =
2046 serde_json::from_value(input).map_err(|e| Error::validation(e.to_string()))?;
2047
2048 let result = run_bash_command(
2049 &self.cwd,
2050 self.shell_path.as_deref(),
2051 self.command_prefix.as_deref(),
2052 &input.command,
2053 input.timeout,
2054 on_update.as_deref(),
2055 )
2056 .await?;
2057
2058 let mut details_map = serde_json::Map::new();
2059 if let Some(truncation) = result.truncation.as_ref() {
2060 details_map.insert("truncation".to_string(), serde_json::to_value(truncation)?);
2061 }
2062 if let Some(path) = result.full_output_path.as_ref() {
2063 details_map.insert(
2064 "fullOutputPath".to_string(),
2065 serde_json::Value::String(path.clone()),
2066 );
2067 }
2068
2069 let details = if details_map.is_empty() {
2070 None
2071 } else {
2072 Some(serde_json::Value::Object(details_map))
2073 };
2074
2075 let is_error = result.cancelled || result.exit_code != 0;
2076
2077 Ok(ToolOutput {
2078 content: vec![ContentBlock::Text(TextContent::new(result.output))],
2079 details,
2080 is_error,
2081 })
2082 }
2083}
2084
2085#[derive(Debug, Deserialize)]
2091#[serde(rename_all = "camelCase")]
2092struct EditInput {
2093 path: String,
2094 old_text: String,
2095 new_text: String,
2096}
2097
2098pub struct EditTool {
2099 cwd: PathBuf,
2100}
2101
2102impl EditTool {
2103 pub fn new(cwd: &Path) -> Self {
2104 Self {
2105 cwd: cwd.to_path_buf(),
2106 }
2107 }
2108}
2109
2110fn strip_bom(s: &str) -> (&str, bool) {
2111 s.strip_prefix('\u{FEFF}')
2112 .map_or_else(|| (s, false), |stripped| (stripped, true))
2113}
2114
2115fn detect_line_ending(content: &str) -> &'static str {
2116 let crlf_idx = content.find("\r\n");
2117 let lf_idx = content.find('\n');
2118 if lf_idx.is_none() {
2119 return "\n";
2120 }
2121 let Some(crlf_idx) = crlf_idx else {
2122 return "\n";
2123 };
2124 let lf_idx = lf_idx.unwrap_or(usize::MAX);
2125 if crlf_idx < lf_idx { "\r\n" } else { "\n" }
2126}
2127
2128fn normalize_to_lf(text: &str) -> String {
2129 text.replace("\r\n", "\n").replace('\r', "\n")
2130}
2131
2132fn restore_line_endings(text: &str, ending: &str) -> String {
2133 if ending == "\r\n" {
2134 text.replace('\n', "\r\n")
2135 } else {
2136 text.to_string()
2137 }
2138}
2139
2140#[derive(Debug, Clone)]
2141struct FuzzyMatchResult {
2142 found: bool,
2143 index: usize,
2144 match_length: usize,
2145}
2146
2147fn map_normalized_range_to_original(
2151 content: &str,
2152 norm_match_start: usize,
2153 norm_match_len: usize,
2154) -> (usize, usize) {
2155 let mut norm_idx = 0;
2156 let mut orig_idx = 0;
2157 let mut match_start = None;
2158 let mut match_end = None;
2159 let norm_match_end = norm_match_start + norm_match_len;
2160
2161 for line in content.split_inclusive('\n') {
2162 let line_content = line.strip_suffix('\n').unwrap_or(line);
2163 let has_newline = line.ends_with('\n');
2164 let trimmed_len = line_content.trim_end().len();
2165
2166 for (char_offset, c) in line_content.char_indices() {
2167 if norm_idx == norm_match_end && match_end.is_none() {
2170 match_end = Some(orig_idx + char_offset);
2171 }
2172
2173 if char_offset >= trimmed_len {
2174 continue;
2175 }
2176
2177 if norm_idx == norm_match_start && match_start.is_none() {
2182 match_start = Some(orig_idx + char_offset);
2183 }
2184 if match_start.is_some() && match_end.is_some() {
2185 break;
2186 }
2187
2188 let normalized_char = if is_special_unicode_space(c) {
2189 ' '
2190 } else if matches!(c, '\u{2018}' | '\u{2019}') {
2191 '\''
2192 } else if matches!(c, '\u{201C}' | '\u{201D}' | '\u{201E}' | '\u{201F}') {
2193 '"'
2194 } else if matches!(
2195 c,
2196 '\u{2010}'
2197 | '\u{2011}'
2198 | '\u{2012}'
2199 | '\u{2013}'
2200 | '\u{2014}'
2201 | '\u{2015}'
2202 | '\u{2212}'
2203 ) {
2204 '-'
2205 } else {
2206 c
2207 };
2208
2209 norm_idx += normalized_char.len_utf8();
2210 }
2211
2212 orig_idx += line_content.len();
2213
2214 if has_newline {
2215 if norm_idx == norm_match_start && match_start.is_none() {
2216 match_start = Some(orig_idx);
2217 }
2218 if norm_idx == norm_match_end && match_end.is_none() {
2219 match_end = Some(orig_idx);
2220 }
2221
2222 norm_idx += 1;
2223 orig_idx += 1;
2224 }
2225
2226 if match_start.is_some() && match_end.is_some() {
2227 break;
2228 }
2229 }
2230
2231 if norm_idx == norm_match_end && match_end.is_none() {
2232 match_end = Some(orig_idx);
2233 }
2234
2235 let start = match_start.unwrap_or(0);
2236 let end = match_end.unwrap_or(content.len());
2237 (start, end.saturating_sub(start))
2238}
2239
2240fn build_normalized_content(content: &str) -> String {
2241 let mut normalized = String::with_capacity(content.len());
2242 let mut lines = content.split('\n').peekable();
2243
2244 while let Some(line) = lines.next() {
2245 let trimmed_len = line.trim_end().len();
2246 for (char_offset, c) in line.char_indices() {
2247 if char_offset >= trimmed_len {
2248 continue;
2249 }
2250 let normalized_char = if is_special_unicode_space(c) {
2251 ' '
2252 } else if matches!(c, '\u{2018}' | '\u{2019}') {
2253 '\''
2254 } else if matches!(c, '\u{201C}' | '\u{201D}' | '\u{201E}' | '\u{201F}') {
2255 '"'
2256 } else if matches!(
2257 c,
2258 '\u{2010}'
2259 | '\u{2011}'
2260 | '\u{2012}'
2261 | '\u{2013}'
2262 | '\u{2014}'
2263 | '\u{2015}'
2264 | '\u{2212}'
2265 ) {
2266 '-'
2267 } else {
2268 c
2269 };
2270 normalized.push(normalized_char);
2271 }
2272 if lines.peek().is_some() {
2273 normalized.push('\n');
2274 }
2275 }
2276 normalized
2277}
2278
2279fn fuzzy_find_text(content: &str, old_text: &str) -> FuzzyMatchResult {
2280 fuzzy_find_text_with_normalized(content, old_text, None, None)
2281}
2282
2283fn fuzzy_find_text_with_normalized(
2286 content: &str,
2287 old_text: &str,
2288 precomputed_content: Option<&str>,
2289 precomputed_old: Option<&str>,
2290) -> FuzzyMatchResult {
2291 use std::borrow::Cow;
2292
2293 if let Some(index) = content.find(old_text) {
2295 return FuzzyMatchResult {
2296 found: true,
2297 index,
2298 match_length: old_text.len(),
2299 };
2300 }
2301
2302 let normalized_content = precomputed_content.map_or_else(
2304 || Cow::Owned(build_normalized_content(content)),
2305 Cow::Borrowed,
2306 );
2307 let normalized_old_text = precomputed_old.map_or_else(
2308 || Cow::Owned(build_normalized_content(old_text)),
2309 Cow::Borrowed,
2310 );
2311
2312 if let Some(normalized_index) = normalized_content.find(normalized_old_text.as_ref()) {
2314 let (original_start, original_match_len) =
2315 map_normalized_range_to_original(content, normalized_index, normalized_old_text.len());
2316
2317 return FuzzyMatchResult {
2318 found: true,
2319 index: original_start,
2320 match_length: original_match_len,
2321 };
2322 }
2323
2324 FuzzyMatchResult {
2325 found: false,
2326 index: 0,
2327 match_length: 0,
2328 }
2329}
2330
2331#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2332enum DiffTag {
2333 Equal,
2334 Added,
2335 Removed,
2336}
2337
2338#[derive(Debug, Clone)]
2339struct DiffPart {
2340 tag: DiffTag,
2341 value: String,
2342}
2343
2344fn diff_parts(old_content: &str, new_content: &str) -> Vec<DiffPart> {
2345 use similar::ChangeTag;
2346
2347 let diff = similar::TextDiff::from_lines(old_content, new_content);
2348
2349 let mut parts: Vec<DiffPart> = Vec::new();
2350 let mut current_tag: Option<DiffTag> = None;
2351 let mut current_value = String::new();
2352
2353 for change in diff.iter_all_changes() {
2354 let tag = match change.tag() {
2355 ChangeTag::Equal => DiffTag::Equal,
2356 ChangeTag::Insert => DiffTag::Added,
2357 ChangeTag::Delete => DiffTag::Removed,
2358 };
2359
2360 let mut line = change.value();
2361 if let Some(stripped) = line.strip_suffix('\n') {
2362 line = stripped;
2363 }
2364
2365 if current_tag == Some(tag) {
2366 if !current_value.is_empty() {
2367 current_value.push('\n');
2368 }
2369 current_value.push_str(line);
2370 } else {
2371 if let Some(prev_tag) = current_tag {
2372 parts.push(DiffPart {
2373 tag: prev_tag,
2374 value: current_value,
2375 });
2376 }
2377 current_tag = Some(tag);
2378 current_value = line.to_string();
2379 }
2380 }
2381
2382 if let Some(tag) = current_tag {
2383 parts.push(DiffPart {
2384 tag,
2385 value: current_value,
2386 });
2387 }
2388
2389 parts
2390}
2391
2392fn generate_diff_string(old_content: &str, new_content: &str) -> (String, Option<usize>) {
2393 let parts = diff_parts(old_content, new_content);
2394
2395 let old_line_count = memchr::memchr_iter(b'\n', old_content.as_bytes()).count() + 1;
2397 let new_line_count = memchr::memchr_iter(b'\n', new_content.as_bytes()).count() + 1;
2398 let max_line_num = old_line_count.max(new_line_count).max(1);
2399 let line_num_width = max_line_num.ilog10() as usize + 1;
2400
2401 let mut output = String::new();
2404 let mut old_line_num: usize = 1;
2405 let mut new_line_num: usize = 1;
2406 let mut last_was_change = false;
2407 let mut first_changed_line: Option<usize> = None;
2408 let context_lines: usize = 4;
2409
2410 for (i, part) in parts.iter().enumerate() {
2411 let collected: Vec<&str> = part.value.split('\n').collect();
2412 let raw = if collected.last().is_some_and(|l| l.is_empty()) {
2414 &collected[..collected.len() - 1]
2415 } else {
2416 &collected[..]
2417 };
2418
2419 match part.tag {
2420 DiffTag::Added | DiffTag::Removed => {
2421 if first_changed_line.is_none() {
2422 first_changed_line = Some(new_line_num);
2423 }
2424
2425 for line in raw {
2426 if !output.is_empty() {
2427 output.push('\n');
2428 }
2429 match part.tag {
2430 DiffTag::Added => {
2431 let _ = write!(output, "+{new_line_num:>line_num_width$} {line}");
2432 new_line_num = new_line_num.saturating_add(1);
2433 }
2434 DiffTag::Removed => {
2435 let _ = write!(output, "-{old_line_num:>line_num_width$} {line}");
2436 old_line_num = old_line_num.saturating_add(1);
2437 }
2438 DiffTag::Equal => {}
2439 }
2440 }
2441
2442 last_was_change = true;
2443 }
2444 DiffTag::Equal => {
2445 let next_part_is_change = i < parts.len().saturating_sub(1)
2446 && matches!(parts[i + 1].tag, DiffTag::Added | DiffTag::Removed);
2447
2448 if last_was_change || next_part_is_change {
2449 let start = if last_was_change {
2451 0
2452 } else {
2453 raw.len().saturating_sub(context_lines)
2454 };
2455 let lines_after_start = raw.len() - start;
2456 let (end, skip_end) =
2457 if !next_part_is_change && lines_after_start > context_lines {
2458 (start + context_lines, lines_after_start - context_lines)
2459 } else {
2460 (raw.len(), 0)
2461 };
2462 let skip_start = start;
2463
2464 if skip_start > 0 {
2465 if !output.is_empty() {
2466 output.push('\n');
2467 }
2468 let _ = write!(output, " {:>line_num_width$} ...", " ");
2469 old_line_num = old_line_num.saturating_add(skip_start);
2470 new_line_num = new_line_num.saturating_add(skip_start);
2471 }
2472
2473 for line in &raw[start..end] {
2474 if !output.is_empty() {
2475 output.push('\n');
2476 }
2477 let _ = write!(output, " {old_line_num:>line_num_width$} {line}");
2478 old_line_num = old_line_num.saturating_add(1);
2479 new_line_num = new_line_num.saturating_add(1);
2480 }
2481
2482 if skip_end > 0 {
2483 if !output.is_empty() {
2484 output.push('\n');
2485 }
2486 let _ = write!(output, " {:>line_num_width$} ...", " ");
2487 old_line_num = old_line_num.saturating_add(skip_end);
2488 new_line_num = new_line_num.saturating_add(skip_end);
2489 }
2490 } else {
2491 old_line_num = old_line_num.saturating_add(raw.len());
2492 new_line_num = new_line_num.saturating_add(raw.len());
2493 }
2494
2495 last_was_change = false;
2496 }
2497 }
2498 }
2499
2500 (output, first_changed_line)
2501}
2502
2503#[async_trait]
2504#[allow(clippy::unnecessary_literal_bound)]
2505impl Tool for EditTool {
2506 fn name(&self) -> &str {
2507 "edit"
2508 }
2509 fn label(&self) -> &str {
2510 "edit"
2511 }
2512 fn description(&self) -> &str {
2513 "Edit a file by replacing exact text. The oldText must match exactly (including whitespace). Use this for precise, surgical edits."
2514 }
2515
2516 fn parameters(&self) -> serde_json::Value {
2517 serde_json::json!({
2518 "type": "object",
2519 "properties": {
2520 "path": {
2521 "type": "string",
2522 "description": "Path to the file to edit (relative or absolute)"
2523 },
2524 "oldText": {
2525 "type": "string",
2526 "minLength": 1,
2527 "description": "Exact text to find and replace (must match exactly)"
2528 },
2529 "newText": {
2530 "type": "string",
2531 "description": "New text to replace the old text with"
2532 }
2533 },
2534 "required": ["path", "oldText", "newText"]
2535 })
2536 }
2537
2538 #[allow(clippy::too_many_lines)]
2539 async fn execute(
2540 &self,
2541 _tool_call_id: &str,
2542 input: serde_json::Value,
2543 _on_update: Option<Box<dyn Fn(ToolUpdate) + Send + Sync>>,
2544 ) -> Result<ToolOutput> {
2545 let input: EditInput =
2546 serde_json::from_value(input).map_err(|e| Error::validation(e.to_string()))?;
2547
2548 if input.new_text.len() > WRITE_TOOL_MAX_BYTES {
2549 return Err(Error::validation(format!(
2550 "New text size exceeds maximum allowed ({} > {} bytes)",
2551 input.new_text.len(),
2552 WRITE_TOOL_MAX_BYTES
2553 )));
2554 }
2555
2556 let absolute_path = resolve_read_path(&input.path, &self.cwd);
2557
2558 if asupersync::fs::OpenOptions::new()
2560 .read(true)
2561 .write(true)
2562 .open(&absolute_path)
2563 .await
2564 .is_err()
2565 {
2566 return Err(Error::tool(
2567 "edit",
2568 format!("File not found: {}", input.path),
2569 ));
2570 }
2571
2572 if let Ok(meta) = asupersync::fs::metadata(&absolute_path).await {
2573 if meta.len() > READ_TOOL_MAX_BYTES {
2574 return Err(Error::tool(
2575 "edit",
2576 format!(
2577 "File is too large ({} bytes). Max allowed for editing is {} bytes.",
2578 meta.len(),
2579 READ_TOOL_MAX_BYTES
2580 ),
2581 ));
2582 }
2583 }
2584
2585 let raw = asupersync::fs::read(&absolute_path)
2587 .await
2588 .map_err(|e| Error::tool("edit", format!("Failed to read file: {e}")))?;
2589 let raw_content = String::from_utf8(raw).map_err(|_| {
2590 Error::tool(
2591 "edit",
2592 "File contains invalid UTF-8 characters and cannot be safely edited as text."
2593 .to_string(),
2594 )
2595 })?;
2596
2597 let (content_no_bom, had_bom) = strip_bom(&raw_content);
2599
2600 let original_ending = detect_line_ending(content_no_bom);
2601 let normalized_content = normalize_to_lf(content_no_bom);
2602 let normalized_old_text = normalize_to_lf(&input.old_text);
2603
2604 if normalized_old_text.is_empty() {
2605 return Err(Error::tool(
2606 "edit",
2607 "The old text cannot be empty. To prepend text, include the first line's content in oldText and newText.".to_string(),
2608 ));
2609 }
2610
2611 let mut variants = Vec::with_capacity(3);
2618 variants.push(normalized_old_text.clone());
2619
2620 let nfc = normalized_old_text.nfc().collect::<String>();
2621 if nfc != normalized_old_text {
2622 variants.push(nfc);
2623 }
2624
2625 let nfd = normalized_old_text.nfd().collect::<String>();
2626 if nfd != normalized_old_text {
2627 variants.push(nfd);
2628 }
2629
2630 let precomputed_content = build_normalized_content(content_no_bom);
2633
2634 let mut best_match: Option<(FuzzyMatchResult, String)> = None;
2635
2636 for variant in variants {
2637 let precomputed_variant = build_normalized_content(&variant);
2638 let match_result = fuzzy_find_text_with_normalized(
2639 content_no_bom,
2640 &variant,
2641 Some(precomputed_content.as_str()),
2642 Some(precomputed_variant.as_str()),
2643 );
2644
2645 if match_result.found {
2646 best_match = Some((match_result, precomputed_variant));
2647 break;
2648 }
2649 }
2650
2651 let Some((match_result, normalized_old_text)) = best_match else {
2652 return Err(Error::tool(
2653 "edit",
2654 format!(
2655 "Could not find the exact text in {}. The old text must match exactly including all whitespace and newlines.",
2656 input.path
2657 ),
2658 ));
2659 };
2660
2661 let occurrences = if normalized_old_text.is_empty() {
2663 0
2664 } else {
2665 precomputed_content
2666 .split(&normalized_old_text)
2667 .count()
2668 .saturating_sub(1)
2669 };
2670
2671 if occurrences > 1 {
2672 return Err(Error::tool(
2673 "edit",
2674 format!(
2675 "Found {occurrences} occurrences of the text in {}. The text must be unique. Please provide more context to make it unique.",
2676 input.path
2677 ),
2678 ));
2679 }
2680
2681 let idx = match_result.index;
2684 let match_len = match_result.match_length;
2685
2686 let adapted_new_text =
2690 restore_line_endings(&normalize_to_lf(&input.new_text), original_ending);
2691
2692 let new_len = content_no_bom.len() - match_len + adapted_new_text.len();
2693 let mut new_content = String::with_capacity(new_len);
2694 new_content.push_str(&content_no_bom[..idx]);
2695 new_content.push_str(&adapted_new_text);
2696 new_content.push_str(&content_no_bom[idx + match_len..]);
2697
2698 if content_no_bom == new_content {
2699 return Err(Error::tool(
2700 "edit",
2701 format!(
2702 "No changes made to {}. The replacement produced identical content. This might indicate an issue with special characters or the text not existing as expected.",
2703 input.path
2704 ),
2705 ));
2706 }
2707
2708 let new_content_for_diff = normalize_to_lf(&new_content);
2709
2710 let mut final_content = new_content;
2712 if had_bom {
2713 final_content = format!("\u{FEFF}{final_content}");
2714 }
2715
2716 let original_perms = std::fs::metadata(&absolute_path)
2719 .ok()
2720 .map(|m| m.permissions());
2721 let parent = absolute_path.parent().unwrap_or_else(|| Path::new("."));
2722 let mut temp_file = tempfile::NamedTempFile::new_in(parent)
2723 .map_err(|e| Error::tool("edit", format!("Failed to create temp file: {e}")))?;
2724 temp_file
2725 .as_file_mut()
2726 .write_all(final_content.as_bytes())
2727 .map_err(|e| Error::tool("edit", format!("Failed to write temp file: {e}")))?;
2728
2729 if let Some(perms) = original_perms {
2731 let _ = temp_file.as_file().set_permissions(perms);
2732 } else {
2733 #[cfg(unix)]
2735 {
2736 use std::os::unix::fs::PermissionsExt;
2737 let _ = temp_file
2738 .as_file()
2739 .set_permissions(std::fs::Permissions::from_mode(0o644));
2740 }
2741 }
2742
2743 temp_file
2744 .persist(&absolute_path)
2745 .map_err(|e| Error::tool("edit", format!("Failed to persist file: {e}")))?;
2746
2747 let (diff, first_changed_line) =
2748 generate_diff_string(&normalized_content, &new_content_for_diff);
2749 let mut details = serde_json::Map::new();
2750 details.insert("diff".to_string(), serde_json::Value::String(diff));
2751 if let Some(line) = first_changed_line {
2752 details.insert(
2753 "firstChangedLine".to_string(),
2754 serde_json::Value::Number(serde_json::Number::from(line)),
2755 );
2756 }
2757
2758 Ok(ToolOutput {
2759 content: vec![ContentBlock::Text(TextContent::new(format!(
2760 "Successfully replaced text in {}.",
2761 input.path
2762 )))],
2763 details: Some(serde_json::Value::Object(details)),
2764 is_error: false,
2765 })
2766 }
2767}
2768
2769#[derive(Debug, Deserialize)]
2775#[serde(rename_all = "camelCase")]
2776struct WriteInput {
2777 path: String,
2778 content: String,
2779}
2780
2781pub struct WriteTool {
2782 cwd: PathBuf,
2783}
2784
2785impl WriteTool {
2786 pub fn new(cwd: &Path) -> Self {
2787 Self {
2788 cwd: cwd.to_path_buf(),
2789 }
2790 }
2791}
2792
2793#[async_trait]
2794#[allow(clippy::unnecessary_literal_bound)]
2795impl Tool for WriteTool {
2796 fn name(&self) -> &str {
2797 "write"
2798 }
2799 fn label(&self) -> &str {
2800 "write"
2801 }
2802 fn description(&self) -> &str {
2803 "Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories."
2804 }
2805
2806 fn parameters(&self) -> serde_json::Value {
2807 serde_json::json!({
2808 "type": "object",
2809 "properties": {
2810 "path": {
2811 "type": "string",
2812 "description": "Path to the file to write (relative or absolute)"
2813 },
2814 "content": {
2815 "type": "string",
2816 "description": "Content to write to the file"
2817 }
2818 },
2819 "required": ["path", "content"]
2820 })
2821 }
2822
2823 #[allow(clippy::too_many_lines)]
2824 async fn execute(
2825 &self,
2826 _tool_call_id: &str,
2827 input: serde_json::Value,
2828 _on_update: Option<Box<dyn Fn(ToolUpdate) + Send + Sync>>,
2829 ) -> Result<ToolOutput> {
2830 let input: WriteInput =
2831 serde_json::from_value(input).map_err(|e| Error::validation(e.to_string()))?;
2832
2833 if input.content.len() > WRITE_TOOL_MAX_BYTES {
2834 return Err(Error::validation(format!(
2835 "Content size exceeds maximum allowed ({} > {} bytes)",
2836 input.content.len(),
2837 WRITE_TOOL_MAX_BYTES
2838 )));
2839 }
2840
2841 let path = resolve_path(&input.path, &self.cwd);
2842
2843 if let Some(parent) = path.parent() {
2845 asupersync::fs::create_dir_all(parent)
2846 .await
2847 .map_err(|e| Error::tool("write", format!("Failed to create directories: {e}")))?;
2848 }
2849
2850 let bytes_written = input.content.encode_utf16().count();
2852
2853 let original_perms = std::fs::metadata(&path).ok().map(|m| m.permissions());
2856 let parent = path.parent().unwrap_or_else(|| Path::new("."));
2857 let mut temp_file = tempfile::NamedTempFile::new_in(parent)
2858 .map_err(|e| Error::tool("write", format!("Failed to create temp file: {e}")))?;
2859
2860 temp_file
2861 .as_file_mut()
2862 .write_all(input.content.as_bytes())
2863 .map_err(|e| Error::tool("write", format!("Failed to write temp file: {e}")))?;
2864
2865 if let Some(perms) = original_perms {
2867 let _ = temp_file.as_file().set_permissions(perms);
2868 } else {
2869 #[cfg(unix)]
2871 {
2872 use std::os::unix::fs::PermissionsExt;
2873 let _ = temp_file
2874 .as_file()
2875 .set_permissions(std::fs::Permissions::from_mode(0o644));
2876 }
2877 }
2878
2879 temp_file
2881 .persist(&path)
2882 .map_err(|e| Error::tool("write", format!("Failed to persist file: {e}")))?;
2883
2884 Ok(ToolOutput {
2885 content: vec![ContentBlock::Text(TextContent::new(format!(
2886 "Successfully wrote {} bytes to {}",
2887 bytes_written, input.path
2888 )))],
2889 details: None,
2890 is_error: false,
2891 })
2892 }
2893}
2894
2895#[derive(Debug, Deserialize)]
2901#[serde(rename_all = "camelCase")]
2902struct GrepInput {
2903 pattern: String,
2904 path: Option<String>,
2905 glob: Option<String>,
2906 ignore_case: Option<bool>,
2907 literal: Option<bool>,
2908 context: Option<usize>,
2909 limit: Option<usize>,
2910}
2911
2912pub struct GrepTool {
2913 cwd: PathBuf,
2914}
2915
2916impl GrepTool {
2917 pub fn new(cwd: &Path) -> Self {
2918 Self {
2919 cwd: cwd.to_path_buf(),
2920 }
2921 }
2922}
2923
2924#[derive(Debug, Clone, PartialEq, Eq)]
2926struct TruncateLineResult {
2927 text: String,
2928 was_truncated: bool,
2929}
2930
2931fn truncate_line(line: &str, max_chars: usize) -> TruncateLineResult {
2935 let mut chars = line.chars();
2936 let prefix: String = chars.by_ref().take(max_chars).collect();
2937 if chars.next().is_none() {
2938 return TruncateLineResult {
2939 text: line.to_string(),
2940 was_truncated: false,
2941 };
2942 }
2943
2944 TruncateLineResult {
2945 text: format!("{prefix}... [truncated]"),
2946 was_truncated: true,
2947 }
2948}
2949
2950fn process_rg_json_match_line(
2951 line_res: std::io::Result<String>,
2952 matches: &mut Vec<(PathBuf, usize)>,
2953 match_count: &mut usize,
2954 match_limit_reached: &mut bool,
2955 effective_limit: usize,
2956) -> Result<()> {
2957 if *match_limit_reached {
2958 return Ok(());
2959 }
2960
2961 let line = line_res.map_err(|e| Error::tool("grep", e.to_string()))?;
2962 if line.trim().is_empty() {
2963 return Ok(());
2964 }
2965
2966 let Ok(event) = serde_json::from_str::<serde_json::Value>(&line) else {
2967 return Ok(());
2968 };
2969
2970 if event.get("type").and_then(serde_json::Value::as_str) != Some("match") {
2971 return Ok(());
2972 }
2973
2974 *match_count += 1;
2975
2976 let file_path = event
2977 .pointer("/data/path/text")
2978 .and_then(serde_json::Value::as_str)
2979 .map(PathBuf::from);
2980 let line_number = event
2981 .pointer("/data/line_number")
2982 .and_then(serde_json::Value::as_u64)
2983 .and_then(|n| usize::try_from(n).ok());
2984
2985 if let (Some(fp), Some(ln)) = (file_path, line_number) {
2986 matches.push((fp, ln));
2987 }
2988
2989 if *match_count >= effective_limit {
2990 *match_limit_reached = true;
2991 }
2992
2993 Ok(())
2994}
2995
2996fn drain_rg_stdout(
2997 stdout_rx: &std::sync::mpsc::Receiver<std::io::Result<String>>,
2998 matches: &mut Vec<(PathBuf, usize)>,
2999 match_count: &mut usize,
3000 match_limit_reached: &mut bool,
3001 effective_limit: usize,
3002) -> Result<()> {
3003 while let Ok(line_res) = stdout_rx.try_recv() {
3004 process_rg_json_match_line(
3005 line_res,
3006 matches,
3007 match_count,
3008 match_limit_reached,
3009 effective_limit,
3010 )?;
3011 if *match_limit_reached {
3012 break;
3013 }
3014 }
3015 Ok(())
3016}
3017
3018fn drain_rg_stderr(
3019 stderr_rx: &std::sync::mpsc::Receiver<std::result::Result<Vec<u8>, String>>,
3020 stderr_bytes: &mut Vec<u8>,
3021) -> Result<()> {
3022 while let Ok(chunk_result) = stderr_rx.try_recv() {
3023 let chunk = chunk_result
3024 .map_err(|err| Error::tool("grep", format!("Failed to read stderr: {err}")))?;
3025 stderr_bytes.extend_from_slice(&chunk);
3026 }
3027 Ok(())
3028}
3029
3030#[async_trait]
3031#[allow(clippy::unnecessary_literal_bound)]
3032impl Tool for GrepTool {
3033 fn name(&self) -> &str {
3034 "grep"
3035 }
3036 fn label(&self) -> &str {
3037 "grep"
3038 }
3039 fn description(&self) -> &str {
3040 "Search file contents for a pattern. Returns matching lines with file paths and line numbers. Respects .gitignore. Output is truncated to 100 matches or 50KB (whichever is hit first). Long lines are truncated to 500 chars."
3041 }
3042
3043 fn parameters(&self) -> serde_json::Value {
3044 serde_json::json!({
3045 "type": "object",
3046 "properties": {
3047 "pattern": {
3048 "type": "string",
3049 "description": "Search pattern (regex or literal string)"
3050 },
3051 "path": {
3052 "type": "string",
3053 "description": "Directory or file to search (default: current directory)"
3054 },
3055 "glob": {
3056 "type": "string",
3057 "description": "Filter files by glob pattern, e.g. '*.ts' or '**/*.spec.ts'"
3058 },
3059 "ignoreCase": {
3060 "type": "boolean",
3061 "description": "Case-insensitive search (default: false)"
3062 },
3063 "literal": {
3064 "type": "boolean",
3065 "description": "Treat pattern as literal string instead of regex (default: false)"
3066 },
3067 "context": {
3068 "type": "integer",
3069 "description": "Number of lines to show before and after each match (default: 0)"
3070 },
3071 "limit": {
3072 "type": "integer",
3073 "description": "Maximum number of matches to return (default: 100)"
3074 }
3075 },
3076 "required": ["pattern"]
3077 })
3078 }
3079
3080 fn is_read_only(&self) -> bool {
3081 true
3082 }
3083
3084 #[allow(clippy::too_many_lines)]
3085 async fn execute(
3086 &self,
3087 _tool_call_id: &str,
3088 input: serde_json::Value,
3089 _on_update: Option<Box<dyn Fn(ToolUpdate) + Send + Sync>>,
3090 ) -> Result<ToolOutput> {
3091 let input: GrepInput =
3092 serde_json::from_value(input).map_err(|e| Error::validation(e.to_string()))?;
3093
3094 if !rg_available() {
3095 return Err(Error::tool(
3096 "grep",
3097 "ripgrep (rg) is not available (please install ripgrep)".to_string(),
3098 ));
3099 }
3100
3101 let search_dir = input.path.as_deref().unwrap_or(".");
3102 let search_path = resolve_read_path(search_dir, &self.cwd);
3103
3104 let is_directory = std::fs::metadata(&search_path)
3105 .map_err(|e| {
3106 Error::tool(
3107 "grep",
3108 format!("Cannot access path {}: {e}", search_path.display()),
3109 )
3110 })?
3111 .is_dir();
3112
3113 let context_value = input.context.unwrap_or(0);
3114 let effective_limit = input.limit.unwrap_or(DEFAULT_GREP_LIMIT).max(1);
3115
3116 let mut args: Vec<String> = vec![
3117 "--json".to_string(),
3118 "--line-number".to_string(),
3119 "--color=never".to_string(),
3120 "--hidden".to_string(),
3121 "--max-columns=10000".to_string(),
3123 ];
3124
3125 if input.ignore_case.unwrap_or(false) {
3126 args.push("--ignore-case".to_string());
3127 }
3128 if input.literal.unwrap_or(false) {
3129 args.push("--fixed-strings".to_string());
3130 }
3131 if let Some(glob) = &input.glob {
3132 args.push("--glob".to_string());
3133 args.push(glob.clone());
3134 }
3135
3136 let ignore_root = if is_directory {
3139 search_path.clone()
3140 } else {
3141 search_path
3142 .parent()
3143 .unwrap_or_else(|| Path::new("."))
3144 .to_path_buf()
3145 };
3146 let root_gitignore = ignore_root.join(".gitignore");
3152 if root_gitignore.exists() {
3153 args.push("--ignore-file".to_string());
3154 args.push(root_gitignore.display().to_string());
3155 }
3156
3157 args.push("--".to_string());
3158 args.push(input.pattern.clone());
3159 args.push(search_path.display().to_string());
3160
3161 let mut child = Command::new("rg")
3162 .args(args)
3163 .stdout(Stdio::piped())
3164 .stderr(Stdio::piped())
3165 .spawn()
3166 .map_err(|e| Error::tool("grep", format!("Failed to run ripgrep: {e}")))?;
3167
3168 let stdout = child
3169 .stdout
3170 .take()
3171 .ok_or_else(|| Error::tool("grep", "Missing stdout".to_string()))?;
3172 let stderr = child
3173 .stderr
3174 .take()
3175 .ok_or_else(|| Error::tool("grep", "Missing stderr".to_string()))?;
3176
3177 let mut guard = ProcessGuard::new(child, false);
3178
3179 let (stdout_tx, stdout_rx) = std::sync::mpsc::sync_channel(1024);
3180 let (stderr_tx, stderr_rx) =
3181 std::sync::mpsc::sync_channel::<std::result::Result<Vec<u8>, String>>(1024);
3182
3183 let stdout_thread = std::thread::spawn(move || {
3184 let reader = std::io::BufReader::new(stdout);
3185 for line in reader.lines() {
3186 if stdout_tx.send(line).is_err() {
3187 break;
3188 }
3189 }
3190 });
3191
3192 let stderr_thread = std::thread::spawn(move || {
3193 let mut reader = std::io::BufReader::new(stderr);
3194 let mut buf = Vec::new();
3195 let _ = stderr_tx.send(
3196 reader
3197 .read_to_end(&mut buf)
3198 .map(|_| buf)
3199 .map_err(|err| err.to_string()),
3200 );
3201 });
3202
3203 let mut matches: Vec<(PathBuf, usize)> = Vec::new();
3204 let mut match_count: usize = 0;
3205 let mut match_limit_reached = false;
3206 let mut stderr_bytes = Vec::new();
3207
3208 let tick = Duration::from_millis(10);
3209
3210 loop {
3211 drain_rg_stdout(
3212 &stdout_rx,
3213 &mut matches,
3214 &mut match_count,
3215 &mut match_limit_reached,
3216 effective_limit,
3217 )?;
3218 drain_rg_stderr(&stderr_rx, &mut stderr_bytes)?;
3219
3220 if match_limit_reached {
3221 break;
3222 }
3223
3224 match guard.try_wait_child() {
3225 Ok(Some(_)) => break,
3226 Ok(None) => {
3227 let now = AgentCx::for_current_or_request()
3228 .cx()
3229 .timer_driver()
3230 .map_or_else(wall_now, |timer| timer.now());
3231 sleep(now, tick).await;
3232 }
3233 Err(e) => return Err(Error::tool("grep", e.to_string())),
3234 }
3235 }
3236
3237 drain_rg_stdout(
3238 &stdout_rx,
3239 &mut matches,
3240 &mut match_count,
3241 &mut match_limit_reached,
3242 effective_limit,
3243 )?;
3244
3245 let code = if match_limit_reached {
3246 let _ = guard
3249 .kill()
3250 .map_err(|e| Error::tool("grep", format!("Failed to terminate ripgrep: {e}")))?;
3251 while stdout_rx.try_recv().is_ok() {}
3253 while stderr_rx.try_recv().is_ok() {}
3254 0
3255 } else {
3256 guard
3257 .wait()
3258 .map_err(|e| Error::tool("grep", e.to_string()))?
3259 .code()
3260 .unwrap_or(0)
3261 };
3262
3263 while !stdout_thread.is_finished() || !stderr_thread.is_finished() {
3267 if match_limit_reached {
3268 while stdout_rx.try_recv().is_ok() {}
3269 } else {
3270 drain_rg_stdout(
3271 &stdout_rx,
3272 &mut matches,
3273 &mut match_count,
3274 &mut match_limit_reached,
3275 effective_limit,
3276 )?;
3277 }
3278 drain_rg_stderr(&stderr_rx, &mut stderr_bytes)?;
3279 std::thread::sleep(Duration::from_millis(1));
3280 }
3281
3282 stdout_thread
3287 .join()
3288 .map_err(|_| Error::tool("grep", "ripgrep stdout reader thread panicked"))?;
3289 stderr_thread
3290 .join()
3291 .map_err(|_| Error::tool("grep", "ripgrep stderr reader thread panicked"))?;
3292
3293 if match_limit_reached {
3295 while stdout_rx.try_recv().is_ok() {}
3296 } else {
3297 drain_rg_stdout(
3298 &stdout_rx,
3299 &mut matches,
3300 &mut match_count,
3301 &mut match_limit_reached,
3302 effective_limit,
3303 )?;
3304 }
3305 drain_rg_stderr(&stderr_rx, &mut stderr_bytes)?;
3306
3307 let stderr_text = String::from_utf8_lossy(&stderr_bytes).trim().to_string();
3308 if !match_limit_reached && code != 0 && code != 1 {
3309 let msg = if stderr_text.is_empty() {
3310 format!("ripgrep exited with code {code}")
3311 } else {
3312 stderr_text
3313 };
3314 return Err(Error::tool("grep", msg));
3315 }
3316
3317 if match_count == 0 {
3318 return Ok(ToolOutput {
3319 content: vec![ContentBlock::Text(TextContent::new("No matches found"))],
3320 details: None,
3321 is_error: false,
3322 });
3323 }
3324
3325 let mut file_cache: HashMap<PathBuf, Vec<String>> = HashMap::new();
3326 let mut output_lines: Vec<String> = Vec::new();
3327 let mut lines_truncated = false;
3328
3329 for (file_path, line_number) in &matches {
3330 let relative_path = format_grep_path(file_path, &self.cwd);
3331 let lines = get_file_lines_async(file_path, &mut file_cache).await;
3332
3333 if lines.is_empty() {
3334 output_lines.push(format!(
3335 "{relative_path}:{line_number}: (unable to read file or too large)"
3336 ));
3337 continue;
3338 }
3339
3340 let start = if context_value > 0 {
3341 line_number.saturating_sub(context_value).max(1)
3342 } else {
3343 *line_number
3344 };
3345 let end = if context_value > 0 {
3346 line_number.saturating_add(context_value).min(lines.len())
3347 } else {
3348 *line_number
3349 };
3350
3351 for current in start..=end {
3352 let line_text = lines.get(current - 1).map_or("", String::as_str);
3353 let sanitized = line_text.replace('\r', "");
3354 let truncated = truncate_line(&sanitized, GREP_MAX_LINE_LENGTH);
3355 if truncated.was_truncated {
3356 lines_truncated = true;
3357 }
3358
3359 if current == *line_number {
3360 output_lines.push(format!("{relative_path}:{current}: {}", truncated.text));
3361 } else {
3362 output_lines.push(format!("{relative_path}-{current}- {}", truncated.text));
3363 }
3364 }
3365 }
3366
3367 let raw_output = output_lines.join("\n");
3369 let mut truncation = truncate_head(raw_output, usize::MAX, DEFAULT_MAX_BYTES);
3370
3371 let mut output = std::mem::take(&mut truncation.content);
3372 let mut notices: Vec<String> = Vec::new();
3373 let mut details_map = serde_json::Map::new();
3374
3375 if match_limit_reached {
3376 notices.push(format!(
3377 "{effective_limit} matches limit reached. Use limit={} for more, or refine pattern",
3378 effective_limit * 2
3379 ));
3380 details_map.insert(
3381 "matchLimitReached".to_string(),
3382 serde_json::Value::Number(serde_json::Number::from(effective_limit)),
3383 );
3384 }
3385
3386 if truncation.truncated {
3387 notices.push(format!("{} limit reached", format_size(DEFAULT_MAX_BYTES)));
3388 details_map.insert("truncation".to_string(), serde_json::to_value(truncation)?);
3389 }
3390
3391 if lines_truncated {
3392 notices.push(format!(
3393 "Some lines truncated to {GREP_MAX_LINE_LENGTH} chars. Use read tool to see full lines"
3394 ));
3395 details_map.insert("linesTruncated".to_string(), serde_json::Value::Bool(true));
3396 }
3397
3398 if !notices.is_empty() {
3399 let _ = write!(output, "\n\n[{}]", notices.join(". "));
3400 }
3401
3402 let details = if details_map.is_empty() {
3403 None
3404 } else {
3405 Some(serde_json::Value::Object(details_map))
3406 };
3407
3408 Ok(ToolOutput {
3409 content: vec![ContentBlock::Text(TextContent::new(output))],
3410 details,
3411 is_error: false,
3412 })
3413 }
3414}
3415
3416#[derive(Debug, Deserialize)]
3422#[serde(rename_all = "camelCase")]
3423struct FindInput {
3424 pattern: String,
3425 path: Option<String>,
3426 limit: Option<usize>,
3427}
3428
3429pub struct FindTool {
3430 cwd: PathBuf,
3431}
3432
3433impl FindTool {
3434 pub fn new(cwd: &Path) -> Self {
3435 Self {
3436 cwd: cwd.to_path_buf(),
3437 }
3438 }
3439}
3440
3441#[async_trait]
3442#[allow(clippy::unnecessary_literal_bound)]
3443impl Tool for FindTool {
3444 fn name(&self) -> &str {
3445 "find"
3446 }
3447 fn label(&self) -> &str {
3448 "find"
3449 }
3450 fn description(&self) -> &str {
3451 "Search for files by glob pattern. Returns matching file paths relative to the search directory. Respects .gitignore. Output is truncated to 1000 results or 50KB (whichever is hit first)."
3452 }
3453
3454 fn parameters(&self) -> serde_json::Value {
3455 serde_json::json!({
3456 "type": "object",
3457 "properties": {
3458 "pattern": {
3459 "type": "string",
3460 "description": "Glob pattern to match files, e.g. '*.ts', '**/*.json', or 'src/**/*.spec.ts'"
3461 },
3462 "path": {
3463 "type": "string",
3464 "description": "Directory to search in (default: current directory)"
3465 },
3466 "limit": {
3467 "type": "integer",
3468 "description": "Maximum number of results (default: 1000)"
3469 }
3470 },
3471 "required": ["pattern"]
3472 })
3473 }
3474
3475 fn is_read_only(&self) -> bool {
3476 true
3477 }
3478
3479 #[allow(clippy::too_many_lines)]
3480 async fn execute(
3481 &self,
3482 _tool_call_id: &str,
3483 input: serde_json::Value,
3484 _on_update: Option<Box<dyn Fn(ToolUpdate) + Send + Sync>>,
3485 ) -> Result<ToolOutput> {
3486 let input: FindInput =
3487 serde_json::from_value(input).map_err(|e| Error::validation(e.to_string()))?;
3488
3489 let search_dir = input.path.as_deref().unwrap_or(".");
3490 let search_path = strip_unc_prefix(resolve_read_path(search_dir, &self.cwd));
3491 let effective_limit = input.limit.unwrap_or(DEFAULT_FIND_LIMIT);
3492
3493 if !search_path.exists() {
3494 return Err(Error::tool(
3495 "find",
3496 format!("Path not found: {}", search_path.display()),
3497 ));
3498 }
3499
3500 let fd_cmd = find_fd_binary().ok_or_else(|| {
3501 Error::tool(
3502 "find",
3503 "fd is not available (please install fd-find or fd)".to_string(),
3504 )
3505 })?;
3506
3507 let mut args: Vec<String> = vec![
3509 "--glob".to_string(),
3510 "--color=never".to_string(),
3511 "--hidden".to_string(),
3512 "--max-results".to_string(),
3513 effective_limit.to_string(),
3514 ];
3515
3516 let root_gitignore = search_path.join(".gitignore");
3521 if root_gitignore.exists() {
3522 args.push("--ignore-file".to_string());
3523 args.push(root_gitignore.display().to_string());
3524 }
3525
3526 args.push("--".to_string());
3527 args.push(input.pattern.clone());
3528 args.push(search_path.display().to_string());
3529
3530 let mut child = Command::new(fd_cmd)
3531 .args(args)
3532 .stdout(Stdio::piped())
3533 .stderr(Stdio::piped())
3534 .spawn()
3535 .map_err(|e| Error::tool("find", format!("Failed to run fd: {e}")))?;
3536
3537 let mut stdout_pipe = child
3538 .stdout
3539 .take()
3540 .ok_or_else(|| Error::tool("find", "Missing stdout"))?;
3541 let mut stderr_pipe = child
3542 .stderr
3543 .take()
3544 .ok_or_else(|| Error::tool("find", "Missing stderr"))?;
3545
3546 let mut guard = ProcessGuard::new(child, false);
3547
3548 let stdout_handle = std::thread::spawn(move || -> std::result::Result<Vec<u8>, String> {
3549 let mut buf = Vec::new();
3550 stdout_pipe
3551 .read_to_end(&mut buf)
3552 .map_err(|err| err.to_string())?;
3553 Ok(buf)
3554 });
3555
3556 let stderr_handle = std::thread::spawn(move || -> std::result::Result<Vec<u8>, String> {
3557 let mut buf = Vec::new();
3558 stderr_pipe
3559 .read_to_end(&mut buf)
3560 .map_err(|err| err.to_string())?;
3561 Ok(buf)
3562 });
3563
3564 let tick = Duration::from_millis(10);
3565
3566 loop {
3567 match guard.try_wait_child() {
3569 Ok(Some(_)) => break,
3570 Ok(None) => {
3571 let now = AgentCx::for_current_or_request()
3572 .cx()
3573 .timer_driver()
3574 .map_or_else(wall_now, |timer| timer.now());
3575 sleep(now, tick).await;
3576 }
3577 Err(e) => return Err(Error::tool("find", e.to_string())),
3578 }
3579 }
3580
3581 let status = guard
3582 .wait()
3583 .map_err(|e| Error::tool("find", e.to_string()))?;
3584
3585 let stdout_bytes = stdout_handle
3586 .join()
3587 .map_err(|_| Error::tool("find", "fd stdout reader thread panicked"))?
3588 .map_err(|err| Error::tool("find", format!("Failed to read fd stdout: {err}")))?;
3589 let stderr_bytes = stderr_handle
3590 .join()
3591 .map_err(|_| Error::tool("find", "fd stderr reader thread panicked"))?
3592 .map_err(|err| Error::tool("find", format!("Failed to read fd stderr: {err}")))?;
3593
3594 let stdout = String::from_utf8_lossy(&stdout_bytes).trim().to_string();
3595 let stderr = String::from_utf8_lossy(&stderr_bytes).trim().to_string();
3596
3597 if !status.success() && stdout.is_empty() {
3598 let code = status.code().unwrap_or(1);
3599 let msg = if stderr.is_empty() {
3600 format!("fd exited with code {code}")
3601 } else {
3602 stderr
3603 };
3604 return Err(Error::tool("find", msg));
3605 }
3606
3607 if stdout.is_empty() {
3608 return Ok(ToolOutput {
3609 content: vec![ContentBlock::Text(TextContent::new(
3610 "No files found matching pattern",
3611 ))],
3612 details: None,
3613 is_error: false,
3614 });
3615 }
3616
3617 let mut relativized: Vec<String> = Vec::new();
3618 for raw_line in stdout.lines() {
3619 let line = raw_line.trim_end_matches('\r').trim();
3620 if line.is_empty() {
3621 continue;
3622 }
3623
3624 let clean = strip_unc_prefix(PathBuf::from(line));
3627 let line_path = clean.as_path();
3628 let mut rel = if line_path.is_absolute() {
3629 line_path.strip_prefix(&search_path).map_or_else(
3630 |_| line_path.to_string_lossy().to_string(),
3631 |stripped| stripped.to_string_lossy().to_string(),
3632 )
3633 } else {
3634 line_path.to_string_lossy().to_string()
3635 };
3636
3637 let full_path = if line_path.is_absolute() {
3638 line_path.to_path_buf()
3639 } else {
3640 search_path.join(line_path)
3641 };
3642 if full_path.is_dir() && !rel.ends_with('/') {
3643 rel.push('/');
3644 }
3645
3646 relativized.push(rel);
3647 }
3648
3649 if relativized.is_empty() {
3650 return Ok(ToolOutput {
3651 content: vec![ContentBlock::Text(TextContent::new(
3652 "No files found matching pattern",
3653 ))],
3654 details: None,
3655 is_error: false,
3656 });
3657 }
3658
3659 let result_limit_reached = relativized.len() >= effective_limit;
3660 let raw_output = relativized.join("\n");
3661 let mut truncation = truncate_head(raw_output, usize::MAX, DEFAULT_MAX_BYTES);
3662
3663 let mut result_output = std::mem::take(&mut truncation.content);
3664 let mut notices: Vec<String> = Vec::new();
3665 let mut details_map = serde_json::Map::new();
3666
3667 if !status.success() {
3668 let code = status.code().unwrap_or(1);
3669 notices.push(format!("fd exited with code {code}"));
3670 }
3671
3672 if result_limit_reached {
3673 notices.push(format!(
3674 "{effective_limit} results limit reached. Use limit={} for more, or refine pattern",
3675 effective_limit * 2
3676 ));
3677 details_map.insert(
3678 "resultLimitReached".to_string(),
3679 serde_json::Value::Number(serde_json::Number::from(effective_limit)),
3680 );
3681 }
3682
3683 if truncation.truncated {
3684 notices.push(format!("{} limit reached", format_size(DEFAULT_MAX_BYTES)));
3685 details_map.insert("truncation".to_string(), serde_json::to_value(truncation)?);
3686 }
3687
3688 if !notices.is_empty() {
3689 let _ = write!(result_output, "\n\n[{}]", notices.join(". "));
3690 }
3691
3692 let details = if details_map.is_empty() {
3693 None
3694 } else {
3695 Some(serde_json::Value::Object(details_map))
3696 };
3697
3698 Ok(ToolOutput {
3699 content: vec![ContentBlock::Text(TextContent::new(result_output))],
3700 details,
3701 is_error: false,
3702 })
3703 }
3704}
3705
3706#[derive(Debug, Deserialize)]
3712#[serde(rename_all = "camelCase")]
3713struct LsInput {
3714 path: Option<String>,
3715 limit: Option<usize>,
3716}
3717
3718pub struct LsTool {
3719 cwd: PathBuf,
3720}
3721
3722impl LsTool {
3723 pub fn new(cwd: &Path) -> Self {
3724 Self {
3725 cwd: cwd.to_path_buf(),
3726 }
3727 }
3728}
3729
3730#[async_trait]
3731#[allow(clippy::unnecessary_literal_bound, clippy::too_many_lines)]
3732impl Tool for LsTool {
3733 fn name(&self) -> &str {
3734 "ls"
3735 }
3736 fn label(&self) -> &str {
3737 "ls"
3738 }
3739 fn description(&self) -> &str {
3740 "List directory contents. Returns entries sorted alphabetically, with '/' suffix for directories. Includes dotfiles. Output is truncated to 500 entries or 50KB (whichever is hit first)."
3741 }
3742
3743 fn parameters(&self) -> serde_json::Value {
3744 serde_json::json!({
3745 "type": "object",
3746 "properties": {
3747 "path": {
3748 "type": "string",
3749 "description": "Directory to list (default: current directory)"
3750 },
3751 "limit": {
3752 "type": "integer",
3753 "description": "Maximum number of entries to return (default: 500)"
3754 }
3755 }
3756 })
3757 }
3758
3759 fn is_read_only(&self) -> bool {
3760 true
3761 }
3762
3763 async fn execute(
3764 &self,
3765 _tool_call_id: &str,
3766 input: serde_json::Value,
3767 _on_update: Option<Box<dyn Fn(ToolUpdate) + Send + Sync>>,
3768 ) -> Result<ToolOutput> {
3769 let input: LsInput =
3770 serde_json::from_value(input).map_err(|e| Error::validation(e.to_string()))?;
3771
3772 let dir_path = input
3773 .path
3774 .as_ref()
3775 .map_or_else(|| self.cwd.clone(), |p| resolve_read_path(p, &self.cwd));
3776
3777 let effective_limit = input.limit.unwrap_or(DEFAULT_LS_LIMIT);
3778
3779 if !dir_path.exists() {
3780 return Err(Error::tool(
3781 "ls",
3782 format!("Path not found: {}", dir_path.display()),
3783 ));
3784 }
3785 if !dir_path.is_dir() {
3786 return Err(Error::tool(
3787 "ls",
3788 format!("Not a directory: {}", dir_path.display()),
3789 ));
3790 }
3791
3792 let mut entries = Vec::new();
3793 let mut read_dir = asupersync::fs::read_dir(&dir_path)
3794 .await
3795 .map_err(|e| Error::tool("ls", format!("Cannot read directory: {e}")))?;
3796
3797 let mut scan_limit_reached = false;
3798 while let Some(entry) = read_dir
3799 .next_entry()
3800 .await
3801 .map_err(|e| Error::tool("ls", format!("Cannot read directory entry: {e}")))?
3802 {
3803 if entries.len() >= LS_SCAN_HARD_LIMIT {
3804 scan_limit_reached = true;
3805 break;
3806 }
3807 let name = entry.file_name().to_string_lossy().to_string();
3808 let is_dir = match entry.file_type().await {
3811 Ok(ft) => {
3812 if ft.is_dir() {
3813 true
3814 } else if ft.is_symlink() {
3815 entry.metadata().await.is_ok_and(|meta| meta.is_dir())
3817 } else {
3818 false
3819 }
3820 }
3821 Err(_) => entry.metadata().await.is_ok_and(|meta| meta.is_dir()),
3822 };
3823 entries.push((name, is_dir));
3824 }
3825
3826 entries.sort_by_key(|(a, _)| a.to_lowercase());
3828
3829 let mut results: Vec<String> = Vec::new();
3830 let mut entry_limit_reached = false;
3831
3832 for (entry, is_dir) in entries {
3833 if results.len() >= effective_limit {
3834 entry_limit_reached = true;
3835 break;
3836 }
3837 if is_dir {
3838 results.push(format!("{entry}/"));
3839 } else {
3840 results.push(entry);
3841 }
3842 }
3843
3844 if results.is_empty() {
3845 return Ok(ToolOutput {
3846 content: vec![ContentBlock::Text(TextContent::new("(empty directory)"))],
3847 details: None,
3848 is_error: false,
3849 });
3850 }
3851
3852 let raw_output = results.join("\n");
3854 let mut truncation = truncate_head(raw_output, usize::MAX, DEFAULT_MAX_BYTES);
3855
3856 let mut output = std::mem::take(&mut truncation.content);
3857 let mut details_map = serde_json::Map::new();
3858 let mut notices: Vec<String> = Vec::new();
3859
3860 if entry_limit_reached {
3861 notices.push(format!(
3862 "{effective_limit} entries limit reached. Use limit={} for more",
3863 effective_limit * 2
3864 ));
3865 details_map.insert(
3866 "entryLimitReached".to_string(),
3867 serde_json::Value::Number(serde_json::Number::from(effective_limit)),
3868 );
3869 }
3870
3871 if scan_limit_reached {
3872 notices.push(format!(
3873 "Directory scan limited to {LS_SCAN_HARD_LIMIT} entries to prevent system overload"
3874 ));
3875 details_map.insert(
3876 "scanLimitReached".to_string(),
3877 serde_json::Value::Number(serde_json::Number::from(LS_SCAN_HARD_LIMIT)),
3878 );
3879 }
3880
3881 if truncation.truncated {
3882 notices.push(format!("{} limit reached", format_size(DEFAULT_MAX_BYTES)));
3883 details_map.insert("truncation".to_string(), serde_json::to_value(truncation)?);
3884 }
3885
3886 if !notices.is_empty() {
3887 let _ = write!(output, "\n\n[{}]", notices.join(". "));
3888 }
3889
3890 let details = if details_map.is_empty() {
3891 None
3892 } else {
3893 Some(serde_json::Value::Object(details_map))
3894 };
3895
3896 Ok(ToolOutput {
3897 content: vec![ContentBlock::Text(TextContent::new(output))],
3898 details,
3899 is_error: false,
3900 })
3901 }
3902}
3903
3904pub fn cleanup_temp_files() {
3914 std::thread::spawn(|| {
3916 let temp_dir = std::env::temp_dir();
3917 let Ok(entries) = std::fs::read_dir(&temp_dir) else {
3918 return;
3919 };
3920
3921 let now = std::time::SystemTime::now();
3922 let threshold = now
3923 .checked_sub(Duration::from_secs(24 * 60 * 60))
3924 .unwrap_or(std::time::SystemTime::UNIX_EPOCH);
3925
3926 for entry in entries.flatten() {
3927 let path = entry.path();
3928 if !path.is_file() {
3929 continue;
3930 }
3931
3932 let Some(file_name) = path.file_name().and_then(|n| n.to_str()) else {
3933 continue;
3934 };
3935
3936 if (file_name.starts_with("pi-bash-") || file_name.starts_with("pi-rpc-bash-"))
3938 && std::path::Path::new(file_name)
3939 .extension()
3940 .is_some_and(|ext| ext.eq_ignore_ascii_case("log"))
3941 {
3942 if let Ok(metadata) = entry.metadata() {
3943 if let Ok(modified) = metadata.modified() {
3944 if modified < threshold {
3945 if let Err(e) = std::fs::remove_file(&path) {
3946 tracing::debug!(
3948 "Failed to remove temp file {}: {}",
3949 path.display(),
3950 e
3951 );
3952 }
3953 }
3954 }
3955 }
3956 }
3957 }
3958 });
3959}
3960
3961fn rg_available() -> bool {
3966 static AVAILABLE: OnceLock<bool> = OnceLock::new();
3967 *AVAILABLE.get_or_init(|| {
3968 std::process::Command::new("rg")
3969 .arg("--version")
3970 .stdout(Stdio::null())
3971 .stderr(Stdio::null())
3972 .status()
3973 .is_ok()
3974 })
3975}
3976
3977fn pump_stream<R: Read + Send + 'static>(mut reader: R, tx: &mpsc::SyncSender<Vec<u8>>) {
3978 let mut buf = vec![0u8; 8192];
3979 loop {
3980 match reader.read(&mut buf) {
3981 Ok(0) => break,
3982 Ok(n) => {
3983 if tx.send(buf[..n].to_vec()).is_err() {
3984 break;
3985 }
3986 }
3987 Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => {}
3988 Err(_) => break,
3989 }
3990 }
3991}
3992
3993fn concat_chunks(chunks: &VecDeque<Vec<u8>>) -> Vec<u8> {
3994 let total: usize = chunks.iter().map(Vec::len).sum();
3995 let mut out = Vec::with_capacity(total);
3996 for chunk in chunks {
3997 out.extend_from_slice(chunk);
3998 }
3999 out
4000}
4001
4002struct BashOutputState {
4003 total_bytes: usize,
4004 line_count: usize,
4005 last_byte_was_newline: bool,
4006 start_time: std::time::Instant,
4007 timeout_ms: Option<u64>,
4008 temp_file_path: Option<PathBuf>,
4009 temp_file: Option<asupersync::fs::File>,
4010 chunks: VecDeque<Vec<u8>>,
4011 chunks_bytes: usize,
4012 max_chunks_bytes: usize,
4013 spill_failed: bool,
4014}
4015
4016impl BashOutputState {
4017 fn new(max_chunks_bytes: usize) -> Self {
4018 Self {
4019 total_bytes: 0,
4020 line_count: 0,
4021 last_byte_was_newline: false,
4022 start_time: std::time::Instant::now(),
4023 timeout_ms: None,
4024 temp_file_path: None,
4025 temp_file: None,
4026 chunks: VecDeque::new(),
4027 chunks_bytes: 0,
4028 max_chunks_bytes,
4029 spill_failed: false,
4030 }
4031 }
4032}
4033
4034async fn ingest_bash_chunk(chunk: Vec<u8>, state: &mut BashOutputState) -> Result<()> {
4035 state.last_byte_was_newline = chunk.last().is_some_and(|byte| *byte == b'\n');
4036 state.total_bytes = state.total_bytes.saturating_add(chunk.len());
4037 state.line_count = state
4038 .line_count
4039 .saturating_add(memchr::memchr_iter(b'\n', &chunk).count());
4040
4041 if state.total_bytes > DEFAULT_MAX_BYTES && state.temp_file.is_none() && !state.spill_failed {
4042 let id_full = Uuid::new_v4().simple().to_string();
4043 let id = &id_full[..16];
4044 let path = std::env::temp_dir().join(format!("pi-bash-{id}.log"));
4045
4046 let expected_inode: Option<u64> = {
4050 let mut options = std::fs::OpenOptions::new();
4051 options.write(true).create_new(true);
4052
4053 #[cfg(unix)]
4054 {
4055 use std::os::unix::fs::OpenOptionsExt;
4056 options.mode(0o600);
4057 }
4058
4059 let file = options
4060 .open(&path)
4061 .map_err(|e| Error::tool("bash", format!("Failed to create temp file: {e}")))?;
4062
4063 #[cfg(unix)]
4064 {
4065 use std::os::unix::fs::MetadataExt;
4066 file.metadata().ok().map(|m| m.ino())
4067 }
4068 #[cfg(not(unix))]
4069 {
4070 None
4071 }
4072 };
4073
4074 let mut file = asupersync::fs::OpenOptions::new()
4075 .append(true)
4076 .open(&path)
4077 .await
4078 .map_err(|e| Error::tool("bash", format!("Failed to open temp file: {e}")))?;
4079
4080 #[cfg(unix)]
4083 if let Some(expected) = expected_inode {
4084 use std::os::unix::fs::MetadataExt;
4085 let meta = file
4086 .metadata()
4087 .await
4088 .map_err(|e| Error::tool("bash", format!("Failed to stat temp file: {e}")))?;
4089 if meta.ino() != expected {
4090 return Err(Error::tool(
4091 "bash",
4092 "Temp file identity mismatch (possible TOCTOU attack)".to_string(),
4093 ));
4094 }
4095 }
4096
4097 let mut failed_flush = false;
4099 for existing in &state.chunks {
4100 if let Err(e) = file.write_all(existing).await {
4101 tracing::warn!("Failed to flush bash chunk to temp file: {e}");
4102 failed_flush = true;
4103 break;
4104 }
4105 }
4106
4107 if failed_flush {
4108 state.spill_failed = true;
4109 let _ = std::fs::remove_file(&path);
4110 } else {
4111 state.temp_file_path = Some(path);
4112 state.temp_file = Some(file);
4113 }
4114 }
4115
4116 if let Some(file) = state.temp_file.as_mut() {
4117 if state.total_bytes <= BASH_FILE_LIMIT_BYTES {
4118 if let Err(e) = file.write_all(&chunk).await {
4119 tracing::warn!("Failed to write bash chunk to temp file: {e}");
4120 state.spill_failed = true;
4121 state.temp_file = None;
4122 }
4123 } else {
4124 if !state.spill_failed {
4126 tracing::warn!("Bash output exceeded hard limit; stopping file log");
4127 state.spill_failed = true;
4128 state.temp_file = None;
4129 }
4130 }
4131 }
4132
4133 state.chunks_bytes = state.chunks_bytes.saturating_add(chunk.len());
4134 state.chunks.push_back(chunk);
4135 while state.chunks_bytes > state.max_chunks_bytes && state.chunks.len() > 1 {
4136 if let Some(front) = state.chunks.pop_front() {
4137 state.chunks_bytes = state.chunks_bytes.saturating_sub(front.len());
4138 }
4139 }
4140 Ok(())
4141}
4142
4143const fn line_count_from_newline_count(
4144 total_bytes: usize,
4145 newline_count: usize,
4146 last_byte_was_newline: bool,
4147) -> usize {
4148 if total_bytes == 0 {
4149 0
4150 } else if last_byte_was_newline {
4151 newline_count
4152 } else {
4153 newline_count.saturating_add(1)
4154 }
4155}
4156
4157fn emit_bash_update(
4158 state: &BashOutputState,
4159 on_update: Option<&(dyn Fn(ToolUpdate) + Send + Sync)>,
4160) -> Result<()> {
4161 if let Some(callback) = on_update {
4162 let raw = concat_chunks(&state.chunks);
4163 let full_text = String::from_utf8_lossy(&raw);
4164 let truncation =
4165 truncate_tail(full_text.into_owned(), DEFAULT_MAX_LINES, DEFAULT_MAX_BYTES);
4166
4167 let elapsed_ms = state.start_time.elapsed().as_millis();
4172 let line_count = line_count_from_newline_count(
4173 state.total_bytes,
4174 state.line_count,
4175 state.last_byte_was_newline,
4176 );
4177 let mut details = serde_json::json!({
4178 "progress": {
4179 "elapsedMs": elapsed_ms,
4180 "lineCount": line_count,
4181 "byteCount": state.total_bytes
4182 }
4183 });
4184 let details_map = details.as_object_mut().expect("just built");
4185
4186 if let Some(timeout) = state.timeout_ms {
4187 details_map["progress"]
4188 .as_object_mut()
4189 .expect("just built")
4190 .insert("timeoutMs".into(), serde_json::json!(timeout));
4191 }
4192 if truncation.truncated {
4193 details_map.insert("truncation".into(), serde_json::to_value(&truncation)?);
4194 }
4195 if let Some(path) = state.temp_file_path.as_ref() {
4196 details_map.insert(
4197 "fullOutputPath".into(),
4198 serde_json::Value::String(path.display().to_string()),
4199 );
4200 }
4201
4202 callback(ToolUpdate {
4203 content: vec![ContentBlock::Text(TextContent::new(truncation.content))],
4204 details: Some(details),
4205 });
4206 }
4207 Ok(())
4208}
4209
4210#[allow(dead_code)]
4211async fn process_bash_chunk(
4212 chunk: Vec<u8>,
4213 state: &mut BashOutputState,
4214 on_update: Option<&(dyn Fn(ToolUpdate) + Send + Sync)>,
4215) -> Result<()> {
4216 ingest_bash_chunk(chunk, state).await?;
4217 emit_bash_update(state, on_update)
4218}
4219
4220pub(crate) struct ProcessGuard {
4221 child: Option<std::process::Child>,
4222 kill_tree: bool,
4223}
4224
4225impl ProcessGuard {
4226 pub(crate) const fn new(child: std::process::Child, kill_tree: bool) -> Self {
4227 Self {
4228 child: Some(child),
4229 kill_tree,
4230 }
4231 }
4232
4233 pub(crate) fn try_wait_child(&mut self) -> std::io::Result<Option<std::process::ExitStatus>> {
4234 self.child
4235 .as_mut()
4236 .map_or(Ok(None), std::process::Child::try_wait)
4237 }
4238
4239 pub(crate) fn kill(&mut self) -> std::io::Result<Option<std::process::ExitStatus>> {
4240 if let Some(mut child) = self.child.take() {
4241 if self.kill_tree {
4242 let pid = child.id();
4243 kill_process_tree(Some(pid));
4244 }
4245 let _ = child.kill();
4246 let status = child.wait()?;
4247 return Ok(Some(status));
4248 }
4249 Ok(None)
4250 }
4251
4252 pub(crate) fn wait(&mut self) -> std::io::Result<std::process::ExitStatus> {
4253 if let Some(mut child) = self.child.take() {
4254 return child.wait();
4255 }
4256 Err(std::io::Error::other("Already waited"))
4257 }
4258}
4259
4260impl Drop for ProcessGuard {
4261 fn drop(&mut self) {
4262 if let Some(mut child) = self.child.take() {
4263 match child.try_wait() {
4264 Ok(None) => {}
4265 Ok(Some(_)) | Err(_) => return,
4266 }
4267 if self.kill_tree {
4268 let pid = child.id();
4269 kill_process_tree(Some(pid));
4270 }
4271 let _ = child.kill();
4272 let _ = child.wait();
4273 }
4274 }
4275}
4276
4277fn terminate_process_tree(pid: Option<u32>) {
4278 kill_process_tree_with(pid, sysinfo::Signal::Term);
4279}
4280
4281pub fn kill_process_tree(pid: Option<u32>) {
4282 kill_process_tree_with(pid, sysinfo::Signal::Kill);
4283}
4284
4285fn kill_process_tree_with(pid: Option<u32>, signal: sysinfo::Signal) {
4286 let Some(pid) = pid else {
4287 return;
4288 };
4289 let root = sysinfo::Pid::from_u32(pid);
4290
4291 let mut sys = sysinfo::System::new();
4292 sys.refresh_processes(sysinfo::ProcessesToUpdate::All, true);
4293
4294 let mut children_map: HashMap<sysinfo::Pid, Vec<sysinfo::Pid>> = HashMap::new();
4295 for (p, proc_) in sys.processes() {
4296 if let Some(parent) = proc_.parent() {
4297 children_map.entry(parent).or_default().push(*p);
4298 }
4299 }
4300
4301 let mut to_kill = Vec::new();
4302 collect_process_tree(root, &children_map, &mut to_kill);
4303
4304 for pid in to_kill.into_iter().rev() {
4306 if let Some(proc_) = sys.process(pid) {
4307 match proc_.kill_with(signal) {
4308 Some(true) => {}
4309 Some(false) | None => {
4310 let _ = proc_.kill();
4311 }
4312 }
4313 }
4314 }
4315}
4316
4317fn collect_process_tree(
4318 pid: sysinfo::Pid,
4319 children_map: &HashMap<sysinfo::Pid, Vec<sysinfo::Pid>>,
4320 out: &mut Vec<sysinfo::Pid>,
4321) {
4322 out.push(pid);
4323 if let Some(children) = children_map.get(&pid) {
4324 for child in children {
4325 collect_process_tree(*child, children_map, out);
4326 }
4327 }
4328}
4329
4330fn format_grep_path(file_path: &Path, cwd: &Path) -> String {
4331 if let Ok(rel) = file_path.strip_prefix(cwd) {
4332 let rel_str = rel.display().to_string().replace('\\', "/");
4333 if !rel_str.is_empty() {
4334 return rel_str;
4335 }
4336 }
4337 file_path.display().to_string().replace('\\', "/")
4338}
4339
4340async fn get_file_lines_async<'a>(
4341 path: &Path,
4342 cache: &'a mut HashMap<PathBuf, Vec<String>>,
4343) -> &'a [String] {
4344 if !cache.contains_key(path) {
4345 if let Ok(meta) = asupersync::fs::metadata(path).await {
4347 if meta.len() > 10 * 1024 * 1024 {
4348 cache.insert(path.to_path_buf(), Vec::new());
4349 return &[];
4350 }
4351 }
4352
4353 let bytes = asupersync::fs::read(path).await.unwrap_or_default();
4355 let content = String::from_utf8_lossy(&bytes).to_string();
4356 let normalized = content.replace("\r\n", "\n").replace('\r', "\n");
4357 let lines: Vec<String> = normalized.split('\n').map(str::to_string).collect();
4358 cache.insert(path.to_path_buf(), lines);
4359 }
4360 cache.get(path).unwrap().as_slice()
4361}
4362
4363fn find_fd_binary() -> Option<&'static str> {
4364 static BINARY: OnceLock<Option<&'static str>> = OnceLock::new();
4365 *BINARY.get_or_init(|| {
4366 if std::process::Command::new("fd")
4367 .arg("--version")
4368 .stdout(Stdio::null())
4369 .stderr(Stdio::null())
4370 .status()
4371 .is_ok()
4372 {
4373 return Some("fd");
4374 }
4375 if std::process::Command::new("fdfind")
4376 .arg("--version")
4377 .stdout(Stdio::null())
4378 .stderr(Stdio::null())
4379 .status()
4380 .is_ok()
4381 {
4382 return Some("fdfind");
4383 }
4384 None
4385 })
4386}
4387
4388#[cfg(test)]
4393mod tests {
4394 use super::*;
4395 use proptest::prelude::*;
4396 #[cfg(target_os = "linux")]
4397 use std::time::Duration;
4398
4399 #[test]
4400 fn test_truncate_head() {
4401 let content = "line1\nline2\nline3\nline4\nline5".to_string();
4402 let result = truncate_head(content, 3, 1000);
4403
4404 assert_eq!(result.content, "line1\nline2\nline3\n");
4405 assert!(result.truncated);
4406 assert_eq!(result.truncated_by, Some(TruncatedBy::Lines));
4407 assert_eq!(result.total_lines, 5);
4408 assert_eq!(result.output_lines, 3);
4409 }
4410
4411 #[test]
4412 fn test_truncate_tail() {
4413 let content = "line1\nline2\nline3\nline4\nline5".to_string();
4414 let result = truncate_tail(content, 3, 1000);
4415
4416 assert_eq!(result.content, "line3\nline4\nline5");
4417 assert!(result.truncated);
4418 assert_eq!(result.truncated_by, Some(TruncatedBy::Lines));
4419 assert_eq!(result.total_lines, 5);
4420 assert_eq!(result.output_lines, 3);
4421 }
4422
4423 #[test]
4424 fn test_truncate_tail_zero_lines_returns_empty_output() {
4425 let result = truncate_tail("line1\nline2".to_string(), 0, 1000);
4426
4427 assert!(result.truncated);
4428 assert_eq!(result.truncated_by, Some(TruncatedBy::Lines));
4429 assert_eq!(result.output_lines, 0);
4430 assert_eq!(result.output_bytes, 0);
4431 assert!(result.content.is_empty());
4432 }
4433
4434 #[test]
4435 fn test_line_count_from_newline_count_matches_trailing_newline_semantics() {
4436 assert_eq!(line_count_from_newline_count(0, 0, false), 0);
4437 assert_eq!(line_count_from_newline_count(2, 1, true), 1);
4438 assert_eq!(line_count_from_newline_count(1, 0, false), 1);
4439 assert_eq!(line_count_from_newline_count(3, 1, false), 2);
4440 }
4441
4442 #[test]
4443 fn test_truncate_by_bytes() {
4444 let content = "short\nthis is a longer line\nanother".to_string();
4445 let result = truncate_head(content, 100, 15);
4446
4447 assert!(result.truncated);
4448 assert_eq!(result.truncated_by, Some(TruncatedBy::Bytes));
4449 }
4450
4451 #[test]
4452 fn test_resolve_path_absolute() {
4453 let cwd = PathBuf::from("/home/user/project");
4454 let result = resolve_path("/absolute/path", &cwd);
4455 assert_eq!(result, PathBuf::from("/absolute/path"));
4456 }
4457
4458 #[test]
4459 fn test_resolve_path_relative() {
4460 let cwd = PathBuf::from("/home/user/project");
4461 let result = resolve_path("src/main.rs", &cwd);
4462 assert_eq!(result, PathBuf::from("/home/user/project/src/main.rs"));
4463 }
4464
4465 #[test]
4466 fn test_normalize_dot_segments_preserves_root() {
4467 let result = normalize_dot_segments(std::path::Path::new("/../etc/passwd"));
4468 assert_eq!(result, PathBuf::from("/etc/passwd"));
4469 }
4470
4471 #[test]
4472 fn test_normalize_dot_segments_preserves_leading_parent_for_relative() {
4473 let result = normalize_dot_segments(std::path::Path::new("../a/../b"));
4474 assert_eq!(result, PathBuf::from("../b"));
4475 }
4476
4477 #[test]
4478 fn test_detect_supported_image_mime_type_from_bytes() {
4479 assert_eq!(
4480 detect_supported_image_mime_type_from_bytes(b"\x89PNG\r\n\x1A\n"),
4481 Some("image/png")
4482 );
4483 assert_eq!(
4484 detect_supported_image_mime_type_from_bytes(b"\xFF\xD8\xFF"),
4485 Some("image/jpeg")
4486 );
4487 assert_eq!(
4488 detect_supported_image_mime_type_from_bytes(b"GIF89a"),
4489 Some("image/gif")
4490 );
4491 assert_eq!(
4492 detect_supported_image_mime_type_from_bytes(b"RIFF1234WEBP"),
4493 Some("image/webp")
4494 );
4495 assert_eq!(
4496 detect_supported_image_mime_type_from_bytes(b"not an image"),
4497 None
4498 );
4499 }
4500
4501 #[test]
4502 fn test_format_size() {
4503 assert_eq!(format_size(500), "500B");
4504 assert_eq!(format_size(1024), "1.0KB");
4505 assert_eq!(format_size(1536), "1.5KB");
4506 assert_eq!(format_size(1_048_576), "1.0MB");
4507 assert_eq!(format_size(1_073_741_824), "1024.0MB");
4508 }
4509
4510 #[test]
4511 fn test_js_string_length() {
4512 assert_eq!(js_string_length("hello"), 5);
4513 assert_eq!(js_string_length("😀"), 2);
4514 }
4515
4516 #[test]
4517 fn test_truncate_line() {
4518 let short = "short line";
4519 let result = truncate_line(short, 100);
4520 assert_eq!(result.text, "short line");
4521 assert!(!result.was_truncated);
4522
4523 let long = "a".repeat(600);
4524 let result = truncate_line(&long, 500);
4525 assert!(result.was_truncated);
4526 assert!(result.text.ends_with("... [truncated]"));
4527 }
4528
4529 fn get_text(content: &[ContentBlock]) -> String {
4534 content
4535 .iter()
4536 .filter_map(|block| {
4537 if let ContentBlock::Text(text) = block {
4538 Some(text.text.clone())
4539 } else {
4540 None
4541 }
4542 })
4543 .collect::<String>()
4544 }
4545
4546 #[test]
4551 fn test_read_valid_file() {
4552 asupersync::test_utils::run_test(|| async {
4553 let tmp = tempfile::tempdir().unwrap();
4554 std::fs::write(tmp.path().join("hello.txt"), "alpha\nbeta\ngamma").unwrap();
4555
4556 let tool = ReadTool::new(tmp.path());
4557 let out = tool
4558 .execute(
4559 "t",
4560 serde_json::json!({ "path": tmp.path().join("hello.txt").to_string_lossy() }),
4561 None,
4562 )
4563 .await
4564 .unwrap();
4565 let text = get_text(&out.content);
4566 assert!(text.contains("alpha"));
4567 assert!(text.contains("beta"));
4568 assert!(text.contains("gamma"));
4569 assert!(!out.is_error);
4570 });
4571 }
4572
4573 #[test]
4574 fn test_read_nonexistent_file() {
4575 asupersync::test_utils::run_test(|| async {
4576 let tmp = tempfile::tempdir().unwrap();
4577 let tool = ReadTool::new(tmp.path());
4578 let err = tool
4579 .execute(
4580 "t",
4581 serde_json::json!({ "path": tmp.path().join("nope.txt").to_string_lossy() }),
4582 None,
4583 )
4584 .await;
4585 assert!(err.is_err());
4586 });
4587 }
4588
4589 #[test]
4590 fn test_read_empty_file() {
4591 asupersync::test_utils::run_test(|| async {
4592 let tmp = tempfile::tempdir().unwrap();
4593 std::fs::write(tmp.path().join("empty.txt"), "").unwrap();
4594
4595 let tool = ReadTool::new(tmp.path());
4596 let out = tool
4597 .execute(
4598 "t",
4599 serde_json::json!({ "path": tmp.path().join("empty.txt").to_string_lossy() }),
4600 None,
4601 )
4602 .await
4603 .unwrap();
4604 let text = get_text(&out.content);
4605 assert_eq!(text, "");
4606 assert!(!out.is_error);
4607 });
4608 }
4609
4610 #[test]
4611 fn test_read_empty_file_positive_offset_errors() {
4612 asupersync::test_utils::run_test(|| async {
4613 let tmp = tempfile::tempdir().unwrap();
4614 std::fs::write(tmp.path().join("empty.txt"), "").unwrap();
4615
4616 let tool = ReadTool::new(tmp.path());
4617 let err = tool
4618 .execute(
4619 "t",
4620 serde_json::json!({
4621 "path": tmp.path().join("empty.txt").to_string_lossy(),
4622 "offset": 1
4623 }),
4624 None,
4625 )
4626 .await;
4627 assert!(err.is_err());
4628 let msg = err.unwrap_err().to_string();
4629 assert!(msg.contains("beyond end of file"));
4630 });
4631 }
4632
4633 #[test]
4634 fn test_read_rejects_zero_limit() {
4635 asupersync::test_utils::run_test(|| async {
4636 let tmp = tempfile::tempdir().unwrap();
4637 std::fs::write(tmp.path().join("lines.txt"), "a\nb\nc\n").unwrap();
4638
4639 let tool = ReadTool::new(tmp.path());
4640 let err = tool
4641 .execute(
4642 "t",
4643 serde_json::json!({
4644 "path": tmp.path().join("lines.txt").to_string_lossy(),
4645 "limit": 0
4646 }),
4647 None,
4648 )
4649 .await;
4650 assert!(err.is_err());
4651 assert!(
4652 err.unwrap_err()
4653 .to_string()
4654 .contains("`limit` must be greater than 0")
4655 );
4656 });
4657 }
4658
4659 #[test]
4660 fn test_read_offset_and_limit() {
4661 asupersync::test_utils::run_test(|| async {
4662 let tmp = tempfile::tempdir().unwrap();
4663 std::fs::write(
4664 tmp.path().join("lines.txt"),
4665 "L1\nL2\nL3\nL4\nL5\nL6\nL7\nL8\nL9\nL10",
4666 )
4667 .unwrap();
4668
4669 let tool = ReadTool::new(tmp.path());
4670 let out = tool
4671 .execute(
4672 "t",
4673 serde_json::json!({
4674 "path": tmp.path().join("lines.txt").to_string_lossy(),
4675 "offset": 3,
4676 "limit": 2
4677 }),
4678 None,
4679 )
4680 .await
4681 .unwrap();
4682 let text = get_text(&out.content);
4683 assert!(text.contains("L3"));
4684 assert!(text.contains("L4"));
4685 assert!(!text.contains("L2"));
4686 assert!(!text.contains("L5"));
4687 });
4688 }
4689
4690 #[test]
4691 fn test_read_offset_beyond_eof() {
4692 asupersync::test_utils::run_test(|| async {
4693 let tmp = tempfile::tempdir().unwrap();
4694 std::fs::write(tmp.path().join("short.txt"), "a\nb").unwrap();
4695
4696 let tool = ReadTool::new(tmp.path());
4697 let err = tool
4698 .execute(
4699 "t",
4700 serde_json::json!({
4701 "path": tmp.path().join("short.txt").to_string_lossy(),
4702 "offset": 100
4703 }),
4704 None,
4705 )
4706 .await;
4707 assert!(err.is_err());
4708 let msg = err.unwrap_err().to_string();
4709 assert!(msg.contains("beyond end of file"));
4710 });
4711 }
4712
4713 #[test]
4714 fn test_map_normalized_with_trailing_whitespace() {
4715 let content = "A \nB";
4717
4718 let (start, len) = map_normalized_range_to_original(content, 0, 1);
4720 assert_eq!(start, 0);
4721 assert_eq!(len, 1);
4722 assert_eq!(&content[start..start + len], "A");
4723
4724 let (start, len) = map_normalized_range_to_original(content, 1, 1);
4735 assert_eq!(start, 4);
4736 assert_eq!(len, 1);
4737 assert_eq!(&content[start..start + len], "\n");
4738
4739 let (start, len) = map_normalized_range_to_original(content, 2, 1);
4741 assert_eq!(start, 5);
4742 assert_eq!(len, 1);
4743 assert_eq!(&content[start..start + len], "B");
4744 }
4745
4746 #[test]
4747 fn test_read_binary_file_lossy() {
4748 asupersync::test_utils::run_test(|| async {
4749 let tmp = tempfile::tempdir().unwrap();
4750 let binary_data: Vec<u8> = (0..=255).collect();
4751 std::fs::write(tmp.path().join("binary.bin"), &binary_data).unwrap();
4752
4753 let tool = ReadTool::new(tmp.path());
4754 let out = tool
4755 .execute(
4756 "t",
4757 serde_json::json!({ "path": tmp.path().join("binary.bin").to_string_lossy() }),
4758 None,
4759 )
4760 .await
4761 .unwrap();
4762 let text = get_text(&out.content);
4764 assert!(!text.is_empty());
4765 assert!(!out.is_error);
4766 });
4767 }
4768
4769 #[test]
4770 fn test_read_image_detection() {
4771 asupersync::test_utils::run_test(|| async {
4772 let tmp = tempfile::tempdir().unwrap();
4773 let png_header: Vec<u8> = vec![
4775 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,
4779 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,
4785 ];
4786 std::fs::write(tmp.path().join("test.png"), &png_header).unwrap();
4787
4788 let tool = ReadTool::new(tmp.path());
4789 let out = tool
4790 .execute(
4791 "t",
4792 serde_json::json!({ "path": tmp.path().join("test.png").to_string_lossy() }),
4793 None,
4794 )
4795 .await
4796 .unwrap();
4797
4798 let has_image = out
4800 .content
4801 .iter()
4802 .any(|b| matches!(b, ContentBlock::Image(_)));
4803 assert!(has_image, "expected image content block for PNG file");
4804 });
4805 }
4806
4807 #[test]
4808 fn test_read_blocked_images() {
4809 asupersync::test_utils::run_test(|| async {
4810 let tmp = tempfile::tempdir().unwrap();
4811 let png_header: Vec<u8> =
4812 vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00];
4813 std::fs::write(tmp.path().join("test.png"), &png_header).unwrap();
4814
4815 let tool = ReadTool::with_settings(tmp.path(), false, true);
4816 let err = tool
4817 .execute(
4818 "t",
4819 serde_json::json!({ "path": tmp.path().join("test.png").to_string_lossy() }),
4820 None,
4821 )
4822 .await;
4823 assert!(err.is_err());
4824 assert!(err.unwrap_err().to_string().contains("blocked"));
4825 });
4826 }
4827
4828 #[test]
4829 fn test_read_truncation_at_max_lines() {
4830 asupersync::test_utils::run_test(|| async {
4831 let tmp = tempfile::tempdir().unwrap();
4832 let content: String = (0..DEFAULT_MAX_LINES + 500)
4833 .map(|i| format!("line {i}"))
4834 .collect::<Vec<_>>()
4835 .join("\n");
4836 std::fs::write(tmp.path().join("big.txt"), &content).unwrap();
4837
4838 let tool = ReadTool::new(tmp.path());
4839 let out = tool
4840 .execute(
4841 "t",
4842 serde_json::json!({ "path": tmp.path().join("big.txt").to_string_lossy() }),
4843 None,
4844 )
4845 .await
4846 .unwrap();
4847 assert!(out.details.is_some(), "expected truncation details");
4849 let text = get_text(&out.content);
4850 assert!(text.contains("offset="));
4851 });
4852 }
4853
4854 #[test]
4855 fn test_read_first_line_exceeds_max_bytes() {
4856 asupersync::test_utils::run_test(|| async {
4857 let tmp = tempfile::tempdir().unwrap();
4858 let long_line = "a".repeat(DEFAULT_MAX_BYTES + 128);
4859 std::fs::write(tmp.path().join("too_long.txt"), long_line).unwrap();
4860
4861 let tool = ReadTool::new(tmp.path());
4862 let out = tool
4863 .execute(
4864 "t",
4865 serde_json::json!({ "path": tmp.path().join("too_long.txt").to_string_lossy() }),
4866 None,
4867 )
4868 .await
4869 .unwrap();
4870
4871 let text = get_text(&out.content);
4872 assert!(text.contains("exceeds 50.0KB limit"));
4873 let details = out.details.expect("expected truncation details");
4874 assert_eq!(
4875 details
4876 .get("truncation")
4877 .and_then(|v| v.get("firstLineExceedsLimit"))
4878 .and_then(serde_json::Value::as_bool),
4879 Some(true)
4880 );
4881 });
4882 }
4883
4884 #[test]
4885 fn test_read_unicode_content() {
4886 asupersync::test_utils::run_test(|| async {
4887 let tmp = tempfile::tempdir().unwrap();
4888 std::fs::write(tmp.path().join("uni.txt"), "Hello 你好 🌍\nLine 2 café").unwrap();
4889
4890 let tool = ReadTool::new(tmp.path());
4891 let out = tool
4892 .execute(
4893 "t",
4894 serde_json::json!({ "path": tmp.path().join("uni.txt").to_string_lossy() }),
4895 None,
4896 )
4897 .await
4898 .unwrap();
4899 let text = get_text(&out.content);
4900 assert!(text.contains("你好"));
4901 assert!(text.contains("🌍"));
4902 assert!(text.contains("café"));
4903 });
4904 }
4905
4906 #[test]
4911 fn test_write_new_file() {
4912 asupersync::test_utils::run_test(|| async {
4913 let tmp = tempfile::tempdir().unwrap();
4914 let tool = WriteTool::new(tmp.path());
4915 let out = tool
4916 .execute(
4917 "t",
4918 serde_json::json!({
4919 "path": tmp.path().join("new.txt").to_string_lossy(),
4920 "content": "hello world"
4921 }),
4922 None,
4923 )
4924 .await
4925 .unwrap();
4926 assert!(!out.is_error);
4927 let contents = std::fs::read_to_string(tmp.path().join("new.txt")).unwrap();
4928 assert_eq!(contents, "hello world");
4929 });
4930 }
4931
4932 #[test]
4933 fn test_write_overwrite_existing() {
4934 asupersync::test_utils::run_test(|| async {
4935 let tmp = tempfile::tempdir().unwrap();
4936 std::fs::write(tmp.path().join("exist.txt"), "old content").unwrap();
4937
4938 let tool = WriteTool::new(tmp.path());
4939 let out = tool
4940 .execute(
4941 "t",
4942 serde_json::json!({
4943 "path": tmp.path().join("exist.txt").to_string_lossy(),
4944 "content": "new content"
4945 }),
4946 None,
4947 )
4948 .await
4949 .unwrap();
4950 assert!(!out.is_error);
4951 let contents = std::fs::read_to_string(tmp.path().join("exist.txt")).unwrap();
4952 assert_eq!(contents, "new content");
4953 });
4954 }
4955
4956 #[test]
4957 fn test_write_creates_parent_dirs() {
4958 asupersync::test_utils::run_test(|| async {
4959 let tmp = tempfile::tempdir().unwrap();
4960 let tool = WriteTool::new(tmp.path());
4961 let deep_path = tmp.path().join("a/b/c/deep.txt");
4962 let out = tool
4963 .execute(
4964 "t",
4965 serde_json::json!({
4966 "path": deep_path.to_string_lossy(),
4967 "content": "deep file"
4968 }),
4969 None,
4970 )
4971 .await
4972 .unwrap();
4973 assert!(!out.is_error);
4974 assert!(deep_path.exists());
4975 assert_eq!(std::fs::read_to_string(&deep_path).unwrap(), "deep file");
4976 });
4977 }
4978
4979 #[test]
4980 fn test_write_empty_file() {
4981 asupersync::test_utils::run_test(|| async {
4982 let tmp = tempfile::tempdir().unwrap();
4983 let tool = WriteTool::new(tmp.path());
4984 let out = tool
4985 .execute(
4986 "t",
4987 serde_json::json!({
4988 "path": tmp.path().join("empty.txt").to_string_lossy(),
4989 "content": ""
4990 }),
4991 None,
4992 )
4993 .await
4994 .unwrap();
4995 assert!(!out.is_error);
4996 let contents = std::fs::read_to_string(tmp.path().join("empty.txt")).unwrap();
4997 assert_eq!(contents, "");
4998 let text = get_text(&out.content);
4999 assert!(text.contains("Successfully wrote 0 bytes"));
5000 });
5001 }
5002
5003 #[test]
5004 fn test_write_unicode_content() {
5005 asupersync::test_utils::run_test(|| async {
5006 let tmp = tempfile::tempdir().unwrap();
5007 let tool = WriteTool::new(tmp.path());
5008 let out = tool
5009 .execute(
5010 "t",
5011 serde_json::json!({
5012 "path": tmp.path().join("unicode.txt").to_string_lossy(),
5013 "content": "日本語 🎉 Ñoño"
5014 }),
5015 None,
5016 )
5017 .await
5018 .unwrap();
5019 assert!(!out.is_error);
5020 let contents = std::fs::read_to_string(tmp.path().join("unicode.txt")).unwrap();
5021 assert_eq!(contents, "日本語 🎉 Ñoño");
5022 });
5023 }
5024
5025 #[test]
5026 #[cfg(unix)]
5027 fn test_write_file_permissions_unix() {
5028 use std::os::unix::fs::PermissionsExt;
5029 asupersync::test_utils::run_test(|| async {
5030 let tmp = tempfile::tempdir().unwrap();
5031 let tool = WriteTool::new(tmp.path());
5032 let path = tmp.path().join("perms.txt");
5033 let out = tool
5034 .execute(
5035 "t",
5036 serde_json::json!({
5037 "path": path.to_string_lossy(),
5038 "content": "check perms"
5039 }),
5040 None,
5041 )
5042 .await
5043 .unwrap();
5044 assert!(!out.is_error);
5045
5046 let meta = std::fs::metadata(&path).unwrap();
5047 let mode = meta.permissions().mode();
5048 assert_eq!(mode & 0o777, 0o644, "Expected 0o644 permissions");
5063 });
5064 }
5065
5066 #[test]
5071 fn test_edit_exact_match_replace() {
5072 asupersync::test_utils::run_test(|| async {
5073 let tmp = tempfile::tempdir().unwrap();
5074 std::fs::write(tmp.path().join("code.rs"), "fn foo() { bar() }").unwrap();
5075
5076 let tool = EditTool::new(tmp.path());
5077 let out = tool
5078 .execute(
5079 "t",
5080 serde_json::json!({
5081 "path": tmp.path().join("code.rs").to_string_lossy(),
5082 "oldText": "bar()",
5083 "newText": "baz()"
5084 }),
5085 None,
5086 )
5087 .await
5088 .unwrap();
5089 assert!(!out.is_error);
5090 let contents = std::fs::read_to_string(tmp.path().join("code.rs")).unwrap();
5091 assert_eq!(contents, "fn foo() { baz() }");
5092 });
5093 }
5094
5095 #[test]
5096 fn test_edit_no_match_error() {
5097 asupersync::test_utils::run_test(|| async {
5098 let tmp = tempfile::tempdir().unwrap();
5099 std::fs::write(tmp.path().join("code.rs"), "fn foo() {}").unwrap();
5100
5101 let tool = EditTool::new(tmp.path());
5102 let err = tool
5103 .execute(
5104 "t",
5105 serde_json::json!({
5106 "path": tmp.path().join("code.rs").to_string_lossy(),
5107 "oldText": "NONEXISTENT TEXT",
5108 "newText": "replacement"
5109 }),
5110 None,
5111 )
5112 .await;
5113 assert!(err.is_err());
5114 });
5115 }
5116
5117 #[test]
5118 fn test_edit_empty_old_text_error() {
5119 asupersync::test_utils::run_test(|| async {
5120 let tmp = tempfile::tempdir().unwrap();
5121 let path = tmp.path().join("code.rs");
5122 std::fs::write(&path, "fn foo() {}").unwrap();
5123
5124 let tool = EditTool::new(tmp.path());
5125 let err = tool
5126 .execute(
5127 "t",
5128 serde_json::json!({
5129 "path": path.to_string_lossy(),
5130 "oldText": "",
5131 "newText": "prefix"
5132 }),
5133 None,
5134 )
5135 .await
5136 .expect_err("empty oldText should be rejected");
5137
5138 let msg = err.to_string();
5139 assert!(
5140 msg.contains("old text cannot be empty"),
5141 "unexpected error: {msg}"
5142 );
5143 let after = std::fs::read_to_string(path).unwrap();
5144 assert_eq!(after, "fn foo() {}");
5145 });
5146 }
5147
5148 #[test]
5149 fn test_edit_ambiguous_match_error() {
5150 asupersync::test_utils::run_test(|| async {
5151 let tmp = tempfile::tempdir().unwrap();
5152 std::fs::write(tmp.path().join("dup.txt"), "hello hello hello").unwrap();
5153
5154 let tool = EditTool::new(tmp.path());
5155 let err = tool
5156 .execute(
5157 "t",
5158 serde_json::json!({
5159 "path": tmp.path().join("dup.txt").to_string_lossy(),
5160 "oldText": "hello",
5161 "newText": "world"
5162 }),
5163 None,
5164 )
5165 .await;
5166 assert!(err.is_err(), "expected error for ambiguous match");
5167 });
5168 }
5169
5170 #[test]
5171 fn test_edit_multi_line_replacement() {
5172 asupersync::test_utils::run_test(|| async {
5173 let tmp = tempfile::tempdir().unwrap();
5174 std::fs::write(
5175 tmp.path().join("multi.txt"),
5176 "line 1\nline 2\nline 3\nline 4",
5177 )
5178 .unwrap();
5179
5180 let tool = EditTool::new(tmp.path());
5181 let out = tool
5182 .execute(
5183 "t",
5184 serde_json::json!({
5185 "path": tmp.path().join("multi.txt").to_string_lossy(),
5186 "oldText": "line 2\nline 3",
5187 "newText": "replaced 2\nreplaced 3\nextra line"
5188 }),
5189 None,
5190 )
5191 .await
5192 .unwrap();
5193 assert!(!out.is_error);
5194 let contents = std::fs::read_to_string(tmp.path().join("multi.txt")).unwrap();
5195 assert_eq!(
5196 contents,
5197 "line 1\nreplaced 2\nreplaced 3\nextra line\nline 4"
5198 );
5199 });
5200 }
5201
5202 #[test]
5203 fn test_edit_unicode_content() {
5204 asupersync::test_utils::run_test(|| async {
5205 let tmp = tempfile::tempdir().unwrap();
5206 std::fs::write(tmp.path().join("uni.txt"), "Héllo wörld 🌍").unwrap();
5207
5208 let tool = EditTool::new(tmp.path());
5209 let out = tool
5210 .execute(
5211 "t",
5212 serde_json::json!({
5213 "path": tmp.path().join("uni.txt").to_string_lossy(),
5214 "oldText": "wörld 🌍",
5215 "newText": "Welt 🌎"
5216 }),
5217 None,
5218 )
5219 .await
5220 .unwrap();
5221 assert!(!out.is_error);
5222 let contents = std::fs::read_to_string(tmp.path().join("uni.txt")).unwrap();
5223 assert_eq!(contents, "Héllo Welt 🌎");
5224 });
5225 }
5226
5227 #[test]
5228 fn test_edit_missing_file() {
5229 asupersync::test_utils::run_test(|| async {
5230 let tmp = tempfile::tempdir().unwrap();
5231 let tool = EditTool::new(tmp.path());
5232 let err = tool
5233 .execute(
5234 "t",
5235 serde_json::json!({
5236 "path": tmp.path().join("nope.txt").to_string_lossy(),
5237 "oldText": "foo",
5238 "newText": "bar"
5239 }),
5240 None,
5241 )
5242 .await;
5243 assert!(err.is_err());
5244 });
5245 }
5246
5247 #[test]
5252 fn test_bash_simple_command() {
5253 asupersync::test_utils::run_test(|| async {
5254 let tmp = tempfile::tempdir().unwrap();
5255 let tool = BashTool::new(tmp.path());
5256 let out = tool
5257 .execute(
5258 "t",
5259 serde_json::json!({ "command": "echo hello_from_bash" }),
5260 None,
5261 )
5262 .await
5263 .unwrap();
5264 let text = get_text(&out.content);
5265 assert!(text.contains("hello_from_bash"));
5266 assert!(!out.is_error);
5267 });
5268 }
5269
5270 #[test]
5271 fn test_bash_exit_code_nonzero() {
5272 asupersync::test_utils::run_test(|| async {
5273 let tmp = tempfile::tempdir().unwrap();
5274 let tool = BashTool::new(tmp.path());
5275 let out = tool
5276 .execute("t", serde_json::json!({ "command": "exit 42" }), None)
5277 .await
5278 .expect("non-zero exit should return Ok with is_error=true");
5279 assert!(out.is_error, "non-zero exit must set is_error");
5280 let msg = get_text(&out.content);
5281 assert!(
5282 msg.contains("42"),
5283 "expected exit code 42 in output, got: {msg}"
5284 );
5285 });
5286 }
5287
5288 #[cfg(unix)]
5289 #[test]
5290 fn test_bash_signal_termination_is_error() {
5291 asupersync::test_utils::run_test(|| async {
5292 let tmp = tempfile::tempdir().unwrap();
5293 let tool = BashTool::new(tmp.path());
5294 let out = tool
5295 .execute("t", serde_json::json!({ "command": "kill -KILL $$" }), None)
5296 .await
5297 .expect("signal-terminated shell should return Ok with is_error=true");
5298 assert!(
5299 out.is_error,
5300 "signal-terminated shell must be reported as error"
5301 );
5302 let msg = get_text(&out.content);
5303 assert!(
5304 msg.contains("Command exited with code"),
5305 "expected explicit exit-code report, got: {msg}"
5306 );
5307 assert!(
5308 !msg.contains("Command exited with code 0"),
5309 "signal-terminated shell must not appear successful: {msg}"
5310 );
5311 });
5312 }
5313
5314 #[test]
5315 fn test_bash_stderr_capture() {
5316 asupersync::test_utils::run_test(|| async {
5317 let tmp = tempfile::tempdir().unwrap();
5318 let tool = BashTool::new(tmp.path());
5319 let out = tool
5320 .execute(
5321 "t",
5322 serde_json::json!({ "command": "echo stderr_msg >&2" }),
5323 None,
5324 )
5325 .await
5326 .unwrap();
5327 let text = get_text(&out.content);
5328 assert!(
5329 text.contains("stderr_msg"),
5330 "expected stderr output in result, got: {text}"
5331 );
5332 });
5333 }
5334
5335 #[test]
5336 fn test_bash_timeout() {
5337 asupersync::test_utils::run_test(|| async {
5338 let tmp = tempfile::tempdir().unwrap();
5339 let tool = BashTool::new(tmp.path());
5340 let out = tool
5341 .execute(
5342 "t",
5343 serde_json::json!({ "command": "sleep 60", "timeout": 2 }),
5344 None,
5345 )
5346 .await
5347 .expect("timeout should return Ok with is_error=true");
5348 assert!(out.is_error, "timeout must set is_error");
5349 let msg = get_text(&out.content);
5350 assert!(
5351 msg.to_lowercase().contains("timeout") || msg.to_lowercase().contains("timed out"),
5352 "expected timeout indication, got: {msg}"
5353 );
5354 });
5355 }
5356
5357 #[cfg(target_os = "linux")]
5358 #[test]
5359 fn test_bash_timeout_kills_process_tree() {
5360 asupersync::test_utils::run_test(|| async {
5361 let tmp = tempfile::tempdir().unwrap();
5362 let marker = tmp.path().join("leaked_child.txt");
5363 let tool = BashTool::new(tmp.path());
5364
5365 let out = tool
5366 .execute(
5367 "t",
5368 serde_json::json!({
5369 "command": "(sleep 3; echo leaked > leaked_child.txt) & sleep 10",
5370 "timeout": 1
5371 }),
5372 None,
5373 )
5374 .await
5375 .expect("timeout should return Ok with is_error=true");
5376
5377 assert!(out.is_error, "timeout must set is_error");
5378 let msg = get_text(&out.content);
5379 assert!(msg.contains("Command timed out"));
5380
5381 std::thread::sleep(Duration::from_secs(4));
5383 assert!(
5384 !marker.exists(),
5385 "background child was not terminated on timeout"
5386 );
5387 });
5388 }
5389
5390 #[test]
5391 #[cfg(unix)]
5392 fn test_bash_working_directory() {
5393 asupersync::test_utils::run_test(|| async {
5394 let tmp = tempfile::tempdir().unwrap();
5395 let tool = BashTool::new(tmp.path());
5396 let out = tool
5397 .execute("t", serde_json::json!({ "command": "pwd" }), None)
5398 .await
5399 .unwrap();
5400 let text = get_text(&out.content);
5401 let canonical = tmp.path().canonicalize().unwrap();
5402 assert!(
5403 text.contains(&canonical.to_string_lossy().to_string()),
5404 "expected cwd in output, got: {text}"
5405 );
5406 });
5407 }
5408
5409 #[test]
5410 fn test_bash_multiline_output() {
5411 asupersync::test_utils::run_test(|| async {
5412 let tmp = tempfile::tempdir().unwrap();
5413 let tool = BashTool::new(tmp.path());
5414 let out = tool
5415 .execute(
5416 "t",
5417 serde_json::json!({ "command": "echo line1; echo line2; echo line3" }),
5418 None,
5419 )
5420 .await
5421 .unwrap();
5422 let text = get_text(&out.content);
5423 assert!(text.contains("line1"));
5424 assert!(text.contains("line2"));
5425 assert!(text.contains("line3"));
5426 });
5427 }
5428
5429 #[test]
5434 fn test_grep_basic_pattern() {
5435 asupersync::test_utils::run_test(|| async {
5436 let tmp = tempfile::tempdir().unwrap();
5437 std::fs::write(
5438 tmp.path().join("search.txt"),
5439 "apple\nbanana\napricot\ncherry",
5440 )
5441 .unwrap();
5442
5443 let tool = GrepTool::new(tmp.path());
5444 let out = tool
5445 .execute(
5446 "t",
5447 serde_json::json!({
5448 "pattern": "ap",
5449 "path": tmp.path().join("search.txt").to_string_lossy()
5450 }),
5451 None,
5452 )
5453 .await
5454 .unwrap();
5455 let text = get_text(&out.content);
5456 assert!(text.contains("apple"));
5457 assert!(text.contains("apricot"));
5458 assert!(!text.contains("banana"));
5459 assert!(!text.contains("cherry"));
5460 });
5461 }
5462
5463 #[test]
5464 fn test_grep_regex_pattern() {
5465 asupersync::test_utils::run_test(|| async {
5466 let tmp = tempfile::tempdir().unwrap();
5467 std::fs::write(
5468 tmp.path().join("regex.txt"),
5469 "foo123\nbar456\nbaz789\nfoo000",
5470 )
5471 .unwrap();
5472
5473 let tool = GrepTool::new(tmp.path());
5474 let out = tool
5475 .execute(
5476 "t",
5477 serde_json::json!({
5478 "pattern": "foo\\d+",
5479 "path": tmp.path().join("regex.txt").to_string_lossy()
5480 }),
5481 None,
5482 )
5483 .await
5484 .unwrap();
5485 let text = get_text(&out.content);
5486 assert!(text.contains("foo123"));
5487 assert!(text.contains("foo000"));
5488 assert!(!text.contains("bar456"));
5489 });
5490 }
5491
5492 #[test]
5493 fn test_grep_case_insensitive() {
5494 asupersync::test_utils::run_test(|| async {
5495 let tmp = tempfile::tempdir().unwrap();
5496 std::fs::write(tmp.path().join("case.txt"), "Hello\nhello\nHELLO").unwrap();
5497
5498 let tool = GrepTool::new(tmp.path());
5499 let out = tool
5500 .execute(
5501 "t",
5502 serde_json::json!({
5503 "pattern": "hello",
5504 "path": tmp.path().join("case.txt").to_string_lossy(),
5505 "ignoreCase": true
5506 }),
5507 None,
5508 )
5509 .await
5510 .unwrap();
5511 let text = get_text(&out.content);
5512 assert!(text.contains("Hello"));
5513 assert!(text.contains("hello"));
5514 assert!(text.contains("HELLO"));
5515 });
5516 }
5517
5518 #[test]
5519 fn test_grep_case_sensitive_by_default() {
5520 asupersync::test_utils::run_test(|| async {
5521 let tmp = tempfile::tempdir().unwrap();
5522 std::fs::write(tmp.path().join("case_sensitive.txt"), "Hello\nHELLO").unwrap();
5523
5524 let tool = GrepTool::new(tmp.path());
5525 let out = tool
5526 .execute(
5527 "t",
5528 serde_json::json!({
5529 "pattern": "hello",
5530 "path": tmp.path().join("case_sensitive.txt").to_string_lossy()
5531 }),
5532 None,
5533 )
5534 .await
5535 .unwrap();
5536 let text = get_text(&out.content);
5537 assert!(
5538 text.contains("No matches found"),
5539 "expected case-sensitive search to find no matches, got: {text}"
5540 );
5541 });
5542 }
5543
5544 #[test]
5545 fn test_grep_no_matches() {
5546 asupersync::test_utils::run_test(|| async {
5547 let tmp = tempfile::tempdir().unwrap();
5548 std::fs::write(tmp.path().join("nothing.txt"), "alpha\nbeta\ngamma").unwrap();
5549
5550 let tool = GrepTool::new(tmp.path());
5551 let out = tool
5552 .execute(
5553 "t",
5554 serde_json::json!({
5555 "pattern": "ZZZZZ_NOMATCH",
5556 "path": tmp.path().join("nothing.txt").to_string_lossy()
5557 }),
5558 None,
5559 )
5560 .await
5561 .unwrap();
5562 let text = get_text(&out.content);
5563 assert!(
5564 text.to_lowercase().contains("no match")
5565 || text.is_empty()
5566 || text.to_lowercase().contains("no results"),
5567 "expected no-match indication, got: {text}"
5568 );
5569 });
5570 }
5571
5572 #[test]
5573 fn test_grep_context_lines() {
5574 asupersync::test_utils::run_test(|| async {
5575 let tmp = tempfile::tempdir().unwrap();
5576 std::fs::write(
5577 tmp.path().join("ctx.txt"),
5578 "aaa\nbbb\nccc\ntarget\nddd\neee\nfff",
5579 )
5580 .unwrap();
5581
5582 let tool = GrepTool::new(tmp.path());
5583 let out = tool
5584 .execute(
5585 "t",
5586 serde_json::json!({
5587 "pattern": "target",
5588 "path": tmp.path().join("ctx.txt").to_string_lossy(),
5589 "context": 1
5590 }),
5591 None,
5592 )
5593 .await
5594 .unwrap();
5595 let text = get_text(&out.content);
5596 assert!(text.contains("target"));
5597 assert!(text.contains("ccc"), "expected context line before match");
5598 assert!(text.contains("ddd"), "expected context line after match");
5599 });
5600 }
5601
5602 #[test]
5603 fn test_grep_limit() {
5604 asupersync::test_utils::run_test(|| async {
5605 let tmp = tempfile::tempdir().unwrap();
5606 let content: String = (0..200)
5607 .map(|i| format!("match_line_{i}"))
5608 .collect::<Vec<_>>()
5609 .join("\n");
5610 std::fs::write(tmp.path().join("many.txt"), &content).unwrap();
5611
5612 let tool = GrepTool::new(tmp.path());
5613 let out = tool
5614 .execute(
5615 "t",
5616 serde_json::json!({
5617 "pattern": "match_line",
5618 "path": tmp.path().join("many.txt").to_string_lossy(),
5619 "limit": 5
5620 }),
5621 None,
5622 )
5623 .await
5624 .unwrap();
5625 let text = get_text(&out.content);
5626 let match_count = text.matches("match_line_").count();
5628 assert!(
5629 match_count <= 5,
5630 "expected at most 5 matches with limit=5, got {match_count}"
5631 );
5632 let details = out.details.expect("expected limit details");
5633 assert_eq!(
5634 details
5635 .get("matchLimitReached")
5636 .and_then(serde_json::Value::as_u64),
5637 Some(5)
5638 );
5639 });
5640 }
5641
5642 #[test]
5643 fn test_grep_large_output_does_not_deadlock_reader_threads() {
5644 asupersync::test_utils::run_test(|| async {
5645 use std::fmt::Write as _;
5646
5647 let tmp = tempfile::tempdir().unwrap();
5648 let mut content = String::with_capacity(80_000);
5649 for i in 0..5000 {
5650 let _ = writeln!(&mut content, "needle_line_{i}");
5651 }
5652 let file = tmp.path().join("large_grep.txt");
5653 std::fs::write(&file, content).unwrap();
5654
5655 let tool = GrepTool::new(tmp.path());
5656 let run = tool.execute(
5657 "t",
5658 serde_json::json!({
5659 "pattern": "needle_line_",
5660 "path": file.to_string_lossy(),
5661 "limit": 6000
5662 }),
5663 None,
5664 );
5665
5666 let out = asupersync::time::timeout(
5667 asupersync::time::wall_now(),
5668 Duration::from_secs(15),
5669 Box::pin(run),
5670 )
5671 .await
5672 .expect("grep timed out; possible stdout/stderr reader deadlock")
5673 .expect("grep should succeed");
5674
5675 let text = get_text(&out.content);
5676 assert!(text.contains("needle_line_0"));
5677 });
5678 }
5679
5680 #[test]
5681 fn test_grep_respects_gitignore() {
5682 asupersync::test_utils::run_test(|| async {
5683 let tmp = tempfile::tempdir().unwrap();
5684 std::fs::write(tmp.path().join(".gitignore"), "ignored.txt\n").unwrap();
5685 std::fs::write(tmp.path().join("ignored.txt"), "needle in ignored file").unwrap();
5686 std::fs::write(tmp.path().join("visible.txt"), "nothing here").unwrap();
5687
5688 let tool = GrepTool::new(tmp.path());
5689 let out = tool
5690 .execute("t", serde_json::json!({ "pattern": "needle" }), None)
5691 .await
5692 .unwrap();
5693
5694 let text = get_text(&out.content);
5695 assert!(
5696 text.contains("No matches found"),
5697 "expected ignored file to be excluded, got: {text}"
5698 );
5699 });
5700 }
5701
5702 #[test]
5703 fn test_grep_literal_mode() {
5704 asupersync::test_utils::run_test(|| async {
5705 let tmp = tempfile::tempdir().unwrap();
5706 std::fs::write(tmp.path().join("literal.txt"), "a+b\na.b\nab\na\\+b").unwrap();
5707
5708 let tool = GrepTool::new(tmp.path());
5709 let out = tool
5710 .execute(
5711 "t",
5712 serde_json::json!({
5713 "pattern": "a+b",
5714 "path": tmp.path().join("literal.txt").to_string_lossy(),
5715 "literal": true
5716 }),
5717 None,
5718 )
5719 .await
5720 .unwrap();
5721 let text = get_text(&out.content);
5722 assert!(text.contains("a+b"), "literal match should find 'a+b'");
5723 });
5724 }
5725
5726 #[test]
5731 fn test_find_glob_pattern() {
5732 asupersync::test_utils::run_test(|| async {
5733 if find_fd_binary().is_none() {
5734 return;
5735 }
5736 let tmp = tempfile::tempdir().unwrap();
5737 std::fs::write(tmp.path().join("file1.rs"), "").unwrap();
5738 std::fs::write(tmp.path().join("file2.rs"), "").unwrap();
5739 std::fs::write(tmp.path().join("file3.txt"), "").unwrap();
5740
5741 let tool = FindTool::new(tmp.path());
5742 let out = tool
5743 .execute(
5744 "t",
5745 serde_json::json!({
5746 "pattern": "*.rs",
5747 "path": tmp.path().to_string_lossy()
5748 }),
5749 None,
5750 )
5751 .await
5752 .unwrap();
5753 let text = get_text(&out.content);
5754 assert!(text.contains("file1.rs"));
5755 assert!(text.contains("file2.rs"));
5756 assert!(!text.contains("file3.txt"));
5757 });
5758 }
5759
5760 #[test]
5761 fn test_find_limit() {
5762 asupersync::test_utils::run_test(|| async {
5763 if find_fd_binary().is_none() {
5764 return;
5765 }
5766 let tmp = tempfile::tempdir().unwrap();
5767 for i in 0..20 {
5768 std::fs::write(tmp.path().join(format!("f{i}.txt")), "").unwrap();
5769 }
5770
5771 let tool = FindTool::new(tmp.path());
5772 let out = tool
5773 .execute(
5774 "t",
5775 serde_json::json!({
5776 "pattern": "*.txt",
5777 "path": tmp.path().to_string_lossy(),
5778 "limit": 5
5779 }),
5780 None,
5781 )
5782 .await
5783 .unwrap();
5784 let text = get_text(&out.content);
5785 let file_count = text.lines().filter(|l| l.contains(".txt")).count();
5786 assert!(
5787 file_count <= 5,
5788 "expected at most 5 files with limit=5, got {file_count}"
5789 );
5790 let details = out.details.expect("expected limit details");
5791 assert_eq!(
5792 details
5793 .get("resultLimitReached")
5794 .and_then(serde_json::Value::as_u64),
5795 Some(5)
5796 );
5797 });
5798 }
5799
5800 #[test]
5801 fn test_find_no_matches() {
5802 asupersync::test_utils::run_test(|| async {
5803 if find_fd_binary().is_none() {
5804 return;
5805 }
5806 let tmp = tempfile::tempdir().unwrap();
5807 std::fs::write(tmp.path().join("only.txt"), "").unwrap();
5808
5809 let tool = FindTool::new(tmp.path());
5810 let out = tool
5811 .execute(
5812 "t",
5813 serde_json::json!({
5814 "pattern": "*.rs",
5815 "path": tmp.path().to_string_lossy()
5816 }),
5817 None,
5818 )
5819 .await
5820 .unwrap();
5821 let text = get_text(&out.content);
5822 assert!(
5823 text.to_lowercase().contains("no files found")
5824 || text.to_lowercase().contains("no matches")
5825 || text.is_empty(),
5826 "expected no-match indication, got: {text}"
5827 );
5828 });
5829 }
5830
5831 #[test]
5832 fn test_find_nonexistent_path() {
5833 asupersync::test_utils::run_test(|| async {
5834 if find_fd_binary().is_none() {
5835 return;
5836 }
5837 let tmp = tempfile::tempdir().unwrap();
5838 let tool = FindTool::new(tmp.path());
5839 let err = tool
5840 .execute(
5841 "t",
5842 serde_json::json!({
5843 "pattern": "*.rs",
5844 "path": tmp.path().join("nonexistent").to_string_lossy()
5845 }),
5846 None,
5847 )
5848 .await;
5849 assert!(err.is_err());
5850 });
5851 }
5852
5853 #[test]
5854 fn test_find_nested_directories() {
5855 asupersync::test_utils::run_test(|| async {
5856 if find_fd_binary().is_none() {
5857 return;
5858 }
5859 let tmp = tempfile::tempdir().unwrap();
5860 std::fs::create_dir_all(tmp.path().join("a/b/c")).unwrap();
5861 std::fs::write(tmp.path().join("top.rs"), "").unwrap();
5862 std::fs::write(tmp.path().join("a/mid.rs"), "").unwrap();
5863 std::fs::write(tmp.path().join("a/b/c/deep.rs"), "").unwrap();
5864
5865 let tool = FindTool::new(tmp.path());
5866 let out = tool
5867 .execute(
5868 "t",
5869 serde_json::json!({
5870 "pattern": "*.rs",
5871 "path": tmp.path().to_string_lossy()
5872 }),
5873 None,
5874 )
5875 .await
5876 .unwrap();
5877 let text = get_text(&out.content);
5878 assert!(text.contains("top.rs"));
5879 assert!(text.contains("mid.rs"));
5880 assert!(text.contains("deep.rs"));
5881 });
5882 }
5883
5884 #[test]
5885 fn test_find_results_are_sorted() {
5886 asupersync::test_utils::run_test(|| async {
5887 if find_fd_binary().is_none() {
5888 return;
5889 }
5890 let tmp = tempfile::tempdir().unwrap();
5891 std::fs::write(tmp.path().join("zeta.txt"), "").unwrap();
5892 std::fs::write(tmp.path().join("alpha.txt"), "").unwrap();
5893 std::fs::write(tmp.path().join("beta.txt"), "").unwrap();
5894
5895 let tool = FindTool::new(tmp.path());
5896 let out = tool
5897 .execute(
5898 "t",
5899 serde_json::json!({
5900 "pattern": "*.txt",
5901 "path": tmp.path().to_string_lossy()
5902 }),
5903 None,
5904 )
5905 .await
5906 .unwrap();
5907 let lines: Vec<String> = get_text(&out.content)
5908 .lines()
5909 .map(str::trim)
5910 .filter(|line| !line.is_empty())
5911 .map(str::to_string)
5912 .collect();
5913 let mut sorted = lines.clone();
5914 sorted.sort_by_key(|line| line.to_lowercase());
5915 assert_eq!(lines, sorted, "expected sorted find output");
5916 });
5917 }
5918
5919 #[test]
5920 fn test_find_respects_gitignore() {
5921 asupersync::test_utils::run_test(|| async {
5922 if find_fd_binary().is_none() {
5923 return;
5924 }
5925 let tmp = tempfile::tempdir().unwrap();
5926 std::fs::write(tmp.path().join(".gitignore"), "ignored.txt\n").unwrap();
5927 std::fs::write(tmp.path().join("ignored.txt"), "").unwrap();
5928
5929 let tool = FindTool::new(tmp.path());
5930 let out = tool
5931 .execute(
5932 "t",
5933 serde_json::json!({
5934 "pattern": "*.txt",
5935 "path": tmp.path().to_string_lossy()
5936 }),
5937 None,
5938 )
5939 .await
5940 .unwrap();
5941 let text = get_text(&out.content);
5942 assert!(
5943 text.contains("No files found matching pattern"),
5944 "expected .gitignore'd files to be excluded, got: {text}"
5945 );
5946 });
5947 }
5948
5949 #[test]
5954 fn test_ls_directory_listing() {
5955 asupersync::test_utils::run_test(|| async {
5956 let tmp = tempfile::tempdir().unwrap();
5957 std::fs::write(tmp.path().join("file_a.txt"), "content").unwrap();
5958 std::fs::write(tmp.path().join("file_b.rs"), "fn main() {}").unwrap();
5959 std::fs::create_dir(tmp.path().join("subdir")).unwrap();
5960
5961 let tool = LsTool::new(tmp.path());
5962 let out = tool
5963 .execute(
5964 "t",
5965 serde_json::json!({ "path": tmp.path().to_string_lossy() }),
5966 None,
5967 )
5968 .await
5969 .unwrap();
5970 let text = get_text(&out.content);
5971 assert!(text.contains("file_a.txt"));
5972 assert!(text.contains("file_b.rs"));
5973 assert!(text.contains("subdir"));
5974 });
5975 }
5976
5977 #[test]
5978 fn test_ls_trailing_slash_for_dirs() {
5979 asupersync::test_utils::run_test(|| async {
5980 let tmp = tempfile::tempdir().unwrap();
5981 std::fs::write(tmp.path().join("file.txt"), "").unwrap();
5982 std::fs::create_dir(tmp.path().join("mydir")).unwrap();
5983
5984 let tool = LsTool::new(tmp.path());
5985 let out = tool
5986 .execute(
5987 "t",
5988 serde_json::json!({ "path": tmp.path().to_string_lossy() }),
5989 None,
5990 )
5991 .await
5992 .unwrap();
5993 let text = get_text(&out.content);
5994 assert!(
5995 text.contains("mydir/"),
5996 "expected trailing slash for directory, got: {text}"
5997 );
5998 });
5999 }
6000
6001 #[test]
6002 fn test_ls_limit() {
6003 asupersync::test_utils::run_test(|| async {
6004 let tmp = tempfile::tempdir().unwrap();
6005 for i in 0..20 {
6006 std::fs::write(tmp.path().join(format!("item_{i:02}.txt")), "").unwrap();
6007 }
6008
6009 let tool = LsTool::new(tmp.path());
6010 let out = tool
6011 .execute(
6012 "t",
6013 serde_json::json!({
6014 "path": tmp.path().to_string_lossy(),
6015 "limit": 5
6016 }),
6017 None,
6018 )
6019 .await
6020 .unwrap();
6021 let text = get_text(&out.content);
6022 let entry_count = text.lines().filter(|l| l.contains("item_")).count();
6023 assert!(
6024 entry_count <= 5,
6025 "expected at most 5 entries, got {entry_count}"
6026 );
6027 let details = out.details.expect("expected limit details");
6028 assert_eq!(
6029 details
6030 .get("entryLimitReached")
6031 .and_then(serde_json::Value::as_u64),
6032 Some(5)
6033 );
6034 });
6035 }
6036
6037 #[test]
6038 fn test_ls_nonexistent_directory() {
6039 asupersync::test_utils::run_test(|| async {
6040 let tmp = tempfile::tempdir().unwrap();
6041 let tool = LsTool::new(tmp.path());
6042 let err = tool
6043 .execute(
6044 "t",
6045 serde_json::json!({ "path": tmp.path().join("nope").to_string_lossy() }),
6046 None,
6047 )
6048 .await;
6049 assert!(err.is_err());
6050 });
6051 }
6052
6053 #[test]
6054 fn test_ls_empty_directory() {
6055 asupersync::test_utils::run_test(|| async {
6056 let tmp = tempfile::tempdir().unwrap();
6057 let empty_dir = tmp.path().join("empty");
6058 std::fs::create_dir(&empty_dir).unwrap();
6059
6060 let tool = LsTool::new(tmp.path());
6061 let out = tool
6062 .execute(
6063 "t",
6064 serde_json::json!({ "path": empty_dir.to_string_lossy() }),
6065 None,
6066 )
6067 .await
6068 .unwrap();
6069 assert!(!out.is_error);
6070 });
6071 }
6072
6073 #[test]
6074 fn test_ls_default_cwd() {
6075 asupersync::test_utils::run_test(|| async {
6076 let tmp = tempfile::tempdir().unwrap();
6077 std::fs::write(tmp.path().join("in_cwd.txt"), "").unwrap();
6078
6079 let tool = LsTool::new(tmp.path());
6080 let out = tool
6081 .execute("t", serde_json::json!({}), None)
6082 .await
6083 .unwrap();
6084 let text = get_text(&out.content);
6085 assert!(
6086 text.contains("in_cwd.txt"),
6087 "expected cwd listing to include the file, got: {text}"
6088 );
6089 });
6090 }
6091
6092 #[test]
6097 fn test_truncate_head_no_truncation() {
6098 let content = "short".to_string();
6099 let result = truncate_head(content, 100, 1000);
6100 assert!(!result.truncated);
6101 assert_eq!(result.content, "short");
6102 assert_eq!(result.truncated_by, None);
6103 }
6104
6105 #[test]
6106 fn test_truncate_tail_no_truncation() {
6107 let content = "short".to_string();
6108 let result = truncate_tail(content, 100, 1000);
6109 assert!(!result.truncated);
6110 assert_eq!(result.content, "short");
6111 }
6112
6113 #[test]
6114 fn test_truncate_head_empty_input() {
6115 let result = truncate_head(String::new(), 100, 1000);
6116 assert!(!result.truncated);
6117 assert_eq!(result.content, "");
6118 }
6119
6120 #[test]
6121 fn test_truncate_tail_empty_input() {
6122 let result = truncate_tail(String::new(), 100, 1000);
6123 assert!(!result.truncated);
6124 assert_eq!(result.content, "");
6125 }
6126
6127 #[test]
6128 fn test_detect_line_ending_crlf() {
6129 assert_eq!(detect_line_ending("hello\r\nworld"), "\r\n");
6130 }
6131
6132 #[test]
6133 fn test_detect_line_ending_lf() {
6134 assert_eq!(detect_line_ending("hello\nworld"), "\n");
6135 }
6136
6137 #[test]
6138 fn test_detect_line_ending_no_newline() {
6139 assert_eq!(detect_line_ending("hello world"), "\n");
6140 }
6141
6142 #[test]
6143 fn test_normalize_to_lf() {
6144 assert_eq!(normalize_to_lf("a\r\nb\rc\nd"), "a\nb\nc\nd");
6145 }
6146
6147 #[test]
6148 fn test_strip_bom_present() {
6149 let (result, had_bom) = strip_bom("\u{FEFF}hello");
6150 assert_eq!(result, "hello");
6151 assert!(had_bom);
6152 }
6153
6154 #[test]
6155 fn test_strip_bom_absent() {
6156 let (result, had_bom) = strip_bom("hello");
6157 assert_eq!(result, "hello");
6158 assert!(!had_bom);
6159 }
6160
6161 #[test]
6162 fn test_resolve_path_tilde_expansion() {
6163 let cwd = PathBuf::from("/home/user/project");
6164 let result = resolve_path("~/file.txt", &cwd);
6165 assert!(!result.to_string_lossy().starts_with("~/"));
6167 }
6168
6169 fn arbitrary_text() -> impl Strategy<Value = String> {
6170 prop::collection::vec(any::<u8>(), 0..512)
6171 .prop_map(|bytes| String::from_utf8_lossy(&bytes).into_owned())
6172 }
6173
6174 fn match_char_strategy() -> impl Strategy<Value = char> {
6175 prop_oneof![
6176 8 => any::<char>(),
6177 1 => Just('\u{00A0}'),
6178 1 => Just('\u{202F}'),
6179 1 => Just('\u{205F}'),
6180 1 => Just('\u{3000}'),
6181 1 => Just('\u{2018}'),
6182 1 => Just('\u{2019}'),
6183 1 => Just('\u{201C}'),
6184 1 => Just('\u{201D}'),
6185 1 => Just('\u{201E}'),
6186 1 => Just('\u{201F}'),
6187 1 => Just('\u{2010}'),
6188 1 => Just('\u{2011}'),
6189 1 => Just('\u{2012}'),
6190 1 => Just('\u{2013}'),
6191 1 => Just('\u{2014}'),
6192 1 => Just('\u{2015}'),
6193 1 => Just('\u{2212}'),
6194 1 => Just('\u{200D}'),
6195 1 => Just('\u{0301}'),
6196 ]
6197 }
6198
6199 fn arbitrary_match_text() -> impl Strategy<Value = String> {
6200 prop_oneof![
6201 9 => prop::collection::vec(match_char_strategy(), 0..2048),
6202 1 => prop::collection::vec(match_char_strategy(), 8192..16384),
6203 ]
6204 .prop_map(|chars| chars.into_iter().collect())
6205 }
6206
6207 fn line_char_strategy() -> impl Strategy<Value = char> {
6208 prop_oneof![
6209 8 => any::<char>().prop_filter("single-line chars only", |c| *c != '\n'),
6210 1 => Just('é'),
6211 1 => Just('你'),
6212 1 => Just('😀'),
6213 ]
6214 }
6215
6216 fn boundary_line_text() -> impl Strategy<Value = String> {
6217 prop_oneof![
6218 Just(0usize),
6219 Just(GREP_MAX_LINE_LENGTH.saturating_sub(1)),
6220 Just(GREP_MAX_LINE_LENGTH),
6221 Just(GREP_MAX_LINE_LENGTH + 1),
6222 0usize..(GREP_MAX_LINE_LENGTH + 128),
6223 ]
6224 .prop_flat_map(|len| {
6225 prop::collection::vec(line_char_strategy(), len)
6226 .prop_map(|chars| chars.into_iter().collect())
6227 })
6228 }
6229
6230 fn safe_relative_segment() -> impl Strategy<Value = String> {
6231 prop_oneof![
6232 proptest::string::string_regex("[A-Za-z0-9._-]{1,12}")
6233 .expect("segment regex should compile"),
6234 Just("emoji😀".to_string()),
6235 Just("accent-é".to_string()),
6236 Just("rtl-עברית".to_string()),
6237 Just("line\nbreak".to_string()),
6238 Just("nul\0byte".to_string()),
6239 ]
6240 .prop_filter("segment cannot be . or ..", |segment| {
6241 segment != "." && segment != ".."
6242 })
6243 }
6244
6245 fn safe_relative_path() -> impl Strategy<Value = String> {
6246 prop::collection::vec(safe_relative_segment(), 1..6).prop_map(|segments| segments.join("/"))
6247 }
6248
6249 fn pathish_input() -> impl Strategy<Value = String> {
6250 prop_oneof![
6251 5 => safe_relative_path(),
6252 2 => safe_relative_path().prop_map(|p| format!("../{p}")),
6253 2 => safe_relative_path().prop_map(|p| format!("../../{p}")),
6254 1 => safe_relative_path().prop_map(|p| format!("/tmp/{p}")),
6255 1 => safe_relative_path().prop_map(|p| format!("~/{p}")),
6256 1 => Just("~".to_string()),
6257 1 => Just(".".to_string()),
6258 1 => Just("..".to_string()),
6259 1 => Just("././nested/../file.txt".to_string()),
6260 ]
6261 }
6262
6263 proptest! {
6264 #![proptest_config(ProptestConfig { cases: 64, .. ProptestConfig::default() })]
6265
6266 #[test]
6267 fn proptest_truncate_head_invariants(
6268 input in arbitrary_text(),
6269 max_lines in 0usize..32,
6270 max_bytes in 0usize..256,
6271 ) {
6272 let result = truncate_head(input.clone(), max_lines, max_bytes);
6273
6274 prop_assert!(result.output_lines <= max_lines);
6275 prop_assert!(result.output_bytes <= max_bytes);
6276 prop_assert_eq!(result.output_bytes, result.content.len());
6277
6278 prop_assert_eq!(result.truncated, result.truncated_by.is_some());
6279 prop_assert!(input.starts_with(&result.content));
6280
6281 let repeat = truncate_head(result.content.clone(), max_lines, max_bytes);
6282 prop_assert_eq!(&repeat.content, &result.content);
6283
6284 if result.truncated {
6285 prop_assert!(result.total_lines > max_lines || result.total_bytes > max_bytes);
6286 } else {
6287 prop_assert_eq!(&result.content, &input);
6288 prop_assert!(result.total_lines <= max_lines);
6289 prop_assert!(result.total_bytes <= max_bytes);
6290 }
6291
6292 if result.first_line_exceeds_limit {
6293 prop_assert!(result.truncated);
6294 prop_assert_eq!(result.truncated_by, Some(TruncatedBy::Bytes));
6295 prop_assert!(result.output_bytes <= max_bytes);
6296 prop_assert!(result.output_lines <= 1);
6297 prop_assert!(input.starts_with(&result.content));
6298 }
6299 }
6300
6301 #[test]
6302 fn proptest_truncate_tail_invariants(
6303 input in arbitrary_text(),
6304 max_lines in 0usize..32,
6305 max_bytes in 0usize..256,
6306 ) {
6307 let result = truncate_tail(input.clone(), max_lines, max_bytes);
6308
6309 prop_assert!(result.output_lines <= max_lines);
6310 prop_assert!(result.output_bytes <= max_bytes);
6311 prop_assert_eq!(result.output_bytes, result.content.len());
6312
6313 prop_assert_eq!(result.truncated, result.truncated_by.is_some());
6314 prop_assert!(input.ends_with(&result.content));
6315
6316 let repeat = truncate_tail(result.content.clone(), max_lines, max_bytes);
6317 prop_assert_eq!(&repeat.content, &result.content);
6318
6319 if result.last_line_partial {
6320 prop_assert!(result.truncated);
6321 prop_assert_eq!(result.truncated_by, Some(TruncatedBy::Bytes));
6322 prop_assert!(result.output_lines >= 1 && result.output_lines <= 2);
6325 let content_trimmed = result.content.trim_end_matches('\n');
6326 prop_assert!(input
6327 .split('\n')
6328 .rev()
6329 .any(|line| line.ends_with(content_trimmed)));
6330 }
6331 }
6332 }
6333
6334 proptest! {
6335 #![proptest_config(ProptestConfig { cases: 128, .. ProptestConfig::default() })]
6336
6337 #[test]
6338 fn proptest_normalize_for_match_invariants(input in arbitrary_match_text()) {
6339 let normalized = normalize_for_match(&input);
6340 let renormalized = normalize_for_match(&normalized);
6341
6342 prop_assert_eq!(&renormalized, &normalized);
6343 prop_assert!(normalized.len() <= input.len());
6344 prop_assert!(
6345 normalized.chars().all(|c| {
6346 !is_special_unicode_space(c)
6347 && !matches!(
6348 c,
6349 '\u{2018}'
6350 | '\u{2019}'
6351 | '\u{201C}'
6352 | '\u{201D}'
6353 | '\u{201E}'
6354 | '\u{201F}'
6355 | '\u{2010}'
6356 | '\u{2011}'
6357 | '\u{2012}'
6358 | '\u{2013}'
6359 | '\u{2014}'
6360 | '\u{2015}'
6361 | '\u{2212}'
6362 )
6363 }),
6364 "normalize_for_match should remove target punctuation/space variants"
6365 );
6366 }
6367
6368 #[test]
6369 fn proptest_truncate_line_boundary_invariants(line in boundary_line_text()) {
6370 const TRUNCATION_SUFFIX: &str = "... [truncated]";
6371
6372 let result = truncate_line(&line, GREP_MAX_LINE_LENGTH);
6373 let line_char_count = line.chars().count();
6374 let suffix_chars = TRUNCATION_SUFFIX.chars().count();
6375
6376 if line_char_count <= GREP_MAX_LINE_LENGTH {
6377 prop_assert!(!result.was_truncated);
6378 prop_assert_eq!(result.text, line);
6379 } else {
6380 prop_assert!(result.was_truncated);
6381 prop_assert!(result.text.ends_with(TRUNCATION_SUFFIX));
6382 let expected_prefix: String = line.chars().take(GREP_MAX_LINE_LENGTH).collect();
6383 let expected = format!("{expected_prefix}{TRUNCATION_SUFFIX}");
6384 prop_assert_eq!(&result.text, &expected);
6385 prop_assert!(result.text.chars().count() <= GREP_MAX_LINE_LENGTH + suffix_chars);
6386 }
6387 }
6388
6389 #[test]
6390 fn proptest_resolve_path_safe_relative_invariants(relative_path in safe_relative_path()) {
6391 let cwd = PathBuf::from("/tmp/pi-agent-rust-tools-proptest");
6392 let resolved = resolve_path(&relative_path, &cwd);
6393 let normalized = normalize_dot_segments(&resolved);
6394
6395 prop_assert_eq!(&resolved, &cwd.join(&relative_path));
6396 prop_assert!(resolved.starts_with(&cwd));
6397 prop_assert!(normalized.starts_with(&cwd));
6398 prop_assert_eq!(normalize_dot_segments(&normalized), normalized);
6399 }
6400
6401 #[test]
6402 fn proptest_normalize_dot_segments_pathish_invariants(path_input in pathish_input()) {
6403 let cwd = PathBuf::from("/tmp/pi-agent-rust-tools-proptest");
6404 let resolved = resolve_path(&path_input, &cwd);
6405 let normalized_once = normalize_dot_segments(&resolved);
6406 let normalized_twice = normalize_dot_segments(&normalized_once);
6407
6408 prop_assert_eq!(&normalized_once, &normalized_twice);
6409 prop_assert!(
6410 normalized_once
6411 .components()
6412 .all(|component| !matches!(component, std::path::Component::CurDir))
6413 );
6414
6415 if std::path::Path::new(&path_input).is_absolute() {
6416 prop_assert!(resolved.is_absolute());
6417 prop_assert!(normalized_once.is_absolute());
6418 }
6419 }
6420 }
6421
6422 fn fuzzy_content_strategy() -> impl Strategy<Value = String> {
6430 prop::collection::vec(
6431 prop_oneof![
6432 8 => any::<char>().prop_filter("no nul", |c| *c != '\0'),
6433 1 => Just('\u{00A0}'),
6434 1 => Just('\u{2019}'),
6435 1 => Just('\u{201C}'),
6436 1 => Just('\u{2014}'),
6437 ],
6438 1..512,
6439 )
6440 .prop_map(|chars| chars.into_iter().collect())
6441 }
6442
6443 fn needle_from_content(content: String) -> impl Strategy<Value = (String, String)> {
6446 let len = content.len();
6447 if len == 0 {
6448 return Just((content, String::new())).boxed();
6449 }
6450 (0..len)
6451 .prop_flat_map(move |start| {
6452 let c = content.clone();
6453 let remaining = c.len() - start;
6454 let max_needle = remaining.min(256);
6455 (Just(c), start..=start + max_needle.saturating_sub(1))
6456 })
6457 .prop_filter_map("valid char boundary", |(c, end)| {
6458 let start_candidates: Vec<usize> =
6460 (0..c.len()).filter(|i| c.is_char_boundary(*i)).collect();
6461 if start_candidates.is_empty() {
6462 return None;
6463 }
6464 let start = *start_candidates
6465 .iter()
6466 .min_by_key(|&&i| i.abs_diff(end.saturating_sub(end / 2)))
6467 .unwrap_or(&0);
6468 let end_clamped = end.min(c.len());
6469 let actual_end = (end_clamped..=c.len())
6471 .find(|i| c.is_char_boundary(*i))
6472 .unwrap_or(c.len());
6473 if start >= actual_end {
6474 return Some((c, String::new()));
6475 }
6476 Some((c.clone(), c[start..actual_end].to_string()))
6477 })
6478 .boxed()
6479 }
6480
6481 proptest! {
6482 #![proptest_config(ProptestConfig { cases: 128, .. ProptestConfig::default() })]
6483
6484 #[test]
6486 fn proptest_fuzzy_find_text_exact_match_invariants(
6487 (content, needle) in fuzzy_content_strategy().prop_flat_map(needle_from_content)
6488 ) {
6489 let result = fuzzy_find_text(&content, &needle);
6490 if needle.is_empty() {
6491 prop_assert!(result.found, "empty needle should always match");
6493 prop_assert_eq!(result.index, 0);
6494 prop_assert_eq!(result.match_length, 0);
6495 } else {
6496 prop_assert!(
6497 result.found,
6498 "exact substring must be found: content len={}, needle len={}",
6499 content.len(),
6500 needle.len()
6501 );
6502 prop_assert!(content.is_char_boundary(result.index));
6504 prop_assert!(content.is_char_boundary(result.index + result.match_length));
6505 let matched = &content[result.index..result.index + result.match_length];
6507 prop_assert_eq!(matched, needle.as_str());
6508 }
6509 }
6510
6511 #[test]
6516 fn proptest_fuzzy_find_text_normalized_match_invariants(
6517 content in arbitrary_match_text()
6518 ) {
6519 let normalized = build_normalized_content(&content);
6521 if normalized.is_empty() {
6522 return Ok(());
6523 }
6524 let needle_end = normalized
6526 .char_indices()
6527 .nth(128.min(normalized.chars().count().saturating_sub(1)))
6528 .map_or(normalized.len(), |(i, _)| i);
6529 let needle_end = (needle_end..=normalized.len())
6531 .find(|i| normalized.is_char_boundary(*i))
6532 .unwrap_or(normalized.len());
6533 let needle = &normalized[..needle_end];
6534 if needle.is_empty() {
6535 return Ok(());
6536 }
6537
6538 let result = fuzzy_find_text(&content, needle);
6539 prop_assert!(
6540 result.found,
6541 "normalized needle should be found via fuzzy match: needle={:?}",
6542 needle
6543 );
6544 prop_assert!(content.is_char_boundary(result.index));
6546 prop_assert!(content.is_char_boundary(result.index + result.match_length));
6547 }
6548
6549 #[test]
6552 fn proptest_build_normalized_content_invariants(input in arbitrary_match_text()) {
6553 let normalized = build_normalized_content(&input);
6554 let renormalized = build_normalized_content(&normalized);
6555
6556 prop_assert_eq!(
6558 &renormalized,
6559 &normalized,
6560 "build_normalized_content should be idempotent"
6561 );
6562
6563 prop_assert!(
6567 normalized.len() <= input.len(),
6568 "normalized should not be larger: {} vs {}",
6569 normalized.len(),
6570 input.len()
6571 );
6572
6573 let input_lines = input.split('\n').count();
6576 let norm_lines = normalized.split('\n').count();
6577 prop_assert_eq!(
6578 norm_lines, input_lines,
6579 "line count must be preserved by normalization"
6580 );
6581
6582 prop_assert!(
6584 normalized.chars().all(|c| {
6585 !is_special_unicode_space(c)
6586 && !matches!(
6587 c,
6588 '\u{2018}'
6589 | '\u{2019}'
6590 | '\u{201C}'
6591 | '\u{201D}'
6592 | '\u{201E}'
6593 | '\u{201F}'
6594 | '\u{2010}'
6595 | '\u{2011}'
6596 | '\u{2012}'
6597 | '\u{2013}'
6598 | '\u{2014}'
6599 | '\u{2015}'
6600 | '\u{2212}'
6601 )
6602 }),
6603 "normalized content should not contain target Unicode chars"
6604 );
6605 }
6606
6607 #[test]
6615 fn proptest_map_normalized_range_roundtrip(input in arbitrary_match_text()) {
6616 let normalized = build_normalized_content(&input);
6617 if normalized.is_empty() {
6618 return Ok(());
6619 }
6620
6621 let norm_chars: Vec<(usize, char)> = normalized.char_indices().collect();
6623 let norm_len = norm_chars.len();
6624 if norm_len == 0 {
6625 return Ok(());
6626 }
6627
6628 let end_char = (norm_len / 4).max(1).min(norm_len);
6630 let norm_start = norm_chars[0].0;
6631 let norm_end = if end_char < norm_chars.len() {
6632 norm_chars[end_char].0
6633 } else {
6634 normalized.len()
6635 };
6636 let norm_match_len = norm_end - norm_start;
6637
6638 let (orig_start, orig_len) =
6639 map_normalized_range_to_original(&input, norm_start, norm_match_len);
6640
6641 prop_assert!(
6643 orig_start + orig_len <= input.len(),
6644 "mapped range {orig_start}..{} exceeds input len {}",
6645 orig_start + orig_len,
6646 input.len()
6647 );
6648
6649 prop_assert!(
6651 input.is_char_boundary(orig_start),
6652 "orig_start {} is not a char boundary",
6653 orig_start
6654 );
6655 prop_assert!(
6656 input.is_char_boundary(orig_start + orig_len),
6657 "orig_end {} is not a char boundary",
6658 orig_start + orig_len
6659 );
6660
6661 prop_assert!(
6665 orig_len >= norm_match_len
6666 || orig_len == 0
6667 || norm_match_len == 0,
6668 "original range ({orig_len}) should be >= normalized range ({norm_match_len})"
6669 );
6670
6671 let expected_norm = &normalized[norm_start..norm_end];
6675 if !expected_norm.is_empty() {
6676 let fuzzy_result = fuzzy_find_text(&input, expected_norm);
6677 prop_assert!(
6678 fuzzy_result.found,
6679 "normalized needle should be findable in original content"
6680 );
6681 }
6682 }
6683 }
6684
6685 #[test]
6686 fn test_truncate_head_preserves_newline() {
6687 let content = "Line1\nLine2".to_string();
6689 let result = truncate_head(content, 1, 1000);
6690 assert_eq!(result.content, "Line1\n");
6691
6692 let content = "Line1".to_string();
6694 let result = truncate_head(content, 1, 1000);
6695 assert_eq!(result.content, "Line1");
6696
6697 let content = "Line1\n".to_string();
6699 let result = truncate_head(content, 1, 1000);
6700 assert_eq!(result.content, "Line1\n");
6701 }
6702
6703 #[test]
6704 fn test_edit_crlf_content_correctness() {
6705 asupersync::test_utils::run_test(|| async {
6707 let tmp = tempfile::tempdir().unwrap();
6708 let path = tmp.path().join("crlf.txt");
6709 let content = "line1\r\nline2\r\nline3";
6711 std::fs::write(&path, content).unwrap();
6712
6713 let tool = EditTool::new(tmp.path());
6714
6715 let out = tool
6720 .execute(
6721 "t",
6722 serde_json::json!({
6723 "path": path.to_string_lossy(),
6724 "oldText": "line2",
6725 "newText": "changed"
6726 }),
6727 None,
6728 )
6729 .await
6730 .unwrap();
6731
6732 assert!(!out.is_error);
6733 let new_content = std::fs::read_to_string(&path).unwrap();
6734
6735 assert_eq!(new_content, "line1\r\nchanged\r\nline3");
6737 });
6738 }
6739}