Skip to main content

pi/
tools.rs

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