Skip to main content

pi/
tools.rs

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