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::cmp::Ordering;
19use std::collections::{HashMap, VecDeque};
20use std::fmt::Write as _;
21use std::io::{BufRead, Read, Write};
22use std::path::{Path, PathBuf};
23use std::process::{Command, Stdio};
24use std::sync::{OnceLock, mpsc};
25use std::thread;
26use std::time::{Duration, SystemTime};
27use unicode_normalization::UnicodeNormalization;
28use uuid::Uuid;
29
30#[async_trait]
36pub trait Tool: Send + Sync {
37 fn name(&self) -> &str;
39
40 fn label(&self) -> &str;
42
43 fn description(&self) -> &str;
45
46 fn parameters(&self) -> serde_json::Value;
48
49 async fn execute(
55 &self,
56 tool_call_id: &str,
57 input: serde_json::Value,
58 on_update: Option<Box<dyn Fn(ToolUpdate) + Send + Sync>>,
59 ) -> Result<ToolOutput>;
60
61 fn is_read_only(&self) -> bool {
65 false
66 }
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize)]
71#[serde(rename_all = "camelCase")]
72pub struct ToolOutput {
73 pub content: Vec<ContentBlock>,
74 pub details: Option<serde_json::Value>,
75 #[serde(default, skip_serializing_if = "is_false")]
76 pub is_error: bool,
77}
78
79#[allow(clippy::trivially_copy_pass_by_ref)] const fn is_false(value: &bool) -> bool {
81 !*value
82}
83
84#[derive(Debug, Clone, Serialize)]
86#[serde(rename_all = "camelCase")]
87pub struct ToolUpdate {
88 pub content: Vec<ContentBlock>,
89 pub details: Option<serde_json::Value>,
90}
91
92pub const DEFAULT_MAX_LINES: usize = 2000;
98
99pub const DEFAULT_MAX_BYTES: usize = 1_000_000; pub const GREP_MAX_LINE_LENGTH: usize = 500;
104
105pub const DEFAULT_GREP_LIMIT: usize = 100;
107
108pub const DEFAULT_FIND_LIMIT: usize = 1000;
110
111pub const DEFAULT_LS_LIMIT: usize = 500;
113
114pub const LS_SCAN_HARD_LIMIT: usize = 20_000;
116
117pub const READ_TOOL_MAX_BYTES: u64 = 100 * 1024 * 1024;
119
120pub const WRITE_TOOL_MAX_BYTES: usize = 100 * 1024 * 1024;
122
123pub const IMAGE_MAX_BYTES: usize = 4_718_592;
125
126pub const DEFAULT_BASH_TIMEOUT_SECS: u64 = 120;
128
129const BASH_TERMINATE_GRACE_SECS: u64 = 5;
130
131pub(crate) const BASH_FILE_LIMIT_BYTES: usize = 1024 * 1024 * 1024; #[derive(Debug, Clone, Serialize)]
136#[serde(rename_all = "camelCase")]
137pub struct TruncationResult {
138 pub content: String,
139 pub truncated: bool,
140 #[serde(skip_serializing_if = "Option::is_none")]
141 pub truncated_by: Option<TruncatedBy>,
142 pub total_lines: usize,
143 pub total_bytes: usize,
144 pub output_lines: usize,
145 pub output_bytes: usize,
146 pub last_line_partial: bool,
147 pub first_line_exceeds_limit: bool,
148 pub max_lines: usize,
149 pub max_bytes: usize,
150}
151
152#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
153#[serde(rename_all = "camelCase")]
154pub enum TruncatedBy {
155 Lines,
156 Bytes,
157}
158
159#[allow(clippy::too_many_lines)]
166pub fn truncate_head(
167 content: impl Into<String>,
168 max_lines: usize,
169 max_bytes: usize,
170) -> TruncationResult {
171 let mut content = content.into();
172 let total_bytes = content.len();
173
174 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 {
186 let truncated = !content.is_empty();
187 content.truncate(0);
188 return TruncationResult {
189 content,
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 max_bytes == 0 {
208 let truncated = !content.is_empty();
209 let first_line_exceeds_limit = !content.is_empty();
210 content.truncate(0);
211 return TruncationResult {
212 content,
213 truncated,
214 truncated_by: if truncated {
215 Some(TruncatedBy::Bytes)
216 } else {
217 None
218 },
219 total_lines,
220 total_bytes,
221 output_lines: 0,
222 output_bytes: 0,
223 last_line_partial: false,
224 first_line_exceeds_limit,
225 max_lines,
226 max_bytes,
227 };
228 }
229
230 if total_lines <= max_lines && total_bytes <= max_bytes {
231 return TruncationResult {
232 content,
233 truncated: false,
234 truncated_by: None,
235 total_lines,
236 total_bytes,
237 output_lines: total_lines,
238 output_bytes: total_bytes,
239 last_line_partial: false,
240 first_line_exceeds_limit: false,
241 max_lines,
242 max_bytes,
243 };
244 }
245
246 let first_newline = memchr::memchr(b'\n', content.as_bytes());
247 let first_line_bytes = first_newline.unwrap_or(content.len());
248
249 if first_line_bytes > max_bytes {
250 let mut valid_bytes = max_bytes;
251 while valid_bytes > 0 && !content.is_char_boundary(valid_bytes) {
252 valid_bytes -= 1;
253 }
254 content.truncate(valid_bytes);
255 return TruncationResult {
256 content,
257 truncated: true,
258 truncated_by: Some(TruncatedBy::Bytes),
259 total_lines,
260 total_bytes,
261 output_lines: usize::from(valid_bytes > 0),
262 output_bytes: valid_bytes,
263 last_line_partial: true,
264 first_line_exceeds_limit: true,
265 max_lines,
266 max_bytes,
267 };
268 }
269
270 let mut line_count = 0;
271 let mut byte_count = 0;
272 let mut truncated_by = None;
273 let mut current_offset = 0;
274 let mut last_line_partial = false;
275
276 while current_offset < content.len() {
277 if line_count >= max_lines {
278 truncated_by = Some(TruncatedBy::Lines);
279 break;
280 }
281
282 let next_newline = memchr::memchr(b'\n', &content.as_bytes()[current_offset..]);
283 let line_end_without_nl = next_newline.map_or(content.len(), |idx| current_offset + idx);
284 let line_end_with_nl = next_newline.map_or(content.len(), |idx| current_offset + idx + 1);
285
286 if line_end_without_nl > max_bytes {
287 let mut byte_limit = max_bytes.min(content.len());
288 if byte_limit < current_offset {
289 truncated_by = Some(TruncatedBy::Bytes);
290 break;
291 }
292 while byte_limit > current_offset && !content.is_char_boundary(byte_limit) {
293 byte_limit -= 1;
294 }
295 if byte_limit > current_offset {
296 byte_count = byte_limit;
297 line_count += 1;
298 last_line_partial = true;
299 }
300 truncated_by = Some(TruncatedBy::Bytes);
301 break;
302 }
303
304 if line_end_with_nl > max_bytes {
305 if line_end_without_nl > current_offset {
306 byte_count = line_end_without_nl;
307 line_count += 1;
308 }
309 truncated_by = Some(TruncatedBy::Bytes);
310 break;
311 }
312
313 byte_count = line_end_with_nl;
314 line_count += 1;
315 current_offset = line_end_with_nl;
316 }
317
318 content.truncate(byte_count);
319
320 TruncationResult {
321 truncated: truncated_by.is_some(),
322 truncated_by,
323 total_lines,
324 total_bytes,
325 output_lines: line_count,
326 output_bytes: byte_count,
327 last_line_partial,
328 first_line_exceeds_limit: false,
329 max_lines,
330 max_bytes,
331 content,
332 }
333}
334
335#[allow(clippy::too_many_lines)]
341pub fn truncate_tail(
342 content: impl Into<String>,
343 max_lines: usize,
344 max_bytes: usize,
345) -> TruncationResult {
346 let mut content = content.into();
347 let total_bytes = content.len();
348
349 let mut total_lines = memchr::memchr_iter(b'\n', content.as_bytes()).count();
352 if !content.ends_with('\n') && !content.is_empty() {
353 total_lines += 1;
354 }
355 if content.is_empty() {
356 total_lines = 0;
357 }
358
359 if max_lines == 0 {
362 let truncated = !content.is_empty();
363 return TruncationResult {
364 content: String::new(),
365 truncated,
366 truncated_by: if truncated {
367 Some(TruncatedBy::Lines)
368 } else {
369 None
370 },
371 total_lines,
372 total_bytes,
373 output_lines: 0,
374 output_bytes: 0,
375 last_line_partial: false,
376 first_line_exceeds_limit: false,
377 max_lines,
378 max_bytes,
379 };
380 }
381
382 if total_lines <= max_lines && total_bytes <= max_bytes {
384 return TruncationResult {
385 content,
386 truncated: false,
387 truncated_by: None,
388 total_lines,
389 total_bytes,
390 output_lines: total_lines,
391 output_bytes: total_bytes,
392 last_line_partial: false,
393 first_line_exceeds_limit: false,
394 max_lines,
395 max_bytes,
396 };
397 }
398
399 let mut line_count = 0usize;
400 let mut byte_count = 0usize;
401 let mut start_idx = content.len();
402 let mut partial_output: Option<String> = None;
403 let mut partial_line_truncated = false;
404 let mut truncated_by = None;
405 let mut last_line_partial = false;
406
407 {
409 let bytes = content.as_bytes();
410 let mut search_limit = bytes.len();
414 if search_limit > 0 && bytes[search_limit - 1] == b'\n' {
415 search_limit -= 1;
416 }
417
418 loop {
419 let prev_newline = memchr::memrchr(b'\n', &bytes[..search_limit]);
421 let line_start = prev_newline.map_or(0, |idx| idx + 1);
422
423 let added_bytes = start_idx - line_start;
427
428 if byte_count + added_bytes > max_bytes {
429 let remaining = max_bytes.saturating_sub(byte_count);
432 if remaining > 0 {
433 let chunk = &content[line_start..start_idx];
434 let truncated_chunk = truncate_string_to_bytes_from_end(chunk, remaining);
435 if !truncated_chunk.is_empty() {
436 partial_output = Some(truncated_chunk);
437 partial_line_truncated = true;
438 if line_count == 0 {
439 last_line_partial = true;
440 }
441 }
442 }
443 truncated_by = Some(TruncatedBy::Bytes);
444 break;
445 }
446
447 line_count += 1;
448 byte_count += added_bytes;
449 start_idx = line_start;
450
451 if line_count >= max_lines {
452 truncated_by = Some(TruncatedBy::Lines);
453 break;
454 }
455
456 if line_start == 0 {
457 break;
458 }
459
460 search_limit = line_start - 1;
466 }
467 } let partial_suffix = if partial_line_truncated {
472 Some(content[start_idx..].to_string())
473 } else {
474 None
475 };
476
477 let mut output = partial_output.unwrap_or_else(|| {
478 drop(content.drain(..start_idx));
479 content
480 });
481
482 if let Some(suffix) = partial_suffix {
497 output.push_str(&suffix);
503 let mut count = memchr::memchr_iter(b'\n', output.as_bytes()).count();
506 if !output.ends_with('\n') && !output.is_empty() {
507 count += 1;
508 }
509 if output.is_empty() {
510 count = 0;
511 }
512 line_count = count;
513 }
514
515 let output_bytes = output.len();
516
517 TruncationResult {
518 content: output,
519 truncated: truncated_by.is_some(),
520 truncated_by,
521 total_lines,
522 total_bytes,
523 output_lines: line_count,
524 output_bytes,
525 last_line_partial,
526 first_line_exceeds_limit: false,
527 max_lines,
528 max_bytes,
529 }
530}
531
532fn truncate_string_to_bytes_from_end(s: &str, max_bytes: usize) -> String {
534 let bytes = s.as_bytes();
535 if bytes.len() <= max_bytes {
536 return s.to_string();
537 }
538
539 let mut start = bytes.len().saturating_sub(max_bytes);
540 while start < bytes.len() && (bytes[start] & 0b1100_0000) == 0b1000_0000 {
541 start += 1;
542 }
543
544 std::str::from_utf8(&bytes[start..])
545 .map(str::to_string)
546 .unwrap_or_default()
547}
548
549#[allow(clippy::cast_precision_loss)]
551fn format_size(bytes: usize) -> String {
552 const KB: usize = 1024;
553 const MB: usize = 1024 * 1024;
554
555 if bytes >= MB {
556 format!("{:.1}MB", bytes as f64 / MB as f64)
557 } else if bytes >= KB {
558 format!("{:.1}KB", bytes as f64 / KB as f64)
559 } else {
560 format!("{bytes}B")
561 }
562}
563
564fn js_string_length(s: &str) -> usize {
565 s.encode_utf16().count()
567}
568
569fn is_special_unicode_space(c: char) -> bool {
574 matches!(c, '\u{00A0}' | '\u{202F}' | '\u{205F}' | '\u{3000}')
575 || ('\u{2000}'..='\u{200A}').contains(&c)
576}
577
578fn normalize_unicode_spaces(s: &str) -> String {
579 s.chars()
580 .map(|c| if is_special_unicode_space(c) { ' ' } else { c })
581 .collect()
582}
583
584fn normalize_quotes(s: &str) -> String {
585 s.replace(['\u{2018}', '\u{2019}'], "'")
586 .replace(['\u{201C}', '\u{201D}', '\u{201E}', '\u{201F}'], "\"")
587}
588
589fn normalize_dashes(s: &str) -> String {
590 s.replace(
591 [
592 '\u{2010}', '\u{2011}', '\u{2012}', '\u{2013}', '\u{2014}', '\u{2015}', '\u{2212}',
593 ],
594 "-",
595 )
596}
597
598fn normalize_for_match(s: &str) -> String {
599 let mut out = String::with_capacity(s.len());
602 for c in s.chars() {
603 match c {
604 c if is_special_unicode_space(c) => out.push(' '),
606 '\u{2018}' | '\u{2019}' => out.push('\''),
608 '\u{201C}' | '\u{201D}' | '\u{201E}' | '\u{201F}' => out.push('"'),
610 '\u{2010}' | '\u{2011}' | '\u{2012}' | '\u{2013}' | '\u{2014}' | '\u{2015}'
612 | '\u{2212}' => out.push('-'),
613 c => out.push(c),
615 }
616 }
617 out
618}
619
620fn normalize_line_for_match(line: &str) -> String {
621 normalize_for_match(line.trim_end())
622}
623
624fn expand_path(file_path: &str) -> String {
625 let normalized = normalize_unicode_spaces(file_path);
626 if normalized == "~" {
627 return dirs::home_dir()
628 .unwrap_or_else(|| PathBuf::from("~"))
629 .to_string_lossy()
630 .to_string();
631 }
632 if let Some(rest) = normalized.strip_prefix("~/") {
633 let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("~"));
634 return home.join(rest).to_string_lossy().to_string();
635 }
636 normalized
637}
638
639fn resolve_to_cwd(file_path: &str, cwd: &Path) -> PathBuf {
641 let expanded = expand_path(file_path);
642 let expanded_path = PathBuf::from(expanded);
643 if expanded_path.is_absolute() {
644 expanded_path
645 } else {
646 cwd.join(expanded_path)
647 }
648}
649
650fn try_mac_os_screenshot_path(file_path: &str) -> String {
651 file_path
653 .replace(" AM.", "\u{202F}AM.")
654 .replace(" PM.", "\u{202F}PM.")
655}
656
657fn try_curly_quote_variant(file_path: &str) -> String {
658 file_path.replace('\'', "\u{2019}")
660}
661
662fn try_nfd_variant(file_path: &str) -> String {
663 use unicode_normalization::UnicodeNormalization;
666 file_path.nfd().collect::<String>()
667}
668
669fn file_exists(path: &Path) -> bool {
670 std::fs::metadata(path).is_ok()
671}
672
673pub(crate) fn resolve_read_path(file_path: &str, cwd: &Path) -> PathBuf {
675 let resolved = normalize_dot_segments(&resolve_to_cwd(file_path, cwd));
676 let normalized_cwd = normalize_dot_segments(cwd);
677 let within_cwd = resolved.starts_with(&normalized_cwd);
678 if within_cwd && file_exists(&resolved) {
679 return resolved;
680 }
681 if !within_cwd {
682 return resolved;
684 }
685
686 let Some(resolved_str) = resolved.to_str() else {
687 return resolved;
688 };
689
690 let am_pm_variant = try_mac_os_screenshot_path(resolved_str);
691 if am_pm_variant != resolved_str {
692 let candidate = PathBuf::from(&am_pm_variant);
693 if candidate.starts_with(&normalized_cwd) && file_exists(&candidate) {
694 return candidate;
695 }
696 }
697
698 let nfd_variant = try_nfd_variant(resolved_str);
699 if nfd_variant != resolved_str {
700 let candidate = PathBuf::from(&nfd_variant);
701 if candidate.starts_with(&normalized_cwd) && file_exists(&candidate) {
702 return candidate;
703 }
704 }
705
706 let curly_variant = try_curly_quote_variant(resolved_str);
707 if curly_variant != resolved_str {
708 let candidate = PathBuf::from(&curly_variant);
709 if candidate.starts_with(&normalized_cwd) && file_exists(&candidate) {
710 return candidate;
711 }
712 }
713
714 let nfd_curly_variant = try_curly_quote_variant(&nfd_variant);
715 if nfd_curly_variant != resolved_str {
716 let candidate = PathBuf::from(&nfd_curly_variant);
717 if candidate.starts_with(&normalized_cwd) && file_exists(&candidate) {
718 return candidate;
719 }
720 }
721
722 resolved
723}
724
725fn enforce_cwd_scope(path: &Path, cwd: &Path, action: &str) -> Result<PathBuf> {
726 let canonical_path = crate::extensions::safe_canonicalize(path);
727 let canonical_cwd = crate::extensions::safe_canonicalize(cwd);
728 if !canonical_path.starts_with(&canonical_cwd) {
729 return Err(Error::validation(format!(
730 "Cannot {action} outside the working directory (resolved: {}, cwd: {})",
731 canonical_path.display(),
732 canonical_cwd.display()
733 )));
734 }
735 Ok(canonical_path)
736}
737
738#[derive(Debug, Clone, Default)]
744pub struct ProcessedFiles {
745 pub text: String,
746 pub images: Vec<ImageContent>,
747}
748
749fn normalize_dot_segments(path: &Path) -> PathBuf {
750 use std::ffi::{OsStr, OsString};
751 use std::path::Component;
752
753 let mut out = PathBuf::new();
754 let mut normals: Vec<OsString> = Vec::new();
755 let mut has_prefix = false;
756 let mut has_root = false;
757
758 for component in path.components() {
759 match component {
760 Component::Prefix(prefix) => {
761 out.push(prefix.as_os_str());
762 has_prefix = true;
763 }
764 Component::RootDir => {
765 out.push(component.as_os_str());
766 has_root = true;
767 }
768 Component::CurDir => {}
769 Component::ParentDir => match normals.last() {
770 Some(last) if last.as_os_str() != OsStr::new("..") => {
771 normals.pop();
772 }
773 _ => {
774 if !has_root && !has_prefix {
775 normals.push(OsString::from(".."));
776 }
777 }
778 },
779 Component::Normal(part) => normals.push(part.to_os_string()),
780 }
781 }
782
783 for part in normals {
784 out.push(part);
785 }
786
787 out
788}
789
790#[cfg(feature = "fuzzing")]
791pub fn fuzz_normalize_dot_segments(path: &Path) -> PathBuf {
792 normalize_dot_segments(path)
793}
794
795#[cfg(unix)]
796fn sync_parent_dir(path: &Path) -> std::io::Result<()> {
797 let Some(parent) = path.parent() else {
798 return Ok(());
799 };
800 std::fs::File::open(parent)?.sync_all()
801}
802
803#[cfg(not(unix))]
804fn sync_parent_dir(_path: &Path) -> std::io::Result<()> {
805 Ok(())
806}
807
808fn escape_file_tag_attribute(value: &str) -> String {
809 let mut escaped = String::with_capacity(value.len());
810 for ch in value.chars() {
811 match ch {
812 '&' => escaped.push_str("&"),
813 '"' => escaped.push_str("""),
814 '<' => escaped.push_str("<"),
815 '>' => escaped.push_str(">"),
816 '\n' => escaped.push_str(" "),
817 '\r' => escaped.push_str(" "),
818 '\t' => escaped.push_str("	"),
819 _ => escaped.push(ch),
820 }
821 }
822 escaped
823}
824
825fn escaped_file_tag_name(path: &Path) -> String {
826 escape_file_tag_attribute(&path.display().to_string())
827}
828
829fn append_file_notice_block(out: &mut String, path: &Path, notice: &str) {
830 let path_str = escaped_file_tag_name(path);
831 let _ = writeln!(out, "<file name=\"{path_str}\">\n{notice}\n</file>");
832}
833
834fn append_image_file_ref(out: &mut String, path: &Path, note: Option<&str>) {
835 let path_str = escaped_file_tag_name(path);
836 match note {
837 Some(text) => {
838 let _ = writeln!(out, "<file name=\"{path_str}\">{text}</file>");
839 }
840 None => {
841 let _ = writeln!(out, "<file name=\"{path_str}\"></file>");
842 }
843 }
844}
845
846fn append_text_file_block(out: &mut String, path: &Path, bytes: &[u8]) {
847 let content = String::from_utf8_lossy(bytes);
848 let path_str = escaped_file_tag_name(path);
849 let _ = writeln!(out, "<file name=\"{path_str}\">");
850
851 let truncation = truncate_head(content.into_owned(), DEFAULT_MAX_LINES, DEFAULT_MAX_BYTES);
852 let needs_trailing_newline = !truncation.truncated && !truncation.content.ends_with('\n');
853 out.push_str(&truncation.content);
854
855 if truncation.truncated {
856 let _ = write!(
857 out,
858 "\n... [Truncated: showing {}/{} lines, {}/{} bytes]",
859 truncation.output_lines,
860 truncation.total_lines,
861 format_size(truncation.output_bytes),
862 format_size(truncation.total_bytes)
863 );
864 } else if needs_trailing_newline {
865 out.push('\n');
866 }
867 let _ = writeln!(out, "</file>");
868}
869
870fn maybe_append_image_argument(
871 out: &mut ProcessedFiles,
872 absolute_path: &Path,
873 bytes: &[u8],
874 auto_resize_images: bool,
875) -> Result<bool> {
876 let Some(mime_type) = detect_supported_image_mime_type_from_bytes(bytes) else {
877 return Ok(false);
878 };
879
880 let resized = if auto_resize_images {
881 resize_image_if_needed(bytes, mime_type)?
882 } else {
883 ResizedImage::original(bytes.to_vec(), mime_type)
884 };
885
886 if resized.bytes.len() > IMAGE_MAX_BYTES {
887 let msg = if resized.resized {
888 format!(
889 "[Image is too large ({} bytes) after resizing. Max allowed is {} bytes.]",
890 resized.bytes.len(),
891 IMAGE_MAX_BYTES
892 )
893 } else {
894 format!(
895 "[Image is too large ({} bytes). Max allowed is {} bytes.]",
896 resized.bytes.len(),
897 IMAGE_MAX_BYTES
898 )
899 };
900 append_file_notice_block(&mut out.text, absolute_path, &msg);
901 return Ok(true);
902 }
903
904 let base64_data =
905 base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &resized.bytes);
906 out.images.push(ImageContent {
907 data: base64_data,
908 mime_type: resized.mime_type.to_string(),
909 });
910
911 let note = if resized.resized {
912 if let (Some(ow), Some(oh), Some(w), Some(h)) = (
913 resized.original_width,
914 resized.original_height,
915 resized.width,
916 resized.height,
917 ) {
918 if w > 0 {
919 let scale = f64::from(ow) / f64::from(w);
920 Some(format!(
921 "[Image: original {ow}x{oh}, displayed at {w}x{h}. Multiply coordinates by {scale:.2} to map to original image.]"
922 ))
923 } else {
924 Some(format!(
925 "[Image: original {ow}x{oh}, displayed at {w}x{h}.]"
926 ))
927 }
928 } else {
929 None
930 }
931 } else {
932 None
933 };
934 append_image_file_ref(&mut out.text, absolute_path, note.as_deref());
935 Ok(true)
936}
937
938pub fn process_file_arguments(
946 file_args: &[String],
947 cwd: &Path,
948 auto_resize_images: bool,
949) -> Result<ProcessedFiles> {
950 let mut out = ProcessedFiles::default();
951
952 for file_arg in file_args {
953 let resolved = resolve_read_path(file_arg, cwd);
954 let absolute_path = normalize_dot_segments(&resolved);
955 let absolute_path = enforce_cwd_scope(&absolute_path, cwd, "read")?;
956
957 let meta = std::fs::metadata(&absolute_path).map_err(|e| {
958 Error::tool(
959 "read",
960 format!("Cannot access file {}: {e}", absolute_path.display()),
961 )
962 })?;
963 if meta.is_dir() {
964 append_file_notice_block(
965 &mut out.text,
966 &absolute_path,
967 "[Path is a directory, not a file. Use the list tool to view its contents.]",
968 );
969 continue;
970 }
971
972 if meta.len() == 0 {
973 continue;
974 }
975
976 if meta.len() > READ_TOOL_MAX_BYTES {
977 append_file_notice_block(
978 &mut out.text,
979 &absolute_path,
980 &format!(
981 "[File is too large ({} bytes). Max allowed is {} bytes.]",
982 meta.len(),
983 READ_TOOL_MAX_BYTES
984 ),
985 );
986 continue;
987 }
988
989 let bytes = std::fs::read(&absolute_path).map_err(|e| {
990 Error::tool(
991 "read",
992 format!("Could not read file {}: {e}", absolute_path.display()),
993 )
994 })?;
995
996 if maybe_append_image_argument(&mut out, &absolute_path, &bytes, auto_resize_images)? {
997 continue;
998 }
999
1000 append_text_file_block(&mut out.text, &absolute_path, &bytes);
1001 }
1002
1003 Ok(out)
1004}
1005
1006fn resolve_path(file_path: &str, cwd: &Path) -> PathBuf {
1009 normalize_dot_segments(&resolve_to_cwd(file_path, cwd))
1010}
1011
1012#[cfg(feature = "fuzzing")]
1013pub fn fuzz_resolve_path(file_path: &str, cwd: &Path) -> PathBuf {
1014 resolve_path(file_path, cwd)
1015}
1016
1017pub(crate) fn detect_supported_image_mime_type_from_bytes(bytes: &[u8]) -> Option<&'static str> {
1018 if bytes.len() >= 8 && bytes.starts_with(b"\x89PNG\r\n\x1A\n") {
1020 return Some("image/png");
1021 }
1022 if bytes.len() >= 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF {
1023 return Some("image/jpeg");
1024 }
1025 if bytes.len() >= 6 && (bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a")) {
1026 return Some("image/gif");
1027 }
1028 if bytes.len() >= 12 && bytes.starts_with(b"RIFF") && &bytes[8..12] == b"WEBP" {
1029 return Some("image/webp");
1030 }
1031 None
1032}
1033
1034#[derive(Debug, Clone)]
1035pub(crate) struct ResizedImage {
1036 pub(crate) bytes: Vec<u8>,
1037 pub(crate) mime_type: &'static str,
1038 pub(crate) resized: bool,
1039 pub(crate) width: Option<u32>,
1040 pub(crate) height: Option<u32>,
1041 pub(crate) original_width: Option<u32>,
1042 pub(crate) original_height: Option<u32>,
1043}
1044
1045impl ResizedImage {
1046 pub(crate) const fn original(bytes: Vec<u8>, mime_type: &'static str) -> Self {
1047 Self {
1048 bytes,
1049 mime_type,
1050 resized: false,
1051 width: None,
1052 height: None,
1053 original_width: None,
1054 original_height: None,
1055 }
1056 }
1057}
1058
1059#[cfg(feature = "image-resize")]
1060#[allow(clippy::too_many_lines)]
1061pub(crate) fn resize_image_if_needed(
1062 bytes: &[u8],
1063 mime_type: &'static str,
1064) -> Result<ResizedImage> {
1065 use image::codecs::jpeg::JpegEncoder;
1076 use image::codecs::png::PngEncoder;
1077 use image::imageops::FilterType;
1078 use image::{GenericImageView, ImageEncoder, ImageReader, Limits};
1079 use std::io::Cursor;
1080
1081 const MAX_WIDTH: u32 = 2000;
1082 const MAX_HEIGHT: u32 = 2000;
1083 const DEFAULT_JPEG_QUALITY: u8 = 80;
1084 const QUALITY_STEPS: [u8; 4] = [85, 70, 55, 40];
1085 const SCALE_STEPS: [f64; 5] = [1.0, 0.75, 0.5, 0.35, 0.25];
1086
1087 fn scale_u32(value: u32, numerator: u32, denominator: u32) -> u32 {
1088 let den = u64::from(denominator).max(1);
1089 let num = u64::from(value) * u64::from(numerator);
1090 let rounded = (num + den / 2) / den;
1091 u32::try_from(rounded).unwrap_or(u32::MAX)
1092 }
1093
1094 fn encode_png(img: &image::DynamicImage) -> Result<Vec<u8>> {
1095 let rgba = img.to_rgba8();
1096 let mut out = Vec::new();
1097 PngEncoder::new(&mut out)
1098 .write_image(
1099 rgba.as_raw(),
1100 rgba.width(),
1101 rgba.height(),
1102 image::ExtendedColorType::Rgba8,
1103 )
1104 .map_err(|e| Error::tool("read", format!("Failed to encode PNG: {e}")))?;
1105 Ok(out)
1106 }
1107
1108 fn encode_jpeg(img: &image::DynamicImage, quality: u8) -> Result<Vec<u8>> {
1109 let rgb = img.to_rgb8();
1110 let mut out = Vec::new();
1111 JpegEncoder::new_with_quality(&mut out, quality)
1112 .write_image(
1113 rgb.as_raw(),
1114 rgb.width(),
1115 rgb.height(),
1116 image::ExtendedColorType::Rgb8,
1117 )
1118 .map_err(|e| Error::tool("read", format!("Failed to encode JPEG: {e}")))?;
1119 Ok(out)
1120 }
1121
1122 fn try_both_formats(
1123 img: &image::DynamicImage,
1124 width: u32,
1125 height: u32,
1126 jpeg_quality: u8,
1127 ) -> Result<(Vec<u8>, &'static str)> {
1128 let resized = img.resize_exact(width, height, FilterType::Lanczos3);
1129 let png = encode_png(&resized)?;
1130 let jpeg = encode_jpeg(&resized, jpeg_quality)?;
1131 if png.len() <= jpeg.len() {
1132 Ok((png, "image/png"))
1133 } else {
1134 Ok((jpeg, "image/jpeg"))
1135 }
1136 }
1137
1138 let mut limits = Limits::default();
1141 limits.max_alloc = Some(128 * 1024 * 1024);
1142
1143 let reader = ImageReader::new(Cursor::new(bytes))
1144 .with_guessed_format()
1145 .map_err(|e| Error::tool("read", format!("Failed to detect image format: {e}")))?;
1146
1147 let mut reader = reader;
1148 reader.limits(limits);
1149
1150 let Ok(img) = reader.decode() else {
1151 return Ok(ResizedImage::original(bytes.to_vec(), mime_type));
1152 };
1153
1154 let (original_width, original_height) = img.dimensions();
1155 let original_size = bytes.len();
1156
1157 if original_width <= MAX_WIDTH
1158 && original_height <= MAX_HEIGHT
1159 && original_size <= IMAGE_MAX_BYTES
1160 {
1161 return Ok(ResizedImage {
1162 bytes: bytes.to_vec(),
1163 mime_type,
1164 resized: false,
1165 width: Some(original_width),
1166 height: Some(original_height),
1167 original_width: Some(original_width),
1168 original_height: Some(original_height),
1169 });
1170 }
1171
1172 let mut target_width = original_width;
1173 let mut target_height = original_height;
1174
1175 if target_width > MAX_WIDTH {
1176 target_height = scale_u32(target_height, MAX_WIDTH, target_width);
1177 target_width = MAX_WIDTH;
1178 }
1179 if target_height > MAX_HEIGHT {
1180 target_width = scale_u32(target_width, MAX_HEIGHT, target_height);
1181 target_height = MAX_HEIGHT;
1182 }
1183
1184 let mut best = try_both_formats(&img, target_width, target_height, DEFAULT_JPEG_QUALITY)?;
1185 let mut final_width = target_width;
1186 let mut final_height = target_height;
1187
1188 if best.0.len() <= IMAGE_MAX_BYTES {
1189 return Ok(ResizedImage {
1190 bytes: best.0,
1191 mime_type: best.1,
1192 resized: true,
1193 width: Some(final_width),
1194 height: Some(final_height),
1195 original_width: Some(original_width),
1196 original_height: Some(original_height),
1197 });
1198 }
1199
1200 for quality in QUALITY_STEPS {
1201 best = try_both_formats(&img, target_width, target_height, quality)?;
1202 if best.0.len() <= IMAGE_MAX_BYTES {
1203 return Ok(ResizedImage {
1204 bytes: best.0,
1205 mime_type: best.1,
1206 resized: true,
1207 width: Some(final_width),
1208 height: Some(final_height),
1209 original_width: Some(original_width),
1210 original_height: Some(original_height),
1211 });
1212 }
1213 }
1214
1215 for scale in SCALE_STEPS {
1216 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
1217 {
1218 final_width = (f64::from(target_width) * scale).round() as u32;
1219 final_height = (f64::from(target_height) * scale).round() as u32;
1220 }
1221
1222 if final_width < 100 || final_height < 100 {
1223 break;
1224 }
1225
1226 for quality in QUALITY_STEPS {
1227 best = try_both_formats(&img, final_width, final_height, quality)?;
1228 if best.0.len() <= IMAGE_MAX_BYTES {
1229 return Ok(ResizedImage {
1230 bytes: best.0,
1231 mime_type: best.1,
1232 resized: true,
1233 width: Some(final_width),
1234 height: Some(final_height),
1235 original_width: Some(original_width),
1236 original_height: Some(original_height),
1237 });
1238 }
1239 }
1240 }
1241
1242 Ok(ResizedImage {
1243 bytes: best.0,
1244 mime_type: best.1,
1245 resized: true,
1246 width: Some(final_width),
1247 height: Some(final_height),
1248 original_width: Some(original_width),
1249 original_height: Some(original_height),
1250 })
1251}
1252
1253#[cfg(not(feature = "image-resize"))]
1254#[expect(
1255 clippy::unnecessary_wraps,
1256 reason = "The no-feature stub preserves the feature-enabled Result API at shared call sites."
1257)]
1258pub(crate) fn resize_image_if_needed(
1259 bytes: &[u8],
1260 mime_type: &'static str,
1261) -> Result<ResizedImage> {
1262 Ok(ResizedImage::original(bytes.to_vec(), mime_type))
1263}
1264
1265pub struct ToolRegistry {
1275 tools: Vec<Box<dyn Tool>>,
1276}
1277
1278impl ToolRegistry {
1279 pub fn new(enabled: &[&str], cwd: &Path, config: Option<&Config>) -> Self {
1281 let mut tools: Vec<Box<dyn Tool>> = Vec::new();
1282 let shell_path = config.and_then(|c| c.shell_path.clone());
1283 let shell_command_prefix = config.and_then(|c| c.shell_command_prefix.clone());
1284 let image_auto_resize = config.is_none_or(Config::image_auto_resize);
1285 let block_images = config
1286 .and_then(|c| c.images.as_ref().and_then(|i| i.block_images))
1287 .unwrap_or(false);
1288
1289 for name in enabled {
1290 match *name {
1291 "read" => tools.push(Box::new(ReadTool::with_settings(
1292 cwd,
1293 image_auto_resize,
1294 block_images,
1295 ))),
1296 "bash" => tools.push(Box::new(BashTool::with_shell(
1297 cwd,
1298 shell_path.clone(),
1299 shell_command_prefix.clone(),
1300 ))),
1301 "edit" => tools.push(Box::new(EditTool::new(cwd))),
1302 "write" => tools.push(Box::new(WriteTool::new(cwd))),
1303 "grep" => tools.push(Box::new(GrepTool::new(cwd))),
1304 "find" => tools.push(Box::new(FindTool::new(cwd))),
1305 "ls" => tools.push(Box::new(LsTool::new(cwd))),
1306 "hashline_edit" => tools.push(Box::new(HashlineEditTool::new(cwd))),
1307 _ => {}
1308 }
1309 }
1310
1311 Self { tools }
1312 }
1313
1314 pub fn from_tools(tools: Vec<Box<dyn Tool>>) -> Self {
1316 Self { tools }
1317 }
1318
1319 pub fn into_tools(self) -> Vec<Box<dyn Tool>> {
1321 self.tools
1322 }
1323
1324 pub fn push(&mut self, tool: Box<dyn Tool>) {
1326 self.tools.push(tool);
1327 }
1328
1329 pub fn extend<I>(&mut self, tools: I)
1331 where
1332 I: IntoIterator<Item = Box<dyn Tool>>,
1333 {
1334 self.tools.extend(tools);
1335 }
1336
1337 pub fn tools(&self) -> &[Box<dyn Tool>] {
1339 &self.tools
1340 }
1341
1342 pub fn get(&self, name: &str) -> Option<&dyn Tool> {
1344 self.tools
1345 .iter()
1346 .find(|t| t.name() == name)
1347 .map(std::convert::AsRef::as_ref)
1348 }
1349}
1350
1351#[derive(Debug, Deserialize)]
1357#[serde(rename_all = "camelCase")]
1358struct ReadInput {
1359 path: String,
1360 offset: Option<i64>,
1361 limit: Option<i64>,
1362 #[serde(default)]
1363 hashline: bool,
1364}
1365
1366pub struct ReadTool {
1367 cwd: PathBuf,
1368 auto_resize: bool,
1370 block_images: bool,
1371}
1372
1373impl ReadTool {
1374 pub fn new(cwd: &Path) -> Self {
1375 Self {
1376 cwd: cwd.to_path_buf(),
1377 auto_resize: true,
1378 block_images: false,
1379 }
1380 }
1381
1382 pub fn with_settings(cwd: &Path, auto_resize: bool, block_images: bool) -> Self {
1383 Self {
1384 cwd: cwd.to_path_buf(),
1385 auto_resize,
1386 block_images,
1387 }
1388 }
1389}
1390
1391async fn read_some<R>(reader: &mut R, dst: &mut [u8]) -> std::io::Result<usize>
1392where
1393 R: AsyncRead + Unpin,
1394{
1395 if dst.is_empty() {
1396 return Ok(0);
1397 }
1398
1399 futures::future::poll_fn(|cx| {
1400 let mut read_buf = ReadBuf::new(dst);
1401 match std::pin::Pin::new(&mut *reader).poll_read(cx, &mut read_buf) {
1402 std::task::Poll::Ready(Ok(())) => std::task::Poll::Ready(Ok(read_buf.filled().len())),
1403 std::task::Poll::Ready(Err(err)) => std::task::Poll::Ready(Err(err)),
1404 std::task::Poll::Pending => std::task::Poll::Pending,
1405 }
1406 })
1407 .await
1408}
1409
1410#[async_trait]
1411#[allow(clippy::unnecessary_literal_bound)]
1412impl Tool for ReadTool {
1413 fn name(&self) -> &str {
1414 "read"
1415 }
1416 fn label(&self) -> &str {
1417 "read"
1418 }
1419 fn description(&self) -> &str {
1420 "Read the contents of a file. Supports text files and images (jpg, png, gif, webp). Images are sent as attachments. For text files, output is truncated to 2000 lines or 1MB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete."
1421 }
1422
1423 fn parameters(&self) -> serde_json::Value {
1424 serde_json::json!({
1425 "type": "object",
1426 "properties": {
1427 "path": {
1428 "type": "string",
1429 "description": "Path to the file to read (relative or absolute)"
1430 },
1431 "offset": {
1432 "type": "integer",
1433 "description": "Line number to start reading from (1-indexed)"
1434 },
1435 "limit": {
1436 "type": "integer",
1437 "description": "Maximum number of lines to read"
1438 },
1439 "hashline": {
1440 "type": "boolean",
1441 "description": "When true, output each line as N#AB:content where N is the line number and AB is a content hash. Use with hashline_edit tool for precise edits."
1442 }
1443 },
1444 "required": ["path"]
1445 })
1446 }
1447
1448 fn is_read_only(&self) -> bool {
1449 true
1450 }
1451
1452 #[allow(clippy::too_many_lines)]
1453 async fn execute(
1454 &self,
1455 _tool_call_id: &str,
1456 input: serde_json::Value,
1457 _on_update: Option<Box<dyn Fn(ToolUpdate) + Send + Sync>>,
1458 ) -> Result<ToolOutput> {
1459 let input: ReadInput =
1460 serde_json::from_value(input).map_err(|e| Error::validation(e.to_string()))?;
1461
1462 if matches!(input.limit, Some(limit) if limit <= 0) {
1463 return Err(Error::validation(
1464 "`limit` must be greater than 0".to_string(),
1465 ));
1466 }
1467 if matches!(input.offset, Some(offset) if offset < 0) {
1468 return Err(Error::validation(
1469 "`offset` must be non-negative".to_string(),
1470 ));
1471 }
1472
1473 let path = resolve_read_path(&input.path, &self.cwd);
1474 let path = enforce_cwd_scope(&path, &self.cwd, "read")?;
1475
1476 let meta = asupersync::fs::metadata(&path).await.ok();
1477 if let Some(meta) = &meta {
1478 if !meta.is_file() {
1479 return Err(Error::tool(
1480 "read",
1481 format!("Path {} is not a regular file", path.display()),
1482 ));
1483 }
1484 }
1485
1486 let mut file = asupersync::fs::File::open(&path)
1487 .await
1488 .map_err(|e| Error::tool("read", e.to_string()))?;
1489
1490 let mut buffer = [0u8; 8192];
1492 let mut initial_read = 0;
1493 loop {
1494 let n = read_some(&mut file, &mut buffer[initial_read..])
1495 .await
1496 .map_err(|e| Error::tool("read", format!("Failed to read file: {e}")))?;
1497 if n == 0 {
1498 break;
1499 }
1500 initial_read += n;
1501 if initial_read == buffer.len() {
1502 break;
1503 }
1504 }
1505 let initial_bytes = &buffer[..initial_read];
1506
1507 if let Some(mime_type) = detect_supported_image_mime_type_from_bytes(initial_bytes) {
1508 if self.block_images {
1509 return Err(Error::tool(
1510 "read",
1511 "Images are blocked by configuration".to_string(),
1512 ));
1513 }
1514
1515 let max_image_input_bytes = usize::try_from(READ_TOOL_MAX_BYTES).unwrap_or(usize::MAX);
1519 if let Some(meta) = &meta {
1520 if meta.len() > READ_TOOL_MAX_BYTES {
1521 return Err(Error::tool(
1522 "read",
1523 format!(
1524 "Image is too large ({} bytes). Max allowed is {} bytes.",
1525 meta.len(),
1526 READ_TOOL_MAX_BYTES
1527 ),
1528 ));
1529 }
1530 }
1531 let mut all_bytes = Vec::with_capacity(initial_read);
1532 all_bytes.extend_from_slice(initial_bytes);
1533
1534 let remaining_limit = max_image_input_bytes.saturating_sub(initial_read);
1535 let mut limiter = file.take((remaining_limit as u64).saturating_add(1));
1536 limiter
1537 .read_to_end(&mut all_bytes)
1538 .await
1539 .map_err(|e| Error::tool("read", format!("Failed to read image: {e}")))?;
1540
1541 if all_bytes.len() > max_image_input_bytes {
1542 return Err(Error::tool(
1543 "read",
1544 format!(
1545 "Image is too large ({} bytes). Max allowed is {} bytes.",
1546 all_bytes.len(),
1547 READ_TOOL_MAX_BYTES
1548 ),
1549 ));
1550 }
1551
1552 let resized = if self.auto_resize {
1553 resize_image_if_needed(&all_bytes, mime_type)?
1554 } else {
1555 ResizedImage::original(all_bytes, mime_type)
1556 };
1557
1558 if resized.bytes.len() > IMAGE_MAX_BYTES {
1559 let message = if resized.resized {
1560 format!(
1561 "Image is too large ({} bytes) after resizing. Max allowed is {} bytes.",
1562 resized.bytes.len(),
1563 IMAGE_MAX_BYTES
1564 )
1565 } else {
1566 format!(
1567 "Image is too large ({} bytes). Max allowed is {} bytes.",
1568 resized.bytes.len(),
1569 IMAGE_MAX_BYTES
1570 )
1571 };
1572 return Err(Error::tool("read", message));
1573 }
1574
1575 let base64_data =
1576 base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &resized.bytes);
1577
1578 let mut note = format!("Read image file [{}]", resized.mime_type);
1579 if resized.resized {
1580 if let (Some(ow), Some(oh), Some(w), Some(h)) = (
1581 resized.original_width,
1582 resized.original_height,
1583 resized.width,
1584 resized.height,
1585 ) {
1586 if w > 0 {
1587 let scale = f64::from(ow) / f64::from(w);
1588 let _ = write!(
1589 note,
1590 "\n[Image: original {ow}x{oh}, displayed at {w}x{h}. Multiply coordinates by {scale:.2} to map to original image.]"
1591 );
1592 } else {
1593 let _ =
1594 write!(note, "\n[Image: original {ow}x{oh}, displayed at {w}x{h}.]");
1595 }
1596 }
1597 }
1598
1599 return Ok(ToolOutput {
1600 content: vec![
1601 ContentBlock::Text(TextContent::new(note)),
1602 ContentBlock::Image(ImageContent {
1603 data: base64_data,
1604 mime_type: resized.mime_type.to_string(),
1605 }),
1606 ],
1607 details: None,
1608 is_error: false,
1609 });
1610 }
1611
1612 if initial_read > 0 {
1619 file.seek(SeekFrom::Start(0))
1620 .await
1621 .map_err(|e| Error::tool("read", format!("Failed to seek: {e}")))?;
1622 }
1623
1624 let mut raw_content = Vec::new();
1625 let mut newlines_seen = 0usize;
1626
1627 let start_line_idx = match input.offset {
1629 Some(n) if n > 0 => n.saturating_sub(1).try_into().unwrap_or(usize::MAX),
1630 _ => 0,
1631 };
1632 let limit_lines = input
1633 .limit
1634 .map_or(usize::MAX, |l| l.try_into().unwrap_or(usize::MAX));
1635 let end_line_idx = start_line_idx.saturating_add(limit_lines);
1636
1637 let mut collecting = start_line_idx == 0;
1638 let mut buf = vec![0u8; 64 * 1024].into_boxed_slice(); let mut last_byte_was_newline = false;
1640 let mut pending_cr = false;
1641
1642 let mut total_bytes_read = 0u64;
1646
1647 loop {
1648 let n = read_some(&mut file, &mut buf)
1649 .await
1650 .map_err(|e| Error::tool("read", e.to_string()))?;
1651 if n == 0 {
1652 break;
1653 }
1654 total_bytes_read = total_bytes_read.saturating_add(n as u64);
1655
1656 let chunk = normalize_line_endings_chunk(&buf[..n], &mut pending_cr);
1657 if chunk.is_empty() {
1658 continue;
1659 }
1660 last_byte_was_newline = chunk.last().is_some_and(|byte| *byte == b'\n');
1661 let mut chunk_cursor = 0;
1662
1663 for pos in memchr::memchr_iter(b'\n', &chunk) {
1664 if collecting {
1666 if newlines_seen + 1 == end_line_idx {
1668 if raw_content.len() < DEFAULT_MAX_BYTES {
1670 let remaining = DEFAULT_MAX_BYTES - raw_content.len();
1671 let slice_len = (pos + 1 - chunk_cursor).min(remaining);
1672 raw_content
1673 .extend_from_slice(&chunk[chunk_cursor..chunk_cursor + slice_len]);
1674 }
1675 collecting = false;
1676 chunk_cursor = pos + 1;
1677 }
1678 }
1679
1680 newlines_seen += 1;
1681
1682 if !collecting && newlines_seen == start_line_idx {
1684 collecting = true;
1685 chunk_cursor = pos + 1;
1686 }
1687 }
1688
1689 if collecting && chunk_cursor < chunk.len() && raw_content.len() < DEFAULT_MAX_BYTES {
1691 let remaining = DEFAULT_MAX_BYTES - raw_content.len();
1692 let slice_len = (chunk.len() - chunk_cursor).min(remaining);
1693 raw_content.extend_from_slice(&chunk[chunk_cursor..chunk_cursor + slice_len]);
1694 }
1695 }
1696
1697 if pending_cr {
1698 last_byte_was_newline = true;
1699 if collecting && raw_content.len() < DEFAULT_MAX_BYTES {
1700 raw_content.push(b'\n');
1701 }
1702 newlines_seen += 1;
1703 }
1704
1705 let total_lines = if total_bytes_read == 0 {
1708 0
1709 } else if last_byte_was_newline {
1710 newlines_seen
1711 } else {
1712 newlines_seen + 1
1713 };
1714 let text_content = String::from_utf8_lossy(&raw_content).into_owned();
1715
1716 if total_lines == 0 {
1719 if input.offset.unwrap_or(0) > 0 {
1720 let offset_display = input.offset.unwrap_or(0);
1721 return Err(Error::tool(
1722 "read",
1723 format!(
1724 "Offset {offset_display} is beyond end of file ({total_lines} lines total)"
1725 ),
1726 ));
1727 }
1728 return Ok(ToolOutput {
1729 content: vec![ContentBlock::Text(TextContent::new(""))],
1730 details: None,
1731 is_error: false,
1732 });
1733 }
1734
1735 let start_line = start_line_idx;
1739 let start_line_display = start_line.saturating_add(1);
1740
1741 if start_line >= total_lines {
1742 let offset_display = input.offset.unwrap_or(0);
1743 return Err(Error::tool(
1744 "read",
1745 format!(
1746 "Offset {offset_display} is beyond end of file ({total_lines} lines total)"
1747 ),
1748 ));
1749 }
1750
1751 let max_lines_for_truncation = input
1752 .limit
1753 .and_then(|l| usize::try_from(l).ok())
1754 .unwrap_or(DEFAULT_MAX_LINES);
1755 let display_limit = max_lines_for_truncation.saturating_add(1);
1756
1757 let lines_to_take = limit_lines.min(display_limit);
1761
1762 let mut selected_content = String::new();
1763 let line_iter = text_content.split('\n');
1764
1765 let effective_iter = if text_content.ends_with('\n') {
1767 line_iter.take(lines_to_take)
1768 } else {
1769 line_iter.take(usize::MAX)
1770 };
1771
1772 let max_line_num = start_line.saturating_add(lines_to_take).min(total_lines);
1773 let line_num_width = max_line_num.to_string().len().max(5);
1774
1775 for (i, line) in effective_iter.enumerate() {
1776 if i >= lines_to_take || start_line + i >= total_lines {
1777 break;
1778 }
1779 if i > 0 {
1780 selected_content.push('\n');
1781 }
1782 let line_idx = start_line + i; let line = line.strip_suffix('\r').unwrap_or(line);
1784 if input.hashline {
1785 let tag = format_hashline_tag(line_idx, line);
1786 let _ = write!(selected_content, "{tag}:{line}");
1787 } else {
1788 let line_num = line_idx + 1;
1789 let _ = write!(selected_content, "{line_num:>line_num_width$}→{line}");
1790 }
1791
1792 if selected_content.len() > DEFAULT_MAX_BYTES * 2 {
1793 break;
1794 }
1795 }
1796
1797 let mut truncation = truncate_head(
1798 selected_content,
1799 max_lines_for_truncation,
1800 DEFAULT_MAX_BYTES,
1801 );
1802 truncation.total_lines = total_lines;
1803
1804 let mut output_text = std::mem::take(&mut truncation.content);
1805 let mut details: Option<serde_json::Value> = None;
1806
1807 if truncation.first_line_exceeds_limit {
1808 let first_line = text_content.split('\n').next().unwrap_or("");
1809 let first_line = first_line.strip_suffix('\r').unwrap_or(first_line);
1810 let first_line_size = format_size(first_line.len());
1811 output_text = format!(
1812 "[Line {start_line_display} is {first_line_size}, exceeds {} limit. Use bash: sed -n '{start_line_display}p' '{}' | head -c {DEFAULT_MAX_BYTES}]",
1813 format_size(DEFAULT_MAX_BYTES),
1814 input.path.replace('\'', "'\\''")
1815 );
1816 details = Some(serde_json::json!({ "truncation": truncation }));
1817 } else if truncation.truncated {
1818 let end_line_display = start_line_display
1819 .saturating_add(truncation.output_lines)
1820 .saturating_sub(1);
1821 let next_offset = end_line_display.saturating_add(1);
1822
1823 if truncation.truncated_by == Some(TruncatedBy::Lines) {
1824 let _ = write!(
1825 output_text,
1826 "\n\n[Showing lines {start_line_display}-{end_line_display} of {total_lines}. Use offset={next_offset} to continue.]"
1827 );
1828 } else {
1829 let _ = write!(
1830 output_text,
1831 "\n\n[Showing lines {start_line_display}-{end_line_display} of {total_lines} ({} limit). Use offset={next_offset} to continue.]",
1832 format_size(DEFAULT_MAX_BYTES)
1833 );
1834 }
1835
1836 details = Some(serde_json::json!({ "truncation": truncation }));
1837 } else {
1838 let displayed_lines = truncation.output_lines;
1840 let end_line_display = start_line_display
1841 .saturating_add(displayed_lines)
1842 .saturating_sub(1);
1843
1844 if end_line_display < total_lines {
1845 let remaining = total_lines.saturating_sub(end_line_display);
1846 let next_offset = end_line_display.saturating_add(1);
1847 let _ = write!(
1848 output_text,
1849 "\n\n[{remaining} more lines in file. Use offset={next_offset} to continue.]"
1850 );
1851 }
1852 }
1853
1854 Ok(ToolOutput {
1855 content: vec![ContentBlock::Text(TextContent::new(output_text))],
1856 details,
1857 is_error: false,
1858 })
1859 }
1860}
1861
1862#[derive(Debug, Deserialize)]
1868#[serde(rename_all = "camelCase")]
1869struct BashInput {
1870 command: String,
1871 timeout: Option<u64>,
1872}
1873
1874pub struct BashTool {
1875 cwd: PathBuf,
1876 shell_path: Option<String>,
1877 command_prefix: Option<String>,
1878}
1879
1880#[derive(Debug, Clone)]
1881pub struct BashRunResult {
1882 pub output: String,
1883 pub exit_code: i32,
1884 pub cancelled: bool,
1885 pub truncated: bool,
1886 pub full_output_path: Option<String>,
1887 pub truncation: Option<TruncationResult>,
1888}
1889
1890#[allow(clippy::unnecessary_lazy_evaluations)] fn exit_status_code(status: std::process::ExitStatus) -> i32 {
1892 status.code().unwrap_or_else(|| {
1893 #[cfg(unix)]
1894 {
1895 use std::os::unix::process::ExitStatusExt as _;
1896 status.signal().map_or(-1, |signal| -signal)
1897 }
1898 #[cfg(not(unix))]
1899 {
1900 -1
1901 }
1902 })
1903}
1904
1905#[allow(clippy::too_many_lines)]
1906pub(crate) async fn run_bash_command(
1907 cwd: &Path,
1908 shell_path: Option<&str>,
1909 command_prefix: Option<&str>,
1910 command: &str,
1911 timeout_secs: Option<u64>,
1912 on_update: Option<&(dyn Fn(ToolUpdate) + Send + Sync)>,
1913) -> Result<BashRunResult> {
1914 let timeout_secs = match timeout_secs {
1915 None => Some(DEFAULT_BASH_TIMEOUT_SECS),
1916 Some(0) => None,
1917 Some(value) => Some(value),
1918 };
1919 let command = command_prefix.filter(|p| !p.trim().is_empty()).map_or_else(
1920 || command.to_string(),
1921 |prefix| format!("{prefix}\n{command}"),
1922 );
1923 let command = format!("trap 'code=$?; wait; exit $code' EXIT\n{command}");
1924
1925 if !cwd.exists() {
1926 return Err(Error::tool(
1927 "bash",
1928 format!(
1929 "Working directory does not exist: {}\nCannot execute bash commands.",
1930 cwd.display()
1931 ),
1932 ));
1933 }
1934
1935 let shell = shell_path.unwrap_or_else(|| {
1936 for path in ["/bin/bash", "/usr/bin/bash", "/usr/local/bin/bash"] {
1937 if Path::new(path).exists() {
1938 return path;
1939 }
1940 }
1941 "sh"
1942 });
1943
1944 let mut cmd = Command::new(shell);
1945 cmd.arg("-c")
1946 .arg(&command)
1947 .current_dir(cwd)
1948 .stdin(Stdio::null())
1949 .stdout(Stdio::piped())
1950 .stderr(Stdio::piped());
1951
1952 isolate_command_process_group(&mut cmd);
1955
1956 let mut child = cmd
1957 .spawn()
1958 .map_err(|e| Error::tool("bash", format!("Failed to spawn shell: {e}")))?;
1959
1960 let stdout = child
1961 .stdout
1962 .take()
1963 .ok_or_else(|| Error::tool("bash", "Missing stdout".to_string()))?;
1964 let stderr = child
1965 .stderr
1966 .take()
1967 .ok_or_else(|| Error::tool("bash", "Missing stderr".to_string()))?;
1968
1969 let mut guard = ProcessGuard::new(child, ProcessCleanupMode::ProcessGroupTree);
1971
1972 let (tx, rx) = mpsc::sync_channel::<Vec<u8>>(1024);
1978 let tx_stdout = tx.clone();
1979
1980 let stdout_thread = thread::spawn(move || pump_stream(stdout, &tx_stdout));
1988 let stderr_thread = thread::spawn(move || pump_stream(stderr, &tx));
1989
1990 let max_chunks_bytes = DEFAULT_MAX_BYTES.saturating_mul(2);
1991 let mut bash_output = BashOutputState::new(max_chunks_bytes);
1992 bash_output.timeout_ms = timeout_secs.map(|s| s.saturating_mul(1000));
1993
1994 let cx = AgentCx::for_current_or_request();
1995 let mut timed_out = false;
1996 let mut cancelled = false;
1997 let mut exit_code: Option<i32> = None;
1998 let start = cx
1999 .cx()
2000 .timer_driver()
2001 .map_or_else(wall_now, |timer| timer.now());
2002 let timeout = timeout_secs.map(Duration::from_secs);
2003 let mut terminate_deadline: Option<asupersync::Time> = None;
2004
2005 let tick = Duration::from_millis(10);
2006 loop {
2007 let mut updated = false;
2008 while let Ok(chunk) = rx.try_recv() {
2009 ingest_bash_chunk(chunk, &mut bash_output).await?;
2010 updated = true;
2011 }
2012
2013 if updated {
2014 emit_bash_update(&bash_output, on_update)?;
2015 }
2016
2017 match guard.try_wait_child() {
2018 Ok(Some(status)) => {
2019 exit_code = Some(exit_status_code(status));
2020 break;
2021 }
2022 Ok(None) => {}
2023 Err(err) => return Err(Error::tool("bash", err.to_string())),
2024 }
2025
2026 let now = cx
2027 .cx()
2028 .timer_driver()
2029 .map_or_else(wall_now, |timer| timer.now());
2030
2031 if let Some(deadline) = terminate_deadline {
2032 if now >= deadline {
2033 if let Some(status) = guard.kill() {
2034 exit_code = Some(exit_status_code(status));
2035 }
2036 break; }
2038 } else if let Some(timeout) = timeout {
2039 let elapsed = std::time::Duration::from_nanos(now.duration_since(start));
2040 if elapsed >= timeout {
2041 timed_out = true;
2042 let pid = guard.child.as_ref().map(std::process::Child::id);
2043 terminate_process_group_tree(pid);
2044 terminate_deadline = Some(now + Duration::from_secs(BASH_TERMINATE_GRACE_SECS));
2045 }
2046 }
2047
2048 if terminate_deadline.is_none() && cx.checkpoint().is_err() {
2049 cancelled = true;
2050 let _ = guard.kill();
2051 exit_code = Some(-1);
2052 break;
2053 }
2054
2055 sleep(now, tick).await;
2056 }
2057
2058 {
2065 let drain_start = cx
2066 .cx()
2067 .timer_driver()
2068 .map_or_else(wall_now, |timer| timer.now());
2069 let drain_deadline = drain_start + Duration::from_secs(5);
2070 let allow_drain_cancellation = !cancelled && !timed_out && exit_code.is_none();
2071 loop {
2072 let mut got_data = false;
2074 while let Ok(chunk) = rx.try_recv() {
2075 ingest_bash_chunk(chunk, &mut bash_output).await?;
2076 got_data = true;
2077 }
2078 if got_data {
2079 emit_bash_update(&bash_output, on_update)?;
2080 }
2081
2082 if stdout_thread.is_finished() && stderr_thread.is_finished() {
2085 while let Ok(chunk) = rx.try_recv() {
2088 ingest_bash_chunk(chunk, &mut bash_output).await?;
2089 }
2090 break;
2091 }
2092
2093 let now = cx
2094 .cx()
2095 .timer_driver()
2096 .map_or_else(wall_now, |timer| timer.now());
2097 if now >= drain_deadline {
2098 break;
2099 }
2100 if allow_drain_cancellation && cx.checkpoint().is_err() {
2101 cancelled = true;
2102 break;
2103 }
2104 sleep(now, tick).await;
2105 }
2106 }
2107
2108 if guard.child.is_some() {
2114 if let Ok(status) = guard.wait() {
2115 exit_code.get_or_insert_with(|| exit_status_code(status));
2116 }
2117 }
2118
2119 drop(bash_output.temp_file.take());
2120
2121 let raw_output = concat_chunks(&bash_output.chunks);
2122 let full_output = String::from_utf8_lossy(&raw_output).into_owned();
2123 let full_output_last_line_len = full_output.split('\n').next_back().map_or(0, str::len);
2124
2125 let mut truncation = truncate_tail(full_output, DEFAULT_MAX_LINES, DEFAULT_MAX_BYTES);
2126 if bash_output.total_bytes > bash_output.chunks_bytes {
2127 truncation.truncated = true;
2128 truncation.truncated_by = Some(TruncatedBy::Bytes);
2129 truncation.total_bytes = bash_output.total_bytes;
2130 truncation.total_lines = line_count_from_newline_count(
2131 bash_output.total_bytes,
2132 bash_output.line_count,
2133 bash_output.last_byte_was_newline,
2134 );
2135 }
2136
2137 let mut output_text = if truncation.content.is_empty() {
2138 "(no output)".to_string()
2139 } else {
2140 std::mem::take(&mut truncation.content)
2141 };
2142
2143 let mut full_output_path = None;
2144 if truncation.truncated {
2145 if let Some(path) = bash_output.temp_file_path.as_ref() {
2146 full_output_path = Some(path.display().to_string());
2147 }
2148
2149 let start_line = truncation
2150 .total_lines
2151 .saturating_sub(truncation.output_lines)
2152 .saturating_add(1);
2153 let end_line = truncation.total_lines;
2154
2155 let display_path = full_output_path.as_deref().unwrap_or("undefined");
2156 let file_limit_hit = bash_output.total_bytes > BASH_FILE_LIMIT_BYTES;
2157 let output_qualifier = if file_limit_hit {
2158 format!(
2159 "Partial output (capped at {})",
2160 format_size(BASH_FILE_LIMIT_BYTES)
2161 )
2162 } else {
2163 "Full output".to_string()
2164 };
2165
2166 if truncation.last_line_partial {
2167 let last_line_size = format_size(full_output_last_line_len);
2168 let _ = write!(
2169 output_text,
2170 "\n\n[Showing last {} of line {end_line} (line is {last_line_size}). {output_qualifier}: {display_path}]",
2171 format_size(truncation.output_bytes)
2172 );
2173 } else if truncation.truncated_by == Some(TruncatedBy::Lines) {
2174 let _ = write!(
2175 output_text,
2176 "\n\n[Showing lines {start_line}-{end_line} of {}. {output_qualifier}: {display_path}]",
2177 truncation.total_lines
2178 );
2179 } else {
2180 let _ = write!(
2181 output_text,
2182 "\n\n[Showing lines {start_line}-{end_line} of {} ({} limit). {output_qualifier}: {display_path}]",
2183 truncation.total_lines,
2184 format_size(DEFAULT_MAX_BYTES)
2185 );
2186 }
2187 }
2188
2189 if timed_out {
2190 cancelled = true;
2191 if !output_text.is_empty() {
2192 output_text.push_str("\n\n");
2193 }
2194 let timeout_display = timeout_secs.unwrap_or(0);
2195 let _ = write!(
2196 output_text,
2197 "Command timed out after {timeout_display} seconds"
2198 );
2199 }
2200
2201 let exit_code = exit_code.unwrap_or(-1);
2202 if !cancelled && exit_code != 0 {
2203 let _ = write!(output_text, "\n\nCommand exited with code {exit_code}");
2204 }
2205
2206 Ok(BashRunResult {
2207 output: output_text,
2208 exit_code,
2209 cancelled,
2210 truncated: truncation.truncated,
2211 full_output_path,
2212 truncation: if truncation.truncated {
2213 Some(truncation)
2214 } else {
2215 None
2216 },
2217 })
2218}
2219
2220impl BashTool {
2221 pub fn new(cwd: &Path) -> Self {
2222 Self {
2223 cwd: cwd.to_path_buf(),
2224 shell_path: None,
2225 command_prefix: None,
2226 }
2227 }
2228
2229 pub fn with_shell(
2230 cwd: &Path,
2231 shell_path: Option<String>,
2232 command_prefix: Option<String>,
2233 ) -> Self {
2234 Self {
2235 cwd: cwd.to_path_buf(),
2236 shell_path,
2237 command_prefix,
2238 }
2239 }
2240}
2241
2242#[async_trait]
2243#[allow(clippy::unnecessary_literal_bound)]
2244impl Tool for BashTool {
2245 fn name(&self) -> &str {
2246 "bash"
2247 }
2248 fn label(&self) -> &str {
2249 "bash"
2250 }
2251 fn description(&self) -> &str {
2252 "Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last 2000 lines or 1MB (whichever is hit first). If truncated, full output is saved to a temp file. `timeout` defaults to 120 seconds; set `timeout: 0` to disable."
2253 }
2254
2255 fn parameters(&self) -> serde_json::Value {
2256 serde_json::json!({
2257 "type": "object",
2258 "properties": {
2259 "command": {
2260 "type": "string",
2261 "description": "Bash command to execute"
2262 },
2263 "timeout": {
2264 "type": "integer",
2265 "description": "Timeout in seconds (default 120; set 0 to disable)"
2266 }
2267 },
2268 "required": ["command"]
2269 })
2270 }
2271
2272 #[allow(clippy::too_many_lines)]
2273 async fn execute(
2274 &self,
2275 _tool_call_id: &str,
2276 input: serde_json::Value,
2277 on_update: Option<Box<dyn Fn(ToolUpdate) + Send + Sync>>,
2278 ) -> Result<ToolOutput> {
2279 let input: BashInput =
2280 serde_json::from_value(input).map_err(|e| Error::validation(e.to_string()))?;
2281
2282 let result = run_bash_command(
2283 &self.cwd,
2284 self.shell_path.as_deref(),
2285 self.command_prefix.as_deref(),
2286 &input.command,
2287 input.timeout,
2288 on_update.as_deref(),
2289 )
2290 .await?;
2291
2292 let mut details_map = serde_json::Map::new();
2293 if let Some(truncation) = result.truncation.as_ref() {
2294 details_map.insert("truncation".to_string(), serde_json::to_value(truncation)?);
2295 }
2296 if let Some(path) = result.full_output_path.as_ref() {
2297 details_map.insert(
2298 "fullOutputPath".to_string(),
2299 serde_json::Value::String(path.clone()),
2300 );
2301 }
2302
2303 let details = if details_map.is_empty() {
2304 None
2305 } else {
2306 Some(serde_json::Value::Object(details_map))
2307 };
2308
2309 let is_error = result.cancelled || result.exit_code != 0;
2310
2311 Ok(ToolOutput {
2312 content: vec![ContentBlock::Text(TextContent::new(result.output))],
2313 details,
2314 is_error,
2315 })
2316 }
2317}
2318
2319#[derive(Debug, Deserialize)]
2325#[serde(rename_all = "camelCase")]
2326struct EditInput {
2327 path: String,
2328 old_text: String,
2329 new_text: String,
2330}
2331
2332pub struct EditTool {
2333 cwd: PathBuf,
2334}
2335
2336impl EditTool {
2337 pub fn new(cwd: &Path) -> Self {
2338 Self {
2339 cwd: cwd.to_path_buf(),
2340 }
2341 }
2342}
2343
2344fn strip_bom(s: &str) -> (&str, bool) {
2345 s.strip_prefix('\u{FEFF}')
2346 .map_or_else(|| (s, false), |stripped| (stripped, true))
2347}
2348
2349fn detect_line_ending(content: &str) -> &'static str {
2350 let bytes = content.as_bytes();
2351 let mut idx = 0;
2352 while idx < bytes.len() {
2353 match bytes[idx] {
2354 b'\r' => {
2355 return if bytes.get(idx + 1) == Some(&b'\n') {
2356 "\r\n"
2357 } else {
2358 "\r"
2359 };
2360 }
2361 b'\n' => return "\n",
2362 _ => idx += 1,
2363 }
2364 }
2365 "\n"
2366}
2367
2368fn normalize_to_lf(text: &str) -> String {
2369 if !text.contains('\r') {
2370 return text.to_string();
2371 }
2372 let mut out = String::with_capacity(text.len());
2373 let mut chars = text.chars().peekable();
2374 while let Some(c) = chars.next() {
2375 if c == '\r' {
2376 out.push('\n');
2377 if chars.peek() == Some(&'\n') {
2378 chars.next();
2379 }
2380 } else {
2381 out.push(c);
2382 }
2383 }
2384 out
2385}
2386
2387fn normalize_line_endings_chunk<'a>(
2388 chunk: &'a [u8],
2389 pending_cr: &mut bool,
2390) -> std::borrow::Cow<'a, [u8]> {
2391 if !*pending_cr && memchr::memchr(b'\r', chunk).is_none() {
2392 return std::borrow::Cow::Borrowed(chunk);
2393 }
2394
2395 let mut normalized = Vec::with_capacity(chunk.len().saturating_add(usize::from(*pending_cr)));
2396 let mut idx = 0;
2397
2398 if *pending_cr {
2399 normalized.push(b'\n');
2400 if chunk.first() == Some(&b'\n') {
2401 idx = 1;
2402 }
2403 *pending_cr = false;
2404 }
2405
2406 while idx < chunk.len() {
2407 match chunk[idx] {
2408 b'\r' => {
2409 if chunk.get(idx + 1) == Some(&b'\n') {
2410 normalized.push(b'\n');
2411 idx += 2;
2412 } else if idx + 1 < chunk.len() {
2413 normalized.push(b'\n');
2414 idx += 1;
2415 } else {
2416 *pending_cr = true;
2417 idx += 1;
2418 }
2419 }
2420 byte => {
2421 normalized.push(byte);
2422 idx += 1;
2423 }
2424 }
2425 }
2426
2427 std::borrow::Cow::Owned(normalized)
2428}
2429
2430fn restore_line_endings(text: &str, ending: &str) -> String {
2431 match ending {
2432 "\r\n" => text.replace('\n', "\r\n"),
2433 "\r" => text.replace('\n', "\r"),
2434 _ => text.to_string(),
2435 }
2436}
2437
2438#[derive(Debug, Clone)]
2439struct FuzzyMatchResult {
2440 found: bool,
2441 index: usize,
2442 match_length: usize,
2443 exact_match: bool,
2444}
2445
2446fn map_normalized_range_to_original(
2450 content: &str,
2451 norm_match_start: usize,
2452 norm_match_len: usize,
2453) -> (usize, usize) {
2454 let mut norm_idx = 0;
2455 let mut orig_idx = 0;
2456 let mut match_start = None;
2457 let mut match_end = None;
2458 let norm_match_end = norm_match_start + norm_match_len;
2459 let mut last_trimmed_end = 0;
2460 let mut last_has_newline = false;
2461
2462 for line in content.split_inclusive('\n') {
2463 let line_content = line.strip_suffix('\n').unwrap_or(line);
2464 let has_newline = line.ends_with('\n');
2465 let trimmed_len = line_content
2466 .trim_end_matches(|c: char| c.is_whitespace() || is_special_unicode_space(c))
2467 .len();
2468 let trimmed_end = orig_idx + trimmed_len;
2469 last_trimmed_end = trimmed_end;
2470 last_has_newline = has_newline;
2471
2472 for (char_offset, c) in line_content.char_indices() {
2473 if norm_idx == norm_match_end && match_end.is_none() {
2476 match_end = Some(orig_idx + char_offset);
2477 }
2478
2479 if char_offset >= trimmed_len {
2480 continue;
2481 }
2482
2483 if norm_idx == norm_match_start && match_start.is_none() {
2488 match_start = Some(orig_idx + char_offset);
2489 }
2490 if match_start.is_some() && match_end.is_some() {
2491 break;
2492 }
2493
2494 let normalized_char = if is_special_unicode_space(c) {
2495 ' '
2496 } else if matches!(c, '\u{2018}' | '\u{2019}') {
2497 '\''
2498 } else if matches!(c, '\u{201C}' | '\u{201D}' | '\u{201E}' | '\u{201F}') {
2499 '"'
2500 } else if matches!(
2501 c,
2502 '\u{2010}'
2503 | '\u{2011}'
2504 | '\u{2012}'
2505 | '\u{2013}'
2506 | '\u{2014}'
2507 | '\u{2015}'
2508 | '\u{2212}'
2509 ) {
2510 '-'
2511 } else {
2512 c
2513 };
2514
2515 norm_idx += normalized_char.len_utf8();
2516 }
2517
2518 orig_idx += line_content.len();
2519
2520 if has_newline {
2521 if norm_idx == norm_match_start && match_start.is_none() {
2522 match_start = Some(orig_idx);
2523 }
2524 if norm_idx == norm_match_end && match_end.is_none() {
2525 match_end = Some(trimmed_end);
2526 }
2527
2528 norm_idx += 1;
2529 orig_idx += 1;
2530 }
2531
2532 if match_start.is_some() && match_end.is_some() {
2533 break;
2534 }
2535 }
2536
2537 if norm_idx == norm_match_end && match_end.is_none() {
2538 match_end = Some(if last_has_newline {
2539 orig_idx
2540 } else {
2541 last_trimmed_end
2542 });
2543 }
2544
2545 let start = match_start.unwrap_or(0);
2546 let end = match_end.unwrap_or(content.len());
2547 (start, end.saturating_sub(start))
2548}
2549
2550fn build_normalized_content(content: &str) -> String {
2551 let mut normalized = String::with_capacity(content.len());
2552 let mut lines = content.split('\n').peekable();
2553
2554 while let Some(line) = lines.next() {
2555 let trimmed_len = line
2556 .trim_end_matches(|c: char| c.is_whitespace() || is_special_unicode_space(c))
2557 .len();
2558 for (char_offset, c) in line.char_indices() {
2559 if char_offset >= trimmed_len {
2560 continue;
2561 }
2562 let normalized_char = if is_special_unicode_space(c) {
2563 ' '
2564 } else if matches!(c, '\u{2018}' | '\u{2019}') {
2565 '\''
2566 } else if matches!(c, '\u{201C}' | '\u{201D}' | '\u{201E}' | '\u{201F}') {
2567 '"'
2568 } else if matches!(
2569 c,
2570 '\u{2010}'
2571 | '\u{2011}'
2572 | '\u{2012}'
2573 | '\u{2013}'
2574 | '\u{2014}'
2575 | '\u{2015}'
2576 | '\u{2212}'
2577 ) {
2578 '-'
2579 } else {
2580 c
2581 };
2582 normalized.push(normalized_char);
2583 }
2584 if lines.peek().is_some() {
2585 normalized.push('\n');
2586 }
2587 }
2588 normalized
2589}
2590
2591fn fuzzy_find_text(content: &str, old_text: &str) -> FuzzyMatchResult {
2592 fuzzy_find_text_with_normalized(content, old_text, None, None)
2593}
2594
2595fn fuzzy_find_text_with_normalized(
2598 content: &str,
2599 old_text: &str,
2600 precomputed_content: Option<&str>,
2601 precomputed_old: Option<&str>,
2602) -> FuzzyMatchResult {
2603 use std::borrow::Cow;
2604
2605 if let Some(index) = content.find(old_text) {
2607 return FuzzyMatchResult {
2608 found: true,
2609 index,
2610 match_length: old_text.len(),
2611 exact_match: true,
2612 };
2613 }
2614
2615 let normalized_content = precomputed_content.map_or_else(
2617 || Cow::Owned(build_normalized_content(content)),
2618 Cow::Borrowed,
2619 );
2620 let normalized_old_text = precomputed_old.map_or_else(
2621 || Cow::Owned(build_normalized_content(old_text)),
2622 Cow::Borrowed,
2623 );
2624
2625 if let Some(normalized_index) = normalized_content.find(normalized_old_text.as_ref()) {
2627 let (original_start, original_match_len) =
2628 map_normalized_range_to_original(content, normalized_index, normalized_old_text.len());
2629
2630 return FuzzyMatchResult {
2631 found: true,
2632 index: original_start,
2633 match_length: original_match_len,
2634 exact_match: false,
2635 };
2636 }
2637
2638 FuzzyMatchResult {
2639 found: false,
2640 index: 0,
2641 match_length: 0,
2642 exact_match: false,
2643 }
2644}
2645
2646fn count_overlapping_occurrences(haystack: &str, needle: &str) -> usize {
2647 if needle.is_empty() {
2648 return 0;
2649 }
2650
2651 haystack
2652 .char_indices()
2653 .filter(|(idx, _)| haystack[*idx..].starts_with(needle))
2654 .count()
2655}
2656
2657#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2658enum DiffTag {
2659 Equal,
2660 Added,
2661 Removed,
2662}
2663
2664#[derive(Debug, Clone)]
2665struct DiffPart {
2666 tag: DiffTag,
2667 value: String,
2668}
2669
2670fn diff_parts(old_content: &str, new_content: &str) -> Vec<DiffPart> {
2671 use similar::ChangeTag;
2672
2673 let diff = similar::TextDiff::from_lines(old_content, new_content);
2674
2675 let mut parts: Vec<DiffPart> = Vec::new();
2676 let mut current_tag: Option<DiffTag> = None;
2677 let mut current_lines: Vec<&str> = Vec::new();
2678
2679 for change in diff.iter_all_changes() {
2680 let tag = match change.tag() {
2681 ChangeTag::Equal => DiffTag::Equal,
2682 ChangeTag::Insert => DiffTag::Added,
2683 ChangeTag::Delete => DiffTag::Removed,
2684 };
2685
2686 let mut line = change.value();
2687 if let Some(stripped) = line.strip_suffix('\n') {
2688 line = stripped;
2689 }
2690
2691 if current_tag == Some(tag) {
2692 current_lines.push(line);
2693 } else {
2694 if let Some(prev_tag) = current_tag {
2695 parts.push(DiffPart {
2696 tag: prev_tag,
2697 value: current_lines.join("\n"),
2698 });
2699 }
2700 current_tag = Some(tag);
2701 current_lines = vec![line];
2702 }
2703 }
2704
2705 if let Some(tag) = current_tag {
2706 parts.push(DiffPart {
2707 tag,
2708 value: current_lines.join("\n"),
2709 });
2710 }
2711
2712 parts
2713}
2714
2715fn diff_line_num_width(old_content: &str, new_content: &str) -> usize {
2716 let old_line_count = memchr::memchr_iter(b'\n', old_content.as_bytes()).count() + 1;
2718 let new_line_count = memchr::memchr_iter(b'\n', new_content.as_bytes()).count() + 1;
2719 let max_line_num = old_line_count.max(new_line_count).max(1);
2720 max_line_num.ilog10() as usize + 1
2721}
2722
2723fn split_diff_lines(value: &str) -> Vec<&str> {
2724 value.split('\n').collect()
2730}
2731
2732#[inline]
2733const fn is_change_tag(tag: DiffTag) -> bool {
2734 matches!(tag, DiffTag::Added | DiffTag::Removed)
2735}
2736
2737#[derive(Debug)]
2738struct DiffRenderState {
2739 output: String,
2740 old_line_num: usize,
2741 new_line_num: usize,
2742 last_was_change: bool,
2743 first_changed_line: Option<usize>,
2744 line_num_width: usize,
2745 context_lines: usize,
2746}
2747
2748impl DiffRenderState {
2749 const fn new(line_num_width: usize, context_lines: usize) -> Self {
2750 Self {
2751 output: String::new(),
2752 old_line_num: 1,
2753 new_line_num: 1,
2754 last_was_change: false,
2755 first_changed_line: None,
2756 line_num_width,
2757 context_lines,
2758 }
2759 }
2760
2761 #[inline]
2762 fn ensure_line_break(&mut self) {
2763 if !self.output.is_empty() {
2764 self.output.push('\n');
2765 }
2766 }
2767
2768 const fn mark_first_change(&mut self) {
2769 if self.first_changed_line.is_none() {
2770 self.first_changed_line = Some(self.new_line_num);
2771 }
2772 }
2773
2774 fn push_added_line(&mut self, line: &str) {
2775 self.ensure_line_break();
2776 let _ = write!(
2777 self.output,
2778 "+{line_num:>width$} {line}",
2779 line_num = self.new_line_num,
2780 width = self.line_num_width
2781 );
2782 self.new_line_num = self.new_line_num.saturating_add(1);
2783 }
2784
2785 fn push_removed_line(&mut self, line: &str) {
2786 self.ensure_line_break();
2787 let _ = write!(
2788 self.output,
2789 "-{line_num:>width$} {line}",
2790 line_num = self.old_line_num,
2791 width = self.line_num_width
2792 );
2793 self.old_line_num = self.old_line_num.saturating_add(1);
2794 }
2795
2796 fn push_context_line(&mut self, line: &str) {
2797 self.ensure_line_break();
2798 let _ = write!(
2799 self.output,
2800 " {line_num:>width$} {line}",
2801 line_num = self.old_line_num,
2802 width = self.line_num_width
2803 );
2804 self.old_line_num = self.old_line_num.saturating_add(1);
2805 self.new_line_num = self.new_line_num.saturating_add(1);
2806 }
2807
2808 fn push_skip_marker(&mut self, skip: usize) {
2809 if skip == 0 {
2810 return;
2811 }
2812 self.ensure_line_break();
2813 let _ = write!(
2814 self.output,
2815 " {:>width$} ...",
2816 " ",
2817 width = self.line_num_width
2818 );
2819 self.old_line_num = self.old_line_num.saturating_add(skip);
2820 self.new_line_num = self.new_line_num.saturating_add(skip);
2821 }
2822}
2823
2824fn render_changed_part(tag: DiffTag, raw: &[&str], state: &mut DiffRenderState) {
2825 state.mark_first_change();
2826 for line in raw {
2827 match tag {
2828 DiffTag::Added => state.push_added_line(line),
2829 DiffTag::Removed => state.push_removed_line(line),
2830 DiffTag::Equal => {}
2831 }
2832 }
2833 state.last_was_change = true;
2834}
2835
2836fn render_equal_part(raw: &[&str], next_part_is_change: bool, state: &mut DiffRenderState) {
2837 if !(state.last_was_change || next_part_is_change) {
2838 let raw_len = raw.len();
2839 state.old_line_num = state.old_line_num.saturating_add(raw_len);
2840 state.new_line_num = state.new_line_num.saturating_add(raw_len);
2841 state.last_was_change = false;
2842 return;
2843 }
2844
2845 if state.last_was_change
2846 && next_part_is_change
2847 && raw.len() > state.context_lines.saturating_mul(2)
2848 {
2849 for line in raw.iter().take(state.context_lines) {
2850 state.push_context_line(line);
2851 }
2852
2853 let skip = raw.len().saturating_sub(state.context_lines * 2);
2854 state.push_skip_marker(skip);
2855
2856 for line in raw
2857 .iter()
2858 .skip(raw.len().saturating_sub(state.context_lines))
2859 {
2860 state.push_context_line(line);
2861 }
2862 } else {
2863 let start = if state.last_was_change {
2865 0
2866 } else {
2867 raw.len().saturating_sub(state.context_lines)
2868 };
2869 let lines_after_start = raw.len().saturating_sub(start);
2870 let (end, skip_end) = if !next_part_is_change && lines_after_start > state.context_lines {
2871 (
2872 start + state.context_lines,
2873 lines_after_start - state.context_lines,
2874 )
2875 } else {
2876 (raw.len(), 0)
2877 };
2878
2879 state.push_skip_marker(start);
2880 for line in &raw[start..end] {
2881 state.push_context_line(line);
2882 }
2883 state.push_skip_marker(skip_end);
2884 }
2885
2886 state.last_was_change = false;
2887}
2888
2889fn generate_diff_string(old_content: &str, new_content: &str) -> (String, Option<usize>) {
2890 let parts = diff_parts(old_content, new_content);
2891 let mut state = DiffRenderState::new(diff_line_num_width(old_content, new_content), 4);
2892
2893 for (i, part) in parts.iter().enumerate() {
2894 let raw = split_diff_lines(&part.value);
2895 let next_part_is_change = parts.get(i + 1).is_some_and(|next| is_change_tag(next.tag));
2896
2897 match part.tag {
2898 DiffTag::Added | DiffTag::Removed => render_changed_part(part.tag, &raw, &mut state),
2899 DiffTag::Equal => render_equal_part(&raw, next_part_is_change, &mut state),
2900 }
2901 }
2902
2903 (state.output, state.first_changed_line)
2904}
2905
2906#[async_trait]
2907#[allow(clippy::unnecessary_literal_bound)]
2908impl Tool for EditTool {
2909 fn name(&self) -> &str {
2910 "edit"
2911 }
2912 fn label(&self) -> &str {
2913 "edit"
2914 }
2915 fn description(&self) -> &str {
2916 "Edit a file by replacing text. The oldText must match a unique region; matching is exact but normalizes line endings, Unicode spaces/quotes/dashes, and ignores trailing whitespace."
2917 }
2918
2919 fn parameters(&self) -> serde_json::Value {
2920 serde_json::json!({
2921 "type": "object",
2922 "properties": {
2923 "path": {
2924 "type": "string",
2925 "description": "Path to the file to edit (relative or absolute)"
2926 },
2927 "oldText": {
2928 "type": "string",
2929 "minLength": 1,
2930 "description": "Text to find and replace (must match uniquely; matching normalizes line endings, Unicode spaces/quotes/dashes, and ignores trailing whitespace)"
2931 },
2932 "newText": {
2933 "type": "string",
2934 "description": "New text to replace the old text with"
2935 }
2936 },
2937 "required": ["path", "oldText", "newText"]
2938 })
2939 }
2940
2941 #[allow(clippy::too_many_lines)]
2942 async fn execute(
2943 &self,
2944 _tool_call_id: &str,
2945 input: serde_json::Value,
2946 _on_update: Option<Box<dyn Fn(ToolUpdate) + Send + Sync>>,
2947 ) -> Result<ToolOutput> {
2948 let input: EditInput =
2949 serde_json::from_value(input).map_err(|e| Error::validation(e.to_string()))?;
2950
2951 if input.new_text.len() > WRITE_TOOL_MAX_BYTES {
2952 return Err(Error::validation(format!(
2953 "New text size exceeds maximum allowed ({} > {} bytes)",
2954 input.new_text.len(),
2955 WRITE_TOOL_MAX_BYTES
2956 )));
2957 }
2958
2959 let absolute_path = resolve_read_path(&input.path, &self.cwd);
2960 let absolute_path = enforce_cwd_scope(&absolute_path, &self.cwd, "edit")?;
2961
2962 let meta = asupersync::fs::metadata(&absolute_path)
2963 .await
2964 .map_err(|err| {
2965 let message = match err.kind() {
2966 std::io::ErrorKind::NotFound => format!("File not found: {}", input.path),
2967 std::io::ErrorKind::PermissionDenied => {
2968 format!("Permission denied: {}", input.path)
2969 }
2970 _ => format!("Failed to access file {}: {err}", input.path),
2971 };
2972 Error::tool("edit", message)
2973 })?;
2974
2975 if !meta.is_file() {
2976 return Err(Error::tool(
2977 "edit",
2978 format!("Path {} is not a regular file", absolute_path.display()),
2979 ));
2980 }
2981 if meta.len() > READ_TOOL_MAX_BYTES {
2982 return Err(Error::tool(
2983 "edit",
2984 format!(
2985 "File is too large ({} bytes). Max allowed for editing is {} bytes.",
2986 meta.len(),
2987 READ_TOOL_MAX_BYTES
2988 ),
2989 ));
2990 }
2991
2992 if let Err(err) = asupersync::fs::OpenOptions::new()
2993 .read(true)
2994 .write(true)
2995 .open(&absolute_path)
2996 .await
2997 {
2998 let message = match err.kind() {
2999 std::io::ErrorKind::NotFound => format!("File not found: {}", input.path),
3000 std::io::ErrorKind::PermissionDenied => {
3001 format!("Permission denied: {}", input.path)
3002 }
3003 _ => format!("Failed to open file for editing: {err}"),
3004 };
3005 return Err(Error::tool("edit", message));
3006 }
3007
3008 let file = asupersync::fs::File::open(&absolute_path)
3010 .await
3011 .map_err(|e| Error::tool("edit", format!("Failed to open file: {e}")))?;
3012 let mut raw = Vec::new();
3013 let mut limiter = file.take(READ_TOOL_MAX_BYTES.saturating_add(1));
3014 limiter
3015 .read_to_end(&mut raw)
3016 .await
3017 .map_err(|e| Error::tool("edit", format!("Failed to read file: {e}")))?;
3018
3019 if raw.len() > usize::try_from(READ_TOOL_MAX_BYTES).unwrap_or(usize::MAX) {
3020 return Err(Error::tool(
3021 "edit",
3022 format!("File is too large (> {READ_TOOL_MAX_BYTES} bytes)."),
3023 ));
3024 }
3025
3026 let raw_content = String::from_utf8(raw).map_err(|_| {
3027 Error::tool(
3028 "edit",
3029 "File contains invalid UTF-8 characters and cannot be safely edited as text."
3030 .to_string(),
3031 )
3032 })?;
3033
3034 let (content_no_bom, had_bom) = strip_bom(&raw_content);
3036
3037 let original_ending = detect_line_ending(content_no_bom);
3038 let normalized_content = normalize_to_lf(content_no_bom);
3039 let content_for_matching =
3040 if content_no_bom.contains('\r') && !content_no_bom.contains('\n') {
3041 std::borrow::Cow::Owned(content_no_bom.replace('\r', "\n"))
3042 } else {
3043 std::borrow::Cow::Borrowed(content_no_bom)
3044 };
3045 let normalized_old_text = normalize_to_lf(&input.old_text);
3046
3047 if normalized_old_text.is_empty() {
3048 return Err(Error::tool(
3049 "edit",
3050 "The old text cannot be empty. To prepend text, include the first line's content in oldText and newText.".to_string(),
3051 ));
3052 }
3053 if build_normalized_content(&normalized_old_text).is_empty() {
3054 return Err(Error::tool(
3055 "edit",
3056 "The old text must include at least one non-whitespace character.".to_string(),
3057 ));
3058 }
3059
3060 let mut variants = Vec::with_capacity(3);
3067 variants.push(normalized_old_text.clone());
3068
3069 let nfc = normalized_old_text.nfc().collect::<String>();
3070 if nfc != normalized_old_text {
3071 variants.push(nfc);
3072 }
3073
3074 let nfd = normalized_old_text.nfd().collect::<String>();
3075 if nfd != normalized_old_text {
3076 variants.push(nfd);
3077 }
3078
3079 let precomputed_content = build_normalized_content(content_for_matching.as_ref());
3082
3083 let mut best_match: Option<(FuzzyMatchResult, String, String)> = None;
3084
3085 for variant in variants {
3086 let precomputed_variant = build_normalized_content(&variant);
3087 let match_result = fuzzy_find_text_with_normalized(
3088 content_for_matching.as_ref(),
3089 &variant,
3090 Some(precomputed_content.as_str()),
3091 Some(precomputed_variant.as_str()),
3092 );
3093
3094 if match_result.found {
3095 best_match = Some((match_result, precomputed_variant, variant));
3096 break;
3097 }
3098 }
3099
3100 let Some((match_result, normalized_old_text, matched_variant)) = best_match else {
3101 return Err(Error::tool(
3102 "edit",
3103 format!(
3104 "Could not find the exact text in {}. The old text must match exactly including all whitespace and newlines.",
3105 input.path
3106 ),
3107 ));
3108 };
3109
3110 let occurrences = if match_result.exact_match {
3113 count_overlapping_occurrences(content_for_matching.as_ref(), &matched_variant)
3114 } else {
3115 count_overlapping_occurrences(&precomputed_content, &normalized_old_text)
3116 };
3117
3118 if occurrences > 1 {
3119 return Err(Error::tool(
3120 "edit",
3121 format!(
3122 "Found {occurrences} occurrences of the text in {}. The text must be unique. Please provide more context to make it unique.",
3123 input.path
3124 ),
3125 ));
3126 }
3127
3128 let idx = match_result.index;
3131 let match_len = match_result.match_length;
3132
3133 let adapted_new_text =
3137 restore_line_endings(&normalize_to_lf(&input.new_text), original_ending);
3138
3139 let new_len = content_no_bom.len() - match_len + adapted_new_text.len();
3140 let mut new_content = String::with_capacity(new_len);
3141 new_content.push_str(&content_no_bom[..idx]);
3142 new_content.push_str(&adapted_new_text);
3143 new_content.push_str(&content_no_bom[idx + match_len..]);
3144
3145 if content_no_bom == new_content {
3146 return Err(Error::tool(
3147 "edit",
3148 format!(
3149 "No changes made to {}. The replacement produced identical content. This might indicate an issue with special characters or the text not existing as expected.",
3150 input.path
3151 ),
3152 ));
3153 }
3154
3155 let new_content_for_diff = normalize_to_lf(&new_content);
3156
3157 let mut final_content = new_content;
3159 if had_bom {
3160 final_content = format!("\u{FEFF}{final_content}");
3161 }
3162
3163 let absolute_path_clone = absolute_path.clone();
3165 let final_content_bytes = final_content.into_bytes();
3166 asupersync::runtime::spawn_blocking_io(move || {
3167 let original_perms = std::fs::metadata(&absolute_path_clone)
3169 .ok()
3170 .map(|m| m.permissions());
3171 let parent = absolute_path_clone
3172 .parent()
3173 .unwrap_or_else(|| Path::new("."));
3174 let mut temp_file = tempfile::NamedTempFile::new_in(parent)?;
3175
3176 temp_file.as_file_mut().write_all(&final_content_bytes)?;
3177 temp_file.as_file_mut().sync_all()?;
3178
3179 if let Some(perms) = original_perms {
3181 let _ = temp_file.as_file().set_permissions(perms);
3182 } else {
3183 #[cfg(unix)]
3185 {
3186 use std::os::unix::fs::PermissionsExt;
3187 let _ = temp_file
3188 .as_file()
3189 .set_permissions(std::fs::Permissions::from_mode(0o644));
3190 }
3191 }
3192
3193 temp_file
3194 .persist(&absolute_path_clone)
3195 .map_err(|e| e.error)?;
3196 sync_parent_dir(&absolute_path_clone)?;
3197 Ok(())
3198 })
3199 .await
3200 .map_err(|e| Error::tool("edit", format!("Failed to write file: {e}")))?;
3201
3202 let (diff, first_changed_line) =
3203 generate_diff_string(&normalized_content, &new_content_for_diff);
3204 let mut details = serde_json::Map::new();
3205 details.insert("diff".to_string(), serde_json::Value::String(diff));
3206 if let Some(line) = first_changed_line {
3207 details.insert(
3208 "firstChangedLine".to_string(),
3209 serde_json::Value::Number(serde_json::Number::from(line)),
3210 );
3211 }
3212
3213 Ok(ToolOutput {
3214 content: vec![ContentBlock::Text(TextContent::new(format!(
3215 "Successfully replaced text in {}.",
3216 input.path
3217 )))],
3218 details: Some(serde_json::Value::Object(details)),
3219 is_error: false,
3220 })
3221 }
3222}
3223
3224#[derive(Debug, Deserialize)]
3230#[serde(rename_all = "camelCase")]
3231struct WriteInput {
3232 path: String,
3233 content: String,
3234}
3235
3236pub struct WriteTool {
3237 cwd: PathBuf,
3238}
3239
3240impl WriteTool {
3241 pub fn new(cwd: &Path) -> Self {
3242 Self {
3243 cwd: cwd.to_path_buf(),
3244 }
3245 }
3246}
3247
3248#[async_trait]
3249#[allow(clippy::unnecessary_literal_bound)]
3250impl Tool for WriteTool {
3251 fn name(&self) -> &str {
3252 "write"
3253 }
3254 fn label(&self) -> &str {
3255 "write"
3256 }
3257 fn description(&self) -> &str {
3258 "Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories."
3259 }
3260
3261 fn parameters(&self) -> serde_json::Value {
3262 serde_json::json!({
3263 "type": "object",
3264 "properties": {
3265 "path": {
3266 "type": "string",
3267 "description": "Path to the file to write (relative or absolute)"
3268 },
3269 "content": {
3270 "type": "string",
3271 "description": "Content to write to the file"
3272 }
3273 },
3274 "required": ["path", "content"]
3275 })
3276 }
3277
3278 #[allow(clippy::too_many_lines)]
3279 async fn execute(
3280 &self,
3281 _tool_call_id: &str,
3282 input: serde_json::Value,
3283 _on_update: Option<Box<dyn Fn(ToolUpdate) + Send + Sync>>,
3284 ) -> Result<ToolOutput> {
3285 let input: WriteInput =
3286 serde_json::from_value(input).map_err(|e| Error::validation(e.to_string()))?;
3287
3288 if input.content.len() > WRITE_TOOL_MAX_BYTES {
3289 return Err(Error::validation(format!(
3290 "Content size exceeds maximum allowed ({} > {} bytes)",
3291 input.content.len(),
3292 WRITE_TOOL_MAX_BYTES
3293 )));
3294 }
3295
3296 let path = resolve_path(&input.path, &self.cwd);
3297 let path = enforce_cwd_scope(&path, &self.cwd, "write")?;
3298
3299 if let Some(parent) = path.parent() {
3301 asupersync::fs::create_dir_all(parent)
3302 .await
3303 .map_err(|e| Error::tool("write", format!("Failed to create directories: {e}")))?;
3304 }
3305
3306 let bytes_written = input.content.encode_utf16().count();
3308
3309 let path_clone = path.clone();
3311 let content_bytes = input.content.into_bytes();
3312 asupersync::runtime::spawn_blocking_io(move || {
3313 let original_perms = std::fs::metadata(&path_clone).ok().map(|m| m.permissions());
3315 let parent = path_clone.parent().unwrap_or_else(|| Path::new("."));
3316 let mut temp_file = tempfile::NamedTempFile::new_in(parent)?;
3317
3318 temp_file.as_file_mut().write_all(&content_bytes)?;
3319 temp_file.as_file_mut().sync_all()?;
3320
3321 if let Some(perms) = original_perms {
3323 let _ = temp_file.as_file().set_permissions(perms);
3324 } else {
3325 #[cfg(unix)]
3327 {
3328 use std::os::unix::fs::PermissionsExt;
3329 let _ = temp_file
3330 .as_file()
3331 .set_permissions(std::fs::Permissions::from_mode(0o644));
3332 }
3333 }
3334
3335 temp_file.persist(&path_clone).map_err(|e| e.error)?;
3337 sync_parent_dir(&path_clone)?;
3338 Ok(())
3339 })
3340 .await
3341 .map_err(|e| Error::tool("write", format!("Failed to write file: {e}")))?;
3342
3343 Ok(ToolOutput {
3344 content: vec![ContentBlock::Text(TextContent::new(format!(
3345 "Successfully wrote {} bytes to {}",
3346 bytes_written, input.path
3347 )))],
3348 details: None,
3349 is_error: false,
3350 })
3351 }
3352}
3353
3354#[derive(Debug, Deserialize)]
3360#[serde(rename_all = "camelCase")]
3361struct GrepInput {
3362 pattern: String,
3363 path: Option<String>,
3364 glob: Option<String>,
3365 ignore_case: Option<bool>,
3366 literal: Option<bool>,
3367 context: Option<usize>,
3368 limit: Option<usize>,
3369 #[serde(default)]
3370 hashline: bool,
3371}
3372
3373pub struct GrepTool {
3374 cwd: PathBuf,
3375}
3376
3377impl GrepTool {
3378 pub fn new(cwd: &Path) -> Self {
3379 Self {
3380 cwd: cwd.to_path_buf(),
3381 }
3382 }
3383}
3384
3385#[derive(Debug, Clone, PartialEq, Eq)]
3387struct TruncateLineResult {
3388 text: String,
3389 was_truncated: bool,
3390}
3391
3392fn truncate_line(line: &str, max_chars: usize) -> TruncateLineResult {
3396 let mut chars = line.chars();
3397 let prefix: String = chars.by_ref().take(max_chars).collect();
3398 if chars.next().is_none() {
3399 return TruncateLineResult {
3400 text: line.to_string(),
3401 was_truncated: false,
3402 };
3403 }
3404
3405 TruncateLineResult {
3406 text: format!("{prefix}... [truncated]"),
3407 was_truncated: true,
3408 }
3409}
3410
3411fn process_rg_json_match_line(
3412 line_res: std::io::Result<String>,
3413 matches: &mut Vec<(PathBuf, usize)>,
3414 match_count: &mut usize,
3415 match_limit_reached: &mut bool,
3416 scan_limit: usize,
3417) {
3418 if *match_limit_reached {
3419 return;
3420 }
3421
3422 let line = match line_res {
3423 Ok(l) => l,
3424 Err(e) => {
3425 tracing::debug!("Skipping ripgrep output line due to read error: {e}");
3426 return;
3427 }
3428 };
3429 if line.trim().is_empty() {
3430 return;
3431 }
3432
3433 let Ok(event) = serde_json::from_str::<serde_json::Value>(&line) else {
3434 return;
3435 };
3436
3437 if event.get("type").and_then(serde_json::Value::as_str) != Some("match") {
3438 return;
3439 }
3440
3441 let file_path = event
3442 .pointer("/data/path/text")
3443 .and_then(serde_json::Value::as_str)
3444 .map(PathBuf::from);
3445 let line_number = event
3446 .pointer("/data/line_number")
3447 .and_then(serde_json::Value::as_u64)
3448 .and_then(|n| usize::try_from(n).ok());
3449
3450 if let (Some(fp), Some(ln)) = (file_path, line_number) {
3451 matches.push((fp, ln));
3452 *match_count += 1;
3453 if *match_count >= scan_limit {
3454 *match_limit_reached = true;
3455 }
3456 }
3457}
3458
3459fn drain_rg_stdout(
3460 stdout_rx: &std::sync::mpsc::Receiver<std::io::Result<String>>,
3461 matches: &mut Vec<(PathBuf, usize)>,
3462 match_count: &mut usize,
3463 match_limit_reached: &mut bool,
3464 scan_limit: usize,
3465) {
3466 while let Ok(line_res) = stdout_rx.try_recv() {
3467 process_rg_json_match_line(
3468 line_res,
3469 matches,
3470 match_count,
3471 match_limit_reached,
3472 scan_limit,
3473 );
3474 if *match_limit_reached {
3475 break;
3476 }
3477 }
3478}
3479
3480fn drain_rg_stderr(
3481 stderr_rx: &std::sync::mpsc::Receiver<std::result::Result<Vec<u8>, String>>,
3482 stderr_bytes: &mut Vec<u8>,
3483) -> Result<()> {
3484 while let Ok(chunk_result) = stderr_rx.try_recv() {
3485 let chunk = chunk_result
3486 .map_err(|err| Error::tool("grep", format!("Failed to read stderr: {err}")))?;
3487 stderr_bytes.extend_from_slice(&chunk);
3488 }
3489 Ok(())
3490}
3491
3492#[async_trait]
3493#[allow(clippy::unnecessary_literal_bound)]
3494impl Tool for GrepTool {
3495 fn name(&self) -> &str {
3496 "grep"
3497 }
3498 fn label(&self) -> &str {
3499 "grep"
3500 }
3501 fn description(&self) -> &str {
3502 "Search file contents for a pattern. Returns matching lines with file paths and line numbers. Respects .gitignore. Output is truncated to 100 matches or 1MB (whichever is hit first). Long lines are truncated to 500 chars. Use hashline=true to get N#AB content-hash tags for use with hashline_edit."
3503 }
3504
3505 fn parameters(&self) -> serde_json::Value {
3506 serde_json::json!({
3507 "type": "object",
3508 "properties": {
3509 "pattern": {
3510 "type": "string",
3511 "description": "Search pattern (regex or literal string)"
3512 },
3513 "path": {
3514 "type": "string",
3515 "description": "Directory or file to search (default: current directory)"
3516 },
3517 "glob": {
3518 "type": "string",
3519 "description": "Filter files by glob pattern, e.g. '*.ts' or '**/*.spec.ts'"
3520 },
3521 "ignoreCase": {
3522 "type": "boolean",
3523 "description": "Case-insensitive search (default: false)"
3524 },
3525 "literal": {
3526 "type": "boolean",
3527 "description": "Treat pattern as literal string instead of regex (default: false)"
3528 },
3529 "context": {
3530 "type": "integer",
3531 "description": "Number of lines to show before and after each match (default: 0)"
3532 },
3533 "limit": {
3534 "type": "integer",
3535 "description": "Maximum number of matches to return (default: 100)"
3536 },
3537 "hashline": {
3538 "type": "boolean",
3539 "description": "When true, output each line as N#AB:content where N is the line number and AB is a content hash. Use with hashline_edit tool for precise edits."
3540 }
3541 },
3542 "required": ["pattern"]
3543 })
3544 }
3545
3546 fn is_read_only(&self) -> bool {
3547 true
3548 }
3549
3550 #[allow(clippy::too_many_lines)]
3551 async fn execute(
3552 &self,
3553 _tool_call_id: &str,
3554 input: serde_json::Value,
3555 _on_update: Option<Box<dyn Fn(ToolUpdate) + Send + Sync>>,
3556 ) -> Result<ToolOutput> {
3557 let input: GrepInput =
3558 serde_json::from_value(input).map_err(|e| Error::validation(e.to_string()))?;
3559
3560 if matches!(input.limit, Some(0)) {
3561 return Err(Error::validation(
3562 "`limit` must be greater than 0".to_string(),
3563 ));
3564 }
3565
3566 if !rg_available() {
3567 return Err(Error::tool(
3568 "grep",
3569 "ripgrep (rg) is not available (please install ripgrep)".to_string(),
3570 ));
3571 }
3572
3573 let search_dir = input.path.as_deref().unwrap_or(".");
3574 let search_path = resolve_read_path(search_dir, &self.cwd);
3575 let search_path = enforce_cwd_scope(&search_path, &self.cwd, "grep")?;
3576
3577 let is_directory = asupersync::fs::metadata(&search_path)
3578 .await
3579 .map_err(|e| {
3580 Error::tool(
3581 "grep",
3582 format!("Cannot access path {}: {e}", search_path.display()),
3583 )
3584 })?
3585 .is_dir();
3586
3587 let context_value = input.context.unwrap_or(0);
3588 let effective_limit = input.limit.unwrap_or(DEFAULT_GREP_LIMIT).max(1);
3589 let scan_limit = effective_limit.saturating_add(1);
3591
3592 let mut args: Vec<String> = vec![
3593 "--json".to_string(),
3594 "--line-number".to_string(),
3595 "--color=never".to_string(),
3596 "--hidden".to_string(),
3597 "--max-columns=10000".to_string(),
3599 ];
3600
3601 if input.ignore_case.unwrap_or(false) {
3602 args.push("--ignore-case".to_string());
3603 }
3604 if input.literal.unwrap_or(false) {
3605 args.push("--fixed-strings".to_string());
3606 }
3607 if let Some(glob) = &input.glob {
3608 args.push("--glob".to_string());
3609 args.push(glob.clone());
3610 }
3611
3612 let ignore_root = if is_directory {
3615 search_path.clone()
3616 } else {
3617 search_path
3618 .parent()
3619 .unwrap_or_else(|| Path::new("."))
3620 .to_path_buf()
3621 };
3622 let workspace_gitignore = self.cwd.join(".gitignore");
3628 if workspace_gitignore.exists() {
3629 args.push("--ignore-file".to_string());
3630 args.push(workspace_gitignore.display().to_string());
3631 }
3632 let root_gitignore = ignore_root.join(".gitignore");
3633 if root_gitignore != workspace_gitignore && root_gitignore.exists() {
3634 args.push("--ignore-file".to_string());
3635 args.push(root_gitignore.display().to_string());
3636 }
3637
3638 args.push("--".to_string());
3639 args.push(input.pattern.clone());
3640 args.push(search_path.display().to_string());
3641
3642 let rg_cmd = find_rg_binary().ok_or_else(|| {
3643 Error::tool(
3644 "grep",
3645 "rg is not available (please install ripgrep or rg)".to_string(),
3646 )
3647 })?;
3648
3649 let mut child = Command::new(rg_cmd)
3650 .args(args)
3651 .stdout(Stdio::piped())
3652 .stderr(Stdio::piped())
3653 .spawn()
3654 .map_err(|e| Error::tool("grep", format!("Failed to run ripgrep: {e}")))?;
3655
3656 let stdout = child
3657 .stdout
3658 .take()
3659 .ok_or_else(|| Error::tool("grep", "Missing stdout".to_string()))?;
3660 let stderr = child
3661 .stderr
3662 .take()
3663 .ok_or_else(|| Error::tool("grep", "Missing stderr".to_string()))?;
3664
3665 let mut guard = ProcessGuard::new(child, ProcessCleanupMode::ChildOnly);
3666
3667 let (stdout_tx, stdout_rx) = std::sync::mpsc::sync_channel(1024);
3668 let (stderr_tx, stderr_rx) =
3669 std::sync::mpsc::sync_channel::<std::result::Result<Vec<u8>, String>>(1024);
3670
3671 let stdout_thread = std::thread::spawn(move || {
3672 let reader = std::io::BufReader::new(stdout);
3673 for line in reader.lines() {
3674 if stdout_tx.send(line).is_err() {
3675 break;
3676 }
3677 }
3678 });
3679
3680 let stderr_thread = std::thread::spawn(move || {
3681 let reader = std::io::BufReader::new(stderr);
3682 let _ = stderr_tx.send(read_to_end_capped_and_drain(reader, READ_TOOL_MAX_BYTES));
3683 });
3684
3685 let mut matches: Vec<(PathBuf, usize)> = Vec::new();
3686 let mut match_count: usize = 0;
3687 let mut match_scan_limit_reached = false;
3688 let mut stderr_bytes = Vec::new();
3689
3690 let tick = Duration::from_millis(10);
3691 let mut cx_cancelled = false;
3692
3693 let exit_status = loop {
3694 let agent_cx = AgentCx::for_current_or_request();
3695 let cx = agent_cx.cx();
3696 if cx.checkpoint().is_err() {
3697 cx_cancelled = true;
3698 break None;
3699 }
3700
3701 drain_rg_stdout(
3702 &stdout_rx,
3703 &mut matches,
3704 &mut match_count,
3705 &mut match_scan_limit_reached,
3706 scan_limit,
3707 );
3708 drain_rg_stderr(&stderr_rx, &mut stderr_bytes)?;
3709
3710 if match_scan_limit_reached {
3711 break None;
3712 }
3713
3714 match guard.try_wait_child() {
3715 Ok(Some(status)) => break Some(status),
3716 Ok(None) => {
3717 let now = cx.timer_driver().map_or_else(wall_now, |timer| timer.now());
3718 sleep(now, tick).await;
3719 }
3720 Err(e) => return Err(Error::tool("grep", e.to_string())),
3721 }
3722 };
3723
3724 drain_rg_stdout(
3725 &stdout_rx,
3726 &mut matches,
3727 &mut match_count,
3728 &mut match_scan_limit_reached,
3729 scan_limit,
3730 );
3731
3732 let code = if match_scan_limit_reached || cx_cancelled {
3733 let _ = guard.kill();
3737 while stdout_rx.try_recv().is_ok() {}
3739 while stderr_rx.try_recv().is_ok() {}
3740 0
3741 } else {
3742 let status = exit_status.expect("rg exit status");
3743 status.code().unwrap_or(0)
3744 };
3745
3746 while !stdout_thread.is_finished() || !stderr_thread.is_finished() {
3750 if match_scan_limit_reached || cx_cancelled {
3751 while stdout_rx.try_recv().is_ok() {}
3752 } else {
3753 drain_rg_stdout(
3754 &stdout_rx,
3755 &mut matches,
3756 &mut match_count,
3757 &mut match_scan_limit_reached,
3758 scan_limit,
3759 );
3760 }
3761 drain_rg_stderr(&stderr_rx, &mut stderr_bytes)?;
3762 sleep(wall_now(), Duration::from_millis(1)).await;
3763 }
3764
3765 if cx_cancelled {
3766 return Err(Error::tool("grep", "Command cancelled"));
3767 }
3768
3769 stdout_thread
3774 .join()
3775 .map_err(|_| Error::tool("grep", "ripgrep stdout reader thread panicked"))?;
3776 stderr_thread
3777 .join()
3778 .map_err(|_| Error::tool("grep", "ripgrep stderr reader thread panicked"))?;
3779
3780 if match_scan_limit_reached {
3782 while stdout_rx.try_recv().is_ok() {}
3783 } else {
3784 drain_rg_stdout(
3785 &stdout_rx,
3786 &mut matches,
3787 &mut match_count,
3788 &mut match_scan_limit_reached,
3789 scan_limit,
3790 );
3791 }
3792 drain_rg_stderr(&stderr_rx, &mut stderr_bytes)?;
3793
3794 let mut stderr_text = String::from_utf8_lossy(&stderr_bytes).trim().to_string();
3795 if stderr_bytes.len() as u64 > READ_TOOL_MAX_BYTES {
3796 stderr_text.push_str("\n... [stderr truncated] ...");
3797 }
3798 if !match_scan_limit_reached && code != 0 && code != 1 {
3799 let msg = if stderr_text.is_empty() {
3800 format!("ripgrep exited with code {code}")
3801 } else {
3802 stderr_text
3803 };
3804 return Err(Error::tool("grep", msg));
3805 }
3806
3807 let match_limit_reached = match_count > effective_limit;
3808 if match_limit_reached {
3809 matches.truncate(effective_limit);
3810 match_count = effective_limit;
3811 }
3812
3813 if match_count == 0 {
3814 return Ok(ToolOutput {
3815 content: vec![ContentBlock::Text(TextContent::new("No matches found"))],
3816 details: None,
3817 is_error: false,
3818 });
3819 }
3820
3821 let mut file_cache: HashMap<PathBuf, Vec<String>> = HashMap::new();
3822 let mut output_lines: Vec<String> = Vec::new();
3823 let mut lines_truncated = false;
3824
3825 let mut file_order: Vec<PathBuf> = Vec::new();
3827 let mut matches_by_file: HashMap<PathBuf, Vec<usize>> = HashMap::new();
3828 for (file_path, line_number) in &matches {
3829 if !matches_by_file.contains_key(file_path) {
3830 file_order.push(file_path.clone());
3831 }
3832 matches_by_file
3833 .entry(file_path.clone())
3834 .or_default()
3835 .push(*line_number);
3836 }
3837
3838 for file_path in file_order {
3839 let Some(mut match_lines) = matches_by_file.remove(&file_path) else {
3840 continue;
3841 };
3842 let relative_path = format_grep_path(&file_path, &self.cwd);
3843 let lines = get_file_lines_async(&file_path, &mut file_cache).await;
3844
3845 if lines.is_empty() {
3846 if let Some(first_match) = match_lines.first() {
3847 output_lines.push(format!(
3848 "{relative_path}:{first_match}: (unable to read file or too large)"
3849 ));
3850 }
3851 continue;
3852 }
3853
3854 match_lines.sort_unstable();
3855 match_lines.dedup();
3856
3857 let mut blocks: Vec<(usize, usize)> = Vec::new();
3858 for &line_number in &match_lines {
3859 let start = if context_value > 0 {
3860 line_number.saturating_sub(context_value).max(1)
3861 } else {
3862 line_number
3863 };
3864 let end = if context_value > 0 {
3865 line_number.saturating_add(context_value).min(lines.len())
3866 } else {
3867 line_number
3868 };
3869
3870 if let Some(last_block) = blocks.last_mut() {
3871 if start <= last_block.1.saturating_add(1) {
3872 last_block.1 = last_block.1.max(end);
3873 continue;
3874 }
3875 }
3876 blocks.push((start, end));
3877 }
3878
3879 for (i, (start, end)) in blocks.into_iter().enumerate() {
3880 if i > 0 {
3881 output_lines.push("--".to_string());
3882 }
3883 for current in start..=end {
3884 let line_text = lines.get(current - 1).map_or("", String::as_str);
3885 let sanitized = line_text.replace('\r', "");
3886 let truncated = truncate_line(&sanitized, GREP_MAX_LINE_LENGTH);
3887 if truncated.was_truncated {
3888 lines_truncated = true;
3889 }
3890
3891 if input.hashline {
3892 let line_idx = current - 1; let tag = format_hashline_tag(line_idx, &sanitized);
3894 if match_lines.binary_search(¤t).is_ok() {
3895 output_lines.push(format!("{relative_path}:{tag}: {}", truncated.text));
3896 } else {
3897 output_lines.push(format!("{relative_path}-{tag}- {}", truncated.text));
3898 }
3899 } else if match_lines.binary_search(¤t).is_ok() {
3900 output_lines.push(format!("{relative_path}:{current}: {}", truncated.text));
3901 } else {
3902 output_lines.push(format!("{relative_path}-{current}- {}", truncated.text));
3903 }
3904 }
3905 }
3906 }
3907
3908 let raw_output = output_lines.join("\n");
3910 let mut truncation = truncate_head(raw_output, usize::MAX, DEFAULT_MAX_BYTES);
3911
3912 let mut output = std::mem::take(&mut truncation.content);
3913 let mut notices: Vec<String> = Vec::new();
3914 let mut details_map = serde_json::Map::new();
3915
3916 if match_limit_reached {
3917 notices.push(format!(
3918 "{effective_limit} matches limit reached. Use limit={} for more, or refine pattern",
3919 effective_limit * 2
3920 ));
3921 details_map.insert(
3922 "matchLimitReached".to_string(),
3923 serde_json::Value::Number(serde_json::Number::from(effective_limit)),
3924 );
3925 }
3926
3927 if truncation.truncated {
3928 notices.push(format!("{} limit reached", format_size(DEFAULT_MAX_BYTES)));
3929 details_map.insert("truncation".to_string(), serde_json::to_value(truncation)?);
3930 }
3931
3932 if lines_truncated {
3933 notices.push(format!(
3934 "Some lines truncated to {GREP_MAX_LINE_LENGTH} chars. Use read tool to see full lines"
3935 ));
3936 details_map.insert("linesTruncated".to_string(), serde_json::Value::Bool(true));
3937 }
3938
3939 if !notices.is_empty() {
3940 let _ = write!(output, "\n\n[{}]", notices.join(". "));
3941 }
3942
3943 let details = if details_map.is_empty() {
3944 None
3945 } else {
3946 Some(serde_json::Value::Object(details_map))
3947 };
3948
3949 Ok(ToolOutput {
3950 content: vec![ContentBlock::Text(TextContent::new(output))],
3951 details,
3952 is_error: false,
3953 })
3954 }
3955}
3956
3957#[derive(Debug, Deserialize)]
3963#[serde(rename_all = "camelCase")]
3964struct FindInput {
3965 pattern: String,
3966 path: Option<String>,
3967 limit: Option<usize>,
3968}
3969
3970#[derive(Debug)]
3971struct FindEntry {
3972 rel: String,
3973 modified: Option<SystemTime>,
3974}
3975
3976pub struct FindTool {
3977 cwd: PathBuf,
3978}
3979
3980impl FindTool {
3981 pub fn new(cwd: &Path) -> Self {
3982 Self {
3983 cwd: cwd.to_path_buf(),
3984 }
3985 }
3986}
3987
3988#[async_trait]
3989#[allow(clippy::unnecessary_literal_bound)]
3990impl Tool for FindTool {
3991 fn name(&self) -> &str {
3992 "find"
3993 }
3994 fn label(&self) -> &str {
3995 "find"
3996 }
3997 fn description(&self) -> &str {
3998 "Search for files by glob pattern. Returns matching file paths relative to the search directory. Sorted by modification time (newest first). Respects .gitignore. Output is truncated to 1000 results or 1MB (whichever is hit first)."
3999 }
4000
4001 fn parameters(&self) -> serde_json::Value {
4002 serde_json::json!({
4003 "type": "object",
4004 "properties": {
4005 "pattern": {
4006 "type": "string",
4007 "description": "Glob pattern to match files, e.g. '*.ts', '**/*.json', or 'src/**/*.spec.ts'"
4008 },
4009 "path": {
4010 "type": "string",
4011 "description": "Directory to search in (default: current directory)"
4012 },
4013 "limit": {
4014 "type": "integer",
4015 "description": "Maximum number of results (default: 1000)"
4016 }
4017 },
4018 "required": ["pattern"]
4019 })
4020 }
4021
4022 fn is_read_only(&self) -> bool {
4023 true
4024 }
4025
4026 #[allow(clippy::too_many_lines)]
4027 async fn execute(
4028 &self,
4029 _tool_call_id: &str,
4030 input: serde_json::Value,
4031 _on_update: Option<Box<dyn Fn(ToolUpdate) + Send + Sync>>,
4032 ) -> Result<ToolOutput> {
4033 let input: FindInput =
4034 serde_json::from_value(input).map_err(|e| Error::validation(e.to_string()))?;
4035
4036 if matches!(input.limit, Some(0)) {
4037 return Err(Error::validation(
4038 "`limit` must be greater than 0".to_string(),
4039 ));
4040 }
4041
4042 let search_dir = input.path.as_deref().unwrap_or(".");
4043 let search_path = resolve_read_path(search_dir, &self.cwd);
4044 let search_path = enforce_cwd_scope(&search_path, &self.cwd, "find")?;
4045 let search_path = strip_unc_prefix(search_path);
4046 let effective_limit = input.limit.unwrap_or(DEFAULT_FIND_LIMIT);
4047 let scan_limit = effective_limit.saturating_add(1);
4049
4050 if !search_path.exists() {
4051 return Err(Error::tool(
4052 "find",
4053 format!("Path not found: {}", search_path.display()),
4054 ));
4055 }
4056
4057 let fd_cmd = find_fd_binary().ok_or_else(|| {
4058 Error::tool(
4059 "find",
4060 "fd is not available (please install fd-find or fd)".to_string(),
4061 )
4062 })?;
4063
4064 let mut args: Vec<String> = vec![
4066 "--glob".to_string(),
4067 "--color=never".to_string(),
4068 "--hidden".to_string(),
4069 "--max-results".to_string(),
4070 scan_limit.to_string(),
4071 ];
4072
4073 let workspace_gitignore = self.cwd.join(".gitignore");
4078 if workspace_gitignore.exists() {
4079 args.push("--ignore-file".to_string());
4080 args.push(workspace_gitignore.display().to_string());
4081 }
4082 let root_gitignore = search_path.join(".gitignore");
4083 if root_gitignore != workspace_gitignore && root_gitignore.exists() {
4084 args.push("--ignore-file".to_string());
4085 args.push(root_gitignore.display().to_string());
4086 }
4087
4088 args.push("--".to_string());
4089 args.push(input.pattern.clone());
4090 args.push(search_path.display().to_string());
4091
4092 let mut child = Command::new(fd_cmd)
4093 .args(args)
4094 .current_dir(&self.cwd)
4095 .stdin(Stdio::null())
4096 .stdout(Stdio::piped())
4097 .stderr(Stdio::piped())
4098 .spawn()
4099 .map_err(|e| Error::tool("find", format!("Failed to run fd: {e}")))?;
4100
4101 let stdout_pipe = child
4102 .stdout
4103 .take()
4104 .ok_or_else(|| Error::tool("find", "Missing stdout"))?;
4105 let stderr_pipe = child
4106 .stderr
4107 .take()
4108 .ok_or_else(|| Error::tool("find", "Missing stderr"))?;
4109
4110 let mut guard = ProcessGuard::new(child, ProcessCleanupMode::ChildOnly);
4111
4112 let stdout_handle = std::thread::spawn(move || -> std::result::Result<Vec<u8>, String> {
4113 read_to_end_capped_and_drain(stdout_pipe, READ_TOOL_MAX_BYTES)
4114 });
4115
4116 let stderr_handle = std::thread::spawn(move || -> std::result::Result<Vec<u8>, String> {
4117 read_to_end_capped_and_drain(stderr_pipe, READ_TOOL_MAX_BYTES)
4118 });
4119
4120 let tick = Duration::from_millis(10);
4121 let start_time = std::time::Instant::now();
4122 let timeout_ms = 60_000; let mut timed_out = false;
4124 let mut cx_cancelled = false;
4125
4126 let status = loop {
4127 let agent_cx = AgentCx::for_current_or_request();
4128 let cx = agent_cx.cx();
4129 if cx.checkpoint().is_err() {
4130 cx_cancelled = true;
4131 let _ = guard.kill();
4132 break None;
4133 }
4134
4135 match guard.try_wait_child() {
4137 Ok(Some(status)) => break Some(status),
4138 Ok(None) => {
4139 if start_time.elapsed().as_millis() > timeout_ms {
4140 timed_out = true;
4141 let _ = guard.kill();
4142 break None;
4143 }
4144 let now = cx.timer_driver().map_or_else(wall_now, |timer| timer.now());
4145 sleep(now, tick).await;
4146 }
4147 Err(e) => return Err(Error::tool("find", e.to_string())),
4148 }
4149 };
4150
4151 let stdout_bytes = stdout_handle
4152 .join()
4153 .map_err(|_| Error::tool("find", "fd stdout reader thread panicked"))?
4154 .map_err(|err| Error::tool("find", format!("Failed to read fd stdout: {err}")))?;
4155 let stderr_bytes = stderr_handle
4156 .join()
4157 .map_err(|_| Error::tool("find", "fd stderr reader thread panicked"))?
4158 .map_err(|err| Error::tool("find", format!("Failed to read fd stderr: {err}")))?;
4159
4160 if cx_cancelled {
4161 return Err(Error::tool("find", "Command cancelled"));
4162 }
4163 if timed_out {
4164 return Err(Error::tool("find", "Command timed out after 60 seconds"));
4165 }
4166 let status = status.expect("fd exit status after successful completion");
4167
4168 let mut stdout = String::from_utf8_lossy(&stdout_bytes).trim().to_string();
4169 if stdout_bytes.len() as u64 > READ_TOOL_MAX_BYTES {
4170 stdout.push_str("\n... [stdout truncated] ...");
4171 }
4172 let mut stderr = String::from_utf8_lossy(&stderr_bytes).trim().to_string();
4173 if stderr_bytes.len() as u64 > READ_TOOL_MAX_BYTES {
4174 stderr.push_str("\n... [stderr truncated] ...");
4175 }
4176
4177 if !status.success() && stdout.is_empty() {
4178 if status.code() == Some(1) && stderr.is_empty() {
4179 } else {
4181 let code = status.code().unwrap_or(1);
4182 let msg = if stderr.is_empty() {
4183 format!("fd exited with code {code}")
4184 } else {
4185 stderr
4186 };
4187 return Err(Error::tool("find", msg));
4188 }
4189 }
4190
4191 if stdout.is_empty() {
4192 return Ok(ToolOutput {
4193 content: vec![ContentBlock::Text(TextContent::new(
4194 "No files found matching pattern",
4195 ))],
4196 details: None,
4197 is_error: false,
4198 });
4199 }
4200
4201 let mut entries: Vec<FindEntry> = Vec::new();
4202 for raw_line in stdout.lines() {
4203 let line = raw_line.trim_end_matches('\r').trim();
4204 if line.is_empty() {
4205 continue;
4206 }
4207
4208 let clean = strip_unc_prefix(PathBuf::from(line));
4211 let line_path = clean.as_path();
4212 let mut rel = if line_path.is_absolute() {
4213 line_path.strip_prefix(&search_path).map_or_else(
4214 |_| line_path.to_string_lossy().to_string(),
4215 |stripped| stripped.to_string_lossy().to_string(),
4216 )
4217 } else {
4218 line_path.to_string_lossy().to_string()
4219 };
4220
4221 let full_path = if line_path.is_absolute() {
4222 line_path.to_path_buf()
4223 } else {
4224 search_path.join(line_path)
4225 };
4226 if full_path.is_dir() && !rel.ends_with('/') {
4227 rel.push('/');
4228 }
4229
4230 let modified = std::fs::metadata(&full_path)
4231 .and_then(|meta| meta.modified())
4232 .ok();
4233 entries.push(FindEntry { rel, modified });
4234 }
4235
4236 entries.sort_by(|a, b| {
4237 let ordering = match (&a.modified, &b.modified) {
4238 (Some(a_time), Some(b_time)) => b_time.cmp(a_time),
4239 (Some(_), None) => Ordering::Less,
4240 (None, Some(_)) => Ordering::Greater,
4241 (None, None) => Ordering::Equal,
4242 };
4243 ordering.then_with(|| {
4244 let a_lower = a.rel.to_lowercase();
4245 let b_lower = b.rel.to_lowercase();
4246 a_lower.cmp(&b_lower).then_with(|| a.rel.cmp(&b.rel))
4247 })
4248 });
4249
4250 let mut relativized: Vec<String> = entries.into_iter().map(|entry| entry.rel).collect();
4251
4252 if relativized.is_empty() {
4253 return Ok(ToolOutput {
4254 content: vec![ContentBlock::Text(TextContent::new(
4255 "No files found matching pattern",
4256 ))],
4257 details: None,
4258 is_error: false,
4259 });
4260 }
4261
4262 let result_limit_reached = relativized.len() > effective_limit;
4263 if result_limit_reached {
4264 relativized.truncate(effective_limit);
4265 }
4266 let raw_output = relativized.join("\n");
4267 let mut truncation = truncate_head(raw_output, usize::MAX, DEFAULT_MAX_BYTES);
4268
4269 let mut result_output = std::mem::take(&mut truncation.content);
4270 let mut notices: Vec<String> = Vec::new();
4271 let mut details_map = serde_json::Map::new();
4272
4273 if !status.success() {
4274 let code = status.code().unwrap_or(1);
4275 notices.push(format!("fd exited with code {code}"));
4276 }
4277
4278 if result_limit_reached {
4279 notices.push(format!(
4280 "{effective_limit} results limit reached. Use limit={} for more, or refine pattern",
4281 effective_limit * 2
4282 ));
4283 details_map.insert(
4284 "resultLimitReached".to_string(),
4285 serde_json::Value::Number(serde_json::Number::from(effective_limit)),
4286 );
4287 }
4288
4289 if truncation.truncated {
4290 notices.push(format!("{} limit reached", format_size(DEFAULT_MAX_BYTES)));
4291 details_map.insert("truncation".to_string(), serde_json::to_value(truncation)?);
4292 }
4293
4294 if !notices.is_empty() {
4295 let _ = write!(result_output, "\n\n[{}]", notices.join(". "));
4296 }
4297
4298 let details = if details_map.is_empty() {
4299 None
4300 } else {
4301 Some(serde_json::Value::Object(details_map))
4302 };
4303
4304 Ok(ToolOutput {
4305 content: vec![ContentBlock::Text(TextContent::new(result_output))],
4306 details,
4307 is_error: false,
4308 })
4309 }
4310}
4311
4312#[derive(Debug, Deserialize)]
4318#[serde(rename_all = "camelCase")]
4319struct LsInput {
4320 path: Option<String>,
4321 limit: Option<usize>,
4322}
4323
4324pub struct LsTool {
4325 cwd: PathBuf,
4326}
4327
4328impl LsTool {
4329 pub fn new(cwd: &Path) -> Self {
4330 Self {
4331 cwd: cwd.to_path_buf(),
4332 }
4333 }
4334}
4335
4336#[async_trait]
4337#[allow(clippy::unnecessary_literal_bound, clippy::too_many_lines)]
4338impl Tool for LsTool {
4339 fn name(&self) -> &str {
4340 "ls"
4341 }
4342 fn label(&self) -> &str {
4343 "ls"
4344 }
4345 fn description(&self) -> &str {
4346 "List directory contents. Returns entries sorted alphabetically, with '/' suffix for directories. Includes dotfiles. Output is truncated to 500 entries or 1MB (whichever is hit first)."
4347 }
4348
4349 fn parameters(&self) -> serde_json::Value {
4350 serde_json::json!({
4351 "type": "object",
4352 "properties": {
4353 "path": {
4354 "type": "string",
4355 "description": "Directory to list (default: current directory)"
4356 },
4357 "limit": {
4358 "type": "integer",
4359 "description": "Maximum number of entries to return (default: 500)"
4360 }
4361 }
4362 })
4363 }
4364
4365 fn is_read_only(&self) -> bool {
4366 true
4367 }
4368
4369 async fn execute(
4370 &self,
4371 _tool_call_id: &str,
4372 input: serde_json::Value,
4373 _on_update: Option<Box<dyn Fn(ToolUpdate) + Send + Sync>>,
4374 ) -> Result<ToolOutput> {
4375 let input: LsInput =
4376 serde_json::from_value(input).map_err(|e| Error::validation(e.to_string()))?;
4377
4378 if matches!(input.limit, Some(0)) {
4379 return Err(Error::validation(
4380 "`limit` must be greater than 0".to_string(),
4381 ));
4382 }
4383
4384 let dir_path = input
4385 .path
4386 .as_ref()
4387 .map_or_else(|| self.cwd.clone(), |p| resolve_read_path(p, &self.cwd));
4388 let dir_path = enforce_cwd_scope(&dir_path, &self.cwd, "list")?;
4389
4390 let effective_limit = input.limit.unwrap_or(DEFAULT_LS_LIMIT);
4391
4392 if !dir_path.exists() {
4393 return Err(Error::tool(
4394 "ls",
4395 format!("Path not found: {}", dir_path.display()),
4396 ));
4397 }
4398 if !dir_path.is_dir() {
4399 return Err(Error::tool(
4400 "ls",
4401 format!("Not a directory: {}", dir_path.display()),
4402 ));
4403 }
4404
4405 let mut entries = Vec::new();
4406 let mut read_dir = asupersync::fs::read_dir(&dir_path)
4407 .await
4408 .map_err(|e| Error::tool("ls", format!("Cannot read directory: {e}")))?;
4409
4410 let mut scan_limit_reached = false;
4411 while let Some(entry) = read_dir
4412 .next_entry()
4413 .await
4414 .map_err(|e| Error::tool("ls", format!("Cannot read directory entry: {e}")))?
4415 {
4416 if entries.len() >= LS_SCAN_HARD_LIMIT {
4417 scan_limit_reached = true;
4418 break;
4419 }
4420 let name = entry.file_name().to_string_lossy().to_string();
4421 let is_dir = match entry.file_type().await {
4424 Ok(ft) => {
4425 if ft.is_dir() {
4426 true
4427 } else if ft.is_symlink() {
4428 entry.metadata().await.is_ok_and(|meta| meta.is_dir())
4430 } else {
4431 false
4432 }
4433 }
4434 Err(_) => entry.metadata().await.is_ok_and(|meta| meta.is_dir()),
4435 };
4436 entries.push((name, is_dir));
4437 }
4438
4439 entries.sort_by_cached_key(|(a, _)| a.to_lowercase());
4441
4442 let mut results: Vec<String> = Vec::new();
4443 let mut entry_limit_reached = false;
4444
4445 for (entry, is_dir) in entries {
4446 if results.len() >= effective_limit {
4447 entry_limit_reached = true;
4448 break;
4449 }
4450 if is_dir {
4451 results.push(format!("{entry}/"));
4452 } else {
4453 results.push(entry);
4454 }
4455 }
4456
4457 if results.is_empty() {
4458 return Ok(ToolOutput {
4459 content: vec![ContentBlock::Text(TextContent::new("(empty directory)"))],
4460 details: None,
4461 is_error: false,
4462 });
4463 }
4464
4465 let raw_output = results.join("\n");
4467 let mut truncation = truncate_head(raw_output, usize::MAX, DEFAULT_MAX_BYTES);
4468
4469 let mut output = std::mem::take(&mut truncation.content);
4470 let mut details_map = serde_json::Map::new();
4471 let mut notices: Vec<String> = Vec::new();
4472
4473 if entry_limit_reached {
4474 notices.push(format!(
4475 "{effective_limit} entries limit reached. Use limit={} for more",
4476 effective_limit * 2
4477 ));
4478 details_map.insert(
4479 "entryLimitReached".to_string(),
4480 serde_json::Value::Number(serde_json::Number::from(effective_limit)),
4481 );
4482 }
4483
4484 if scan_limit_reached {
4485 notices.push(format!(
4486 "Directory scan limited to {LS_SCAN_HARD_LIMIT} entries to prevent system overload"
4487 ));
4488 details_map.insert(
4489 "scanLimitReached".to_string(),
4490 serde_json::Value::Number(serde_json::Number::from(LS_SCAN_HARD_LIMIT)),
4491 );
4492 }
4493
4494 if truncation.truncated {
4495 notices.push(format!("{} limit reached", format_size(DEFAULT_MAX_BYTES)));
4496 details_map.insert("truncation".to_string(), serde_json::to_value(truncation)?);
4497 }
4498
4499 if !notices.is_empty() {
4500 let _ = write!(output, "\n\n[{}]", notices.join(". "));
4501 }
4502
4503 let details = if details_map.is_empty() {
4504 None
4505 } else {
4506 Some(serde_json::Value::Object(details_map))
4507 };
4508
4509 Ok(ToolOutput {
4510 content: vec![ContentBlock::Text(TextContent::new(output))],
4511 details,
4512 is_error: false,
4513 })
4514 }
4515}
4516
4517pub fn cleanup_temp_files() {
4527 std::thread::spawn(|| {
4529 let temp_dir = std::env::temp_dir();
4530 let Ok(entries) = std::fs::read_dir(&temp_dir) else {
4531 return;
4532 };
4533
4534 let now = std::time::SystemTime::now();
4535 let threshold = now
4536 .checked_sub(Duration::from_secs(24 * 60 * 60))
4537 .unwrap_or(std::time::SystemTime::UNIX_EPOCH);
4538
4539 for entry in entries.flatten() {
4540 let path = entry.path();
4541 if !path.is_file() {
4542 continue;
4543 }
4544
4545 let Some(file_name) = path.file_name().and_then(|n| n.to_str()) else {
4546 continue;
4547 };
4548
4549 if (file_name.starts_with("pi-bash-") || file_name.starts_with("pi-rpc-bash-"))
4551 && std::path::Path::new(file_name)
4552 .extension()
4553 .is_some_and(|ext| ext.eq_ignore_ascii_case("log"))
4554 {
4555 if let Ok(metadata) = entry.metadata() {
4556 if let Ok(modified) = metadata.modified() {
4557 if modified < threshold {
4558 if let Err(e) = std::fs::remove_file(&path) {
4559 tracing::debug!(
4561 "Failed to remove temp file {}: {}",
4562 path.display(),
4563 e
4564 );
4565 }
4566 }
4567 }
4568 }
4569 }
4570 }
4571 });
4572}
4573
4574fn rg_available() -> bool {
4579 find_rg_binary().is_some()
4580}
4581
4582fn pump_stream<R: Read + Send + 'static>(mut reader: R, tx: &mpsc::SyncSender<Vec<u8>>) {
4583 let mut buf = vec![0u8; 8192];
4584 loop {
4585 match reader.read(&mut buf) {
4586 Ok(0) => break,
4587 Ok(n) => {
4588 if tx.send(buf[..n].to_vec()).is_err() {
4589 break;
4590 }
4591 }
4592 Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => {}
4593 Err(_) => break,
4594 }
4595 }
4596}
4597
4598pub(crate) fn read_to_end_capped_and_drain<R: Read>(
4602 mut reader: R,
4603 max_bytes: u64,
4604) -> std::result::Result<Vec<u8>, String> {
4605 let capture_limit = usize::try_from(max_bytes.saturating_add(1)).unwrap_or(usize::MAX);
4606 let mut captured = Vec::with_capacity(capture_limit.min(8192));
4607 let mut chunk = [0u8; 8192];
4608
4609 loop {
4610 match reader.read(&mut chunk) {
4611 Ok(0) => break,
4612 Ok(read) => {
4613 let remaining = capture_limit.saturating_sub(captured.len());
4614 if remaining > 0 {
4615 let keep = remaining.min(read);
4616 captured.extend_from_slice(&chunk[..keep]);
4617 }
4618 }
4619 Err(err) if err.kind() == std::io::ErrorKind::Interrupted => {}
4620 Err(err) => return Err(err.to_string()),
4621 }
4622 }
4623
4624 Ok(captured)
4625}
4626
4627#[allow(clippy::needless_pass_by_ref_mut)]
4631async fn drain_bash_output(
4632 rx: &mut mpsc::Receiver<Vec<u8>>,
4633 bash_output: &mut BashOutputState,
4634 cx: &AgentCx,
4635 drain_deadline: asupersync::Time,
4636 tick: Duration,
4637 allow_cancellation: bool,
4638) -> Result<bool> {
4639 loop {
4640 match rx.try_recv() {
4641 Ok(chunk) => ingest_bash_chunk(chunk, bash_output).await?,
4642 Err(mpsc::TryRecvError::Empty) => {
4643 let now = cx
4644 .cx()
4645 .timer_driver()
4646 .map_or_else(wall_now, |timer| timer.now());
4647 if now >= drain_deadline {
4648 return Ok(false);
4649 }
4650 if allow_cancellation && cx.checkpoint().is_err() {
4651 return Ok(true);
4652 }
4653 sleep(now, tick).await;
4654 }
4655 Err(mpsc::TryRecvError::Disconnected) => return Ok(false),
4656 }
4657 }
4658}
4659
4660fn concat_chunks(chunks: &VecDeque<Vec<u8>>) -> Vec<u8> {
4661 let total: usize = chunks.iter().map(Vec::len).sum();
4662 let mut out = Vec::with_capacity(total);
4663 for chunk in chunks {
4664 out.extend_from_slice(chunk);
4665 }
4666 out
4667}
4668
4669struct BashOutputState {
4670 total_bytes: usize,
4671 line_count: usize,
4672 last_byte_was_newline: bool,
4673 start_time: std::time::Instant,
4674 timeout_ms: Option<u64>,
4675 temp_file_path: Option<PathBuf>,
4676 temp_file: Option<asupersync::fs::File>,
4677 chunks: VecDeque<Vec<u8>>,
4678 chunks_bytes: usize,
4679 max_chunks_bytes: usize,
4680 spill_failed: bool,
4681}
4682
4683impl BashOutputState {
4684 fn new(max_chunks_bytes: usize) -> Self {
4685 Self {
4686 total_bytes: 0,
4687 line_count: 0,
4688 last_byte_was_newline: false,
4689 start_time: std::time::Instant::now(),
4690 timeout_ms: None,
4691 temp_file_path: None,
4692 temp_file: None,
4693 chunks: VecDeque::new(),
4694 chunks_bytes: 0,
4695 max_chunks_bytes,
4696 spill_failed: false,
4697 }
4698 }
4699
4700 fn abandon_spill_file(&mut self) {
4701 self.spill_failed = true;
4702 self.temp_file = None;
4703 if let Some(path) = self.temp_file_path.take() {
4704 if let Err(e) = std::fs::remove_file(&path)
4705 && e.kind() != std::io::ErrorKind::NotFound
4706 {
4707 tracing::debug!(
4708 "Failed to remove incomplete bash spill file {}: {}",
4709 path.display(),
4710 e
4711 );
4712 }
4713 }
4714 }
4715}
4716
4717#[allow(clippy::too_many_lines)]
4718async fn ingest_bash_chunk(chunk: Vec<u8>, state: &mut BashOutputState) -> Result<()> {
4719 if chunk.is_empty() {
4720 return Ok(());
4721 }
4722
4723 state.last_byte_was_newline = chunk.last().is_some_and(|byte| *byte == b'\n');
4724 state.total_bytes = state.total_bytes.saturating_add(chunk.len());
4725 state.line_count = state
4726 .line_count
4727 .saturating_add(memchr::memchr_iter(b'\n', &chunk).count());
4728
4729 if state.total_bytes > DEFAULT_MAX_BYTES
4730 && state.temp_file.is_none()
4731 && state.temp_file_path.is_none()
4732 && !state.spill_failed
4733 {
4734 let id_full = Uuid::new_v4().simple().to_string();
4735 let id = &id_full[..16];
4736 let path = std::env::temp_dir().join(format!("pi-bash-{id}.log"));
4737
4738 let path_clone = path.clone();
4742 let expected_inode: Option<u64> =
4743 asupersync::runtime::spawn_blocking_io(move || -> std::io::Result<Option<u64>> {
4744 let mut options = std::fs::OpenOptions::new();
4745 options.write(true).create_new(true);
4746
4747 #[cfg(unix)]
4748 {
4749 use std::os::unix::fs::OpenOptionsExt;
4750 options.mode(0o600);
4751 }
4752
4753 match options.open(&path_clone) {
4754 Ok(file) => {
4755 #[cfg(unix)]
4756 {
4757 use std::os::unix::fs::MetadataExt;
4758 Ok(file.metadata().ok().map(|m| m.ino()))
4759 }
4760 #[cfg(not(unix))]
4761 {
4762 drop(file);
4763 Ok(None)
4764 }
4765 }
4766 Err(e) => {
4767 tracing::warn!("Failed to create bash temp file: {e}");
4768 Ok(None)
4769 }
4770 }
4771 })
4772 .await
4773 .unwrap_or(None);
4774
4775 if expected_inode.is_some() || !cfg!(unix) {
4776 match asupersync::fs::OpenOptions::new()
4777 .append(true)
4778 .open(&path)
4779 .await
4780 {
4781 Ok(mut file) => {
4782 #[cfg_attr(not(unix), allow(unused_mut))]
4783 let mut identity_match = true;
4784 #[cfg(unix)]
4785 if let Some(expected) = expected_inode {
4786 use std::os::unix::fs::MetadataExt;
4787 match file.metadata().await {
4788 Ok(meta) => {
4789 if meta.ino() != expected {
4790 tracing::warn!(
4791 "Temp file identity mismatch (possible TOCTOU attack)"
4792 );
4793 identity_match = false;
4794 }
4795 }
4796 Err(e) => {
4797 tracing::warn!("Failed to stat temp file: {e}");
4798 identity_match = false;
4799 }
4800 }
4801 }
4802
4803 if identity_match {
4804 let mut failed_flush = false;
4806 for existing in &state.chunks {
4807 if let Err(e) = file.write_all(existing).await {
4808 tracing::warn!("Failed to flush bash chunk to temp file: {e}");
4809 failed_flush = true;
4810 break;
4811 }
4812 }
4813
4814 state.temp_file_path = Some(path);
4815 if failed_flush {
4816 state.abandon_spill_file();
4817 } else {
4818 state.temp_file = Some(file);
4819 }
4820 } else {
4821 state.temp_file_path = Some(path);
4822 state.abandon_spill_file();
4823 }
4824 }
4825 Err(e) => {
4826 tracing::warn!("Failed to open temp file async: {e}");
4827 state.temp_file_path = Some(path);
4828 state.abandon_spill_file();
4829 }
4830 }
4831 } else {
4832 state.spill_failed = true;
4833 }
4834 }
4835
4836 let mut close_spill_file = false;
4837 if let Some(file) = state.temp_file.as_mut() {
4838 let mut abandon_spill_file = false;
4839 if state.total_bytes <= BASH_FILE_LIMIT_BYTES {
4840 if let Err(e) = file.write_all(&chunk).await {
4841 tracing::warn!("Failed to write bash chunk to temp file: {e}");
4842 abandon_spill_file = true;
4843 }
4844 } else {
4845 if !state.spill_failed {
4847 tracing::warn!("Bash output exceeded hard limit; stopping file log");
4848 close_spill_file = true;
4849 }
4850 }
4851 if abandon_spill_file {
4852 state.abandon_spill_file();
4853 }
4854 }
4855 if close_spill_file {
4856 state.temp_file = None;
4857 }
4858
4859 state.chunks_bytes = state.chunks_bytes.saturating_add(chunk.len());
4860 state.chunks.push_back(chunk);
4861 while state.chunks_bytes > state.max_chunks_bytes && state.chunks.len() > 1 {
4862 if let Some(front) = state.chunks.pop_front() {
4863 state.chunks_bytes = state.chunks_bytes.saturating_sub(front.len());
4864 }
4865 }
4866 Ok(())
4867}
4868
4869const fn line_count_from_newline_count(
4870 total_bytes: usize,
4871 newline_count: usize,
4872 last_byte_was_newline: bool,
4873) -> usize {
4874 if total_bytes == 0 {
4875 0
4876 } else if last_byte_was_newline {
4877 newline_count
4878 } else {
4879 newline_count.saturating_add(1)
4880 }
4881}
4882
4883fn emit_bash_update(
4884 state: &BashOutputState,
4885 on_update: Option<&(dyn Fn(ToolUpdate) + Send + Sync)>,
4886) -> Result<()> {
4887 if let Some(callback) = on_update {
4888 let raw = concat_chunks(&state.chunks);
4889 let full_text = String::from_utf8_lossy(&raw);
4890 let truncation =
4891 truncate_tail(full_text.into_owned(), DEFAULT_MAX_LINES, DEFAULT_MAX_BYTES);
4892
4893 let elapsed_ms = state.start_time.elapsed().as_millis();
4898 let line_count = line_count_from_newline_count(
4899 state.total_bytes,
4900 state.line_count,
4901 state.last_byte_was_newline,
4902 );
4903 let mut details = serde_json::json!({
4904 "progress": {
4905 "elapsedMs": elapsed_ms,
4906 "lineCount": line_count,
4907 "byteCount": state.total_bytes
4908 }
4909 });
4910 let Some(details_map) = details.as_object_mut() else {
4911 return Ok(());
4912 };
4913
4914 if let Some(timeout) = state.timeout_ms {
4915 if let Some(progress) = details_map
4916 .get_mut("progress")
4917 .and_then(|v| v.as_object_mut())
4918 {
4919 progress.insert("timeoutMs".into(), serde_json::json!(timeout));
4920 }
4921 }
4922 if truncation.truncated {
4923 details_map.insert("truncation".into(), serde_json::to_value(&truncation)?);
4924 }
4925 if let Some(path) = state.temp_file_path.as_ref() {
4926 details_map.insert(
4927 "fullOutputPath".into(),
4928 serde_json::Value::String(path.display().to_string()),
4929 );
4930 }
4931
4932 callback(ToolUpdate {
4933 content: vec![ContentBlock::Text(TextContent::new(truncation.content))],
4934 details: Some(details),
4935 });
4936 }
4937 Ok(())
4938}
4939
4940pub(crate) struct ProcessGuard {
4941 child: Option<std::process::Child>,
4942 cleanup_mode: ProcessCleanupMode,
4943}
4944
4945#[derive(Clone, Copy, Debug, Eq, PartialEq)]
4946pub(crate) enum ProcessCleanupMode {
4947 ChildOnly,
4948 ProcessGroupTree,
4949}
4950
4951impl ProcessGuard {
4952 pub(crate) const fn new(child: std::process::Child, cleanup_mode: ProcessCleanupMode) -> Self {
4953 Self {
4954 child: Some(child),
4955 cleanup_mode,
4956 }
4957 }
4958
4959 pub(crate) fn try_wait_child(&mut self) -> std::io::Result<Option<std::process::ExitStatus>> {
4960 self.child
4961 .as_mut()
4962 .map_or(Ok(None), std::process::Child::try_wait)
4963 }
4964
4965 pub(crate) fn kill(&mut self) -> Option<std::process::ExitStatus> {
4966 if let Some(mut child) = self.child.take() {
4967 cleanup_child(Some(child.id()), self.cleanup_mode);
4968 let _ = child.kill();
4969 std::thread::spawn(move || {
4970 let _ = child.wait();
4971 });
4972 return None;
4975 }
4976 None
4977 }
4978
4979 pub(crate) fn wait(&mut self) -> std::io::Result<std::process::ExitStatus> {
4980 if let Some(mut child) = self.child.take() {
4981 return child.wait();
4982 }
4983 Err(std::io::Error::other("Already waited"))
4984 }
4985}
4986
4987impl Drop for ProcessGuard {
4988 fn drop(&mut self) {
4989 if let Some(mut child) = self.child.take() {
4990 match child.try_wait() {
4991 Ok(None) => {}
4992 Ok(Some(_)) | Err(_) => return,
4993 }
4994 let cleanup_mode = self.cleanup_mode;
4995 std::thread::spawn(move || {
4996 cleanup_child(Some(child.id()), cleanup_mode);
4997 let _ = child.kill();
4998 let _ = child.wait();
4999 });
5000 }
5001 }
5002}
5003
5004fn cleanup_child(pid: Option<u32>, cleanup_mode: ProcessCleanupMode) {
5005 if cleanup_mode == ProcessCleanupMode::ProcessGroupTree {
5006 kill_process_group_tree(pid);
5007 }
5008}
5009
5010pub fn kill_process_tree(pid: Option<u32>) {
5011 kill_process_tree_with(pid, sysinfo::Signal::Kill, false);
5012}
5013
5014pub(crate) fn kill_process_group_tree(pid: Option<u32>) {
5015 kill_process_tree_with(pid, sysinfo::Signal::Kill, true);
5016}
5017
5018fn terminate_process_group_tree(pid: Option<u32>) {
5019 kill_process_tree_with(pid, sysinfo::Signal::Term, true);
5020}
5021
5022fn kill_process_tree_with(pid: Option<u32>, signal: sysinfo::Signal, include_process_group: bool) {
5023 let Some(pid) = pid else {
5024 return;
5025 };
5026
5027 let root = sysinfo::Pid::from_u32(pid);
5028
5029 let mut sys = sysinfo::System::new();
5030 sys.refresh_processes(sysinfo::ProcessesToUpdate::All, true);
5031
5032 let mut children_map: HashMap<sysinfo::Pid, Vec<sysinfo::Pid>> = HashMap::new();
5033 for (p, proc_) in sys.processes() {
5034 if let Some(parent) = proc_.parent() {
5035 children_map.entry(parent).or_default().push(*p);
5036 }
5037 }
5038
5039 let mut to_kill = Vec::new();
5040 let mut visited = std::collections::HashSet::new();
5041 collect_process_tree(root, &children_map, &mut to_kill, &mut visited);
5042
5043 if include_process_group {
5044 #[cfg(unix)]
5048 {
5049 let sig_num = match signal {
5050 sysinfo::Signal::Kill => "9",
5051 _ => "15",
5052 };
5053 let _ = Command::new("kill")
5054 .arg(format!("-{sig_num}"))
5055 .arg("--")
5056 .arg(format!("-{pid}"))
5057 .stdin(Stdio::null())
5058 .stdout(Stdio::null())
5059 .stderr(Stdio::null())
5060 .status();
5061 }
5062 }
5063
5064 for pid in to_kill.into_iter().rev() {
5066 if let Some(proc_) = sys.process(pid) {
5067 match proc_.kill_with(signal) {
5068 Some(true) => {}
5069 Some(false) | None => {
5070 let _ = proc_.kill();
5071 }
5072 }
5073 }
5074 }
5075}
5076
5077fn collect_process_tree(
5078 pid: sysinfo::Pid,
5079 children_map: &HashMap<sysinfo::Pid, Vec<sysinfo::Pid>>,
5080 out: &mut Vec<sysinfo::Pid>,
5081 visited: &mut std::collections::HashSet<sysinfo::Pid>,
5082) {
5083 if !visited.insert(pid) {
5084 return;
5085 }
5086 out.push(pid);
5087 if let Some(children) = children_map.get(&pid) {
5088 for child in children {
5089 collect_process_tree(*child, children_map, out, visited);
5090 }
5091 }
5092}
5093
5094pub(crate) fn isolate_command_process_group(command: &mut Command) {
5095 #[cfg(unix)]
5096 {
5097 use std::os::unix::process::CommandExt as _;
5098 command.process_group(0);
5099 }
5100
5101 #[cfg(not(unix))]
5102 {
5103 let _ = command;
5104 }
5105}
5106
5107fn format_grep_path(file_path: &Path, cwd: &Path) -> String {
5108 if let Ok(rel) = file_path.strip_prefix(cwd) {
5109 let rel_str = rel.display().to_string().replace('\\', "/");
5110 if !rel_str.is_empty() {
5111 return rel_str;
5112 }
5113 }
5114 file_path.display().to_string().replace('\\', "/")
5115}
5116
5117async fn get_file_lines_async<'a>(
5118 path: &Path,
5119 cache: &'a mut HashMap<PathBuf, Vec<String>>,
5120) -> &'a [String] {
5121 if !cache.contains_key(path) {
5122 if let Ok(meta) = asupersync::fs::metadata(path).await {
5124 if !meta.is_file() || meta.len() > 10 * 1024 * 1024 {
5125 cache.insert(path.to_path_buf(), Vec::new());
5126 return &[];
5127 }
5128 } else {
5129 cache.insert(path.to_path_buf(), Vec::new());
5130 return &[];
5131 }
5132
5133 let bytes = match asupersync::fs::read(path).await {
5135 Ok(bytes) => bytes,
5136 Err(err) => {
5137 tracing::debug!("Failed to read grep file {}: {err}", path.display());
5138 cache.insert(path.to_path_buf(), Vec::new());
5139 return &[];
5140 }
5141 };
5142 let content = String::from_utf8_lossy(&bytes);
5143 let mut lines = Vec::new();
5144 for line in content.split('\n') {
5145 let trimmed = line.strip_suffix('\r').unwrap_or(line);
5146 for piece in trimmed.split('\r') {
5147 lines.push(piece.to_string());
5148 }
5149 }
5150 if content.ends_with('\n') && lines.last().is_some_and(std::string::String::is_empty) {
5151 lines.pop();
5152 }
5153 cache.insert(path.to_path_buf(), lines);
5154 }
5155 cache.get(path).unwrap().as_slice()
5156}
5157
5158fn find_fd_binary() -> Option<&'static str> {
5159 static BINARY: OnceLock<Option<&'static str>> = OnceLock::new();
5160 *BINARY.get_or_init(|| {
5161 if std::process::Command::new("fd")
5162 .arg("--version")
5163 .stdout(Stdio::null())
5164 .stderr(Stdio::null())
5165 .status()
5166 .is_ok()
5167 {
5168 return Some("fd");
5169 }
5170 if std::process::Command::new("fdfind")
5171 .arg("--version")
5172 .stdout(Stdio::null())
5173 .stderr(Stdio::null())
5174 .status()
5175 .is_ok()
5176 {
5177 return Some("fdfind");
5178 }
5179 None
5180 })
5181}
5182
5183fn find_rg_binary() -> Option<&'static str> {
5184 static BINARY: OnceLock<Option<&'static str>> = OnceLock::new();
5185 *BINARY.get_or_init(|| {
5186 if std::process::Command::new("rg")
5187 .arg("--version")
5188 .stdout(Stdio::null())
5189 .stderr(Stdio::null())
5190 .status()
5191 .is_ok()
5192 {
5193 return Some("rg");
5194 }
5195 if std::process::Command::new("ripgrep")
5196 .arg("--version")
5197 .stdout(Stdio::null())
5198 .stderr(Stdio::null())
5199 .status()
5200 .is_ok()
5201 {
5202 return Some("ripgrep");
5203 }
5204 None
5205 })
5206}
5207
5208const NIBBLE_STR: &[u8; 16] = b"ZPMQVRWSNKTXJBYH";
5214
5215static HASHLINE_DICT: OnceLock<[[u8; 2]; 256]> = OnceLock::new();
5218
5219fn hashline_dict() -> &'static [[u8; 2]; 256] {
5220 HASHLINE_DICT.get_or_init(|| {
5221 let mut dict = [[0u8; 2]; 256];
5222 for i in 0..256 {
5223 dict[i] = [NIBBLE_STR[i & 0x0F], NIBBLE_STR[(i >> 4) & 0x0F]];
5224 }
5225 dict
5226 })
5227}
5228
5229fn compute_line_hash(line_idx: usize, line: &str) -> [u8; 2] {
5239 let line = line.strip_suffix('\r').unwrap_or(line);
5240 let significant: String = line.chars().filter(|c| !c.is_whitespace()).collect();
5242 let has_alnum = significant.chars().any(char::is_alphanumeric);
5243 let seed = if has_alnum {
5244 0
5245 } else {
5246 #[allow(clippy::cast_possible_truncation)]
5247 let s = line_idx as u32;
5248 s
5249 };
5250 let hash = xxhash_rust::xxh32::xxh32(significant.as_bytes(), seed);
5251 let byte = (hash & 0xFF) as usize;
5252 hashline_dict()[byte]
5253}
5254
5255fn format_hashline_tag(line_idx: usize, line: &str) -> String {
5257 let h = compute_line_hash(line_idx, line);
5258 format!("{}#{}{}", line_idx + 1, h[0] as char, h[1] as char)
5259}
5260
5261fn format_hashline_tag_with_bom(line_idx: usize, line: &str, had_bom: bool) -> String {
5263 let h = compute_line_hash_with_bom(line_idx, line, had_bom);
5264 format!("{}#{}{}", line_idx + 1, h[0] as char, h[1] as char)
5265}
5266
5267fn compute_line_hash_with_bom(line_idx: usize, line: &str, had_bom: bool) -> [u8; 2] {
5268 if had_bom && line_idx == 0 {
5269 let mut with_bom = String::with_capacity(line.len().saturating_add(1));
5270 with_bom.push('\u{FEFF}');
5271 with_bom.push_str(line);
5272 compute_line_hash(line_idx, &with_bom)
5273 } else {
5274 compute_line_hash(line_idx, line)
5275 }
5276}
5277
5278static HASHLINE_TAG_RE: OnceLock<regex::Regex> = OnceLock::new();
5281
5282fn hashline_tag_regex() -> &'static regex::Regex {
5283 HASHLINE_TAG_RE.get_or_init(|| {
5284 regex::Regex::new(r"^[\s>+\-]*(\d+)\s*#\s*([ZPMQVRWSNKTXJBYH]{2})")
5285 .expect("valid hashline regex")
5286 })
5287}
5288
5289fn parse_hashline_tag(ref_str: &str) -> std::result::Result<(usize, [u8; 2]), String> {
5291 let re = hashline_tag_regex();
5292 let caps = re
5293 .captures(ref_str)
5294 .ok_or_else(|| format!("Invalid hashline reference: {ref_str:?}"))?;
5295 let line_num: usize = caps[1]
5296 .parse()
5297 .map_err(|e| format!("Invalid line number in {ref_str:?}: {e}"))?;
5298 if line_num == 0 {
5299 return Err(format!("Line number must be >= 1, got 0 in {ref_str:?}"));
5300 }
5301 let hash_bytes = caps[2].as_bytes();
5302 Ok((line_num, [hash_bytes[0], hash_bytes[1]]))
5303}
5304
5305static HASHLINE_PREFIX_RE: OnceLock<regex::Regex> = OnceLock::new();
5308
5309fn strip_hashline_prefix(line: &str) -> &str {
5310 let re = HASHLINE_PREFIX_RE.get_or_init(|| {
5311 regex::Regex::new(r"^[\s>+\-]*\d+\s*#\s*[ZPMQVRWSNKTXJBYH]{2}\s*:")
5312 .expect("valid hashline prefix regex")
5313 });
5314 re.find(line).map_or(line, |m| &line[m.end()..])
5315}
5316
5317#[derive(Debug, Deserialize)]
5319#[serde(rename_all = "camelCase")]
5320struct HashlineEditInput {
5321 path: String,
5322 edits: Vec<HashlineOp>,
5323}
5324
5325#[derive(Debug, Clone, Deserialize)]
5327#[serde(rename_all = "camelCase")]
5328struct HashlineOp {
5329 op: String,
5331 pos: Option<String>,
5333 end: Option<String>,
5335 lines: Option<serde_json::Value>,
5337}
5338
5339impl HashlineOp {
5340 fn get_lines(&self) -> Vec<String> {
5342 match &self.lines {
5343 None | Some(serde_json::Value::Null) => vec![],
5344 Some(serde_json::Value::String(s)) => {
5345 normalize_to_lf(s).split('\n').map(String::from).collect()
5346 }
5347 Some(serde_json::Value::Array(arr)) => arr
5348 .iter()
5349 .map(|v| match v {
5350 serde_json::Value::String(s) => normalize_to_lf(s),
5351 other => normalize_to_lf(&other.to_string()),
5352 })
5353 .collect(),
5354 Some(other) => vec![normalize_to_lf(&other.to_string())],
5355 }
5356 }
5357}
5358
5359struct ResolvedEdit<'a> {
5361 op: &'a str,
5362 start: usize,
5364 end: usize,
5366 lines: Vec<String>,
5367}
5368
5369pub struct HashlineEditTool {
5370 cwd: PathBuf,
5371}
5372
5373impl HashlineEditTool {
5374 pub fn new(cwd: &Path) -> Self {
5375 Self {
5376 cwd: cwd.to_path_buf(),
5377 }
5378 }
5379}
5380
5381fn validate_line_ref(
5384 ref_str: &str,
5385 file_lines: &[&str],
5386 had_bom: bool,
5387) -> std::result::Result<usize, String> {
5388 let (line_num, expected_hash) = parse_hashline_tag(ref_str)?;
5389 let line_idx = line_num - 1;
5390 if line_idx >= file_lines.len() {
5391 return Err(format!(
5392 "Line {line_num} out of range (file has {} lines)",
5393 file_lines.len()
5394 ));
5395 }
5396 let actual_hash = compute_line_hash_with_bom(line_idx, file_lines[line_idx], had_bom);
5397 if actual_hash != expected_hash {
5398 let tag = format_hashline_tag_with_bom(line_idx, file_lines[line_idx], had_bom);
5399 return Err(format!(
5400 "Hash mismatch at line {line_num}: expected {}#{}{}, actual is {tag}",
5401 line_num, expected_hash[0] as char, expected_hash[1] as char,
5402 ));
5403 }
5404 Ok(line_idx)
5405}
5406
5407fn mismatch_context(file_lines: &[&str], line_idx: usize, context: usize, had_bom: bool) -> String {
5409 let start = line_idx.saturating_sub(context);
5410 let end = (line_idx + context + 1).min(file_lines.len());
5411 let mut out = String::new();
5412 for (i, &file_line) in file_lines.iter().enumerate().take(end).skip(start) {
5413 let tag = format_hashline_tag_with_bom(i, file_line, had_bom);
5414 if i == line_idx {
5415 let _ = writeln!(out, ">>> {tag}:{file_line}");
5416 } else {
5417 let _ = writeln!(out, " {tag}:{file_line}");
5418 }
5419 }
5420 out
5421}
5422
5423fn collect_mismatches(
5425 edits: &[HashlineOp],
5426 file_lines: &[&str],
5427 had_bom: bool,
5428) -> std::result::Result<(), String> {
5429 let mut errors = Vec::new();
5430 for edit in edits {
5431 if let Some(ref pos) = edit.pos {
5432 if let Err(e) = validate_line_ref(pos, file_lines, had_bom) {
5433 if let Ok((line_num, _)) = parse_hashline_tag(pos) {
5435 let idx = (line_num - 1).min(file_lines.len().saturating_sub(1));
5436 errors.push(format!(
5437 "{e}\n{}",
5438 mismatch_context(file_lines, idx, 2, had_bom)
5439 ));
5440 } else {
5441 errors.push(e);
5442 }
5443 }
5444 }
5445 if let Some(ref end) = edit.end {
5446 if let Err(e) = validate_line_ref(end, file_lines, had_bom) {
5447 if let Ok((line_num, _)) = parse_hashline_tag(end) {
5448 let idx = (line_num - 1).min(file_lines.len().saturating_sub(1));
5449 errors.push(format!(
5450 "{e}\n{}",
5451 mismatch_context(file_lines, idx, 2, had_bom)
5452 ));
5453 } else {
5454 errors.push(e);
5455 }
5456 }
5457 }
5458 }
5459 if errors.is_empty() {
5460 Ok(())
5461 } else {
5462 Err(errors.join("\n"))
5463 }
5464}
5465
5466#[derive(Debug, Clone, PartialEq, Eq, Hash)]
5468struct NormalizedEdit {
5469 op: String,
5470 pos_line: Option<usize>,
5471 end_line: Option<usize>,
5472 lines: Vec<String>,
5473}
5474
5475fn op_precedence(op: &str) -> u8 {
5477 match op {
5478 "replace" => 0,
5479 "append" => 1,
5480 "prepend" => 2,
5481 _ => 3,
5482 }
5483}
5484
5485#[async_trait]
5486#[allow(clippy::unnecessary_literal_bound)]
5487impl Tool for HashlineEditTool {
5488 fn name(&self) -> &str {
5489 "hashline_edit"
5490 }
5491 fn label(&self) -> &str {
5492 "hashline edit"
5493 }
5494 fn description(&self) -> &str {
5495 "Apply precise file edits using LINE#HASH tags from a prior read with hashline=true. \
5496 Each edit specifies an op (replace/prepend/append), a pos anchor (\"N#AB\"), an optional \
5497 end anchor for range replace, and replacement lines. Edits are validated against current \
5498 file hashes and applied bottom-up to avoid index invalidation."
5499 }
5500
5501 fn parameters(&self) -> serde_json::Value {
5502 serde_json::json!({
5503 "type": "object",
5504 "properties": {
5505 "path": {
5506 "type": "string",
5507 "description": "Path to the file to edit (relative or absolute)"
5508 },
5509 "edits": {
5510 "type": "array",
5511 "description": "Array of edit operations to apply",
5512 "items": {
5513 "type": "object",
5514 "properties": {
5515 "op": {
5516 "type": "string",
5517 "enum": ["replace", "prepend", "append"],
5518 "description": "Operation type"
5519 },
5520 "pos": {
5521 "type": "string",
5522 "description": "Anchor line reference in LINE#HASH format (e.g. \"5#KJ\")"
5523 },
5524 "end": {
5525 "type": "string",
5526 "description": "End anchor for range replace (inclusive)"
5527 },
5528 "lines": {
5529 "description": "Replacement/insertion content as array of strings, single string, or null for deletion",
5530 "oneOf": [
5531 { "type": "array", "items": { "type": "string" } },
5532 { "type": "string" },
5533 { "type": "null" }
5534 ]
5535 }
5536 },
5537 "required": ["op"]
5538 }
5539 }
5540 },
5541 "required": ["path", "edits"]
5542 })
5543 }
5544
5545 #[allow(clippy::too_many_lines)]
5546 async fn execute(
5547 &self,
5548 _tool_call_id: &str,
5549 input: serde_json::Value,
5550 _on_update: Option<Box<dyn Fn(ToolUpdate) + Send + Sync>>,
5551 ) -> Result<ToolOutput> {
5552 let input: HashlineEditInput = serde_json::from_value(input)
5553 .map_err(|e| Error::tool("hashline_edit", format!("Invalid input: {e}")))?;
5554
5555 if input.edits.is_empty() {
5556 return Err(Error::tool("hashline_edit", "No edits provided"));
5557 }
5558
5559 let resolved = resolve_read_path(&input.path, &self.cwd);
5561 let absolute_path = enforce_cwd_scope(&resolved, &self.cwd, "hashline_edit")?;
5562
5563 let metadata = asupersync::fs::metadata(&absolute_path)
5565 .await
5566 .map_err(|err| {
5567 let message = match err.kind() {
5568 std::io::ErrorKind::NotFound => format!("File not found: {}", input.path),
5569 std::io::ErrorKind::PermissionDenied => {
5570 format!("Permission denied: {}", input.path)
5571 }
5572 _ => format!("Cannot read file metadata: {err}"),
5573 };
5574 Error::tool("hashline_edit", message)
5575 })?;
5576 if !metadata.is_file() {
5577 return Err(Error::tool(
5578 "hashline_edit",
5579 format!("Path {} is not a regular file", absolute_path.display()),
5580 ));
5581 }
5582 if metadata.len() > READ_TOOL_MAX_BYTES {
5583 return Err(Error::tool(
5584 "hashline_edit",
5585 format!(
5586 "File too large ({} bytes, max {} bytes)",
5587 metadata.len(),
5588 READ_TOOL_MAX_BYTES
5589 ),
5590 ));
5591 }
5592
5593 let file = asupersync::fs::File::open(&absolute_path)
5595 .await
5596 .map_err(|e| Error::tool("hashline_edit", format!("Cannot open file: {e}")))?;
5597 let mut raw = Vec::new();
5598 let mut limiter = file.take(READ_TOOL_MAX_BYTES.saturating_add(1));
5599 limiter
5600 .read_to_end(&mut raw)
5601 .await
5602 .map_err(|e| Error::tool("hashline_edit", format!("Cannot read file: {e}")))?;
5603
5604 if raw.len() as u64 > READ_TOOL_MAX_BYTES {
5605 return Err(Error::tool(
5606 "hashline_edit",
5607 format!("File too large (> {READ_TOOL_MAX_BYTES} bytes)"),
5608 ));
5609 }
5610
5611 let raw_content = String::from_utf8(raw).map_err(|_| {
5612 Error::tool(
5613 "hashline_edit",
5614 "File contains invalid UTF-8 characters and cannot be safely edited as text."
5615 .to_string(),
5616 )
5617 })?;
5618
5619 let (content_no_bom, had_bom) = strip_bom(&raw_content);
5620 let original_ending = detect_line_ending(content_no_bom);
5621 let normalized = normalize_to_lf(content_no_bom);
5622 let file_lines: Vec<&str> = normalized.split('\n').collect();
5623
5624 if let Err(e) = collect_mismatches(&input.edits, &file_lines, had_bom) {
5626 return Err(Error::tool(
5627 "hashline_edit",
5628 format!("Hash validation failed — re-read the file to get current tags.\n\n{e}"),
5629 ));
5630 }
5631
5632 let mut seen = std::collections::HashSet::new();
5634 let mut deduped_edits: Vec<&HashlineOp> = Vec::new();
5635 for edit in &input.edits {
5636 let pos_line = edit
5637 .pos
5638 .as_ref()
5639 .and_then(|p| parse_hashline_tag(p).ok())
5640 .map(|(n, _)| n);
5641 let end_line = edit
5642 .end
5643 .as_ref()
5644 .and_then(|e| parse_hashline_tag(e).ok())
5645 .map(|(n, _)| n);
5646 let key = NormalizedEdit {
5647 op: edit.op.clone(),
5648 pos_line,
5649 end_line,
5650 lines: edit.get_lines(),
5651 };
5652 if seen.insert(key) {
5653 deduped_edits.push(edit);
5654 }
5655 }
5656
5657 let mut resolved: Vec<ResolvedEdit<'_>> = Vec::new();
5659 for edit in &deduped_edits {
5660 let replacement_lines: Vec<String> = edit
5661 .get_lines()
5662 .into_iter()
5663 .map(|l| strip_hashline_prefix(&l).to_string())
5664 .collect();
5665
5666 match edit.op.as_str() {
5667 "replace" => {
5668 let start_idx = match &edit.pos {
5669 Some(pos) => validate_line_ref(pos, &file_lines, had_bom)
5670 .map_err(|e| Error::tool("hashline_edit", e))?,
5671 None => {
5672 return Err(Error::tool(
5673 "hashline_edit",
5674 "replace operation requires a pos anchor",
5675 ));
5676 }
5677 };
5678 let end_idx = match &edit.end {
5679 Some(end) => validate_line_ref(end, &file_lines, had_bom)
5680 .map_err(|e| Error::tool("hashline_edit", e))?,
5681 None => start_idx,
5682 };
5683 if end_idx < start_idx {
5684 return Err(Error::tool(
5685 "hashline_edit",
5686 format!(
5687 "End anchor (line {}) is before start anchor (line {})",
5688 end_idx + 1,
5689 start_idx + 1
5690 ),
5691 ));
5692 }
5693 resolved.push(ResolvedEdit {
5694 op: "replace",
5695 start: start_idx,
5696 end: end_idx,
5697 lines: replacement_lines,
5698 });
5699 }
5700 "prepend" => {
5701 let idx = match &edit.pos {
5702 Some(pos) => validate_line_ref(pos, &file_lines, had_bom)
5703 .map_err(|e| Error::tool("hashline_edit", e))?,
5704 None => 0, };
5706 let end_idx = if file_lines == [""] && edit.pos.is_none() {
5707 0 } else {
5709 idx
5710 };
5711 resolved.push(ResolvedEdit {
5712 op: if file_lines == [""] && edit.pos.is_none() {
5713 "replace"
5714 } else {
5715 "prepend"
5716 },
5717 start: idx,
5718 end: end_idx,
5719 lines: replacement_lines,
5720 });
5721 }
5722 "append" => {
5723 let idx = match &edit.pos {
5724 Some(pos) => validate_line_ref(pos, &file_lines, had_bom)
5725 .map_err(|e| Error::tool("hashline_edit", e))?,
5726 None => {
5727 if file_lines.len() > 1 && file_lines.last() == Some(&"") {
5728 file_lines.len() - 2
5729 } else {
5730 file_lines.len().saturating_sub(1)
5731 }
5732 }
5733 };
5734 let end_idx = if file_lines == [""] && edit.pos.is_none() {
5735 0 } else {
5737 idx
5738 };
5739 resolved.push(ResolvedEdit {
5740 op: if file_lines == [""] && edit.pos.is_none() {
5741 "replace"
5742 } else {
5743 "append"
5744 },
5745 start: idx,
5746 end: end_idx,
5747 lines: replacement_lines,
5748 });
5749 }
5750 other => {
5751 return Err(Error::tool(
5752 "hashline_edit",
5753 format!("Unknown op: {other:?}. Must be replace, prepend, or append."),
5754 ));
5755 }
5756 }
5757 }
5758
5759 resolved.sort_by(|a, b| {
5761 b.start
5762 .cmp(&a.start)
5763 .then_with(|| op_precedence(a.op).cmp(&op_precedence(b.op)))
5764 });
5765
5766 for i in 0..resolved.len() {
5768 for j in (i + 1)..resolved.len() {
5769 let a = &resolved[i];
5770 let b = &resolved[j];
5771 if a.start <= b.end && b.start <= a.end {
5772 return Err(Error::tool(
5773 "hashline_edit",
5774 format!(
5775 "Overlapping edits detected: {} at line {}-{} and {} at line {}-{}. \
5776 Please combine overlapping edits into a single operation.",
5777 a.op,
5778 a.start + 1,
5779 a.end + 1,
5780 b.op,
5781 b.start + 1,
5782 b.end + 1
5783 ),
5784 ));
5785 }
5786 }
5787 }
5788
5789 let mut lines: Vec<String> = file_lines.iter().map(|s| (*s).to_string()).collect();
5791 let mut any_change = false;
5792
5793 for edit in &resolved {
5794 match edit.op {
5795 "replace" => {
5796 let existing: Vec<&str> = lines[edit.start..=edit.end]
5798 .iter()
5799 .map(String::as_str)
5800 .collect();
5801 if existing == edit.lines.iter().map(String::as_str).collect::<Vec<&str>>() {
5802 continue; }
5804 lines.splice(edit.start..=edit.end, edit.lines.iter().cloned());
5806 any_change = true;
5807 }
5808 "prepend" => {
5809 lines.splice(edit.start..edit.start, edit.lines.iter().cloned());
5811 if !edit.lines.is_empty() {
5812 any_change = true;
5813 }
5814 }
5815 "append" => {
5816 let insert_at = edit.start + 1;
5818 lines.splice(insert_at..insert_at, edit.lines.iter().cloned());
5819 if !edit.lines.is_empty() {
5820 any_change = true;
5821 }
5822 }
5823 _ => {} }
5825 }
5826
5827 if !any_change {
5828 return Err(Error::tool(
5829 "hashline_edit",
5830 format!(
5831 "No changes made to {}. All edits were no-ops (replacement identical to existing content).",
5832 input.path
5833 ),
5834 ));
5835 }
5836
5837 let new_normalized = lines.join("\n");
5839 let new_content = restore_line_endings(&new_normalized, original_ending);
5840 let mut final_content = new_content;
5841 if had_bom {
5842 final_content = format!("\u{FEFF}{final_content}");
5843 }
5844
5845 let absolute_path_clone = absolute_path.clone();
5847 let final_content_bytes = final_content.into_bytes();
5848 asupersync::runtime::spawn_blocking_io(move || {
5849 let original_perms = std::fs::metadata(&absolute_path_clone)
5850 .ok()
5851 .map(|m| m.permissions());
5852 let parent = absolute_path_clone
5853 .parent()
5854 .unwrap_or_else(|| Path::new("."));
5855 let mut temp_file = tempfile::NamedTempFile::new_in(parent)?;
5856
5857 temp_file.as_file_mut().write_all(&final_content_bytes)?;
5858 temp_file.as_file_mut().sync_all()?;
5859
5860 if let Some(perms) = original_perms {
5861 let _ = temp_file.as_file().set_permissions(perms);
5862 } else {
5863 #[cfg(unix)]
5864 {
5865 use std::os::unix::fs::PermissionsExt;
5866 let _ = temp_file
5867 .as_file()
5868 .set_permissions(std::fs::Permissions::from_mode(0o644));
5869 }
5870 }
5871
5872 temp_file
5873 .persist(&absolute_path_clone)
5874 .map_err(|e| e.error)?;
5875 Ok(())
5876 })
5877 .await
5878 .map_err(|e| Error::tool("hashline_edit", format!("Failed to write file: {e}")))?;
5879
5880 let (diff, first_changed_line) = generate_diff_string(&normalized, &new_normalized);
5882 let mut details = serde_json::Map::new();
5883 details.insert("diff".to_string(), serde_json::Value::String(diff));
5884 if let Some(line) = first_changed_line {
5885 details.insert(
5886 "firstChangedLine".to_string(),
5887 serde_json::Value::Number(serde_json::Number::from(line)),
5888 );
5889 }
5890
5891 Ok(ToolOutput {
5892 content: vec![ContentBlock::Text(TextContent::new(format!(
5893 "Successfully applied hashline edits to {}.",
5894 input.path
5895 )))],
5896 details: Some(serde_json::Value::Object(details)),
5897 is_error: false,
5898 })
5899 }
5900}
5901
5902#[cfg(test)]
5907mod tests {
5908 use super::*;
5909 use proptest::prelude::*;
5910 #[cfg(target_os = "linux")]
5911 use std::time::Duration;
5912
5913 #[test]
5914 fn test_truncate_head() {
5915 let content = "line1\nline2\nline3\nline4\nline5".to_string();
5916 let result = truncate_head(content, 3, 1000);
5917
5918 assert_eq!(result.content, "line1\nline2\nline3\n");
5919 assert!(result.truncated);
5920 assert_eq!(result.truncated_by, Some(TruncatedBy::Lines));
5921 assert_eq!(result.total_lines, 5);
5922 assert_eq!(result.output_lines, 3);
5923 }
5924
5925 #[test]
5926 fn test_truncate_tail() {
5927 let content = "line1\nline2\nline3\nline4\nline5".to_string();
5928 let result = truncate_tail(content, 3, 1000);
5929
5930 assert_eq!(result.content, "line3\nline4\nline5");
5931 assert!(result.truncated);
5932 assert_eq!(result.truncated_by, Some(TruncatedBy::Lines));
5933 assert_eq!(result.total_lines, 5);
5934 assert_eq!(result.output_lines, 3);
5935 }
5936
5937 #[test]
5938 fn test_truncate_tail_zero_lines_returns_empty_output() {
5939 let result = truncate_tail("line1\nline2".to_string(), 0, 1000);
5940
5941 assert!(result.truncated);
5942 assert_eq!(result.truncated_by, Some(TruncatedBy::Lines));
5943 assert_eq!(result.output_lines, 0);
5944 assert_eq!(result.output_bytes, 0);
5945 assert!(result.content.is_empty());
5946 }
5947
5948 #[test]
5949 fn test_line_count_from_newline_count_matches_trailing_newline_semantics() {
5950 assert_eq!(line_count_from_newline_count(0, 0, false), 0);
5951 assert_eq!(line_count_from_newline_count(2, 1, true), 1);
5952 assert_eq!(line_count_from_newline_count(1, 0, false), 1);
5953 assert_eq!(line_count_from_newline_count(3, 1, false), 2);
5954 }
5955
5956 #[test]
5957 fn test_rg_match_requires_path_and_line_number() {
5958 let mut matches = Vec::new();
5959 let mut match_count = 0usize;
5960 let mut match_limit_reached = false;
5961 let scan_limit = 1;
5962
5963 let missing_line =
5964 Ok(r#"{"type":"match","data":{"path":{"text":"file.txt"}}}"#.to_string());
5965 process_rg_json_match_line(
5966 missing_line,
5967 &mut matches,
5968 &mut match_count,
5969 &mut match_limit_reached,
5970 scan_limit,
5971 );
5972 assert!(matches.is_empty());
5973 assert_eq!(match_count, 0);
5974 assert!(!match_limit_reached);
5975
5976 let valid_line = Ok(
5977 r#"{"type":"match","data":{"path":{"text":"file.txt"},"line_number":3}}"#.to_string(),
5978 );
5979 process_rg_json_match_line(
5980 valid_line,
5981 &mut matches,
5982 &mut match_count,
5983 &mut match_limit_reached,
5984 scan_limit,
5985 );
5986 assert_eq!(matches.len(), 1);
5987 assert_eq!(matches[0].1, 3);
5988 assert_eq!(match_count, 1);
5989 assert!(match_limit_reached);
5990 }
5991
5992 #[test]
5993 fn test_truncate_by_bytes() {
5994 let content = "short\nthis is a longer line\nanother".to_string();
5995 let result = truncate_head(content, 100, 15);
5996
5997 assert!(result.truncated);
5998 assert_eq!(result.truncated_by, Some(TruncatedBy::Bytes));
5999 }
6000
6001 #[cfg(target_os = "linux")]
6002 #[test]
6003 fn test_read_to_end_capped_and_drain_preserves_writer_exit_status() {
6004 let mut child = std::process::Command::new("dd")
6005 .args(["if=/dev/zero", "bs=1", "count=70000", "status=none"])
6006 .stdout(std::process::Stdio::piped())
6007 .spawn()
6008 .expect("spawn dd");
6009
6010 let stdout = child.stdout.take().expect("dd stdout");
6011 let captured = read_to_end_capped_and_drain(stdout, 1024).expect("capture bounded stdout");
6012 let status = child.wait().expect("wait for dd");
6013
6014 assert!(
6015 status.success(),
6016 "bounded reader should drain to EOF instead of SIGPIPEing the writer: {status:?}"
6017 );
6018 assert_eq!(captured.len(), 1025);
6019 }
6020
6021 #[cfg(unix)]
6022 #[test]
6023 fn test_get_file_lines_async_unreadable_file_returns_empty() {
6024 asupersync::test_utils::run_test(|| async {
6025 use std::os::unix::fs::PermissionsExt;
6026
6027 let tmp = tempfile::tempdir().unwrap();
6028 let path = tmp.path().join("secret.txt");
6029 std::fs::write(&path, "secret\n").unwrap();
6030
6031 let mut perms = std::fs::metadata(&path).unwrap().permissions();
6032 perms.set_mode(0o000);
6033 std::fs::set_permissions(&path, perms).unwrap();
6034
6035 let mut cache = HashMap::new();
6036 let lines = get_file_lines_async(&path, &mut cache).await;
6037 assert!(lines.is_empty());
6038 });
6039 }
6040
6041 #[test]
6042 fn test_resolve_path_absolute() {
6043 let cwd = PathBuf::from("/home/user/project");
6044 let result = resolve_path("/absolute/path", &cwd);
6045 assert_eq!(result, PathBuf::from("/absolute/path"));
6046 }
6047
6048 #[test]
6049 fn test_resolve_path_relative() {
6050 let cwd = PathBuf::from("/home/user/project");
6051 let result = resolve_path("src/main.rs", &cwd);
6052 assert_eq!(result, PathBuf::from("/home/user/project/src/main.rs"));
6053 }
6054
6055 #[test]
6056 fn test_normalize_dot_segments_preserves_root() {
6057 let result = normalize_dot_segments(std::path::Path::new("/../etc/passwd"));
6058 assert_eq!(result, PathBuf::from("/etc/passwd"));
6059 }
6060
6061 #[test]
6062 fn test_normalize_dot_segments_preserves_leading_parent_for_relative() {
6063 let result = normalize_dot_segments(std::path::Path::new("../a/../b"));
6064 assert_eq!(result, PathBuf::from("../b"));
6065 }
6066
6067 #[test]
6068 fn test_detect_supported_image_mime_type_from_bytes() {
6069 assert_eq!(
6070 detect_supported_image_mime_type_from_bytes(b"\x89PNG\r\n\x1A\n"),
6071 Some("image/png")
6072 );
6073 assert_eq!(
6074 detect_supported_image_mime_type_from_bytes(b"\xFF\xD8\xFF"),
6075 Some("image/jpeg")
6076 );
6077 assert_eq!(
6078 detect_supported_image_mime_type_from_bytes(b"GIF89a"),
6079 Some("image/gif")
6080 );
6081 assert_eq!(
6082 detect_supported_image_mime_type_from_bytes(b"RIFF1234WEBP"),
6083 Some("image/webp")
6084 );
6085 assert_eq!(
6086 detect_supported_image_mime_type_from_bytes(b"not an image"),
6087 None
6088 );
6089 }
6090
6091 #[test]
6092 fn test_format_size() {
6093 assert_eq!(format_size(500), "500B");
6094 assert_eq!(format_size(1024), "1.0KB");
6095 assert_eq!(format_size(1536), "1.5KB");
6096 assert_eq!(format_size(1_048_576), "1.0MB");
6097 assert_eq!(format_size(1_073_741_824), "1024.0MB");
6098 }
6099
6100 #[test]
6101 fn test_js_string_length() {
6102 assert_eq!(js_string_length("hello"), 5);
6103 assert_eq!(js_string_length("😀"), 2);
6104 }
6105
6106 #[test]
6107 fn test_truncate_line() {
6108 let short = "short line";
6109 let result = truncate_line(short, 100);
6110 assert_eq!(result.text, "short line");
6111 assert!(!result.was_truncated);
6112
6113 let long = "a".repeat(600);
6114 let result = truncate_line(&long, 500);
6115 assert!(result.was_truncated);
6116 assert!(result.text.ends_with("... [truncated]"));
6117 }
6118
6119 fn get_text(content: &[ContentBlock]) -> String {
6124 content
6125 .iter()
6126 .filter_map(|block| {
6127 if let ContentBlock::Text(text) = block {
6128 Some(text.text.clone())
6129 } else {
6130 None
6131 }
6132 })
6133 .collect::<String>()
6134 }
6135
6136 #[test]
6141 fn test_read_valid_file() {
6142 asupersync::test_utils::run_test(|| async {
6143 let tmp = tempfile::tempdir().unwrap();
6144 std::fs::write(tmp.path().join("hello.txt"), "alpha\nbeta\ngamma").unwrap();
6145
6146 let tool = ReadTool::new(tmp.path());
6147 let out = tool
6148 .execute(
6149 "t",
6150 serde_json::json!({ "path": tmp.path().join("hello.txt").to_string_lossy() }),
6151 None,
6152 )
6153 .await
6154 .unwrap();
6155 let text = get_text(&out.content);
6156 assert!(text.contains("alpha"));
6157 assert!(text.contains("beta"));
6158 assert!(text.contains("gamma"));
6159 assert!(!out.is_error);
6160 });
6161 }
6162
6163 #[test]
6164 fn test_read_nonexistent_file() {
6165 asupersync::test_utils::run_test(|| async {
6166 let tmp = tempfile::tempdir().unwrap();
6167 let tool = ReadTool::new(tmp.path());
6168 let err = tool
6169 .execute(
6170 "t",
6171 serde_json::json!({ "path": tmp.path().join("nope.txt").to_string_lossy() }),
6172 None,
6173 )
6174 .await;
6175 assert!(err.is_err());
6176 });
6177 }
6178
6179 #[test]
6180 fn test_read_rejects_outside_cwd() {
6181 asupersync::test_utils::run_test(|| async {
6182 let cwd = tempfile::tempdir().unwrap();
6183 let outside = tempfile::tempdir().unwrap();
6184 std::fs::write(outside.path().join("secret.txt"), "secret").unwrap();
6185
6186 let tool = ReadTool::new(cwd.path());
6187 let err = tool
6188 .execute(
6189 "t",
6190 serde_json::json!({ "path": outside.path().join("secret.txt").to_string_lossy() }),
6191 None,
6192 )
6193 .await
6194 .unwrap_err();
6195 assert!(err.to_string().contains("outside the working directory"));
6196 });
6197 }
6198
6199 #[test]
6200 fn test_read_empty_file() {
6201 asupersync::test_utils::run_test(|| async {
6202 let tmp = tempfile::tempdir().unwrap();
6203 std::fs::write(tmp.path().join("empty.txt"), "").unwrap();
6204
6205 let tool = ReadTool::new(tmp.path());
6206 let out = tool
6207 .execute(
6208 "t",
6209 serde_json::json!({ "path": tmp.path().join("empty.txt").to_string_lossy() }),
6210 None,
6211 )
6212 .await
6213 .unwrap();
6214 let text = get_text(&out.content);
6215 assert_eq!(text, "");
6216 assert!(!out.is_error);
6217 });
6218 }
6219
6220 #[test]
6221 fn test_read_empty_file_positive_offset_errors() {
6222 asupersync::test_utils::run_test(|| async {
6223 let tmp = tempfile::tempdir().unwrap();
6224 std::fs::write(tmp.path().join("empty.txt"), "").unwrap();
6225
6226 let tool = ReadTool::new(tmp.path());
6227 let err = tool
6228 .execute(
6229 "t",
6230 serde_json::json!({
6231 "path": tmp.path().join("empty.txt").to_string_lossy(),
6232 "offset": 1
6233 }),
6234 None,
6235 )
6236 .await;
6237 assert!(err.is_err());
6238 let msg = err.unwrap_err().to_string();
6239 assert!(msg.contains("beyond end of file"));
6240 });
6241 }
6242
6243 #[test]
6244 fn test_read_rejects_zero_limit() {
6245 asupersync::test_utils::run_test(|| async {
6246 let tmp = tempfile::tempdir().unwrap();
6247 std::fs::write(tmp.path().join("lines.txt"), "a\nb\nc\n").unwrap();
6248
6249 let tool = ReadTool::new(tmp.path());
6250 let err = tool
6251 .execute(
6252 "t",
6253 serde_json::json!({
6254 "path": tmp.path().join("lines.txt").to_string_lossy(),
6255 "limit": 0
6256 }),
6257 None,
6258 )
6259 .await;
6260 assert!(err.is_err());
6261 assert!(
6262 err.unwrap_err()
6263 .to_string()
6264 .contains("`limit` must be greater than 0")
6265 );
6266 });
6267 }
6268
6269 #[test]
6270 fn test_read_offset_and_limit() {
6271 asupersync::test_utils::run_test(|| async {
6272 let tmp = tempfile::tempdir().unwrap();
6273 std::fs::write(
6274 tmp.path().join("lines.txt"),
6275 "L1\nL2\nL3\nL4\nL5\nL6\nL7\nL8\nL9\nL10",
6276 )
6277 .unwrap();
6278
6279 let tool = ReadTool::new(tmp.path());
6280 let out = tool
6281 .execute(
6282 "t",
6283 serde_json::json!({
6284 "path": tmp.path().join("lines.txt").to_string_lossy(),
6285 "offset": 3,
6286 "limit": 2
6287 }),
6288 None,
6289 )
6290 .await
6291 .unwrap();
6292 let text = get_text(&out.content);
6293 assert!(text.contains("L3"));
6294 assert!(text.contains("L4"));
6295 assert!(!text.contains("L2"));
6296 assert!(!text.contains("L5"));
6297 });
6298 }
6299
6300 #[test]
6301 fn test_read_offset_and_limit_with_cr_only_line_endings() {
6302 asupersync::test_utils::run_test(|| async {
6303 let tmp = tempfile::tempdir().unwrap();
6304 std::fs::write(tmp.path().join("lines.txt"), b"L1\rL2\rL3\r").unwrap();
6305
6306 let tool = ReadTool::new(tmp.path());
6307 let out = tool
6308 .execute(
6309 "t",
6310 serde_json::json!({
6311 "path": tmp.path().join("lines.txt").to_string_lossy(),
6312 "offset": 2,
6313 "limit": 1
6314 }),
6315 None,
6316 )
6317 .await
6318 .unwrap();
6319 let text = get_text(&out.content);
6320 assert!(text.contains("L2"));
6321 assert!(!text.contains("L1"));
6322 assert!(!text.contains("L3"));
6323 assert!(text.contains("offset=3"));
6324 assert!(!text.contains('\r'));
6325 });
6326 }
6327
6328 #[test]
6329 fn test_read_offset_and_limit_with_split_crlf_chunk_boundary() {
6330 asupersync::test_utils::run_test(|| async {
6331 let tmp = tempfile::tempdir().unwrap();
6332 let mut content = vec![b'x'; (64 * 1024) - 1];
6333 content.extend_from_slice(b"\r\nSECOND\r\nTHIRD");
6334 std::fs::write(tmp.path().join("lines.txt"), content).unwrap();
6335
6336 let tool = ReadTool::new(tmp.path());
6337 let out = tool
6338 .execute(
6339 "t",
6340 serde_json::json!({
6341 "path": tmp.path().join("lines.txt").to_string_lossy(),
6342 "offset": 2,
6343 "limit": 1
6344 }),
6345 None,
6346 )
6347 .await
6348 .unwrap();
6349 let text = get_text(&out.content);
6350 assert!(text.contains("SECOND"));
6351 assert!(!text.contains("THIRD"));
6352 assert!(!text.contains("xxxx"));
6353 assert!(text.contains("offset=3"));
6354 });
6355 }
6356
6357 #[test]
6358 fn test_read_offset_beyond_eof() {
6359 asupersync::test_utils::run_test(|| async {
6360 let tmp = tempfile::tempdir().unwrap();
6361 std::fs::write(tmp.path().join("short.txt"), "a\nb").unwrap();
6362
6363 let tool = ReadTool::new(tmp.path());
6364 let err = tool
6365 .execute(
6366 "t",
6367 serde_json::json!({
6368 "path": tmp.path().join("short.txt").to_string_lossy(),
6369 "offset": 100
6370 }),
6371 None,
6372 )
6373 .await;
6374 assert!(err.is_err());
6375 let msg = err.unwrap_err().to_string();
6376 assert!(msg.contains("beyond end of file"));
6377 });
6378 }
6379
6380 #[test]
6381 fn test_map_normalized_with_trailing_whitespace() {
6382 let content = "A \nB";
6384 let normalized = build_normalized_content(content);
6385 assert_eq!(normalized, "A\nB");
6386
6387 let (start, len) = map_normalized_range_to_original(content, 0, 1);
6389 assert_eq!(start, 0);
6390 assert_eq!(len, 1);
6391 assert_eq!(&content[start..start + len], "A");
6392
6393 let (start, len) = map_normalized_range_to_original(content, 1, 1);
6395 assert_eq!(start, 4);
6396 assert_eq!(len, 1);
6397 assert_eq!(&content[start..start + len], "\n");
6398
6399 let (start, len) = map_normalized_range_to_original(content, 2, 1);
6401 assert_eq!(start, 5);
6402 assert_eq!(len, 1);
6403 assert_eq!(&content[start..start + len], "B");
6404 }
6405
6406 #[test]
6407 fn test_read_binary_file_lossy() {
6408 asupersync::test_utils::run_test(|| async {
6409 let tmp = tempfile::tempdir().unwrap();
6410 let binary_data: Vec<u8> = (0..=255).collect();
6411 std::fs::write(tmp.path().join("binary.bin"), &binary_data).unwrap();
6412
6413 let tool = ReadTool::new(tmp.path());
6414 let out = tool
6415 .execute(
6416 "t",
6417 serde_json::json!({ "path": tmp.path().join("binary.bin").to_string_lossy() }),
6418 None,
6419 )
6420 .await
6421 .unwrap();
6422 let text = get_text(&out.content);
6424 assert!(!text.is_empty());
6425 assert!(!out.is_error);
6426 });
6427 }
6428
6429 #[test]
6430 fn test_read_image_detection() {
6431 asupersync::test_utils::run_test(|| async {
6432 let tmp = tempfile::tempdir().unwrap();
6433 let png_header: Vec<u8> = vec![
6435 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,
6439 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,
6445 ];
6446 std::fs::write(tmp.path().join("test.png"), &png_header).unwrap();
6447
6448 let tool = ReadTool::new(tmp.path());
6449 let out = tool
6450 .execute(
6451 "t",
6452 serde_json::json!({ "path": tmp.path().join("test.png").to_string_lossy() }),
6453 None,
6454 )
6455 .await
6456 .unwrap();
6457
6458 let has_image = out
6460 .content
6461 .iter()
6462 .any(|b| matches!(b, ContentBlock::Image(_)));
6463 assert!(has_image, "expected image content block for PNG file");
6464 });
6465 }
6466
6467 #[cfg(feature = "image-resize")]
6468 #[test]
6469 fn test_read_resizes_large_source_image_before_api_limit_check() {
6470 asupersync::test_utils::run_test(|| async {
6471 use image::codecs::png::PngEncoder;
6472 use image::{ExtendedColorType, ImageEncoder, Rgb, RgbImage};
6473
6474 let tmp = tempfile::tempdir().unwrap();
6475 let image = RgbImage::from_fn(2600, 2600, |x, y| {
6476 let seed = x.wrapping_mul(1_973)
6477 ^ y.wrapping_mul(9_277)
6478 ^ x.rotate_left(7)
6479 ^ y.rotate_left(13);
6480 Rgb([
6481 u8::try_from(seed % 256).unwrap_or(0),
6482 u8::try_from((seed >> 8) % 256).unwrap_or(0),
6483 u8::try_from((seed >> 16) % 256).unwrap_or(0),
6484 ])
6485 });
6486
6487 let mut png_bytes = Vec::new();
6488 PngEncoder::new(&mut png_bytes)
6489 .write_image(
6490 image.as_raw(),
6491 image.width(),
6492 image.height(),
6493 ExtendedColorType::Rgb8,
6494 )
6495 .unwrap();
6496
6497 assert!(
6498 png_bytes.len() > IMAGE_MAX_BYTES,
6499 "fixture must exceed API image limit to exercise resize path"
6500 );
6501 assert!(
6502 png_bytes.len() < usize::try_from(READ_TOOL_MAX_BYTES).unwrap_or(usize::MAX),
6503 "fixture must stay within read-tool input bound"
6504 );
6505
6506 let image_path = tmp.path().join("large.png");
6507 std::fs::write(&image_path, &png_bytes).unwrap();
6508
6509 let tool = ReadTool::new(tmp.path());
6510 let out = tool
6511 .execute(
6512 "t",
6513 serde_json::json!({ "path": image_path.to_string_lossy() }),
6514 None,
6515 )
6516 .await
6517 .unwrap();
6518
6519 assert!(!out.is_error, "resizable large images should succeed");
6520 assert!(
6521 out.content
6522 .iter()
6523 .any(|block| matches!(block, ContentBlock::Image(_))),
6524 "expected an image attachment after resizing"
6525 );
6526
6527 let text = get_text(&out.content);
6528 assert!(text.contains("Read image file"));
6529 assert!(
6530 text.contains("displayed at"),
6531 "expected resize note in read output, got: {text}"
6532 );
6533 });
6534 }
6535
6536 #[test]
6537 fn test_read_blocked_images() {
6538 asupersync::test_utils::run_test(|| async {
6539 let tmp = tempfile::tempdir().unwrap();
6540 let png_header: Vec<u8> =
6541 vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00];
6542 std::fs::write(tmp.path().join("test.png"), &png_header).unwrap();
6543
6544 let tool = ReadTool::with_settings(tmp.path(), false, true);
6545 let err = tool
6546 .execute(
6547 "t",
6548 serde_json::json!({ "path": tmp.path().join("test.png").to_string_lossy() }),
6549 None,
6550 )
6551 .await;
6552 assert!(err.is_err());
6553 assert!(err.unwrap_err().to_string().contains("blocked"));
6554 });
6555 }
6556
6557 #[test]
6558 fn test_read_truncation_at_max_lines() {
6559 asupersync::test_utils::run_test(|| async {
6560 let tmp = tempfile::tempdir().unwrap();
6561 let content: String = (0..DEFAULT_MAX_LINES + 500)
6562 .map(|i| format!("line {i}"))
6563 .collect::<Vec<_>>()
6564 .join("\n");
6565 std::fs::write(tmp.path().join("big.txt"), &content).unwrap();
6566
6567 let tool = ReadTool::new(tmp.path());
6568 let out = tool
6569 .execute(
6570 "t",
6571 serde_json::json!({ "path": tmp.path().join("big.txt").to_string_lossy() }),
6572 None,
6573 )
6574 .await
6575 .unwrap();
6576 assert!(out.details.is_some(), "expected truncation details");
6578 let text = get_text(&out.content);
6579 assert!(text.contains("offset="));
6580 });
6581 }
6582
6583 #[test]
6584 fn test_read_first_line_exceeds_max_bytes() {
6585 asupersync::test_utils::run_test(|| async {
6586 let tmp = tempfile::tempdir().unwrap();
6587 let long_line = "a".repeat(DEFAULT_MAX_BYTES + 128);
6588 std::fs::write(tmp.path().join("too_long.txt"), long_line).unwrap();
6589
6590 let tool = ReadTool::new(tmp.path());
6591 let out = tool
6592 .execute(
6593 "t",
6594 serde_json::json!({ "path": tmp.path().join("too_long.txt").to_string_lossy() }),
6595 None,
6596 )
6597 .await
6598 .unwrap();
6599
6600 let text = get_text(&out.content);
6601 let expected_limit = format!("exceeds {} limit", format_size(DEFAULT_MAX_BYTES));
6602 assert!(
6603 text.contains(&expected_limit),
6604 "expected limit hint '{expected_limit}', got: {text}"
6605 );
6606 let details = out.details.expect("expected truncation details");
6607 assert_eq!(
6608 details
6609 .get("truncation")
6610 .and_then(|v| v.get("firstLineExceedsLimit"))
6611 .and_then(serde_json::Value::as_bool),
6612 Some(true)
6613 );
6614 });
6615 }
6616
6617 #[test]
6618 fn test_read_unicode_content() {
6619 asupersync::test_utils::run_test(|| async {
6620 let tmp = tempfile::tempdir().unwrap();
6621 std::fs::write(tmp.path().join("uni.txt"), "Hello 你好 🌍\nLine 2 café").unwrap();
6622
6623 let tool = ReadTool::new(tmp.path());
6624 let out = tool
6625 .execute(
6626 "t",
6627 serde_json::json!({ "path": tmp.path().join("uni.txt").to_string_lossy() }),
6628 None,
6629 )
6630 .await
6631 .unwrap();
6632 let text = get_text(&out.content);
6633 assert!(text.contains("你好"));
6634 assert!(text.contains("🌍"));
6635 assert!(text.contains("café"));
6636 });
6637 }
6638
6639 #[test]
6644 fn test_write_new_file() {
6645 asupersync::test_utils::run_test(|| async {
6646 let tmp = tempfile::tempdir().unwrap();
6647 let tool = WriteTool::new(tmp.path());
6648 let out = tool
6649 .execute(
6650 "t",
6651 serde_json::json!({
6652 "path": tmp.path().join("new.txt").to_string_lossy(),
6653 "content": "hello world"
6654 }),
6655 None,
6656 )
6657 .await
6658 .unwrap();
6659 assert!(!out.is_error);
6660 let contents = std::fs::read_to_string(tmp.path().join("new.txt")).unwrap();
6661 assert_eq!(contents, "hello world");
6662 });
6663 }
6664
6665 #[test]
6666 fn test_write_overwrite_existing() {
6667 asupersync::test_utils::run_test(|| async {
6668 let tmp = tempfile::tempdir().unwrap();
6669 std::fs::write(tmp.path().join("exist.txt"), "old content").unwrap();
6670
6671 let tool = WriteTool::new(tmp.path());
6672 let out = tool
6673 .execute(
6674 "t",
6675 serde_json::json!({
6676 "path": tmp.path().join("exist.txt").to_string_lossy(),
6677 "content": "new content"
6678 }),
6679 None,
6680 )
6681 .await
6682 .unwrap();
6683 assert!(!out.is_error);
6684 let contents = std::fs::read_to_string(tmp.path().join("exist.txt")).unwrap();
6685 assert_eq!(contents, "new content");
6686 });
6687 }
6688
6689 #[test]
6690 fn test_write_creates_parent_dirs() {
6691 asupersync::test_utils::run_test(|| async {
6692 let tmp = tempfile::tempdir().unwrap();
6693 let tool = WriteTool::new(tmp.path());
6694 let deep_path = tmp.path().join("a/b/c/deep.txt");
6695 let out = tool
6696 .execute(
6697 "t",
6698 serde_json::json!({
6699 "path": deep_path.to_string_lossy(),
6700 "content": "deep file"
6701 }),
6702 None,
6703 )
6704 .await
6705 .unwrap();
6706 assert!(!out.is_error);
6707 assert!(deep_path.exists());
6708 assert_eq!(std::fs::read_to_string(&deep_path).unwrap(), "deep file");
6709 });
6710 }
6711
6712 #[test]
6713 fn test_write_empty_file() {
6714 asupersync::test_utils::run_test(|| async {
6715 let tmp = tempfile::tempdir().unwrap();
6716 let tool = WriteTool::new(tmp.path());
6717 let out = tool
6718 .execute(
6719 "t",
6720 serde_json::json!({
6721 "path": tmp.path().join("empty.txt").to_string_lossy(),
6722 "content": ""
6723 }),
6724 None,
6725 )
6726 .await
6727 .unwrap();
6728 assert!(!out.is_error);
6729 let contents = std::fs::read_to_string(tmp.path().join("empty.txt")).unwrap();
6730 assert_eq!(contents, "");
6731 let text = get_text(&out.content);
6732 assert!(text.contains("Successfully wrote 0 bytes"));
6733 });
6734 }
6735
6736 #[test]
6737 fn test_write_rejects_outside_cwd() {
6738 asupersync::test_utils::run_test(|| async {
6739 let cwd = tempfile::tempdir().unwrap();
6740 let outside = tempfile::tempdir().unwrap();
6741 let tool = WriteTool::new(cwd.path());
6742 let err = tool
6743 .execute(
6744 "t",
6745 serde_json::json!({
6746 "path": outside.path().join("escape.txt").to_string_lossy(),
6747 "content": "nope"
6748 }),
6749 None,
6750 )
6751 .await
6752 .unwrap_err();
6753 assert!(err.to_string().contains("outside the working directory"));
6754
6755 let err = tool
6756 .execute(
6757 "t",
6758 serde_json::json!({
6759 "path": "../escape.txt",
6760 "content": "nope"
6761 }),
6762 None,
6763 )
6764 .await
6765 .unwrap_err();
6766 assert!(err.to_string().contains("outside the working directory"));
6767 });
6768 }
6769
6770 #[test]
6771 fn test_write_unicode_content() {
6772 asupersync::test_utils::run_test(|| async {
6773 let tmp = tempfile::tempdir().unwrap();
6774 let tool = WriteTool::new(tmp.path());
6775 let out = tool
6776 .execute(
6777 "t",
6778 serde_json::json!({
6779 "path": tmp.path().join("unicode.txt").to_string_lossy(),
6780 "content": "日本語 🎉 Ñoño"
6781 }),
6782 None,
6783 )
6784 .await
6785 .unwrap();
6786 assert!(!out.is_error);
6787 let contents = std::fs::read_to_string(tmp.path().join("unicode.txt")).unwrap();
6788 assert_eq!(contents, "日本語 🎉 Ñoño");
6789 });
6790 }
6791
6792 #[test]
6793 #[cfg(unix)]
6794 fn test_write_file_permissions_unix() {
6795 use std::os::unix::fs::PermissionsExt;
6796 asupersync::test_utils::run_test(|| async {
6797 let tmp = tempfile::tempdir().unwrap();
6798 let tool = WriteTool::new(tmp.path());
6799 let path = tmp.path().join("perms.txt");
6800 let out = tool
6801 .execute(
6802 "t",
6803 serde_json::json!({
6804 "path": path.to_string_lossy(),
6805 "content": "check perms"
6806 }),
6807 None,
6808 )
6809 .await
6810 .unwrap();
6811 assert!(!out.is_error);
6812
6813 let meta = std::fs::metadata(&path).unwrap();
6814 let mode = meta.permissions().mode();
6815 assert_eq!(
6816 mode & 0o777,
6817 0o644,
6818 "Expected default 0o644 permissions for new files"
6819 );
6820 });
6821 }
6822
6823 #[test]
6828 fn test_edit_exact_match_replace() {
6829 asupersync::test_utils::run_test(|| async {
6830 let tmp = tempfile::tempdir().unwrap();
6831 std::fs::write(tmp.path().join("code.rs"), "fn foo() { bar() }").unwrap();
6832
6833 let tool = EditTool::new(tmp.path());
6834 let out = tool
6835 .execute(
6836 "t",
6837 serde_json::json!({
6838 "path": tmp.path().join("code.rs").to_string_lossy(),
6839 "oldText": "bar()",
6840 "newText": "baz()"
6841 }),
6842 None,
6843 )
6844 .await
6845 .unwrap();
6846 assert!(!out.is_error);
6847 let contents = std::fs::read_to_string(tmp.path().join("code.rs")).unwrap();
6848 assert_eq!(contents, "fn foo() { baz() }");
6849 });
6850 }
6851
6852 #[test]
6853 fn test_edit_no_match_error() {
6854 asupersync::test_utils::run_test(|| async {
6855 let tmp = tempfile::tempdir().unwrap();
6856 std::fs::write(tmp.path().join("code.rs"), "fn foo() {}").unwrap();
6857
6858 let tool = EditTool::new(tmp.path());
6859 let err = tool
6860 .execute(
6861 "t",
6862 serde_json::json!({
6863 "path": tmp.path().join("code.rs").to_string_lossy(),
6864 "oldText": "NONEXISTENT TEXT",
6865 "newText": "replacement"
6866 }),
6867 None,
6868 )
6869 .await;
6870 assert!(err.is_err());
6871 });
6872 }
6873
6874 #[test]
6875 fn test_edit_empty_old_text_error() {
6876 asupersync::test_utils::run_test(|| async {
6877 let tmp = tempfile::tempdir().unwrap();
6878 let path = tmp.path().join("code.rs");
6879 std::fs::write(&path, "fn foo() {}").unwrap();
6880
6881 let tool = EditTool::new(tmp.path());
6882 let err = tool
6883 .execute(
6884 "t",
6885 serde_json::json!({
6886 "path": path.to_string_lossy(),
6887 "oldText": "",
6888 "newText": "prefix"
6889 }),
6890 None,
6891 )
6892 .await
6893 .expect_err("empty oldText should be rejected");
6894
6895 let msg = err.to_string();
6896 assert!(
6897 msg.contains("old text cannot be empty"),
6898 "unexpected error: {msg}"
6899 );
6900 let after = std::fs::read_to_string(path).unwrap();
6901 assert_eq!(after, "fn foo() {}");
6902 });
6903 }
6904
6905 #[test]
6906 fn test_edit_ambiguous_match_error() {
6907 asupersync::test_utils::run_test(|| async {
6908 let tmp = tempfile::tempdir().unwrap();
6909 std::fs::write(tmp.path().join("dup.txt"), "hello hello hello").unwrap();
6910
6911 let tool = EditTool::new(tmp.path());
6912 let err = tool
6913 .execute(
6914 "t",
6915 serde_json::json!({
6916 "path": tmp.path().join("dup.txt").to_string_lossy(),
6917 "oldText": "hello",
6918 "newText": "world"
6919 }),
6920 None,
6921 )
6922 .await;
6923 assert!(err.is_err(), "expected error for ambiguous match");
6924 });
6925 }
6926
6927 #[test]
6928 fn test_edit_multi_line_replacement() {
6929 asupersync::test_utils::run_test(|| async {
6930 let tmp = tempfile::tempdir().unwrap();
6931 std::fs::write(
6932 tmp.path().join("multi.txt"),
6933 "line 1\nline 2\nline 3\nline 4",
6934 )
6935 .unwrap();
6936
6937 let tool = EditTool::new(tmp.path());
6938 let out = tool
6939 .execute(
6940 "t",
6941 serde_json::json!({
6942 "path": tmp.path().join("multi.txt").to_string_lossy(),
6943 "oldText": "line 2\nline 3",
6944 "newText": "replaced 2\nreplaced 3\nextra line"
6945 }),
6946 None,
6947 )
6948 .await
6949 .unwrap();
6950 assert!(!out.is_error);
6951 let contents = std::fs::read_to_string(tmp.path().join("multi.txt")).unwrap();
6952 assert_eq!(
6953 contents,
6954 "line 1\nreplaced 2\nreplaced 3\nextra line\nline 4"
6955 );
6956 });
6957 }
6958
6959 #[test]
6960 fn test_edit_unicode_content() {
6961 asupersync::test_utils::run_test(|| async {
6962 let tmp = tempfile::tempdir().unwrap();
6963 std::fs::write(tmp.path().join("uni.txt"), "Héllo wörld 🌍").unwrap();
6964
6965 let tool = EditTool::new(tmp.path());
6966 let out = tool
6967 .execute(
6968 "t",
6969 serde_json::json!({
6970 "path": tmp.path().join("uni.txt").to_string_lossy(),
6971 "oldText": "wörld 🌍",
6972 "newText": "Welt 🌎"
6973 }),
6974 None,
6975 )
6976 .await
6977 .unwrap();
6978 assert!(!out.is_error);
6979 let contents = std::fs::read_to_string(tmp.path().join("uni.txt")).unwrap();
6980 assert_eq!(contents, "Héllo Welt 🌎");
6981 });
6982 }
6983
6984 #[test]
6985 fn test_edit_missing_file() {
6986 asupersync::test_utils::run_test(|| async {
6987 let tmp = tempfile::tempdir().unwrap();
6988 let tool = EditTool::new(tmp.path());
6989 let err = tool
6990 .execute(
6991 "t",
6992 serde_json::json!({
6993 "path": tmp.path().join("nope.txt").to_string_lossy(),
6994 "oldText": "foo",
6995 "newText": "bar"
6996 }),
6997 None,
6998 )
6999 .await;
7000 assert!(err.is_err());
7001 });
7002 }
7003
7004 #[test]
7009 fn test_bash_simple_command() {
7010 asupersync::test_utils::run_test(|| async {
7011 let tmp = tempfile::tempdir().unwrap();
7012 let tool = BashTool::new(tmp.path());
7013 let out = tool
7014 .execute(
7015 "t",
7016 serde_json::json!({ "command": "echo hello_from_bash" }),
7017 None,
7018 )
7019 .await
7020 .unwrap();
7021 let text = get_text(&out.content);
7022 assert!(text.contains("hello_from_bash"));
7023 assert!(!out.is_error);
7024 });
7025 }
7026
7027 #[test]
7028 fn test_bash_exit_code_nonzero() {
7029 asupersync::test_utils::run_test(|| async {
7030 let tmp = tempfile::tempdir().unwrap();
7031 let tool = BashTool::new(tmp.path());
7032 let out = tool
7033 .execute("t", serde_json::json!({ "command": "exit 42" }), None)
7034 .await
7035 .expect("non-zero exit should return Ok with is_error=true");
7036 assert!(out.is_error, "non-zero exit must set is_error");
7037 let msg = get_text(&out.content);
7038 assert!(
7039 msg.contains("42"),
7040 "expected exit code 42 in output, got: {msg}"
7041 );
7042 });
7043 }
7044
7045 #[cfg(unix)]
7046 #[test]
7047 fn test_bash_signal_termination_is_error() {
7048 asupersync::test_utils::run_test(|| async {
7049 let tmp = tempfile::tempdir().unwrap();
7050 let tool = BashTool::new(tmp.path());
7051 let out = tool
7052 .execute("t", serde_json::json!({ "command": "kill -KILL $$" }), None)
7053 .await
7054 .expect("signal-terminated shell should return Ok with is_error=true");
7055 assert!(
7056 out.is_error,
7057 "signal-terminated shell must be reported as error"
7058 );
7059 let msg = get_text(&out.content);
7060 assert!(
7061 msg.contains("Command exited with code"),
7062 "expected explicit exit-code report, got: {msg}"
7063 );
7064 assert!(
7065 !msg.contains("Command exited with code 0"),
7066 "signal-terminated shell must not appear successful: {msg}"
7067 );
7068 });
7069 }
7070
7071 #[test]
7072 fn test_bash_stderr_capture() {
7073 asupersync::test_utils::run_test(|| async {
7074 let tmp = tempfile::tempdir().unwrap();
7075 let tool = BashTool::new(tmp.path());
7076 let out = tool
7077 .execute(
7078 "t",
7079 serde_json::json!({ "command": "echo stderr_msg >&2" }),
7080 None,
7081 )
7082 .await
7083 .unwrap();
7084 let text = get_text(&out.content);
7085 assert!(
7086 text.contains("stderr_msg"),
7087 "expected stderr output in result, got: {text}"
7088 );
7089 });
7090 }
7091
7092 #[test]
7093 fn test_bash_timeout() {
7094 asupersync::test_utils::run_test(|| async {
7095 let tmp = tempfile::tempdir().unwrap();
7096 let tool = BashTool::new(tmp.path());
7097 let out = tool
7098 .execute(
7099 "t",
7100 serde_json::json!({ "command": "sleep 60", "timeout": 2 }),
7101 None,
7102 )
7103 .await
7104 .expect("timeout should return Ok with is_error=true");
7105 assert!(out.is_error, "timeout must set is_error");
7106 let msg = get_text(&out.content);
7107 assert!(
7108 msg.to_lowercase().contains("timeout") || msg.to_lowercase().contains("timed out"),
7109 "expected timeout indication, got: {msg}"
7110 );
7111 });
7112 }
7113
7114 #[cfg(target_os = "linux")]
7115 #[test]
7116 fn test_bash_timeout_kills_process_tree() {
7117 asupersync::test_utils::run_test(|| async {
7118 let tmp = tempfile::tempdir().unwrap();
7119 let marker = tmp.path().join("leaked_child.txt");
7120 let tool = BashTool::new(tmp.path());
7121
7122 let out = tool
7123 .execute(
7124 "t",
7125 serde_json::json!({
7126 "command": "(sleep 3; echo leaked > leaked_child.txt) & sleep 10",
7127 "timeout": 1
7128 }),
7129 None,
7130 )
7131 .await
7132 .expect("timeout should return Ok with is_error=true");
7133
7134 assert!(out.is_error, "timeout must set is_error");
7135 let msg = get_text(&out.content);
7136 assert!(msg.contains("Command timed out"));
7137
7138 std::thread::sleep(Duration::from_secs(4));
7140 assert!(
7141 !marker.exists(),
7142 "background child was not terminated on timeout"
7143 );
7144 });
7145 }
7146
7147 #[cfg(target_os = "linux")]
7148 #[test]
7149 fn test_bash_cancelled_context_kills_process_tree() {
7150 asupersync::test_utils::run_test(|| async {
7151 let tmp = tempfile::tempdir().unwrap();
7152 let marker = tmp.path().join("leaked_child.txt");
7153
7154 let ambient_cx = asupersync::Cx::for_testing();
7155 let cancel_cx = ambient_cx.clone();
7156 let _current = asupersync::Cx::set_current(Some(ambient_cx));
7157
7158 let cancel_thread = std::thread::spawn(move || {
7159 std::thread::sleep(Duration::from_millis(100));
7160 cancel_cx.set_cancel_requested(true);
7161 });
7162
7163 let result = run_bash_command(
7164 tmp.path(),
7165 None,
7166 None,
7167 "(sleep 3; echo leaked > leaked_child.txt) & sleep 10",
7168 Some(30),
7169 None,
7170 )
7171 .await
7172 .expect("cancelled bash should return a result");
7173
7174 cancel_thread.join().expect("cancel thread");
7175
7176 assert!(
7177 result.cancelled,
7178 "expected cancelled bash result: {result:?}"
7179 );
7180
7181 std::thread::sleep(Duration::from_secs(4));
7182 assert!(
7183 !marker.exists(),
7184 "background child was not terminated on cancellation"
7185 );
7186 });
7187 }
7188
7189 #[test]
7190 fn test_drain_bash_output_ignores_cancellation_after_process_exit() {
7191 asupersync::test_utils::run_test(|| async {
7192 let (tx, mut rx) = mpsc::sync_channel::<Vec<u8>>(1);
7193 let mut bash_output = BashOutputState::new(DEFAULT_MAX_BYTES);
7194
7195 let ambient_cx = asupersync::Cx::for_testing();
7196 ambient_cx.set_cancel_requested(true);
7197 let _current = asupersync::Cx::set_current(Some(ambient_cx));
7198 let cx = AgentCx::for_current_or_request();
7199 let now = cx
7200 .cx()
7201 .timer_driver()
7202 .map_or_else(wall_now, |timer| timer.now());
7203
7204 let cancelled = drain_bash_output(
7205 &mut rx,
7206 &mut bash_output,
7207 &cx,
7208 now + std::time::Duration::from_millis(10),
7209 std::time::Duration::from_millis(1),
7210 false,
7211 )
7212 .await
7213 .expect("drain should complete without cancellation");
7214
7215 drop(tx);
7216
7217 assert!(
7218 !cancelled,
7219 "post-exit drain should ignore late ambient cancellation"
7220 );
7221 assert_eq!(bash_output.total_bytes, 0);
7222 });
7223 }
7224
7225 #[test]
7226 fn test_drain_bash_output_honors_cancellation_while_process_still_active() {
7227 asupersync::test_utils::run_test(|| async {
7228 let (_tx, mut rx) = mpsc::sync_channel::<Vec<u8>>(1);
7229 let mut bash_output = BashOutputState::new(DEFAULT_MAX_BYTES);
7230
7231 let ambient_cx = asupersync::Cx::for_testing();
7232 ambient_cx.set_cancel_requested(true);
7233 let _current = asupersync::Cx::set_current(Some(ambient_cx));
7234 let cx = AgentCx::for_current_or_request();
7235 let now = cx
7236 .cx()
7237 .timer_driver()
7238 .map_or_else(wall_now, |timer| timer.now());
7239
7240 let cancelled = drain_bash_output(
7241 &mut rx,
7242 &mut bash_output,
7243 &cx,
7244 now + std::time::Duration::from_secs(1),
7245 std::time::Duration::from_millis(1),
7246 true,
7247 )
7248 .await
7249 .expect("drain should complete under cancellation");
7250
7251 assert!(
7252 cancelled,
7253 "active drain should still honor ambient cancellation"
7254 );
7255 assert_eq!(bash_output.total_bytes, 0);
7256 });
7257 }
7258
7259 #[test]
7260 fn test_bash_output_state_abandon_spill_file_clears_path_and_unlinks_file() {
7261 let tmp = tempfile::tempdir().unwrap();
7262 let spill_path = tmp.path().join("partial-bash.log");
7263 std::fs::write(&spill_path, b"partial output").unwrap();
7264
7265 let mut bash_output = BashOutputState::new(DEFAULT_MAX_BYTES);
7266 bash_output.temp_file_path = Some(spill_path.clone());
7267
7268 bash_output.abandon_spill_file();
7269
7270 assert!(bash_output.spill_failed);
7271 assert!(bash_output.temp_file.is_none());
7272 assert!(bash_output.temp_file_path.is_none());
7273 assert!(
7274 !spill_path.exists(),
7275 "abandoned spill files should not be advertised or left behind"
7276 );
7277 }
7278
7279 #[test]
7280 fn test_bash_hard_limit_retains_partial_spill_file() {
7281 asupersync::test_utils::run_test(|| async {
7282 let tmp = tempfile::tempdir().unwrap();
7283 let spill_path = tmp.path().join("hard-limit-bash.log");
7284 std::fs::write(&spill_path, b"partial output").unwrap();
7285
7286 let spill_file = asupersync::fs::OpenOptions::new()
7287 .append(true)
7288 .open(&spill_path)
7289 .await
7290 .unwrap();
7291
7292 let mut bash_output = BashOutputState::new(DEFAULT_MAX_BYTES);
7293 bash_output.total_bytes = BASH_FILE_LIMIT_BYTES;
7294 bash_output.temp_file_path = Some(spill_path.clone());
7295 bash_output.temp_file = Some(spill_file);
7296
7297 ingest_bash_chunk(vec![b'x'], &mut bash_output)
7298 .await
7299 .expect("hard-limit ingestion should still succeed");
7300
7301 assert!(!bash_output.spill_failed);
7302 assert!(bash_output.temp_file.is_none());
7303 assert!(bash_output.temp_file_path.is_some());
7304 assert!(
7305 spill_path.exists(),
7306 "partial spill files must be retained once the hard limit is reached for diagnostics"
7307 );
7308 });
7309 }
7310
7311 #[test]
7312 #[cfg(unix)]
7313 fn test_bash_working_directory() {
7314 asupersync::test_utils::run_test(|| async {
7315 let tmp = tempfile::tempdir().unwrap();
7316 let tool = BashTool::new(tmp.path());
7317 let out = tool
7318 .execute("t", serde_json::json!({ "command": "pwd" }), None)
7319 .await
7320 .unwrap();
7321 let text = get_text(&out.content);
7322 let canonical = tmp.path().canonicalize().unwrap();
7323 assert!(
7324 text.contains(&canonical.to_string_lossy().to_string()),
7325 "expected cwd in output, got: {text}"
7326 );
7327 });
7328 }
7329
7330 #[test]
7331 fn test_bash_multiline_output() {
7332 asupersync::test_utils::run_test(|| async {
7333 let tmp = tempfile::tempdir().unwrap();
7334 let tool = BashTool::new(tmp.path());
7335 let out = tool
7336 .execute(
7337 "t",
7338 serde_json::json!({ "command": "echo line1; echo line2; echo line3" }),
7339 None,
7340 )
7341 .await
7342 .unwrap();
7343 let text = get_text(&out.content);
7344 assert!(text.contains("line1"));
7345 assert!(text.contains("line2"));
7346 assert!(text.contains("line3"));
7347 });
7348 }
7349
7350 #[test]
7355 fn test_grep_basic_pattern() {
7356 asupersync::test_utils::run_test(|| async {
7357 let tmp = tempfile::tempdir().unwrap();
7358 std::fs::write(
7359 tmp.path().join("search.txt"),
7360 "apple\nbanana\napricot\ncherry",
7361 )
7362 .unwrap();
7363
7364 let tool = GrepTool::new(tmp.path());
7365 let out = tool
7366 .execute(
7367 "t",
7368 serde_json::json!({
7369 "pattern": "ap",
7370 "path": tmp.path().join("search.txt").to_string_lossy()
7371 }),
7372 None,
7373 )
7374 .await
7375 .unwrap();
7376 let text = get_text(&out.content);
7377 assert!(text.contains("apple"));
7378 assert!(text.contains("apricot"));
7379 assert!(!text.contains("banana"));
7380 assert!(!text.contains("cherry"));
7381 });
7382 }
7383
7384 #[test]
7385 fn test_grep_rejects_outside_cwd() {
7386 asupersync::test_utils::run_test(|| async {
7387 let cwd = tempfile::tempdir().unwrap();
7388 let outside = tempfile::tempdir().unwrap();
7389 std::fs::write(outside.path().join("secret.txt"), "secret").unwrap();
7390
7391 let tool = GrepTool::new(cwd.path());
7392 let err = tool
7393 .execute(
7394 "t",
7395 serde_json::json!({
7396 "pattern": "secret",
7397 "path": outside.path().join("secret.txt").to_string_lossy()
7398 }),
7399 None,
7400 )
7401 .await
7402 .unwrap_err();
7403 assert!(err.to_string().contains("outside the working directory"));
7404 });
7405 }
7406
7407 #[test]
7408 fn test_grep_rejects_zero_limit() {
7409 asupersync::test_utils::run_test(|| async {
7410 let tmp = tempfile::tempdir().unwrap();
7411 std::fs::write(tmp.path().join("search.txt"), "alpha\nbeta\n").unwrap();
7412
7413 let tool = GrepTool::new(tmp.path());
7414 let err = tool
7415 .execute(
7416 "t",
7417 serde_json::json!({
7418 "pattern": "alpha",
7419 "path": tmp.path().join("search.txt").to_string_lossy(),
7420 "limit": 0
7421 }),
7422 None,
7423 )
7424 .await
7425 .unwrap_err();
7426 assert!(err.to_string().contains("`limit` must be greater than 0"));
7427 });
7428 }
7429
7430 #[test]
7431 fn test_grep_regex_pattern() {
7432 asupersync::test_utils::run_test(|| async {
7433 let tmp = tempfile::tempdir().unwrap();
7434 std::fs::write(
7435 tmp.path().join("regex.txt"),
7436 "foo123\nbar456\nbaz789\nfoo000",
7437 )
7438 .unwrap();
7439
7440 let tool = GrepTool::new(tmp.path());
7441 let out = tool
7442 .execute(
7443 "t",
7444 serde_json::json!({
7445 "pattern": "foo\\d+",
7446 "path": tmp.path().join("regex.txt").to_string_lossy()
7447 }),
7448 None,
7449 )
7450 .await
7451 .unwrap();
7452 let text = get_text(&out.content);
7453 assert!(text.contains("foo123"));
7454 assert!(text.contains("foo000"));
7455 assert!(!text.contains("bar456"));
7456 });
7457 }
7458
7459 #[test]
7460 fn test_grep_case_insensitive() {
7461 asupersync::test_utils::run_test(|| async {
7462 let tmp = tempfile::tempdir().unwrap();
7463 std::fs::write(tmp.path().join("case.txt"), "Hello\nhello\nHELLO").unwrap();
7464
7465 let tool = GrepTool::new(tmp.path());
7466 let out = tool
7467 .execute(
7468 "t",
7469 serde_json::json!({
7470 "pattern": "hello",
7471 "path": tmp.path().join("case.txt").to_string_lossy(),
7472 "ignoreCase": true
7473 }),
7474 None,
7475 )
7476 .await
7477 .unwrap();
7478 let text = get_text(&out.content);
7479 assert!(text.contains("Hello"));
7480 assert!(text.contains("hello"));
7481 assert!(text.contains("HELLO"));
7482 });
7483 }
7484
7485 #[test]
7486 fn test_grep_case_sensitive_by_default() {
7487 asupersync::test_utils::run_test(|| async {
7488 let tmp = tempfile::tempdir().unwrap();
7489 std::fs::write(tmp.path().join("case_sensitive.txt"), "Hello\nHELLO").unwrap();
7490
7491 let tool = GrepTool::new(tmp.path());
7492 let out = tool
7493 .execute(
7494 "t",
7495 serde_json::json!({
7496 "pattern": "hello",
7497 "path": tmp.path().join("case_sensitive.txt").to_string_lossy()
7498 }),
7499 None,
7500 )
7501 .await
7502 .unwrap();
7503 let text = get_text(&out.content);
7504 assert!(
7505 text.contains("No matches found"),
7506 "expected case-sensitive search to find no matches, got: {text}"
7507 );
7508 });
7509 }
7510
7511 #[test]
7512 fn test_grep_append_non_matching_lines_invariant() {
7513 asupersync::test_utils::run_test(|| async {
7514 let tmp = tempfile::tempdir().unwrap();
7515 let file = tmp.path().join("base.txt");
7516 std::fs::write(&file, "needle one\nskip\nneedle two\n").unwrap();
7517
7518 let tool = GrepTool::new(tmp.path());
7519 let base_out = tool
7520 .execute(
7521 "t",
7522 serde_json::json!({
7523 "pattern": "needle",
7524 "path": file.to_string_lossy(),
7525 "limit": 100
7526 }),
7527 None,
7528 )
7529 .await
7530 .unwrap();
7531 let base_text = get_text(&base_out.content);
7532
7533 std::fs::write(&file, "needle one\nskip\nneedle two\nalpha\nbeta\n").unwrap();
7534 let extended_out = tool
7535 .execute(
7536 "t",
7537 serde_json::json!({
7538 "pattern": "needle",
7539 "path": file.to_string_lossy(),
7540 "limit": 100
7541 }),
7542 None,
7543 )
7544 .await
7545 .unwrap();
7546 let extended_text = get_text(&extended_out.content);
7547
7548 assert_eq!(
7549 base_text, extended_text,
7550 "adding non-matching lines should not alter grep output"
7551 );
7552 });
7553 }
7554
7555 #[test]
7556 fn test_grep_no_matches() {
7557 asupersync::test_utils::run_test(|| async {
7558 let tmp = tempfile::tempdir().unwrap();
7559 std::fs::write(tmp.path().join("nothing.txt"), "alpha\nbeta\ngamma").unwrap();
7560
7561 let tool = GrepTool::new(tmp.path());
7562 let out = tool
7563 .execute(
7564 "t",
7565 serde_json::json!({
7566 "pattern": "ZZZZZ_NOMATCH",
7567 "path": tmp.path().join("nothing.txt").to_string_lossy()
7568 }),
7569 None,
7570 )
7571 .await
7572 .unwrap();
7573 let text = get_text(&out.content);
7574 assert!(
7575 text.to_lowercase().contains("no match")
7576 || text.is_empty()
7577 || text.to_lowercase().contains("no results"),
7578 "expected no-match indication, got: {text}"
7579 );
7580 });
7581 }
7582
7583 #[test]
7584 fn test_grep_context_lines() {
7585 asupersync::test_utils::run_test(|| async {
7586 let tmp = tempfile::tempdir().unwrap();
7587 std::fs::write(
7588 tmp.path().join("ctx.txt"),
7589 "aaa\nbbb\nccc\ntarget\nddd\neee\nfff",
7590 )
7591 .unwrap();
7592
7593 let tool = GrepTool::new(tmp.path());
7594 let out = tool
7595 .execute(
7596 "t",
7597 serde_json::json!({
7598 "pattern": "target",
7599 "path": tmp.path().join("ctx.txt").to_string_lossy(),
7600 "context": 1
7601 }),
7602 None,
7603 )
7604 .await
7605 .unwrap();
7606 let text = get_text(&out.content);
7607 assert!(text.contains("target"));
7608 assert!(text.contains("ccc"), "expected context line before match");
7609 assert!(text.contains("ddd"), "expected context line after match");
7610 });
7611 }
7612
7613 #[test]
7614 fn test_grep_limit() {
7615 asupersync::test_utils::run_test(|| async {
7616 let tmp = tempfile::tempdir().unwrap();
7617 let content: String = (0..200)
7618 .map(|i| format!("match_line_{i}"))
7619 .collect::<Vec<_>>()
7620 .join("\n");
7621 std::fs::write(tmp.path().join("many.txt"), &content).unwrap();
7622
7623 let tool = GrepTool::new(tmp.path());
7624 let out = tool
7625 .execute(
7626 "t",
7627 serde_json::json!({
7628 "pattern": "match_line",
7629 "path": tmp.path().join("many.txt").to_string_lossy(),
7630 "limit": 5
7631 }),
7632 None,
7633 )
7634 .await
7635 .unwrap();
7636 let text = get_text(&out.content);
7637 let match_count = text.matches("match_line_").count();
7639 assert!(
7640 match_count <= 5,
7641 "expected at most 5 matches with limit=5, got {match_count}"
7642 );
7643 let details = out.details.expect("expected limit details");
7644 assert_eq!(
7645 details
7646 .get("matchLimitReached")
7647 .and_then(serde_json::Value::as_u64),
7648 Some(5)
7649 );
7650 });
7651 }
7652
7653 #[test]
7654 fn test_grep_exact_limit_does_not_report_limit_reached() {
7655 asupersync::test_utils::run_test(|| async {
7656 let tmp = tempfile::tempdir().unwrap();
7657 let content = (0..5)
7658 .map(|i| format!("match_line_{i}"))
7659 .collect::<Vec<_>>()
7660 .join("\n");
7661 std::fs::write(tmp.path().join("exact.txt"), &content).unwrap();
7662
7663 let tool = GrepTool::new(tmp.path());
7664 let out = tool
7665 .execute(
7666 "t",
7667 serde_json::json!({
7668 "pattern": "match_line",
7669 "path": tmp.path().join("exact.txt").to_string_lossy(),
7670 "limit": 5
7671 }),
7672 None,
7673 )
7674 .await
7675 .unwrap();
7676
7677 let text = get_text(&out.content);
7678 assert_eq!(text.matches("match_line_").count(), 5);
7679 assert!(
7680 !text.contains("matches limit reached"),
7681 "exact-limit grep results should not claim truncation: {text}"
7682 );
7683 assert!(
7684 out.details
7685 .as_ref()
7686 .and_then(|details| details.get("matchLimitReached"))
7687 .is_none(),
7688 "exact-limit grep results should not set matchLimitReached"
7689 );
7690 });
7691 }
7692
7693 #[test]
7694 fn test_grep_large_output_does_not_deadlock_reader_threads() {
7695 asupersync::test_utils::run_test(|| async {
7696 use std::fmt::Write as _;
7697
7698 let tmp = tempfile::tempdir().unwrap();
7699 let mut content = String::with_capacity(80_000);
7700 for i in 0..5000 {
7701 let _ = writeln!(&mut content, "needle_line_{i}");
7702 }
7703 let file = tmp.path().join("large_grep.txt");
7704 std::fs::write(&file, content).unwrap();
7705
7706 let tool = GrepTool::new(tmp.path());
7707 let run = tool.execute(
7708 "t",
7709 serde_json::json!({
7710 "pattern": "needle_line_",
7711 "path": file.to_string_lossy(),
7712 "limit": 6000
7713 }),
7714 None,
7715 );
7716
7717 let out = asupersync::time::timeout(
7718 asupersync::time::wall_now(),
7719 Duration::from_secs(15),
7720 Box::pin(run),
7721 )
7722 .await
7723 .expect("grep timed out; possible stdout/stderr reader deadlock")
7724 .expect("grep should succeed");
7725
7726 let text = get_text(&out.content);
7727 assert!(text.contains("needle_line_0"));
7728 });
7729 }
7730
7731 #[test]
7732 fn test_grep_respects_gitignore() {
7733 asupersync::test_utils::run_test(|| async {
7734 let tmp = tempfile::tempdir().unwrap();
7735 std::fs::write(tmp.path().join(".gitignore"), "ignored.txt\n").unwrap();
7736 std::fs::write(tmp.path().join("ignored.txt"), "needle in ignored file").unwrap();
7737 std::fs::write(tmp.path().join("visible.txt"), "nothing here").unwrap();
7738
7739 let tool = GrepTool::new(tmp.path());
7740 let out = tool
7741 .execute("t", serde_json::json!({ "pattern": "needle" }), None)
7742 .await
7743 .unwrap();
7744
7745 let text = get_text(&out.content);
7746 assert!(
7747 text.contains("No matches found"),
7748 "expected ignored file to be excluded, got: {text}"
7749 );
7750 });
7751 }
7752
7753 #[test]
7754 fn test_grep_literal_mode() {
7755 asupersync::test_utils::run_test(|| async {
7756 let tmp = tempfile::tempdir().unwrap();
7757 std::fs::write(tmp.path().join("literal.txt"), "a+b\na.b\nab\na\\+b").unwrap();
7758
7759 let tool = GrepTool::new(tmp.path());
7760 let out = tool
7761 .execute(
7762 "t",
7763 serde_json::json!({
7764 "pattern": "a+b",
7765 "path": tmp.path().join("literal.txt").to_string_lossy(),
7766 "literal": true
7767 }),
7768 None,
7769 )
7770 .await
7771 .unwrap();
7772 let text = get_text(&out.content);
7773 assert!(text.contains("a+b"), "literal match should find 'a+b'");
7774 });
7775 }
7776
7777 #[test]
7778 fn test_grep_hashline_output() {
7779 asupersync::test_utils::run_test(|| async {
7780 let tmp = tempfile::tempdir().unwrap();
7781 std::fs::write(
7782 tmp.path().join("hash.txt"),
7783 "apple\nbanana\napricot\ncherry",
7784 )
7785 .unwrap();
7786
7787 let tool = GrepTool::new(tmp.path());
7788 let out = tool
7789 .execute(
7790 "t",
7791 serde_json::json!({
7792 "pattern": "ap",
7793 "path": tmp.path().join("hash.txt").to_string_lossy(),
7794 "hashline": true
7795 }),
7796 None,
7797 )
7798 .await
7799 .unwrap();
7800 let text = get_text(&out.content);
7801 assert!(text.contains("apple"), "should contain apple");
7804 assert!(text.contains("apricot"), "should contain apricot");
7805 assert!(
7806 !text.contains("banana"),
7807 "should not contain banana context"
7808 );
7809 let re = regex::Regex::new(r"\d+#[A-Z]{2}").unwrap();
7811 assert!(
7812 re.is_match(&text),
7813 "hashline output should contain N#AB tags, got: {text}"
7814 );
7815 });
7816 }
7817
7818 #[test]
7819 fn test_grep_hashline_with_context() {
7820 asupersync::test_utils::run_test(|| async {
7821 let tmp = tempfile::tempdir().unwrap();
7822 std::fs::write(
7823 tmp.path().join("ctx.txt"),
7824 "line1\nline2\ntarget\nline4\nline5",
7825 )
7826 .unwrap();
7827
7828 let tool = GrepTool::new(tmp.path());
7829 let out = tool
7830 .execute(
7831 "t",
7832 serde_json::json!({
7833 "pattern": "target",
7834 "path": tmp.path().join("ctx.txt").to_string_lossy(),
7835 "hashline": true,
7836 "context": 1
7837 }),
7838 None,
7839 )
7840 .await
7841 .unwrap();
7842 let text = get_text(&out.content);
7843 assert!(text.contains("line2"), "should contain context line2");
7845 assert!(text.contains("target"), "should contain match");
7846 assert!(text.contains("line4"), "should contain context line4");
7847 let re_match = regex::Regex::new(r"\d+#[A-Z]{2}: target").unwrap();
7849 assert!(
7850 re_match.is_match(&text),
7851 "match line should use : separator with hashline tag, got: {text}"
7852 );
7853 let re_ctx = regex::Regex::new(r"\d+#[A-Z]{2}- line").unwrap();
7854 assert!(
7855 re_ctx.is_match(&text),
7856 "context line should use - separator with hashline tag, got: {text}"
7857 );
7858 });
7859 }
7860
7861 #[test]
7866 fn test_find_glob_pattern() {
7867 asupersync::test_utils::run_test(|| async {
7868 if find_fd_binary().is_none() {
7869 return;
7870 }
7871 let tmp = tempfile::tempdir().unwrap();
7872 std::fs::write(tmp.path().join("file1.rs"), "").unwrap();
7873 std::fs::write(tmp.path().join("file2.rs"), "").unwrap();
7874 std::fs::write(tmp.path().join("file3.txt"), "").unwrap();
7875
7876 let tool = FindTool::new(tmp.path());
7877 let out = tool
7878 .execute(
7879 "t",
7880 serde_json::json!({
7881 "pattern": "*.rs",
7882 "path": tmp.path().to_string_lossy()
7883 }),
7884 None,
7885 )
7886 .await
7887 .unwrap();
7888 let text = get_text(&out.content);
7889 assert!(text.contains("file1.rs"));
7890 assert!(text.contains("file2.rs"));
7891 assert!(!text.contains("file3.txt"));
7892 });
7893 }
7894
7895 #[test]
7896 fn test_find_append_non_matching_file_invariant() {
7897 asupersync::test_utils::run_test(|| async {
7898 if find_fd_binary().is_none() {
7899 return;
7900 }
7901 let tmp = tempfile::tempdir().unwrap();
7902 std::fs::write(tmp.path().join("match.txt"), "a").unwrap();
7903
7904 let tool = FindTool::new(tmp.path());
7905 let base_out = tool
7906 .execute(
7907 "t",
7908 serde_json::json!({
7909 "pattern": "*.txt",
7910 "path": tmp.path().to_string_lossy()
7911 }),
7912 None,
7913 )
7914 .await
7915 .unwrap();
7916 let base_text = get_text(&base_out.content);
7917
7918 std::fs::write(tmp.path().join("ignore.md"), "b").unwrap();
7919 let extended_out = tool
7920 .execute(
7921 "t",
7922 serde_json::json!({
7923 "pattern": "*.txt",
7924 "path": tmp.path().to_string_lossy()
7925 }),
7926 None,
7927 )
7928 .await
7929 .unwrap();
7930 let extended_text = get_text(&extended_out.content);
7931
7932 assert_eq!(
7933 base_text, extended_text,
7934 "adding non-matching files should not alter find output"
7935 );
7936 });
7937 }
7938
7939 #[test]
7940 fn test_find_rejects_outside_cwd() {
7941 asupersync::test_utils::run_test(|| async {
7942 let cwd = tempfile::tempdir().unwrap();
7943 let outside = tempfile::tempdir().unwrap();
7944 std::fs::write(outside.path().join("secret.txt"), "secret").unwrap();
7945
7946 let tool = FindTool::new(cwd.path());
7947 let err = tool
7948 .execute(
7949 "t",
7950 serde_json::json!({
7951 "pattern": "*.txt",
7952 "path": outside.path().to_string_lossy()
7953 }),
7954 None,
7955 )
7956 .await
7957 .unwrap_err();
7958 assert!(err.to_string().contains("outside the working directory"));
7959 });
7960 }
7961
7962 #[test]
7963 fn test_find_limit() {
7964 asupersync::test_utils::run_test(|| async {
7965 if find_fd_binary().is_none() {
7966 return;
7967 }
7968 let tmp = tempfile::tempdir().unwrap();
7969 for i in 0..20 {
7970 std::fs::write(tmp.path().join(format!("f{i}.txt")), "").unwrap();
7971 }
7972
7973 let tool = FindTool::new(tmp.path());
7974 let out = tool
7975 .execute(
7976 "t",
7977 serde_json::json!({
7978 "pattern": "*.txt",
7979 "path": tmp.path().to_string_lossy(),
7980 "limit": 5
7981 }),
7982 None,
7983 )
7984 .await
7985 .unwrap();
7986 let text = get_text(&out.content);
7987 let file_count = text.lines().filter(|l| l.contains(".txt")).count();
7988 assert!(
7989 file_count <= 5,
7990 "expected at most 5 files with limit=5, got {file_count}"
7991 );
7992 let details = out.details.expect("expected limit details");
7993 assert_eq!(
7994 details
7995 .get("resultLimitReached")
7996 .and_then(serde_json::Value::as_u64),
7997 Some(5)
7998 );
7999 });
8000 }
8001
8002 #[test]
8003 fn test_find_exact_limit_does_not_report_limit_reached() {
8004 asupersync::test_utils::run_test(|| async {
8005 if find_fd_binary().is_none() {
8006 return;
8007 }
8008 let tmp = tempfile::tempdir().unwrap();
8009 for i in 0..5 {
8010 std::fs::write(tmp.path().join(format!("f{i}.txt")), "").unwrap();
8011 }
8012
8013 let tool = FindTool::new(tmp.path());
8014 let out = tool
8015 .execute(
8016 "t",
8017 serde_json::json!({
8018 "pattern": "*.txt",
8019 "path": tmp.path().to_string_lossy(),
8020 "limit": 5
8021 }),
8022 None,
8023 )
8024 .await
8025 .unwrap();
8026
8027 let text = get_text(&out.content);
8028 assert_eq!(text.lines().filter(|line| line.contains(".txt")).count(), 5);
8029 assert!(
8030 !text.contains("results limit reached"),
8031 "exact-limit find results should not claim truncation: {text}"
8032 );
8033 assert!(
8034 out.details
8035 .as_ref()
8036 .and_then(|details| details.get("resultLimitReached"))
8037 .is_none(),
8038 "exact-limit find results should not set resultLimitReached"
8039 );
8040 });
8041 }
8042
8043 #[test]
8044 fn test_find_zero_limit_is_rejected() {
8045 asupersync::test_utils::run_test(|| async {
8046 if find_fd_binary().is_none() {
8047 return;
8048 }
8049 let tmp = tempfile::tempdir().unwrap();
8050 std::fs::write(tmp.path().join("file.txt"), "").unwrap();
8051
8052 let tool = FindTool::new(tmp.path());
8053 let err = tool
8054 .execute(
8055 "t",
8056 serde_json::json!({
8057 "pattern": "*.txt",
8058 "path": tmp.path().to_string_lossy(),
8059 "limit": 0
8060 }),
8061 None,
8062 )
8063 .await
8064 .expect_err("limit=0 should be rejected");
8065
8066 assert!(
8067 err.to_string().contains("`limit` must be greater than 0"),
8068 "expected validation error, got: {err}"
8069 );
8070 });
8071 }
8072
8073 #[test]
8074 fn test_find_no_matches() {
8075 asupersync::test_utils::run_test(|| async {
8076 if find_fd_binary().is_none() {
8077 return;
8078 }
8079 let tmp = tempfile::tempdir().unwrap();
8080 std::fs::write(tmp.path().join("only.txt"), "").unwrap();
8081
8082 let tool = FindTool::new(tmp.path());
8083 let out = tool
8084 .execute(
8085 "t",
8086 serde_json::json!({
8087 "pattern": "*.rs",
8088 "path": tmp.path().to_string_lossy()
8089 }),
8090 None,
8091 )
8092 .await
8093 .unwrap();
8094 let text = get_text(&out.content);
8095 assert!(
8096 text.to_lowercase().contains("no files found")
8097 || text.to_lowercase().contains("no matches")
8098 || text.is_empty(),
8099 "expected no-match indication, got: {text}"
8100 );
8101 });
8102 }
8103
8104 #[test]
8105 fn test_find_nonexistent_path() {
8106 asupersync::test_utils::run_test(|| async {
8107 if find_fd_binary().is_none() {
8108 return;
8109 }
8110 let tmp = tempfile::tempdir().unwrap();
8111 let tool = FindTool::new(tmp.path());
8112 let err = tool
8113 .execute(
8114 "t",
8115 serde_json::json!({
8116 "pattern": "*.rs",
8117 "path": tmp.path().join("nonexistent").to_string_lossy()
8118 }),
8119 None,
8120 )
8121 .await;
8122 assert!(err.is_err());
8123 });
8124 }
8125
8126 #[test]
8127 fn test_find_nested_directories() {
8128 asupersync::test_utils::run_test(|| async {
8129 if find_fd_binary().is_none() {
8130 return;
8131 }
8132 let tmp = tempfile::tempdir().unwrap();
8133 std::fs::create_dir_all(tmp.path().join("a/b/c")).unwrap();
8134 std::fs::write(tmp.path().join("top.rs"), "").unwrap();
8135 std::fs::write(tmp.path().join("a/mid.rs"), "").unwrap();
8136 std::fs::write(tmp.path().join("a/b/c/deep.rs"), "").unwrap();
8137
8138 let tool = FindTool::new(tmp.path());
8139 let out = tool
8140 .execute(
8141 "t",
8142 serde_json::json!({
8143 "pattern": "*.rs",
8144 "path": tmp.path().to_string_lossy()
8145 }),
8146 None,
8147 )
8148 .await
8149 .unwrap();
8150 let text = get_text(&out.content);
8151 assert!(text.contains("top.rs"));
8152 assert!(text.contains("mid.rs"));
8153 assert!(text.contains("deep.rs"));
8154 });
8155 }
8156
8157 #[test]
8158 fn test_find_results_are_sorted() {
8159 asupersync::test_utils::run_test(|| async {
8162 if find_fd_binary().is_none() {
8163 return;
8164 }
8165 let tmp = tempfile::tempdir().unwrap();
8166
8167 std::fs::write(tmp.path().join("oldest.txt"), "").unwrap();
8170 std::thread::sleep(std::time::Duration::from_millis(50));
8171 std::fs::write(tmp.path().join("middle.txt"), "").unwrap();
8172 std::thread::sleep(std::time::Duration::from_millis(50));
8173 std::fs::write(tmp.path().join("newest.txt"), "").unwrap();
8174
8175 let tool = FindTool::new(tmp.path());
8176 let out = tool
8177 .execute(
8178 "t",
8179 serde_json::json!({
8180 "pattern": "*.txt",
8181 "path": tmp.path().to_string_lossy()
8182 }),
8183 None,
8184 )
8185 .await
8186 .unwrap();
8187 let lines: Vec<String> = get_text(&out.content)
8188 .lines()
8189 .map(str::trim)
8190 .filter(|line| !line.is_empty())
8191 .map(str::to_string)
8192 .collect();
8193
8194 assert_eq!(
8196 lines,
8197 vec!["newest.txt", "middle.txt", "oldest.txt"],
8198 "expected mtime-sorted find output (most recent first)"
8199 );
8200 });
8201 }
8202
8203 #[test]
8204 fn test_find_respects_gitignore() {
8205 asupersync::test_utils::run_test(|| async {
8206 if find_fd_binary().is_none() {
8207 return;
8208 }
8209 let tmp = tempfile::tempdir().unwrap();
8210 std::fs::write(tmp.path().join(".gitignore"), "ignored.txt\n").unwrap();
8211 std::fs::write(tmp.path().join("ignored.txt"), "").unwrap();
8212
8213 let tool = FindTool::new(tmp.path());
8214 let out = tool
8215 .execute(
8216 "t",
8217 serde_json::json!({
8218 "pattern": "*.txt",
8219 "path": tmp.path().to_string_lossy()
8220 }),
8221 None,
8222 )
8223 .await
8224 .unwrap();
8225 let text = get_text(&out.content);
8226 assert!(
8227 text.contains("No files found matching pattern"),
8228 "expected .gitignore'd files to be excluded, got: {text}"
8229 );
8230 });
8231 }
8232
8233 #[test]
8238 fn test_ls_directory_listing() {
8239 asupersync::test_utils::run_test(|| async {
8240 let tmp = tempfile::tempdir().unwrap();
8241 std::fs::write(tmp.path().join("file_a.txt"), "content").unwrap();
8242 std::fs::write(tmp.path().join("file_b.rs"), "fn main() {}").unwrap();
8243 std::fs::create_dir(tmp.path().join("subdir")).unwrap();
8244
8245 let tool = LsTool::new(tmp.path());
8246 let out = tool
8247 .execute(
8248 "t",
8249 serde_json::json!({ "path": tmp.path().to_string_lossy() }),
8250 None,
8251 )
8252 .await
8253 .unwrap();
8254 let text = get_text(&out.content);
8255 assert!(text.contains("file_a.txt"));
8256 assert!(text.contains("file_b.rs"));
8257 assert!(text.contains("subdir"));
8258 });
8259 }
8260
8261 #[test]
8262 fn test_ls_rejects_outside_cwd() {
8263 asupersync::test_utils::run_test(|| async {
8264 let cwd = tempfile::tempdir().unwrap();
8265 let outside = tempfile::tempdir().unwrap();
8266 std::fs::write(outside.path().join("secret.txt"), "secret").unwrap();
8267
8268 let tool = LsTool::new(cwd.path());
8269 let err = tool
8270 .execute(
8271 "t",
8272 serde_json::json!({ "path": outside.path().to_string_lossy() }),
8273 None,
8274 )
8275 .await
8276 .unwrap_err();
8277 assert!(err.to_string().contains("outside the working directory"));
8278 });
8279 }
8280
8281 #[test]
8282 fn test_ls_trailing_slash_for_dirs() {
8283 asupersync::test_utils::run_test(|| async {
8284 let tmp = tempfile::tempdir().unwrap();
8285 std::fs::write(tmp.path().join("file.txt"), "").unwrap();
8286 std::fs::create_dir(tmp.path().join("mydir")).unwrap();
8287
8288 let tool = LsTool::new(tmp.path());
8289 let out = tool
8290 .execute(
8291 "t",
8292 serde_json::json!({ "path": tmp.path().to_string_lossy() }),
8293 None,
8294 )
8295 .await
8296 .unwrap();
8297 let text = get_text(&out.content);
8298 assert!(
8299 text.contains("mydir/"),
8300 "expected trailing slash for directory, got: {text}"
8301 );
8302 });
8303 }
8304
8305 #[test]
8306 fn test_ls_limit() {
8307 asupersync::test_utils::run_test(|| async {
8308 let tmp = tempfile::tempdir().unwrap();
8309 for i in 0..20 {
8310 std::fs::write(tmp.path().join(format!("item_{i:02}.txt")), "").unwrap();
8311 }
8312
8313 let tool = LsTool::new(tmp.path());
8314 let out = tool
8315 .execute(
8316 "t",
8317 serde_json::json!({
8318 "path": tmp.path().to_string_lossy(),
8319 "limit": 5
8320 }),
8321 None,
8322 )
8323 .await
8324 .unwrap();
8325 let text = get_text(&out.content);
8326 let entry_count = text.lines().filter(|l| l.contains("item_")).count();
8327 assert!(
8328 entry_count <= 5,
8329 "expected at most 5 entries, got {entry_count}"
8330 );
8331 let details = out.details.expect("expected limit details");
8332 assert_eq!(
8333 details
8334 .get("entryLimitReached")
8335 .and_then(serde_json::Value::as_u64),
8336 Some(5)
8337 );
8338 });
8339 }
8340
8341 #[test]
8342 fn test_ls_zero_limit_is_rejected() {
8343 asupersync::test_utils::run_test(|| async {
8344 let tmp = tempfile::tempdir().unwrap();
8345 std::fs::write(tmp.path().join("item.txt"), "").unwrap();
8346
8347 let tool = LsTool::new(tmp.path());
8348 let err = tool
8349 .execute(
8350 "t",
8351 serde_json::json!({
8352 "path": tmp.path().to_string_lossy(),
8353 "limit": 0
8354 }),
8355 None,
8356 )
8357 .await
8358 .expect_err("limit=0 should be rejected");
8359
8360 assert!(
8361 err.to_string().contains("`limit` must be greater than 0"),
8362 "expected validation error, got: {err}"
8363 );
8364 });
8365 }
8366
8367 #[test]
8368 fn test_ls_nonexistent_directory() {
8369 asupersync::test_utils::run_test(|| async {
8370 let tmp = tempfile::tempdir().unwrap();
8371 let tool = LsTool::new(tmp.path());
8372 let err = tool
8373 .execute(
8374 "t",
8375 serde_json::json!({ "path": tmp.path().join("nope").to_string_lossy() }),
8376 None,
8377 )
8378 .await;
8379 assert!(err.is_err());
8380 });
8381 }
8382
8383 #[test]
8384 fn test_ls_empty_directory() {
8385 asupersync::test_utils::run_test(|| async {
8386 let tmp = tempfile::tempdir().unwrap();
8387 let empty_dir = tmp.path().join("empty");
8388 std::fs::create_dir(&empty_dir).unwrap();
8389
8390 let tool = LsTool::new(tmp.path());
8391 let out = tool
8392 .execute(
8393 "t",
8394 serde_json::json!({ "path": empty_dir.to_string_lossy() }),
8395 None,
8396 )
8397 .await
8398 .unwrap();
8399 assert!(!out.is_error);
8400 });
8401 }
8402
8403 #[test]
8404 fn test_ls_default_cwd() {
8405 asupersync::test_utils::run_test(|| async {
8406 let tmp = tempfile::tempdir().unwrap();
8407 std::fs::write(tmp.path().join("in_cwd.txt"), "").unwrap();
8408
8409 let tool = LsTool::new(tmp.path());
8410 let out = tool
8411 .execute("t", serde_json::json!({}), None)
8412 .await
8413 .unwrap();
8414 let text = get_text(&out.content);
8415 assert!(
8416 text.contains("in_cwd.txt"),
8417 "expected cwd listing to include the file, got: {text}"
8418 );
8419 });
8420 }
8421
8422 #[test]
8427 fn test_truncate_head_no_truncation() {
8428 let content = "short".to_string();
8429 let result = truncate_head(content, 100, 1000);
8430 assert!(!result.truncated);
8431 assert_eq!(result.content, "short");
8432 assert_eq!(result.truncated_by, None);
8433 }
8434
8435 #[test]
8436 fn test_truncate_tail_no_truncation() {
8437 let content = "short".to_string();
8438 let result = truncate_tail(content, 100, 1000);
8439 assert!(!result.truncated);
8440 assert_eq!(result.content, "short");
8441 }
8442
8443 #[test]
8444 fn test_truncate_head_empty_input() {
8445 let result = truncate_head(String::new(), 100, 1000);
8446 assert!(!result.truncated);
8447 assert_eq!(result.content, "");
8448 }
8449
8450 #[test]
8451 fn test_truncate_tail_empty_input() {
8452 let result = truncate_tail(String::new(), 100, 1000);
8453 assert!(!result.truncated);
8454 assert_eq!(result.content, "");
8455 }
8456
8457 #[test]
8458 fn test_detect_line_ending_crlf() {
8459 assert_eq!(detect_line_ending("hello\r\nworld"), "\r\n");
8460 }
8461
8462 #[test]
8463 fn test_detect_line_ending_cr() {
8464 assert_eq!(detect_line_ending("hello\rworld"), "\r");
8465 }
8466
8467 #[test]
8468 fn test_detect_line_ending_lf() {
8469 assert_eq!(detect_line_ending("hello\nworld"), "\n");
8470 }
8471
8472 #[test]
8473 fn test_detect_line_ending_no_newline() {
8474 assert_eq!(detect_line_ending("hello world"), "\n");
8475 }
8476
8477 #[test]
8478 fn test_normalize_to_lf() {
8479 assert_eq!(normalize_to_lf("a\r\nb\rc\nd"), "a\nb\nc\nd");
8480 }
8481
8482 #[test]
8483 fn test_count_overlapping_occurrences() {
8484 assert_eq!(count_overlapping_occurrences("aaaa", "aa"), 3);
8485 assert_eq!(count_overlapping_occurrences("abababa", "aba"), 3);
8486 assert_eq!(count_overlapping_occurrences("abc", "d"), 0);
8487 assert_eq!(count_overlapping_occurrences("abc", ""), 0);
8488 }
8489
8490 proptest! {
8491 #![proptest_config(ProptestConfig { cases: 64, .. ProptestConfig::default() })]
8492
8493 #[test]
8494 fn proptest_line_ending_roundtrip_invariant(
8495 input in arbitrary_text(),
8496 ending in prop_oneof![
8497 Just("\n".to_string()),
8498 Just("\r\n".to_string()),
8499 Just("\r".to_string()),
8500 ],
8501 ) {
8502 let normalized = normalize_to_lf(&input);
8503 let restored = restore_line_endings(&normalized, &ending);
8504 let renormalized = normalize_to_lf(&restored);
8505 prop_assert_eq!(renormalized, normalized);
8506 }
8507 }
8508
8509 #[test]
8510 fn test_strip_bom_present() {
8511 let (result, had_bom) = strip_bom("\u{FEFF}hello");
8512 assert_eq!(result, "hello");
8513 assert!(had_bom);
8514 }
8515
8516 #[test]
8517 fn test_strip_bom_absent() {
8518 let (result, had_bom) = strip_bom("hello");
8519 assert_eq!(result, "hello");
8520 assert!(!had_bom);
8521 }
8522
8523 #[test]
8524 fn test_resolve_path_tilde_expansion() {
8525 let cwd = PathBuf::from("/home/user/project");
8526 let result = resolve_path("~/file.txt", &cwd);
8527 assert!(!result.to_string_lossy().starts_with("~/"));
8529 }
8530
8531 fn arbitrary_text() -> impl Strategy<Value = String> {
8532 prop::collection::vec(any::<u8>(), 0..512)
8533 .prop_map(|bytes| String::from_utf8_lossy(&bytes).into_owned())
8534 }
8535
8536 fn match_char_strategy() -> impl Strategy<Value = char> {
8537 prop_oneof![
8538 8 => any::<char>(),
8539 1 => Just('\u{00A0}'),
8540 1 => Just('\u{202F}'),
8541 1 => Just('\u{205F}'),
8542 1 => Just('\u{3000}'),
8543 1 => Just('\u{2018}'),
8544 1 => Just('\u{2019}'),
8545 1 => Just('\u{201C}'),
8546 1 => Just('\u{201D}'),
8547 1 => Just('\u{201E}'),
8548 1 => Just('\u{201F}'),
8549 1 => Just('\u{2010}'),
8550 1 => Just('\u{2011}'),
8551 1 => Just('\u{2012}'),
8552 1 => Just('\u{2013}'),
8553 1 => Just('\u{2014}'),
8554 1 => Just('\u{2015}'),
8555 1 => Just('\u{2212}'),
8556 1 => Just('\u{200D}'),
8557 1 => Just('\u{0301}'),
8558 ]
8559 }
8560
8561 fn arbitrary_match_text() -> impl Strategy<Value = String> {
8562 prop_oneof![
8563 9 => prop::collection::vec(match_char_strategy(), 0..2048),
8564 1 => prop::collection::vec(match_char_strategy(), 8192..16384),
8565 ]
8566 .prop_map(|chars| chars.into_iter().collect())
8567 }
8568
8569 fn line_char_strategy() -> impl Strategy<Value = char> {
8570 prop_oneof![
8571 8 => any::<char>().prop_filter("single-line chars only", |c| *c != '\n'),
8572 1 => Just('é'),
8573 1 => Just('你'),
8574 1 => Just('😀'),
8575 ]
8576 }
8577
8578 fn boundary_line_text() -> impl Strategy<Value = String> {
8579 prop_oneof![
8580 Just(0usize),
8581 Just(GREP_MAX_LINE_LENGTH.saturating_sub(1)),
8582 Just(GREP_MAX_LINE_LENGTH),
8583 Just(GREP_MAX_LINE_LENGTH + 1),
8584 0usize..(GREP_MAX_LINE_LENGTH + 128),
8585 ]
8586 .prop_flat_map(|len| {
8587 prop::collection::vec(line_char_strategy(), len)
8588 .prop_map(|chars| chars.into_iter().collect())
8589 })
8590 }
8591
8592 fn safe_relative_segment() -> impl Strategy<Value = String> {
8593 prop_oneof![
8594 proptest::string::string_regex("[A-Za-z0-9._-]{1,12}")
8595 .expect("segment regex should compile"),
8596 Just("emoji😀".to_string()),
8597 Just("accent-é".to_string()),
8598 Just("rtl-עברית".to_string()),
8599 Just("line\nbreak".to_string()),
8600 Just("nul\0byte".to_string()),
8601 ]
8602 .prop_filter("segment cannot be . or ..", |segment| {
8603 segment != "." && segment != ".."
8604 })
8605 }
8606
8607 fn safe_relative_path() -> impl Strategy<Value = String> {
8608 prop::collection::vec(safe_relative_segment(), 1..6).prop_map(|segments| segments.join("/"))
8609 }
8610
8611 fn pathish_input() -> impl Strategy<Value = String> {
8612 prop_oneof![
8613 5 => safe_relative_path(),
8614 2 => safe_relative_path().prop_map(|p| format!("../{p}")),
8615 2 => safe_relative_path().prop_map(|p| format!("../../{p}")),
8616 1 => safe_relative_path().prop_map(|p| format!("/tmp/{p}")),
8617 1 => safe_relative_path().prop_map(|p| format!("~/{p}")),
8618 1 => Just("~".to_string()),
8619 1 => Just(".".to_string()),
8620 1 => Just("..".to_string()),
8621 1 => Just("././nested/../file.txt".to_string()),
8622 ]
8623 }
8624
8625 proptest! {
8626 #![proptest_config(ProptestConfig { cases: 64, .. ProptestConfig::default() })]
8627
8628 #[test]
8629 fn proptest_truncate_head_invariants(
8630 input in arbitrary_text(),
8631 max_lines in 0usize..32,
8632 max_bytes in 0usize..256,
8633 ) {
8634 let result = truncate_head(input.clone(), max_lines, max_bytes);
8635
8636 prop_assert!(result.output_lines <= max_lines);
8637 prop_assert!(result.output_bytes <= max_bytes);
8638 prop_assert_eq!(result.output_bytes, result.content.len());
8639
8640 prop_assert_eq!(result.truncated, result.truncated_by.is_some());
8641 prop_assert!(input.starts_with(&result.content));
8642
8643 let repeat = truncate_head(result.content.clone(), max_lines, max_bytes);
8644 prop_assert_eq!(&repeat.content, &result.content);
8645
8646 if result.truncated {
8647 prop_assert!(result.total_lines > max_lines || result.total_bytes > max_bytes);
8648 } else {
8649 prop_assert_eq!(&result.content, &input);
8650 prop_assert!(result.total_lines <= max_lines);
8651 prop_assert!(result.total_bytes <= max_bytes);
8652 }
8653
8654 if result.first_line_exceeds_limit {
8655 prop_assert!(result.truncated);
8656 prop_assert_eq!(result.truncated_by, Some(TruncatedBy::Bytes));
8657 prop_assert!(result.output_bytes <= max_bytes);
8658 prop_assert!(result.output_lines <= 1);
8659 prop_assert!(input.starts_with(&result.content));
8660 }
8661 }
8662
8663 #[test]
8664 fn proptest_truncate_tail_invariants(
8665 input in arbitrary_text(),
8666 max_lines in 0usize..32,
8667 max_bytes in 0usize..256,
8668 ) {
8669 let result = truncate_tail(input.clone(), max_lines, max_bytes);
8670
8671 prop_assert!(result.output_lines <= max_lines);
8672 prop_assert!(result.output_bytes <= max_bytes);
8673 prop_assert_eq!(result.output_bytes, result.content.len());
8674
8675 prop_assert_eq!(result.truncated, result.truncated_by.is_some());
8676 prop_assert!(input.ends_with(&result.content));
8677
8678 let repeat = truncate_tail(result.content.clone(), max_lines, max_bytes);
8679 prop_assert_eq!(&repeat.content, &result.content);
8680
8681 if result.last_line_partial {
8682 prop_assert!(result.truncated);
8683 prop_assert_eq!(result.truncated_by, Some(TruncatedBy::Bytes));
8684 prop_assert!(result.output_lines >= 1 && result.output_lines <= 2);
8687 let content_trimmed = result.content.trim_end_matches('\n');
8688 prop_assert!(input
8689 .split('\n')
8690 .rev()
8691 .any(|line| line.ends_with(content_trimmed)));
8692 }
8693 }
8694
8695 #[test]
8696 fn proptest_truncate_head_monotonic_limits(
8697 input in arbitrary_text(),
8698 max_lines_a in 0usize..32,
8699 max_lines_b in 0usize..32,
8700 max_bytes_a in 0usize..256,
8701 max_bytes_b in 0usize..256,
8702 ) {
8703 let low_lines = max_lines_a.min(max_lines_b);
8704 let high_lines = max_lines_a.max(max_lines_b);
8705 let low_bytes = max_bytes_a.min(max_bytes_b);
8706 let high_bytes = max_bytes_a.max(max_bytes_b);
8707
8708 let small = truncate_head(input.clone(), low_lines, low_bytes);
8709 let large = truncate_head(input, high_lines, high_bytes);
8710
8711 prop_assert!(large.content.starts_with(&small.content));
8712 prop_assert!(large.output_bytes >= small.output_bytes);
8713 prop_assert!(large.output_lines >= small.output_lines);
8714 }
8715
8716 #[test]
8717 fn proptest_truncate_tail_monotonic_limits(
8718 input in arbitrary_text(),
8719 max_lines_a in 0usize..32,
8720 max_lines_b in 0usize..32,
8721 max_bytes_a in 0usize..256,
8722 max_bytes_b in 0usize..256,
8723 ) {
8724 let low_lines = max_lines_a.min(max_lines_b);
8725 let high_lines = max_lines_a.max(max_lines_b);
8726 let low_bytes = max_bytes_a.min(max_bytes_b);
8727 let high_bytes = max_bytes_a.max(max_bytes_b);
8728
8729 let small = truncate_tail(input.clone(), low_lines, low_bytes);
8730 let large = truncate_tail(input, high_lines, high_bytes);
8731
8732 prop_assert!(large.content.ends_with(&small.content));
8733 prop_assert!(large.output_bytes >= small.output_bytes);
8734 prop_assert!(large.output_lines >= small.output_lines);
8735 }
8736
8737 #[test]
8738 fn proptest_truncate_head_prefix_invariant_under_append(
8739 base in arbitrary_text(),
8740 suffix in arbitrary_text(),
8741 max_lines in 0usize..32,
8742 max_bytes in 0usize..256,
8743 ) {
8744 let base_result = truncate_head(base.clone(), max_lines, max_bytes);
8745 let extended_result = truncate_head(format!("{base}{suffix}"), max_lines, max_bytes);
8746 prop_assert!(extended_result.content.starts_with(&base_result.content));
8747 }
8748
8749 #[test]
8750 fn proptest_truncate_tail_suffix_invariant_under_prepend(
8751 base in arbitrary_text(),
8752 prefix in arbitrary_text(),
8753 max_lines in 0usize..32,
8754 max_bytes in 0usize..256,
8755 ) {
8756 let base_result = truncate_tail(base.clone(), max_lines, max_bytes);
8757 let extended_result = truncate_tail(format!("{prefix}{base}"), max_lines, max_bytes);
8758 prop_assert!(extended_result.content.ends_with(&base_result.content));
8759 }
8760 }
8761
8762 proptest! {
8763 #![proptest_config(ProptestConfig { cases: 128, .. ProptestConfig::default() })]
8764
8765 #[test]
8766 fn proptest_normalize_for_match_invariants(input in arbitrary_match_text()) {
8767 let normalized = normalize_for_match(&input);
8768 let renormalized = normalize_for_match(&normalized);
8769
8770 prop_assert_eq!(&renormalized, &normalized);
8771 prop_assert!(normalized.len() <= input.len());
8772 prop_assert!(
8773 normalized.chars().all(|c| {
8774 !is_special_unicode_space(c)
8775 && !matches!(
8776 c,
8777 '\u{2018}'
8778 | '\u{2019}'
8779 | '\u{201C}'
8780 | '\u{201D}'
8781 | '\u{201E}'
8782 | '\u{201F}'
8783 | '\u{2010}'
8784 | '\u{2011}'
8785 | '\u{2012}'
8786 | '\u{2013}'
8787 | '\u{2014}'
8788 | '\u{2015}'
8789 | '\u{2212}'
8790 )
8791 }),
8792 "normalize_for_match should remove target punctuation/space variants"
8793 );
8794 }
8795
8796 #[test]
8797 fn proptest_truncate_line_boundary_invariants(line in boundary_line_text()) {
8798 const TRUNCATION_SUFFIX: &str = "... [truncated]";
8799
8800 let result = truncate_line(&line, GREP_MAX_LINE_LENGTH);
8801 let line_char_count = line.chars().count();
8802 let suffix_chars = TRUNCATION_SUFFIX.chars().count();
8803
8804 if line_char_count <= GREP_MAX_LINE_LENGTH {
8805 prop_assert!(!result.was_truncated);
8806 prop_assert_eq!(result.text, line);
8807 } else {
8808 prop_assert!(result.was_truncated);
8809 prop_assert!(result.text.ends_with(TRUNCATION_SUFFIX));
8810 let expected_prefix: String = line.chars().take(GREP_MAX_LINE_LENGTH).collect();
8811 let expected = format!("{expected_prefix}{TRUNCATION_SUFFIX}");
8812 prop_assert_eq!(&result.text, &expected);
8813 prop_assert!(result.text.chars().count() <= GREP_MAX_LINE_LENGTH + suffix_chars);
8814 }
8815 }
8816
8817 #[test]
8818 fn proptest_resolve_path_safe_relative_invariants(relative_path in safe_relative_path()) {
8819 let cwd = PathBuf::from("/tmp/pi-agent-rust-tools-proptest");
8820 let resolved = resolve_path(&relative_path, &cwd);
8821 let normalized = normalize_dot_segments(&resolved);
8822
8823 prop_assert_eq!(&resolved, &cwd.join(&relative_path));
8824 prop_assert!(resolved.starts_with(&cwd));
8825 prop_assert!(normalized.starts_with(&cwd));
8826 prop_assert_eq!(normalize_dot_segments(&normalized), normalized);
8827 }
8828
8829 #[test]
8830 fn proptest_normalize_dot_segments_pathish_invariants(path_input in pathish_input()) {
8831 let cwd = PathBuf::from("/tmp/pi-agent-rust-tools-proptest");
8832 let resolved = resolve_path(&path_input, &cwd);
8833 let normalized_once = normalize_dot_segments(&resolved);
8834 let normalized_twice = normalize_dot_segments(&normalized_once);
8835
8836 prop_assert_eq!(&normalized_once, &normalized_twice);
8837 prop_assert!(
8838 normalized_once
8839 .components()
8840 .all(|component| !matches!(component, std::path::Component::CurDir))
8841 );
8842
8843 if std::path::Path::new(&path_input).is_absolute() {
8844 prop_assert!(resolved.is_absolute());
8845 prop_assert!(normalized_once.is_absolute());
8846 }
8847 }
8848 }
8849
8850 fn fuzzy_content_strategy() -> impl Strategy<Value = String> {
8858 prop::collection::vec(
8859 prop_oneof![
8860 8 => any::<char>().prop_filter("no nul", |c| *c != '\0'),
8861 1 => Just('\u{00A0}'),
8862 1 => Just('\u{2019}'),
8863 1 => Just('\u{201C}'),
8864 1 => Just('\u{2014}'),
8865 ],
8866 1..512,
8867 )
8868 .prop_map(|chars| chars.into_iter().collect())
8869 }
8870
8871 fn needle_from_content(content: String) -> impl Strategy<Value = (String, String)> {
8874 let len = content.len();
8875 if len == 0 {
8876 return Just((content, String::new())).boxed();
8877 }
8878 (0..len)
8879 .prop_flat_map(move |start| {
8880 let c = content.clone();
8881 let remaining = c.len() - start;
8882 let max_needle = remaining.min(256);
8883 (Just(c), start..=start + max_needle.saturating_sub(1))
8884 })
8885 .prop_filter_map("valid char boundary", |(c, end)| {
8886 let start_candidates: Vec<usize> =
8888 (0..c.len()).filter(|i| c.is_char_boundary(*i)).collect();
8889 if start_candidates.is_empty() {
8890 return None;
8891 }
8892 let start = *start_candidates
8893 .iter()
8894 .min_by_key(|&&i| i.abs_diff(end.saturating_sub(end / 2)))
8895 .unwrap_or(&0);
8896 let end_clamped = end.min(c.len());
8897 let actual_end = (end_clamped..=c.len())
8899 .find(|i| c.is_char_boundary(*i))
8900 .unwrap_or(c.len());
8901 if start >= actual_end {
8902 return Some((c, String::new()));
8903 }
8904 Some((c.clone(), c[start..actual_end].to_string()))
8905 })
8906 .boxed()
8907 }
8908
8909 proptest! {
8910 #![proptest_config(ProptestConfig { cases: 128, .. ProptestConfig::default() })]
8911
8912 #[test]
8914 fn proptest_fuzzy_find_text_exact_match_invariants(
8915 (content, needle) in fuzzy_content_strategy().prop_flat_map(needle_from_content)
8916 ) {
8917 let result = fuzzy_find_text(&content, &needle);
8918 if needle.is_empty() {
8919 prop_assert!(result.found, "empty needle should always match");
8921 prop_assert_eq!(result.index, 0);
8922 prop_assert_eq!(result.match_length, 0);
8923 } else {
8924 prop_assert!(
8925 result.found,
8926 "exact substring must be found: content len={}, needle len={}",
8927 content.len(),
8928 needle.len()
8929 );
8930 prop_assert!(content.is_char_boundary(result.index));
8932 prop_assert!(content.is_char_boundary(result.index + result.match_length));
8933 let matched = &content[result.index..result.index + result.match_length];
8935 prop_assert_eq!(matched, needle.as_str());
8936 }
8937 }
8938
8939 #[test]
8944 fn proptest_fuzzy_find_text_normalized_match_invariants(
8945 content in arbitrary_match_text()
8946 ) {
8947 let normalized = build_normalized_content(&content);
8949 if normalized.is_empty() {
8950 return Ok(());
8951 }
8952 let needle_end = normalized
8954 .char_indices()
8955 .nth(128.min(normalized.chars().count().saturating_sub(1)))
8956 .map_or(normalized.len(), |(i, _)| i);
8957 let needle_end = (needle_end..=normalized.len())
8959 .find(|i| normalized.is_char_boundary(*i))
8960 .unwrap_or(normalized.len());
8961 let needle = &normalized[..needle_end];
8962 if needle.is_empty() {
8963 return Ok(());
8964 }
8965
8966 let result = fuzzy_find_text(&content, needle);
8967 prop_assert!(
8968 result.found,
8969 "normalized needle should be found via fuzzy match: needle={:?}",
8970 needle
8971 );
8972 prop_assert!(content.is_char_boundary(result.index));
8974 prop_assert!(content.is_char_boundary(result.index + result.match_length));
8975 }
8976
8977 #[test]
8980 fn proptest_build_normalized_content_invariants(input in arbitrary_match_text()) {
8981 let normalized = build_normalized_content(&input);
8982 let renormalized = build_normalized_content(&normalized);
8983
8984 prop_assert_eq!(
8986 &renormalized,
8987 &normalized,
8988 "build_normalized_content should be idempotent"
8989 );
8990
8991 prop_assert!(
8995 normalized.len() <= input.len(),
8996 "normalized should not be larger: {} vs {}",
8997 normalized.len(),
8998 input.len()
8999 );
9000
9001 let input_lines = input.split('\n').count();
9004 let norm_lines = normalized.split('\n').count();
9005 prop_assert_eq!(
9006 norm_lines, input_lines,
9007 "line count must be preserved by normalization"
9008 );
9009
9010 prop_assert!(
9012 normalized.chars().all(|c| {
9013 !is_special_unicode_space(c)
9014 && !matches!(
9015 c,
9016 '\u{2018}'
9017 | '\u{2019}'
9018 | '\u{201C}'
9019 | '\u{201D}'
9020 | '\u{201E}'
9021 | '\u{201F}'
9022 | '\u{2010}'
9023 | '\u{2011}'
9024 | '\u{2012}'
9025 | '\u{2013}'
9026 | '\u{2014}'
9027 | '\u{2015}'
9028 | '\u{2212}'
9029 )
9030 }),
9031 "normalized content should not contain target Unicode chars"
9032 );
9033 }
9034
9035 #[test]
9038 fn proptest_build_normalized_content_trailing_whitespace_invariant(
9039 input in arbitrary_match_text()
9040 ) {
9041 let normalized = build_normalized_content(&input);
9042 let mut with_trailing = String::new();
9043 let mut lines = input.split('\n').peekable();
9044
9045 while let Some(line) = lines.next() {
9046 with_trailing.push_str(line);
9047 with_trailing.push_str(" \t");
9048 if lines.peek().is_some() {
9049 with_trailing.push('\n');
9050 }
9051 }
9052
9053 let normalized_trailing = build_normalized_content(&with_trailing);
9054 prop_assert_eq!(normalized_trailing, normalized);
9055 }
9056
9057 #[test]
9065 fn proptest_map_normalized_range_roundtrip(input in arbitrary_match_text()) {
9066 let normalized = build_normalized_content(&input);
9067 if normalized.is_empty() {
9068 return Ok(());
9069 }
9070
9071 let norm_chars: Vec<(usize, char)> = normalized.char_indices().collect();
9073 let norm_len = norm_chars.len();
9074 if norm_len == 0 {
9075 return Ok(());
9076 }
9077
9078 let end_char = (norm_len / 4).max(1).min(norm_len);
9080 let norm_start = norm_chars[0].0;
9081 let norm_end = if end_char < norm_chars.len() {
9082 norm_chars[end_char].0
9083 } else {
9084 normalized.len()
9085 };
9086 let norm_match_len = norm_end - norm_start;
9087
9088 let (orig_start, orig_len) =
9089 map_normalized_range_to_original(&input, norm_start, norm_match_len);
9090
9091 prop_assert!(
9093 orig_start + orig_len <= input.len(),
9094 "mapped range {orig_start}..{} exceeds input len {}",
9095 orig_start + orig_len,
9096 input.len()
9097 );
9098
9099 prop_assert!(
9101 input.is_char_boundary(orig_start),
9102 "orig_start {} is not a char boundary",
9103 orig_start
9104 );
9105 prop_assert!(
9106 input.is_char_boundary(orig_start + orig_len),
9107 "orig_end {} is not a char boundary",
9108 orig_start + orig_len
9109 );
9110
9111 prop_assert!(
9115 orig_len >= norm_match_len
9116 || orig_len == 0
9117 || norm_match_len == 0,
9118 "original range ({orig_len}) should be >= normalized range ({norm_match_len})"
9119 );
9120
9121 let expected_norm = &normalized[norm_start..norm_end];
9125 if !expected_norm.is_empty() {
9126 let fuzzy_result = fuzzy_find_text(&input, expected_norm);
9127 prop_assert!(
9128 fuzzy_result.found,
9129 "normalized needle should be findable in original content"
9130 );
9131 }
9132 }
9133 }
9134
9135 #[test]
9136 fn test_truncate_head_preserves_newline() {
9137 let content = "Line1\nLine2".to_string();
9139 let result = truncate_head(content, 1, 1000);
9140 assert_eq!(result.content, "Line1\n");
9141
9142 let content = "Line1".to_string();
9144 let result = truncate_head(content, 1, 1000);
9145 assert_eq!(result.content, "Line1");
9146
9147 let content = "Line1\n".to_string();
9149 let result = truncate_head(content, 1, 1000);
9150 assert_eq!(result.content, "Line1\n");
9151 }
9152
9153 #[test]
9154 fn test_edit_crlf_content_correctness() {
9155 asupersync::test_utils::run_test(|| async {
9157 let tmp = tempfile::tempdir().unwrap();
9158 let path = tmp.path().join("crlf.txt");
9159 let content = "line1\r\nline2\r\nline3";
9161 std::fs::write(&path, content).unwrap();
9162
9163 let tool = EditTool::new(tmp.path());
9164
9165 let out = tool
9170 .execute(
9171 "t",
9172 serde_json::json!({
9173 "path": path.to_string_lossy(),
9174 "oldText": "line2",
9175 "newText": "changed"
9176 }),
9177 None,
9178 )
9179 .await
9180 .unwrap();
9181
9182 assert!(!out.is_error);
9183 let new_content = std::fs::read_to_string(&path).unwrap();
9184
9185 assert_eq!(new_content, "line1\r\nchanged\r\nline3");
9187 });
9188 }
9189
9190 #[test]
9191 fn test_edit_cr_content_correctness() {
9192 asupersync::test_utils::run_test(|| async {
9193 let tmp = tempfile::tempdir().unwrap();
9194 let path = tmp.path().join("cr.txt");
9195 std::fs::write(&path, "line1\rline2\rline3").unwrap();
9196
9197 let tool = EditTool::new(tmp.path());
9198 let out = tool
9199 .execute(
9200 "t",
9201 serde_json::json!({
9202 "path": path.to_string_lossy(),
9203 "oldText": "line2",
9204 "newText": "changed"
9205 }),
9206 None,
9207 )
9208 .await
9209 .unwrap();
9210
9211 assert!(!out.is_error);
9212 let new_content = std::fs::read_to_string(&path).unwrap();
9213 assert_eq!(new_content, "line1\rchanged\rline3");
9214 });
9215 }
9216
9217 #[test]
9222 fn test_compute_line_hash_basic() {
9223 let h1 = compute_line_hash(0, "fn main() {");
9225 let h2 = compute_line_hash(0, "fn main() {");
9226 assert_eq!(h1, h2);
9227
9228 let h3 = compute_line_hash(0, "fn foo() {");
9230 assert_ne!(h1, h3);
9232
9233 for &b in &h1 {
9235 assert!(NIBBLE_STR.contains(&b), "hash byte {b} not in NIBBLE_STR");
9236 }
9237 }
9238
9239 #[test]
9240 fn test_compute_line_hash_punctuation_only() {
9241 let h1 = compute_line_hash(0, "}");
9244 let h2 = compute_line_hash(1, "}");
9245 assert_ne!(
9246 h1, h2,
9247 "punctuation-only lines at different indices should differ"
9248 );
9249
9250 let h3 = compute_line_hash(0, "");
9252 let h4 = compute_line_hash(1, "");
9253 assert_ne!(h3, h4);
9254 }
9255
9256 #[test]
9257 fn test_compute_line_hash_whitespace_invariant() {
9258 let h1 = compute_line_hash(0, "return 42;");
9260 let h2 = compute_line_hash(0, " return 42;");
9261 let h3 = compute_line_hash(0, "\treturn 42;");
9262 assert_eq!(h1, h2);
9263 assert_eq!(h1, h3);
9264 }
9265
9266 #[test]
9267 fn test_format_hashline_tag() {
9268 let tag = format_hashline_tag(0, "fn main() {");
9269 assert!(
9271 tag.starts_with("1#"),
9272 "tag should start with 1#, got: {tag}"
9273 );
9274 assert_eq!(tag.len(), 4, "tag should be 4 chars: N#AB");
9275
9276 let tag10 = format_hashline_tag(9, "line 10");
9277 assert!(tag10.starts_with("10#"));
9278 assert_eq!(tag10.len(), 5); }
9280
9281 #[test]
9282 fn test_parse_hashline_tag_valid() {
9283 let (line, hash) = parse_hashline_tag("5#KJ").unwrap();
9285 assert_eq!(line, 5);
9286 assert_eq!(hash, [b'K', b'J']);
9287
9288 let (line, hash) = parse_hashline_tag(" 10 # QR ").unwrap();
9290 assert_eq!(line, 10);
9291 assert_eq!(hash, [b'Q', b'R']);
9292
9293 let (line, hash) = parse_hashline_tag("> + 3#ZZ").unwrap();
9295 assert_eq!(line, 3);
9296 assert_eq!(hash, [b'Z', b'Z']);
9297 }
9298
9299 #[test]
9300 fn test_parse_hashline_tag_invalid() {
9301 assert!(parse_hashline_tag("0#KJ").is_err());
9303 assert!(parse_hashline_tag("5#").is_err());
9305 assert!(parse_hashline_tag("5#AA").is_err()); assert!(parse_hashline_tag("#KJ").is_err());
9309 assert!(parse_hashline_tag("").is_err());
9311 }
9312
9313 #[test]
9314 fn test_strip_hashline_prefix() {
9315 assert_eq!(strip_hashline_prefix("5#KJ:hello world"), "hello world");
9316 assert_eq!(strip_hashline_prefix("100#ZZ:fn main() {"), "fn main() {");
9317 assert_eq!(strip_hashline_prefix(" 5 # KJ:hello world"), "hello world");
9318 assert_eq!(strip_hashline_prefix("> + 5#KJ:hello world"), "hello world");
9319 assert_eq!(strip_hashline_prefix("5#KJ :hello world"), "hello world");
9320 assert_eq!(strip_hashline_prefix("hello world"), "hello world");
9322 assert_eq!(strip_hashline_prefix(""), "");
9323 }
9324
9325 #[test]
9326 fn test_hashline_edit_single_replace() {
9327 asupersync::test_utils::run_test(|| async {
9328 let dir = tempfile::tempdir().unwrap();
9329 let file = dir.path().join("test.txt");
9330 std::fs::write(&file, "line1\nline2\nline3\n").unwrap();
9331
9332 let tool = HashlineEditTool::new(dir.path());
9333
9334 let tag2 = format_hashline_tag(1, "line2");
9336
9337 let input = serde_json::json!({
9338 "path": file.to_str().unwrap(),
9339 "edits": [{
9340 "op": "replace",
9341 "pos": tag2,
9342 "lines": ["changed"]
9343 }]
9344 });
9345
9346 let out = tool.execute("test", input, None).await.unwrap();
9347 assert!(!out.is_error);
9348
9349 let content = std::fs::read_to_string(&file).unwrap();
9350 assert_eq!(content, "line1\nchanged\nline3\n");
9351 });
9352 }
9353
9354 #[test]
9355 fn test_hashline_edit_range_replace() {
9356 asupersync::test_utils::run_test(|| async {
9357 let dir = tempfile::tempdir().unwrap();
9358 let file = dir.path().join("test.txt");
9359 std::fs::write(&file, "a\nb\nc\nd\ne\n").unwrap();
9360
9361 let tool = HashlineEditTool::new(dir.path());
9362
9363 let tag_b = format_hashline_tag(1, "b");
9364 let tag_d = format_hashline_tag(3, "d");
9365
9366 let input = serde_json::json!({
9367 "path": file.to_str().unwrap(),
9368 "edits": [{
9369 "op": "replace",
9370 "pos": tag_b,
9371 "end": tag_d,
9372 "lines": ["X", "Y"]
9373 }]
9374 });
9375
9376 let out = tool.execute("test", input, None).await.unwrap();
9377 assert!(!out.is_error);
9378
9379 let content = std::fs::read_to_string(&file).unwrap();
9380 assert_eq!(content, "a\nX\nY\ne\n");
9381 });
9382 }
9383
9384 #[test]
9385 fn test_hashline_edit_prepend() {
9386 asupersync::test_utils::run_test(|| async {
9387 let dir = tempfile::tempdir().unwrap();
9388 let file = dir.path().join("test.txt");
9389 std::fs::write(&file, "a\nb\nc\n").unwrap();
9390
9391 let tool = HashlineEditTool::new(dir.path());
9392 let tag_b = format_hashline_tag(1, "b");
9393
9394 let input = serde_json::json!({
9395 "path": file.to_str().unwrap(),
9396 "edits": [{
9397 "op": "prepend",
9398 "pos": tag_b,
9399 "lines": ["inserted"]
9400 }]
9401 });
9402
9403 let out = tool.execute("test", input, None).await.unwrap();
9404 assert!(!out.is_error);
9405
9406 let content = std::fs::read_to_string(&file).unwrap();
9407 assert_eq!(content, "a\ninserted\nb\nc\n");
9408 });
9409 }
9410
9411 #[test]
9412 fn test_hashline_edit_append() {
9413 asupersync::test_utils::run_test(|| async {
9414 let dir = tempfile::tempdir().unwrap();
9415 let file = dir.path().join("test.txt");
9416 std::fs::write(&file, "a\nb\nc\n").unwrap();
9417
9418 let tool = HashlineEditTool::new(dir.path());
9419 let tag_b = format_hashline_tag(1, "b");
9420
9421 let input = serde_json::json!({
9422 "path": file.to_str().unwrap(),
9423 "edits": [{
9424 "op": "append",
9425 "pos": tag_b,
9426 "lines": ["inserted"]
9427 }]
9428 });
9429
9430 let out = tool.execute("test", input, None).await.unwrap();
9431 assert!(!out.is_error);
9432
9433 let content = std::fs::read_to_string(&file).unwrap();
9434 assert_eq!(content, "a\nb\ninserted\nc\n");
9435 });
9436 }
9437
9438 #[test]
9439 fn test_hashline_edit_bottom_up_ordering() {
9440 asupersync::test_utils::run_test(|| async {
9441 let dir = tempfile::tempdir().unwrap();
9442 let file = dir.path().join("test.txt");
9443 std::fs::write(&file, "a\nb\nc\nd\n").unwrap();
9444
9445 let tool = HashlineEditTool::new(dir.path());
9446 let tag_b = format_hashline_tag(1, "b");
9447 let tag_d = format_hashline_tag(3, "d");
9448
9449 let input = serde_json::json!({
9451 "path": file.to_str().unwrap(),
9452 "edits": [
9453 { "op": "replace", "pos": tag_b, "lines": ["B"] },
9454 { "op": "replace", "pos": tag_d, "lines": ["D"] }
9455 ]
9456 });
9457
9458 let out = tool.execute("test", input, None).await.unwrap();
9459 assert!(!out.is_error);
9460
9461 let content = std::fs::read_to_string(&file).unwrap();
9462 assert_eq!(content, "a\nB\nc\nD\n");
9463 });
9464 }
9465
9466 #[test]
9467 fn test_hashline_edit_hash_mismatch() {
9468 asupersync::test_utils::run_test(|| async {
9469 let dir = tempfile::tempdir().unwrap();
9470 let file = dir.path().join("test.txt");
9471 std::fs::write(&file, "hello\nworld\n").unwrap();
9472
9473 let tool = HashlineEditTool::new(dir.path());
9474
9475 let input = serde_json::json!({
9477 "path": file.to_str().unwrap(),
9478 "edits": [{
9479 "op": "replace",
9480 "pos": "1#ZZ",
9481 "lines": ["changed"]
9482 }]
9483 });
9484
9485 let result = tool.execute("test", input, None).await;
9486 assert!(result.is_err());
9487 let err_msg = result.unwrap_err().to_string();
9488 assert!(
9489 err_msg.contains("Hash validation failed"),
9490 "error should mention hash validation: {err_msg}"
9491 );
9492 });
9493 }
9494
9495 #[test]
9496 fn test_hashline_edit_dedup() {
9497 asupersync::test_utils::run_test(|| async {
9498 let dir = tempfile::tempdir().unwrap();
9499 let file = dir.path().join("test.txt");
9500 std::fs::write(&file, "a\nb\nc\n").unwrap();
9501
9502 let tool = HashlineEditTool::new(dir.path());
9503 let tag_b = format_hashline_tag(1, "b");
9504
9505 let input = serde_json::json!({
9507 "path": file.to_str().unwrap(),
9508 "edits": [
9509 { "op": "replace", "pos": &tag_b, "lines": ["B"] },
9510 { "op": "replace", "pos": &tag_b, "lines": ["B"] }
9511 ]
9512 });
9513
9514 let out = tool.execute("test", input, None).await.unwrap();
9515 assert!(!out.is_error);
9516
9517 let content = std::fs::read_to_string(&file).unwrap();
9518 assert_eq!(content, "a\nB\nc\n");
9519 });
9520 }
9521
9522 #[test]
9523 fn test_hashline_edit_noop_detection() {
9524 asupersync::test_utils::run_test(|| async {
9525 let dir = tempfile::tempdir().unwrap();
9526 let file = dir.path().join("test.txt");
9527 std::fs::write(&file, "a\nb\nc\n").unwrap();
9528
9529 let tool = HashlineEditTool::new(dir.path());
9530 let tag_b = format_hashline_tag(1, "b");
9531
9532 let input = serde_json::json!({
9534 "path": file.to_str().unwrap(),
9535 "edits": [{
9536 "op": "replace",
9537 "pos": &tag_b,
9538 "lines": ["b"]
9539 }]
9540 });
9541
9542 let result = tool.execute("test", input, None).await;
9543 assert!(result.is_err());
9544 let err_msg = result.unwrap_err().to_string();
9545 assert!(
9546 err_msg.contains("no-ops"),
9547 "error should mention no-ops: {err_msg}"
9548 );
9549 });
9550 }
9551
9552 #[test]
9553 fn test_hashline_read_output_format() {
9554 asupersync::test_utils::run_test(|| async {
9555 let dir = tempfile::tempdir().unwrap();
9556 let file = dir.path().join("test.txt");
9557 std::fs::write(&file, "fn main() {\n println!(\"hello\");\n}\n").unwrap();
9558
9559 let tool = ReadTool::new(dir.path());
9560 let input = serde_json::json!({
9561 "path": file.to_str().unwrap(),
9562 "hashline": true
9563 });
9564
9565 let out = tool.execute("test", input, None).await.unwrap();
9566 assert!(!out.is_error);
9567 let text = get_text(&out.content);
9568
9569 for line in text.lines() {
9571 if line.starts_with('[') || line.is_empty() {
9572 continue; }
9574 assert!(
9575 hashline_tag_regex().is_match(line),
9576 "line should match hashline format: {line:?}"
9577 );
9578 assert!(
9579 line.contains(':'),
9580 "line should contain ':' separator: {line:?}"
9581 );
9582 }
9583
9584 let first_line = text.lines().next().unwrap();
9586 assert!(first_line.starts_with("1#"), "first line: {first_line:?}");
9587 });
9588 }
9589
9590 #[test]
9591 fn test_hashline_edit_prefix_stripping() {
9592 asupersync::test_utils::run_test(|| async {
9593 let dir = tempfile::tempdir().unwrap();
9594 let file = dir.path().join("test.txt");
9595 std::fs::write(&file, "a\nb\nc\n").unwrap();
9596
9597 let tool = HashlineEditTool::new(dir.path());
9598 let tag_b = format_hashline_tag(1, "b");
9599
9600 let input = serde_json::json!({
9602 "path": file.to_str().unwrap(),
9603 "edits": [{
9604 "op": "replace",
9605 "pos": &tag_b,
9606 "lines": ["2#KJ:changed"]
9607 }]
9608 });
9609
9610 let out = tool.execute("test", input, None).await.unwrap();
9611 assert!(!out.is_error);
9612
9613 let content = std::fs::read_to_string(&file).unwrap();
9614 assert_eq!(content, "a\nchanged\nc\n");
9615 });
9616 }
9617
9618 #[test]
9619 fn test_hashline_edit_delete_lines() {
9620 asupersync::test_utils::run_test(|| async {
9621 let dir = tempfile::tempdir().unwrap();
9622 let file = dir.path().join("test.txt");
9623 std::fs::write(&file, "a\nb\nc\nd\n").unwrap();
9624
9625 let tool = HashlineEditTool::new(dir.path());
9626 let tag_b = format_hashline_tag(1, "b");
9627 let tag_c = format_hashline_tag(2, "c");
9628
9629 let input = serde_json::json!({
9631 "path": file.to_str().unwrap(),
9632 "edits": [{
9633 "op": "replace",
9634 "pos": &tag_b,
9635 "end": &tag_c,
9636 "lines": null
9637 }]
9638 });
9639
9640 let out = tool.execute("test", input, None).await.unwrap();
9641 assert!(!out.is_error);
9642
9643 let content = std::fs::read_to_string(&file).unwrap();
9644 assert_eq!(content, "a\nd\n");
9645 });
9646 }
9647
9648 #[test]
9649 fn test_hashline_edit_crlf_preservation() {
9650 asupersync::test_utils::run_test(|| async {
9651 let dir = tempfile::tempdir().unwrap();
9652 let file = dir.path().join("test.txt");
9653 std::fs::write(&file, "line1\r\nline2\r\nline3").unwrap();
9654
9655 let tool = HashlineEditTool::new(dir.path());
9656 let tag2 = format_hashline_tag(1, "line2");
9657
9658 let input = serde_json::json!({
9659 "path": file.to_str().unwrap(),
9660 "edits": [{
9661 "op": "replace",
9662 "pos": tag2,
9663 "lines": ["changed"]
9664 }]
9665 });
9666
9667 let out = tool.execute("test", input, None).await.unwrap();
9668 assert!(!out.is_error);
9669
9670 let content = std::fs::read_to_string(&file).unwrap();
9671 assert_eq!(content, "line1\r\nchanged\r\nline3");
9672 });
9673 }
9674
9675 #[test]
9676 fn test_hashline_edit_cr_preservation() {
9677 asupersync::test_utils::run_test(|| async {
9678 let dir = tempfile::tempdir().unwrap();
9679 let file = dir.path().join("test.txt");
9680 std::fs::write(&file, "line1\rline2\rline3").unwrap();
9681
9682 let tool = HashlineEditTool::new(dir.path());
9683 let tag2 = format_hashline_tag(1, "line2");
9684
9685 let input = serde_json::json!({
9686 "path": file.to_str().unwrap(),
9687 "edits": [{
9688 "op": "replace",
9689 "pos": tag2,
9690 "lines": ["changed"]
9691 }]
9692 });
9693
9694 let out = tool.execute("test", input, None).await.unwrap();
9695 assert!(!out.is_error);
9696
9697 let content = std::fs::read_to_string(&file).unwrap();
9698 assert_eq!(content, "line1\rchanged\rline3");
9699 });
9700 }
9701
9702 #[test]
9703 fn test_hashline_edit_empty_file_append() {
9704 asupersync::test_utils::run_test(|| async {
9705 let dir = tempfile::tempdir().unwrap();
9706 let file = dir.path().join("empty.txt");
9707 std::fs::write(&file, "").unwrap();
9708
9709 let tool = HashlineEditTool::new(dir.path());
9710
9711 let input = serde_json::json!({
9713 "path": file.to_str().unwrap(),
9714 "edits": [{
9715 "op": "append",
9716 "lines": ["new_line"]
9717 }]
9718 });
9719
9720 let out = tool.execute("test", input, None).await.unwrap();
9721 assert!(!out.is_error);
9722
9723 let content = std::fs::read_to_string(&file).unwrap();
9724 assert!(content.contains("new_line"));
9725 });
9726 }
9727
9728 #[test]
9729 fn test_hashline_edit_single_line_no_trailing_newline() {
9730 asupersync::test_utils::run_test(|| async {
9731 let dir = tempfile::tempdir().unwrap();
9732 let file = dir.path().join("single.txt");
9733 std::fs::write(&file, "hello").unwrap();
9734
9735 let tool = HashlineEditTool::new(dir.path());
9736 let tag = format_hashline_tag(0, "hello");
9737
9738 let input = serde_json::json!({
9739 "path": file.to_str().unwrap(),
9740 "edits": [{
9741 "op": "replace",
9742 "pos": tag,
9743 "lines": ["world"]
9744 }]
9745 });
9746
9747 let out = tool.execute("test", input, None).await.unwrap();
9748 assert!(!out.is_error);
9749
9750 let content = std::fs::read_to_string(&file).unwrap();
9751 assert_eq!(content, "world");
9752 });
9753 }
9754
9755 #[test]
9756 fn test_hashline_edit_preserves_bom_hash_validation() {
9757 asupersync::test_utils::run_test(|| async {
9758 let dir = tempfile::tempdir().unwrap();
9759 let file = dir.path().join("bom.txt");
9760 let bom = "\u{FEFF}";
9761 std::fs::write(&file, format!("{bom}alpha\nbeta\n")).unwrap();
9762
9763 let tool = HashlineEditTool::new(dir.path());
9764 let tag1 = format_hashline_tag(0, &format!("{bom}alpha"));
9765
9766 let input = serde_json::json!({
9767 "path": file.to_str().unwrap(),
9768 "edits": [{
9769 "op": "replace",
9770 "pos": tag1,
9771 "lines": ["gamma"]
9772 }]
9773 });
9774
9775 let out = tool.execute("test", input, None).await.unwrap();
9776 assert!(!out.is_error);
9777
9778 let content = std::fs::read_to_string(&file).unwrap();
9779 assert_eq!(content, format!("{bom}gamma\nbeta\n"));
9780 });
9781 }
9782
9783 #[test]
9784 fn test_hashline_edit_bof_prepend_no_pos() {
9785 asupersync::test_utils::run_test(|| async {
9786 let dir = tempfile::tempdir().unwrap();
9787 let file = dir.path().join("test.txt");
9788 std::fs::write(&file, "a\nb\nc\n").unwrap();
9789
9790 let tool = HashlineEditTool::new(dir.path());
9791
9792 let input = serde_json::json!({
9794 "path": file.to_str().unwrap(),
9795 "edits": [{
9796 "op": "prepend",
9797 "lines": ["header"]
9798 }]
9799 });
9800
9801 let out = tool.execute("test", input, None).await.unwrap();
9802 assert!(!out.is_error);
9803
9804 let content = std::fs::read_to_string(&file).unwrap();
9805 assert_eq!(content, "header\na\nb\nc\n");
9806 });
9807 }
9808
9809 #[test]
9810 fn test_hashline_edit_eof_append_no_pos() {
9811 asupersync::test_utils::run_test(|| async {
9812 let dir = tempfile::tempdir().unwrap();
9813 let file = dir.path().join("test.txt");
9814 std::fs::write(&file, "a\nb\nc\n").unwrap();
9815
9816 let tool = HashlineEditTool::new(dir.path());
9817
9818 let input = serde_json::json!({
9820 "path": file.to_str().unwrap(),
9821 "edits": [{
9822 "op": "append",
9823 "lines": ["footer"]
9824 }]
9825 });
9826
9827 let out = tool.execute("test", input, None).await.unwrap();
9828 assert!(!out.is_error);
9829
9830 let content = std::fs::read_to_string(&file).unwrap();
9831 assert!(
9832 content.contains("footer"),
9833 "content should contain footer: {content:?}"
9834 );
9835 });
9836 }
9837
9838 #[test]
9839 fn test_hashline_edit_overlapping_replace_ranges_rejected() {
9840 asupersync::test_utils::run_test(|| async {
9841 let dir = tempfile::tempdir().unwrap();
9842 let file = dir.path().join("test.txt");
9843 std::fs::write(&file, "a\nb\nc\nd\ne\n").unwrap();
9844
9845 let tool = HashlineEditTool::new(dir.path());
9846 let tag_b = format_hashline_tag(1, "b");
9847 let tag_d = format_hashline_tag(3, "d");
9848 let tag_c = format_hashline_tag(2, "c");
9849 let tag_e = format_hashline_tag(4, "e");
9850
9851 let input = serde_json::json!({
9853 "path": file.to_str().unwrap(),
9854 "edits": [
9855 { "op": "replace", "pos": &tag_b, "end": &tag_d, "lines": ["X"] },
9856 { "op": "replace", "pos": &tag_c, "end": &tag_e, "lines": ["Y"] }
9857 ]
9858 });
9859
9860 let result = tool.execute("test", input, None).await;
9861 assert!(result.is_err());
9862 let err_msg = result.unwrap_err().to_string();
9863 assert!(
9864 err_msg.contains("Overlapping"),
9865 "error should mention overlapping: {err_msg}"
9866 );
9867 });
9868 }
9869
9870 #[test]
9871 fn test_hashline_edit_reversed_range_rejected() {
9872 asupersync::test_utils::run_test(|| async {
9873 let dir = tempfile::tempdir().unwrap();
9874 let file = dir.path().join("test.txt");
9875 std::fs::write(&file, "a\nb\nc\nd\n").unwrap();
9876
9877 let tool = HashlineEditTool::new(dir.path());
9878 let tag_b = format_hashline_tag(1, "b");
9879 let tag_d = format_hashline_tag(3, "d");
9880
9881 let input = serde_json::json!({
9883 "path": file.to_str().unwrap(),
9884 "edits": [{
9885 "op": "replace",
9886 "pos": &tag_d,
9887 "end": &tag_b,
9888 "lines": ["X"]
9889 }]
9890 });
9891
9892 let result = tool.execute("test", input, None).await;
9893 assert!(result.is_err());
9894 let err_msg = result.unwrap_err().to_string();
9895 assert!(
9896 err_msg.contains("before start"),
9897 "error should mention before start: {err_msg}"
9898 );
9899 });
9900 }
9901
9902 #[test]
9903 fn test_hashline_edit_trailing_newline_semantics() {
9904 asupersync::test_utils::run_test(|| async {
9905 let dir = tempfile::tempdir().unwrap();
9906 let file = dir.path().join("test.txt");
9907 std::fs::write(&file, "line1\nline2\n").unwrap();
9909
9910 let tool = HashlineEditTool::new(dir.path());
9911 let tag2 = format_hashline_tag(1, "line2");
9912
9913 let input = serde_json::json!({
9915 "path": file.to_str().unwrap(),
9916 "edits": [{
9917 "op": "replace",
9918 "pos": tag2,
9919 "lines": ["changed"]
9920 }]
9921 });
9922
9923 let out = tool.execute("test", input, None).await.unwrap();
9924 assert!(!out.is_error);
9925
9926 let content = std::fs::read_to_string(&file).unwrap();
9927 assert_eq!(content, "line1\nchanged\n");
9928 });
9929 }
9930}