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::{safe_canonicalize, strip_unc_prefix};
13use crate::model::{ContentBlock, ImageContent, TextContent};
14use asupersync::io::{AsyncRead, AsyncReadExt, AsyncWriteExt, ReadBuf, SeekFrom};
15use asupersync::time::{sleep, wall_now};
16use async_trait::async_trait;
17use serde::{Deserialize, Serialize};
18use sha2::Digest as _;
19use std::cmp::Ordering;
20use std::collections::{HashMap, VecDeque};
21use std::ffi::{OsStr, OsString};
22use std::fmt::Write as _;
23use std::io::{BufRead, Read, Write};
24use std::path::{Path, PathBuf};
25use std::process::{Command, Stdio};
26use std::sync::{Mutex, OnceLock, mpsc};
27use std::thread;
28use std::time::{Duration, SystemTime, UNIX_EPOCH};
29use unicode_normalization::UnicodeNormalization;
30use uuid::Uuid;
31
32// ============================================================================
33// Tool Trait
34// ============================================================================
35
36/// Coarse side-effect declaration for tool scheduling.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub struct ToolEffects {
39    bits: u8,
40}
41
42impl ToolEffects {
43    const READ: u8 = 1 << 0;
44    const WRITE: u8 = 1 << 1;
45    const APPEND: u8 = 1 << 2;
46    const NETWORK: u8 = 1 << 3;
47    const PROCESS: u8 = 1 << 4;
48    const BARRIER: u8 = Self::WRITE | Self::APPEND | Self::PROCESS;
49
50    /// Tool reads local state without mutating it.
51    #[must_use]
52    pub const fn read() -> Self {
53        Self { bits: Self::READ }
54    }
55
56    /// Tool may create, replace, or otherwise mutate local state.
57    #[must_use]
58    pub const fn write() -> Self {
59        Self { bits: Self::WRITE }
60    }
61
62    /// Tool appends to existing local state.
63    #[must_use]
64    pub const fn append() -> Self {
65        Self { bits: Self::APPEND }
66    }
67
68    /// Tool performs network I/O but does not mutate local state.
69    #[must_use]
70    pub const fn network() -> Self {
71        Self {
72            bits: Self::NETWORK,
73        }
74    }
75
76    /// Tool starts a local process. This is treated as a scheduling barrier.
77    #[must_use]
78    pub const fn process() -> Self {
79        Self {
80            bits: Self::PROCESS,
81        }
82    }
83
84    /// Combine multiple effect declarations for a single tool or batch.
85    #[must_use]
86    pub const fn union(self, other: Self) -> Self {
87        Self {
88            bits: self.bits | other.bits,
89        }
90    }
91
92    /// Whether this declaration reads local state.
93    #[must_use]
94    pub const fn reads(self) -> bool {
95        self.bits & Self::READ != 0
96    }
97
98    /// Whether this declaration may mutate local state by replacing content.
99    #[must_use]
100    pub const fn writes(self) -> bool {
101        self.bits & Self::WRITE != 0
102    }
103
104    /// Whether this declaration may append to local state.
105    #[must_use]
106    pub const fn appends(self) -> bool {
107        self.bits & Self::APPEND != 0
108    }
109
110    /// Whether this declaration performs network I/O.
111    #[must_use]
112    pub const fn networks(self) -> bool {
113        self.bits & Self::NETWORK != 0
114    }
115
116    /// Whether this declaration starts or controls a local process.
117    #[must_use]
118    pub const fn processes(self) -> bool {
119        self.bits & Self::PROCESS != 0
120    }
121
122    /// Stable labels for machine-readable scheduling evidence.
123    #[must_use]
124    pub fn labels(self) -> Vec<&'static str> {
125        let mut labels = Vec::with_capacity(5);
126        if self.reads() {
127            labels.push("read");
128        }
129        if self.writes() {
130            labels.push("write");
131        }
132        if self.appends() {
133            labels.push("append");
134        }
135        if self.networks() {
136            labels.push("network");
137        }
138        if self.processes() {
139            labels.push("process");
140        }
141        labels
142    }
143
144    /// Whether this effect set can run in a compatible concurrent batch.
145    #[must_use]
146    pub const fn parallel_safe(self) -> bool {
147        self.bits != 0 && self.bits & Self::BARRIER == 0
148    }
149
150    /// Whether two effect sets can share a concurrent batch.
151    #[must_use]
152    pub const fn compatible_with(self, other: Self) -> bool {
153        self.parallel_safe() && other.parallel_safe()
154    }
155}
156
157/// A tool that can be executed by the agent.
158#[async_trait]
159pub trait Tool: Send + Sync {
160    /// Get the tool name.
161    fn name(&self) -> &str;
162
163    /// Get the tool label (display name).
164    fn label(&self) -> &str;
165
166    /// Get the tool description.
167    fn description(&self) -> &str;
168
169    /// Get the tool parameters as JSON Schema.
170    fn parameters(&self) -> serde_json::Value;
171
172    /// Execute the tool.
173    ///
174    /// Tools may call `on_update` to stream incremental results (e.g. while a long-running `bash`
175    /// command is still producing output). The final return value is a [`ToolOutput`] which is
176    /// persisted into the session as a tool result message.
177    async fn execute(
178        &self,
179        tool_call_id: &str,
180        input: serde_json::Value,
181        on_update: Option<Box<dyn Fn(ToolUpdate) + Send + Sync>>,
182    ) -> Result<ToolOutput>;
183
184    /// Declare the coarse side effects used by the agent scheduler.
185    ///
186    /// Defaults to local write effects so undeclared tools are serialized fail-closed.
187    #[must_use]
188    fn effects(&self) -> ToolEffects {
189        ToolEffects::write()
190    }
191}
192
193/// Tool execution output.
194#[derive(Debug, Clone, Serialize, Deserialize)]
195#[serde(rename_all = "camelCase")]
196pub struct ToolOutput {
197    pub content: Vec<ContentBlock>,
198    pub details: Option<serde_json::Value>,
199    #[serde(default, skip_serializing_if = "is_false")]
200    pub is_error: bool,
201}
202
203#[allow(clippy::trivially_copy_pass_by_ref)] // serde requires `fn(&bool) -> bool` for `skip_serializing_if`
204const fn is_false(value: &bool) -> bool {
205    !*value
206}
207
208/// Incremental update during tool execution.
209#[derive(Debug, Clone, Serialize)]
210#[serde(rename_all = "camelCase")]
211pub struct ToolUpdate {
212    pub content: Vec<ContentBlock>,
213    pub details: Option<serde_json::Value>,
214}
215
216// ============================================================================
217// Truncation
218// ============================================================================
219
220/// Default maximum lines for truncation.
221pub const DEFAULT_MAX_LINES: usize = 2000;
222
223/// Default maximum bytes for truncation.
224pub const DEFAULT_MAX_BYTES: usize = 1_000_000; // 1MB
225
226/// Maximum line length for grep results.
227pub const GREP_MAX_LINE_LENGTH: usize = 500;
228
229/// Default grep result limit.
230pub const DEFAULT_GREP_LIMIT: usize = 100;
231
232/// Default find result limit.
233pub const DEFAULT_FIND_LIMIT: usize = 1000;
234
235/// Default ls result limit.
236pub const DEFAULT_LS_LIMIT: usize = 500;
237
238/// Hard limit for directory scanning in ls tool to prevent OOM/hangs.
239pub const LS_SCAN_HARD_LIMIT: usize = 20_000;
240
241/// Hard limit for read tool file size (100MB) to prevent OOM.
242pub const READ_TOOL_MAX_BYTES: u64 = 100 * 1024 * 1024;
243
244/// Hard limit for write/edit tool file size (100MB) to prevent OOM.
245pub const WRITE_TOOL_MAX_BYTES: usize = 100 * 1024 * 1024;
246
247/// Maximum size for an image to be sent to the API (4.5MB).
248pub const IMAGE_MAX_BYTES: usize = 4_718_592;
249
250/// Default timeout (in seconds) for bash tool execution.
251pub const DEFAULT_BASH_TIMEOUT_SECS: u64 = 120;
252
253const BASH_TERMINATE_GRACE_SECS: u64 = 5;
254const BASH_CANCELLATION_SCHEMA_V1: &str = "pi.tool.bash.cancellation.v1";
255
256/// Hard limit for bash output file size (1GB) to prevent disk exhaustion DoS.
257pub(crate) const BASH_FILE_LIMIT_BYTES: usize = 1024 * 1024 * 1024; // 1 GiB
258
259const TOOL_OUTPUT_ARTIFACT_SCHEMA_V1: &str = "pi.tool_output_artifact.v1";
260const TOOL_OUTPUT_ARTIFACT_REDACTION_POLICY_V1: &str = "pi.tool_output_artifact.redaction.v1";
261const TOOL_OUTPUT_ARTIFACT_RETENTION_CLASS: &str = "session_scoped_temp_evidence";
262const TOOL_OUTPUT_ARTIFACT_SPILLOVER_REASON: &str = "sourceBytesExceededPreviewThreshold";
263const TOOL_OUTPUT_ARTIFACT_THRESHOLD_BYTES: usize = DEFAULT_MAX_BYTES;
264const TOOL_OUTPUT_ARTIFACT_REDACTION_MAX_BYTES_USIZE: usize = 64 * 1024 * 1024;
265const TOOL_OUTPUT_ARTIFACT_REDACTION_MAX_BYTES: u64 = 64 * 1024 * 1024;
266const TOOL_OUTPUT_ARTIFACT_MAX_BYTES_USIZE: usize = 1024 * 1024 * 1024;
267const TOOL_OUTPUT_ARTIFACT_MAX_BYTES: u64 = 1024 * 1024 * 1024;
268
269/// Result of truncation operation.
270#[derive(Debug, Clone, Serialize)]
271#[serde(rename_all = "camelCase")]
272pub struct TruncationResult {
273    pub content: String,
274    pub truncated: bool,
275    #[serde(skip_serializing_if = "Option::is_none")]
276    pub truncated_by: Option<TruncatedBy>,
277    pub total_lines: usize,
278    pub total_bytes: usize,
279    pub output_lines: usize,
280    pub output_bytes: usize,
281    pub last_line_partial: bool,
282    pub first_line_exceeds_limit: bool,
283    pub max_lines: usize,
284    pub max_bytes: usize,
285}
286
287#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
288#[serde(rename_all = "camelCase")]
289pub enum TruncatedBy {
290    Lines,
291    Bytes,
292}
293
294#[derive(Debug, Clone, Copy, PartialEq, Eq)]
295pub enum BashCancellationReason {
296    Timeout,
297    AmbientCancellation,
298}
299
300impl BashCancellationReason {
301    const fn as_str(self) -> &'static str {
302        match self {
303            Self::Timeout => "timeout",
304            Self::AmbientCancellation => "ambient_cancellation",
305        }
306    }
307}
308
309/// Truncate from the beginning (keep first N lines).
310///
311/// Takes ownership of the input `String` to avoid allocation in the common
312/// no-truncation case (content moved, zero-copy) and to enable in-place
313/// truncation when the content exceeds limits (`String::truncate`, no new
314/// allocation).
315#[allow(clippy::too_many_lines)]
316pub fn truncate_head(
317    content: impl Into<String>,
318    max_lines: usize,
319    max_bytes: usize,
320) -> TruncationResult {
321    let mut content = content.into();
322    let total_bytes = content.len();
323
324    let total_lines = {
325        let nl = memchr::memchr_iter(b'\n', content.as_bytes()).count();
326        if content.is_empty() {
327            0
328        } else if content.ends_with('\n') {
329            nl
330        } else {
331            nl + 1
332        }
333    };
334
335    if max_lines == 0 {
336        let truncated = !content.is_empty();
337        content.clear();
338        return TruncationResult {
339            content,
340            truncated,
341            truncated_by: if truncated {
342                Some(TruncatedBy::Lines)
343            } else {
344                None
345            },
346            total_lines,
347            total_bytes,
348            output_lines: 0,
349            output_bytes: 0,
350            last_line_partial: false,
351            first_line_exceeds_limit: false,
352            max_lines,
353            max_bytes,
354        };
355    }
356
357    if max_bytes == 0 {
358        let truncated = !content.is_empty();
359        let first_line_exceeds_limit = !content.is_empty();
360        content.clear();
361        return TruncationResult {
362            content,
363            truncated,
364            truncated_by: if truncated {
365                Some(TruncatedBy::Bytes)
366            } else {
367                None
368            },
369            total_lines,
370            total_bytes,
371            output_lines: 0,
372            output_bytes: 0,
373            last_line_partial: false,
374            first_line_exceeds_limit,
375            max_lines,
376            max_bytes,
377        };
378    }
379
380    if total_lines <= max_lines && total_bytes <= max_bytes {
381        return TruncationResult {
382            content,
383            truncated: false,
384            truncated_by: None,
385            total_lines,
386            total_bytes,
387            output_lines: total_lines,
388            output_bytes: total_bytes,
389            last_line_partial: false,
390            first_line_exceeds_limit: false,
391            max_lines,
392            max_bytes,
393        };
394    }
395
396    let first_newline = memchr::memchr(b'\n', content.as_bytes());
397    let first_line_bytes = first_newline.unwrap_or(content.len());
398
399    if first_line_bytes > max_bytes {
400        let mut valid_bytes = max_bytes;
401        while valid_bytes > 0 && !content.is_char_boundary(valid_bytes) {
402            valid_bytes -= 1;
403        }
404        content.truncate(valid_bytes);
405        return TruncationResult {
406            content,
407            truncated: true,
408            truncated_by: Some(TruncatedBy::Bytes),
409            total_lines,
410            total_bytes,
411            output_lines: usize::from(valid_bytes > 0),
412            output_bytes: valid_bytes,
413            last_line_partial: true,
414            first_line_exceeds_limit: true,
415            max_lines,
416            max_bytes,
417        };
418    }
419
420    let mut line_count = 0;
421    let mut byte_count = 0;
422    let mut truncated_by = None;
423    let mut current_offset = 0;
424    let mut last_line_partial = false;
425
426    while current_offset < content.len() {
427        if line_count >= max_lines {
428            truncated_by = Some(TruncatedBy::Lines);
429            break;
430        }
431
432        let next_newline = memchr::memchr(b'\n', &content.as_bytes()[current_offset..]);
433        let line_end_without_nl = next_newline.map_or(content.len(), |idx| current_offset + idx);
434        let line_end_with_nl = next_newline.map_or(content.len(), |idx| current_offset + idx + 1);
435
436        if line_end_without_nl > max_bytes {
437            let mut byte_limit = max_bytes.min(content.len());
438            if byte_limit < current_offset {
439                truncated_by = Some(TruncatedBy::Bytes);
440                break;
441            }
442            while byte_limit > current_offset && !content.is_char_boundary(byte_limit) {
443                byte_limit -= 1;
444            }
445            if byte_limit > current_offset {
446                byte_count = byte_limit;
447                line_count += 1;
448                last_line_partial = true;
449            }
450            truncated_by = Some(TruncatedBy::Bytes);
451            break;
452        }
453
454        if line_end_with_nl > max_bytes {
455            if line_end_without_nl > current_offset {
456                byte_count = line_end_without_nl;
457                line_count += 1;
458            }
459            truncated_by = Some(TruncatedBy::Bytes);
460            break;
461        }
462
463        byte_count = line_end_with_nl;
464        line_count += 1;
465        current_offset = line_end_with_nl;
466    }
467
468    content.truncate(byte_count);
469
470    TruncationResult {
471        truncated: truncated_by.is_some(),
472        truncated_by,
473        total_lines,
474        total_bytes,
475        output_lines: line_count,
476        output_bytes: byte_count,
477        last_line_partial,
478        first_line_exceeds_limit: false,
479        max_lines,
480        max_bytes,
481        content,
482    }
483}
484
485/// Truncate from the end (keep last N lines).
486///
487/// Takes ownership of the input `String` to avoid allocation in the common
488/// no-truncation case (content moved, zero-copy). When truncation is needed,
489/// the prefix is drained in-place, reusing the original buffer.
490#[allow(clippy::too_many_lines)]
491pub fn truncate_tail(
492    content: impl Into<String>,
493    max_lines: usize,
494    max_bytes: usize,
495) -> TruncationResult {
496    let mut content = content.into();
497    let total_bytes = content.len();
498
499    // Count lines correctly: trailing newline terminates the last line, it doesn't start a new one.
500    // "a\n" -> 1 line. "a\nb" -> 2 lines. "a" -> 1 line. "" -> 0 lines (handled below).
501    let mut total_lines = memchr::memchr_iter(b'\n', content.as_bytes()).count();
502    if !content.ends_with('\n') && !content.is_empty() {
503        total_lines += 1;
504    }
505    if content.is_empty() {
506        total_lines = 0;
507    }
508
509    // Explicitly handle zero-line budgets. Keeping any line would violate the
510    // contract (`output_lines <= max_lines`) and proptest invariants.
511    if max_lines == 0 {
512        let truncated = !content.is_empty();
513        return TruncationResult {
514            content: String::new(),
515            truncated,
516            truncated_by: if truncated {
517                Some(TruncatedBy::Lines)
518            } else {
519                None
520            },
521            total_lines,
522            total_bytes,
523            output_lines: 0,
524            output_bytes: 0,
525            last_line_partial: false,
526            first_line_exceeds_limit: false,
527            max_lines,
528            max_bytes,
529        };
530    }
531
532    // No truncation needed — reuse the owned String (zero-copy move).
533    if total_lines <= max_lines && total_bytes <= max_bytes {
534        return TruncationResult {
535            content,
536            truncated: false,
537            truncated_by: None,
538            total_lines,
539            total_bytes,
540            output_lines: total_lines,
541            output_bytes: total_bytes,
542            last_line_partial: false,
543            first_line_exceeds_limit: false,
544            max_lines,
545            max_bytes,
546        };
547    }
548
549    let mut line_count = 0usize;
550    let mut byte_count = 0usize;
551    let mut start_idx = content.len();
552    let mut partial_output: Option<String> = None;
553    let mut partial_line_truncated = false;
554    let mut truncated_by = None;
555    let mut last_line_partial = false;
556
557    // Scope the immutable borrow so we can mutate `content` afterwards.
558    {
559        let bytes = content.as_bytes();
560        // Initialize search_limit outside the loop to track progress backwards.
561        // If the file ends with a newline, we skip it for the purpose of finding
562        // the *start* of the last line, but start_idx (at len) includes it.
563        let mut search_limit = bytes.len();
564        if search_limit > 0 && bytes[search_limit - 1] == b'\n' {
565            search_limit -= 1;
566        }
567
568        loop {
569            // Find the *previous* newline.
570            let prev_newline = memchr::memrchr(b'\n', &bytes[..search_limit]);
571            let line_start = prev_newline.map_or(0, |idx| idx + 1);
572
573            // Bytes for this line (including its newline if it's not the last one,
574            // or if the file ends with newline). start_idx is the end of the
575            // segment we are accumulating.
576            let added_bytes = start_idx - line_start;
577
578            if byte_count + added_bytes > max_bytes {
579                // Try to take a partial line if byte budget remains. This
580                // preserves suffix stability under prepends while staying on a
581                // valid UTF-8 boundary.
582                let remaining = max_bytes.saturating_sub(byte_count);
583                if remaining > 0 {
584                    let chunk = &content[line_start..start_idx];
585                    let truncated_chunk = truncate_string_to_bytes_from_end(chunk, remaining);
586                    if !truncated_chunk.is_empty() {
587                        partial_output = Some(truncated_chunk);
588                        partial_line_truncated = true;
589                        if line_count == 0 {
590                            last_line_partial = true;
591                        }
592                    }
593                }
594                truncated_by = Some(TruncatedBy::Bytes);
595                break;
596            }
597
598            line_count += 1;
599            byte_count += added_bytes;
600            start_idx = line_start;
601
602            if line_count >= max_lines {
603                truncated_by = Some(TruncatedBy::Lines);
604                break;
605            }
606
607            if line_start == 0 {
608                break;
609            }
610
611            // Prepare for next iter.
612            // We just consumed line starting at `line_start`.
613            // The separator before it is at `line_start - 1`.
614            // That separator is the `\n` of the *previous* line.
615            // We want to search *before* it.
616            search_limit = line_start - 1;
617        }
618    } // immutable borrow of `content` released
619
620    // Extract the suffix: drain the prefix in-place (reuses the buffer),
621    // or use the partial output from the byte-truncation path.
622    let partial_suffix = if partial_line_truncated {
623        Some(content[start_idx..].to_string())
624    } else {
625        None
626    };
627
628    let mut output = partial_output.unwrap_or_else(|| {
629        drop(content.drain(..start_idx));
630        content
631    });
632
633    // If we have a partial last line, we need to append the *rest* of the content
634    // that we successfully kept (the `byte_count` lines).
635    // Wait, `partial_output` replaces the *current line*.
636    // The previous successful lines are in `content[old_start_idx..]`.
637    // My logic above for partial output:
638    // `truncated_chunk` is the partial tail of the *current line*.
639    // We need to prepend it to the lines we already collected?
640    // Actually, `content` is the full string.
641    // We are scanning backwards.
642    // `start_idx` tracks the start of the valid suffix so far.
643    // When we hit the byte limit, we are at `line_start..start_idx`.
644    // `truncated_chunk` is the tail of *that* segment.
645    // So final output = `truncated_chunk` + `content[start_idx..]`.
646
647    if let Some(suffix) = partial_suffix {
648        // Need to reconstruct.
649        // `output` is currently just the truncated chunk.
650        // We need to append the previously accumulated suffix.
651        // `content` still holds everything.
652        // `start_idx` points to the start of the *valid* suffix from previous iters.
653        output.push_str(&suffix);
654        // Recalculate line count from the final output.
655        // Since truncated output is bounded (<= max_bytes), this scan is cheap.
656        let mut count = memchr::memchr_iter(b'\n', output.as_bytes()).count();
657        if !output.ends_with('\n') && !output.is_empty() {
658            count += 1;
659        }
660        if output.is_empty() {
661            count = 0;
662        }
663        line_count = count;
664    }
665
666    let output_bytes = output.len();
667
668    TruncationResult {
669        content: output,
670        truncated: truncated_by.is_some(),
671        truncated_by,
672        total_lines,
673        total_bytes,
674        output_lines: line_count,
675        output_bytes,
676        last_line_partial,
677        first_line_exceeds_limit: false,
678        max_lines,
679        max_bytes,
680    }
681}
682
683/// Truncate a string to fit within a byte limit (from the end), preserving UTF-8 boundaries.
684fn truncate_string_to_bytes_from_end(s: &str, max_bytes: usize) -> String {
685    let bytes = s.as_bytes();
686    if bytes.len() <= max_bytes {
687        return s.to_string();
688    }
689
690    let mut start = bytes.len().saturating_sub(max_bytes);
691    while start < bytes.len() && (bytes[start] & 0b1100_0000) == 0b1000_0000 {
692        start += 1;
693    }
694
695    std::str::from_utf8(&bytes[start..])
696        .map(str::to_string)
697        .unwrap_or_default()
698}
699
700struct HeadTruncatingLineWriter {
701    content: String,
702    max_bytes: usize,
703    total_lines: usize,
704    total_bytes: usize,
705    output_lines: usize,
706    truncated: bool,
707    last_line_partial: bool,
708    first_line_exceeds_limit: bool,
709}
710
711impl HeadTruncatingLineWriter {
712    fn new(max_bytes: usize) -> Self {
713        Self {
714            content: String::with_capacity(max_bytes.min(8192)),
715            max_bytes,
716            total_lines: 0,
717            total_bytes: 0,
718            output_lines: 0,
719            truncated: false,
720            last_line_partial: false,
721            first_line_exceeds_limit: false,
722        }
723    }
724
725    fn push_line(&mut self, line: &str) {
726        debug_assert!(!line.contains('\n'));
727
728        let line_index = self.total_lines;
729        let separator_len = usize::from(line_index > 0);
730        let piece_bytes = separator_len.saturating_add(line.len());
731        self.total_lines = self.total_lines.saturating_add(1);
732        self.total_bytes = self.total_bytes.saturating_add(piece_bytes);
733
734        if self.truncated {
735            return;
736        }
737
738        if self.max_bytes == 0 {
739            self.truncated = true;
740            self.first_line_exceeds_limit = line_index == 0 && !line.is_empty();
741            return;
742        }
743
744        let remaining = self.max_bytes.saturating_sub(self.content.len());
745        if piece_bytes <= remaining {
746            if separator_len > 0 {
747                self.content.push('\n');
748            }
749            self.content.push_str(line);
750            self.output_lines = self.output_lines.saturating_add(1);
751            return;
752        }
753
754        self.truncated = true;
755        if line_index == 0 && line.len() > self.max_bytes {
756            self.first_line_exceeds_limit = true;
757        }
758
759        let line_budget = if separator_len > 0 {
760            if remaining == 0 {
761                return;
762            }
763            self.content.push('\n');
764            remaining - 1
765        } else {
766            remaining
767        };
768
769        let valid_bytes = utf8_prefix_len(line, line_budget);
770        if valid_bytes > 0 {
771            self.content.push_str(&line[..valid_bytes]);
772            self.output_lines = self.output_lines.saturating_add(1);
773            self.last_line_partial = valid_bytes < line.len();
774        }
775    }
776
777    fn finish(self) -> TruncationResult {
778        let output_bytes = self.content.len();
779        TruncationResult {
780            content: self.content,
781            truncated: self.truncated,
782            truncated_by: if self.truncated {
783                Some(TruncatedBy::Bytes)
784            } else {
785                None
786            },
787            total_lines: self.total_lines,
788            total_bytes: self.total_bytes,
789            output_lines: self.output_lines,
790            output_bytes,
791            last_line_partial: self.last_line_partial,
792            first_line_exceeds_limit: self.first_line_exceeds_limit,
793            max_lines: usize::MAX,
794            max_bytes: self.max_bytes,
795        }
796    }
797}
798
799fn utf8_prefix_len(s: &str, max_bytes: usize) -> usize {
800    let mut valid_bytes = max_bytes.min(s.len());
801    while valid_bytes > 0 && !s.is_char_boundary(valid_bytes) {
802        valid_bytes -= 1;
803    }
804    valid_bytes
805}
806
807#[derive(Debug, Clone, Serialize)]
808#[serde(rename_all = "camelCase")]
809struct ToolOutputArtifactRef {
810    schema: &'static str,
811    id: String,
812    tool_name: String,
813    source_kind: String,
814    #[serde(skip_serializing_if = "Option::is_none")]
815    session_id: Option<String>,
816    path: String,
817    metadata_path: String,
818    sha256: String,
819    byte_count: u64,
820    line_count: usize,
821    preview_bytes: usize,
822    content_type: &'static str,
823    retention_class: &'static str,
824    spillover_reason: &'static str,
825    redaction_summary: ToolOutputArtifactRedactionSummary,
826    safe_delete_candidate: bool,
827}
828
829#[derive(Debug, Clone, Serialize)]
830#[serde(rename_all = "camelCase")]
831struct ToolOutputArtifactRedactionSummary {
832    policy: &'static str,
833    status: &'static str,
834    redacted_count: usize,
835    fields: Vec<String>,
836    raw_secret_bytes_emitted: usize,
837    binary_suspect: bool,
838    max_redaction_bytes: u64,
839}
840
841struct RedactedToolOutputArtifact {
842    bytes: Vec<u8>,
843    summary: ToolOutputArtifactRedactionSummary,
844}
845
846fn tool_output_artifact_root() -> PathBuf {
847    std::env::var_os("PI_TOOL_OUTPUT_ARTIFACT_DIR").map_or_else(
848        || Config::global_dir().join("tool-output-artifacts"),
849        PathBuf::from,
850    )
851}
852
853static TOOL_OUTPUT_ARTIFACT_SESSIONS: OnceLock<Mutex<HashMap<String, String>>> = OnceLock::new();
854
855fn tool_output_artifact_sessions() -> &'static Mutex<HashMap<String, String>> {
856    TOOL_OUTPUT_ARTIFACT_SESSIONS.get_or_init(|| Mutex::new(HashMap::new()))
857}
858
859pub(crate) struct ToolOutputArtifactSessionGuard {
860    tool_call_id: String,
861    previous_session_id: Option<String>,
862    active: bool,
863}
864
865impl Drop for ToolOutputArtifactSessionGuard {
866    fn drop(&mut self) {
867        if !self.active {
868            return;
869        }
870        let Ok(mut sessions) = tool_output_artifact_sessions().lock() else {
871            return;
872        };
873        if let Some(previous) = self.previous_session_id.take() {
874            sessions.insert(self.tool_call_id.clone(), previous);
875        } else {
876            sessions.remove(&self.tool_call_id);
877        }
878    }
879}
880
881pub(crate) fn register_tool_output_artifact_session(
882    tool_call_id: &str,
883    session_id: &str,
884) -> ToolOutputArtifactSessionGuard {
885    if session_id.is_empty() {
886        return ToolOutputArtifactSessionGuard {
887            tool_call_id: String::new(),
888            previous_session_id: None,
889            active: false,
890        };
891    }
892    let previous_session_id = tool_output_artifact_sessions()
893        .lock()
894        .ok()
895        .and_then(|mut sessions| sessions.insert(tool_call_id.to_string(), session_id.to_string()));
896    ToolOutputArtifactSessionGuard {
897        tool_call_id: tool_call_id.to_string(),
898        previous_session_id,
899        active: true,
900    }
901}
902
903fn tool_output_artifact_session_id(tool_call_id: &str) -> Option<String> {
904    tool_output_artifact_sessions()
905        .lock()
906        .ok()
907        .and_then(|sessions| sessions.get(tool_call_id).cloned())
908}
909
910fn tool_output_artifact_scope_dir(root: &Path, tool_call_id: &str) -> (PathBuf, Option<String>) {
911    let call_scope = sanitize_artifact_scope(tool_call_id);
912    if let Some(session_id) = tool_output_artifact_session_id(tool_call_id) {
913        (
914            root.join(sanitize_artifact_scope(&session_id))
915                .join(call_scope),
916            Some(session_id),
917        )
918    } else {
919        (root.join(call_scope), None)
920    }
921}
922
923fn sanitize_artifact_scope(scope: &str) -> String {
924    let mut out = String::new();
925    for ch in scope.chars().take(96) {
926        if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_') {
927            out.push(ch);
928        } else {
929            out.push('_');
930        }
931    }
932    if out.trim_matches('_').is_empty() {
933        "tool-call".to_string()
934    } else {
935        out
936    }
937}
938
939fn artifact_line_count(bytes: &[u8]) -> usize {
940    if bytes.is_empty() {
941        0
942    } else {
943        memchr::memchr_iter(b'\n', bytes).count() + usize::from(!bytes.ends_with(b"\n"))
944    }
945}
946
947fn artifact_details_object(
948    details: &mut Option<serde_json::Value>,
949) -> &mut serde_json::Map<String, serde_json::Value> {
950    let value = details.get_or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
951    if !value.is_object() {
952        *value = serde_json::Value::Object(serde_json::Map::new());
953    }
954    value
955        .as_object_mut()
956        .expect("details value forced to object")
957}
958
959fn normalize_redaction_field(field: &str) -> String {
960    let mut out = String::new();
961    let mut previous_underscore = false;
962    for ch in field.chars() {
963        let normalized = if ch.is_ascii_alphanumeric() {
964            previous_underscore = false;
965            ch.to_ascii_lowercase()
966        } else if previous_underscore {
967            continue;
968        } else {
969            previous_underscore = true;
970            '_'
971        };
972        out.push(normalized);
973    }
974    out.trim_matches('_').to_string()
975}
976
977fn record_redacted_field(fields: &mut Vec<String>, field: &str) {
978    let field = normalize_redaction_field(field);
979    if !field.is_empty() && !fields.iter().any(|existing| existing == &field) {
980        fields.push(field);
981    }
982}
983
984fn artifact_sensitive_key_value_regex() -> &'static regex::Regex {
985    static RE: OnceLock<regex::Regex> = OnceLock::new();
986    RE.get_or_init(|| {
987        regex::Regex::new(
988            r#"(?i)\b([A-Za-z_][A-Za-z0-9_.-]*(?:api[_-]?key|token|secret|password|passwd|credential|authorization)[A-Za-z0-9_.-]*)(\s*[:=]\s*)("[^"\r\n]*"|'[^'\r\n]*'|[^\s,;}]+)"#,
989        )
990        .expect("valid artifact key-value redaction regex")
991    })
992}
993
994fn artifact_bearer_token_regex() -> &'static regex::Regex {
995    static RE: OnceLock<regex::Regex> = OnceLock::new();
996    RE.get_or_init(|| {
997        regex::Regex::new(r"(?i)\b(Bearer\s+)([A-Za-z0-9._~+/=-]{8,})")
998            .expect("valid artifact bearer redaction regex")
999    })
1000}
1001
1002fn artifact_token_value_regex() -> &'static regex::Regex {
1003    static RE: OnceLock<regex::Regex> = OnceLock::new();
1004    RE.get_or_init(|| {
1005        regex::Regex::new(
1006            r"\b(sk-[A-Za-z0-9][A-Za-z0-9_-]{10,}|gh[pousr]_[A-Za-z0-9_]{10,}|AKIA[0-9A-Z]{12,})\b",
1007        )
1008        .expect("valid artifact token value redaction regex")
1009    })
1010}
1011
1012fn redacted_literal_for_value(value: &str) -> &'static str {
1013    if value.starts_with('"') && value.ends_with('"') {
1014        "\"[REDACTED]\""
1015    } else if value.starts_with('\'') && value.ends_with('\'') {
1016        "'[REDACTED]'"
1017    } else {
1018        "[REDACTED]"
1019    }
1020}
1021
1022fn redact_tool_output_artifact_text(
1023    text: &str,
1024    binary_suspect: bool,
1025) -> RedactedToolOutputArtifact {
1026    let mut fields = Vec::new();
1027    let mut redacted_count = 0usize;
1028
1029    let redacted = artifact_sensitive_key_value_regex()
1030        .replace_all(text, |caps: &regex::Captures<'_>| {
1031            let key = caps.get(1).map_or("", |m| m.as_str());
1032            let sep = caps.get(2).map_or("", |m| m.as_str());
1033            let value = caps.get(3).map_or("", |m| m.as_str());
1034            if value == "[REDACTED]" || value == "\"[REDACTED]\"" || value == "'[REDACTED]'" {
1035                caps.get(0).map_or("", |m| m.as_str()).to_string()
1036            } else {
1037                redacted_count = redacted_count.saturating_add(1);
1038                record_redacted_field(&mut fields, key);
1039                format!("{key}{sep}{}", redacted_literal_for_value(value))
1040            }
1041        })
1042        .to_string();
1043
1044    let redacted = artifact_bearer_token_regex()
1045        .replace_all(&redacted, |caps: &regex::Captures<'_>| {
1046            redacted_count = redacted_count.saturating_add(1);
1047            record_redacted_field(&mut fields, "authorization");
1048            let prefix = caps.get(1).map_or("", |m| m.as_str());
1049            format!("{prefix}[REDACTED]")
1050        })
1051        .to_string();
1052
1053    let redacted = artifact_token_value_regex()
1054        .replace_all(&redacted, |_caps: &regex::Captures<'_>| {
1055            redacted_count = redacted_count.saturating_add(1);
1056            record_redacted_field(&mut fields, "tokenValue");
1057            "[REDACTED]".to_string()
1058        })
1059        .to_string();
1060
1061    fields.sort();
1062    let raw_secret_bytes_emitted = estimate_raw_secret_bytes(&redacted);
1063    let summary = ToolOutputArtifactRedactionSummary {
1064        policy: TOOL_OUTPUT_ARTIFACT_REDACTION_POLICY_V1,
1065        status: if raw_secret_bytes_emitted > 0 {
1066            "unsafe"
1067        } else if redacted_count > 0 {
1068            "redacted"
1069        } else {
1070            "clean"
1071        },
1072        redacted_count,
1073        fields,
1074        raw_secret_bytes_emitted,
1075        binary_suspect,
1076        max_redaction_bytes: TOOL_OUTPUT_ARTIFACT_REDACTION_MAX_BYTES,
1077    };
1078
1079    RedactedToolOutputArtifact {
1080        bytes: redacted.into_bytes(),
1081        summary,
1082    }
1083}
1084
1085fn estimate_raw_secret_bytes(text: &str) -> usize {
1086    let key_value_bytes = artifact_sensitive_key_value_regex()
1087        .captures_iter(text)
1088        .filter_map(|caps| {
1089            let value = caps.get(3)?.as_str();
1090            if value == "[REDACTED]" || value == "\"[REDACTED]\"" || value == "'[REDACTED]'" {
1091                None
1092            } else {
1093                caps.get(0).map(|m| m.as_str().len())
1094            }
1095        })
1096        .sum::<usize>();
1097    let bearer_bytes = artifact_bearer_token_regex()
1098        .find_iter(text)
1099        .map(|m| m.as_str().len())
1100        .sum::<usize>();
1101    let token_bytes = artifact_token_value_regex()
1102        .find_iter(text)
1103        .map(|m| m.as_str().len())
1104        .sum::<usize>();
1105    key_value_bytes
1106        .saturating_add(bearer_bytes)
1107        .saturating_add(token_bytes)
1108}
1109
1110fn redact_tool_output_artifact_bytes(bytes: &[u8]) -> std::io::Result<RedactedToolOutputArtifact> {
1111    let binary_suspect =
1112        memchr::memchr(b'\0', bytes).is_some() || std::str::from_utf8(bytes).is_err();
1113    let text = String::from_utf8_lossy(bytes);
1114    let redacted = redact_tool_output_artifact_text(text.as_ref(), binary_suspect);
1115    if redacted.summary.raw_secret_bytes_emitted > 0 {
1116        return Err(std::io::Error::new(
1117            std::io::ErrorKind::InvalidData,
1118            "artifact redaction failed closed: raw secret-looking bytes remain",
1119        ));
1120    }
1121    Ok(redacted)
1122}
1123
1124fn ensure_artifact_path_under_root(root: &Path, path: &Path) -> std::io::Result<()> {
1125    if path.starts_with(root) {
1126        Ok(())
1127    } else {
1128        Err(std::io::Error::new(
1129            std::io::ErrorKind::PermissionDenied,
1130            format!(
1131                "artifact path {} is outside artifact root {}",
1132                path.display(),
1133                root.display()
1134            ),
1135        ))
1136    }
1137}
1138
1139fn write_artifact_file_if_absent(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
1140    match std::fs::OpenOptions::new()
1141        .write(true)
1142        .create_new(true)
1143        .open(path)
1144    {
1145        Ok(mut file) => {
1146            file.write_all(bytes)?;
1147            tolerate_fsync_refusal(file.sync_all(), "artifact file", path)?;
1148            Ok(())
1149        }
1150        Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => Ok(()),
1151        Err(err) => Err(err),
1152    }
1153}
1154
1155fn write_text_tool_output_artifact_at_root(
1156    root: &Path,
1157    tool_name: &str,
1158    tool_call_id: &str,
1159    source_kind: &str,
1160    full_text: &str,
1161    preview_bytes: usize,
1162) -> std::io::Result<ToolOutputArtifactRef> {
1163    let bytes = full_text.as_bytes();
1164    if bytes.len() > TOOL_OUTPUT_ARTIFACT_MAX_BYTES_USIZE {
1165        return Err(std::io::Error::new(
1166            std::io::ErrorKind::InvalidData,
1167            format!(
1168                "artifact source exceeds {} hard limit",
1169                format_size(TOOL_OUTPUT_ARTIFACT_MAX_BYTES_USIZE)
1170            ),
1171        ));
1172    }
1173    if bytes.len() > TOOL_OUTPUT_ARTIFACT_REDACTION_MAX_BYTES_USIZE {
1174        return Err(std::io::Error::new(
1175            std::io::ErrorKind::InvalidData,
1176            format!(
1177                "artifact source exceeds {} redaction limit",
1178                format_size(TOOL_OUTPUT_ARTIFACT_REDACTION_MAX_BYTES_USIZE)
1179            ),
1180        ));
1181    }
1182    let redacted = redact_tool_output_artifact_bytes(bytes)?;
1183    let bytes = redacted.bytes.as_slice();
1184    let sha256 = format!("{:x}", sha2::Sha256::digest(bytes));
1185    let (scope_dir, session_id) = tool_output_artifact_scope_dir(root, tool_call_id);
1186    std::fs::create_dir_all(&scope_dir)?;
1187
1188    let id = format!("tool-artifact-{}", &sha256[..16]);
1189    let content_path = scope_dir.join(format!("{sha256}.txt"));
1190    let metadata_path = scope_dir.join(format!("{sha256}.json"));
1191    ensure_artifact_path_under_root(root, &content_path)?;
1192    ensure_artifact_path_under_root(root, &metadata_path)?;
1193    write_artifact_file_if_absent(&content_path, bytes)?;
1194
1195    let artifact = ToolOutputArtifactRef {
1196        schema: TOOL_OUTPUT_ARTIFACT_SCHEMA_V1,
1197        id,
1198        tool_name: tool_name.to_string(),
1199        source_kind: source_kind.to_string(),
1200        session_id,
1201        path: content_path.display().to_string(),
1202        metadata_path: metadata_path.display().to_string(),
1203        sha256,
1204        byte_count: bytes.len().try_into().unwrap_or(u64::MAX),
1205        line_count: artifact_line_count(bytes),
1206        preview_bytes,
1207        content_type: "text/plain; charset=utf-8",
1208        retention_class: TOOL_OUTPUT_ARTIFACT_RETENTION_CLASS,
1209        spillover_reason: TOOL_OUTPUT_ARTIFACT_SPILLOVER_REASON,
1210        redaction_summary: redacted.summary,
1211        safe_delete_candidate: true,
1212    };
1213    let metadata = serde_json::to_vec_pretty(&artifact).map_err(std::io::Error::other)?;
1214    write_artifact_file_if_absent(&metadata_path, &metadata)?;
1215    Ok(artifact)
1216}
1217
1218fn copy_text_tool_output_artifact_from_path_at_root(
1219    root: &Path,
1220    tool_name: &str,
1221    tool_call_id: &str,
1222    source_kind: &str,
1223    source_path: &Path,
1224    preview_bytes: usize,
1225) -> std::io::Result<ToolOutputArtifactRef> {
1226    let metadata = std::fs::metadata(source_path)?;
1227    if metadata.len() > TOOL_OUTPUT_ARTIFACT_MAX_BYTES {
1228        return Err(std::io::Error::new(
1229            std::io::ErrorKind::InvalidData,
1230            format!(
1231                "artifact source exceeds {} hard limit",
1232                format_size(TOOL_OUTPUT_ARTIFACT_MAX_BYTES_USIZE)
1233            ),
1234        ));
1235    }
1236    if metadata.len() > TOOL_OUTPUT_ARTIFACT_REDACTION_MAX_BYTES {
1237        return Err(std::io::Error::new(
1238            std::io::ErrorKind::InvalidData,
1239            format!(
1240                "artifact source exceeds {} redaction limit",
1241                format_size(TOOL_OUTPUT_ARTIFACT_REDACTION_MAX_BYTES_USIZE)
1242            ),
1243        ));
1244    }
1245
1246    let mut source = std::fs::File::open(source_path)?;
1247    let mut source_bytes = Vec::with_capacity(usize::try_from(metadata.len()).unwrap_or(0));
1248    source.read_to_end(&mut source_bytes)?;
1249    let redacted = redact_tool_output_artifact_bytes(&source_bytes)?;
1250    let bytes = redacted.bytes.as_slice();
1251
1252    let sha256 = format!("{:x}", sha2::Sha256::digest(bytes));
1253    let (scope_dir, session_id) = tool_output_artifact_scope_dir(root, tool_call_id);
1254    std::fs::create_dir_all(&scope_dir)?;
1255    let id = format!("tool-artifact-{}", &sha256[..16]);
1256    let content_path = scope_dir.join(format!("{sha256}.txt"));
1257    let metadata_path = scope_dir.join(format!("{sha256}.json"));
1258    ensure_artifact_path_under_root(root, &content_path)?;
1259    ensure_artifact_path_under_root(root, &metadata_path)?;
1260    write_artifact_file_if_absent(&content_path, bytes)?;
1261
1262    let artifact = ToolOutputArtifactRef {
1263        schema: TOOL_OUTPUT_ARTIFACT_SCHEMA_V1,
1264        id,
1265        tool_name: tool_name.to_string(),
1266        source_kind: source_kind.to_string(),
1267        session_id,
1268        path: content_path.display().to_string(),
1269        metadata_path: metadata_path.display().to_string(),
1270        sha256,
1271        byte_count: bytes.len().try_into().unwrap_or(u64::MAX),
1272        line_count: artifact_line_count(bytes),
1273        preview_bytes,
1274        content_type: "text/plain; charset=utf-8",
1275        retention_class: TOOL_OUTPUT_ARTIFACT_RETENTION_CLASS,
1276        spillover_reason: TOOL_OUTPUT_ARTIFACT_SPILLOVER_REASON,
1277        redaction_summary: redacted.summary,
1278        safe_delete_candidate: true,
1279    };
1280    let metadata = serde_json::to_vec_pretty(&artifact).map_err(std::io::Error::other)?;
1281    write_artifact_file_if_absent(&metadata_path, &metadata)?;
1282    Ok(artifact)
1283}
1284
1285fn append_tool_output_artifact_notice(output_text: &mut String, artifact: &ToolOutputArtifactRef) {
1286    let _ = write!(
1287        output_text,
1288        "\n\n[Full tool output artifact: {} ({} bytes, {} lines, sha256 {}). Use read on this path to inspect more.]",
1289        artifact.path, artifact.byte_count, artifact.line_count, artifact.sha256,
1290    );
1291}
1292
1293fn append_artifact_source_line(full_text: &mut String, line: &str) {
1294    if !full_text.is_empty() {
1295        full_text.push('\n');
1296    }
1297    full_text.push_str(line);
1298}
1299
1300fn record_tool_output_artifact_error(
1301    output_text: &mut String,
1302    details: &mut Option<serde_json::Value>,
1303    error: &std::io::Error,
1304) {
1305    let _ = write!(
1306        output_text,
1307        "\n\n[Tool output artifact persistence failed: {error}. Showing the bounded preview only.]"
1308    );
1309    artifact_details_object(details).insert(
1310        "artifactError".to_string(),
1311        serde_json::json!({
1312            "schema": TOOL_OUTPUT_ARTIFACT_SCHEMA_V1,
1313            "message": error.to_string(),
1314        }),
1315    );
1316}
1317
1318fn attach_text_artifact_if_needed_at_root(
1319    root: &Path,
1320    output_text: &mut String,
1321    details: &mut Option<serde_json::Value>,
1322    tool_name: &str,
1323    tool_call_id: &str,
1324    source_kind: &str,
1325    full_text: &str,
1326) -> bool {
1327    if full_text.len() <= TOOL_OUTPUT_ARTIFACT_THRESHOLD_BYTES {
1328        return false;
1329    }
1330    match write_text_tool_output_artifact_at_root(
1331        root,
1332        tool_name,
1333        tool_call_id,
1334        source_kind,
1335        full_text,
1336        output_text.len(),
1337    ) {
1338        Ok(artifact) => {
1339            append_tool_output_artifact_notice(output_text, &artifact);
1340            artifact_details_object(details).insert(
1341                "artifact".to_string(),
1342                serde_json::to_value(&artifact).expect("artifact ref serializes"),
1343            );
1344            true
1345        }
1346        Err(err) => {
1347            record_tool_output_artifact_error(output_text, details, &err);
1348            false
1349        }
1350    }
1351}
1352
1353fn attach_text_artifact_if_needed(
1354    output_text: &mut String,
1355    details: &mut Option<serde_json::Value>,
1356    tool_name: &str,
1357    tool_call_id: &str,
1358    source_kind: &str,
1359    full_text: &str,
1360) -> bool {
1361    let root = tool_output_artifact_root();
1362    attach_text_artifact_if_needed_at_root(
1363        &root,
1364        output_text,
1365        details,
1366        tool_name,
1367        tool_call_id,
1368        source_kind,
1369        full_text,
1370    )
1371}
1372
1373fn attach_text_artifact_if_needed_with_root(
1374    root: Option<&Path>,
1375    output_text: &mut String,
1376    details: &mut Option<serde_json::Value>,
1377    tool_name: &str,
1378    tool_call_id: &str,
1379    source_kind: &str,
1380    full_text: &str,
1381) -> bool {
1382    if let Some(root) = root {
1383        attach_text_artifact_if_needed_at_root(
1384            root,
1385            output_text,
1386            details,
1387            tool_name,
1388            tool_call_id,
1389            source_kind,
1390            full_text,
1391        )
1392    } else {
1393        attach_text_artifact_if_needed(
1394            output_text,
1395            details,
1396            tool_name,
1397            tool_call_id,
1398            source_kind,
1399            full_text,
1400        )
1401    }
1402}
1403
1404fn attach_text_artifact_from_path_if_needed_at_root(
1405    root: &Path,
1406    output_text: &mut String,
1407    details: &mut Option<serde_json::Value>,
1408    tool_name: &str,
1409    tool_call_id: &str,
1410    source_kind: &str,
1411    source_path: &Path,
1412) -> bool {
1413    let Ok(metadata) = std::fs::metadata(source_path) else {
1414        return false;
1415    };
1416    if metadata.len() <= u64::try_from(TOOL_OUTPUT_ARTIFACT_THRESHOLD_BYTES).unwrap_or(u64::MAX) {
1417        return false;
1418    }
1419    match copy_text_tool_output_artifact_from_path_at_root(
1420        root,
1421        tool_name,
1422        tool_call_id,
1423        source_kind,
1424        source_path,
1425        output_text.len(),
1426    ) {
1427        Ok(artifact) => {
1428            append_tool_output_artifact_notice(output_text, &artifact);
1429            artifact_details_object(details).insert(
1430                "artifact".to_string(),
1431                serde_json::to_value(&artifact).expect("artifact ref serializes"),
1432            );
1433            true
1434        }
1435        Err(err) => {
1436            record_tool_output_artifact_error(output_text, details, &err);
1437            false
1438        }
1439    }
1440}
1441
1442fn attach_text_artifact_from_path_if_needed(
1443    output_text: &mut String,
1444    details: &mut Option<serde_json::Value>,
1445    tool_name: &str,
1446    tool_call_id: &str,
1447    source_kind: &str,
1448    source_path: &Path,
1449) -> bool {
1450    let root = tool_output_artifact_root();
1451    attach_text_artifact_from_path_if_needed_at_root(
1452        &root,
1453        output_text,
1454        details,
1455        tool_name,
1456        tool_call_id,
1457        source_kind,
1458        source_path,
1459    )
1460}
1461
1462fn attach_text_artifact_from_path_if_needed_with_root(
1463    root: Option<&Path>,
1464    output_text: &mut String,
1465    details: &mut Option<serde_json::Value>,
1466    tool_name: &str,
1467    tool_call_id: &str,
1468    source_kind: &str,
1469    source_path: &Path,
1470) -> bool {
1471    if let Some(root) = root {
1472        attach_text_artifact_from_path_if_needed_at_root(
1473            root,
1474            output_text,
1475            details,
1476            tool_name,
1477            tool_call_id,
1478            source_kind,
1479            source_path,
1480        )
1481    } else {
1482        attach_text_artifact_from_path_if_needed(
1483            output_text,
1484            details,
1485            tool_name,
1486            tool_call_id,
1487            source_kind,
1488            source_path,
1489        )
1490    }
1491}
1492
1493const TOOL_OUTPUT_CACHE_MAX_ENTRIES: usize = 128;
1494const TOOL_OUTPUT_CACHE_MAX_BYTES: usize = 8 * 1024 * 1024;
1495const TOOL_OUTPUT_CACHE_MAX_ENTRY_BYTES: usize = DEFAULT_MAX_BYTES + 64 * 1024;
1496const TOOL_OUTPUT_CACHE_MAX_FINGERPRINT_FILES: usize = 2048;
1497const TOOL_OUTPUT_CACHE_MAX_FINGERPRINT_BYTES: u64 = 8 * 1024 * 1024;
1498const TOOL_OUTPUT_CACHE_MAX_FILE_HASH_BYTES: u64 = 2 * 1024 * 1024;
1499
1500#[derive(Debug, Clone, PartialEq, Eq)]
1501struct ToolCacheDependency {
1502    path: PathBuf,
1503    fingerprint: [u8; 32],
1504}
1505
1506#[derive(Debug, Clone, Copy)]
1507enum ToolCacheFingerprintMode {
1508    FileContent,
1509    DirectoryImmediate,
1510    DirectoryRecursive,
1511}
1512
1513#[derive(Debug, Clone)]
1514struct CachedToolOutput {
1515    deps: Vec<ToolCacheDependency>,
1516    output: ToolOutput,
1517    weight: usize,
1518    generation: u64,
1519}
1520
1521#[cfg(test)]
1522#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1523struct ToolOutputCacheStats {
1524    hits: usize,
1525    misses: usize,
1526    inserts: usize,
1527    invalidations: usize,
1528    disabled: usize,
1529    side_effect_accesses: usize,
1530    side_effect_insert_attempts: usize,
1531}
1532
1533#[derive(Debug, Default)]
1534struct ToolOutputCache {
1535    entries: HashMap<String, CachedToolOutput>,
1536    order: VecDeque<(String, u64)>,
1537    total_bytes: usize,
1538    generation: u64,
1539    #[cfg(test)]
1540    stats: ToolOutputCacheStats,
1541}
1542
1543impl ToolOutputCache {
1544    fn get(&mut self, key: &str, deps: &[ToolCacheDependency]) -> Option<ToolOutput> {
1545        self.generation = self.generation.saturating_add(1);
1546        let generation = self.generation;
1547        #[cfg(test)]
1548        {
1549            if is_side_effect_tool_cache_key(key) {
1550                self.stats.side_effect_accesses = self.stats.side_effect_accesses.saturating_add(1);
1551            }
1552        }
1553
1554        if self
1555            .entries
1556            .get(key)
1557            .is_some_and(|entry| entry.deps == deps)
1558        {
1559            let entry = self.entries.get_mut(key)?;
1560            entry.generation = generation;
1561            self.order.push_back((key.to_string(), generation));
1562            #[cfg(test)]
1563            {
1564                self.stats.hits = self.stats.hits.saturating_add(1);
1565            }
1566            return Some(entry.output.clone());
1567        }
1568
1569        if let Some(removed) = self.entries.remove(key) {
1570            self.total_bytes = self.total_bytes.saturating_sub(removed.weight);
1571            #[cfg(test)]
1572            {
1573                self.stats.invalidations = self.stats.invalidations.saturating_add(1);
1574            }
1575        } else {
1576            #[cfg(test)]
1577            {
1578                self.stats.misses = self.stats.misses.saturating_add(1);
1579            }
1580        }
1581
1582        None
1583    }
1584
1585    fn insert(
1586        &mut self,
1587        key: String,
1588        deps: Vec<ToolCacheDependency>,
1589        output: ToolOutput,
1590        weight: usize,
1591    ) {
1592        if weight == 0 || weight > TOOL_OUTPUT_CACHE_MAX_ENTRY_BYTES {
1593            #[cfg(test)]
1594            {
1595                self.stats.disabled = self.stats.disabled.saturating_add(1);
1596            }
1597            return;
1598        }
1599
1600        #[cfg(test)]
1601        {
1602            if is_side_effect_tool_cache_key(&key) {
1603                self.stats.side_effect_insert_attempts =
1604                    self.stats.side_effect_insert_attempts.saturating_add(1);
1605            }
1606        }
1607
1608        if let Some(removed) = self.entries.remove(&key) {
1609            self.total_bytes = self.total_bytes.saturating_sub(removed.weight);
1610        }
1611
1612        self.generation = self.generation.saturating_add(1);
1613        let generation = self.generation;
1614        self.total_bytes = self.total_bytes.saturating_add(weight);
1615        self.order.push_back((key.clone(), generation));
1616        self.entries.insert(
1617            key,
1618            CachedToolOutput {
1619                deps,
1620                output,
1621                weight,
1622                generation,
1623            },
1624        );
1625        #[cfg(test)]
1626        {
1627            self.stats.inserts = self.stats.inserts.saturating_add(1);
1628        }
1629        self.evict_to_limits();
1630    }
1631
1632    fn evict_to_limits(&mut self) {
1633        while self.entries.len() > TOOL_OUTPUT_CACHE_MAX_ENTRIES
1634            || self.total_bytes > TOOL_OUTPUT_CACHE_MAX_BYTES
1635        {
1636            let Some((key, generation)) = self.order.pop_front() else {
1637                break;
1638            };
1639            if self
1640                .entries
1641                .get(&key)
1642                .is_some_and(|entry| entry.generation == generation)
1643                && let Some(removed) = self.entries.remove(&key)
1644            {
1645                self.total_bytes = self.total_bytes.saturating_sub(removed.weight);
1646            }
1647        }
1648    }
1649}
1650
1651fn tool_output_cache() -> &'static Mutex<ToolOutputCache> {
1652    static CACHE: OnceLock<Mutex<ToolOutputCache>> = OnceLock::new();
1653    CACHE.get_or_init(|| Mutex::new(ToolOutputCache::default()))
1654}
1655
1656fn lock_tool_output_cache() -> std::sync::MutexGuard<'static, ToolOutputCache> {
1657    tool_output_cache()
1658        .lock()
1659        .unwrap_or_else(std::sync::PoisonError::into_inner)
1660}
1661
1662fn tool_cache_key(tool: &str, cwd: &Path, input: &serde_json::Value) -> String {
1663    let input_json = serde_json::to_string(input).unwrap_or_else(|_| input.to_string());
1664    format!("{tool}\0{}\0{input_json}", cwd.display())
1665}
1666
1667#[cfg(test)]
1668fn is_side_effect_tool_cache_key(key: &str) -> bool {
1669    key.starts_with("write\0") || key.starts_with("edit\0") || key.starts_with("bash\0")
1670}
1671
1672fn cached_tool_output(key: &str, deps: Option<&[ToolCacheDependency]>) -> Option<ToolOutput> {
1673    let deps = deps?;
1674    lock_tool_output_cache().get(key, deps)
1675}
1676
1677fn cache_tool_output(key: String, deps: Option<Vec<ToolCacheDependency>>, output: &ToolOutput) {
1678    let Some(deps) = deps else {
1679        return;
1680    };
1681    if output.details.as_ref().is_some_and(|details| {
1682        details.as_object().is_some_and(|details| {
1683            details.contains_key("artifact") || details.contains_key("artifactError")
1684        })
1685    }) {
1686        return;
1687    }
1688    let Some(weight) = cacheable_tool_output_weight(output) else {
1689        return;
1690    };
1691    lock_tool_output_cache().insert(key, deps, output.clone(), weight);
1692}
1693
1694fn stable_cache_dependency_for_path(
1695    path: &Path,
1696    mode: ToolCacheFingerprintMode,
1697    before_deps: Option<&[ToolCacheDependency]>,
1698) -> Option<Vec<ToolCacheDependency>> {
1699    let before_deps = before_deps?;
1700    let after_deps = cache_dependency_for_path(path, mode)?;
1701    (before_deps == after_deps.as_slice()).then_some(after_deps)
1702}
1703
1704fn cacheable_tool_output_weight(output: &ToolOutput) -> Option<usize> {
1705    let mut weight = output
1706        .details
1707        .as_ref()
1708        .and_then(|details| serde_json::to_vec(details).ok())
1709        .map_or(0, |details| details.len());
1710
1711    for block in &output.content {
1712        match block {
1713            ContentBlock::Text(text) => {
1714                weight = weight.saturating_add(text.text.len());
1715                if let Some(signature) = &text.text_signature {
1716                    weight = weight.saturating_add(signature.len());
1717                }
1718            }
1719            ContentBlock::Image(_)
1720            | ContentBlock::Thinking(_)
1721            | ContentBlock::RedactedThinking(_)
1722            | ContentBlock::ToolCall(_) => return None,
1723        }
1724    }
1725
1726    Some(weight)
1727}
1728
1729fn cache_dependency_for_path(
1730    path: &Path,
1731    mode: ToolCacheFingerprintMode,
1732) -> Option<Vec<ToolCacheDependency>> {
1733    let fingerprint = match mode {
1734        ToolCacheFingerprintMode::FileContent => fingerprint_file_content(path)?,
1735        ToolCacheFingerprintMode::DirectoryImmediate => fingerprint_directory_immediate(path)?,
1736        ToolCacheFingerprintMode::DirectoryRecursive => fingerprint_directory_recursive(path)?,
1737    };
1738
1739    Some(vec![ToolCacheDependency {
1740        path: path.to_path_buf(),
1741        fingerprint,
1742    }])
1743}
1744
1745fn fingerprint_file_content(path: &Path) -> Option<[u8; 32]> {
1746    let metadata = std::fs::symlink_metadata(path).ok()?;
1747    if !metadata.is_file() || metadata.len() > TOOL_OUTPUT_CACHE_MAX_FILE_HASH_BYTES {
1748        return None;
1749    }
1750
1751    let bytes = std::fs::read(path).ok()?;
1752    let mut hasher = sha2::Sha256::new();
1753    update_fingerprint_metadata(&mut hasher, Path::new(""), &metadata);
1754    hasher.update(sha2::Sha256::digest(&bytes));
1755    Some(hasher.finalize().into())
1756}
1757
1758fn fingerprint_directory_immediate(path: &Path) -> Option<[u8; 32]> {
1759    let metadata = std::fs::symlink_metadata(path).ok()?;
1760    if !metadata.is_dir() {
1761        return None;
1762    }
1763
1764    let mut entries = std::fs::read_dir(path)
1765        .ok()?
1766        .collect::<std::result::Result<Vec<_>, _>>()
1767        .ok()?;
1768    if entries.len() > TOOL_OUTPUT_CACHE_MAX_FINGERPRINT_FILES {
1769        return None;
1770    }
1771    entries.sort_by_key(std::fs::DirEntry::file_name);
1772
1773    let mut hasher = sha2::Sha256::new();
1774    update_fingerprint_metadata(&mut hasher, Path::new(""), &metadata);
1775    for entry in entries {
1776        let entry_path = entry.path();
1777        let rel = entry.file_name();
1778        let rel = Path::new(&rel);
1779        let entry_metadata = std::fs::symlink_metadata(&entry_path).ok()?;
1780        update_fingerprint_metadata(&mut hasher, rel, &entry_metadata);
1781        if entry_metadata.file_type().is_symlink() {
1782            update_symlink_target(&mut hasher, &entry_path);
1783        }
1784    }
1785
1786    Some(hasher.finalize().into())
1787}
1788
1789fn fingerprint_directory_recursive(path: &Path) -> Option<[u8; 32]> {
1790    let metadata = std::fs::symlink_metadata(path).ok()?;
1791    if metadata.is_file() {
1792        return fingerprint_file_content(path);
1793    }
1794    if !metadata.is_dir() {
1795        return None;
1796    }
1797
1798    let mut budget = FingerprintBudget::default();
1799    let mut hasher = sha2::Sha256::new();
1800    update_fingerprint_metadata(&mut hasher, Path::new(""), &metadata);
1801    fingerprint_tree(path, path, &mut budget, &mut hasher)?;
1802    Some(hasher.finalize().into())
1803}
1804
1805#[derive(Debug, Default)]
1806struct FingerprintBudget {
1807    entries: usize,
1808    bytes: u64,
1809}
1810
1811fn fingerprint_tree(
1812    root: &Path,
1813    dir: &Path,
1814    budget: &mut FingerprintBudget,
1815    hasher: &mut sha2::Sha256,
1816) -> Option<()> {
1817    let mut entries = std::fs::read_dir(dir)
1818        .ok()?
1819        .collect::<std::result::Result<Vec<_>, _>>()
1820        .ok()?;
1821    entries.sort_by_key(std::fs::DirEntry::path);
1822
1823    for entry in entries {
1824        budget.entries = budget.entries.saturating_add(1);
1825        if budget.entries > TOOL_OUTPUT_CACHE_MAX_FINGERPRINT_FILES {
1826            return None;
1827        }
1828
1829        let entry_path = entry.path();
1830        let rel = entry_path.strip_prefix(root).unwrap_or(&entry_path);
1831        let metadata = std::fs::symlink_metadata(&entry_path).ok()?;
1832        update_fingerprint_metadata(hasher, rel, &metadata);
1833
1834        if metadata.file_type().is_symlink() {
1835            update_symlink_target(hasher, &entry_path);
1836        } else if metadata.is_dir() {
1837            fingerprint_tree(root, &entry_path, budget, hasher)?;
1838        } else if metadata.is_file() {
1839            if metadata.len() > TOOL_OUTPUT_CACHE_MAX_FILE_HASH_BYTES {
1840                return None;
1841            }
1842            budget.bytes = budget.bytes.saturating_add(metadata.len());
1843            if budget.bytes > TOOL_OUTPUT_CACHE_MAX_FINGERPRINT_BYTES {
1844                return None;
1845            }
1846            let bytes = std::fs::read(&entry_path).ok()?;
1847            hasher.update(sha2::Sha256::digest(&bytes));
1848        }
1849    }
1850
1851    Some(())
1852}
1853
1854fn update_fingerprint_metadata(
1855    hasher: &mut sha2::Sha256,
1856    path: &Path,
1857    metadata: &std::fs::Metadata,
1858) {
1859    hasher.update(path.to_string_lossy().as_bytes());
1860    hasher.update([0]);
1861    let file_type = metadata.file_type();
1862    hasher.update([
1863        u8::from(metadata.is_file()),
1864        u8::from(metadata.is_dir()),
1865        u8::from(file_type.is_symlink()),
1866    ]);
1867    hasher.update(metadata.len().to_le_bytes());
1868    let modified_nanos = metadata
1869        .modified()
1870        .ok()
1871        .and_then(|modified| modified.duration_since(UNIX_EPOCH).ok())
1872        .map_or(0, |duration| duration.as_nanos());
1873    hasher.update(modified_nanos.to_le_bytes());
1874    hasher.update([0xff]);
1875}
1876
1877fn update_symlink_target(hasher: &mut sha2::Sha256, path: &Path) {
1878    if let Ok(target) = std::fs::read_link(path) {
1879        hasher.update(target.to_string_lossy().as_bytes());
1880    }
1881    hasher.update([0xfe]);
1882}
1883
1884#[cfg(test)]
1885fn reset_tool_output_cache_for_tests() {
1886    *lock_tool_output_cache() = ToolOutputCache::default();
1887}
1888
1889#[cfg(test)]
1890fn tool_output_cache_stats_for_tests() -> ToolOutputCacheStats {
1891    lock_tool_output_cache().stats
1892}
1893
1894/// Format a byte count into a human-readable string with appropriate unit suffix.
1895#[allow(clippy::cast_precision_loss)]
1896fn format_size(bytes: usize) -> String {
1897    const KB: usize = 1024;
1898    const MB: usize = 1024 * 1024;
1899
1900    if bytes >= MB {
1901        format!("{:.1}MB", bytes as f64 / MB as f64)
1902    } else if bytes >= KB {
1903        format!("{:.1}KB", bytes as f64 / KB as f64)
1904    } else {
1905        format!("{bytes}B")
1906    }
1907}
1908
1909#[cfg(test)]
1910fn js_string_length(s: &str) -> usize {
1911    // Match JavaScript's String.length (UTF-16 code units), not UTF-8 bytes.
1912    s.encode_utf16().count()
1913}
1914
1915// ============================================================================
1916// Path Utilities (port of pi-mono path-utils.ts)
1917// ============================================================================
1918
1919fn is_special_unicode_space(c: char) -> bool {
1920    matches!(c, '\u{00A0}' | '\u{202F}' | '\u{205F}' | '\u{3000}')
1921        || ('\u{2000}'..='\u{200A}').contains(&c)
1922}
1923
1924fn normalize_unicode_spaces(s: &str) -> String {
1925    s.chars()
1926        .map(|c| if is_special_unicode_space(c) { ' ' } else { c })
1927        .collect()
1928}
1929
1930#[cfg(test)]
1931fn normalize_for_match(s: &str) -> String {
1932    // Single-pass normalization: spaces, quotes, and dashes in one allocation.
1933    // Avoids 3 intermediate String allocations from chained replace calls.
1934    let mut out = String::with_capacity(s.len());
1935    for c in s.chars() {
1936        match c {
1937            // Unicode spaces → ASCII space
1938            c if is_special_unicode_space(c) => out.push(' '),
1939            // Curly single quotes → straight apostrophe
1940            '\u{2018}' | '\u{2019}' => out.push('\''),
1941            // Curly double quotes → straight double quote
1942            '\u{201C}' | '\u{201D}' | '\u{201E}' | '\u{201F}' => out.push('"'),
1943            // Various dashes → ASCII hyphen
1944            '\u{2010}' | '\u{2011}' | '\u{2012}' | '\u{2013}' | '\u{2014}' | '\u{2015}'
1945            | '\u{2212}' => out.push('-'),
1946            // Everything else passes through
1947            c => out.push(c),
1948        }
1949    }
1950    out
1951}
1952
1953fn expand_path(file_path: &str) -> String {
1954    let normalized = normalize_unicode_spaces(file_path);
1955    if normalized == "~" {
1956        return dirs::home_dir()
1957            .unwrap_or_else(|| PathBuf::from("~"))
1958            .to_string_lossy()
1959            .to_string();
1960    }
1961    if let Some(rest) = normalized.strip_prefix("~/") {
1962        let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("~"));
1963        return home.join(rest).to_string_lossy().to_string();
1964    }
1965    normalized
1966}
1967
1968/// Resolve a path relative to `cwd`. Handles `~` expansion and absolute paths.
1969fn resolve_to_cwd(file_path: &str, cwd: &Path) -> PathBuf {
1970    let expanded = expand_path(file_path);
1971    let expanded_path = PathBuf::from(expanded);
1972    if expanded_path.is_absolute() {
1973        expanded_path
1974    } else {
1975        cwd.join(expanded_path)
1976    }
1977}
1978
1979fn try_mac_os_screenshot_path(file_path: &str) -> String {
1980    // Replace " AM." / " PM." with a narrow no-break space variant used by macOS screenshots.
1981    file_path
1982        .replace(" AM.", "\u{202F}AM.")
1983        .replace(" PM.", "\u{202F}PM.")
1984}
1985
1986fn try_curly_quote_variant(file_path: &str) -> String {
1987    // Replace straight apostrophe with macOS screenshot curly apostrophe.
1988    file_path.replace('\'', "\u{2019}")
1989}
1990
1991fn try_nfd_variant(file_path: &str) -> String {
1992    // NFD normalization - decompose characters into base + combining marks
1993    // This handles macOS HFS+ filesystem normalization differences
1994    use unicode_normalization::UnicodeNormalization;
1995    file_path.nfd().collect::<String>()
1996}
1997
1998fn file_exists(path: &Path) -> bool {
1999    std::fs::metadata(path).is_ok()
2000}
2001
2002/// Resolve a file path for reading, including macOS screenshot name variants.
2003pub(crate) fn resolve_read_path(file_path: &str, cwd: &Path) -> PathBuf {
2004    let resolved = normalize_dot_segments(&resolve_to_cwd(file_path, cwd));
2005    let normalized_cwd = normalize_dot_segments(cwd);
2006    let within_cwd = resolved.starts_with(&normalized_cwd);
2007    if within_cwd && file_exists(&resolved) {
2008        return resolved;
2009    }
2010    if !within_cwd {
2011        // Avoid probing the filesystem outside the working directory.
2012        return resolved;
2013    }
2014
2015    let Some(resolved_str) = resolved.to_str() else {
2016        return resolved;
2017    };
2018
2019    let am_pm_variant = try_mac_os_screenshot_path(resolved_str);
2020    if am_pm_variant.ne(resolved_str) {
2021        let candidate = PathBuf::from(&am_pm_variant);
2022        if candidate.starts_with(&normalized_cwd) && file_exists(&candidate) {
2023            return candidate;
2024        }
2025    }
2026
2027    let nfd_variant = try_nfd_variant(resolved_str);
2028    if nfd_variant.ne(resolved_str) {
2029        let candidate = PathBuf::from(&nfd_variant);
2030        if candidate.starts_with(&normalized_cwd) && file_exists(&candidate) {
2031            return candidate;
2032        }
2033    }
2034
2035    let curly_variant = try_curly_quote_variant(resolved_str);
2036    if curly_variant.ne(resolved_str) {
2037        let candidate = PathBuf::from(&curly_variant);
2038        if candidate.starts_with(&normalized_cwd) && file_exists(&candidate) {
2039            return candidate;
2040        }
2041    }
2042
2043    let nfd_curly_variant = try_curly_quote_variant(&nfd_variant);
2044    if nfd_curly_variant.ne(resolved_str) {
2045        let candidate = PathBuf::from(&nfd_curly_variant);
2046        if candidate.starts_with(&normalized_cwd) && file_exists(&candidate) {
2047            return candidate;
2048        }
2049    }
2050
2051    resolved
2052}
2053
2054fn enforce_cwd_scope(path: &Path, cwd: &Path, action: &str) -> Result<PathBuf> {
2055    let canonical_path = crate::extensions::safe_canonicalize(path);
2056    let canonical_cwd = crate::extensions::safe_canonicalize(cwd);
2057    if !canonical_path.starts_with(&canonical_cwd) {
2058        return Err(Error::validation(format!(
2059            "Cannot {action} outside the working directory (resolved: {}, cwd: {})",
2060            canonical_path.display(),
2061            canonical_cwd.display()
2062        )));
2063    }
2064    Ok(canonical_path)
2065}
2066
2067/// Same scoping contract as `enforce_cwd_scope`, but also accepts paths under
2068/// the configured pi-agent directory (`Config::global_dir()`, default
2069/// `~/.pi/agent/`, override via `PI_CODING_AGENT_DIR`).
2070///
2071/// Read access is broadened so the model can fetch the bodies of skill files,
2072/// prompt templates, and other resources that ship under the agent dir
2073/// without needing to fall back to a `bash cat`. Write/edit/grep/find/list
2074/// stay strictly cwd-only — broadening write access would let a misbehaving
2075/// model persist instructions into the agent dir, which is a much higher-
2076/// risk surface than the read case warrants. See pi_agent_rust#71.
2077///
2078/// Symlink escapes remain blocked because `safe_canonicalize` resolves
2079/// symlinks before the prefix check, so e.g. `~/.pi/agent/skills/foo/SKILL.md`
2080/// pointing at `/etc/passwd` resolves to `/etc/passwd` and fails the prefix
2081/// test against both cwd and agent dir.
2082fn enforce_read_scope_with_roots(path: &Path, cwd: &Path, agent_dir: &Path) -> Result<PathBuf> {
2083    let canonical_path = crate::extensions::safe_canonicalize(path);
2084    let canonical_cwd = crate::extensions::safe_canonicalize(cwd);
2085    if canonical_path.starts_with(&canonical_cwd) {
2086        return Ok(canonical_path);
2087    }
2088
2089    let canonical_agent = crate::extensions::safe_canonicalize(agent_dir);
2090    if canonical_path.starts_with(&canonical_agent) {
2091        return Ok(canonical_path);
2092    }
2093
2094    Err(Error::validation(format!(
2095        "Cannot read outside the working directory or agent dir \
2096         (resolved: {}, cwd: {}, agent dir: {})",
2097        canonical_path.display(),
2098        canonical_cwd.display(),
2099        canonical_agent.display(),
2100    )))
2101}
2102
2103/// Convenience wrapper that pulls the agent dir from the active config.
2104fn enforce_read_scope(path: &Path, cwd: &Path) -> Result<PathBuf> {
2105    let agent_dir = crate::config::Config::global_dir();
2106    enforce_read_scope_with_roots(path, cwd, &agent_dir)
2107}
2108
2109// ============================================================================
2110// CLI @file Processor (used by src/main.rs)
2111// ============================================================================
2112
2113/// Result of processing `@file` CLI arguments.
2114#[derive(Debug, Clone, Default)]
2115pub struct ProcessedFiles {
2116    pub text: String,
2117    pub images: Vec<ImageContent>,
2118}
2119
2120fn normalize_dot_segments(path: &Path) -> PathBuf {
2121    use std::ffi::{OsStr, OsString};
2122    use std::path::Component;
2123
2124    let mut out = PathBuf::new();
2125    let mut normals: Vec<OsString> = Vec::new();
2126    let mut has_prefix = false;
2127    let mut has_root = false;
2128
2129    for component in path.components() {
2130        match component {
2131            Component::Prefix(prefix) => {
2132                out.push(prefix.as_os_str());
2133                has_prefix = true;
2134            }
2135            Component::RootDir => {
2136                out.push(component.as_os_str());
2137                has_root = true;
2138            }
2139            Component::CurDir => {}
2140            Component::ParentDir => match normals.last() {
2141                Some(last) if last.as_os_str() != OsStr::new("..") => {
2142                    normals.pop();
2143                }
2144                _ => {
2145                    if !has_root && !has_prefix {
2146                        normals.push(OsString::from(".."));
2147                    }
2148                }
2149            },
2150            Component::Normal(part) => normals.push(part.to_os_string()),
2151        }
2152    }
2153
2154    for part in normals {
2155        out.push(part);
2156    }
2157
2158    out
2159}
2160
2161#[cfg(feature = "fuzzing")]
2162pub fn fuzz_normalize_dot_segments(path: &Path) -> PathBuf {
2163    normalize_dot_segments(path)
2164}
2165
2166/// Returns `true` when an `fsync`/`fdatasync` durability barrier was *refused*
2167/// by the filesystem rather than reflecting a real write failure.
2168///
2169/// The bytes are already handed to the kernel by the preceding `write(2)`;
2170/// `fsync` only asks the filesystem to make them durable. Some filesystems —
2171/// notably virtiofs / FUSE bind mounts (Docker Desktop for macOS) and various
2172/// network filesystems — do not implement `fsync` on a given descriptor and
2173/// report it with `EBADF`, `EINVAL`, or an "unsupported" error even though the
2174/// `write(2)` and the subsequent atomic `rename(2)` already landed the data
2175/// correctly. Failing the whole write tool in that case is wrong: the file is
2176/// complete and correct on disk. We downgrade these specific refusals to a
2177/// warning. Genuine I/O failures (`EIO`, `ENOSPC`, `EDQUOT`, …) still
2178/// propagate. See issue #136.
2179fn is_fsync_refused(err: &std::io::Error) -> bool {
2180    // EBADF = 9 and EINVAL = 22 on both Linux and macOS. `ErrorKind::Unsupported`
2181    // captures ENOTSUP/EOPNOTSUPP/ENOSYS portably without a `libc` dependency
2182    // (this crate is `#![forbid(unsafe_code)]`).
2183    matches!(err.raw_os_error(), Some(9 | 22)) || err.kind() == std::io::ErrorKind::Unsupported
2184}
2185
2186/// Runs a durability `fsync` (`result`), treating a filesystem *refusal* (see
2187/// [`is_fsync_refused`]) as a non-fatal warning while still propagating real
2188/// I/O errors. `what` and `path` are used only for the diagnostic log line.
2189fn tolerate_fsync_refusal(
2190    result: std::io::Result<()>,
2191    what: &str,
2192    path: &Path,
2193) -> std::io::Result<()> {
2194    match result {
2195        Ok(()) => Ok(()),
2196        Err(err) if is_fsync_refused(&err) => {
2197            tracing::warn!(
2198                path = %path.display(),
2199                error = %err,
2200                "{what} fsync refused by filesystem (non-POSIX durability semantics); \
2201                 data already written, continuing without a durability barrier"
2202            );
2203            Ok(())
2204        }
2205        Err(err) => Err(err),
2206    }
2207}
2208
2209#[cfg(unix)]
2210fn sync_parent_dir(path: &Path) -> std::io::Result<()> {
2211    let Some(parent) = path.parent() else {
2212        return Ok(());
2213    };
2214    let parent = if parent.as_os_str().is_empty() {
2215        Path::new(".")
2216    } else {
2217        parent
2218    };
2219    // Directory fsync is a pure durability nicety (it makes the rename durable);
2220    // on filesystems that refuse it the rename is still visible, so tolerate a
2221    // refusal rather than failing the write. See issue #136.
2222    tolerate_fsync_refusal(
2223        std::fs::File::open(parent).and_then(|dir| dir.sync_all()),
2224        "parent directory",
2225        parent,
2226    )
2227}
2228
2229#[cfg(not(unix))]
2230fn sync_parent_dir(_path: &Path) -> std::io::Result<()> {
2231    Ok(())
2232}
2233
2234fn escape_file_tag_attribute(value: &str) -> String {
2235    let mut escaped = String::with_capacity(value.len());
2236    for ch in value.chars() {
2237        match ch {
2238            '&' => escaped.push_str("&amp;"),
2239            '"' => escaped.push_str("&quot;"),
2240            '<' => escaped.push_str("&lt;"),
2241            '>' => escaped.push_str("&gt;"),
2242            '\n' => escaped.push_str("&#10;"),
2243            '\r' => escaped.push_str("&#13;"),
2244            '\t' => escaped.push_str("&#9;"),
2245            _ => escaped.push(ch),
2246        }
2247    }
2248    escaped
2249}
2250
2251fn escaped_file_tag_name(path: &Path) -> String {
2252    escape_file_tag_attribute(&path.display().to_string())
2253}
2254
2255fn append_file_notice_block(out: &mut String, path: &Path, notice: &str) {
2256    let path_str = escaped_file_tag_name(path);
2257    let _ = writeln!(out, "<file name=\"{path_str}\">\n{notice}\n</file>");
2258}
2259
2260fn append_image_file_ref(out: &mut String, path: &Path, note: Option<&str>) {
2261    let path_str = escaped_file_tag_name(path);
2262    match note {
2263        Some(text) => {
2264            let _ = writeln!(out, "<file name=\"{path_str}\">{text}</file>");
2265        }
2266        None => {
2267            let _ = writeln!(out, "<file name=\"{path_str}\"></file>");
2268        }
2269    }
2270}
2271
2272fn append_text_file_block(out: &mut String, path: &Path, bytes: &[u8]) {
2273    let content = String::from_utf8_lossy(bytes);
2274    let path_str = escaped_file_tag_name(path);
2275    let _ = writeln!(out, "<file name=\"{path_str}\">");
2276
2277    let truncation = truncate_head(content.into_owned(), DEFAULT_MAX_LINES, DEFAULT_MAX_BYTES);
2278    let needs_trailing_newline = !truncation.truncated && !truncation.content.ends_with('\n');
2279    out.push_str(&truncation.content);
2280
2281    if truncation.truncated {
2282        let _ = write!(
2283            out,
2284            "\n... [Truncated: showing {}/{} lines, {}/{} bytes]",
2285            truncation.output_lines,
2286            truncation.total_lines,
2287            format_size(truncation.output_bytes),
2288            format_size(truncation.total_bytes)
2289        );
2290    } else if needs_trailing_newline {
2291        out.push('\n');
2292    }
2293    let _ = writeln!(out, "</file>");
2294}
2295
2296fn maybe_append_image_argument(
2297    out: &mut ProcessedFiles,
2298    absolute_path: &Path,
2299    bytes: &[u8],
2300    auto_resize_images: bool,
2301) -> Result<bool> {
2302    let Some(mime_type) = detect_supported_image_mime_type_from_bytes(bytes) else {
2303        return Ok(false);
2304    };
2305
2306    let resized = if auto_resize_images {
2307        resize_image_if_needed(bytes, mime_type)?
2308    } else {
2309        ResizedImage::original(bytes.to_vec(), mime_type)
2310    };
2311
2312    if resized.bytes.len() > IMAGE_MAX_BYTES {
2313        let msg = if resized.resized {
2314            format!(
2315                "[Image is too large ({} bytes) after resizing. Max allowed is {} bytes.]",
2316                resized.bytes.len(),
2317                IMAGE_MAX_BYTES
2318            )
2319        } else {
2320            format!(
2321                "[Image is too large ({} bytes). Max allowed is {} bytes.]",
2322                resized.bytes.len(),
2323                IMAGE_MAX_BYTES
2324            )
2325        };
2326        append_file_notice_block(&mut out.text, absolute_path, &msg);
2327        return Ok(true);
2328    }
2329
2330    let base64_data =
2331        base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &resized.bytes);
2332    out.images.push(ImageContent {
2333        data: base64_data,
2334        mime_type: resized.mime_type.to_string(),
2335    });
2336
2337    let note = if resized.resized {
2338        if let (Some(ow), Some(oh), Some(w), Some(h)) = (
2339            resized.original_width,
2340            resized.original_height,
2341            resized.width,
2342            resized.height,
2343        ) {
2344            if w > 0 {
2345                let scale = f64::from(ow) / f64::from(w);
2346                Some(format!(
2347                    "[Image: original {ow}x{oh}, displayed at {w}x{h}. Multiply coordinates by {scale:.2} to map to original image.]"
2348                ))
2349            } else {
2350                Some(format!(
2351                    "[Image: original {ow}x{oh}, displayed at {w}x{h}.]"
2352                ))
2353            }
2354        } else {
2355            None
2356        }
2357    } else {
2358        None
2359    };
2360    append_image_file_ref(&mut out.text, absolute_path, note.as_deref());
2361    Ok(true)
2362}
2363
2364/// Process `@file` arguments into a single text prefix and image attachments.
2365///
2366/// Matches the legacy TypeScript behavior:
2367/// - Resolves paths (including `~` expansion + macOS screenshot variants)
2368/// - Skips empty files
2369/// - For images: attaches image blocks and appends `<file name="...">...</file>` references
2370/// - For text: embeds the file contents inside `<file>` tags
2371pub fn process_file_arguments(
2372    file_args: &[String],
2373    cwd: &Path,
2374    auto_resize_images: bool,
2375) -> Result<ProcessedFiles> {
2376    let mut out = ProcessedFiles::default();
2377
2378    for file_arg in file_args {
2379        let resolved = resolve_read_path(file_arg, cwd);
2380        let absolute_path = normalize_dot_segments(&resolved);
2381        let absolute_path = enforce_read_scope(&absolute_path, cwd)?;
2382
2383        let meta = std::fs::metadata(&absolute_path).map_err(|e| {
2384            Error::tool(
2385                "read",
2386                format!("Cannot access file {}: {e}", absolute_path.display()),
2387            )
2388        })?;
2389        if meta.is_dir() {
2390            append_file_notice_block(
2391                &mut out.text,
2392                &absolute_path,
2393                "[Path is a directory, not a file. Use the list tool to view its contents.]",
2394            );
2395            continue;
2396        }
2397
2398        if meta.len() == 0 {
2399            continue;
2400        }
2401
2402        if meta.len() > READ_TOOL_MAX_BYTES {
2403            append_file_notice_block(
2404                &mut out.text,
2405                &absolute_path,
2406                &format!(
2407                    "[File is too large ({} bytes). Max allowed is {} bytes.]",
2408                    meta.len(),
2409                    READ_TOOL_MAX_BYTES
2410                ),
2411            );
2412            continue;
2413        }
2414
2415        let bytes = std::fs::read(&absolute_path).map_err(|e| {
2416            Error::tool(
2417                "read",
2418                format!("Could not read file {}: {e}", absolute_path.display()),
2419            )
2420        })?;
2421
2422        if maybe_append_image_argument(&mut out, &absolute_path, &bytes, auto_resize_images)? {
2423            continue;
2424        }
2425
2426        append_text_file_block(&mut out.text, &absolute_path, &bytes);
2427    }
2428
2429    Ok(out)
2430}
2431
2432/// Resolve a file path relative to the current working directory.
2433/// Public alias for `resolve_to_cwd` used by tools.
2434fn resolve_path(file_path: &str, cwd: &Path) -> PathBuf {
2435    normalize_dot_segments(&resolve_to_cwd(file_path, cwd))
2436}
2437
2438#[cfg(feature = "fuzzing")]
2439pub fn fuzz_resolve_path(file_path: &str, cwd: &Path) -> PathBuf {
2440    resolve_path(file_path, cwd)
2441}
2442
2443pub(crate) fn detect_supported_image_mime_type_from_bytes(bytes: &[u8]) -> Option<&'static str> {
2444    // Supported image types match the legacy tool: jpeg/png/gif/webp only.
2445    if bytes.len() >= 8 && bytes.starts_with(b"\x89PNG\r\n\x1A\n") {
2446        return Some("image/png");
2447    }
2448    if bytes.len() >= 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF {
2449        return Some("image/jpeg");
2450    }
2451    if bytes.len() >= 6 && (bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a")) {
2452        return Some("image/gif");
2453    }
2454    if bytes.len() >= 12 && bytes.starts_with(b"RIFF") && &bytes[8..12] == b"WEBP" {
2455        return Some("image/webp");
2456    }
2457    None
2458}
2459
2460#[derive(Debug, Clone)]
2461pub(crate) struct ResizedImage {
2462    pub(crate) bytes: Vec<u8>,
2463    pub(crate) mime_type: &'static str,
2464    pub(crate) resized: bool,
2465    pub(crate) width: Option<u32>,
2466    pub(crate) height: Option<u32>,
2467    pub(crate) original_width: Option<u32>,
2468    pub(crate) original_height: Option<u32>,
2469}
2470
2471impl ResizedImage {
2472    pub(crate) const fn original(bytes: Vec<u8>, mime_type: &'static str) -> Self {
2473        Self {
2474            bytes,
2475            mime_type,
2476            resized: false,
2477            width: None,
2478            height: None,
2479            original_width: None,
2480            original_height: None,
2481        }
2482    }
2483}
2484
2485#[cfg(feature = "image-resize")]
2486#[allow(clippy::too_many_lines)]
2487pub(crate) fn resize_image_if_needed(
2488    bytes: &[u8],
2489    mime_type: &'static str,
2490) -> Result<ResizedImage> {
2491    // Match legacy behavior from pi-mono `utils/image-resize.ts`.
2492    //
2493    // Strategy:
2494    // 1) If image already fits within max dims AND max bytes: return original
2495    // 2) Otherwise resize to maxWidth/maxHeight (2000x2000)
2496    // 3) Encode as PNG and JPEG, pick smaller
2497    // 4) If still too large, try JPEG with different quality steps
2498    // 5) If still too large, progressively scale down dimensions
2499    //
2500    // Note: even if dimensions don't change, an oversized image may be re-encoded to fit max bytes.
2501    use image::codecs::jpeg::JpegEncoder;
2502    use image::codecs::png::PngEncoder;
2503    use image::imageops::FilterType;
2504    use image::{GenericImageView, ImageEncoder, ImageReader, Limits};
2505    use std::io::Cursor;
2506
2507    const MAX_WIDTH: u32 = 2000;
2508    const MAX_HEIGHT: u32 = 2000;
2509    const DEFAULT_JPEG_QUALITY: u8 = 80;
2510    const QUALITY_STEPS: [u8; 4] = [85, 70, 55, 40];
2511    const SCALE_STEPS: [f64; 5] = [1.0, 0.75, 0.5, 0.35, 0.25];
2512
2513    fn scale_u32(value: u32, numerator: u32, denominator: u32) -> u32 {
2514        let den = u64::from(denominator).max(1);
2515        let num = u64::from(value) * u64::from(numerator);
2516        let rounded = (num + den / 2) / den;
2517        u32::try_from(rounded).unwrap_or(u32::MAX)
2518    }
2519
2520    fn encode_png(img: &image::DynamicImage) -> Result<Vec<u8>> {
2521        let rgba = img.to_rgba8();
2522        let mut out = Vec::new();
2523        PngEncoder::new(&mut out)
2524            .write_image(
2525                rgba.as_raw(),
2526                rgba.width(),
2527                rgba.height(),
2528                image::ExtendedColorType::Rgba8,
2529            )
2530            .map_err(|e| Error::tool("read", format!("Failed to encode PNG: {e}")))?;
2531        Ok(out)
2532    }
2533
2534    fn encode_jpeg(img: &image::DynamicImage, quality: u8) -> Result<Vec<u8>> {
2535        let rgb = img.to_rgb8();
2536        let mut out = Vec::new();
2537        JpegEncoder::new_with_quality(&mut out, quality)
2538            .write_image(
2539                rgb.as_raw(),
2540                rgb.width(),
2541                rgb.height(),
2542                image::ExtendedColorType::Rgb8,
2543            )
2544            .map_err(|e| Error::tool("read", format!("Failed to encode JPEG: {e}")))?;
2545        Ok(out)
2546    }
2547
2548    fn try_both_formats(
2549        img: &image::DynamicImage,
2550        width: u32,
2551        height: u32,
2552        jpeg_quality: u8,
2553    ) -> Result<(Vec<u8>, &'static str)> {
2554        let resized = img.resize_exact(width, height, FilterType::Lanczos3);
2555        let png = encode_png(&resized)?;
2556        let jpeg = encode_jpeg(&resized, jpeg_quality)?;
2557        if png.len() <= jpeg.len() {
2558            Ok((png, "image/png"))
2559        } else {
2560            Ok((jpeg, "image/jpeg"))
2561        }
2562    }
2563
2564    // Use ImageReader with explicit limits to prevent decompression bomb attacks.
2565    // 128MB allocation limit allows reasonable images but stops massive expansions.
2566    let mut limits = Limits::default();
2567    limits.max_alloc = Some(128 * 1024 * 1024);
2568
2569    let reader = ImageReader::new(Cursor::new(bytes))
2570        .with_guessed_format()
2571        .map_err(|e| Error::tool("read", format!("Failed to detect image format: {e}")))?;
2572
2573    let mut reader = reader;
2574    reader.limits(limits);
2575
2576    // ubs:ignore false positive: image decode, not JWT processing.
2577    let Ok(img) = reader.decode() else {
2578        return Ok(ResizedImage::original(bytes.to_vec(), mime_type));
2579    };
2580
2581    let (original_width, original_height) = img.dimensions();
2582    let original_size = bytes.len();
2583
2584    if original_width <= MAX_WIDTH
2585        && original_height <= MAX_HEIGHT
2586        && original_size <= IMAGE_MAX_BYTES
2587    {
2588        return Ok(ResizedImage {
2589            bytes: bytes.to_vec(),
2590            mime_type,
2591            resized: false,
2592            width: Some(original_width),
2593            height: Some(original_height),
2594            original_width: Some(original_width),
2595            original_height: Some(original_height),
2596        });
2597    }
2598
2599    let mut target_width = original_width;
2600    let mut target_height = original_height;
2601
2602    if target_width > MAX_WIDTH {
2603        target_height = scale_u32(target_height, MAX_WIDTH, target_width);
2604        target_width = MAX_WIDTH;
2605    }
2606    if target_height > MAX_HEIGHT {
2607        target_width = scale_u32(target_width, MAX_HEIGHT, target_height);
2608        target_height = MAX_HEIGHT;
2609    }
2610
2611    let mut best = try_both_formats(&img, target_width, target_height, DEFAULT_JPEG_QUALITY)?;
2612    let mut final_width = target_width;
2613    let mut final_height = target_height;
2614
2615    if best.0.len() <= IMAGE_MAX_BYTES {
2616        return Ok(ResizedImage {
2617            bytes: best.0,
2618            mime_type: best.1,
2619            resized: true,
2620            width: Some(final_width),
2621            height: Some(final_height),
2622            original_width: Some(original_width),
2623            original_height: Some(original_height),
2624        });
2625    }
2626
2627    for quality in QUALITY_STEPS {
2628        best = try_both_formats(&img, target_width, target_height, quality)?;
2629        if best.0.len() <= IMAGE_MAX_BYTES {
2630            return Ok(ResizedImage {
2631                bytes: best.0,
2632                mime_type: best.1,
2633                resized: true,
2634                width: Some(final_width),
2635                height: Some(final_height),
2636                original_width: Some(original_width),
2637                original_height: Some(original_height),
2638            });
2639        }
2640    }
2641
2642    for scale in SCALE_STEPS {
2643        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
2644        {
2645            final_width = (f64::from(target_width) * scale).round() as u32;
2646            final_height = (f64::from(target_height) * scale).round() as u32;
2647        }
2648
2649        if final_width < 100 || final_height < 100 {
2650            break;
2651        }
2652
2653        for quality in QUALITY_STEPS {
2654            best = try_both_formats(&img, final_width, final_height, quality)?;
2655            if best.0.len() <= IMAGE_MAX_BYTES {
2656                return Ok(ResizedImage {
2657                    bytes: best.0,
2658                    mime_type: best.1,
2659                    resized: true,
2660                    width: Some(final_width),
2661                    height: Some(final_height),
2662                    original_width: Some(original_width),
2663                    original_height: Some(original_height),
2664                });
2665            }
2666        }
2667    }
2668
2669    Ok(ResizedImage {
2670        bytes: best.0,
2671        mime_type: best.1,
2672        resized: true,
2673        width: Some(final_width),
2674        height: Some(final_height),
2675        original_width: Some(original_width),
2676        original_height: Some(original_height),
2677    })
2678}
2679
2680#[cfg(not(feature = "image-resize"))]
2681#[expect(
2682    clippy::unnecessary_wraps,
2683    reason = "The no-feature stub preserves the feature-enabled Result API at shared call sites."
2684)]
2685pub(crate) fn resize_image_if_needed(
2686    bytes: &[u8],
2687    mime_type: &'static str,
2688) -> Result<ResizedImage> {
2689    Ok(ResizedImage::original(bytes.to_vec(), mime_type))
2690}
2691
2692// ============================================================================
2693// Tool Registry
2694// ============================================================================
2695
2696/// Registry of enabled tools for a Pi run.
2697///
2698/// The registry is constructed from configuration (enabled tool names + settings) and is used for:
2699/// - Looking up a tool implementation by name during tool-call execution.
2700/// - Enumerating tool schemas when building provider requests.
2701pub struct ToolRegistry {
2702    tools: Vec<Box<dyn Tool>>,
2703}
2704
2705impl ToolRegistry {
2706    /// Create a new registry with the specified tools enabled.
2707    pub fn new(enabled: &[&str], cwd: &Path, config: Option<&Config>) -> Self {
2708        let mut tools: Vec<Box<dyn Tool>> = Vec::new();
2709        let shell_path = config.and_then(|c| c.shell_path.clone());
2710        let shell_command_prefix = config.and_then(|c| c.shell_command_prefix.clone());
2711        let image_auto_resize = config.is_none_or(Config::image_auto_resize);
2712        let block_images = config
2713            .and_then(|c| c.images.as_ref().and_then(|i| i.block_images))
2714            .unwrap_or(false);
2715
2716        for name in enabled {
2717            match *name {
2718                "read" => tools.push(Box::new(ReadTool::with_settings(
2719                    cwd,
2720                    image_auto_resize,
2721                    block_images,
2722                ))),
2723                "bash" => tools.push(Box::new(BashTool::with_shell(
2724                    cwd,
2725                    shell_path.clone(),
2726                    shell_command_prefix.clone(),
2727                ))),
2728                "edit" => tools.push(Box::new(EditTool::new(cwd))),
2729                "write" => tools.push(Box::new(WriteTool::new(cwd))),
2730                "grep" => tools.push(Box::new(GrepTool::new(cwd))),
2731                "find" => tools.push(Box::new(FindTool::new(cwd))),
2732                "ls" => tools.push(Box::new(LsTool::new(cwd))),
2733                "hashline_edit" => tools.push(Box::new(HashlineEditTool::new(cwd))),
2734                _ => {}
2735            }
2736        }
2737
2738        Self { tools }
2739    }
2740
2741    /// Construct a registry from a pre-built tool list.
2742    pub fn from_tools(tools: Vec<Box<dyn Tool>>) -> Self {
2743        Self { tools }
2744    }
2745
2746    /// Convert the registry into the owned tool list.
2747    pub fn into_tools(self) -> Vec<Box<dyn Tool>> {
2748        self.tools
2749    }
2750
2751    /// Append a tool.
2752    pub fn push(&mut self, tool: Box<dyn Tool>) {
2753        self.tools.push(tool);
2754    }
2755
2756    /// Extend the registry with additional tools.
2757    pub fn extend<I>(&mut self, tools: I)
2758    where
2759        I: IntoIterator<Item = Box<dyn Tool>>,
2760    {
2761        self.tools.extend(tools);
2762    }
2763
2764    /// Get all tools.
2765    pub fn tools(&self) -> &[Box<dyn Tool>] {
2766        &self.tools
2767    }
2768
2769    /// Find a tool by name.
2770    pub fn get(&self, name: &str) -> Option<&dyn Tool> {
2771        self.tools
2772            .iter()
2773            .find(|t| t.name() == name)
2774            .map(std::convert::AsRef::as_ref)
2775    }
2776}
2777
2778// ============================================================================
2779// Read Tool
2780// ============================================================================
2781
2782/// Input parameters for the read tool.
2783#[derive(Debug, Deserialize)]
2784#[serde(rename_all = "camelCase")]
2785struct ReadInput {
2786    path: String,
2787    offset: Option<i64>,
2788    limit: Option<i64>,
2789    #[serde(default)]
2790    hashline: bool,
2791}
2792
2793pub struct ReadTool {
2794    cwd: PathBuf,
2795    /// Whether to auto-resize images to fit token limits.
2796    auto_resize: bool,
2797    block_images: bool,
2798    artifact_root: Option<PathBuf>,
2799}
2800
2801impl ReadTool {
2802    pub fn new(cwd: &Path) -> Self {
2803        Self {
2804            cwd: cwd.to_path_buf(),
2805            auto_resize: true,
2806            block_images: false,
2807            artifact_root: None,
2808        }
2809    }
2810
2811    pub fn with_settings(cwd: &Path, auto_resize: bool, block_images: bool) -> Self {
2812        Self {
2813            cwd: cwd.to_path_buf(),
2814            auto_resize,
2815            block_images,
2816            artifact_root: None,
2817        }
2818    }
2819
2820    #[cfg(test)]
2821    fn with_artifact_root(cwd: &Path, artifact_root: &Path) -> Self {
2822        Self {
2823            cwd: cwd.to_path_buf(),
2824            auto_resize: true,
2825            block_images: false,
2826            artifact_root: Some(artifact_root.to_path_buf()),
2827        }
2828    }
2829}
2830
2831async fn read_some<R>(reader: &mut R, dst: &mut [u8]) -> std::io::Result<usize>
2832where
2833    R: AsyncRead + Unpin,
2834{
2835    if dst.is_empty() {
2836        return Ok(0);
2837    }
2838
2839    futures::future::poll_fn(|cx| {
2840        let mut read_buf = ReadBuf::new(dst);
2841        match std::pin::Pin::new(&mut *reader).poll_read(cx, &mut read_buf) {
2842            std::task::Poll::Ready(Ok(())) => std::task::Poll::Ready(Ok(read_buf.filled().len())),
2843            std::task::Poll::Ready(Err(err)) => std::task::Poll::Ready(Err(err)),
2844            std::task::Poll::Pending => std::task::Poll::Pending,
2845        }
2846    })
2847    .await
2848}
2849
2850#[async_trait]
2851#[allow(clippy::unnecessary_literal_bound)]
2852impl Tool for ReadTool {
2853    fn name(&self) -> &str {
2854        "read"
2855    }
2856    fn label(&self) -> &str {
2857        "read"
2858    }
2859    fn description(&self) -> &str {
2860        "Read the contents of a file. Supports text files and images (jpg, png, gif, webp). Images are sent as attachments. For text files, output is truncated to 2000 lines or 1MB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete."
2861    }
2862
2863    fn parameters(&self) -> serde_json::Value {
2864        serde_json::json!({
2865            "type": "object",
2866            "properties": {
2867                "path": {
2868                    "type": "string",
2869                    "description": "Path to the file to read (relative or absolute)"
2870                },
2871                "offset": {
2872                    "type": "integer",
2873                    "description": "Line number to start reading from (1-indexed)"
2874                },
2875                "limit": {
2876                    "type": "integer",
2877                    "description": "Maximum number of lines to read"
2878                },
2879                "hashline": {
2880                    "type": "boolean",
2881                    "description": "When true, output each line as N#AB:content where N is the line number and AB is a content hash. Use with hashline_edit tool for precise edits."
2882                }
2883            },
2884            "required": ["path"]
2885        })
2886    }
2887
2888    fn effects(&self) -> ToolEffects {
2889        ToolEffects::read()
2890    }
2891
2892    #[allow(clippy::too_many_lines)]
2893    async fn execute(
2894        &self,
2895        tool_call_id: &str,
2896        input: serde_json::Value,
2897        _on_update: Option<Box<dyn Fn(ToolUpdate) + Send + Sync>>,
2898    ) -> Result<ToolOutput> {
2899        let input_value = input.clone();
2900        let input: ReadInput =
2901            serde_json::from_value(input).map_err(|e| Error::validation(e.to_string()))?;
2902
2903        if matches!(input.limit, Some(limit) if limit <= 0) {
2904            return Err(Error::validation(
2905                "`limit` must be greater than 0".to_string(),
2906            ));
2907        }
2908        if matches!(input.offset, Some(offset) if offset < 0) {
2909            return Err(Error::validation(
2910                "`offset` must be non-negative".to_string(),
2911            ));
2912        }
2913
2914        let path = resolve_read_path(&input.path, &self.cwd);
2915        let path = enforce_read_scope(&path, &self.cwd)?;
2916
2917        let meta = asupersync::fs::metadata(&path).await.ok();
2918        if let Some(meta) = &meta {
2919            if !meta.is_file() {
2920                return Err(Error::tool(
2921                    "read",
2922                    format!("Path {} is not a regular file", path.display()),
2923                ));
2924            }
2925        }
2926
2927        let cache_key = tool_cache_key("read", &self.cwd, &input_value);
2928        let cache_mode = ToolCacheFingerprintMode::FileContent;
2929        let cache_deps = cache_dependency_for_path(&path, cache_mode);
2930        if let Some(output) = cached_tool_output(&cache_key, cache_deps.as_deref()) {
2931            return Ok(output);
2932        }
2933
2934        let mut file = asupersync::fs::File::open(&path)
2935            .await
2936            .map_err(|e| Error::tool("read", e.to_string()))?;
2937
2938        // Read initial chunk for mime detection
2939        let mut buffer = [0u8; 8192];
2940        let mut initial_read = 0;
2941        loop {
2942            let n = read_some(&mut file, &mut buffer[initial_read..])
2943                .await
2944                .map_err(|e| Error::tool("read", format!("Failed to read file: {e}")))?;
2945            if n == 0 {
2946                break;
2947            }
2948            initial_read += n;
2949            if initial_read == buffer.len() {
2950                break;
2951            }
2952        }
2953        let initial_bytes = &buffer[..initial_read];
2954
2955        if let Some(mime_type) = detect_supported_image_mime_type_from_bytes(initial_bytes) {
2956            if self.block_images {
2957                return Err(Error::tool(
2958                    "read",
2959                    "Images are blocked by configuration".to_string(),
2960                ));
2961            }
2962
2963            // For images, allow a larger on-disk source as long as it stays
2964            // within the read-tool input bound; resize/re-encode may still
2965            // bring the API payload under IMAGE_MAX_BYTES.
2966            let max_image_input_bytes = usize::try_from(READ_TOOL_MAX_BYTES).unwrap_or(usize::MAX);
2967            if let Some(meta) = &meta {
2968                if meta.len() > READ_TOOL_MAX_BYTES {
2969                    return Err(Error::tool(
2970                        "read",
2971                        format!(
2972                            "Image is too large ({} bytes). Max allowed is {} bytes.",
2973                            meta.len(),
2974                            READ_TOOL_MAX_BYTES
2975                        ),
2976                    ));
2977                }
2978            }
2979            let mut all_bytes = Vec::with_capacity(initial_read);
2980            all_bytes.extend_from_slice(initial_bytes);
2981
2982            let remaining_limit = max_image_input_bytes.saturating_sub(initial_read);
2983            let mut limiter = file.take((remaining_limit as u64).saturating_add(1));
2984            limiter
2985                .read_to_end(&mut all_bytes)
2986                .await
2987                .map_err(|e| Error::tool("read", format!("Failed to read image: {e}")))?;
2988
2989            if all_bytes.len() > max_image_input_bytes {
2990                return Err(Error::tool(
2991                    "read",
2992                    format!(
2993                        "Image is too large ({} bytes). Max allowed is {} bytes.",
2994                        all_bytes.len(),
2995                        READ_TOOL_MAX_BYTES
2996                    ),
2997                ));
2998            }
2999
3000            let resized = if self.auto_resize {
3001                resize_image_if_needed(&all_bytes, mime_type)?
3002            } else {
3003                ResizedImage::original(all_bytes, mime_type)
3004            };
3005
3006            if resized.bytes.len() > IMAGE_MAX_BYTES {
3007                let message = if resized.resized {
3008                    format!(
3009                        "Image is too large ({} bytes) after resizing. Max allowed is {} bytes.",
3010                        resized.bytes.len(),
3011                        IMAGE_MAX_BYTES
3012                    )
3013                } else {
3014                    format!(
3015                        "Image is too large ({} bytes). Max allowed is {} bytes.",
3016                        resized.bytes.len(),
3017                        IMAGE_MAX_BYTES
3018                    )
3019                };
3020                return Err(Error::tool("read", message));
3021            }
3022
3023            let base64_data =
3024                base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &resized.bytes);
3025
3026            let mut note = format!("Read image file [{}]", resized.mime_type);
3027            if resized.resized {
3028                if let (Some(ow), Some(oh), Some(w), Some(h)) = (
3029                    resized.original_width,
3030                    resized.original_height,
3031                    resized.width,
3032                    resized.height,
3033                ) {
3034                    if w > 0 {
3035                        let scale = f64::from(ow) / f64::from(w);
3036                        let _ = write!(
3037                            note,
3038                            "\n[Image: original {ow}x{oh}, displayed at {w}x{h}. Multiply coordinates by {scale:.2} to map to original image.]"
3039                        );
3040                    } else {
3041                        let _ =
3042                            write!(note, "\n[Image: original {ow}x{oh}, displayed at {w}x{h}.]");
3043                    }
3044                }
3045            }
3046
3047            return Ok(ToolOutput {
3048                content: vec![
3049                    ContentBlock::Text(TextContent::new(note)),
3050                    ContentBlock::Image(ImageContent {
3051                        data: base64_data,
3052                        mime_type: resized.mime_type.to_string(),
3053                    }),
3054                ],
3055                details: None,
3056                is_error: false,
3057            });
3058        }
3059
3060        // Text path: optimized streaming read.
3061        // We need:
3062        // 1. Total line count.
3063        // 2. Content for the requested range (offset/limit) OR head/tail if no range.
3064
3065        // Reset file to start if we read some bytes
3066        if initial_read > 0 {
3067            file.seek(SeekFrom::Start(0))
3068                .await
3069                .map_err(|e| Error::tool("read", format!("Failed to seek: {e}")))?;
3070        }
3071
3072        let mut raw_content = Vec::new();
3073        let mut newlines_seen = 0usize;
3074
3075        // Input offset is 1-based. Convert to 0-based index.
3076        let start_line_idx = match input.offset {
3077            Some(n) if n > 0 => n.saturating_sub(1).try_into().unwrap_or(usize::MAX),
3078            _ => 0,
3079        };
3080        let limit_lines = input
3081            .limit
3082            .map_or(usize::MAX, |l| l.try_into().unwrap_or(usize::MAX));
3083        let end_line_idx = start_line_idx.saturating_add(limit_lines);
3084
3085        let mut collecting = start_line_idx == 0;
3086        let mut buf = vec![0u8; 64 * 1024].into_boxed_slice(); // 64KB chunks
3087        let mut last_byte_was_newline = false;
3088        let mut pending_cr = false;
3089
3090        // We need to track total_lines accurately for the output.
3091        // We will respect MAX_BYTES for *collected* content, but continue scanning for line counts
3092        // so pagination metadata is correct.
3093        let mut total_bytes_read = 0u64;
3094
3095        loop {
3096            let n = read_some(&mut file, &mut buf)
3097                .await
3098                .map_err(|e| Error::tool("read", e.to_string()))?;
3099            if n == 0 {
3100                break;
3101            }
3102            total_bytes_read = total_bytes_read.saturating_add(n as u64);
3103
3104            let chunk = normalize_line_endings_chunk(&buf[..n], &mut pending_cr);
3105            if chunk.is_empty() {
3106                continue;
3107            }
3108            last_byte_was_newline = chunk.last().is_some_and(|byte| *byte == b'\n');
3109            let mut chunk_cursor = 0;
3110
3111            for pos in memchr::memchr_iter(b'\n', &chunk) {
3112                // Check if this newline marks the end of a line we are collecting
3113                if collecting {
3114                    // newlines_seen is the index of the line ending at this newline
3115                    if newlines_seen + 1 == end_line_idx {
3116                        // We reached the limit. Collect up to this newline.
3117                        if raw_content.len() < DEFAULT_MAX_BYTES {
3118                            let remaining = DEFAULT_MAX_BYTES - raw_content.len();
3119                            let slice_len = (pos + 1 - chunk_cursor).min(remaining);
3120                            raw_content
3121                                .extend_from_slice(&chunk[chunk_cursor..chunk_cursor + slice_len]);
3122                        }
3123                        collecting = false;
3124                        chunk_cursor = pos + 1;
3125                    }
3126                }
3127
3128                newlines_seen += 1;
3129
3130                // Check if this newline marks the start of the window
3131                if !collecting && newlines_seen == start_line_idx {
3132                    collecting = true;
3133                    chunk_cursor = pos + 1;
3134                }
3135            }
3136
3137            // Append remainder of chunk if collecting
3138            if collecting && chunk_cursor < chunk.len() && raw_content.len() < DEFAULT_MAX_BYTES {
3139                let remaining = DEFAULT_MAX_BYTES - raw_content.len();
3140                let slice_len = (chunk.len() - chunk_cursor).min(remaining);
3141                raw_content.extend_from_slice(&chunk[chunk_cursor..chunk_cursor + slice_len]);
3142            }
3143        }
3144
3145        if pending_cr {
3146            last_byte_was_newline = true;
3147            if collecting && raw_content.len() < DEFAULT_MAX_BYTES {
3148                raw_content.push(b'\n');
3149            }
3150            newlines_seen += 1;
3151        }
3152
3153        // A trailing newline terminates the last line rather than starting a new one.
3154        // Also keep empty files at 0 lines so explicit positive offsets can error correctly.
3155        let total_lines = if total_bytes_read == 0 {
3156            0
3157        } else if last_byte_was_newline {
3158            newlines_seen
3159        } else {
3160            newlines_seen + 1
3161        };
3162        let text_content = String::from_utf8_lossy(&raw_content).into_owned();
3163
3164        // Handle empty file.
3165        // Offset=0 behaves like "start from beginning", but positive offsets should fail.
3166        if total_lines == 0 {
3167            if input.offset.unwrap_or(0) > 0 {
3168                let offset_display = input.offset.unwrap_or(0);
3169                return Err(Error::tool(
3170                    "read",
3171                    format!(
3172                        "Offset {offset_display} is beyond end of file ({total_lines} lines total)"
3173                    ),
3174                ));
3175            }
3176            let output = ToolOutput {
3177                content: vec![ContentBlock::Text(TextContent::new(""))],
3178                details: None,
3179                is_error: false,
3180            };
3181            cache_tool_output(
3182                cache_key,
3183                stable_cache_dependency_for_path(&path, cache_mode, cache_deps.as_deref()),
3184                &output,
3185            );
3186            return Ok(output);
3187        }
3188
3189        // Now we have the content (up to safety limit) in memory, but only for the requested window.
3190        // `text_content` starts at `start_line_idx`.
3191
3192        let start_line = start_line_idx;
3193        let start_line_display = start_line.saturating_add(1);
3194
3195        if start_line >= total_lines {
3196            let offset_display = input.offset.unwrap_or(0);
3197            return Err(Error::tool(
3198                "read",
3199                format!(
3200                    "Offset {offset_display} is beyond end of file ({total_lines} lines total)"
3201                ),
3202            ));
3203        }
3204
3205        let max_lines_for_truncation = input
3206            .limit
3207            .and_then(|l| usize::try_from(l).ok())
3208            .unwrap_or(DEFAULT_MAX_LINES);
3209        let display_limit = max_lines_for_truncation.saturating_add(1);
3210
3211        // We calculate lines to take based on the limit, but since we already filtered
3212        // during read, we can mostly trust `text_content`, except for `DEFAULT_MAX_BYTES` truncation.
3213
3214        let lines_to_take = limit_lines.min(display_limit);
3215
3216        let mut selected_content = String::new();
3217        let line_iter = text_content.split('\n');
3218
3219        // Note: we use skip(0) because text_content is already offset
3220        let effective_iter = if text_content.ends_with('\n') {
3221            line_iter.take(lines_to_take)
3222        } else {
3223            line_iter.take(usize::MAX)
3224        };
3225
3226        let max_line_num = start_line.saturating_add(lines_to_take).min(total_lines);
3227        let line_num_width = max_line_num.to_string().len().max(5);
3228
3229        for (i, line) in effective_iter.enumerate() {
3230            if i >= lines_to_take || start_line + i >= total_lines {
3231                break;
3232            }
3233            if i > 0 {
3234                selected_content.push('\n');
3235            }
3236            let line_idx = start_line + i; // 0-indexed
3237            let line = line.strip_suffix('\r').unwrap_or(line);
3238            if input.hashline {
3239                let tag = format_hashline_tag(line_idx, line);
3240                let _ = write!(selected_content, "{tag}:{line}");
3241            } else {
3242                let line_num = line_idx + 1;
3243                let _ = write!(selected_content, "{line_num:>line_num_width$}→{line}");
3244            }
3245
3246            if selected_content.len() > DEFAULT_MAX_BYTES * 2 {
3247                break;
3248            }
3249        }
3250
3251        let artifact_source = (selected_content.len() > TOOL_OUTPUT_ARTIFACT_THRESHOLD_BYTES)
3252            .then(|| selected_content.clone());
3253
3254        let mut truncation = truncate_head(
3255            selected_content,
3256            max_lines_for_truncation,
3257            DEFAULT_MAX_BYTES,
3258        );
3259        truncation.total_lines = total_lines;
3260
3261        let mut output_text = std::mem::take(&mut truncation.content);
3262        let mut details: Option<serde_json::Value> = None;
3263
3264        if truncation.first_line_exceeds_limit {
3265            let first_line = text_content.split('\n').next().unwrap_or("");
3266            let first_line = first_line.strip_suffix('\r').unwrap_or(first_line);
3267            let first_line_size = format_size(first_line.len());
3268            output_text = format!(
3269                "[Line {start_line_display} is {first_line_size}, exceeds {} limit. Use bash: sed -n '{start_line_display}p' '{}' | head -c {DEFAULT_MAX_BYTES}]",
3270                format_size(DEFAULT_MAX_BYTES),
3271                input.path.replace('\'', "'\\''")
3272            );
3273            details = Some(serde_json::json!({ "truncation": truncation }));
3274        } else if truncation.truncated {
3275            let end_line_display = start_line_display
3276                .saturating_add(truncation.output_lines)
3277                .saturating_sub(1);
3278            let next_offset = end_line_display.saturating_add(1);
3279
3280            if truncation.truncated_by == Some(TruncatedBy::Lines) {
3281                let _ = write!(
3282                    output_text,
3283                    "\n\n[Showing lines {start_line_display}-{end_line_display} of {total_lines}. Use offset={next_offset} to continue.]"
3284                );
3285            } else {
3286                let _ = write!(
3287                    output_text,
3288                    "\n\n[Showing lines {start_line_display}-{end_line_display} of {total_lines} ({} limit). Use offset={next_offset} to continue.]",
3289                    format_size(DEFAULT_MAX_BYTES)
3290                );
3291            }
3292
3293            details = Some(serde_json::json!({ "truncation": truncation }));
3294        } else {
3295            // Calculate how many lines we actually displayed
3296            let displayed_lines = truncation.output_lines;
3297            let end_line_display = start_line_display
3298                .saturating_add(displayed_lines)
3299                .saturating_sub(1);
3300
3301            if end_line_display < total_lines {
3302                let remaining = total_lines.saturating_sub(end_line_display);
3303                let next_offset = end_line_display.saturating_add(1);
3304                let _ = write!(
3305                    output_text,
3306                    "\n\n[{remaining} more lines in file. Use offset={next_offset} to continue.]"
3307                );
3308            }
3309        }
3310
3311        if let Some(artifact_source) = artifact_source.as_deref() {
3312            attach_text_artifact_if_needed_with_root(
3313                self.artifact_root.as_deref(),
3314                &mut output_text,
3315                &mut details,
3316                "read",
3317                tool_call_id,
3318                "selectedTextWindow",
3319                artifact_source,
3320            );
3321        }
3322
3323        let output = ToolOutput {
3324            content: vec![ContentBlock::Text(TextContent::new(output_text))],
3325            details,
3326            is_error: false,
3327        };
3328        cache_tool_output(
3329            cache_key,
3330            stable_cache_dependency_for_path(&path, cache_mode, cache_deps.as_deref()),
3331            &output,
3332        );
3333        Ok(output)
3334    }
3335}
3336
3337// ============================================================================
3338// Bash Tool
3339// ============================================================================
3340
3341/// Input parameters for the bash tool.
3342#[derive(Debug, Deserialize)]
3343#[serde(rename_all = "camelCase")]
3344struct BashInput {
3345    command: String,
3346    timeout: Option<u64>,
3347}
3348
3349pub struct BashTool {
3350    cwd: PathBuf,
3351    shell_path: Option<String>,
3352    command_prefix: Option<String>,
3353    artifact_root: Option<PathBuf>,
3354}
3355
3356#[derive(Debug, Clone)]
3357pub struct BashRunResult {
3358    pub output: String,
3359    pub exit_code: i32,
3360    pub cancelled: bool,
3361    pub cancellation_reason: Option<BashCancellationReason>,
3362    pub timeout_ms: Option<u64>,
3363    pub truncated: bool,
3364    pub full_output_path: Option<String>,
3365    pub truncation: Option<TruncationResult>,
3366}
3367
3368#[derive(Debug)]
3369enum BashPipeFrame {
3370    Chunk(Vec<u8>),
3371    Error(String),
3372}
3373
3374#[allow(clippy::unnecessary_lazy_evaluations)] // lazy eval needed on unix for signal()
3375fn exit_status_code(status: std::process::ExitStatus) -> i32 {
3376    status.code().unwrap_or_else(|| {
3377        #[cfg(unix)]
3378        {
3379            use std::os::unix::process::ExitStatusExt as _;
3380            status.signal().map_or(-1, |signal| -signal)
3381        }
3382        #[cfg(not(unix))]
3383        {
3384            -1
3385        }
3386    })
3387}
3388
3389fn bash_cancellation_details(
3390    reason: BashCancellationReason,
3391    timeout_ms: Option<u64>,
3392    exit_code: i32,
3393) -> serde_json::Value {
3394    serde_json::json!({
3395        "schema": BASH_CANCELLATION_SCHEMA_V1,
3396        "status": "cancelled",
3397        "reason": reason.as_str(),
3398        "cleanup": "process_group_tree_terminated",
3399        "exitCode": exit_code,
3400        "timeoutMs": timeout_ms,
3401    })
3402}
3403
3404#[allow(clippy::too_many_lines)]
3405pub(crate) async fn run_bash_command(
3406    cwd: &Path,
3407    shell_path: Option<&str>,
3408    command_prefix: Option<&str>,
3409    command: &str,
3410    timeout_secs: Option<u64>,
3411    on_update: Option<&(dyn Fn(ToolUpdate) + Send + Sync)>,
3412) -> Result<BashRunResult> {
3413    let timeout_secs = match timeout_secs {
3414        None => Some(DEFAULT_BASH_TIMEOUT_SECS),
3415        Some(0) => None,
3416        Some(value) => Some(value),
3417    };
3418    let command = command_prefix.filter(|p| !p.trim().is_empty()).map_or_else(
3419        || command.to_string(),
3420        |prefix| format!("{prefix}\n{command}"),
3421    );
3422    let command = format!("trap 'code=$?; wait; exit $code' EXIT\n{command}");
3423
3424    if !cwd.exists() {
3425        return Err(Error::tool(
3426            "bash",
3427            format!(
3428                "Working directory does not exist: {}\nCannot execute bash commands.",
3429                cwd.display()
3430            ),
3431        ));
3432    }
3433
3434    let shell = shell_path.unwrap_or_else(|| {
3435        for path in ["/bin/bash", "/usr/bin/bash", "/usr/local/bin/bash"] {
3436            if Path::new(path).exists() {
3437                return path;
3438            }
3439        }
3440        "sh"
3441    });
3442
3443    let mut cmd = command_with_default_sigpipe_in_dir(shell, cwd)
3444        .map_err(|e| Error::tool("bash", format!("Failed to prepare shell: {e}")))?;
3445    cmd.arg("-c")
3446        .arg(&command)
3447        .current_dir(cwd)
3448        .stdin(Stdio::null())
3449        .stdout(Stdio::piped())
3450        .stderr(Stdio::piped());
3451
3452    // Place the shell in its own process group so background children
3453    // can be killed reliably even if the shell exits first.
3454    isolate_command_process_group(&mut cmd);
3455
3456    let mut child = cmd
3457        .spawn()
3458        .map_err(|e| Error::tool("bash", format!("Failed to spawn shell: {e}")))?;
3459
3460    let stdout = child
3461        .stdout
3462        .take()
3463        .ok_or_else(|| Error::tool("bash", "Missing stdout".to_string()))?;
3464    let stderr = child
3465        .stderr
3466        .take()
3467        .ok_or_else(|| Error::tool("bash", "Missing stderr".to_string()))?;
3468
3469    // Wrap in ProcessGuard for cleanup (including tree kill)
3470    let mut guard = ProcessGuard::new(child, ProcessCleanupMode::ProcessGroupTree);
3471
3472    // We use a bounded channel to provide backpressure. If the child process
3473    // produces output faster than the async loop can drain it (and spill to disk),
3474    // the pump threads will block on send(), which stops them from reading from the OS pipe.
3475    // The OS pipe buffer will fill up, causing the child's `write()` calls to block.
3476    // This correctly pauses the child until we catch up, preventing unbounded memory growth (OOM).
3477    let (tx, rx) = mpsc::sync_channel::<BashPipeFrame>(1024);
3478    let tx_stdout = tx.clone();
3479
3480    // Design Decision (bd-xdcrh.4.3):
3481    // We intentionally use raw dedicated OS threads here rather than `asupersync::runtime::spawn_blocking`.
3482    // The `pump_stream` loop blocks indefinitely on `read()` until the subprocess closes the pipe (EOF).
3483    // If we used the runtime's blocking pool, concurrently running long-lived bash tools (like compilers
3484    // or servers) could easily exhaust the pool's thread limit, starving the rest of the application
3485    // of threads needed for short-lived blocking I/O (e.g., SQLite transactions or filesystem metadata).
3486    // Dedicated threads cleanly isolate this unbounded blocking risk.
3487    let stdout_thread = thread::spawn(move || pump_stream(stdout, "stdout", &tx_stdout));
3488    let stderr_thread = thread::spawn(move || pump_stream(stderr, "stderr", &tx));
3489
3490    let max_chunks_bytes = DEFAULT_MAX_BYTES.saturating_mul(2);
3491    let mut bash_output = BashOutputState::new(max_chunks_bytes);
3492    bash_output.timeout_ms = timeout_secs.map(|s| s.saturating_mul(1000));
3493
3494    let cx = AgentCx::for_current_or_request();
3495    let mut timed_out = false;
3496    let mut cancelled = false;
3497    let mut cancellation_reason: Option<BashCancellationReason> = None;
3498    let mut exit_code: Option<i32> = None;
3499    let start = cx
3500        .cx()
3501        .timer_driver()
3502        .map_or_else(wall_now, |timer| timer.now());
3503    let timeout = timeout_secs.map(Duration::from_secs);
3504    let mut terminate_deadline: Option<asupersync::Time> = None;
3505
3506    let tick = Duration::from_millis(10);
3507    loop {
3508        let mut updated = false;
3509        while let Ok(frame) = rx.try_recv() {
3510            if let Err(err) = ingest_bash_pipe_frame(frame, &mut bash_output).await {
3511                let _ = guard.kill();
3512                return Err(err);
3513            }
3514            updated = true;
3515        }
3516
3517        if updated {
3518            emit_bash_update(&bash_output, on_update)?;
3519        }
3520
3521        match guard.try_wait_child() {
3522            Ok(Some(status)) => {
3523                exit_code = Some(exit_status_code(status));
3524                break;
3525            }
3526            Ok(None) => {}
3527            Err(err) => return Err(Error::tool("bash", err.to_string())),
3528        }
3529
3530        let now = cx
3531            .cx()
3532            .timer_driver()
3533            .map_or_else(wall_now, |timer| timer.now());
3534
3535        if let Some(deadline) = terminate_deadline {
3536            if now >= deadline {
3537                if let Some(status) = guard.kill() {
3538                    exit_code = Some(exit_status_code(status));
3539                }
3540                break; // Guard now owns no child after kill()
3541            }
3542        } else if let Some(timeout) = timeout {
3543            let elapsed = std::time::Duration::from_nanos(now.duration_since(start));
3544            if elapsed >= timeout {
3545                timed_out = true;
3546                cancellation_reason = Some(BashCancellationReason::Timeout);
3547                let pid = guard.child.as_ref().map(std::process::Child::id);
3548                terminate_process_group_tree(pid);
3549                terminate_deadline = Some(now + Duration::from_secs(BASH_TERMINATE_GRACE_SECS));
3550            }
3551        }
3552
3553        if terminate_deadline.is_none() && cx.checkpoint().is_err() {
3554            cancelled = true;
3555            cancellation_reason = Some(BashCancellationReason::AmbientCancellation);
3556            let _ = guard.kill();
3557            exit_code = Some(-1);
3558            break;
3559        }
3560
3561        sleep(now, tick).await;
3562    }
3563
3564    // Drain any remaining channel frames while waiting for the pump threads
3565    // to observe EOF and exit. Because the channel is bounded, they may still
3566    // be blocked on send() until we consume the buffered output after the child
3567    // closes its pipe ends. The 5-second cap is a safety net for pathological
3568    // cases (e.g. the child spawned a grandchild that inherited the pipe fd
3569    // and is still running).
3570    {
3571        let drain_start = cx
3572            .cx()
3573            .timer_driver()
3574            .map_or_else(wall_now, |timer| timer.now());
3575        let drain_deadline = drain_start + Duration::from_secs(5);
3576        let allow_drain_cancellation = !cancelled && !timed_out && exit_code.is_none();
3577        loop {
3578            // Drain everything currently available in the channel.
3579            let mut got_data = false;
3580            while let Ok(frame) = rx.try_recv() {
3581                if let Err(err) = ingest_bash_pipe_frame(frame, &mut bash_output).await {
3582                    let _ = guard.kill();
3583                    return Err(err);
3584                }
3585                got_data = true;
3586            }
3587            if got_data {
3588                emit_bash_update(&bash_output, on_update)?;
3589            }
3590
3591            // If both pump threads have finished, all data is in the channel
3592            // and we've drained it above, so we're done.
3593            if stdout_thread.is_finished() && stderr_thread.is_finished() {
3594                // One final drain in case they sent items between our last
3595                // try_recv loop and the is_finished check.
3596                while let Ok(frame) = rx.try_recv() {
3597                    if let Err(err) = ingest_bash_pipe_frame(frame, &mut bash_output).await {
3598                        let _ = guard.kill();
3599                        return Err(err);
3600                    }
3601                }
3602                break;
3603            }
3604
3605            let now = cx
3606                .cx()
3607                .timer_driver()
3608                .map_or_else(wall_now, |timer| timer.now());
3609            if now >= drain_deadline {
3610                break;
3611            }
3612            if allow_drain_cancellation && cx.checkpoint().is_err() {
3613                cancelled = true;
3614                cancellation_reason.get_or_insert(BashCancellationReason::AmbientCancellation);
3615                break;
3616            }
3617            sleep(now, tick).await;
3618        }
3619    }
3620
3621    // Explicitly reap the child process to prevent zombies. try_wait_child()
3622    // uses WNOHANG which *should* reap the zombie on the first successful
3623    // return, but calling wait() as a belt-and-suspenders ensures the zombie
3624    // is cleaned up even if try_wait missed it (observed on macOS when the
3625    // child is in its own process group).
3626    if guard.child.is_some() {
3627        if let Ok(status) = guard.wait() {
3628            exit_code.get_or_insert_with(|| exit_status_code(status));
3629        }
3630    }
3631
3632    drop(bash_output.temp_file.take());
3633
3634    let raw_output = concat_chunks(&bash_output.chunks);
3635    let full_output = String::from_utf8_lossy(&raw_output).into_owned();
3636    let full_output_last_line_len = full_output.split('\n').next_back().map_or(0, str::len);
3637
3638    let mut truncation = truncate_tail(full_output, DEFAULT_MAX_LINES, DEFAULT_MAX_BYTES);
3639    if bash_output.total_bytes > bash_output.chunks_bytes {
3640        truncation.truncated = true;
3641        truncation.truncated_by = Some(TruncatedBy::Bytes);
3642        truncation.total_bytes = bash_output.total_bytes;
3643        truncation.total_lines = line_count_from_newline_count(
3644            bash_output.total_bytes,
3645            bash_output.line_count,
3646            bash_output.last_byte_was_newline,
3647        );
3648    }
3649
3650    let mut output_text = if truncation.content.is_empty() {
3651        "(no output)".to_string()
3652    } else {
3653        std::mem::take(&mut truncation.content)
3654    };
3655
3656    let mut full_output_path = None;
3657    if truncation.truncated {
3658        if let Some(path) = bash_output.temp_file_path.as_ref() {
3659            full_output_path = Some(path.display().to_string());
3660        }
3661
3662        let start_line = truncation
3663            .total_lines
3664            .saturating_sub(truncation.output_lines)
3665            .saturating_add(1);
3666        let end_line = truncation.total_lines;
3667
3668        let display_path = full_output_path.as_deref().unwrap_or("undefined");
3669        let file_limit_hit = bash_output.total_bytes > BASH_FILE_LIMIT_BYTES;
3670        let output_qualifier = if file_limit_hit {
3671            format!(
3672                "Partial output (capped at {})",
3673                format_size(BASH_FILE_LIMIT_BYTES)
3674            )
3675        } else {
3676            "Full output".to_string()
3677        };
3678
3679        if truncation.last_line_partial {
3680            let last_line_size = format_size(full_output_last_line_len);
3681            let _ = write!(
3682                output_text,
3683                "\n\n[Showing last {} of line {end_line} (line is {last_line_size}). {output_qualifier}: {display_path}]",
3684                format_size(truncation.output_bytes)
3685            );
3686        } else if truncation.truncated_by == Some(TruncatedBy::Lines) {
3687            let _ = write!(
3688                output_text,
3689                "\n\n[Showing lines {start_line}-{end_line} of {}. {output_qualifier}: {display_path}]",
3690                truncation.total_lines
3691            );
3692        } else {
3693            let _ = write!(
3694                output_text,
3695                "\n\n[Showing lines {start_line}-{end_line} of {} ({} limit). {output_qualifier}: {display_path}]",
3696                truncation.total_lines,
3697                format_size(DEFAULT_MAX_BYTES)
3698            );
3699        }
3700    }
3701
3702    if timed_out {
3703        cancelled = true;
3704        if !output_text.is_empty() {
3705            output_text.push_str("\n\n");
3706        }
3707        let timeout_display = timeout_secs.unwrap_or(0);
3708        let _ = write!(
3709            output_text,
3710            "Command timed out after {timeout_display} seconds"
3711        );
3712    }
3713
3714    let exit_code = exit_code.unwrap_or(-1);
3715    if !cancelled && exit_code != 0 {
3716        let _ = write!(output_text, "\n\nCommand exited with code {exit_code}");
3717    }
3718
3719    Ok(BashRunResult {
3720        output: output_text,
3721        exit_code,
3722        cancelled,
3723        cancellation_reason,
3724        timeout_ms: timeout_secs.map(|s| s.saturating_mul(1000)),
3725        truncated: truncation.truncated,
3726        full_output_path,
3727        truncation: if truncation.truncated {
3728            Some(truncation)
3729        } else {
3730            None
3731        },
3732    })
3733}
3734
3735impl BashTool {
3736    pub fn new(cwd: &Path) -> Self {
3737        Self {
3738            cwd: cwd.to_path_buf(),
3739            shell_path: None,
3740            command_prefix: None,
3741            artifact_root: None,
3742        }
3743    }
3744
3745    pub fn with_shell(
3746        cwd: &Path,
3747        shell_path: Option<String>,
3748        command_prefix: Option<String>,
3749    ) -> Self {
3750        Self {
3751            cwd: cwd.to_path_buf(),
3752            shell_path,
3753            command_prefix,
3754            artifact_root: None,
3755        }
3756    }
3757
3758    #[cfg(test)]
3759    fn with_artifact_root(cwd: &Path, artifact_root: &Path) -> Self {
3760        Self {
3761            cwd: cwd.to_path_buf(),
3762            shell_path: None,
3763            command_prefix: None,
3764            artifact_root: Some(artifact_root.to_path_buf()),
3765        }
3766    }
3767}
3768
3769#[async_trait]
3770#[allow(clippy::unnecessary_literal_bound)]
3771impl Tool for BashTool {
3772    fn name(&self) -> &str {
3773        "bash"
3774    }
3775    fn label(&self) -> &str {
3776        "bash"
3777    }
3778    fn description(&self) -> &str {
3779        "Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last 2000 lines or 1MB (whichever is hit first). If truncated, full output is saved to a temp file. `timeout` defaults to 120 seconds; set `timeout: 0` to disable."
3780    }
3781
3782    fn parameters(&self) -> serde_json::Value {
3783        serde_json::json!({
3784            "type": "object",
3785            "properties": {
3786                "command": {
3787                    "type": "string",
3788                    "description": "Bash command to execute"
3789                },
3790                "timeout": {
3791                    "type": "integer",
3792                    "description": "Timeout in seconds (default 120; set 0 to disable)"
3793                }
3794            },
3795            "required": ["command"]
3796        })
3797    }
3798
3799    fn effects(&self) -> ToolEffects {
3800        ToolEffects::process().union(ToolEffects::write())
3801    }
3802
3803    #[allow(clippy::too_many_lines)]
3804    async fn execute(
3805        &self,
3806        tool_call_id: &str,
3807        input: serde_json::Value,
3808        on_update: Option<Box<dyn Fn(ToolUpdate) + Send + Sync>>,
3809    ) -> Result<ToolOutput> {
3810        let input: BashInput =
3811            serde_json::from_value(input).map_err(|e| Error::validation(e.to_string()))?;
3812
3813        let result = run_bash_command(
3814            &self.cwd,
3815            self.shell_path.as_deref(),
3816            self.command_prefix.as_deref(),
3817            &input.command,
3818            input.timeout,
3819            on_update.as_deref(),
3820        )
3821        .await?;
3822
3823        let mut details_map = serde_json::Map::new();
3824        if let Some(truncation) = result.truncation.as_ref() {
3825            details_map.insert("truncation".to_string(), serde_json::to_value(truncation)?);
3826        }
3827        if let Some(path) = result.full_output_path.as_ref() {
3828            details_map.insert(
3829                "fullOutputPath".to_string(),
3830                serde_json::Value::String(path.clone()),
3831            );
3832        }
3833        if let Some(reason) = result.cancellation_reason {
3834            details_map.insert(
3835                "cancellation".to_string(),
3836                bash_cancellation_details(reason, result.timeout_ms, result.exit_code),
3837            );
3838        }
3839
3840        let details = if details_map.is_empty() {
3841            None
3842        } else {
3843            Some(serde_json::Value::Object(details_map))
3844        };
3845        let mut details = details;
3846        let mut output_text = result.output;
3847
3848        if let Some(path) = result.full_output_path.as_deref() {
3849            attach_text_artifact_from_path_if_needed_with_root(
3850                self.artifact_root.as_deref(),
3851                &mut output_text,
3852                &mut details,
3853                "bash",
3854                tool_call_id,
3855                "fullCommandOutput",
3856                Path::new(path),
3857            );
3858        }
3859
3860        let is_error = result.cancelled || result.exit_code != 0;
3861
3862        Ok(ToolOutput {
3863            content: vec![ContentBlock::Text(TextContent::new(output_text))],
3864            details,
3865            is_error,
3866        })
3867    }
3868}
3869
3870// ============================================================================
3871// Edit Tool
3872// ============================================================================
3873
3874/// Input parameters for the edit tool.
3875#[derive(Debug, Deserialize)]
3876#[serde(rename_all = "camelCase")]
3877struct EditInput {
3878    path: String,
3879    old_text: String,
3880    new_text: String,
3881}
3882
3883pub struct EditTool {
3884    cwd: PathBuf,
3885}
3886
3887impl EditTool {
3888    pub fn new(cwd: &Path) -> Self {
3889        Self {
3890            cwd: cwd.to_path_buf(),
3891        }
3892    }
3893}
3894
3895fn strip_bom(s: &str) -> (&str, bool) {
3896    s.strip_prefix('\u{FEFF}')
3897        .map_or_else(|| (s, false), |stripped| (stripped, true))
3898}
3899
3900fn detect_line_ending(content: &str) -> &'static str {
3901    let bytes = content.as_bytes();
3902    let mut idx = 0;
3903    while idx < bytes.len() {
3904        match bytes[idx] {
3905            b'\r' => {
3906                return if bytes.get(idx + 1) == Some(&b'\n') {
3907                    "\r\n"
3908                } else {
3909                    "\r"
3910                };
3911            }
3912            b'\n' => return "\n",
3913            _ => idx += 1,
3914        }
3915    }
3916    "\n"
3917}
3918
3919fn normalize_to_lf(text: &str) -> String {
3920    if !text.contains('\r') {
3921        return text.to_string();
3922    }
3923    let mut out = String::with_capacity(text.len());
3924    let mut chars = text.chars().peekable();
3925    while let Some(c) = chars.next() {
3926        if c == '\r' {
3927            out.push('\n');
3928            if chars.peek() == Some(&'\n') {
3929                chars.next();
3930            }
3931        } else {
3932            out.push(c);
3933        }
3934    }
3935    out
3936}
3937
3938fn normalize_line_endings_chunk<'a>(
3939    chunk: &'a [u8],
3940    pending_cr: &mut bool,
3941) -> std::borrow::Cow<'a, [u8]> {
3942    if !*pending_cr && memchr::memchr(b'\r', chunk).is_none() {
3943        return std::borrow::Cow::Borrowed(chunk);
3944    }
3945
3946    let mut normalized = Vec::with_capacity(chunk.len().saturating_add(usize::from(*pending_cr)));
3947    let mut idx = 0;
3948
3949    if *pending_cr {
3950        normalized.push(b'\n');
3951        if chunk.first() == Some(&b'\n') {
3952            idx = 1;
3953        }
3954        *pending_cr = false;
3955    }
3956
3957    while idx < chunk.len() {
3958        match chunk[idx] {
3959            b'\r' => {
3960                if chunk.get(idx + 1) == Some(&b'\n') {
3961                    normalized.push(b'\n');
3962                    idx += 2;
3963                } else if idx + 1 < chunk.len() {
3964                    normalized.push(b'\n');
3965                    idx += 1;
3966                } else {
3967                    *pending_cr = true;
3968                    idx += 1;
3969                }
3970            }
3971            byte => {
3972                normalized.push(byte);
3973                idx += 1;
3974            }
3975        }
3976    }
3977
3978    std::borrow::Cow::Owned(normalized)
3979}
3980
3981fn restore_line_endings(text: &str, ending: &str) -> String {
3982    match ending {
3983        "\r\n" => text.replace('\n', "\r\n"),
3984        "\r" => text.replace('\n', "\r"),
3985        _ => text.to_string(),
3986    }
3987}
3988
3989#[derive(Debug, Clone)]
3990struct FuzzyMatchResult {
3991    found: bool,
3992    index: usize,
3993    match_length: usize,
3994    exact_match: bool,
3995}
3996
3997/// Map a range in normalized content back to byte offsets in the original text.
3998///
3999/// Returns `(original_start_byte_idx, original_match_byte_len)`.
4000fn map_normalized_range_to_original(
4001    content: &str,
4002    norm_match_start: usize,
4003    norm_match_len: usize,
4004) -> (usize, usize) {
4005    let mut norm_idx = 0;
4006    let mut orig_idx = 0;
4007    let mut match_start = None;
4008    let mut match_end = None;
4009    let norm_match_end = norm_match_start + norm_match_len;
4010    let mut last_trimmed_end = 0;
4011    let mut last_has_newline = false;
4012
4013    for line in content.split_inclusive('\n') {
4014        let line_content = line.strip_suffix('\n').unwrap_or(line);
4015        let has_newline = line.ends_with('\n');
4016        let trimmed_len = line_content
4017            .trim_end_matches(|c: char| c.is_whitespace() || is_special_unicode_space(c))
4018            .len();
4019        let trimmed_end = orig_idx + trimmed_len;
4020        last_trimmed_end = trimmed_end;
4021        last_has_newline = has_newline;
4022
4023        for (char_offset, c) in line_content.char_indices() {
4024            // match_end can be detected at any position including trailing
4025            // whitespace — it correctly points to right after the last content char.
4026            if norm_idx == norm_match_end && match_end.is_none() {
4027                match_end = Some(orig_idx + char_offset);
4028            }
4029
4030            if char_offset >= trimmed_len {
4031                continue;
4032            }
4033
4034            // match_start must only be detected at non-trailing-whitespace positions.
4035            // During trailing whitespace, norm_idx is "frozen" at the value after the
4036            // last real char, which corresponds to the newline in normalized content —
4037            // not the trailing space. The post-loop newline check handles that case.
4038            if norm_idx == norm_match_start && match_start.is_none() {
4039                match_start = Some(orig_idx + char_offset);
4040            }
4041            if match_start.is_some() && match_end.is_some() {
4042                break;
4043            }
4044
4045            let normalized_char = if is_special_unicode_space(c) {
4046                ' '
4047            } else if matches!(c, '\u{2018}' | '\u{2019}') {
4048                '\''
4049            } else if matches!(c, '\u{201C}' | '\u{201D}' | '\u{201E}' | '\u{201F}') {
4050                '"'
4051            } else if matches!(
4052                c,
4053                '\u{2010}'
4054                    | '\u{2011}'
4055                    | '\u{2012}'
4056                    | '\u{2013}'
4057                    | '\u{2014}'
4058                    | '\u{2015}'
4059                    | '\u{2212}'
4060            ) {
4061                '-'
4062            } else {
4063                c
4064            };
4065
4066            norm_idx += normalized_char.len_utf8();
4067        }
4068
4069        orig_idx += line_content.len();
4070
4071        if has_newline {
4072            if norm_idx == norm_match_start && match_start.is_none() {
4073                match_start = Some(orig_idx);
4074            }
4075            if norm_idx == norm_match_end && match_end.is_none() {
4076                match_end = Some(trimmed_end);
4077            }
4078
4079            norm_idx += 1;
4080            orig_idx += 1;
4081        }
4082
4083        if match_start.is_some() && match_end.is_some() {
4084            break;
4085        }
4086    }
4087
4088    if norm_idx == norm_match_end && match_end.is_none() {
4089        match_end = Some(if last_has_newline {
4090            orig_idx
4091        } else {
4092            last_trimmed_end
4093        });
4094    }
4095
4096    let start = match_start.unwrap_or(0);
4097    let end = match_end.unwrap_or(content.len());
4098    (start, end.saturating_sub(start))
4099}
4100
4101fn build_normalized_content(content: &str) -> String {
4102    let mut normalized = String::with_capacity(content.len());
4103    let mut lines = content.split('\n').peekable();
4104
4105    while let Some(line) = lines.next() {
4106        let trimmed_len = line
4107            .trim_end_matches(|c: char| c.is_whitespace() || is_special_unicode_space(c))
4108            .len();
4109        for (char_offset, c) in line.char_indices() {
4110            if char_offset >= trimmed_len {
4111                continue;
4112            }
4113            let normalized_char = if is_special_unicode_space(c) {
4114                ' '
4115            } else if matches!(c, '\u{2018}' | '\u{2019}') {
4116                '\''
4117            } else if matches!(c, '\u{201C}' | '\u{201D}' | '\u{201E}' | '\u{201F}') {
4118                '"'
4119            } else if matches!(
4120                c,
4121                '\u{2010}'
4122                    | '\u{2011}'
4123                    | '\u{2012}'
4124                    | '\u{2013}'
4125                    | '\u{2014}'
4126                    | '\u{2015}'
4127                    | '\u{2212}'
4128            ) {
4129                '-'
4130            } else {
4131                c
4132            };
4133            normalized.push(normalized_char);
4134        }
4135        if lines.peek().is_some() {
4136            normalized.push('\n');
4137        }
4138    }
4139    normalized
4140}
4141
4142#[cfg(test)]
4143fn fuzzy_find_text(content: &str, old_text: &str) -> FuzzyMatchResult {
4144    fuzzy_find_text_with_normalized(content, old_text, None, None)
4145}
4146
4147/// Like [`fuzzy_find_text`], but accepts optional pre-computed normalized
4148/// versions.
4149fn fuzzy_find_text_with_normalized(
4150    content: &str,
4151    old_text: &str,
4152    precomputed_content: Option<&str>,
4153    precomputed_old: Option<&str>,
4154) -> FuzzyMatchResult {
4155    use std::borrow::Cow;
4156
4157    // First, try exact match (fastest path)
4158    if let Some(index) = content.find(old_text) {
4159        return FuzzyMatchResult {
4160            found: true,
4161            index,
4162            match_length: old_text.len(),
4163            exact_match: true,
4164        };
4165    }
4166
4167    // Build normalized versions (reuse pre-computed if available)
4168    let normalized_content = precomputed_content.map_or_else(
4169        || Cow::Owned(build_normalized_content(content)),
4170        Cow::Borrowed,
4171    );
4172    let normalized_old_text = precomputed_old.map_or_else(
4173        || Cow::Owned(build_normalized_content(old_text)),
4174        Cow::Borrowed,
4175    );
4176
4177    // Try to find the normalized old_text in normalized content
4178    if let Some(normalized_index) = normalized_content.find(normalized_old_text.as_ref()) {
4179        let (original_start, original_match_len) =
4180            map_normalized_range_to_original(content, normalized_index, normalized_old_text.len());
4181
4182        return FuzzyMatchResult {
4183            found: true,
4184            index: original_start,
4185            match_length: original_match_len,
4186            exact_match: false,
4187        };
4188    }
4189
4190    FuzzyMatchResult {
4191        found: false,
4192        index: 0,
4193        match_length: 0,
4194        exact_match: false,
4195    }
4196}
4197
4198fn count_overlapping_occurrences(haystack: &str, needle: &str) -> usize {
4199    if needle.is_empty() {
4200        return 0;
4201    }
4202
4203    haystack
4204        .char_indices()
4205        .filter(|(idx, _)| haystack[*idx..].starts_with(needle))
4206        .count()
4207}
4208
4209#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4210enum DiffTag {
4211    Equal,
4212    Added,
4213    Removed,
4214}
4215
4216#[derive(Debug, Clone)]
4217struct DiffPart {
4218    tag: DiffTag,
4219    value: String,
4220}
4221
4222fn diff_parts(old_content: &str, new_content: &str) -> Vec<DiffPart> {
4223    use similar::ChangeTag;
4224
4225    let diff = similar::TextDiff::from_lines(old_content, new_content);
4226
4227    let mut parts: Vec<DiffPart> = Vec::new();
4228    let mut current_tag: Option<DiffTag> = None;
4229    let mut current_lines: Vec<&str> = Vec::new();
4230
4231    for change in diff.iter_all_changes() {
4232        let tag = match change.tag() {
4233            ChangeTag::Equal => DiffTag::Equal,
4234            ChangeTag::Insert => DiffTag::Added,
4235            ChangeTag::Delete => DiffTag::Removed,
4236        };
4237
4238        let mut line = change.value();
4239        if let Some(stripped) = line.strip_suffix('\n') {
4240            line = stripped;
4241        }
4242
4243        if current_tag == Some(tag) {
4244            current_lines.push(line);
4245        } else {
4246            if let Some(prev_tag) = current_tag {
4247                parts.push(DiffPart {
4248                    tag: prev_tag,
4249                    value: current_lines.join("\n"),
4250                });
4251            }
4252            current_tag = Some(tag);
4253            current_lines = vec![line];
4254        }
4255    }
4256
4257    if let Some(tag) = current_tag {
4258        parts.push(DiffPart {
4259            tag,
4260            value: current_lines.join("\n"),
4261        });
4262    }
4263
4264    parts
4265}
4266
4267fn diff_line_num_width(old_content: &str, new_content: &str) -> usize {
4268    // Count newlines with memchr (avoids iterator-item overhead of split().count())
4269    let old_line_count = memchr::memchr_iter(b'\n', old_content.as_bytes()).count() + 1;
4270    let new_line_count = memchr::memchr_iter(b'\n', new_content.as_bytes()).count() + 1;
4271    let max_line_num = old_line_count.max(new_line_count).max(1);
4272    max_line_num.ilog10() as usize + 1
4273}
4274
4275fn split_diff_lines(value: &str) -> Vec<&str> {
4276    // value is joined by `\n` from a Vec<&str> in diff_parts, so there is no
4277    // spurious trailing newline. We can split exactly.
4278    // We only need to handle the case where value is empty but it originated from
4279    // 0 elements, but `diff_parts` only emits when there is at least 1 line.
4280    // If value is "", `split('\n')` returns `[""]`, which correctly represents 1 empty line.
4281    value.split('\n').collect()
4282}
4283
4284#[inline]
4285const fn is_change_tag(tag: DiffTag) -> bool {
4286    matches!(tag, DiffTag::Added | DiffTag::Removed)
4287}
4288
4289#[derive(Debug)]
4290struct DiffRenderState {
4291    output: String,
4292    old_line_num: usize,
4293    new_line_num: usize,
4294    last_was_change: bool,
4295    first_changed_line: Option<usize>,
4296    line_num_width: usize,
4297    context_lines: usize,
4298}
4299
4300impl DiffRenderState {
4301    const fn new(line_num_width: usize, context_lines: usize) -> Self {
4302        Self {
4303            output: String::new(),
4304            old_line_num: 1,
4305            new_line_num: 1,
4306            last_was_change: false,
4307            first_changed_line: None,
4308            line_num_width,
4309            context_lines,
4310        }
4311    }
4312
4313    #[inline]
4314    fn ensure_line_break(&mut self) {
4315        if !self.output.is_empty() {
4316            self.output.push('\n');
4317        }
4318    }
4319
4320    const fn mark_first_change(&mut self) {
4321        if self.first_changed_line.is_none() {
4322            self.first_changed_line = Some(self.new_line_num);
4323        }
4324    }
4325
4326    fn push_added_line(&mut self, line: &str) {
4327        self.ensure_line_break();
4328        let _ = write!(
4329            self.output,
4330            "+{line_num:>width$} {line}",
4331            line_num = self.new_line_num,
4332            width = self.line_num_width
4333        );
4334        self.new_line_num = self.new_line_num.saturating_add(1);
4335    }
4336
4337    fn push_removed_line(&mut self, line: &str) {
4338        self.ensure_line_break();
4339        let _ = write!(
4340            self.output,
4341            "-{line_num:>width$} {line}",
4342            line_num = self.old_line_num,
4343            width = self.line_num_width
4344        );
4345        self.old_line_num = self.old_line_num.saturating_add(1);
4346    }
4347
4348    fn push_context_line(&mut self, line: &str) {
4349        self.ensure_line_break();
4350        let _ = write!(
4351            self.output,
4352            " {line_num:>width$} {line}",
4353            line_num = self.old_line_num,
4354            width = self.line_num_width
4355        );
4356        self.old_line_num = self.old_line_num.saturating_add(1);
4357        self.new_line_num = self.new_line_num.saturating_add(1);
4358    }
4359
4360    fn push_skip_marker(&mut self, skip: usize) {
4361        if skip == 0 {
4362            return;
4363        }
4364        self.ensure_line_break();
4365        let _ = write!(
4366            self.output,
4367            " {:>width$} ...",
4368            " ",
4369            width = self.line_num_width
4370        );
4371        self.old_line_num = self.old_line_num.saturating_add(skip);
4372        self.new_line_num = self.new_line_num.saturating_add(skip);
4373    }
4374}
4375
4376fn render_changed_part(tag: DiffTag, raw: &[&str], state: &mut DiffRenderState) {
4377    state.mark_first_change();
4378    for line in raw {
4379        match tag {
4380            DiffTag::Added => state.push_added_line(line),
4381            DiffTag::Removed => state.push_removed_line(line),
4382            DiffTag::Equal => {}
4383        }
4384    }
4385    state.last_was_change = true;
4386}
4387
4388fn render_equal_part(raw: &[&str], next_part_is_change: bool, state: &mut DiffRenderState) {
4389    if !(state.last_was_change || next_part_is_change) {
4390        let raw_len = raw.len();
4391        state.old_line_num = state.old_line_num.saturating_add(raw_len);
4392        state.new_line_num = state.new_line_num.saturating_add(raw_len);
4393        state.last_was_change = false;
4394        return;
4395    }
4396
4397    if state.last_was_change
4398        && next_part_is_change
4399        && raw.len() > state.context_lines.saturating_mul(2)
4400    {
4401        for line in raw.iter().take(state.context_lines) {
4402            state.push_context_line(line);
4403        }
4404
4405        let skip = raw.len().saturating_sub(state.context_lines * 2);
4406        state.push_skip_marker(skip);
4407
4408        for line in raw
4409            .iter()
4410            .skip(raw.len().saturating_sub(state.context_lines))
4411        {
4412            state.push_context_line(line);
4413        }
4414    } else {
4415        // Compute slice bounds directly instead of cloning Vecs
4416        let start = if state.last_was_change {
4417            0
4418        } else {
4419            raw.len().saturating_sub(state.context_lines)
4420        };
4421        let lines_after_start = raw.len().saturating_sub(start);
4422        let (end, skip_end) = if !next_part_is_change && lines_after_start > state.context_lines {
4423            (
4424                start + state.context_lines,
4425                lines_after_start - state.context_lines,
4426            )
4427        } else {
4428            (raw.len(), 0)
4429        };
4430
4431        state.push_skip_marker(start);
4432        for line in &raw[start..end] {
4433            state.push_context_line(line);
4434        }
4435        state.push_skip_marker(skip_end);
4436    }
4437
4438    state.last_was_change = false;
4439}
4440
4441fn generate_diff_string(old_content: &str, new_content: &str) -> (String, Option<usize>) {
4442    let parts = diff_parts(old_content, new_content);
4443    let mut state = DiffRenderState::new(diff_line_num_width(old_content, new_content), 4);
4444
4445    for (i, part) in parts.iter().enumerate() {
4446        let raw = split_diff_lines(&part.value);
4447        let next_part_is_change = parts.get(i + 1).is_some_and(|next| is_change_tag(next.tag));
4448
4449        match part.tag {
4450            DiffTag::Added | DiffTag::Removed => render_changed_part(part.tag, &raw, &mut state),
4451            DiffTag::Equal => render_equal_part(&raw, next_part_is_change, &mut state),
4452        }
4453    }
4454
4455    (state.output, state.first_changed_line)
4456}
4457
4458#[async_trait]
4459#[allow(clippy::unnecessary_literal_bound)]
4460impl Tool for EditTool {
4461    fn name(&self) -> &str {
4462        "edit"
4463    }
4464    fn label(&self) -> &str {
4465        "edit"
4466    }
4467    fn description(&self) -> &str {
4468        "Edit a file by replacing text. The oldText must match a unique region; matching is exact but normalizes line endings, Unicode spaces/quotes/dashes, and ignores trailing whitespace."
4469    }
4470
4471    fn parameters(&self) -> serde_json::Value {
4472        serde_json::json!({
4473            "type": "object",
4474            "properties": {
4475                "path": {
4476                    "type": "string",
4477                    "description": "Path to the file to edit (relative or absolute)"
4478                },
4479                "oldText": {
4480                    "type": "string",
4481                    "minLength": 1,
4482                    "description": "Text to find and replace (must match uniquely; matching normalizes line endings, Unicode spaces/quotes/dashes, and ignores trailing whitespace)"
4483                },
4484                "newText": {
4485                    "type": "string",
4486                    "description": "New text to replace the old text with"
4487                }
4488            },
4489            "required": ["path", "oldText", "newText"]
4490        })
4491    }
4492
4493    #[allow(clippy::too_many_lines)]
4494    async fn execute(
4495        &self,
4496        _tool_call_id: &str,
4497        input: serde_json::Value,
4498        _on_update: Option<Box<dyn Fn(ToolUpdate) + Send + Sync>>,
4499    ) -> Result<ToolOutput> {
4500        let input: EditInput =
4501            serde_json::from_value(input).map_err(|e| Error::validation(e.to_string()))?;
4502
4503        if input.new_text.len() > WRITE_TOOL_MAX_BYTES {
4504            return Err(Error::validation(format!(
4505                "New text size exceeds maximum allowed ({} > {} bytes)",
4506                input.new_text.len(),
4507                WRITE_TOOL_MAX_BYTES
4508            )));
4509        }
4510
4511        let absolute_path = resolve_read_path(&input.path, &self.cwd);
4512        let absolute_path = enforce_cwd_scope(&absolute_path, &self.cwd, "edit")?;
4513
4514        let meta = asupersync::fs::metadata(&absolute_path)
4515            .await
4516            .map_err(|err| {
4517                let message = match err.kind() {
4518                    std::io::ErrorKind::NotFound => format!("File not found: {}", input.path),
4519                    std::io::ErrorKind::PermissionDenied => {
4520                        format!("Permission denied: {}", input.path)
4521                    }
4522                    _ => format!("Failed to access file {}: {err}", input.path),
4523                };
4524                Error::tool("edit", message)
4525            })?;
4526
4527        if !meta.is_file() {
4528            return Err(Error::tool(
4529                "edit",
4530                format!("Path {} is not a regular file", absolute_path.display()),
4531            ));
4532        }
4533        if meta.len() > READ_TOOL_MAX_BYTES {
4534            return Err(Error::tool(
4535                "edit",
4536                format!(
4537                    "File is too large ({} bytes). Max allowed for editing is {} bytes.",
4538                    meta.len(),
4539                    READ_TOOL_MAX_BYTES
4540                ),
4541            ));
4542        }
4543
4544        if let Err(err) = asupersync::fs::OpenOptions::new()
4545            .read(true)
4546            .write(true)
4547            .open(&absolute_path)
4548            .await
4549        {
4550            let message = match err.kind() {
4551                std::io::ErrorKind::NotFound => format!("File not found: {}", input.path),
4552                std::io::ErrorKind::PermissionDenied => {
4553                    format!("Permission denied: {}", input.path)
4554                }
4555                _ => format!("Failed to open file for editing: {err}"),
4556            };
4557            return Err(Error::tool("edit", message));
4558        }
4559
4560        // Read bytes strictly up to the limit to prevent OOM if metadata failed or file grows.
4561        let file = asupersync::fs::File::open(&absolute_path)
4562            .await
4563            .map_err(|e| Error::tool("edit", format!("Failed to open file: {e}")))?;
4564        let mut raw = Vec::new();
4565        let mut limiter = file.take(READ_TOOL_MAX_BYTES.saturating_add(1));
4566        limiter
4567            .read_to_end(&mut raw)
4568            .await
4569            .map_err(|e| Error::tool("edit", format!("Failed to read file: {e}")))?;
4570
4571        if raw.len() > usize::try_from(READ_TOOL_MAX_BYTES).unwrap_or(usize::MAX) {
4572            return Err(Error::tool(
4573                "edit",
4574                format!("File is too large (> {READ_TOOL_MAX_BYTES} bytes)."),
4575            ));
4576        }
4577
4578        let raw_content = String::from_utf8(raw).map_err(|_| {
4579            Error::tool(
4580                "edit",
4581                "File contains invalid UTF-8 characters and cannot be safely edited as text."
4582                    .to_string(),
4583            )
4584        })?;
4585
4586        // Strip BOM before matching (LLM won't include invisible BOM in oldText).
4587        let (content_no_bom, had_bom) = strip_bom(&raw_content);
4588
4589        let original_ending = detect_line_ending(content_no_bom);
4590        let normalized_content = normalize_to_lf(content_no_bom);
4591        let content_for_matching =
4592            if content_no_bom.contains('\r') && !content_no_bom.contains('\n') {
4593                std::borrow::Cow::Owned(content_no_bom.replace('\r', "\n"))
4594            } else {
4595                std::borrow::Cow::Borrowed(content_no_bom)
4596            };
4597        let normalized_old_text = normalize_to_lf(&input.old_text);
4598
4599        if normalized_old_text.is_empty() {
4600            return Err(Error::tool(
4601                "edit",
4602                "The old text cannot be empty. To prepend text, include the first line's content in oldText and newText.".to_string(),
4603            ));
4604        }
4605        if build_normalized_content(&normalized_old_text).is_empty() {
4606            return Err(Error::tool(
4607                "edit",
4608                "The old text must include at least one non-whitespace character.".to_string(),
4609            ));
4610        }
4611
4612        // Try variants of old_text to handle Unicode normalization differences (NFC vs NFD)
4613        // and potential input normalization (clipboard, LLM output).
4614        //
4615        // Note: normalized_content is already LF-normalized but preserves Unicode form
4616        // (from String::from_utf8).
4617
4618        let mut variants = Vec::with_capacity(3);
4619        variants.push(normalized_old_text.clone());
4620
4621        let nfc = normalized_old_text.nfc().collect::<String>();
4622        if nfc != normalized_old_text {
4623            variants.push(nfc);
4624        }
4625
4626        let nfd = normalized_old_text.nfd().collect::<String>();
4627        if nfd != normalized_old_text {
4628            variants.push(nfd);
4629        }
4630
4631        // Pre-compute normalized versions once and reuse for both matching and
4632        // occurrence counting (avoids 2x redundant O(n) normalization).
4633        let precomputed_content = build_normalized_content(content_for_matching.as_ref());
4634
4635        let mut best_match: Option<(FuzzyMatchResult, String, String)> = None;
4636
4637        for variant in variants {
4638            let precomputed_variant = build_normalized_content(&variant);
4639            let match_result = fuzzy_find_text_with_normalized(
4640                content_for_matching.as_ref(),
4641                &variant,
4642                Some(precomputed_content.as_str()),
4643                Some(precomputed_variant.as_str()),
4644            );
4645
4646            if match_result.found {
4647                best_match = Some((match_result, precomputed_variant, variant));
4648                break;
4649            }
4650        }
4651
4652        let Some((match_result, normalized_old_text, matched_variant)) = best_match else {
4653            return Err(Error::tool(
4654                "edit",
4655                format!(
4656                    "Could not find the exact text in {}. The old text must match exactly including all whitespace and newlines.",
4657                    input.path
4658                ),
4659            ));
4660        };
4661
4662        // Count occurrences in the same matching mode to avoid false ambiguity
4663        // when normalized matching collapses distinct trailing whitespace.
4664        let occurrences = if match_result.exact_match {
4665            count_overlapping_occurrences(content_for_matching.as_ref(), &matched_variant)
4666        } else {
4667            count_overlapping_occurrences(&precomputed_content, &normalized_old_text)
4668        };
4669
4670        if occurrences > 1 {
4671            return Err(Error::tool(
4672                "edit",
4673                format!(
4674                    "Found {occurrences} occurrences of the text in {}. The text must be unique. Please provide more context to make it unique.",
4675                    input.path
4676                ),
4677            ));
4678        }
4679
4680        // Perform replacement in the original coordinate space to preserve
4681        // line endings and unmatched content exactly.
4682        let idx = match_result.index;
4683        let match_len = match_result.match_length;
4684
4685        // Adapt new_text to match the file's line endings.
4686        // normalize_to_lf ensures we start from a known state (LF), then
4687        // restore_line_endings converts LFs to the target ending (e.g. CRLF).
4688        let adapted_new_text =
4689            restore_line_endings(&normalize_to_lf(&input.new_text), original_ending);
4690
4691        let new_len = content_no_bom.len() - match_len + adapted_new_text.len();
4692        let mut new_content = String::with_capacity(new_len);
4693        new_content.push_str(&content_no_bom[..idx]);
4694        new_content.push_str(&adapted_new_text);
4695        new_content.push_str(&content_no_bom[idx + match_len..]);
4696
4697        if content_no_bom.eq(&new_content) {
4698            return Err(Error::tool(
4699                "edit",
4700                format!(
4701                    "No changes made to {}. The replacement produced identical content. This might indicate an issue with special characters or the text not existing as expected.",
4702                    input.path
4703                ),
4704            ));
4705        }
4706
4707        let new_content_for_diff = normalize_to_lf(&new_content);
4708
4709        // Re-add BOM if present.
4710        let mut final_content = new_content;
4711        if had_bom {
4712            final_content = format!("\u{FEFF}{final_content}");
4713        }
4714
4715        // Atomic write (safe improvement vs legacy, behavior-equivalent).
4716        let absolute_path_clone = absolute_path.clone();
4717        let final_content_bytes = final_content.into_bytes();
4718        asupersync::runtime::spawn_blocking_io(move || {
4719            // Capture original permissions before the file is replaced.
4720            let original_perms = std::fs::metadata(&absolute_path_clone)
4721                .ok()
4722                .map(|m| m.permissions());
4723            let parent = absolute_path_clone
4724                .parent()
4725                .unwrap_or_else(|| Path::new("."));
4726            let mut temp_file = tempfile::NamedTempFile::new_in(parent)?;
4727
4728            temp_file.as_file_mut().write_all(&final_content_bytes)?;
4729            tolerate_fsync_refusal(
4730                temp_file.as_file_mut().sync_all(),
4731                "temp file",
4732                &absolute_path_clone,
4733            )?;
4734
4735            // Restore original file permissions (tempfile defaults to 0o600) before persisting.
4736            if let Some(perms) = original_perms {
4737                let _ = temp_file.as_file().set_permissions(perms);
4738            } else {
4739                // Default to 0644 (rw-r--r--) instead of tempfile's 0600 if we couldn't read original perms.
4740                #[cfg(unix)]
4741                {
4742                    use std::os::unix::fs::PermissionsExt;
4743                    let _ = temp_file
4744                        .as_file()
4745                        .set_permissions(std::fs::Permissions::from_mode(0o644));
4746                }
4747            }
4748
4749            temp_file
4750                .persist(&absolute_path_clone)
4751                .map_err(|e| e.error)?;
4752            sync_parent_dir(&absolute_path_clone)?;
4753            Ok(())
4754        })
4755        .await
4756        .map_err(|e| Error::tool("edit", format!("Failed to write file: {e}")))?;
4757
4758        let (diff, first_changed_line) =
4759            generate_diff_string(&normalized_content, &new_content_for_diff);
4760        let mut details = serde_json::Map::new();
4761        details.insert("diff".to_string(), serde_json::Value::String(diff));
4762        if let Some(line) = first_changed_line {
4763            details.insert(
4764                "firstChangedLine".to_string(),
4765                serde_json::Value::Number(serde_json::Number::from(line)),
4766            );
4767        }
4768
4769        Ok(ToolOutput {
4770            content: vec![ContentBlock::Text(TextContent::new(format!(
4771                "Successfully replaced text in {}.",
4772                input.path
4773            )))],
4774            details: Some(serde_json::Value::Object(details)),
4775            is_error: false,
4776        })
4777    }
4778}
4779
4780// ============================================================================
4781// Write Tool
4782// ============================================================================
4783
4784/// Input parameters for the write tool.
4785#[derive(Debug, Deserialize)]
4786#[serde(rename_all = "camelCase")]
4787struct WriteInput {
4788    path: String,
4789    content: String,
4790}
4791
4792pub struct WriteTool {
4793    cwd: PathBuf,
4794}
4795
4796impl WriteTool {
4797    pub fn new(cwd: &Path) -> Self {
4798        Self {
4799            cwd: cwd.to_path_buf(),
4800        }
4801    }
4802}
4803
4804#[async_trait]
4805#[allow(clippy::unnecessary_literal_bound)]
4806impl Tool for WriteTool {
4807    fn name(&self) -> &str {
4808        "write"
4809    }
4810    fn label(&self) -> &str {
4811        "write"
4812    }
4813    fn description(&self) -> &str {
4814        "Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories."
4815    }
4816
4817    fn parameters(&self) -> serde_json::Value {
4818        serde_json::json!({
4819            "type": "object",
4820            "properties": {
4821                "path": {
4822                    "type": "string",
4823                    "description": "Path to the file to write (relative or absolute)"
4824                },
4825                "content": {
4826                    "type": "string",
4827                    "description": "Content to write to the file"
4828                }
4829            },
4830            "required": ["path", "content"]
4831        })
4832    }
4833
4834    #[allow(clippy::too_many_lines)]
4835    async fn execute(
4836        &self,
4837        _tool_call_id: &str,
4838        input: serde_json::Value,
4839        _on_update: Option<Box<dyn Fn(ToolUpdate) + Send + Sync>>,
4840    ) -> Result<ToolOutput> {
4841        let input: WriteInput =
4842            serde_json::from_value(input).map_err(|e| Error::validation(e.to_string()))?;
4843
4844        if input.content.len() > WRITE_TOOL_MAX_BYTES {
4845            return Err(Error::validation(format!(
4846                "Content size exceeds maximum allowed ({} > {} bytes)",
4847                input.content.len(),
4848                WRITE_TOOL_MAX_BYTES
4849            )));
4850        }
4851
4852        let path = resolve_path(&input.path, &self.cwd);
4853        let path = enforce_cwd_scope(&path, &self.cwd, "write")?;
4854
4855        if let Ok(meta) = asupersync::fs::metadata(&path).await {
4856            if !meta.is_file() {
4857                return Err(Error::tool(
4858                    "write",
4859                    format!("Path {} is not a regular file", path.display()),
4860                ));
4861            }
4862            if let Err(err) = asupersync::fs::OpenOptions::new()
4863                .write(true)
4864                .open(&path)
4865                .await
4866            {
4867                let message = match err.kind() {
4868                    std::io::ErrorKind::PermissionDenied => {
4869                        format!("Permission denied: {}", input.path)
4870                    }
4871                    _ => format!("Failed to open file for writing: {err}"),
4872                };
4873                return Err(Error::tool("write", message));
4874            }
4875        }
4876
4877        // Create parent directories if needed
4878        if let Some(parent) = path.parent() {
4879            asupersync::fs::create_dir_all(parent)
4880                .await
4881                .map_err(|e| Error::tool("write", format!("Failed to create directories: {e}")))?;
4882        }
4883
4884        // Parity with legacy pi-mono: report JS string length (UTF-16 code units) as "bytes".
4885        let bytes_written = input.content.encode_utf16().count();
4886
4887        // Write atomically using tempfile on a blocking thread
4888        let path_clone = path.clone();
4889        let content_bytes = input.content.into_bytes();
4890        asupersync::runtime::spawn_blocking_io(move || {
4891            // Capture original permissions before the file is replaced (new files get None).
4892            let original_perms = std::fs::metadata(&path_clone).ok().map(|m| m.permissions());
4893            let parent = path_clone.parent().unwrap_or_else(|| Path::new("."));
4894            let mut temp_file = tempfile::NamedTempFile::new_in(parent)?;
4895
4896            temp_file.as_file_mut().write_all(&content_bytes)?;
4897            tolerate_fsync_refusal(temp_file.as_file_mut().sync_all(), "temp file", &path_clone)?;
4898
4899            // Restore original file permissions (tempfile defaults to 0o600) before persisting.
4900            if let Some(perms) = original_perms {
4901                let _ = temp_file.as_file().set_permissions(perms);
4902            } else {
4903                // New file: default to 0644 (rw-r--r--) instead of tempfile's 0600.
4904                #[cfg(unix)]
4905                {
4906                    use std::os::unix::fs::PermissionsExt;
4907                    let _ = temp_file
4908                        .as_file()
4909                        .set_permissions(std::fs::Permissions::from_mode(0o644));
4910                }
4911            }
4912
4913            // Persist (atomic rename)
4914            temp_file.persist(&path_clone).map_err(|e| e.error)?;
4915            sync_parent_dir(&path_clone)?;
4916            Ok(())
4917        })
4918        .await
4919        .map_err(|e| Error::tool("write", format!("Failed to write file: {e}")))?;
4920
4921        Ok(ToolOutput {
4922            content: vec![ContentBlock::Text(TextContent::new(format!(
4923                "Successfully wrote {} bytes to {}",
4924                bytes_written, input.path
4925            )))],
4926            details: None,
4927            is_error: false,
4928        })
4929    }
4930}
4931
4932// ============================================================================
4933// Grep Tool
4934// ============================================================================
4935
4936/// Input parameters for the grep tool.
4937#[derive(Debug, Deserialize)]
4938#[serde(rename_all = "camelCase")]
4939struct GrepInput {
4940    pattern: String,
4941    path: Option<String>,
4942    glob: Option<String>,
4943    ignore_case: Option<bool>,
4944    literal: Option<bool>,
4945    context: Option<usize>,
4946    limit: Option<usize>,
4947    #[serde(default)]
4948    hashline: bool,
4949}
4950
4951pub struct GrepTool {
4952    cwd: PathBuf,
4953    artifact_root: Option<PathBuf>,
4954}
4955
4956impl GrepTool {
4957    pub fn new(cwd: &Path) -> Self {
4958        Self {
4959            cwd: cwd.to_path_buf(),
4960            artifact_root: None,
4961        }
4962    }
4963
4964    #[cfg(test)]
4965    fn with_artifact_root(cwd: &Path, artifact_root: &Path) -> Self {
4966        Self {
4967            cwd: cwd.to_path_buf(),
4968            artifact_root: Some(artifact_root.to_path_buf()),
4969        }
4970    }
4971}
4972
4973/// Result of truncating a single grep output line.
4974#[derive(Debug, Clone, PartialEq, Eq)]
4975struct TruncateLineResult {
4976    text: String,
4977    was_truncated: bool,
4978}
4979
4980/// Truncate a single line to max characters, adding a marker suffix.
4981///
4982/// Matches pi-mono behavior: `${line.slice(0, maxChars)}... [truncated]`.
4983fn truncate_line(line: &str, max_chars: usize) -> TruncateLineResult {
4984    let mut chars = line.chars();
4985    let prefix: String = chars.by_ref().take(max_chars).collect();
4986    if chars.next().is_none() {
4987        return TruncateLineResult {
4988            text: line.to_string(),
4989            was_truncated: false,
4990        };
4991    }
4992
4993    TruncateLineResult {
4994        text: format!("{prefix}... [truncated]"),
4995        was_truncated: true,
4996    }
4997}
4998
4999fn process_rg_json_match_line(
5000    line_res: std::io::Result<String>,
5001    matches: &mut Vec<(PathBuf, usize)>,
5002    match_count: &mut usize,
5003    match_limit_reached: &mut bool,
5004    scan_limit: usize,
5005) {
5006    if *match_limit_reached {
5007        return;
5008    }
5009
5010    let line = match line_res {
5011        Ok(l) => l,
5012        Err(e) => {
5013            tracing::debug!("Skipping ripgrep output line due to read error: {e}");
5014            return;
5015        }
5016    };
5017    if line.trim().is_empty() {
5018        return;
5019    }
5020
5021    let Ok(event) = serde_json::from_str::<serde_json::Value>(&line) else {
5022        return;
5023    };
5024
5025    if event.get("type").and_then(serde_json::Value::as_str) != Some("match") {
5026        return;
5027    }
5028
5029    let file_path = event
5030        .pointer("/data/path/text")
5031        .and_then(serde_json::Value::as_str)
5032        .map(PathBuf::from);
5033    let line_number = event
5034        .pointer("/data/line_number")
5035        .and_then(serde_json::Value::as_u64)
5036        .and_then(|n| usize::try_from(n).ok());
5037
5038    if let (Some(fp), Some(ln)) = (file_path, line_number) {
5039        matches.push((fp, ln));
5040        *match_count += 1;
5041        if *match_count >= scan_limit {
5042            *match_limit_reached = true;
5043        }
5044    }
5045}
5046
5047fn drain_rg_stdout(
5048    stdout_rx: &std::sync::mpsc::Receiver<std::io::Result<String>>,
5049    matches: &mut Vec<(PathBuf, usize)>,
5050    match_count: &mut usize,
5051    match_limit_reached: &mut bool,
5052    scan_limit: usize,
5053) {
5054    while let Ok(line_res) = stdout_rx.try_recv() {
5055        process_rg_json_match_line(
5056            line_res,
5057            matches,
5058            match_count,
5059            match_limit_reached,
5060            scan_limit,
5061        );
5062        if *match_limit_reached {
5063            break;
5064        }
5065    }
5066}
5067
5068fn drain_rg_stderr(
5069    stderr_rx: &std::sync::mpsc::Receiver<std::result::Result<Vec<u8>, String>>,
5070    stderr_bytes: &mut Vec<u8>,
5071) -> Result<()> {
5072    while let Ok(chunk_result) = stderr_rx.try_recv() {
5073        let chunk = chunk_result
5074            .map_err(|err| Error::tool("grep", format!("Failed to read stderr: {err}")))?;
5075        stderr_bytes.extend_from_slice(&chunk);
5076    }
5077    Ok(())
5078}
5079
5080#[async_trait]
5081#[allow(clippy::unnecessary_literal_bound)]
5082impl Tool for GrepTool {
5083    fn name(&self) -> &str {
5084        "grep"
5085    }
5086    fn label(&self) -> &str {
5087        "grep"
5088    }
5089    fn description(&self) -> &str {
5090        "Search file contents for a pattern. Returns matching lines with file paths and line numbers. Respects .gitignore. Output is truncated to 100 matches or 1MB (whichever is hit first). Long lines are truncated to 500 chars. Use hashline=true to get N#AB content-hash tags for use with hashline_edit."
5091    }
5092
5093    fn parameters(&self) -> serde_json::Value {
5094        serde_json::json!({
5095            "type": "object",
5096            "properties": {
5097                "pattern": {
5098                    "type": "string",
5099                    "description": "Search pattern (regex or literal string)"
5100                },
5101                "path": {
5102                    "type": "string",
5103                    "description": "Directory or file to search (default: current directory)"
5104                },
5105                "glob": {
5106                    "type": "string",
5107                    "description": "Filter files by glob pattern, e.g. '*.ts' or '**/*.spec.ts'"
5108                },
5109                "ignoreCase": {
5110                    "type": "boolean",
5111                    "description": "Case-insensitive search (default: false)"
5112                },
5113                "literal": {
5114                    "type": "boolean",
5115                    "description": "Treat pattern as literal string instead of regex (default: false)"
5116                },
5117                "context": {
5118                    "type": "integer",
5119                    "description": "Number of lines to show before and after each match (default: 0)"
5120                },
5121                "limit": {
5122                    "type": "integer",
5123                    "description": "Maximum number of matches to return (default: 100)"
5124                },
5125                "hashline": {
5126                    "type": "boolean",
5127                    "description": "When true, output each line as N#AB:content where N is the line number and AB is a content hash. Use with hashline_edit tool for precise edits."
5128                }
5129            },
5130            "required": ["pattern"]
5131        })
5132    }
5133
5134    fn effects(&self) -> ToolEffects {
5135        ToolEffects::read()
5136    }
5137
5138    #[allow(clippy::too_many_lines)]
5139    async fn execute(
5140        &self,
5141        tool_call_id: &str,
5142        input: serde_json::Value,
5143        _on_update: Option<Box<dyn Fn(ToolUpdate) + Send + Sync>>,
5144    ) -> Result<ToolOutput> {
5145        let input_value = input.clone();
5146        let input: GrepInput =
5147            serde_json::from_value(input).map_err(|e| Error::validation(e.to_string()))?;
5148
5149        if matches!(input.limit, Some(0)) {
5150            return Err(Error::validation(
5151                "`limit` must be greater than 0".to_string(),
5152            ));
5153        }
5154
5155        if !rg_available() {
5156            return Err(Error::tool(
5157                "grep",
5158                "ripgrep (rg) is not available (please install ripgrep)".to_string(),
5159            ));
5160        }
5161
5162        let search_dir = input.path.as_deref().unwrap_or(".");
5163        let search_path = resolve_read_path(search_dir, &self.cwd);
5164        let search_path = enforce_cwd_scope(&search_path, &self.cwd, "grep")?;
5165
5166        let is_directory = asupersync::fs::metadata(&search_path)
5167            .await
5168            .map_err(|e| {
5169                Error::tool(
5170                    "grep",
5171                    format!("Cannot access path {}: {e}", search_path.display()),
5172                )
5173            })?
5174            .is_dir();
5175
5176        let context_value = input.context.unwrap_or(0);
5177        let effective_limit = input.limit.unwrap_or(DEFAULT_GREP_LIMIT).max(1);
5178        // Overfetch one match so limit notices only appear after confirmed overflow.
5179        let scan_limit = effective_limit.saturating_add(1);
5180        let cache_key = tool_cache_key("grep", &self.cwd, &input_value);
5181        let cache_mode = if is_directory {
5182            ToolCacheFingerprintMode::DirectoryRecursive
5183        } else {
5184            ToolCacheFingerprintMode::FileContent
5185        };
5186        let cache_deps = cache_dependency_for_path(&search_path, cache_mode);
5187        if let Some(output) = cached_tool_output(&cache_key, cache_deps.as_deref()) {
5188            return Ok(output);
5189        }
5190
5191        let mut args: Vec<String> = vec![
5192            "--json".to_string(),
5193            "--line-number".to_string(),
5194            "--color=never".to_string(),
5195            "--hidden".to_string(),
5196            // Prevent massive JSON lines from minified files causing OOM
5197            "--max-columns=10000".to_string(),
5198        ];
5199
5200        if input.ignore_case.unwrap_or(false) {
5201            args.push("--ignore-case".to_string());
5202        }
5203        if input.literal.unwrap_or(false) {
5204            args.push("--fixed-strings".to_string());
5205        }
5206        if let Some(glob) = &input.glob {
5207            args.push("--glob".to_string());
5208            args.push(glob.clone());
5209        }
5210
5211        // Mirror find-tool behavior: explicitly pass root/nested .gitignore files
5212        // so ignore rules apply consistently even outside a git worktree.
5213        let ignore_root = if is_directory {
5214            search_path.clone()
5215        } else {
5216            search_path
5217                .parent()
5218                .unwrap_or_else(|| Path::new("."))
5219                .to_path_buf()
5220        };
5221        // NOTE: We rely on rg's native .gitignore discovery. We only explicitly pass
5222        // the root .gitignore if it exists, to ensure it's respected even if the
5223        // search path logic might otherwise miss it (e.g. searching a subdir).
5224        // We do NOT perform a blocking `glob("**/.gitignore")` here, as that stalls
5225        // the async runtime on large repos.
5226        let workspace_gitignore = self.cwd.join(".gitignore");
5227        if workspace_gitignore.exists() {
5228            args.push("--ignore-file".to_string());
5229            args.push(workspace_gitignore.display().to_string());
5230        }
5231        let root_gitignore = ignore_root.join(".gitignore");
5232        if root_gitignore != workspace_gitignore && root_gitignore.exists() {
5233            args.push("--ignore-file".to_string());
5234            args.push(root_gitignore.display().to_string());
5235        }
5236
5237        args.push("--".to_string());
5238        args.push(input.pattern.clone());
5239        args.push(search_path.display().to_string());
5240
5241        let rg_cmd = find_rg_binary().ok_or_else(|| {
5242            Error::tool(
5243                "grep",
5244                "rg is not available (please install ripgrep or rg)".to_string(),
5245            )
5246        })?;
5247
5248        let mut child = command_with_default_sigpipe(rg_cmd)
5249            .map_err(|e| Error::tool("grep", format!("Failed to prepare ripgrep: {e}")))?
5250            .args(args)
5251            .stdout(Stdio::piped())
5252            .stderr(Stdio::piped())
5253            .spawn()
5254            .map_err(|e| Error::tool("grep", format!("Failed to run ripgrep: {e}")))?;
5255
5256        let stdout = child
5257            .stdout
5258            .take()
5259            .ok_or_else(|| Error::tool("grep", "Missing stdout".to_string()))?;
5260        let stderr = child
5261            .stderr
5262            .take()
5263            .ok_or_else(|| Error::tool("grep", "Missing stderr".to_string()))?;
5264
5265        let mut guard = ProcessGuard::new(child, ProcessCleanupMode::ChildOnly);
5266
5267        let (stdout_tx, stdout_rx) = std::sync::mpsc::sync_channel(1024);
5268        let (stderr_tx, stderr_rx) =
5269            std::sync::mpsc::sync_channel::<std::result::Result<Vec<u8>, String>>(1024);
5270
5271        let stdout_thread = std::thread::spawn(move || {
5272            let reader = std::io::BufReader::new(stdout);
5273            for line in reader.lines() {
5274                if stdout_tx.send(line).is_err() {
5275                    break;
5276                }
5277            }
5278        });
5279
5280        let stderr_thread = std::thread::spawn(move || {
5281            let reader = std::io::BufReader::new(stderr);
5282            let _ = stderr_tx.send(read_to_end_capped_and_drain(reader, READ_TOOL_MAX_BYTES));
5283        });
5284
5285        let mut matches: Vec<(PathBuf, usize)> = Vec::new();
5286        let mut match_count: usize = 0;
5287        let mut match_scan_limit_reached = false;
5288        let mut stderr_bytes = Vec::new();
5289
5290        let tick = Duration::from_millis(10);
5291        let mut cx_cancelled = false;
5292
5293        let exit_status = loop {
5294            let agent_cx = AgentCx::for_current_or_request();
5295            let cx = agent_cx.cx();
5296            if cx.checkpoint().is_err() {
5297                cx_cancelled = true;
5298                break None;
5299            }
5300
5301            drain_rg_stdout(
5302                &stdout_rx,
5303                &mut matches,
5304                &mut match_count,
5305                &mut match_scan_limit_reached,
5306                scan_limit,
5307            );
5308            drain_rg_stderr(&stderr_rx, &mut stderr_bytes)?;
5309
5310            if match_scan_limit_reached {
5311                break None;
5312            }
5313
5314            match guard.try_wait_child() {
5315                Ok(Some(status)) => break Some(status),
5316                Ok(None) => {
5317                    let now = cx.timer_driver().map_or_else(wall_now, |timer| timer.now());
5318                    sleep(now, tick).await;
5319                }
5320                Err(e) => return Err(Error::tool("grep", e.to_string())),
5321            }
5322        };
5323
5324        drain_rg_stdout(
5325            &stdout_rx,
5326            &mut matches,
5327            &mut match_count,
5328            &mut match_scan_limit_reached,
5329            scan_limit,
5330        );
5331
5332        let code = if match_scan_limit_reached || cx_cancelled {
5333            // Avoid buffering unbounded stdout/stderr once we've hit the match limit.
5334            // `kill()` terminates the process, and we reap it in a background thread
5335            // so the stdout reader threads can exit promptly without blocking this task.
5336            let _ = guard.kill();
5337            // Drop any buffered stdout/stderr lines that were queued before termination.
5338            while stdout_rx.try_recv().is_ok() {}
5339            while stderr_rx.try_recv().is_ok() {}
5340            0
5341        } else {
5342            let status = exit_status.expect("rg exit status");
5343            status.code().unwrap_or(0)
5344        };
5345
5346        // Keep draining while waiting for reader threads to finish; otherwise a
5347        // bounded channel can fill and block the sender thread, causing join()
5348        // to hang after ripgrep has already exited.
5349        while !stdout_thread.is_finished() || !stderr_thread.is_finished() {
5350            if match_scan_limit_reached || cx_cancelled {
5351                while stdout_rx.try_recv().is_ok() {}
5352            } else {
5353                drain_rg_stdout(
5354                    &stdout_rx,
5355                    &mut matches,
5356                    &mut match_count,
5357                    &mut match_scan_limit_reached,
5358                    scan_limit,
5359                );
5360            }
5361            drain_rg_stderr(&stderr_rx, &mut stderr_bytes)?;
5362            sleep(wall_now(), Duration::from_millis(1)).await;
5363        }
5364
5365        if cx_cancelled {
5366            return Err(Error::tool("grep", "Command cancelled"));
5367        }
5368
5369        // Ensure stdout/stderr reader threads have fully drained the pipes before
5370        // we decide whether matches were found. Without this, fast ripgrep runs can
5371        // exit before the reader thread has delivered JSON match lines, causing
5372        // false "No matches found" results.
5373        stdout_thread
5374            .join()
5375            .map_err(|_| Error::tool("grep", "ripgrep stdout reader thread panicked"))?;
5376        stderr_thread
5377            .join()
5378            .map_err(|_| Error::tool("grep", "ripgrep stderr reader thread panicked"))?;
5379
5380        // Drain any remaining stdout/stderr produced after the last poll.
5381        if match_scan_limit_reached {
5382            while stdout_rx.try_recv().is_ok() {}
5383        } else {
5384            drain_rg_stdout(
5385                &stdout_rx,
5386                &mut matches,
5387                &mut match_count,
5388                &mut match_scan_limit_reached,
5389                scan_limit,
5390            );
5391        }
5392        drain_rg_stderr(&stderr_rx, &mut stderr_bytes)?;
5393
5394        let mut stderr_text = String::from_utf8_lossy(&stderr_bytes).trim().to_string();
5395        if stderr_bytes.len() as u64 > READ_TOOL_MAX_BYTES {
5396            stderr_text.push_str("\n... [stderr truncated] ...");
5397        }
5398        if !match_scan_limit_reached && code != 0 && code != 1 {
5399            let msg = if stderr_text.is_empty() {
5400                format!("ripgrep exited with code {code}")
5401            } else {
5402                stderr_text
5403            };
5404            return Err(Error::tool("grep", msg));
5405        }
5406
5407        let match_limit_reached = match_count > effective_limit;
5408        if match_limit_reached {
5409            matches.truncate(effective_limit);
5410            match_count = effective_limit;
5411        }
5412
5413        if match_count == 0 {
5414            let output = ToolOutput {
5415                content: vec![ContentBlock::Text(TextContent::new("No matches found"))],
5416                details: None,
5417                is_error: false,
5418            };
5419            cache_tool_output(
5420                cache_key,
5421                stable_cache_dependency_for_path(&search_path, cache_mode, cache_deps.as_deref()),
5422                &output,
5423            );
5424            return Ok(output);
5425        }
5426
5427        let mut file_cache: HashMap<PathBuf, Vec<String>> = HashMap::new();
5428        let mut output_builder = HeadTruncatingLineWriter::new(DEFAULT_MAX_BYTES);
5429        let mut artifact_source = String::new();
5430        let mut lines_truncated = false;
5431
5432        // Group matches by file to merge overlapping context windows
5433        let mut file_order: Vec<PathBuf> = Vec::new();
5434        let mut matches_by_file: HashMap<PathBuf, Vec<usize>> = HashMap::new();
5435        for (file_path, line_number) in &matches {
5436            if !matches_by_file.contains_key(file_path) {
5437                file_order.push(file_path.clone());
5438            }
5439            matches_by_file
5440                .entry(file_path.clone())
5441                .or_default()
5442                .push(*line_number);
5443        }
5444
5445        for file_path in file_order {
5446            let Some(mut match_lines) = matches_by_file.remove(&file_path) else {
5447                continue;
5448            };
5449            let relative_path = format_grep_path(&file_path, &self.cwd);
5450            let lines = get_file_lines_async(&file_path, &mut file_cache).await;
5451
5452            if lines.is_empty() {
5453                if let Some(first_match) = match_lines.first() {
5454                    let line = format!(
5455                        "{relative_path}:{first_match}: (unable to read file or too large)"
5456                    );
5457                    output_builder.push_line(&line);
5458                    append_artifact_source_line(&mut artifact_source, &line);
5459                }
5460                continue;
5461            }
5462
5463            match_lines.sort_unstable();
5464            match_lines.dedup();
5465
5466            let mut blocks: Vec<(usize, usize)> = Vec::new();
5467            for &line_number in &match_lines {
5468                let start = if context_value > 0 {
5469                    line_number.saturating_sub(context_value).max(1)
5470                } else {
5471                    line_number
5472                };
5473                let end = if context_value > 0 {
5474                    line_number.saturating_add(context_value).min(lines.len())
5475                } else {
5476                    line_number
5477                };
5478
5479                if let Some(last_block) = blocks.last_mut() {
5480                    if start <= last_block.1.saturating_add(1) {
5481                        last_block.1 = last_block.1.max(end);
5482                        continue;
5483                    }
5484                }
5485                blocks.push((start, end));
5486            }
5487
5488            for (i, (start, end)) in blocks.into_iter().enumerate() {
5489                if i > 0 {
5490                    output_builder.push_line("--");
5491                    append_artifact_source_line(&mut artifact_source, "--");
5492                }
5493                for current in start..=end {
5494                    let line_text = lines.get(current - 1).map_or("", String::as_str);
5495                    let sanitized = line_text.replace('\r', "");
5496                    let truncated = truncate_line(&sanitized, GREP_MAX_LINE_LENGTH);
5497                    if truncated.was_truncated {
5498                        lines_truncated = true;
5499                    }
5500
5501                    if input.hashline {
5502                        let line_idx = current - 1; // 0-indexed for hashline
5503                        let tag = format_hashline_tag(line_idx, &sanitized);
5504                        let line = if match_lines.binary_search(&current).is_ok() {
5505                            format!("{relative_path}:{tag}: {}", truncated.text)
5506                        } else {
5507                            format!("{relative_path}-{tag}- {}", truncated.text)
5508                        };
5509                        output_builder.push_line(&line);
5510                        append_artifact_source_line(&mut artifact_source, &line);
5511                    } else if match_lines.binary_search(&current).is_ok() {
5512                        let line = format!("{relative_path}:{current}: {}", truncated.text);
5513                        output_builder.push_line(&line);
5514                        append_artifact_source_line(&mut artifact_source, &line);
5515                    } else {
5516                        let line = format!("{relative_path}-{current}- {}", truncated.text);
5517                        output_builder.push_line(&line);
5518                        append_artifact_source_line(&mut artifact_source, &line);
5519                    }
5520                }
5521            }
5522        }
5523
5524        // Apply byte truncation while writing, avoiding a second joined copy.
5525        let mut truncation = output_builder.finish();
5526
5527        let mut output = std::mem::take(&mut truncation.content);
5528        let mut notices: Vec<String> = Vec::new();
5529        let mut details_map = serde_json::Map::new();
5530
5531        if match_limit_reached {
5532            notices.push(format!(
5533                "{effective_limit} matches limit reached. Use limit={} for more, or refine pattern",
5534                effective_limit * 2
5535            ));
5536            details_map.insert(
5537                "matchLimitReached".to_string(),
5538                serde_json::Value::Number(serde_json::Number::from(effective_limit)),
5539            );
5540        }
5541
5542        if truncation.truncated {
5543            notices.push(format!("{} limit reached", format_size(DEFAULT_MAX_BYTES)));
5544            details_map.insert("truncation".to_string(), serde_json::to_value(truncation)?);
5545        }
5546
5547        if lines_truncated {
5548            notices.push(format!(
5549                "Some lines truncated to {GREP_MAX_LINE_LENGTH} chars. Use read tool to see full lines"
5550            ));
5551            details_map.insert("linesTruncated".to_string(), serde_json::Value::Bool(true));
5552        }
5553
5554        if !notices.is_empty() {
5555            let _ = write!(output, "\n\n[{}]", notices.join(". "));
5556        }
5557
5558        let mut details = if details_map.is_empty() {
5559            None
5560        } else {
5561            Some(serde_json::Value::Object(details_map))
5562        };
5563
5564        attach_text_artifact_if_needed_with_root(
5565            self.artifact_root.as_deref(),
5566            &mut output,
5567            &mut details,
5568            "grep",
5569            tool_call_id,
5570            "searchResults",
5571            &artifact_source,
5572        );
5573
5574        let output = ToolOutput {
5575            content: vec![ContentBlock::Text(TextContent::new(output))],
5576            details,
5577            is_error: false,
5578        };
5579        cache_tool_output(
5580            cache_key,
5581            stable_cache_dependency_for_path(&search_path, cache_mode, cache_deps.as_deref()),
5582            &output,
5583        );
5584        Ok(output)
5585    }
5586}
5587
5588// ============================================================================
5589// Find Tool
5590// ============================================================================
5591
5592/// Input parameters for the find tool.
5593#[derive(Debug, Deserialize)]
5594#[serde(rename_all = "camelCase")]
5595struct FindInput {
5596    pattern: String,
5597    path: Option<String>,
5598    limit: Option<usize>,
5599}
5600
5601#[derive(Debug)]
5602struct FindEntry {
5603    rel: String,
5604    modified: Option<SystemTime>,
5605}
5606
5607pub struct FindTool {
5608    cwd: PathBuf,
5609    artifact_root: Option<PathBuf>,
5610}
5611
5612impl FindTool {
5613    pub fn new(cwd: &Path) -> Self {
5614        Self {
5615            cwd: cwd.to_path_buf(),
5616            artifact_root: None,
5617        }
5618    }
5619}
5620
5621#[async_trait]
5622#[allow(clippy::unnecessary_literal_bound)]
5623impl Tool for FindTool {
5624    fn name(&self) -> &str {
5625        "find"
5626    }
5627    fn label(&self) -> &str {
5628        "find"
5629    }
5630    fn description(&self) -> &str {
5631        "Search for files by glob pattern. Returns matching file paths relative to the search directory. Sorted by modification time (newest first). Respects .gitignore. Output is truncated to 1000 results or 1MB (whichever is hit first)."
5632    }
5633
5634    fn parameters(&self) -> serde_json::Value {
5635        serde_json::json!({
5636            "type": "object",
5637            "properties": {
5638                "pattern": {
5639                    "type": "string",
5640                    "description": "Glob pattern to match files, e.g. '*.ts', '**/*.json', or 'src/**/*.spec.ts'"
5641                },
5642                "path": {
5643                    "type": "string",
5644                    "description": "Directory to search in (default: current directory)"
5645                },
5646                "limit": {
5647                    "type": "integer",
5648                    "description": "Maximum number of results (default: 1000)"
5649                }
5650            },
5651            "required": ["pattern"]
5652        })
5653    }
5654
5655    fn effects(&self) -> ToolEffects {
5656        ToolEffects::read()
5657    }
5658
5659    #[allow(clippy::too_many_lines)]
5660    async fn execute(
5661        &self,
5662        tool_call_id: &str,
5663        input: serde_json::Value,
5664        _on_update: Option<Box<dyn Fn(ToolUpdate) + Send + Sync>>,
5665    ) -> Result<ToolOutput> {
5666        let input_value = input.clone();
5667        let input: FindInput =
5668            serde_json::from_value(input).map_err(|e| Error::validation(e.to_string()))?;
5669
5670        if matches!(input.limit, Some(0)) {
5671            return Err(Error::validation(
5672                "`limit` must be greater than 0".to_string(),
5673            ));
5674        }
5675
5676        let search_dir = input.path.as_deref().unwrap_or(".");
5677        let search_path = resolve_read_path(search_dir, &self.cwd);
5678        let search_path = enforce_cwd_scope(&search_path, &self.cwd, "find")?;
5679        let search_path = strip_unc_prefix(search_path);
5680        let effective_limit = input.limit.unwrap_or(DEFAULT_FIND_LIMIT);
5681        // Overfetch one result so limit notices only appear after confirmed overflow.
5682        let scan_limit = effective_limit.saturating_add(1);
5683
5684        if !search_path.exists() {
5685            return Err(Error::tool(
5686                "find",
5687                format!("Path not found: {}", search_path.display()),
5688            ));
5689        }
5690
5691        let cache_key = tool_cache_key("find", &self.cwd, &input_value);
5692        let cache_mode = if search_path.is_dir() {
5693            ToolCacheFingerprintMode::DirectoryRecursive
5694        } else {
5695            ToolCacheFingerprintMode::FileContent
5696        };
5697        let cache_deps = cache_dependency_for_path(&search_path, cache_mode);
5698        if let Some(output) = cached_tool_output(&cache_key, cache_deps.as_deref()) {
5699            return Ok(output);
5700        }
5701
5702        let fd_cmd = find_fd_binary().ok_or_else(|| {
5703            Error::tool(
5704                "find",
5705                "fd is not available (please install fd-find or fd)".to_string(),
5706            )
5707        })?;
5708
5709        // Build fd arguments
5710        let mut args: Vec<String> = vec![
5711            "--glob".to_string(),
5712            "--color=never".to_string(),
5713            "--hidden".to_string(),
5714            "--max-results".to_string(),
5715            scan_limit.to_string(),
5716        ];
5717
5718        // NOTE: We rely on fd's native .gitignore discovery. We only explicitly pass
5719        // the root .gitignore if it exists, to ensure it's respected even if the
5720        // search path logic might otherwise miss it.
5721        // We do NOT perform a blocking `glob("**/.gitignore")` here.
5722        let workspace_gitignore = self.cwd.join(".gitignore");
5723        if workspace_gitignore.exists() {
5724            args.push("--ignore-file".to_string());
5725            args.push(workspace_gitignore.display().to_string());
5726        }
5727        let root_gitignore = search_path.join(".gitignore");
5728        if root_gitignore != workspace_gitignore && root_gitignore.exists() {
5729            args.push("--ignore-file".to_string());
5730            args.push(root_gitignore.display().to_string());
5731        }
5732
5733        args.push("--".to_string());
5734        args.push(input.pattern.clone());
5735        args.push(search_path.display().to_string());
5736
5737        let mut child = command_with_default_sigpipe_in_dir(fd_cmd, &self.cwd)
5738            .map_err(|e| Error::tool("find", format!("Failed to prepare fd: {e}")))?
5739            .args(args)
5740            .current_dir(&self.cwd)
5741            .stdin(Stdio::null())
5742            .stdout(Stdio::piped())
5743            .stderr(Stdio::piped())
5744            .spawn()
5745            .map_err(|e| Error::tool("find", format!("Failed to run fd: {e}")))?;
5746
5747        let stdout_pipe = child
5748            .stdout
5749            .take()
5750            .ok_or_else(|| Error::tool("find", "Missing stdout"))?;
5751        let stderr_pipe = child
5752            .stderr
5753            .take()
5754            .ok_or_else(|| Error::tool("find", "Missing stderr"))?;
5755
5756        let mut guard = ProcessGuard::new(child, ProcessCleanupMode::ChildOnly);
5757
5758        let stdout_handle = std::thread::spawn(move || -> std::result::Result<Vec<u8>, String> {
5759            read_to_end_capped_and_drain(stdout_pipe, READ_TOOL_MAX_BYTES)
5760        });
5761
5762        let stderr_handle = std::thread::spawn(move || -> std::result::Result<Vec<u8>, String> {
5763            read_to_end_capped_and_drain(stderr_pipe, READ_TOOL_MAX_BYTES)
5764        });
5765
5766        let tick = Duration::from_millis(10);
5767        let start_time = std::time::Instant::now();
5768        let timeout_ms = 60_000; // 60 seconds
5769        let mut timed_out = false;
5770        let mut cx_cancelled = false;
5771
5772        let status = loop {
5773            let agent_cx = AgentCx::for_current_or_request();
5774            let cx = agent_cx.cx();
5775            if cx.checkpoint().is_err() {
5776                cx_cancelled = true;
5777                let _ = guard.kill();
5778                break None;
5779            }
5780
5781            // Check if process is done
5782            match guard.try_wait_child() {
5783                Ok(Some(status)) => break Some(status),
5784                Ok(None) => {
5785                    if start_time.elapsed().as_millis() > timeout_ms {
5786                        timed_out = true;
5787                        let _ = guard.kill();
5788                        break None;
5789                    }
5790                    let now = cx.timer_driver().map_or_else(wall_now, |timer| timer.now());
5791                    sleep(now, tick).await;
5792                }
5793                Err(e) => return Err(Error::tool("find", e.to_string())),
5794            }
5795        };
5796
5797        let stdout_bytes = stdout_handle
5798            .join()
5799            .map_err(|_| Error::tool("find", "fd stdout reader thread panicked"))?
5800            .map_err(|err| Error::tool("find", format!("Failed to read fd stdout: {err}")))?;
5801        let stderr_bytes = stderr_handle
5802            .join()
5803            .map_err(|_| Error::tool("find", "fd stderr reader thread panicked"))?
5804            .map_err(|err| Error::tool("find", format!("Failed to read fd stderr: {err}")))?;
5805
5806        if cx_cancelled {
5807            return Err(Error::tool("find", "Command cancelled"));
5808        }
5809        if timed_out {
5810            return Err(Error::tool("find", "Command timed out after 60 seconds"));
5811        }
5812        let status = status.expect("fd exit status after successful completion");
5813
5814        let mut stdout = String::from_utf8_lossy(&stdout_bytes).trim().to_string();
5815        if stdout_bytes.len() as u64 > READ_TOOL_MAX_BYTES {
5816            stdout.push_str("\n... [stdout truncated] ...");
5817        }
5818        let mut stderr = String::from_utf8_lossy(&stderr_bytes).trim().to_string();
5819        if stderr_bytes.len() as u64 > READ_TOOL_MAX_BYTES {
5820            stderr.push_str("\n... [stderr truncated] ...");
5821        }
5822
5823        if !status.success() && stdout.is_empty() {
5824            if status.code() == Some(1) && stderr.is_empty() {
5825                // fd uses exit code 1 for "no matches"; treat as empty result.
5826            } else {
5827                let code = status.code().unwrap_or(1);
5828                let msg = if stderr.is_empty() {
5829                    format!("fd exited with code {code}")
5830                } else {
5831                    stderr
5832                };
5833                return Err(Error::tool("find", msg));
5834            }
5835        }
5836
5837        if stdout.is_empty() {
5838            let output = ToolOutput {
5839                content: vec![ContentBlock::Text(TextContent::new(
5840                    "No files found matching pattern",
5841                ))],
5842                details: None,
5843                is_error: false,
5844            };
5845            cache_tool_output(
5846                cache_key,
5847                stable_cache_dependency_for_path(&search_path, cache_mode, cache_deps.as_deref()),
5848                &output,
5849            );
5850            return Ok(output);
5851        }
5852
5853        let mut entries: Vec<FindEntry> = Vec::new();
5854        for raw_line in stdout.lines() {
5855            let line = raw_line.trim_end_matches('\r').trim();
5856            if line.is_empty() {
5857                continue;
5858            }
5859
5860            // On Windows, fd may emit `//?/…` or `\\?\…` extended-length
5861            // paths. Strip the prefix so relativization works correctly.
5862            let clean = strip_unc_prefix(PathBuf::from(line));
5863            let line_path = clean.as_path();
5864            let mut rel = if line_path.is_absolute() {
5865                line_path.strip_prefix(&search_path).map_or_else(
5866                    |_| line_path.to_string_lossy().to_string(),
5867                    |stripped| stripped.to_string_lossy().to_string(),
5868                )
5869            } else {
5870                line_path.to_string_lossy().to_string()
5871            };
5872
5873            let full_path = if line_path.is_absolute() {
5874                line_path.to_path_buf()
5875            } else {
5876                search_path.join(line_path)
5877            };
5878            if full_path.is_dir() && !rel.ends_with('/') {
5879                rel.push('/');
5880            }
5881
5882            let modified = std::fs::metadata(&full_path)
5883                .and_then(|meta| meta.modified())
5884                .ok();
5885            entries.push(FindEntry { rel, modified });
5886        }
5887
5888        entries.sort_by(|a, b| {
5889            let ordering = match (&a.modified, &b.modified) {
5890                (Some(a_time), Some(b_time)) => b_time.cmp(a_time),
5891                (Some(_), None) => Ordering::Less,
5892                (None, Some(_)) => Ordering::Greater,
5893                (None, None) => Ordering::Equal,
5894            };
5895            ordering.then_with(|| {
5896                let a_lower = a.rel.to_lowercase();
5897                let b_lower = b.rel.to_lowercase();
5898                a_lower.cmp(&b_lower).then_with(|| a.rel.cmp(&b.rel))
5899            })
5900        });
5901
5902        if entries.is_empty() {
5903            let output = ToolOutput {
5904                content: vec![ContentBlock::Text(TextContent::new(
5905                    "No files found matching pattern",
5906                ))],
5907                details: None,
5908                is_error: false,
5909            };
5910            cache_tool_output(
5911                cache_key,
5912                stable_cache_dependency_for_path(&search_path, cache_mode, cache_deps.as_deref()),
5913                &output,
5914            );
5915            return Ok(output);
5916        }
5917
5918        let result_limit_reached = entries.len() > effective_limit;
5919        let mut output_builder = HeadTruncatingLineWriter::new(DEFAULT_MAX_BYTES);
5920        let mut artifact_source = String::new();
5921        for entry in entries.into_iter().take(effective_limit) {
5922            output_builder.push_line(&entry.rel);
5923            append_artifact_source_line(&mut artifact_source, &entry.rel);
5924        }
5925        let mut truncation = output_builder.finish();
5926
5927        let mut result_output = std::mem::take(&mut truncation.content);
5928        let mut notices: Vec<String> = Vec::new();
5929        let mut details_map = serde_json::Map::new();
5930
5931        if !status.success() {
5932            let code = status.code().unwrap_or(1);
5933            notices.push(format!("fd exited with code {code}"));
5934        }
5935
5936        if result_limit_reached {
5937            notices.push(format!(
5938                "{effective_limit} results limit reached. Use limit={} for more, or refine pattern",
5939                effective_limit * 2
5940            ));
5941            details_map.insert(
5942                "resultLimitReached".to_string(),
5943                serde_json::Value::Number(serde_json::Number::from(effective_limit)),
5944            );
5945        }
5946
5947        if truncation.truncated {
5948            notices.push(format!("{} limit reached", format_size(DEFAULT_MAX_BYTES)));
5949            details_map.insert("truncation".to_string(), serde_json::to_value(truncation)?);
5950        }
5951
5952        if !notices.is_empty() {
5953            let _ = write!(result_output, "\n\n[{}]", notices.join(". "));
5954        }
5955
5956        let mut details = if details_map.is_empty() {
5957            None
5958        } else {
5959            Some(serde_json::Value::Object(details_map))
5960        };
5961
5962        attach_text_artifact_if_needed_with_root(
5963            self.artifact_root.as_deref(),
5964            &mut result_output,
5965            &mut details,
5966            "find",
5967            tool_call_id,
5968            "fileResults",
5969            &artifact_source,
5970        );
5971
5972        let output = ToolOutput {
5973            content: vec![ContentBlock::Text(TextContent::new(result_output))],
5974            details,
5975            is_error: false,
5976        };
5977        cache_tool_output(
5978            cache_key,
5979            stable_cache_dependency_for_path(&search_path, cache_mode, cache_deps.as_deref()),
5980            &output,
5981        );
5982        Ok(output)
5983    }
5984}
5985
5986// ============================================================================
5987// Ls Tool
5988// ============================================================================
5989
5990/// Input parameters for the ls tool.
5991#[derive(Debug, Deserialize)]
5992#[serde(rename_all = "camelCase")]
5993struct LsInput {
5994    path: Option<String>,
5995    limit: Option<usize>,
5996}
5997
5998pub struct LsTool {
5999    cwd: PathBuf,
6000    artifact_root: Option<PathBuf>,
6001}
6002
6003impl LsTool {
6004    pub fn new(cwd: &Path) -> Self {
6005        Self {
6006            cwd: cwd.to_path_buf(),
6007            artifact_root: None,
6008        }
6009    }
6010
6011    #[cfg(test)]
6012    fn with_artifact_root(cwd: &Path, artifact_root: &Path) -> Self {
6013        Self {
6014            cwd: cwd.to_path_buf(),
6015            artifact_root: Some(artifact_root.to_path_buf()),
6016        }
6017    }
6018}
6019
6020#[async_trait]
6021#[allow(clippy::unnecessary_literal_bound, clippy::too_many_lines)]
6022impl Tool for LsTool {
6023    fn name(&self) -> &str {
6024        "ls"
6025    }
6026    fn label(&self) -> &str {
6027        "ls"
6028    }
6029    fn description(&self) -> &str {
6030        "List directory contents. Returns entries sorted alphabetically, with '/' suffix for directories. Includes dotfiles. Output is truncated to 500 entries or 1MB (whichever is hit first)."
6031    }
6032
6033    fn parameters(&self) -> serde_json::Value {
6034        serde_json::json!({
6035            "type": "object",
6036            "properties": {
6037                "path": {
6038                    "type": "string",
6039                    "description": "Directory to list (default: current directory)"
6040                },
6041                "limit": {
6042                    "type": "integer",
6043                    "description": "Maximum number of entries to return (default: 500)"
6044                }
6045            }
6046        })
6047    }
6048
6049    fn effects(&self) -> ToolEffects {
6050        ToolEffects::read()
6051    }
6052
6053    async fn execute(
6054        &self,
6055        tool_call_id: &str,
6056        input: serde_json::Value,
6057        _on_update: Option<Box<dyn Fn(ToolUpdate) + Send + Sync>>,
6058    ) -> Result<ToolOutput> {
6059        let input_value = input.clone();
6060        let input: LsInput =
6061            serde_json::from_value(input).map_err(|e| Error::validation(e.to_string()))?;
6062
6063        if matches!(input.limit, Some(0)) {
6064            return Err(Error::validation(
6065                "`limit` must be greater than 0".to_string(),
6066            ));
6067        }
6068
6069        let dir_path = input
6070            .path
6071            .as_ref()
6072            .map_or_else(|| self.cwd.clone(), |p| resolve_read_path(p, &self.cwd));
6073        let dir_path = enforce_cwd_scope(&dir_path, &self.cwd, "list")?;
6074
6075        let effective_limit = input.limit.unwrap_or(DEFAULT_LS_LIMIT);
6076
6077        if !dir_path.exists() {
6078            return Err(Error::tool(
6079                "ls",
6080                format!("Path not found: {}", dir_path.display()),
6081            ));
6082        }
6083        if !dir_path.is_dir() {
6084            return Err(Error::tool(
6085                "ls",
6086                format!("Not a directory: {}", dir_path.display()),
6087            ));
6088        }
6089
6090        let cache_key = tool_cache_key("ls", &self.cwd, &input_value);
6091        let cache_mode = ToolCacheFingerprintMode::DirectoryImmediate;
6092        let cache_deps = cache_dependency_for_path(&dir_path, cache_mode);
6093        if let Some(output) = cached_tool_output(&cache_key, cache_deps.as_deref()) {
6094            return Ok(output);
6095        }
6096
6097        let mut entries = Vec::new();
6098        let mut read_dir = asupersync::fs::read_dir(&dir_path)
6099            .await
6100            .map_err(|e| Error::tool("ls", format!("Cannot read directory: {e}")))?;
6101
6102        let mut scan_limit_reached = false;
6103        while let Some(entry) = read_dir
6104            .next_entry()
6105            .await
6106            .map_err(|e| Error::tool("ls", format!("Cannot read directory entry: {e}")))?
6107        {
6108            if entries.len() >= LS_SCAN_HARD_LIMIT {
6109                scan_limit_reached = true;
6110                break;
6111            }
6112            let name = entry.file_name().to_string_lossy().to_string();
6113            // Handle broken symlinks or permission errors by treating them as non-directories
6114            // Optimization: use file_type() first to avoid stat overhead on every file.
6115            let is_dir = match entry.file_type().await {
6116                Ok(ft) => {
6117                    if ft.is_dir() {
6118                        true
6119                    } else if ft.is_symlink() {
6120                        // Only stat if it's a symlink to see if it points to a directory
6121                        entry.metadata().await.is_ok_and(|meta| meta.is_dir())
6122                    } else {
6123                        false
6124                    }
6125                }
6126                Err(_) => entry.metadata().await.is_ok_and(|meta| meta.is_dir()),
6127            };
6128            entries.push((name, is_dir));
6129        }
6130
6131        // Sort alphabetically (case-insensitive).
6132        entries.sort_by_cached_key(|(a, _)| a.to_lowercase());
6133
6134        let mut output_builder = HeadTruncatingLineWriter::new(DEFAULT_MAX_BYTES);
6135        let mut artifact_source = String::new();
6136        let mut emitted_entries = 0usize;
6137        let mut entry_limit_reached = false;
6138
6139        for (entry, is_dir) in entries {
6140            if emitted_entries >= effective_limit {
6141                entry_limit_reached = true;
6142                break;
6143            }
6144            let line = if is_dir { format!("{entry}/") } else { entry };
6145            output_builder.push_line(&line);
6146            append_artifact_source_line(&mut artifact_source, &line);
6147            emitted_entries = emitted_entries.saturating_add(1);
6148        }
6149
6150        if emitted_entries == 0 {
6151            let output = ToolOutput {
6152                content: vec![ContentBlock::Text(TextContent::new("(empty directory)"))],
6153                details: None,
6154                is_error: false,
6155            };
6156            cache_tool_output(
6157                cache_key,
6158                stable_cache_dependency_for_path(&dir_path, cache_mode, cache_deps.as_deref()),
6159                &output,
6160            );
6161            return Ok(output);
6162        }
6163
6164        // Apply byte truncation while writing, avoiding a second joined copy.
6165        let mut truncation = output_builder.finish();
6166
6167        let mut output = std::mem::take(&mut truncation.content);
6168        let mut details_map = serde_json::Map::new();
6169        let mut notices: Vec<String> = Vec::new();
6170
6171        if entry_limit_reached {
6172            notices.push(format!(
6173                "{effective_limit} entries limit reached. Use limit={} for more",
6174                effective_limit * 2
6175            ));
6176            details_map.insert(
6177                "entryLimitReached".to_string(),
6178                serde_json::Value::Number(serde_json::Number::from(effective_limit)),
6179            );
6180        }
6181
6182        if scan_limit_reached {
6183            notices.push(format!(
6184                "Directory scan limited to {LS_SCAN_HARD_LIMIT} entries to prevent system overload"
6185            ));
6186            details_map.insert(
6187                "scanLimitReached".to_string(),
6188                serde_json::Value::Number(serde_json::Number::from(LS_SCAN_HARD_LIMIT)),
6189            );
6190        }
6191
6192        if truncation.truncated {
6193            notices.push(format!("{} limit reached", format_size(DEFAULT_MAX_BYTES)));
6194            details_map.insert("truncation".to_string(), serde_json::to_value(truncation)?);
6195        }
6196
6197        if !notices.is_empty() {
6198            let _ = write!(output, "\n\n[{}]", notices.join(". "));
6199        }
6200
6201        let mut details = if details_map.is_empty() {
6202            None
6203        } else {
6204            Some(serde_json::Value::Object(details_map))
6205        };
6206
6207        attach_text_artifact_if_needed_with_root(
6208            self.artifact_root.as_deref(),
6209            &mut output,
6210            &mut details,
6211            "ls",
6212            tool_call_id,
6213            "directoryEntries",
6214            &artifact_source,
6215        );
6216
6217        let output = ToolOutput {
6218            content: vec![ContentBlock::Text(TextContent::new(output))],
6219            details,
6220            is_error: false,
6221        };
6222        cache_tool_output(
6223            cache_key,
6224            stable_cache_dependency_for_path(&dir_path, cache_mode, cache_deps.as_deref()),
6225            &output,
6226        );
6227        Ok(output)
6228    }
6229}
6230
6231// ============================================================================
6232// Cleanup
6233// ============================================================================
6234
6235/// Clean up old temporary files created by the bash tool.
6236///
6237/// Scans the system temporary directory for files matching `pi-bash-*.log`
6238/// that are older than 24 hours and deletes them. This prevents indefinite
6239/// accumulation of log files from long-running sessions.
6240pub fn cleanup_temp_files() {
6241    // Run in a detached thread to avoid blocking startup/shutdown.
6242    std::thread::spawn(|| {
6243        let temp_dir = std::env::temp_dir();
6244        let Ok(entries) = std::fs::read_dir(&temp_dir) else {
6245            return;
6246        };
6247
6248        for entry in entries.flatten() {
6249            let path = entry.path();
6250            if !path.is_file() {
6251                continue;
6252            }
6253
6254            let Some(file_name) = path.file_name().and_then(|n| n.to_str()) else {
6255                continue;
6256            };
6257
6258            // Match "pi-bash-" or "pi-rpc-bash-" prefix and ".log" suffix.
6259            if (file_name.starts_with("pi-bash-") || file_name.starts_with("pi-rpc-bash-"))
6260                && std::path::Path::new(file_name)
6261                    .extension()
6262                    .is_some_and(|ext| ext.eq_ignore_ascii_case("log"))
6263                && let Ok(metadata) = entry.metadata()
6264                && metadata.modified().is_ok_and(|modified| {
6265                    modified
6266                        .elapsed()
6267                        .is_ok_and(|age| age > Duration::from_secs(24 * 60 * 60))
6268                })
6269                && let Err(e) = std::fs::remove_file(&path)
6270            {
6271                // Log but don't panic on cleanup failure
6272                tracing::debug!("Failed to remove temp file {}: {}", path.display(), e);
6273            }
6274        }
6275    });
6276}
6277
6278// ============================================================================
6279// Helper functions
6280// ============================================================================
6281
6282fn rg_available() -> bool {
6283    find_rg_binary().is_some()
6284}
6285
6286fn pump_stream<R: Read + Send + 'static>(
6287    mut reader: R,
6288    stream_name: &'static str,
6289    tx: &mpsc::SyncSender<BashPipeFrame>,
6290) {
6291    let mut buf = vec![0u8; 8192];
6292    loop {
6293        match reader.read(&mut buf) {
6294            Ok(0) => break,
6295            Ok(n) => {
6296                if tx.send(BashPipeFrame::Chunk(buf[..n].to_vec())).is_err() {
6297                    break;
6298                }
6299            }
6300            Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => {}
6301            Err(err) => {
6302                let _ = tx.send(BashPipeFrame::Error(format!(
6303                    "Failed to read bash {stream_name}: {err}"
6304                )));
6305                break;
6306            }
6307        }
6308    }
6309}
6310
6311async fn ingest_bash_pipe_frame(frame: BashPipeFrame, state: &mut BashOutputState) -> Result<()> {
6312    match frame {
6313        BashPipeFrame::Chunk(chunk) => ingest_bash_chunk(chunk, state).await,
6314        BashPipeFrame::Error(message) => {
6315            let error_message = bash_capture_error_message(&message, state);
6316            state.abandon_spill_file();
6317            Err(Error::tool("bash", error_message))
6318        }
6319    }
6320}
6321
6322fn bash_capture_error_message(message: &str, state: &BashOutputState) -> String {
6323    let raw = concat_chunks(&state.chunks);
6324    if raw.is_empty() {
6325        return message.to_string();
6326    }
6327
6328    let full_text = String::from_utf8_lossy(&raw).into_owned();
6329    let truncation = truncate_tail(full_text, DEFAULT_MAX_LINES, DEFAULT_MAX_BYTES);
6330    let mut error_message = message.to_string();
6331    let partial_output = if truncation.content.is_empty() {
6332        "(no output)".to_string()
6333    } else {
6334        truncation.content
6335    };
6336    let _ = write!(
6337        error_message,
6338        "\n\nPartial output before failure:\n{partial_output}"
6339    );
6340    if truncation.truncated || state.total_bytes > state.chunks_bytes {
6341        let _ = write!(
6342            error_message,
6343            "\n\n[Partial output truncated before failure]"
6344        );
6345    }
6346    error_message
6347}
6348
6349/// Read from a subprocess pipe until EOF while retaining only the first
6350/// `max_bytes + 1` bytes in memory so callers can detect truncation without
6351/// changing child-process behavior by closing the pipe early.
6352pub(crate) fn read_to_end_capped_and_drain<R: Read>(
6353    mut reader: R,
6354    max_bytes: u64,
6355) -> std::result::Result<Vec<u8>, String> {
6356    let capture_limit = usize::try_from(max_bytes.saturating_add(1)).unwrap_or(usize::MAX);
6357    let mut captured = Vec::with_capacity(capture_limit.min(8192));
6358    let mut chunk = [0u8; 8192];
6359
6360    loop {
6361        match reader.read(&mut chunk) {
6362            Ok(0) => break,
6363            Ok(read) => {
6364                let remaining = capture_limit.saturating_sub(captured.len());
6365                if remaining > 0 {
6366                    let keep = remaining.min(read);
6367                    captured.extend_from_slice(&chunk[..keep]);
6368                }
6369            }
6370            Err(err) if matches!(err.kind(), std::io::ErrorKind::Interrupted) => {}
6371            Err(err) => return Err(err.to_string()),
6372        }
6373    }
6374
6375    Ok(captured)
6376}
6377
6378// Keep `rx` as `&mut Receiver`: `std::sync::mpsc::Receiver` is `Send` but not
6379// `Sync`, and this helper awaits between polls, so `&Receiver` would make the
6380// surrounding future non-Send.
6381#[allow(clippy::needless_pass_by_ref_mut)]
6382#[cfg(test)]
6383async fn drain_bash_output(
6384    rx: &mut mpsc::Receiver<BashPipeFrame>,
6385    bash_output: &mut BashOutputState,
6386    cx: &AgentCx,
6387    drain_deadline: asupersync::Time,
6388    tick: Duration,
6389    allow_cancellation: bool,
6390) -> Result<bool> {
6391    loop {
6392        match rx.try_recv() {
6393            Ok(frame) => ingest_bash_pipe_frame(frame, bash_output).await?,
6394            Err(mpsc::TryRecvError::Empty) => {
6395                let now = cx
6396                    .cx()
6397                    .timer_driver()
6398                    .map_or_else(wall_now, |timer| timer.now());
6399                if now >= drain_deadline {
6400                    return Ok(false);
6401                }
6402                if allow_cancellation && cx.checkpoint().is_err() {
6403                    return Ok(true);
6404                }
6405                sleep(now, tick).await;
6406            }
6407            Err(mpsc::TryRecvError::Disconnected) => return Ok(false),
6408        }
6409    }
6410}
6411
6412fn concat_chunks(chunks: &VecDeque<Vec<u8>>) -> Vec<u8> {
6413    let total: usize = chunks.iter().map(Vec::len).sum();
6414    let mut out = Vec::with_capacity(total);
6415    for chunk in chunks {
6416        out.extend_from_slice(chunk);
6417    }
6418    out
6419}
6420
6421struct BashOutputState {
6422    total_bytes: usize,
6423    line_count: usize,
6424    last_byte_was_newline: bool,
6425    start_time: std::time::Instant,
6426    timeout_ms: Option<u64>,
6427    temp_file_path: Option<PathBuf>,
6428    temp_file: Option<asupersync::fs::File>,
6429    chunks: VecDeque<Vec<u8>>,
6430    chunks_bytes: usize,
6431    max_chunks_bytes: usize,
6432    spill_failed: bool,
6433}
6434
6435impl BashOutputState {
6436    fn new(max_chunks_bytes: usize) -> Self {
6437        Self {
6438            total_bytes: 0,
6439            line_count: 0,
6440            last_byte_was_newline: false,
6441            start_time: std::time::Instant::now(),
6442            timeout_ms: None,
6443            temp_file_path: None,
6444            temp_file: None,
6445            chunks: VecDeque::new(),
6446            chunks_bytes: 0,
6447            max_chunks_bytes,
6448            spill_failed: false,
6449        }
6450    }
6451
6452    fn abandon_spill_file(&mut self) {
6453        self.spill_failed = true;
6454        self.temp_file = None;
6455        if let Some(path) = self.temp_file_path.take() {
6456            if let Err(e) = std::fs::remove_file(&path)
6457                && e.kind() != std::io::ErrorKind::NotFound
6458            {
6459                tracing::debug!(
6460                    "Failed to remove incomplete bash spill file {}: {}",
6461                    path.display(),
6462                    e
6463                );
6464            }
6465        }
6466    }
6467}
6468
6469#[allow(clippy::too_many_lines)]
6470async fn ingest_bash_chunk(chunk: Vec<u8>, state: &mut BashOutputState) -> Result<()> {
6471    if chunk.is_empty() {
6472        return Ok(());
6473    }
6474
6475    state.last_byte_was_newline = chunk.last().is_some_and(|byte| *byte == b'\n');
6476    state.total_bytes = state.total_bytes.saturating_add(chunk.len());
6477    state.line_count = state
6478        .line_count
6479        .saturating_add(memchr::memchr_iter(b'\n', &chunk).count());
6480
6481    if state.total_bytes > DEFAULT_MAX_BYTES
6482        && state.temp_file.is_none()
6483        && state.temp_file_path.is_none()
6484        && !state.spill_failed
6485    {
6486        let id_full = Uuid::new_v4().simple().to_string();
6487        let id = &id_full[..16];
6488        let path = std::env::temp_dir().join(format!("pi-bash-{id}.log"));
6489
6490        // Create the file synchronously with restricted permissions to avoid
6491        // a race condition where the file is world-readable before we fix it.
6492        // We also capture the inode (on Unix) to verify identity later.
6493        let path_clone = path.clone();
6494        let expected_inode: Option<u64> =
6495            asupersync::runtime::spawn_blocking_io(move || -> std::io::Result<Option<u64>> {
6496                let mut options = std::fs::OpenOptions::new();
6497                options.write(true).create_new(true);
6498
6499                #[cfg(unix)]
6500                {
6501                    use std::os::unix::fs::OpenOptionsExt;
6502                    options.mode(0o600);
6503                }
6504
6505                match options.open(&path_clone) {
6506                    Ok(file) => {
6507                        #[cfg(unix)]
6508                        {
6509                            use std::os::unix::fs::MetadataExt;
6510                            Ok(file.metadata().ok().map(|m| m.ino()))
6511                        }
6512                        #[cfg(not(unix))]
6513                        {
6514                            drop(file);
6515                            Ok(None)
6516                        }
6517                    }
6518                    Err(e) => {
6519                        tracing::warn!("Failed to create bash temp file: {e}");
6520                        Ok(None)
6521                    }
6522                }
6523            })
6524            .await
6525            .unwrap_or(None);
6526
6527        if expected_inode.is_some() || !cfg!(unix) {
6528            match asupersync::fs::OpenOptions::new()
6529                .append(true)
6530                .open(&path)
6531                .await
6532            {
6533                Ok(mut file) => {
6534                    #[cfg_attr(not(unix), allow(unused_mut))]
6535                    let mut identity_match = true;
6536                    #[cfg(unix)]
6537                    if let Some(expected) = expected_inode {
6538                        use std::os::unix::fs::MetadataExt;
6539                        // asupersync 0.3.6's fs::Metadata no longer exposes the
6540                        // inode (and fs::File has no general AsRawFd), so re-stat
6541                        // the path with std symlink_metadata (does not follow
6542                        // symlinks) for the TOCTOU/identity guard.
6543                        match std::fs::symlink_metadata(&path) {
6544                            Ok(meta) => {
6545                                if !meta.ino().eq(&expected) {
6546                                    tracing::warn!(
6547                                        "Temp file identity mismatch (possible TOCTOU attack)"
6548                                    );
6549                                    identity_match = false;
6550                                }
6551                            }
6552                            Err(e) => {
6553                                tracing::warn!("Failed to stat temp file: {e}");
6554                                identity_match = false;
6555                            }
6556                        }
6557                    }
6558
6559                    if identity_match {
6560                        // Write buffered chunks to file first so it contains output from the beginning.
6561                        let mut failed_flush = false;
6562                        for existing in &state.chunks {
6563                            if let Err(e) = file.write_all(existing).await {
6564                                tracing::warn!("Failed to flush bash chunk to temp file: {e}");
6565                                failed_flush = true;
6566                                break;
6567                            }
6568                        }
6569
6570                        state.temp_file_path = Some(path);
6571                        if failed_flush {
6572                            state.abandon_spill_file();
6573                        } else {
6574                            state.temp_file = Some(file);
6575                        }
6576                    } else {
6577                        state.temp_file_path = Some(path);
6578                        state.abandon_spill_file();
6579                    }
6580                }
6581                Err(e) => {
6582                    tracing::warn!("Failed to open temp file async: {e}");
6583                    state.temp_file_path = Some(path);
6584                    state.abandon_spill_file();
6585                }
6586            }
6587        } else {
6588            state.spill_failed = true;
6589        }
6590    }
6591
6592    let mut close_spill_file = false;
6593    if let Some(file) = state.temp_file.as_mut() {
6594        let mut abandon_spill_file = false;
6595        if state.total_bytes <= BASH_FILE_LIMIT_BYTES {
6596            if let Err(e) = file.write_all(&chunk).await {
6597                tracing::warn!("Failed to write bash chunk to temp file: {e}");
6598                abandon_spill_file = true;
6599            }
6600        } else {
6601            // Hard limit reached. Stop writing and close the file to release the FD.
6602            if !state.spill_failed {
6603                tracing::warn!("Bash output exceeded hard limit; stopping file log");
6604                close_spill_file = true;
6605            }
6606        }
6607        if abandon_spill_file {
6608            state.abandon_spill_file();
6609        }
6610    }
6611    if close_spill_file {
6612        state.temp_file = None;
6613    }
6614
6615    state.chunks_bytes = state.chunks_bytes.saturating_add(chunk.len());
6616    state.chunks.push_back(chunk);
6617    while state.chunks_bytes > state.max_chunks_bytes && state.chunks.len() > 1 {
6618        if let Some(front) = state.chunks.pop_front() {
6619            state.chunks_bytes = state.chunks_bytes.saturating_sub(front.len());
6620        }
6621    }
6622    Ok(())
6623}
6624
6625const fn line_count_from_newline_count(
6626    total_bytes: usize,
6627    newline_count: usize,
6628    last_byte_was_newline: bool,
6629) -> usize {
6630    if total_bytes == 0 {
6631        0
6632    } else if last_byte_was_newline {
6633        newline_count
6634    } else {
6635        newline_count.saturating_add(1)
6636    }
6637}
6638
6639fn emit_bash_update(
6640    state: &BashOutputState,
6641    on_update: Option<&(dyn Fn(ToolUpdate) + Send + Sync)>,
6642) -> Result<()> {
6643    if let Some(callback) = on_update {
6644        let raw = concat_chunks(&state.chunks);
6645        let full_text = String::from_utf8_lossy(&raw);
6646        let truncation =
6647            truncate_tail(full_text.into_owned(), DEFAULT_MAX_LINES, DEFAULT_MAX_BYTES);
6648
6649        // Build the progress + details JSON using the json! macro instead of
6650        // manual Map::insert calls.  This eliminates 7+ String heap
6651        // allocations per update for the constant field-name keys
6652        // ("elapsedMs", "lineCount", …) that the manual path required.
6653        let elapsed_ms = state.start_time.elapsed().as_millis();
6654        let line_count = line_count_from_newline_count(
6655            state.total_bytes,
6656            state.line_count,
6657            state.last_byte_was_newline,
6658        );
6659        let mut details = serde_json::json!({
6660            "progress": {
6661                "elapsedMs": elapsed_ms,
6662                "lineCount": line_count,
6663                "byteCount": state.total_bytes
6664            }
6665        });
6666        let Some(details_map) = details.as_object_mut() else {
6667            return Ok(());
6668        };
6669
6670        if let Some(timeout) = state.timeout_ms {
6671            if let Some(progress) = details_map
6672                .get_mut("progress")
6673                .and_then(|v| v.as_object_mut())
6674            {
6675                progress.insert("timeoutMs".into(), serde_json::json!(timeout));
6676            }
6677        }
6678        if truncation.truncated {
6679            details_map.insert("truncation".into(), serde_json::to_value(&truncation)?);
6680        }
6681        if let Some(path) = state.temp_file_path.as_ref() {
6682            details_map.insert(
6683                "fullOutputPath".into(),
6684                serde_json::Value::String(path.display().to_string()),
6685            );
6686        }
6687
6688        callback(ToolUpdate {
6689            content: vec![ContentBlock::Text(TextContent::new(truncation.content))],
6690            details: Some(details),
6691        });
6692    }
6693    Ok(())
6694}
6695
6696pub(crate) struct ProcessGuard {
6697    child: Option<std::process::Child>,
6698    cleanup_mode: ProcessCleanupMode,
6699}
6700
6701#[derive(Clone, Copy, Debug, Eq, PartialEq)]
6702pub(crate) enum ProcessCleanupMode {
6703    ChildOnly,
6704    ProcessGroupTree,
6705}
6706
6707impl ProcessGuard {
6708    pub(crate) const fn new(child: std::process::Child, cleanup_mode: ProcessCleanupMode) -> Self {
6709        Self {
6710            child: Some(child),
6711            cleanup_mode,
6712        }
6713    }
6714
6715    pub(crate) fn try_wait_child(&mut self) -> std::io::Result<Option<std::process::ExitStatus>> {
6716        self.child
6717            .as_mut()
6718            .map_or(Ok(None), std::process::Child::try_wait)
6719    }
6720
6721    pub(crate) fn kill(&mut self) -> Option<std::process::ExitStatus> {
6722        if let Some(mut child) = self.child.take() {
6723            cleanup_child(Some(child.id()), self.cleanup_mode);
6724            let _ = child.kill();
6725            std::thread::spawn(move || {
6726                let _ = child.wait();
6727            });
6728            // We cannot return the exit status synchronously without blocking,
6729            // so we return None to indicate the process was forcefully killed.
6730            return None;
6731        }
6732        None
6733    }
6734
6735    pub(crate) fn wait(&mut self) -> std::io::Result<std::process::ExitStatus> {
6736        if let Some(mut child) = self.child.take() {
6737            return child.wait();
6738        }
6739        Err(std::io::Error::other("Already waited"))
6740    }
6741}
6742
6743impl Drop for ProcessGuard {
6744    fn drop(&mut self) {
6745        if let Some(mut child) = self.child.take() {
6746            match child.try_wait() {
6747                Ok(None) => {}
6748                Ok(Some(_)) | Err(_) => return,
6749            }
6750            let cleanup_mode = self.cleanup_mode;
6751            std::thread::spawn(move || {
6752                cleanup_child(Some(child.id()), cleanup_mode);
6753                let _ = child.kill();
6754                let _ = child.wait();
6755            });
6756        }
6757    }
6758}
6759
6760fn cleanup_child(pid: Option<u32>, cleanup_mode: ProcessCleanupMode) {
6761    if cleanup_mode == ProcessCleanupMode::ProcessGroupTree {
6762        kill_process_group_tree(pid);
6763    }
6764}
6765
6766pub fn kill_process_tree(pid: Option<u32>) {
6767    kill_process_tree_with(pid, sysinfo::Signal::Kill, false);
6768}
6769
6770pub(crate) fn kill_process_group_tree(pid: Option<u32>) {
6771    kill_process_tree_with(pid, sysinfo::Signal::Kill, true);
6772}
6773
6774fn terminate_process_group_tree(pid: Option<u32>) {
6775    kill_process_tree_with(pid, sysinfo::Signal::Term, true);
6776}
6777
6778fn kill_process_tree_with(pid: Option<u32>, signal: sysinfo::Signal, include_process_group: bool) {
6779    let Some(pid) = pid else {
6780        return;
6781    };
6782
6783    let root = sysinfo::Pid::from_u32(pid);
6784
6785    let mut sys = sysinfo::System::new();
6786    sys.refresh_processes(sysinfo::ProcessesToUpdate::All, true);
6787
6788    let mut children_map: HashMap<sysinfo::Pid, Vec<sysinfo::Pid>> = HashMap::new();
6789    for (p, proc_) in sys.processes() {
6790        if let Some(parent) = proc_.parent() {
6791            children_map.entry(parent).or_default().push(*p);
6792        }
6793    }
6794
6795    let mut to_kill = Vec::new();
6796    let mut visited = std::collections::HashSet::new();
6797    collect_process_tree(root, &children_map, &mut to_kill, &mut visited);
6798
6799    if include_process_group {
6800        // Some subprocess surfaces isolate the child into its own process group.
6801        // When they do, killing the group first catches background children even
6802        // if they have already been reparented away from the original root PID.
6803        #[cfg(unix)]
6804        {
6805            let sig_num = match signal {
6806                sysinfo::Signal::Kill => "9",
6807                _ => "15",
6808            };
6809            let _ = Command::new("kill")
6810                .arg(format!("-{sig_num}"))
6811                .arg("--")
6812                .arg(format!("-{pid}"))
6813                .stdin(Stdio::null())
6814                .stdout(Stdio::null())
6815                .stderr(Stdio::null())
6816                .status();
6817        }
6818    }
6819
6820    // Kill children first.
6821    for pid in to_kill.into_iter().rev() {
6822        if let Some(proc_) = sys.process(pid) {
6823            match proc_.kill_with(signal) {
6824                Some(true) => {}
6825                Some(false) | None => {
6826                    let _ = proc_.kill();
6827                }
6828            }
6829        }
6830    }
6831}
6832
6833fn collect_process_tree(
6834    pid: sysinfo::Pid,
6835    children_map: &HashMap<sysinfo::Pid, Vec<sysinfo::Pid>>,
6836    out: &mut Vec<sysinfo::Pid>,
6837    visited: &mut std::collections::HashSet<sysinfo::Pid>,
6838) {
6839    if !visited.insert(pid) {
6840        return;
6841    }
6842    out.push(pid);
6843    if let Some(children) = children_map.get(&pid) {
6844        for child in children {
6845            collect_process_tree(*child, children_map, out, visited);
6846        }
6847    }
6848}
6849
6850/// Build a child command whose Unix process image starts with SIGPIPE restored
6851/// to the platform default, without using `Command::pre_exec`.
6852///
6853/// Rust binaries ignore SIGPIPE by default, and POSIX inherits that disposition
6854/// across `exec(2)`. The tiny `/bin/sh` trampoline resets PIPE and then `exec`s
6855/// the requested program, preserving argv, cwd, stdio, and the process id that
6856/// later becomes the isolated process-group leader.
6857pub(crate) const SIGPIPE_TRAMPOLINE_EXEC_FAILURE_PREFIX: &str = "pi-sigpipe-reset: exec failed:";
6858
6859pub(crate) fn command_with_default_sigpipe(program: impl AsRef<OsStr>) -> std::io::Result<Command> {
6860    command_with_default_sigpipe_for_cwd(program.as_ref(), None)
6861}
6862
6863/// Variant of [`command_with_default_sigpipe`] for commands that will run with
6864/// `current_dir(cwd)`. This preserves relative `./program` lookup semantics.
6865pub(crate) fn command_with_default_sigpipe_in_dir(
6866    program: impl AsRef<OsStr>,
6867    cwd: &Path,
6868) -> std::io::Result<Command> {
6869    command_with_default_sigpipe_for_cwd(program.as_ref(), Some(cwd))
6870}
6871
6872#[cfg(unix)]
6873fn command_with_default_sigpipe_for_cwd(
6874    program: &OsStr,
6875    cwd: Option<&Path>,
6876) -> std::io::Result<Command> {
6877    let program = resolve_executable_for_shell_trampoline(program, cwd)?;
6878    let mut command = Command::new("/bin/sh");
6879    command
6880        .arg("-c")
6881        .arg(
6882            "trap - PIPE\n\
6883             exec \"$@\"\n\
6884             status=$?\n\
6885             printf 'pi-sigpipe-reset: exec failed: %s\\n' \"$1\" >&2\n\
6886             exit \"$status\"",
6887        )
6888        .arg("pi-sigpipe-reset")
6889        .arg(program);
6890    Ok(command)
6891}
6892
6893#[cfg(not(unix))]
6894fn command_with_default_sigpipe_for_cwd(
6895    program: &OsStr,
6896    _cwd: Option<&Path>,
6897) -> std::io::Result<Command> {
6898    let command = Command::new(program); // ubs:ignore policy-checked non-Unix command runner
6899    Ok(command)
6900}
6901
6902#[cfg(unix)]
6903fn resolve_executable_for_shell_trampoline(
6904    program: &OsStr,
6905    cwd: Option<&Path>,
6906) -> std::io::Result<OsString> {
6907    use std::os::unix::ffi::OsStrExt as _;
6908    use std::os::unix::fs::PermissionsExt as _;
6909
6910    fn executable_candidate(path: &Path) -> std::io::Result<bool> {
6911        let metadata = std::fs::metadata(path)?;
6912        Ok(metadata.is_file() && metadata.permissions().mode() & 0o111 != 0)
6913    }
6914
6915    fn absolutize_candidate(path: &Path, cwd: Option<&Path>) -> std::io::Result<PathBuf> {
6916        if path.is_absolute() {
6917            return Ok(path.to_path_buf());
6918        }
6919
6920        let base = std::env::current_dir()?;
6921        Ok(cwd.map_or_else(|| base.join(path), |cwd| base.join(cwd).join(path)))
6922    }
6923
6924    if program.as_bytes().contains(&b'/') {
6925        let path = Path::new(program);
6926        let candidate = absolutize_candidate(path, cwd)?;
6927        if executable_candidate(&candidate)? {
6928            return Ok(candidate.into_os_string());
6929        }
6930        return Err(std::io::Error::new(
6931            std::io::ErrorKind::PermissionDenied,
6932            format!("not an executable file: {}", candidate.display()),
6933        ));
6934    }
6935
6936    let mut permission_denied = false;
6937    let paths = std::env::var_os("PATH").unwrap_or_else(|| OsString::from("/bin:/usr/bin"));
6938    for dir in std::env::split_paths(&paths) {
6939        let candidate = absolutize_candidate(&dir.join(program), cwd)?;
6940        match executable_candidate(&candidate) {
6941            Ok(true) => return Ok(candidate.into_os_string()),
6942            Ok(false) => permission_denied = true,
6943            Err(err) if matches!(err.kind(), std::io::ErrorKind::NotFound) => {}
6944            Err(err) if matches!(err.kind(), std::io::ErrorKind::PermissionDenied) => {
6945                permission_denied = true;
6946            }
6947            Err(_) => {}
6948        }
6949    }
6950
6951    if permission_denied {
6952        Err(std::io::Error::new(
6953            std::io::ErrorKind::PermissionDenied,
6954            format!("command is not executable: {}", program.to_string_lossy()),
6955        ))
6956    } else {
6957        Err(std::io::Error::new(
6958            std::io::ErrorKind::NotFound,
6959            format!("command not found: {}", program.to_string_lossy()),
6960        ))
6961    }
6962}
6963
6964/// Detach a child process from pi's controlling terminal.
6965pub(crate) fn isolate_command_process_group(command: &mut Command) {
6966    #[cfg(unix)]
6967    {
6968        use std::os::unix::process::CommandExt as _;
6969        command.process_group(0);
6970    }
6971
6972    #[cfg(not(unix))]
6973    {
6974        let _ = command;
6975    }
6976}
6977
6978fn format_grep_path(file_path: &Path, cwd: &Path) -> String {
6979    if let Ok(rel) = file_path.strip_prefix(cwd) {
6980        let rel_str = rel.display().to_string().replace('\\', "/");
6981        if !rel_str.is_empty() {
6982            return rel_str;
6983        }
6984    }
6985
6986    let canonical_file = safe_canonicalize(file_path);
6987    let canonical_cwd = safe_canonicalize(cwd);
6988    if let Ok(rel) = canonical_file.strip_prefix(&canonical_cwd) {
6989        let rel_str = rel.display().to_string().replace('\\', "/");
6990        if !rel_str.is_empty() {
6991            return rel_str;
6992        }
6993    }
6994
6995    file_path.display().to_string().replace('\\', "/")
6996}
6997
6998async fn get_file_lines_async<'a>(
6999    path: &Path,
7000    cache: &'a mut HashMap<PathBuf, Vec<String>>,
7001) -> &'a [String] {
7002    if !cache.contains_key(path) {
7003        // Prevent OOM on huge files and hangs on pipes
7004        if let Ok(meta) = asupersync::fs::metadata(path).await {
7005            if !meta.is_file() || meta.len() > 10 * 1024 * 1024 {
7006                cache.insert(path.to_path_buf(), Vec::new());
7007                return &[];
7008            }
7009        } else {
7010            cache.insert(path.to_path_buf(), Vec::new());
7011            return &[];
7012        }
7013
7014        // Match Node's `readFileSync(..., "utf-8")` behavior: decode lossily rather than failing.
7015        let bytes = match asupersync::fs::read(path).await {
7016            Ok(bytes) => bytes,
7017            Err(err) => {
7018                tracing::debug!("Failed to read grep file {}: {err}", path.display());
7019                cache.insert(path.to_path_buf(), Vec::new());
7020                return &[];
7021            }
7022        };
7023        let content = String::from_utf8_lossy(&bytes);
7024        let mut lines = Vec::new();
7025        for line in content.split('\n') {
7026            let trimmed = line.strip_suffix('\r').unwrap_or(line);
7027            for piece in trimmed.split('\r') {
7028                lines.push(piece.to_string());
7029            }
7030        }
7031        if content.ends_with('\n') && lines.last().is_some_and(std::string::String::is_empty) {
7032            lines.pop();
7033        }
7034        cache.insert(path.to_path_buf(), lines);
7035    }
7036    if let Some(lines) = cache.get(path) {
7037        lines.as_slice()
7038    } else {
7039        &[]
7040    }
7041}
7042
7043fn find_fd_binary() -> Option<&'static str> {
7044    static BINARY: OnceLock<Option<&'static str>> = OnceLock::new();
7045    *BINARY.get_or_init(|| {
7046        if std::process::Command::new("fd")
7047            .arg("--version")
7048            .stdout(Stdio::null())
7049            .stderr(Stdio::null())
7050            .status()
7051            .is_ok()
7052        {
7053            return Some("fd");
7054        }
7055        if std::process::Command::new("fdfind")
7056            .arg("--version")
7057            .stdout(Stdio::null())
7058            .stderr(Stdio::null())
7059            .status()
7060            .is_ok()
7061        {
7062            return Some("fdfind");
7063        }
7064        None
7065    })
7066}
7067
7068fn find_rg_binary() -> Option<&'static str> {
7069    static BINARY: OnceLock<Option<&'static str>> = OnceLock::new();
7070    *BINARY.get_or_init(|| {
7071        if std::process::Command::new("rg")
7072            .arg("--version")
7073            .stdout(Stdio::null())
7074            .stderr(Stdio::null())
7075            .status()
7076            .is_ok()
7077        {
7078            return Some("rg");
7079        }
7080        if std::process::Command::new("ripgrep")
7081            .arg("--version")
7082            .stdout(Stdio::null())
7083            .stderr(Stdio::null())
7084            .status()
7085            .is_ok()
7086        {
7087            return Some("ripgrep");
7088        }
7089        None
7090    })
7091}
7092
7093// ============================================================================
7094// Hashline Edit Tool
7095// ============================================================================
7096
7097/// Custom nibble-encoding alphabet used for hashline tags.
7098const NIBBLE_STR: &[u8; 16] = b"ZPMQVRWSNKTXJBYH";
7099
7100/// Pre-computed 256-entry lookup table mapping each byte value to its
7101/// 2-character NIBBLE_STR encoding.
7102static HASHLINE_DICT: OnceLock<[[u8; 2]; 256]> = OnceLock::new();
7103
7104fn hashline_dict() -> &'static [[u8; 2]; 256] {
7105    HASHLINE_DICT.get_or_init(|| {
7106        let mut dict = [[0u8; 2]; 256];
7107        for i in 0..256 {
7108            dict[i] = [NIBBLE_STR[i & 0x0F], NIBBLE_STR[(i >> 4) & 0x0F]];
7109        }
7110        dict
7111    })
7112}
7113
7114/// Compute a 2-character hash tag for a line at the given 0-indexed position.
7115///
7116/// The algorithm:
7117/// 1. Strip trailing `\r`
7118/// 2. Remove all whitespace to get a "significant" string
7119/// 3. If the significant string contains at least one letter or digit, seed = 0;
7120///    otherwise seed = line index (to disambiguate punctuation-only or blank lines)
7121/// 4. Compute `xxh32(significant_bytes, seed) & 0xFF`
7122/// 5. Encode the low byte as 2 nibble chars from `NIBBLE_STR`
7123fn compute_line_hash(line_idx: usize, line: &str) -> [u8; 2] {
7124    let line = line.strip_suffix('\r').unwrap_or(line);
7125    // Remove all whitespace
7126    let significant: String = line.chars().filter(|c| !c.is_whitespace()).collect();
7127    let has_alnum = significant.chars().any(char::is_alphanumeric);
7128    let seed = if has_alnum {
7129        0
7130    } else {
7131        #[allow(clippy::cast_possible_truncation)]
7132        let s = line_idx as u32;
7133        s
7134    };
7135    let hash = xxhash_rust::xxh32::xxh32(significant.as_bytes(), seed);
7136    let byte = (hash & 0xFF) as usize;
7137    hashline_dict()[byte]
7138}
7139
7140/// Format a hashline tag as `"N#AB"` where N is the 1-indexed line number.
7141fn format_hashline_tag(line_idx: usize, line: &str) -> String {
7142    let h = compute_line_hash(line_idx, line);
7143    format!("{}#{}{}", line_idx + 1, h[0] as char, h[1] as char)
7144}
7145
7146/// Compute a hashline tag, reapplying a stripped BOM for the first line if needed.
7147fn format_hashline_tag_with_bom(line_idx: usize, line: &str, had_bom: bool) -> String {
7148    let h = compute_line_hash_with_bom(line_idx, line, had_bom);
7149    format!("{}#{}{}", line_idx + 1, h[0] as char, h[1] as char)
7150}
7151
7152fn compute_line_hash_with_bom(line_idx: usize, line: &str, had_bom: bool) -> [u8; 2] {
7153    if had_bom && line_idx == 0 {
7154        let mut with_bom = String::with_capacity(line.len().saturating_add(1));
7155        with_bom.push('\u{FEFF}');
7156        with_bom.push_str(line);
7157        compute_line_hash(line_idx, &with_bom)
7158    } else {
7159        compute_line_hash(line_idx, line)
7160    }
7161}
7162
7163/// Regex for parsing hashline references like `5#KJ` or ` > +  5 # KJ `.
7164/// Tolerates leading whitespace, diff markers (`>`, `+`, `-`), and spaces around `#`.
7165static HASHLINE_TAG_RE: OnceLock<regex::Regex> = OnceLock::new();
7166
7167fn hashline_tag_regex() -> &'static regex::Regex {
7168    HASHLINE_TAG_RE.get_or_init(|| {
7169        regex::Regex::new(r"^[\s>+\-]*(\d+)\s*#\s*([ZPMQVRWSNKTXJBYH]{2})")
7170            .expect("valid hashline regex")
7171    })
7172}
7173
7174/// Parse a hashline tag reference string into (1-indexed line number, 2-byte hash).
7175fn parse_hashline_tag(ref_str: &str) -> std::result::Result<(usize, [u8; 2]), String> {
7176    let re = hashline_tag_regex();
7177    let caps = re
7178        .captures(ref_str)
7179        .ok_or_else(|| format!("Invalid hashline reference: {ref_str:?}"))?;
7180    let line_num: usize = caps[1]
7181        .parse()
7182        .map_err(|e| format!("Invalid line number in {ref_str:?}: {e}"))?;
7183    if line_num == 0 {
7184        return Err(format!("Line number must be >= 1, got 0 in {ref_str:?}"));
7185    }
7186    let hash_bytes = caps[2].as_bytes();
7187    Ok((line_num, [hash_bytes[0], hash_bytes[1]]))
7188}
7189
7190/// Strip hashline tag prefixes that models sometimes copy into replacement content.
7191/// Matches patterns like `5#KJ:content` and returns just `content`.
7192static HASHLINE_PREFIX_RE: OnceLock<regex::Regex> = OnceLock::new();
7193
7194fn strip_hashline_prefix(line: &str) -> &str {
7195    let re = HASHLINE_PREFIX_RE.get_or_init(|| {
7196        regex::Regex::new(r"^[\s>+\-]*\d+\s*#\s*[ZPMQVRWSNKTXJBYH]{2}\s*:")
7197            .expect("valid hashline prefix regex")
7198    });
7199    re.find(line).map_or(line, |m| &line[m.end()..])
7200}
7201
7202/// Input parameters for the hashline edit tool.
7203#[derive(Debug, Deserialize)]
7204#[serde(rename_all = "camelCase")]
7205struct HashlineEditInput {
7206    path: String,
7207    edits: Vec<HashlineOp>,
7208}
7209
7210/// A single hashline edit operation.
7211#[derive(Debug, Clone, Deserialize)]
7212#[serde(rename_all = "camelCase")]
7213struct HashlineOp {
7214    /// Operation type: "replace", "prepend", or "append"
7215    op: String,
7216    /// Start anchor in "LINE#HASH" format (optional for BOF prepend / EOF append)
7217    pos: Option<String>,
7218    /// End anchor for range replace (inclusive)
7219    end: Option<String>,
7220    /// Replacement / insertion lines
7221    lines: Option<serde_json::Value>,
7222}
7223
7224impl HashlineOp {
7225    /// Extract lines from the `lines` field, handling string, array, and null variants.
7226    fn get_lines(&self) -> Vec<String> {
7227        match &self.lines {
7228            None | Some(serde_json::Value::Null) => vec![],
7229            Some(serde_json::Value::String(s)) => {
7230                normalize_to_lf(s).split('\n').map(String::from).collect()
7231            }
7232            Some(serde_json::Value::Array(arr)) => arr
7233                .iter()
7234                .map(|v| match v {
7235                    serde_json::Value::String(s) => normalize_to_lf(s),
7236                    other => normalize_to_lf(&other.to_string()),
7237                })
7238                .collect(),
7239            Some(other) => vec![normalize_to_lf(&other.to_string())],
7240        }
7241    }
7242}
7243
7244/// A resolved hashline edit operation ready for application.
7245struct ResolvedEdit<'a> {
7246    op: &'a str,
7247    /// 0-indexed start line (or 0 for BOF, `file_lines.len()` for EOF)
7248    start: usize,
7249    /// 0-indexed end line (inclusive, same as start for single-line ops)
7250    end: usize,
7251    lines: Vec<String>,
7252}
7253
7254pub struct HashlineEditTool {
7255    cwd: PathBuf,
7256}
7257
7258impl HashlineEditTool {
7259    pub fn new(cwd: &Path) -> Self {
7260        Self {
7261            cwd: cwd.to_path_buf(),
7262        }
7263    }
7264}
7265
7266/// Validate a hashline tag reference against actual file lines.
7267/// Returns `Ok(0-indexed line)` or `Err(message)` with context.
7268fn validate_line_ref(
7269    ref_str: &str,
7270    file_lines: &[&str],
7271    had_bom: bool,
7272) -> std::result::Result<usize, String> {
7273    let (line_num, expected_hash) = parse_hashline_tag(ref_str)?;
7274    let line_idx = line_num - 1;
7275    if line_idx >= file_lines.len() {
7276        return Err(format!(
7277            "Line {line_num} out of range (file has {} lines)",
7278            file_lines.len()
7279        ));
7280    }
7281    let actual_hash = compute_line_hash_with_bom(line_idx, file_lines[line_idx], had_bom);
7282    if actual_hash != expected_hash {
7283        let tag = format_hashline_tag_with_bom(line_idx, file_lines[line_idx], had_bom);
7284        return Err(format!(
7285            "Hash mismatch at line {line_num}: expected {}#{}{}, actual is {tag}",
7286            line_num, expected_hash[0] as char, expected_hash[1] as char,
7287        ));
7288    }
7289    Ok(line_idx)
7290}
7291
7292/// Build a context snippet around a mismatched line for error reporting.
7293fn mismatch_context(file_lines: &[&str], line_idx: usize, context: usize, had_bom: bool) -> String {
7294    let start = line_idx.saturating_sub(context);
7295    let end = (line_idx + context + 1).min(file_lines.len());
7296    let mut out = String::new();
7297    for (i, &file_line) in file_lines.iter().enumerate().take(end).skip(start) {
7298        let tag = format_hashline_tag_with_bom(i, file_line, had_bom);
7299        if i == line_idx {
7300            let _ = writeln!(out, ">>> {tag}:{file_line}");
7301        } else {
7302            let _ = writeln!(out, "    {tag}:{file_line}");
7303        }
7304    }
7305    out
7306}
7307
7308/// Collect all hash mismatches from a set of edits, returning a combined error message.
7309fn collect_mismatches(
7310    edits: &[HashlineOp],
7311    file_lines: &[&str],
7312    had_bom: bool,
7313) -> std::result::Result<(), String> {
7314    let mut errors = Vec::new();
7315    for edit in edits {
7316        if let Some(ref pos) = edit.pos {
7317            if let Err(e) = validate_line_ref(pos, file_lines, had_bom) {
7318                // Find the line index for context
7319                if let Ok((line_num, _)) = parse_hashline_tag(pos) {
7320                    let idx = (line_num - 1).min(file_lines.len().saturating_sub(1));
7321                    errors.push(format!(
7322                        "{e}\n{}",
7323                        mismatch_context(file_lines, idx, 2, had_bom)
7324                    ));
7325                } else {
7326                    errors.push(e);
7327                }
7328            }
7329        }
7330        if let Some(ref end) = edit.end {
7331            if let Err(e) = validate_line_ref(end, file_lines, had_bom) {
7332                if let Ok((line_num, _)) = parse_hashline_tag(end) {
7333                    let idx = (line_num - 1).min(file_lines.len().saturating_sub(1));
7334                    errors.push(format!(
7335                        "{e}\n{}",
7336                        mismatch_context(file_lines, idx, 2, had_bom)
7337                    ));
7338                } else {
7339                    errors.push(e);
7340                }
7341            }
7342        }
7343    }
7344    if errors.is_empty() {
7345        Ok(())
7346    } else {
7347        Err(errors.join("\n"))
7348    }
7349}
7350
7351/// Normalized representation of an edit for deduplication.
7352#[derive(Debug, Clone, PartialEq, Eq, Hash)]
7353struct NormalizedEdit {
7354    op: String,
7355    pos_line: Option<usize>,
7356    end_line: Option<usize>,
7357    lines: Vec<String>,
7358}
7359
7360/// Sort precedence for overlapping edits at the same line.
7361fn op_precedence(op: &str) -> u8 {
7362    match op {
7363        "replace" => 0,
7364        "append" => 1,
7365        "prepend" => 2,
7366        _ => 3,
7367    }
7368}
7369
7370#[async_trait]
7371#[allow(clippy::unnecessary_literal_bound)]
7372impl Tool for HashlineEditTool {
7373    fn name(&self) -> &str {
7374        "hashline_edit"
7375    }
7376    fn label(&self) -> &str {
7377        "hashline edit"
7378    }
7379    fn description(&self) -> &str {
7380        "Apply precise file edits using LINE#HASH tags from a prior read with hashline=true. \
7381         Each edit specifies an op (replace/prepend/append), a pos anchor (\"N#AB\"), an optional \
7382         end anchor for range replace, and replacement lines. Edits are validated against current \
7383         file hashes and applied bottom-up to avoid index invalidation."
7384    }
7385
7386    fn parameters(&self) -> serde_json::Value {
7387        serde_json::json!({
7388            "type": "object",
7389            "properties": {
7390                "path": {
7391                    "type": "string",
7392                    "description": "Path to the file to edit (relative or absolute)"
7393                },
7394                "edits": {
7395                    "type": "array",
7396                    "description": "Array of edit operations to apply",
7397                    "items": {
7398                        "type": "object",
7399                        "properties": {
7400                            "op": {
7401                                "type": "string",
7402                                "enum": ["replace", "prepend", "append"],
7403                                "description": "Operation type"
7404                            },
7405                            "pos": {
7406                                "type": "string",
7407                                "description": "Anchor line reference in LINE#HASH format (e.g. \"5#KJ\")"
7408                            },
7409                            "end": {
7410                                "type": "string",
7411                                "description": "End anchor for range replace (inclusive)"
7412                            },
7413                            "lines": {
7414                                "description": "Replacement/insertion content as array of strings, single string, or null for deletion",
7415                                "oneOf": [
7416                                    { "type": "array", "items": { "type": "string" } },
7417                                    { "type": "string" },
7418                                    { "type": "null" }
7419                                ]
7420                            }
7421                        },
7422                        "required": ["op"]
7423                    }
7424                }
7425            },
7426            "required": ["path", "edits"]
7427        })
7428    }
7429
7430    #[allow(clippy::too_many_lines)]
7431    async fn execute(
7432        &self,
7433        _tool_call_id: &str,
7434        input: serde_json::Value,
7435        _on_update: Option<Box<dyn Fn(ToolUpdate) + Send + Sync>>,
7436    ) -> Result<ToolOutput> {
7437        let input: HashlineEditInput = serde_json::from_value(input)
7438            .map_err(|e| Error::tool("hashline_edit", format!("Invalid input: {e}")))?;
7439
7440        if input.edits.is_empty() {
7441            return Err(Error::tool("hashline_edit", "No edits provided"));
7442        }
7443
7444        // Resolve file path and enforce scope before touching the filesystem.
7445        let resolved = resolve_read_path(&input.path, &self.cwd);
7446        let absolute_path = enforce_cwd_scope(&resolved, &self.cwd, "hashline_edit")?;
7447
7448        // Check file size
7449        let metadata = asupersync::fs::metadata(&absolute_path)
7450            .await
7451            .map_err(|err| {
7452                let message = match err.kind() {
7453                    std::io::ErrorKind::NotFound => format!("File not found: {}", input.path),
7454                    std::io::ErrorKind::PermissionDenied => {
7455                        format!("Permission denied: {}", input.path)
7456                    }
7457                    _ => format!("Cannot read file metadata: {err}"),
7458                };
7459                Error::tool("hashline_edit", message)
7460            })?;
7461        if !metadata.is_file() {
7462            return Err(Error::tool(
7463                "hashline_edit",
7464                format!("Path {} is not a regular file", absolute_path.display()),
7465            ));
7466        }
7467        if metadata.len() > READ_TOOL_MAX_BYTES {
7468            return Err(Error::tool(
7469                "hashline_edit",
7470                format!(
7471                    "File too large ({} bytes, max {} bytes)",
7472                    metadata.len(),
7473                    READ_TOOL_MAX_BYTES
7474                ),
7475            ));
7476        }
7477
7478        // Read file content
7479        let file = asupersync::fs::File::open(&absolute_path)
7480            .await
7481            .map_err(|e| Error::tool("hashline_edit", format!("Cannot open file: {e}")))?;
7482        let mut raw = Vec::new();
7483        let mut limiter = file.take(READ_TOOL_MAX_BYTES.saturating_add(1));
7484        limiter
7485            .read_to_end(&mut raw)
7486            .await
7487            .map_err(|e| Error::tool("hashline_edit", format!("Cannot read file: {e}")))?;
7488
7489        if raw.len() as u64 > READ_TOOL_MAX_BYTES {
7490            return Err(Error::tool(
7491                "hashline_edit",
7492                format!("File too large (> {READ_TOOL_MAX_BYTES} bytes)"),
7493            ));
7494        }
7495
7496        let raw_content = String::from_utf8(raw).map_err(|_| {
7497            Error::tool(
7498                "hashline_edit",
7499                "File contains invalid UTF-8 characters and cannot be safely edited as text."
7500                    .to_string(),
7501            )
7502        })?;
7503
7504        let (content_no_bom, had_bom) = strip_bom(&raw_content);
7505        let original_ending = detect_line_ending(content_no_bom);
7506        let normalized = normalize_to_lf(content_no_bom);
7507        let file_lines: Vec<&str> = normalized.split('\n').collect();
7508
7509        // Validate all hash references before making any changes
7510        if let Err(e) = collect_mismatches(&input.edits, &file_lines, had_bom) {
7511            return Err(Error::tool(
7512                "hashline_edit",
7513                format!("Hash validation failed — re-read the file to get current tags.\n\n{e}"),
7514            ));
7515        }
7516
7517        // Deduplicate edits
7518        let mut seen = std::collections::HashSet::new();
7519        let mut deduped_edits: Vec<&HashlineOp> = Vec::new();
7520        for edit in &input.edits {
7521            let pos_line = edit
7522                .pos
7523                .as_ref()
7524                .and_then(|p| parse_hashline_tag(p).ok())
7525                .map(|(n, _)| n);
7526            let end_line = edit
7527                .end
7528                .as_ref()
7529                .and_then(|e| parse_hashline_tag(e).ok())
7530                .map(|(n, _)| n);
7531            let key = NormalizedEdit {
7532                op: edit.op.clone(),
7533                pos_line,
7534                end_line,
7535                lines: edit.get_lines(),
7536            };
7537            if seen.insert(key) {
7538                deduped_edits.push(edit);
7539            }
7540        }
7541
7542        // Resolve line indices and sort bottom-up
7543        let mut resolved: Vec<ResolvedEdit<'_>> = Vec::new();
7544        for edit in &deduped_edits {
7545            let replacement_lines: Vec<String> = edit
7546                .get_lines()
7547                .into_iter()
7548                .map(|l| strip_hashline_prefix(&l).to_string())
7549                .collect();
7550
7551            match edit.op.as_str() {
7552                "replace" => {
7553                    let start_idx = match &edit.pos {
7554                        Some(pos) => validate_line_ref(pos, &file_lines, had_bom)
7555                            .map_err(|e| Error::tool("hashline_edit", e))?,
7556                        None => {
7557                            return Err(Error::tool(
7558                                "hashline_edit",
7559                                "replace operation requires a pos anchor",
7560                            ));
7561                        }
7562                    };
7563                    let end_idx = match &edit.end {
7564                        Some(end) => validate_line_ref(end, &file_lines, had_bom)
7565                            .map_err(|e| Error::tool("hashline_edit", e))?,
7566                        None => start_idx,
7567                    };
7568                    if end_idx < start_idx {
7569                        return Err(Error::tool(
7570                            "hashline_edit",
7571                            format!(
7572                                "End anchor (line {}) is before start anchor (line {})",
7573                                end_idx + 1,
7574                                start_idx + 1
7575                            ),
7576                        ));
7577                    }
7578                    resolved.push(ResolvedEdit {
7579                        op: "replace",
7580                        start: start_idx,
7581                        end: end_idx,
7582                        lines: replacement_lines,
7583                    });
7584                }
7585                "prepend" => {
7586                    let idx = match &edit.pos {
7587                        Some(pos) => validate_line_ref(pos, &file_lines, had_bom)
7588                            .map_err(|e| Error::tool("hashline_edit", e))?,
7589                        None => 0, // BOF
7590                    };
7591                    let end_idx = if file_lines == [""] && edit.pos.is_none() {
7592                        0 // replace the empty line
7593                    } else {
7594                        idx
7595                    };
7596                    resolved.push(ResolvedEdit {
7597                        op: if file_lines == [""] && edit.pos.is_none() {
7598                            "replace"
7599                        } else {
7600                            "prepend"
7601                        },
7602                        start: idx,
7603                        end: end_idx,
7604                        lines: replacement_lines,
7605                    });
7606                }
7607                "append" => {
7608                    let idx = match &edit.pos {
7609                        Some(pos) => validate_line_ref(pos, &file_lines, had_bom)
7610                            .map_err(|e| Error::tool("hashline_edit", e))?,
7611                        None => {
7612                            if file_lines.len() > 1 && file_lines.last() == Some(&"") {
7613                                file_lines.len() - 2
7614                            } else {
7615                                file_lines.len().saturating_sub(1)
7616                            }
7617                        }
7618                    };
7619                    let end_idx = if file_lines == [""] && edit.pos.is_none() {
7620                        0 // replace the empty line
7621                    } else {
7622                        idx
7623                    };
7624                    resolved.push(ResolvedEdit {
7625                        op: if file_lines == [""] && edit.pos.is_none() {
7626                            "replace"
7627                        } else {
7628                            "append"
7629                        },
7630                        start: idx,
7631                        end: end_idx,
7632                        lines: replacement_lines,
7633                    });
7634                }
7635                other => {
7636                    return Err(Error::tool(
7637                        "hashline_edit",
7638                        format!("Unknown op: {other:?}. Must be replace, prepend, or append."),
7639                    ));
7640                }
7641            }
7642        }
7643
7644        // Sort bottom-up: highest line first, then by precedence (replace < append < prepend)
7645        resolved.sort_by(|a, b| {
7646            b.start
7647                .cmp(&a.start)
7648                .then_with(|| op_precedence(a.op).cmp(&op_precedence(b.op)))
7649        });
7650
7651        // Detect overlapping edit ranges (undefined behavior if applied bottom-up)
7652        for i in 0..resolved.len() {
7653            for j in (i + 1)..resolved.len() {
7654                let a = &resolved[i];
7655                let b = &resolved[j];
7656                if a.start <= b.end && b.start <= a.end {
7657                    return Err(Error::tool(
7658                        "hashline_edit",
7659                        format!(
7660                            "Overlapping edits detected: {} at line {}-{} and {} at line {}-{}. \
7661                             Please combine overlapping edits into a single operation.",
7662                            a.op,
7663                            a.start + 1,
7664                            a.end + 1,
7665                            b.op,
7666                            b.start + 1,
7667                            b.end + 1
7668                        ),
7669                    ));
7670                }
7671            }
7672        }
7673
7674        // Apply splices bottom-up on a mutable Vec of lines
7675        let mut lines: Vec<String> = file_lines.iter().map(|s| (*s).to_string()).collect();
7676        let mut any_change = false;
7677
7678        for edit in &resolved {
7679            match edit.op {
7680                "replace" => {
7681                    // Check if it's a no-op
7682                    let existing: Vec<&str> = lines[edit.start..=edit.end]
7683                        .iter()
7684                        .map(String::as_str)
7685                        .collect();
7686                    if existing.eq(&edit.lines.iter().map(String::as_str).collect::<Vec<&str>>()) {
7687                        continue; // no-op
7688                    }
7689                    // Splice: remove old range, insert new lines
7690                    lines.splice(edit.start..=edit.end, edit.lines.iter().cloned());
7691                    any_change = true;
7692                }
7693                "prepend" => {
7694                    // Insert before the target line
7695                    lines.splice(edit.start..edit.start, edit.lines.iter().cloned());
7696                    if !edit.lines.is_empty() {
7697                        any_change = true;
7698                    }
7699                }
7700                "append" => {
7701                    // Insert after the target line
7702                    let insert_at = edit.start + 1;
7703                    lines.splice(insert_at..insert_at, edit.lines.iter().cloned());
7704                    if !edit.lines.is_empty() {
7705                        any_change = true;
7706                    }
7707                }
7708                _ => {} // unreachable due to earlier validation
7709            }
7710        }
7711
7712        if !any_change {
7713            return Err(Error::tool(
7714                "hashline_edit",
7715                format!(
7716                    "No changes made to {}. All edits were no-ops (replacement identical to existing content).",
7717                    input.path
7718                ),
7719            ));
7720        }
7721
7722        // Reconstruct content
7723        let new_normalized = lines.join("\n");
7724        let new_content = restore_line_endings(&new_normalized, original_ending);
7725        let mut final_content = new_content;
7726        if had_bom {
7727            final_content = format!("\u{FEFF}{final_content}");
7728        }
7729
7730        // Atomic write (same pattern as EditTool)
7731        let absolute_path_clone = absolute_path.clone();
7732        let final_content_bytes = final_content.into_bytes();
7733        asupersync::runtime::spawn_blocking_io(move || {
7734            let original_perms = std::fs::metadata(&absolute_path_clone)
7735                .ok()
7736                .map(|m| m.permissions());
7737            let parent = absolute_path_clone
7738                .parent()
7739                .unwrap_or_else(|| Path::new("."));
7740            let mut temp_file = tempfile::NamedTempFile::new_in(parent)?;
7741
7742            temp_file.as_file_mut().write_all(&final_content_bytes)?;
7743            temp_file.as_file_mut().sync_all()?;
7744
7745            if let Some(perms) = original_perms {
7746                let _ = temp_file.as_file().set_permissions(perms);
7747            } else {
7748                #[cfg(unix)]
7749                {
7750                    use std::os::unix::fs::PermissionsExt;
7751                    let _ = temp_file
7752                        .as_file()
7753                        .set_permissions(std::fs::Permissions::from_mode(0o644));
7754                }
7755            }
7756
7757            temp_file
7758                .persist(&absolute_path_clone)
7759                .map_err(|e| e.error)?;
7760            Ok(())
7761        })
7762        .await
7763        .map_err(|e| Error::tool("hashline_edit", format!("Failed to write file: {e}")))?;
7764
7765        // Generate diff
7766        let (diff, first_changed_line) = generate_diff_string(&normalized, &new_normalized);
7767        let mut details = serde_json::Map::new();
7768        details.insert("diff".to_string(), serde_json::Value::String(diff));
7769        if let Some(line) = first_changed_line {
7770            details.insert(
7771                "firstChangedLine".to_string(),
7772                serde_json::Value::Number(serde_json::Number::from(line)),
7773            );
7774        }
7775
7776        Ok(ToolOutput {
7777            content: vec![ContentBlock::Text(TextContent::new(format!(
7778                "Successfully applied hashline edits to {}.",
7779                input.path
7780            )))],
7781            details: Some(serde_json::Value::Object(details)),
7782            is_error: false,
7783        })
7784    }
7785}
7786
7787// ============================================================================
7788// Tests
7789// ============================================================================
7790
7791#[cfg(test)]
7792mod tests {
7793    use super::*;
7794    use proptest::prelude::*;
7795    #[cfg(target_os = "linux")]
7796    use std::time::Duration;
7797
7798    #[test]
7799    fn fsync_refusal_classifies_non_posix_durability_errors() {
7800        use std::io::{Error, ErrorKind};
7801
7802        // EBADF (the exact errno reported against virtiofs in issue #136) and
7803        // EINVAL (common for directory fsync on FUSE/network mounts) are
7804        // filesystem *refusals* of the durability barrier, not write failures.
7805        assert!(is_fsync_refused(&Error::from_raw_os_error(9))); // EBADF
7806        assert!(is_fsync_refused(&Error::from_raw_os_error(22))); // EINVAL
7807        assert!(is_fsync_refused(&Error::new(
7808            ErrorKind::Unsupported,
7809            "nope"
7810        )));
7811
7812        // Genuine I/O failures must still propagate so real corruption/space
7813        // problems are never silently swallowed.
7814        assert!(!is_fsync_refused(&Error::from_raw_os_error(5))); // EIO
7815        assert!(!is_fsync_refused(&Error::from_raw_os_error(28))); // ENOSPC
7816        assert!(!is_fsync_refused(&Error::new(
7817            ErrorKind::PermissionDenied,
7818            "no"
7819        )));
7820    }
7821
7822    #[test]
7823    fn tolerate_fsync_refusal_downgrades_refusals_but_propagates_real_errors() {
7824        use std::io::{Error, ErrorKind};
7825        use std::path::Path;
7826
7827        let p = Path::new("/tmp/does-not-matter");
7828        // Refusals are downgraded to Ok so an already-written file is not
7829        // reported as a failed write.
7830        assert!(tolerate_fsync_refusal(Ok(()), "x", p).is_ok());
7831        assert!(tolerate_fsync_refusal(Err(Error::from_raw_os_error(9)), "temp file", p).is_ok());
7832        // Real I/O errors still surface to the caller.
7833        assert!(tolerate_fsync_refusal(Err(Error::from_raw_os_error(5)), "temp file", p).is_err());
7834        assert!(
7835            tolerate_fsync_refusal(Err(Error::new(ErrorKind::PermissionDenied, "no")), "x", p)
7836                .is_err()
7837        );
7838    }
7839
7840    #[test]
7841    fn test_truncate_head() {
7842        let content = "line1\nline2\nline3\nline4\nline5".to_string();
7843        let result = truncate_head(content, 3, 1000);
7844
7845        assert_eq!(result.content, "line1\nline2\nline3\n");
7846        assert!(result.truncated);
7847        assert_eq!(result.truncated_by, Some(TruncatedBy::Lines));
7848        assert_eq!(result.total_lines, 5);
7849        assert_eq!(result.output_lines, 3);
7850    }
7851
7852    #[test]
7853    fn test_truncate_tail() {
7854        let content = "line1\nline2\nline3\nline4\nline5".to_string();
7855        let result = truncate_tail(content, 3, 1000);
7856
7857        assert_eq!(result.content, "line3\nline4\nline5");
7858        assert!(result.truncated);
7859        assert_eq!(result.truncated_by, Some(TruncatedBy::Lines));
7860        assert_eq!(result.total_lines, 5);
7861        assert_eq!(result.output_lines, 3);
7862    }
7863
7864    fn assert_same_head_truncation(actual: &TruncationResult, expected: &TruncationResult) {
7865        assert_eq!(actual.content, expected.content);
7866        assert_eq!(actual.truncated, expected.truncated);
7867        assert_eq!(actual.truncated_by, expected.truncated_by);
7868        assert_eq!(actual.total_lines, expected.total_lines);
7869        assert_eq!(actual.total_bytes, expected.total_bytes);
7870        assert_eq!(actual.output_lines, expected.output_lines);
7871        assert_eq!(actual.output_bytes, expected.output_bytes);
7872        assert_eq!(actual.last_line_partial, expected.last_line_partial);
7873        assert_eq!(
7874            actual.first_line_exceeds_limit,
7875            expected.first_line_exceeds_limit
7876        );
7877        assert_eq!(actual.max_lines, expected.max_lines);
7878        assert_eq!(actual.max_bytes, expected.max_bytes);
7879    }
7880
7881    fn write_lines_with_builder(lines: &[&str], max_bytes: usize) -> TruncationResult {
7882        let mut writer = HeadTruncatingLineWriter::new(max_bytes);
7883        for line in lines {
7884            writer.push_line(line);
7885        }
7886        writer.finish()
7887    }
7888
7889    #[test]
7890    fn head_truncating_line_writer_matches_join_without_truncation() {
7891        let lines = ["alpha", "beta", "gamma"];
7892        let expected = truncate_head(lines.join("\n"), usize::MAX, 1000);
7893        let actual = write_lines_with_builder(&lines, 1000);
7894
7895        assert_same_head_truncation(&actual, &expected);
7896    }
7897
7898    #[test]
7899    fn head_truncating_line_writer_matches_join_at_byte_boundary() {
7900        let lines = ["alpha", "beta", "gamma"];
7901        let expected = truncate_head(lines.join("\n"), usize::MAX, 8);
7902        let actual = write_lines_with_builder(&lines, 8);
7903
7904        assert_same_head_truncation(&actual, &expected);
7905        assert_eq!(actual.content, "alpha\nbe");
7906    }
7907
7908    #[test]
7909    fn head_truncating_line_writer_preserves_utf8_boundary_and_order() {
7910        let lines = ["alpha", "βeta", "gamma"];
7911        let expected = truncate_head(lines.join("\n"), usize::MAX, 8);
7912        let actual = write_lines_with_builder(&lines, 8);
7913
7914        assert_same_head_truncation(&actual, &expected);
7915        assert_eq!(actual.content, "alpha\nβ");
7916    }
7917
7918    fn first_text(output: &ToolOutput) -> &str {
7919        output
7920            .content
7921            .first()
7922            .and_then(|block| match block {
7923                ContentBlock::Text(text) => Some(text.text.as_str()),
7924                _ => None,
7925            })
7926            .unwrap_or("")
7927    }
7928
7929    fn artifact_json(details: Option<&serde_json::Value>) -> &serde_json::Value {
7930        details
7931            .and_then(|value| value.get("artifact"))
7932            .expect("artifact details")
7933    }
7934
7935    fn artifact_str_field<'a>(artifact: &'a serde_json::Value, field: &str) -> &'a str {
7936        artifact
7937            .get(field)
7938            .and_then(serde_json::Value::as_str)
7939            .unwrap_or("")
7940    }
7941
7942    #[test]
7943    fn tool_output_artifact_respects_spill_threshold() {
7944        let tmp = tempfile::tempdir().expect("artifact root");
7945        let mut output = "small preview".to_string();
7946        let mut details = None;
7947        let spilled = attach_text_artifact_if_needed_at_root(
7948            tmp.path(),
7949            &mut output,
7950            &mut details,
7951            "read",
7952            "call-small",
7953            "selectedTextWindow",
7954            "small body",
7955        );
7956
7957        assert!(!spilled);
7958        assert_eq!(output, "small preview");
7959        assert!(details.is_none());
7960    }
7961
7962    #[test]
7963    fn tool_output_artifact_writes_content_addressed_text_and_metadata()
7964    -> std::result::Result<(), Box<dyn std::error::Error>> {
7965        let tmp = tempfile::tempdir().expect("artifact root");
7966        let full = "a".repeat(TOOL_OUTPUT_ARTIFACT_THRESHOLD_BYTES + 1);
7967        let mut output = "bounded preview".to_string();
7968        let mut details = None;
7969        let _session_guard =
7970            register_tool_output_artifact_session("call/text:1", "session/artifacts:one");
7971        let spilled = attach_text_artifact_if_needed_at_root(
7972            tmp.path(),
7973            &mut output,
7974            &mut details,
7975            "read",
7976            "call/text:1",
7977            "selectedTextWindow",
7978            &full,
7979        );
7980
7981        assert!(spilled);
7982        assert!(output.contains("Full tool output artifact:"));
7983        let artifact = artifact_json(details.as_ref());
7984        assert_eq!(artifact["schema"], TOOL_OUTPUT_ARTIFACT_SCHEMA_V1);
7985        assert_eq!(artifact["toolName"], "read");
7986        assert_eq!(artifact["sourceKind"], "selectedTextWindow");
7987        assert_eq!(artifact["sessionId"], "session/artifacts:one");
7988        assert_eq!(
7989            artifact["byteCount"].as_u64().unwrap(),
7990            u64::try_from(full.len()).unwrap()
7991        );
7992
7993        let path_value = artifact_str_field(artifact, "path");
7994        let metadata_path_value = artifact_str_field(artifact, "metadataPath");
7995        assert!(!path_value.is_empty(), "artifact path must be a string");
7996        assert!(
7997            !metadata_path_value.is_empty(),
7998            "artifact metadataPath must be a string"
7999        );
8000        let path = PathBuf::from(path_value);
8001        let metadata_path = PathBuf::from(metadata_path_value);
8002        assert!(path.starts_with(tmp.path().join("session_artifacts_one").join("call_text_1")));
8003        assert_eq!(std::fs::read_to_string(path)?, full);
8004        let metadata_bytes = std::fs::read(metadata_path)?;
8005        let metadata: serde_json::Value = serde_json::from_slice(&metadata_bytes)?;
8006        assert_eq!(metadata["sha256"], artifact["sha256"]);
8007        assert_eq!(
8008            metadata["retentionClass"],
8009            TOOL_OUTPUT_ARTIFACT_RETENTION_CLASS
8010        );
8011        assert_eq!(
8012            metadata["spilloverReason"],
8013            TOOL_OUTPUT_ARTIFACT_SPILLOVER_REASON
8014        );
8015        assert_eq!(metadata["safeDeleteCandidate"], true);
8016        assert_eq!(
8017            metadata["redactionSummary"]["policy"],
8018            TOOL_OUTPUT_ARTIFACT_REDACTION_POLICY_V1
8019        );
8020        assert_eq!(metadata["redactionSummary"]["status"], "clean");
8021        assert_eq!(metadata["redactionSummary"]["rawSecretBytesEmitted"], 0);
8022        Ok(())
8023    }
8024
8025    #[test]
8026    fn tool_output_artifact_redacts_sensitive_text_before_persisting()
8027    -> std::result::Result<(), Box<dyn std::error::Error>> {
8028        let tmp = tempfile::tempdir().expect("artifact root");
8029        let leaked_token = "sk-redactionfixture1234567890";
8030        let leaked_bearer = "ghp_redactionfixture1234567890";
8031        let full = format!(
8032            "API_TOKEN={leaked_token}\nAuthorization: Bearer {leaked_bearer}\n{}",
8033            "x".repeat(TOOL_OUTPUT_ARTIFACT_THRESHOLD_BYTES + 1)
8034        );
8035        let mut output = "bounded preview".to_string();
8036        let mut details = None;
8037
8038        let spilled = attach_text_artifact_if_needed_at_root(
8039            tmp.path(),
8040            &mut output,
8041            &mut details,
8042            "read",
8043            "call-secret",
8044            "selectedTextWindow",
8045            &full,
8046        );
8047
8048        assert!(spilled);
8049        let artifact = artifact_json(details.as_ref());
8050        let path = PathBuf::from(artifact_str_field(artifact, "path"));
8051        let metadata_path = PathBuf::from(artifact_str_field(artifact, "metadataPath"));
8052        let persisted = std::fs::read_to_string(path)?;
8053        let metadata: serde_json::Value = serde_json::from_slice(&std::fs::read(metadata_path)?)?;
8054
8055        assert!(!persisted.contains(leaked_token));
8056        assert!(!persisted.contains(leaked_bearer));
8057        assert!(persisted.contains("API_TOKEN=[REDACTED]"));
8058        assert_eq!(artifact["redactionSummary"]["status"], "redacted");
8059        assert_eq!(artifact["redactionSummary"]["rawSecretBytesEmitted"], 0);
8060        assert_eq!(metadata["redactionSummary"], artifact["redactionSummary"]);
8061        let fields = artifact["redactionSummary"]["fields"]
8062            .as_array()
8063            .expect("redaction fields");
8064        assert!(fields.iter().any(|field| field == "api_token"));
8065        assert!(fields.iter().any(|field| field == "authorization"));
8066        Ok(())
8067    }
8068
8069    #[test]
8070    fn tool_output_artifact_marks_binaryish_payloads_in_lifecycle_manifest() {
8071        let tmp = tempfile::tempdir().expect("artifact root");
8072        let full = format!(
8073            "{}\0{}",
8074            "z".repeat(TOOL_OUTPUT_ARTIFACT_THRESHOLD_BYTES / 2),
8075            "z".repeat(TOOL_OUTPUT_ARTIFACT_THRESHOLD_BYTES / 2 + 2)
8076        );
8077        let mut output = "bounded preview".to_string();
8078        let mut details = None;
8079
8080        let spilled = attach_text_artifact_if_needed_at_root(
8081            tmp.path(),
8082            &mut output,
8083            &mut details,
8084            "read",
8085            "call-binaryish",
8086            "selectedTextWindow",
8087            &full,
8088        );
8089
8090        assert!(spilled);
8091        let artifact = artifact_json(details.as_ref());
8092        assert_eq!(artifact["redactionSummary"]["binarySuspect"], true);
8093        assert_eq!(artifact["redactionSummary"]["rawSecretBytesEmitted"], 0);
8094        assert_eq!(artifact["safeDeleteCandidate"], true);
8095    }
8096
8097    #[test]
8098    fn tool_output_artifact_failure_records_degraded_preview() {
8099        let tmp = tempfile::tempdir().expect("artifact root parent");
8100        let root_file = tmp.path().join("not-a-directory");
8101        std::fs::write(&root_file, "not a directory").expect("root file");
8102        let full = "b".repeat(TOOL_OUTPUT_ARTIFACT_THRESHOLD_BYTES + 1);
8103        let mut output = "bounded preview".to_string();
8104        let mut details = None;
8105
8106        let spilled = attach_text_artifact_if_needed_at_root(
8107            &root_file,
8108            &mut output,
8109            &mut details,
8110            "read",
8111            "call-fail",
8112            "selectedTextWindow",
8113            &full,
8114        );
8115
8116        assert!(!spilled);
8117        assert!(output.contains("Tool output artifact persistence failed"));
8118        assert!(
8119            details
8120                .as_ref()
8121                .and_then(|value| value.get("artifactError"))
8122                .is_some()
8123        );
8124    }
8125
8126    #[test]
8127    fn read_tool_spills_oversized_selected_text_window_to_artifact() {
8128        asupersync::test_utils::run_test(|| async {
8129            let tmp = tempfile::tempdir().expect("workspace");
8130            let artifact_root = tempfile::tempdir().expect("artifact root");
8131
8132            let body = "r".repeat(TOOL_OUTPUT_ARTIFACT_THRESHOLD_BYTES + 8);
8133            std::fs::write(tmp.path().join("large.txt"), &body).expect("large file");
8134            let read_tool = ReadTool::with_artifact_root(tmp.path(), artifact_root.path());
8135            let output = read_tool
8136                .execute(
8137                    "read-artifact-call",
8138                    serde_json::json!({ "path": "large.txt" }),
8139                    None,
8140                )
8141                .await
8142                .expect("read large file");
8143
8144            assert!(first_text(&output).contains("Full tool output artifact:"));
8145            let artifact = artifact_json(output.details.as_ref());
8146            assert_eq!(artifact["toolName"], "read");
8147            assert_eq!(artifact["sourceKind"], "selectedTextWindow");
8148            let path_value = artifact_str_field(artifact, "path");
8149            assert!(!path_value.is_empty(), "artifact path must be a string");
8150            let path = PathBuf::from(path_value);
8151            let spilled = match std::fs::read_to_string(&path) {
8152                Ok(spilled) => spilled,
8153                Err(err) => {
8154                    assert!(false, "read spilled artifact {}: {err}", path.display());
8155                    return;
8156                }
8157            };
8158            let prefix = "    1→";
8159            assert_eq!(spilled.len(), prefix.len() + DEFAULT_MAX_BYTES);
8160            assert_eq!(
8161                artifact["byteCount"].as_u64().unwrap(),
8162                u64::try_from(spilled.len()).unwrap()
8163            );
8164            assert!(spilled.starts_with(prefix));
8165            assert!(spilled[prefix.len()..].bytes().all(|byte| byte == b'r'));
8166            assert_eq!(
8167                artifact["retentionClass"],
8168                TOOL_OUTPUT_ARTIFACT_RETENTION_CLASS
8169            );
8170            assert_eq!(
8171                artifact["spilloverReason"],
8172                TOOL_OUTPUT_ARTIFACT_SPILLOVER_REASON
8173            );
8174            assert_eq!(artifact["safeDeleteCandidate"], true);
8175        });
8176    }
8177
8178    #[test]
8179    fn bash_tool_spills_truncated_full_output_to_artifact() {
8180        asupersync::test_utils::run_test(|| async {
8181            if !Path::new("/dev/zero").exists() {
8182                return;
8183            }
8184
8185            let tmp = tempfile::tempdir().expect("workspace");
8186            let artifact_root = tempfile::tempdir().expect("artifact root");
8187
8188            let bash_tool = BashTool::with_artifact_root(tmp.path(), artifact_root.path());
8189            let output = bash_tool
8190                .execute(
8191                    "bash-artifact-call",
8192                    serde_json::json!({
8193                        "command": "head -c 1001000 /dev/zero | tr '\\0' x",
8194                        "timeout": 10
8195                    }),
8196                    None,
8197                )
8198                .await
8199                .expect("bash large output");
8200
8201            assert!(first_text(&output).contains("Full tool output artifact:"));
8202            let artifact = artifact_json(output.details.as_ref());
8203            assert_eq!(artifact["toolName"], "bash");
8204            assert_eq!(artifact["sourceKind"], "fullCommandOutput");
8205            let path = PathBuf::from(artifact_str_field(artifact, "path"));
8206            assert_eq!(std::fs::metadata(path).unwrap().len(), 1_001_000);
8207            assert_eq!(artifact["redactionSummary"]["status"], "clean");
8208            assert_eq!(artifact["safeDeleteCandidate"], true);
8209        });
8210    }
8211
8212    #[test]
8213    fn bash_tool_redacts_secret_like_full_output_artifacts() {
8214        asupersync::test_utils::run_test(|| async {
8215            if !Path::new("/dev/zero").exists() {
8216                return;
8217            }
8218
8219            let tmp = tempfile::tempdir().expect("workspace");
8220            let artifact_root = tempfile::tempdir().expect("artifact root");
8221            let leaked_token = "sk-bashredactionfixture1234567890";
8222
8223            let bash_tool = BashTool::with_artifact_root(tmp.path(), artifact_root.path());
8224            let output = bash_tool
8225                .execute(
8226                    "bash-secret-artifact-call",
8227                    serde_json::json!({
8228                        "command": format!("printf 'API_TOKEN={leaked_token}\\n'; head -c 1001000 /dev/zero | tr '\\0' x"),
8229                        "timeout": 10
8230                    }),
8231                    None,
8232                )
8233                .await
8234                .expect("bash large output");
8235
8236            assert!(first_text(&output).contains("Full tool output artifact:"));
8237            let artifact = artifact_json(output.details.as_ref());
8238            assert_eq!(artifact["toolName"], "bash");
8239            assert_eq!(artifact["redactionSummary"]["status"], "redacted");
8240            assert_eq!(artifact["redactionSummary"]["rawSecretBytesEmitted"], 0);
8241            let path = PathBuf::from(artifact_str_field(artifact, "path"));
8242            let persisted = std::fs::read_to_string(path).expect("read redacted bash artifact");
8243            assert!(!persisted.contains(leaked_token));
8244            assert!(persisted.contains("API_TOKEN=[REDACTED]"));
8245        });
8246    }
8247
8248    #[test]
8249    fn grep_tool_spills_large_search_results_with_lifecycle_manifest() {
8250        asupersync::test_utils::run_test(|| async {
8251            if !rg_available() {
8252                return;
8253            }
8254
8255            let tmp = tempfile::tempdir().expect("workspace");
8256            let artifact_root = tempfile::tempdir().expect("artifact root");
8257            let mut body = String::new();
8258            let suffix = "g".repeat(560);
8259            for idx in 0..2200 {
8260                let _ = writeln!(body, "target {idx:04} {suffix}");
8261            }
8262            std::fs::write(tmp.path().join("large-grep.txt"), body).expect("write grep fixture");
8263
8264            let grep_tool = GrepTool::with_artifact_root(tmp.path(), artifact_root.path());
8265            let output = grep_tool
8266                .execute(
8267                    "grep-artifact-call",
8268                    serde_json::json!({
8269                        "pattern": "target",
8270                        "path": "large-grep.txt",
8271                        "literal": true,
8272                        "limit": 2200
8273                    }),
8274                    None,
8275                )
8276                .await
8277                .expect("grep large output");
8278
8279            assert!(first_text(&output).contains("Full tool output artifact:"));
8280            let artifact = artifact_json(output.details.as_ref());
8281            assert_eq!(artifact["toolName"], "grep");
8282            assert_eq!(artifact["sourceKind"], "searchResults");
8283            assert_eq!(
8284                artifact["retentionClass"],
8285                TOOL_OUTPUT_ARTIFACT_RETENTION_CLASS
8286            );
8287            assert_eq!(artifact["safeDeleteCandidate"], true);
8288            assert_eq!(artifact["redactionSummary"]["status"], "clean");
8289            let path = PathBuf::from(artifact_str_field(artifact, "path"));
8290            let persisted = std::fs::read_to_string(path).expect("read grep artifact");
8291            assert!(persisted.contains("large-grep.txt:1: target 0000"));
8292            assert!(
8293                artifact["byteCount"].as_u64().unwrap()
8294                    > u64::try_from(TOOL_OUTPUT_ARTIFACT_THRESHOLD_BYTES).unwrap()
8295            );
8296        });
8297    }
8298
8299    #[test]
8300    fn read_tool_denied_path_does_not_emit_lifecycle_artifact() {
8301        asupersync::test_utils::run_test(|| async {
8302            let cwd = tempfile::tempdir().expect("workspace");
8303            let outside = tempfile::tempdir().expect("outside");
8304            let artifact_root = tempfile::tempdir().expect("artifact root");
8305            let outside_path = outside.path().join("secret.txt");
8306            std::fs::write(&outside_path, "API_TOKEN=sk-deniedpathfixture1234567890")
8307                .expect("outside secret");
8308
8309            let read_tool = ReadTool::with_artifact_root(cwd.path(), artifact_root.path());
8310            let err = read_tool
8311                .execute(
8312                    "read-denied-artifact-call",
8313                    serde_json::json!({ "path": outside_path }),
8314                    None,
8315                )
8316                .await
8317                .expect_err("outside read should be denied");
8318
8319            assert!(
8320                err.to_string()
8321                    .contains("Cannot read outside the working directory or agent dir")
8322            );
8323            let mut entries = std::fs::read_dir(artifact_root.path()).expect("artifact root");
8324            assert!(
8325                entries.next().is_none(),
8326                "denied reads must not write artifacts"
8327            );
8328        });
8329    }
8330
8331    #[test]
8332    fn ls_tool_spills_oversized_directory_listing_to_artifact() {
8333        asupersync::test_utils::run_test(|| async {
8334            let tmp = tempfile::tempdir().expect("workspace");
8335            let artifact_root = tempfile::tempdir().expect("artifact root");
8336            let suffix = "x".repeat(224);
8337            for i in 0..4_500 {
8338                let name = format!("entry-{i:04}-{suffix}.txt");
8339                std::fs::write(tmp.path().join(name), "").expect("write listing fixture");
8340            }
8341
8342            let ls_tool = LsTool::with_artifact_root(tmp.path(), artifact_root.path());
8343            let output = ls_tool
8344                .execute(
8345                    "ls-artifact-call",
8346                    serde_json::json!({ "path": ".", "limit": 4500 }),
8347                    None,
8348                )
8349                .await
8350                .expect("ls large directory");
8351
8352            assert!(first_text(&output).contains("Full tool output artifact:"));
8353            let artifact = artifact_json(output.details.as_ref());
8354            assert_eq!(artifact["toolName"], "ls");
8355            assert_eq!(artifact["sourceKind"], "directoryEntries");
8356            assert!(
8357                artifact["byteCount"].as_u64().unwrap()
8358                    > u64::try_from(TOOL_OUTPUT_ARTIFACT_THRESHOLD_BYTES).unwrap()
8359            );
8360            let path = PathBuf::from(artifact_str_field(artifact, "path"));
8361            assert!(
8362                std::fs::read_to_string(path)
8363                    .unwrap()
8364                    .contains("entry-0000-")
8365            );
8366        });
8367    }
8368
8369    async fn assert_read_cache_hit_and_stale(tmp: &Path) {
8370        let note = tmp.join("note.txt");
8371        std::fs::write(&note, "alpha\n").expect("write note");
8372
8373        let read_tool = ReadTool::new(tmp);
8374        let read_input = serde_json::json!({ "path": "note.txt" });
8375        let first = read_tool
8376            .execute("read-1", read_input.clone(), None)
8377            .await
8378            .expect("first read");
8379        assert!(first_text(&first).contains("alpha"));
8380
8381        let hits_before = tool_output_cache_stats_for_tests().hits;
8382        let second = read_tool
8383            .execute("read-2", read_input.clone(), None)
8384            .await
8385            .expect("cached read");
8386        assert_eq!(first_text(&first), first_text(&second));
8387        assert!(tool_output_cache_stats_for_tests().hits > hits_before);
8388
8389        let invalidations_before = tool_output_cache_stats_for_tests().invalidations;
8390        std::fs::write(&note, "beta\n").expect("rewrite note");
8391        let third = read_tool
8392            .execute("read-3", read_input.clone(), None)
8393            .await
8394            .expect("invalidated read");
8395        assert!(first_text(&third).contains("beta"));
8396        assert!(!first_text(&third).contains("alpha"));
8397        assert!(tool_output_cache_stats_for_tests().invalidations > invalidations_before);
8398    }
8399
8400    async fn assert_ls_cache_hit_and_stale(tmp: &Path) {
8401        let ls_tool = LsTool::new(tmp);
8402        let ls_input = serde_json::json!({ "path": "." });
8403        let ls_first = ls_tool
8404            .execute("ls-1", ls_input.clone(), None)
8405            .await
8406            .expect("first ls");
8407        assert!(first_text(&ls_first).contains("note.txt"));
8408
8409        let hits_before = tool_output_cache_stats_for_tests().hits;
8410        let ls_second = ls_tool
8411            .execute("ls-2", ls_input.clone(), None)
8412            .await
8413            .expect("cached ls");
8414        assert_eq!(first_text(&ls_first), first_text(&ls_second));
8415        assert!(tool_output_cache_stats_for_tests().hits > hits_before);
8416
8417        let invalidations_before = tool_output_cache_stats_for_tests().invalidations;
8418        std::fs::write(tmp.join("new.txt"), "new\n").expect("write new file");
8419        let ls_third = ls_tool
8420            .execute("ls-3", ls_input.clone(), None)
8421            .await
8422            .expect("invalidated ls");
8423        assert!(first_text(&ls_third).contains("new.txt"));
8424        assert!(tool_output_cache_stats_for_tests().invalidations > invalidations_before);
8425    }
8426
8427    async fn assert_grep_cache_hit_and_stale_when_available(tmp: &Path) {
8428        if find_rg_binary().is_none() {
8429            return;
8430        }
8431
8432        let grep_tool = GrepTool::new(tmp);
8433        let grep_input = serde_json::json!({ "pattern": "needle", "path": "." });
8434        std::fs::write(tmp.join("a.txt"), "needle\n").expect("write grep file");
8435
8436        let grep_first = grep_tool
8437            .execute("grep-1", grep_input.clone(), None)
8438            .await
8439            .expect("first grep");
8440        assert!(first_text(&grep_first).contains("a.txt"));
8441
8442        let hits_before = tool_output_cache_stats_for_tests().hits;
8443        let grep_second = grep_tool
8444            .execute("grep-2", grep_input.clone(), None)
8445            .await
8446            .expect("cached grep");
8447        assert_eq!(first_text(&grep_first), first_text(&grep_second));
8448        assert!(tool_output_cache_stats_for_tests().hits > hits_before);
8449
8450        let invalidations_before = tool_output_cache_stats_for_tests().invalidations;
8451        std::fs::write(tmp.join("b.txt"), "needle\n").expect("write new match");
8452        let grep_third = grep_tool
8453            .execute("grep-3", grep_input.clone(), None)
8454            .await
8455            .expect("invalidated grep");
8456        assert!(first_text(&grep_third).contains("b.txt"));
8457        assert!(tool_output_cache_stats_for_tests().invalidations > invalidations_before);
8458    }
8459
8460    async fn assert_find_cache_hit_and_stale_when_available(tmp: &Path) {
8461        if find_fd_binary().is_none() {
8462            return;
8463        }
8464
8465        let find_tool = FindTool::new(tmp);
8466        let find_input = serde_json::json!({ "pattern": "*find*.txt", "path": "." });
8467        std::fs::write(tmp.join("find-a.txt"), "find\n").expect("write first find file");
8468
8469        let find_first = find_tool
8470            .execute("find-1", find_input.clone(), None)
8471            .await
8472            .expect("first find");
8473        assert!(first_text(&find_first).contains("find-a.txt"));
8474
8475        let hits_before = tool_output_cache_stats_for_tests().hits;
8476        let find_second = find_tool
8477            .execute("find-2", find_input.clone(), None)
8478            .await
8479            .expect("cached find");
8480        assert_eq!(first_text(&find_first), first_text(&find_second));
8481        assert!(tool_output_cache_stats_for_tests().hits > hits_before);
8482
8483        let invalidations_before = tool_output_cache_stats_for_tests().invalidations;
8484        std::fs::write(tmp.join("find-b.txt"), "find\n").expect("write second find file");
8485        let find_third = find_tool
8486            .execute("find-3", find_input.clone(), None)
8487            .await
8488            .expect("invalidated find");
8489        assert!(first_text(&find_third).contains("find-b.txt"));
8490        assert!(tool_output_cache_stats_for_tests().invalidations > invalidations_before);
8491    }
8492
8493    async fn assert_side_effect_tools_remain_uncached(tmp: &Path) {
8494        let side_effect_stats_before = tool_output_cache_stats_for_tests();
8495        let write_tool = WriteTool::new(tmp);
8496        write_tool
8497            .execute(
8498                "write-1",
8499                serde_json::json!({
8500                    "path": "side-effect.txt",
8501                    "content": "one\n"
8502                }),
8503                None,
8504            )
8505            .await
8506            .expect("write side-effect file");
8507
8508        let edit_tool = EditTool::new(tmp);
8509        edit_tool
8510            .execute(
8511                "edit-1",
8512                serde_json::json!({
8513                    "path": "side-effect.txt",
8514                    "oldText": "one",
8515                    "newText": "two"
8516                }),
8517                None,
8518            )
8519            .await
8520            .expect("edit side-effect file");
8521
8522        let bash_tool = BashTool::new(tmp);
8523        bash_tool
8524            .execute(
8525                "bash-1",
8526                serde_json::json!({
8527                    "command": "printf 'cache-uncached\\n'",
8528                    "timeout": 5
8529                }),
8530                None,
8531            )
8532            .await
8533            .expect("run uncached bash");
8534
8535        let side_effect_stats_after = tool_output_cache_stats_for_tests();
8536        assert_eq!(
8537            (
8538                side_effect_stats_after.side_effect_accesses,
8539                side_effect_stats_after.side_effect_insert_attempts
8540            ),
8541            (
8542                side_effect_stats_before.side_effect_accesses,
8543                side_effect_stats_before.side_effect_insert_attempts
8544            ),
8545            "write, edit, and bash must not consult or populate the read-only output cache"
8546        );
8547    }
8548
8549    #[test]
8550    fn tool_output_cache_reuses_and_invalidates_read_only_tool_outputs() {
8551        asupersync::test_utils::run_test(|| async {
8552            reset_tool_output_cache_for_tests();
8553
8554            let tmp = tempfile::tempdir().expect("create temp dir");
8555            assert_read_cache_hit_and_stale(tmp.path()).await;
8556            assert_ls_cache_hit_and_stale(tmp.path()).await;
8557            assert_grep_cache_hit_and_stale_when_available(tmp.path()).await;
8558            assert_find_cache_hit_and_stale_when_available(tmp.path()).await;
8559            assert_side_effect_tools_remain_uncached(tmp.path()).await;
8560        });
8561    }
8562
8563    #[test]
8564    fn tool_output_context_cache_evidence_jsonl_covers_required_decisions()
8565    -> std::result::Result<(), String> {
8566        let evidence = include_str!("../docs/evidence/tool-output-context-cache.jsonl");
8567        let mut saw_read_hit = false;
8568        let mut saw_grep_stale = false;
8569        let mut saw_find_stale = false;
8570        let mut saw_ls_stale = false;
8571        let mut saw_write_uncached = false;
8572        let mut saw_edit_uncached = false;
8573        let mut saw_bash_uncached = false;
8574
8575        for (line_number, line) in evidence.lines().enumerate() {
8576            if line.trim().is_empty() {
8577                continue;
8578            }
8579
8580            let event: serde_json::Value = serde_json::from_str(line).map_err(|err| {
8581                format!(
8582                    "invalid context-cache JSONL at line {}: {err}",
8583                    line_number + 1
8584                )
8585            })?;
8586            assert_eq!(
8587                event.get("schema").and_then(serde_json::Value::as_str),
8588                Some("pi.tool_output_context_cache.evidence.v1")
8589            );
8590            assert_eq!(
8591                event.get("bead").and_then(serde_json::Value::as_str),
8592                Some("bd-dklqn.1")
8593            );
8594            let related_beads = event
8595                .get("related_beads")
8596                .and_then(serde_json::Value::as_array)
8597                .ok_or_else(|| format!("missing related_beads at line {}", line_number + 1))?;
8598            assert!(
8599                related_beads
8600                    .iter()
8601                    .any(|bead| bead.as_str() == Some("bd-dklqn.2")),
8602                "evidence line {} must cover bd-dklqn.2",
8603                line_number + 1
8604            );
8605
8606            let tool = event
8607                .get("tool")
8608                .and_then(serde_json::Value::as_str)
8609                .expect("tool");
8610            let outcome = event
8611                .get("outcome")
8612                .and_then(serde_json::Value::as_str)
8613                .expect("outcome");
8614            let reason = event
8615                .get("reason")
8616                .and_then(serde_json::Value::as_str)
8617                .expect("reason");
8618
8619            match (tool, outcome, reason) {
8620                ("read", "hit", "unchanged_file_fingerprint") => saw_read_hit = true,
8621                ("grep", "stale", "recursive_directory_fingerprint_changed") => {
8622                    saw_grep_stale = true;
8623                }
8624                ("find", "stale", "recursive_directory_fingerprint_changed") => {
8625                    saw_find_stale = true;
8626                }
8627                ("ls", "stale", "directory_entry_fingerprint_changed") => saw_ls_stale = true,
8628                ("write", "uncached", "write_effect_tool") => saw_write_uncached = true,
8629                ("edit", "uncached", "write_effect_tool") => saw_edit_uncached = true,
8630                ("bash", "uncached", "process_effect_tool") => saw_bash_uncached = true,
8631                _ => {}
8632            }
8633        }
8634
8635        assert!(saw_read_hit, "evidence must include a read cache hit");
8636        assert!(saw_grep_stale, "evidence must include grep stale bypass");
8637        assert!(saw_find_stale, "evidence must include find stale bypass");
8638        assert!(saw_ls_stale, "evidence must include ls stale bypass");
8639        assert!(saw_write_uncached, "evidence must include write uncached");
8640        assert!(saw_edit_uncached, "evidence must include edit uncached");
8641        assert!(saw_bash_uncached, "evidence must include bash uncached");
8642        Ok(())
8643    }
8644
8645    #[test]
8646    fn test_truncate_tail_zero_lines_returns_empty_output() {
8647        let result = truncate_tail("line1\nline2".to_string(), 0, 1000);
8648
8649        assert!(result.truncated);
8650        assert_eq!(result.truncated_by, Some(TruncatedBy::Lines));
8651        assert_eq!(result.output_lines, 0);
8652        assert_eq!(result.output_bytes, 0);
8653        assert!(result.content.is_empty());
8654    }
8655
8656    #[test]
8657    fn test_line_count_from_newline_count_matches_trailing_newline_semantics() {
8658        assert_eq!(line_count_from_newline_count(0, 0, false), 0);
8659        assert_eq!(line_count_from_newline_count(2, 1, true), 1);
8660        assert_eq!(line_count_from_newline_count(1, 0, false), 1);
8661        assert_eq!(line_count_from_newline_count(3, 1, false), 2);
8662    }
8663
8664    #[test]
8665    fn test_rg_match_requires_path_and_line_number() {
8666        let mut matches = Vec::new();
8667        let mut match_count = 0usize;
8668        let mut match_limit_reached = false;
8669        let scan_limit = 1;
8670
8671        let missing_line =
8672            Ok(r#"{"type":"match","data":{"path":{"text":"file.txt"}}}"#.to_string());
8673        process_rg_json_match_line(
8674            missing_line,
8675            &mut matches,
8676            &mut match_count,
8677            &mut match_limit_reached,
8678            scan_limit,
8679        );
8680        assert!(matches.is_empty());
8681        assert_eq!(match_count, 0);
8682        assert!(!match_limit_reached);
8683
8684        let valid_line = Ok(
8685            r#"{"type":"match","data":{"path":{"text":"file.txt"},"line_number":3}}"#.to_string(),
8686        );
8687        process_rg_json_match_line(
8688            valid_line,
8689            &mut matches,
8690            &mut match_count,
8691            &mut match_limit_reached,
8692            scan_limit,
8693        );
8694        assert_eq!(matches.len(), 1);
8695        assert_eq!(matches[0].1, 3);
8696        assert_eq!(match_count, 1);
8697        assert!(match_limit_reached);
8698    }
8699
8700    #[test]
8701    fn test_truncate_by_bytes() {
8702        let content = "short\nthis is a longer line\nanother".to_string();
8703        let result = truncate_head(content, 100, 15);
8704
8705        assert!(result.truncated);
8706        assert_eq!(result.truncated_by, Some(TruncatedBy::Bytes));
8707    }
8708
8709    #[cfg(any(target_os = "linux", target_os = "freebsd"))]
8710    #[test]
8711    fn test_command_with_default_sigpipe_restores_pipe_disposition() {
8712        // Verify the spawned child does NOT inherit the parent's
8713        // SIGPIPE=SIG_IGN. The probe parses the SigIgn: hex mask exposed by
8714        // Linux-format /proc/<pid>/status — available natively on Linux and,
8715        // on FreeBSD, through the linprocfs compat module mounted at
8716        // /compat/linux/proc. Skip with a one-line notice when linprocfs is
8717        // not mounted rather than failing the test.
8718        #[cfg(target_os = "freebsd")]
8719        let status_dir = {
8720            let probe = format!("/compat/linux/proc/{}/status", std::process::id());
8721            if !std::path::Path::new(&probe).exists() {
8722                eprintln!(
8723                    "skipping sigpipe disposition test: linprocfs not mounted \
8724                     at /compat/linux/proc — add `linprocfs /compat/linux/proc \
8725                     linprocfs rw 0 0` to /etc/fstab and `mount /compat/linux/proc` \
8726                     to enable"
8727                );
8728                return;
8729            }
8730            "/compat/linux/proc"
8731        };
8732        #[cfg(not(target_os = "freebsd"))]
8733        let status_dir = "/proc";
8734
8735        let probe_cmd = format!(
8736            "while read name value _; do [ \"$name\" = SigIgn: ] && \
8737             {{ printf '%s' \"$value\"; exit 0; }}; done < {status_dir}/$$/status"
8738        );
8739
8740        let output = command_with_default_sigpipe("sh")
8741            .expect("prepare sigpipe disposition probe")
8742            .args(["-c", &probe_cmd])
8743            .stdout(std::process::Stdio::piped())
8744            .output()
8745            .expect("spawn sigpipe disposition probe");
8746
8747        assert!(output.status.success(), "probe failed: {output:?}");
8748        let sigign = String::from_utf8(output.stdout).expect("SigIgn should be utf8");
8749        let ignored_mask =
8750            u64::from_str_radix(sigign.trim(), 16).expect("SigIgn should be a hex mask");
8751        let sigpipe_bit = 1_u64 << (13 - 1);
8752        assert_eq!(
8753            ignored_mask & sigpipe_bit,
8754            0,
8755            "child should not inherit ignored SIGPIPE: SigIgn={sigign}"
8756        );
8757    }
8758
8759    #[cfg(unix)]
8760    #[test]
8761    fn test_command_with_default_sigpipe_in_dir_resolves_relative_program_after_cwd() {
8762        use std::os::unix::fs::PermissionsExt as _;
8763
8764        let tmp = tempfile::tempdir().expect("create temp dir");
8765        let script = tmp.path().join("relative-probe");
8766        std::fs::write(&script, "#!/bin/sh\nprintf cwd-relative-ok\n").expect("write script");
8767        let mut permissions = std::fs::metadata(&script)
8768            .expect("stat script")
8769            .permissions();
8770        permissions.set_mode(0o755);
8771        std::fs::set_permissions(&script, permissions).expect("make script executable");
8772
8773        let output = command_with_default_sigpipe_in_dir("./relative-probe", tmp.path())
8774            .expect("prepare relative executable")
8775            .current_dir(tmp.path())
8776            .stdout(std::process::Stdio::piped())
8777            .output()
8778            .expect("spawn relative executable");
8779
8780        assert!(output.status.success(), "probe failed: {output:?}");
8781        assert_eq!(
8782            String::from_utf8(output.stdout).expect("probe stdout should be utf8"),
8783            "cwd-relative-ok"
8784        );
8785    }
8786
8787    #[cfg(target_os = "linux")]
8788    #[test]
8789    fn test_read_to_end_capped_and_drain_preserves_writer_exit_status() {
8790        let mut child = std::process::Command::new("dd")
8791            .args(["if=/dev/zero", "bs=1", "count=70000", "status=none"])
8792            .stdout(std::process::Stdio::piped())
8793            .spawn()
8794            .expect("spawn dd");
8795
8796        let stdout = child.stdout.take().expect("dd stdout");
8797        let captured = read_to_end_capped_and_drain(stdout, 1024).expect("capture bounded stdout");
8798        let status = child.wait().expect("wait for dd");
8799
8800        assert!(
8801            status.success(),
8802            "bounded reader should drain to EOF instead of SIGPIPEing the writer: {status:?}"
8803        );
8804        assert_eq!(captured.len(), 1025);
8805    }
8806
8807    #[cfg(unix)]
8808    #[test]
8809    fn test_get_file_lines_async_unreadable_file_returns_empty() {
8810        asupersync::test_utils::run_test(|| async {
8811            use std::os::unix::fs::PermissionsExt;
8812
8813            let tmp = tempfile::tempdir().unwrap();
8814            let path = tmp.path().join("secret.txt");
8815            std::fs::write(&path, "secret\n").unwrap();
8816
8817            let mut perms = std::fs::metadata(&path).unwrap().permissions();
8818            perms.set_mode(0o000);
8819            std::fs::set_permissions(&path, perms).unwrap();
8820
8821            let mut cache = HashMap::new();
8822            let lines = get_file_lines_async(&path, &mut cache).await;
8823            assert!(lines.is_empty());
8824        });
8825    }
8826
8827    #[test]
8828    fn test_resolve_path_absolute() {
8829        let cwd = PathBuf::from("/home/user/project");
8830        let result = resolve_path("/absolute/path", &cwd);
8831        assert_eq!(result, PathBuf::from("/absolute/path"));
8832    }
8833
8834    #[test]
8835    fn test_resolve_path_relative() {
8836        let cwd = PathBuf::from("/home/user/project");
8837        let result = resolve_path("src/main.rs", &cwd);
8838        assert_eq!(result, PathBuf::from("/home/user/project/src/main.rs"));
8839    }
8840
8841    #[test]
8842    fn test_normalize_dot_segments_preserves_root() {
8843        let result = normalize_dot_segments(std::path::Path::new("/../etc/passwd"));
8844        assert_eq!(result, PathBuf::from("/etc/passwd"));
8845    }
8846
8847    #[test]
8848    fn test_normalize_dot_segments_preserves_leading_parent_for_relative() {
8849        let result = normalize_dot_segments(std::path::Path::new("../a/../b"));
8850        assert_eq!(result, PathBuf::from("../b"));
8851    }
8852
8853    #[test]
8854    fn test_detect_supported_image_mime_type_from_bytes() {
8855        assert_eq!(
8856            detect_supported_image_mime_type_from_bytes(b"\x89PNG\r\n\x1A\n"),
8857            Some("image/png")
8858        );
8859        assert_eq!(
8860            detect_supported_image_mime_type_from_bytes(b"\xFF\xD8\xFF"),
8861            Some("image/jpeg")
8862        );
8863        assert_eq!(
8864            detect_supported_image_mime_type_from_bytes(b"GIF89a"),
8865            Some("image/gif")
8866        );
8867        assert_eq!(
8868            detect_supported_image_mime_type_from_bytes(b"RIFF1234WEBP"),
8869            Some("image/webp")
8870        );
8871        assert_eq!(
8872            detect_supported_image_mime_type_from_bytes(b"not an image"),
8873            None
8874        );
8875    }
8876
8877    #[test]
8878    fn test_format_size() {
8879        assert_eq!(format_size(500), "500B");
8880        assert_eq!(format_size(1024), "1.0KB");
8881        assert_eq!(format_size(1536), "1.5KB");
8882        assert_eq!(format_size(1_048_576), "1.0MB");
8883        assert_eq!(format_size(1_073_741_824), "1024.0MB");
8884    }
8885
8886    #[test]
8887    fn test_js_string_length() {
8888        assert_eq!(js_string_length("hello"), 5);
8889        assert_eq!(js_string_length("😀"), 2);
8890    }
8891
8892    #[test]
8893    fn test_truncate_line() {
8894        let short = "short line";
8895        let result = truncate_line(short, 100);
8896        assert_eq!(result.text, "short line");
8897        assert!(!result.was_truncated);
8898
8899        let long = "a".repeat(600);
8900        let result = truncate_line(&long, 500);
8901        assert!(result.was_truncated);
8902        assert!(result.text.ends_with("... [truncated]"));
8903    }
8904
8905    // ========================================================================
8906    // Helper: extract text from ToolOutput content blocks
8907    // ========================================================================
8908
8909    fn get_text(content: &[ContentBlock]) -> String {
8910        content
8911            .iter()
8912            .filter_map(|block| {
8913                if let ContentBlock::Text(text) = block {
8914                    Some(text.text.clone())
8915                } else {
8916                    None
8917                }
8918            })
8919            .collect::<String>()
8920    }
8921
8922    // ========================================================================
8923    // Read Tool Tests
8924    // ========================================================================
8925
8926    #[test]
8927    fn test_read_valid_file() {
8928        asupersync::test_utils::run_test(|| async {
8929            let tmp = tempfile::tempdir().unwrap();
8930            std::fs::write(tmp.path().join("hello.txt"), "alpha\nbeta\ngamma").unwrap();
8931
8932            let tool = ReadTool::new(tmp.path());
8933            let out = tool
8934                .execute(
8935                    "t",
8936                    serde_json::json!({ "path": tmp.path().join("hello.txt").to_string_lossy() }),
8937                    None,
8938                )
8939                .await
8940                .unwrap();
8941            let text = get_text(&out.content);
8942            assert!(text.contains("alpha"));
8943            assert!(text.contains("beta"));
8944            assert!(text.contains("gamma"));
8945            assert!(!out.is_error);
8946        });
8947    }
8948
8949    #[test]
8950    fn test_read_nonexistent_file() {
8951        asupersync::test_utils::run_test(|| async {
8952            let tmp = tempfile::tempdir().unwrap();
8953            let tool = ReadTool::new(tmp.path());
8954            let err = tool
8955                .execute(
8956                    "t",
8957                    serde_json::json!({ "path": tmp.path().join("nope.txt").to_string_lossy() }),
8958                    None,
8959                )
8960                .await;
8961            assert!(err.is_err());
8962        });
8963    }
8964
8965    #[test]
8966    fn test_read_rejects_outside_cwd() {
8967        asupersync::test_utils::run_test(|| async {
8968            let cwd = tempfile::tempdir().unwrap();
8969            let outside = tempfile::tempdir().unwrap();
8970            std::fs::write(outside.path().join("secret.txt"), "secret").unwrap();
8971
8972            let tool = ReadTool::new(cwd.path());
8973            let err = tool
8974                .execute(
8975                    "t",
8976                    serde_json::json!({ "path": outside.path().join("secret.txt").to_string_lossy() }),
8977                    None,
8978                )
8979                .await
8980                .unwrap_err();
8981            assert!(err.to_string().contains("outside the working directory"));
8982        });
8983    }
8984
8985    /// Issue #71: skill files, prompt templates, and themes live under the
8986    /// agent dir (`~/.pi/agent/`, default). The agent legitimately needs to
8987    /// read these even when cwd is a user project on a different path.
8988    /// Ensure `enforce_read_scope_with_roots` accepts the agent dir as a
8989    /// second valid root without breaking the cwd-only contract for paths
8990    /// that are under neither.
8991    #[test]
8992    fn test_enforce_read_scope_allows_agent_dir_outside_cwd() {
8993        let cwd = tempfile::tempdir().unwrap();
8994        let agent_dir = tempfile::tempdir().unwrap();
8995        let skill_dir = agent_dir.path().join("skills").join("freebsd-jails");
8996        std::fs::create_dir_all(&skill_dir).unwrap();
8997        let skill_path = skill_dir.join("SKILL.md");
8998        std::fs::write(&skill_path, "---\nname: test\n---\n# body\n").unwrap();
8999
9000        let resolved =
9001            enforce_read_scope_with_roots(&skill_path, cwd.path(), agent_dir.path()).unwrap();
9002        assert!(
9003            resolved.starts_with(
9004                agent_dir
9005                    .path()
9006                    .canonicalize()
9007                    .unwrap_or_else(|_| agent_dir.path().to_path_buf())
9008            ),
9009            "agent-dir path must be allowed and returned canonicalised"
9010        );
9011    }
9012
9013    #[test]
9014    fn test_enforce_read_scope_still_rejects_unrelated_paths() {
9015        // Paths under neither cwd nor agent_dir must keep failing closed.
9016        let cwd = tempfile::tempdir().unwrap();
9017        let agent_dir = tempfile::tempdir().unwrap();
9018        let unrelated = tempfile::tempdir().unwrap();
9019        std::fs::write(unrelated.path().join("secret.txt"), "secret").unwrap();
9020        let secret_path = unrelated.path().join("secret.txt");
9021
9022        let err =
9023            enforce_read_scope_with_roots(&secret_path, cwd.path(), agent_dir.path()).unwrap_err();
9024        let msg = err.to_string();
9025        assert!(
9026            msg.contains("outside the working directory") && msg.contains("agent dir"),
9027            "error must mention both denied roots, got: {msg}"
9028        );
9029    }
9030
9031    #[test]
9032    fn test_enforce_read_scope_prefers_cwd_when_path_is_under_cwd() {
9033        // When a path is under cwd, we must not silently switch to agent-dir
9034        // resolution. This locks in the order of the prefix checks.
9035        let cwd = tempfile::tempdir().unwrap();
9036        let agent_dir = tempfile::tempdir().unwrap();
9037        std::fs::write(cwd.path().join("a.txt"), "in cwd").unwrap();
9038
9039        let resolved =
9040            enforce_read_scope_with_roots(&cwd.path().join("a.txt"), cwd.path(), agent_dir.path())
9041                .unwrap();
9042        assert!(
9043            resolved.starts_with(
9044                cwd.path()
9045                    .canonicalize()
9046                    .unwrap_or_else(|_| cwd.path().to_path_buf())
9047            )
9048        );
9049    }
9050
9051    #[test]
9052    fn test_read_empty_file() {
9053        asupersync::test_utils::run_test(|| async {
9054            let tmp = tempfile::tempdir().unwrap();
9055            std::fs::write(tmp.path().join("empty.txt"), "").unwrap();
9056
9057            let tool = ReadTool::new(tmp.path());
9058            let out = tool
9059                .execute(
9060                    "t",
9061                    serde_json::json!({ "path": tmp.path().join("empty.txt").to_string_lossy() }),
9062                    None,
9063                )
9064                .await
9065                .unwrap();
9066            let text = get_text(&out.content);
9067            assert_eq!(text, "");
9068            assert!(!out.is_error);
9069        });
9070    }
9071
9072    #[test]
9073    fn test_read_empty_file_positive_offset_errors() {
9074        asupersync::test_utils::run_test(|| async {
9075            let tmp = tempfile::tempdir().unwrap();
9076            std::fs::write(tmp.path().join("empty.txt"), "").unwrap();
9077
9078            let tool = ReadTool::new(tmp.path());
9079            let err = tool
9080                .execute(
9081                    "t",
9082                    serde_json::json!({
9083                        "path": tmp.path().join("empty.txt").to_string_lossy(),
9084                        "offset": 1
9085                    }),
9086                    None,
9087                )
9088                .await;
9089            assert!(err.is_err());
9090            let msg = err.unwrap_err().to_string();
9091            assert!(msg.contains("beyond end of file"));
9092        });
9093    }
9094
9095    #[test]
9096    fn test_read_rejects_zero_limit() {
9097        asupersync::test_utils::run_test(|| async {
9098            let tmp = tempfile::tempdir().unwrap();
9099            std::fs::write(tmp.path().join("lines.txt"), "a\nb\nc\n").unwrap();
9100
9101            let tool = ReadTool::new(tmp.path());
9102            let err = tool
9103                .execute(
9104                    "t",
9105                    serde_json::json!({
9106                        "path": tmp.path().join("lines.txt").to_string_lossy(),
9107                        "limit": 0
9108                    }),
9109                    None,
9110                )
9111                .await;
9112            assert!(err.is_err());
9113            assert!(
9114                err.unwrap_err()
9115                    .to_string()
9116                    .contains("`limit` must be greater than 0")
9117            );
9118        });
9119    }
9120
9121    #[test]
9122    fn test_read_offset_and_limit() {
9123        asupersync::test_utils::run_test(|| async {
9124            let tmp = tempfile::tempdir().unwrap();
9125            std::fs::write(
9126                tmp.path().join("lines.txt"),
9127                "L1\nL2\nL3\nL4\nL5\nL6\nL7\nL8\nL9\nL10",
9128            )
9129            .unwrap();
9130
9131            let tool = ReadTool::new(tmp.path());
9132            let out = tool
9133                .execute(
9134                    "t",
9135                    serde_json::json!({
9136                        "path": tmp.path().join("lines.txt").to_string_lossy(),
9137                        "offset": 3,
9138                        "limit": 2
9139                    }),
9140                    None,
9141                )
9142                .await
9143                .unwrap();
9144            let text = get_text(&out.content);
9145            assert!(text.contains("L3"));
9146            assert!(text.contains("L4"));
9147            assert!(!text.contains("L2"));
9148            assert!(!text.contains("L5"));
9149        });
9150    }
9151
9152    #[test]
9153    fn test_read_offset_and_limit_with_cr_only_line_endings() {
9154        asupersync::test_utils::run_test(|| async {
9155            let tmp = tempfile::tempdir().unwrap();
9156            std::fs::write(tmp.path().join("lines.txt"), b"L1\rL2\rL3\r").unwrap();
9157
9158            let tool = ReadTool::new(tmp.path());
9159            let out = tool
9160                .execute(
9161                    "t",
9162                    serde_json::json!({
9163                        "path": tmp.path().join("lines.txt").to_string_lossy(),
9164                        "offset": 2,
9165                        "limit": 1
9166                    }),
9167                    None,
9168                )
9169                .await
9170                .unwrap();
9171            let text = get_text(&out.content);
9172            assert!(text.contains("L2"));
9173            assert!(!text.contains("L1"));
9174            assert!(!text.contains("L3"));
9175            assert!(text.contains("offset=3"));
9176            assert!(!text.contains('\r'));
9177        });
9178    }
9179
9180    #[test]
9181    fn test_read_offset_and_limit_with_split_crlf_chunk_boundary() {
9182        asupersync::test_utils::run_test(|| async {
9183            let tmp = tempfile::tempdir().unwrap();
9184            let mut content = vec![b'x'; (64 * 1024) - 1];
9185            content.extend_from_slice(b"\r\nSECOND\r\nTHIRD");
9186            std::fs::write(tmp.path().join("lines.txt"), content).unwrap();
9187
9188            let tool = ReadTool::new(tmp.path());
9189            let out = tool
9190                .execute(
9191                    "t",
9192                    serde_json::json!({
9193                        "path": tmp.path().join("lines.txt").to_string_lossy(),
9194                        "offset": 2,
9195                        "limit": 1
9196                    }),
9197                    None,
9198                )
9199                .await
9200                .unwrap();
9201            let text = get_text(&out.content);
9202            assert!(text.contains("SECOND"));
9203            assert!(!text.contains("THIRD"));
9204            assert!(!text.contains("xxxx"));
9205            assert!(text.contains("offset=3"));
9206        });
9207    }
9208
9209    #[test]
9210    fn test_read_offset_beyond_eof() {
9211        asupersync::test_utils::run_test(|| async {
9212            let tmp = tempfile::tempdir().unwrap();
9213            std::fs::write(tmp.path().join("short.txt"), "a\nb").unwrap();
9214
9215            let tool = ReadTool::new(tmp.path());
9216            let err = tool
9217                .execute(
9218                    "t",
9219                    serde_json::json!({
9220                        "path": tmp.path().join("short.txt").to_string_lossy(),
9221                        "offset": 100
9222                    }),
9223                    None,
9224                )
9225                .await;
9226            assert!(err.is_err());
9227            let msg = err.unwrap_err().to_string();
9228            assert!(msg.contains("beyond end of file"));
9229        });
9230    }
9231
9232    #[test]
9233    fn test_map_normalized_with_trailing_whitespace() {
9234        // "A   \nB" -> "A\nB" (normalized strips trailing spaces)
9235        let content = "A   \nB";
9236        let normalized = build_normalized_content(content);
9237        assert_eq!(normalized, "A\nB");
9238
9239        // Find "A" (norm idx 0)
9240        let (start, len) = map_normalized_range_to_original(content, 0, 1);
9241        assert_eq!(start, 0);
9242        assert_eq!(len, 1);
9243        assert_eq!(&content[start..start + len], "A");
9244
9245        // Find "\n" (norm idx 1)
9246        let (start, len) = map_normalized_range_to_original(content, 1, 1);
9247        assert_eq!(start, 4);
9248        assert_eq!(len, 1);
9249        assert_eq!(&content[start..start + len], "\n");
9250
9251        // Find "B" (norm idx 2)
9252        let (start, len) = map_normalized_range_to_original(content, 2, 1);
9253        assert_eq!(start, 5);
9254        assert_eq!(len, 1);
9255        assert_eq!(&content[start..start + len], "B");
9256    }
9257
9258    #[test]
9259    fn test_read_binary_file_lossy() {
9260        asupersync::test_utils::run_test(|| async {
9261            let tmp = tempfile::tempdir().unwrap();
9262            let binary_data: Vec<u8> = (0..=255).collect();
9263            std::fs::write(tmp.path().join("binary.bin"), &binary_data).unwrap();
9264
9265            let tool = ReadTool::new(tmp.path());
9266            let out = tool
9267                .execute(
9268                    "t",
9269                    serde_json::json!({ "path": tmp.path().join("binary.bin").to_string_lossy() }),
9270                    None,
9271                )
9272                .await
9273                .unwrap();
9274            // Binary files are read as lossy UTF-8 with replacement characters
9275            let text = get_text(&out.content);
9276            assert!(!text.is_empty());
9277            assert!(!out.is_error);
9278        });
9279    }
9280
9281    #[test]
9282    fn test_read_image_detection() {
9283        asupersync::test_utils::run_test(|| async {
9284            let tmp = tempfile::tempdir().unwrap();
9285            // Minimal valid PNG header
9286            let png_header: Vec<u8> = vec![
9287                0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature
9288                0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, // IHDR chunk
9289                0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, // 1x1 pixel
9290                0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53,
9291                0xDE, // bit depth, color type, etc
9292                0x00, 0x00, 0x00, 0x0C, 0x49, 0x44, 0x41, 0x54, // IDAT chunk
9293                0x08, 0xD7, 0x63, 0xF8, 0xCF, 0xC0, 0x00, 0x00, // compressed data
9294                0x00, 0x02, 0x00, 0x01, 0xE2, 0x21, 0xBC, 0x33, // CRC
9295                0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, // IEND chunk
9296                0xAE, 0x42, 0x60, 0x82,
9297            ];
9298            std::fs::write(tmp.path().join("test.png"), &png_header).unwrap();
9299
9300            let tool = ReadTool::new(tmp.path());
9301            let out = tool
9302                .execute(
9303                    "t",
9304                    serde_json::json!({ "path": tmp.path().join("test.png").to_string_lossy() }),
9305                    None,
9306                )
9307                .await
9308                .unwrap();
9309
9310            // Should return an image content block
9311            let has_image = out
9312                .content
9313                .iter()
9314                .any(|b| matches!(b, ContentBlock::Image(_)));
9315            assert!(has_image, "expected image content block for PNG file");
9316        });
9317    }
9318
9319    #[cfg(feature = "image-resize")]
9320    #[test]
9321    fn test_read_resizes_large_source_image_before_api_limit_check() {
9322        asupersync::test_utils::run_test(|| async {
9323            use image::codecs::png::PngEncoder;
9324            use image::{ExtendedColorType, ImageEncoder, Rgb, RgbImage};
9325
9326            let tmp = tempfile::tempdir().unwrap();
9327            let image = RgbImage::from_fn(2600, 2600, |x, y| {
9328                let seed = x.wrapping_mul(1_973)
9329                    ^ y.wrapping_mul(9_277)
9330                    ^ x.rotate_left(7)
9331                    ^ y.rotate_left(13);
9332                Rgb([
9333                    u8::try_from(seed % 256).unwrap_or(0),
9334                    u8::try_from((seed >> 8) % 256).unwrap_or(0),
9335                    u8::try_from((seed >> 16) % 256).unwrap_or(0),
9336                ])
9337            });
9338
9339            let mut png_bytes = Vec::new();
9340            PngEncoder::new(&mut png_bytes)
9341                .write_image(
9342                    image.as_raw(),
9343                    image.width(),
9344                    image.height(),
9345                    ExtendedColorType::Rgb8,
9346                )
9347                .unwrap();
9348
9349            assert!(
9350                png_bytes.len() > IMAGE_MAX_BYTES,
9351                "fixture must exceed API image limit to exercise resize path"
9352            );
9353            assert!(
9354                png_bytes.len() < usize::try_from(READ_TOOL_MAX_BYTES).unwrap_or(usize::MAX),
9355                "fixture must stay within read-tool input bound"
9356            );
9357
9358            let image_path = tmp.path().join("large.png");
9359            std::fs::write(&image_path, &png_bytes).unwrap();
9360
9361            let tool = ReadTool::new(tmp.path());
9362            let out = tool
9363                .execute(
9364                    "t",
9365                    serde_json::json!({ "path": image_path.to_string_lossy() }),
9366                    None,
9367                )
9368                .await
9369                .unwrap();
9370
9371            assert!(!out.is_error, "resizable large images should succeed");
9372            assert!(
9373                out.content
9374                    .iter()
9375                    .any(|block| matches!(block, ContentBlock::Image(_))),
9376                "expected an image attachment after resizing"
9377            );
9378
9379            let text = get_text(&out.content);
9380            assert!(text.contains("Read image file"));
9381            assert!(
9382                text.contains("displayed at"),
9383                "expected resize note in read output, got: {text}"
9384            );
9385        });
9386    }
9387
9388    #[test]
9389    fn test_read_blocked_images() {
9390        asupersync::test_utils::run_test(|| async {
9391            let tmp = tempfile::tempdir().unwrap();
9392            let png_header: Vec<u8> =
9393                vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00];
9394            std::fs::write(tmp.path().join("test.png"), &png_header).unwrap();
9395
9396            let tool = ReadTool::with_settings(tmp.path(), false, true);
9397            let err = tool
9398                .execute(
9399                    "t",
9400                    serde_json::json!({ "path": tmp.path().join("test.png").to_string_lossy() }),
9401                    None,
9402                )
9403                .await;
9404            assert!(err.is_err());
9405            assert!(err.unwrap_err().to_string().contains("blocked"));
9406        });
9407    }
9408
9409    #[test]
9410    fn test_read_truncation_at_max_lines() {
9411        asupersync::test_utils::run_test(|| async {
9412            let tmp = tempfile::tempdir().unwrap();
9413            let content: String = (0..DEFAULT_MAX_LINES + 500)
9414                .map(|i| format!("line {i}"))
9415                .collect::<Vec<_>>()
9416                .join("\n");
9417            std::fs::write(tmp.path().join("big.txt"), &content).unwrap();
9418
9419            let tool = ReadTool::new(tmp.path());
9420            let out = tool
9421                .execute(
9422                    "t",
9423                    serde_json::json!({ "path": tmp.path().join("big.txt").to_string_lossy() }),
9424                    None,
9425                )
9426                .await
9427                .unwrap();
9428            // Should have truncation details
9429            assert!(out.details.is_some(), "expected truncation details");
9430            let text = get_text(&out.content);
9431            assert!(text.contains("offset="));
9432        });
9433    }
9434
9435    #[test]
9436    fn test_read_first_line_exceeds_max_bytes() {
9437        asupersync::test_utils::run_test(|| async {
9438            let tmp = tempfile::tempdir().unwrap();
9439            let long_line = "a".repeat(DEFAULT_MAX_BYTES + 128);
9440            std::fs::write(tmp.path().join("too_long.txt"), long_line).unwrap();
9441
9442            let tool = ReadTool::new(tmp.path());
9443            let out = tool
9444                .execute(
9445                    "t",
9446                    serde_json::json!({ "path": tmp.path().join("too_long.txt").to_string_lossy() }),
9447                    None,
9448                )
9449                .await
9450                .unwrap();
9451
9452            let text = get_text(&out.content);
9453            let expected_limit = format!("exceeds {} limit", format_size(DEFAULT_MAX_BYTES));
9454            assert!(
9455                text.contains(&expected_limit),
9456                "expected limit hint '{expected_limit}', got: {text}"
9457            );
9458            let details = out.details.expect("expected truncation details");
9459            assert_eq!(
9460                details
9461                    .get("truncation")
9462                    .and_then(|v| v.get("firstLineExceedsLimit"))
9463                    .and_then(serde_json::Value::as_bool),
9464                Some(true)
9465            );
9466        });
9467    }
9468
9469    #[test]
9470    fn test_read_unicode_content() {
9471        asupersync::test_utils::run_test(|| async {
9472            let tmp = tempfile::tempdir().unwrap();
9473            std::fs::write(tmp.path().join("uni.txt"), "Hello 你好 🌍\nLine 2 café").unwrap();
9474
9475            let tool = ReadTool::new(tmp.path());
9476            let out = tool
9477                .execute(
9478                    "t",
9479                    serde_json::json!({ "path": tmp.path().join("uni.txt").to_string_lossy() }),
9480                    None,
9481                )
9482                .await
9483                .unwrap();
9484            let text = get_text(&out.content);
9485            assert!(text.contains("你好"));
9486            assert!(text.contains("🌍"));
9487            assert!(text.contains("café"));
9488        });
9489    }
9490
9491    // ========================================================================
9492    // Write Tool Tests
9493    // ========================================================================
9494
9495    #[test]
9496    fn test_write_new_file() {
9497        asupersync::test_utils::run_test(|| async {
9498            let tmp = tempfile::tempdir().unwrap();
9499            let tool = WriteTool::new(tmp.path());
9500            let out = tool
9501                .execute(
9502                    "t",
9503                    serde_json::json!({
9504                        "path": tmp.path().join("new.txt").to_string_lossy(),
9505                        "content": "hello world"
9506                    }),
9507                    None,
9508                )
9509                .await
9510                .unwrap();
9511            assert!(!out.is_error);
9512            let contents = std::fs::read_to_string(tmp.path().join("new.txt")).unwrap();
9513            assert_eq!(contents, "hello world");
9514        });
9515    }
9516
9517    #[test]
9518    fn test_write_overwrite_existing() {
9519        asupersync::test_utils::run_test(|| async {
9520            let tmp = tempfile::tempdir().unwrap();
9521            std::fs::write(tmp.path().join("exist.txt"), "old content").unwrap();
9522
9523            let tool = WriteTool::new(tmp.path());
9524            let out = tool
9525                .execute(
9526                    "t",
9527                    serde_json::json!({
9528                        "path": tmp.path().join("exist.txt").to_string_lossy(),
9529                        "content": "new content"
9530                    }),
9531                    None,
9532                )
9533                .await
9534                .unwrap();
9535            assert!(!out.is_error);
9536            let contents = std::fs::read_to_string(tmp.path().join("exist.txt")).unwrap();
9537            assert_eq!(contents, "new content");
9538        });
9539    }
9540
9541    #[test]
9542    fn test_write_creates_parent_dirs() {
9543        asupersync::test_utils::run_test(|| async {
9544            let tmp = tempfile::tempdir().unwrap();
9545            let tool = WriteTool::new(tmp.path());
9546            let deep_path = tmp.path().join("a/b/c/deep.txt");
9547            let out = tool
9548                .execute(
9549                    "t",
9550                    serde_json::json!({
9551                        "path": deep_path.to_string_lossy(),
9552                        "content": "deep file"
9553                    }),
9554                    None,
9555                )
9556                .await
9557                .unwrap();
9558            assert!(!out.is_error);
9559            assert!(deep_path.exists());
9560            assert_eq!(std::fs::read_to_string(&deep_path).unwrap(), "deep file");
9561        });
9562    }
9563
9564    #[test]
9565    fn test_write_empty_file() {
9566        asupersync::test_utils::run_test(|| async {
9567            let tmp = tempfile::tempdir().unwrap();
9568            let tool = WriteTool::new(tmp.path());
9569            let out = tool
9570                .execute(
9571                    "t",
9572                    serde_json::json!({
9573                        "path": tmp.path().join("empty.txt").to_string_lossy(),
9574                        "content": ""
9575                    }),
9576                    None,
9577                )
9578                .await
9579                .unwrap();
9580            assert!(!out.is_error);
9581            let contents = std::fs::read_to_string(tmp.path().join("empty.txt")).unwrap();
9582            assert_eq!(contents, "");
9583            let text = get_text(&out.content);
9584            assert!(text.contains("Successfully wrote 0 bytes"));
9585        });
9586    }
9587
9588    #[test]
9589    fn test_write_rejects_outside_cwd() {
9590        asupersync::test_utils::run_test(|| async {
9591            let cwd = tempfile::tempdir().unwrap();
9592            let outside = tempfile::tempdir().unwrap();
9593            let tool = WriteTool::new(cwd.path());
9594            let err = tool
9595                .execute(
9596                    "t",
9597                    serde_json::json!({
9598                        "path": outside.path().join("escape.txt").to_string_lossy(),
9599                        "content": "nope"
9600                    }),
9601                    None,
9602                )
9603                .await
9604                .unwrap_err();
9605            assert!(err.to_string().contains("outside the working directory"));
9606
9607            let err = tool
9608                .execute(
9609                    "t",
9610                    serde_json::json!({
9611                        "path": "../escape.txt",
9612                        "content": "nope"
9613                    }),
9614                    None,
9615                )
9616                .await
9617                .unwrap_err();
9618            assert!(err.to_string().contains("outside the working directory"));
9619        });
9620    }
9621
9622    #[test]
9623    fn test_write_unicode_content() {
9624        asupersync::test_utils::run_test(|| async {
9625            let tmp = tempfile::tempdir().unwrap();
9626            let tool = WriteTool::new(tmp.path());
9627            let out = tool
9628                .execute(
9629                    "t",
9630                    serde_json::json!({
9631                        "path": tmp.path().join("unicode.txt").to_string_lossy(),
9632                        "content": "日本語 🎉 Ñoño"
9633                    }),
9634                    None,
9635                )
9636                .await
9637                .unwrap();
9638            assert!(!out.is_error);
9639            let contents = std::fs::read_to_string(tmp.path().join("unicode.txt")).unwrap();
9640            assert_eq!(contents, "日本語 🎉 Ñoño");
9641        });
9642    }
9643
9644    #[test]
9645    #[cfg(unix)]
9646    fn test_write_file_permissions_unix() {
9647        use std::os::unix::fs::PermissionsExt;
9648        asupersync::test_utils::run_test(|| async {
9649            let tmp = tempfile::tempdir().unwrap();
9650            let tool = WriteTool::new(tmp.path());
9651            let path = tmp.path().join("perms.txt");
9652            let out = tool
9653                .execute(
9654                    "t",
9655                    serde_json::json!({
9656                        "path": path.to_string_lossy(),
9657                        "content": "check perms"
9658                    }),
9659                    None,
9660                )
9661                .await
9662                .unwrap();
9663            assert!(!out.is_error);
9664
9665            let meta = std::fs::metadata(&path).unwrap();
9666            let mode = meta.permissions().mode();
9667            assert_eq!(
9668                mode & 0o777,
9669                0o644,
9670                "Expected default 0o644 permissions for new files"
9671            );
9672        });
9673    }
9674
9675    // ========================================================================
9676    // Edit Tool Tests
9677    // ========================================================================
9678
9679    #[test]
9680    fn test_edit_exact_match_replace() {
9681        asupersync::test_utils::run_test(|| async {
9682            let tmp = tempfile::tempdir().unwrap();
9683            std::fs::write(tmp.path().join("code.rs"), "fn foo() { bar() }").unwrap();
9684
9685            let tool = EditTool::new(tmp.path());
9686            let out = tool
9687                .execute(
9688                    "t",
9689                    serde_json::json!({
9690                        "path": tmp.path().join("code.rs").to_string_lossy(),
9691                        "oldText": "bar()",
9692                        "newText": "baz()"
9693                    }),
9694                    None,
9695                )
9696                .await
9697                .unwrap();
9698            assert!(!out.is_error);
9699            let contents = std::fs::read_to_string(tmp.path().join("code.rs")).unwrap();
9700            assert_eq!(contents, "fn foo() { baz() }");
9701        });
9702    }
9703
9704    #[test]
9705    fn test_edit_no_match_error() {
9706        asupersync::test_utils::run_test(|| async {
9707            let tmp = tempfile::tempdir().unwrap();
9708            std::fs::write(tmp.path().join("code.rs"), "fn foo() {}").unwrap();
9709
9710            let tool = EditTool::new(tmp.path());
9711            let err = tool
9712                .execute(
9713                    "t",
9714                    serde_json::json!({
9715                        "path": tmp.path().join("code.rs").to_string_lossy(),
9716                        "oldText": "NONEXISTENT TEXT",
9717                        "newText": "replacement"
9718                    }),
9719                    None,
9720                )
9721                .await;
9722            assert!(err.is_err());
9723        });
9724    }
9725
9726    #[test]
9727    fn test_edit_empty_old_text_error() {
9728        asupersync::test_utils::run_test(|| async {
9729            let tmp = tempfile::tempdir().unwrap();
9730            let path = tmp.path().join("code.rs");
9731            std::fs::write(&path, "fn foo() {}").unwrap();
9732
9733            let tool = EditTool::new(tmp.path());
9734            let err = tool
9735                .execute(
9736                    "t",
9737                    serde_json::json!({
9738                        "path": path.to_string_lossy(),
9739                        "oldText": "",
9740                        "newText": "prefix"
9741                    }),
9742                    None,
9743                )
9744                .await
9745                .expect_err("empty oldText should be rejected");
9746
9747            let msg = err.to_string();
9748            assert!(
9749                msg.contains("old text cannot be empty"),
9750                "unexpected error: {msg}"
9751            );
9752            let after = std::fs::read_to_string(path).unwrap();
9753            assert_eq!(after, "fn foo() {}");
9754        });
9755    }
9756
9757    #[test]
9758    fn test_edit_ambiguous_match_error() {
9759        asupersync::test_utils::run_test(|| async {
9760            let tmp = tempfile::tempdir().unwrap();
9761            std::fs::write(tmp.path().join("dup.txt"), "hello hello hello").unwrap();
9762
9763            let tool = EditTool::new(tmp.path());
9764            let err = tool
9765                .execute(
9766                    "t",
9767                    serde_json::json!({
9768                        "path": tmp.path().join("dup.txt").to_string_lossy(),
9769                        "oldText": "hello",
9770                        "newText": "world"
9771                    }),
9772                    None,
9773                )
9774                .await;
9775            assert!(err.is_err(), "expected error for ambiguous match");
9776        });
9777    }
9778
9779    #[test]
9780    fn test_edit_multi_line_replacement() {
9781        asupersync::test_utils::run_test(|| async {
9782            let tmp = tempfile::tempdir().unwrap();
9783            std::fs::write(
9784                tmp.path().join("multi.txt"),
9785                "line 1\nline 2\nline 3\nline 4",
9786            )
9787            .unwrap();
9788
9789            let tool = EditTool::new(tmp.path());
9790            let out = tool
9791                .execute(
9792                    "t",
9793                    serde_json::json!({
9794                        "path": tmp.path().join("multi.txt").to_string_lossy(),
9795                        "oldText": "line 2\nline 3",
9796                        "newText": "replaced 2\nreplaced 3\nextra line"
9797                    }),
9798                    None,
9799                )
9800                .await
9801                .unwrap();
9802            assert!(!out.is_error);
9803            let contents = std::fs::read_to_string(tmp.path().join("multi.txt")).unwrap();
9804            assert_eq!(
9805                contents,
9806                "line 1\nreplaced 2\nreplaced 3\nextra line\nline 4"
9807            );
9808        });
9809    }
9810
9811    #[test]
9812    fn test_edit_unicode_content() {
9813        asupersync::test_utils::run_test(|| async {
9814            let tmp = tempfile::tempdir().unwrap();
9815            std::fs::write(tmp.path().join("uni.txt"), "Héllo wörld 🌍").unwrap();
9816
9817            let tool = EditTool::new(tmp.path());
9818            let out = tool
9819                .execute(
9820                    "t",
9821                    serde_json::json!({
9822                        "path": tmp.path().join("uni.txt").to_string_lossy(),
9823                        "oldText": "wörld 🌍",
9824                        "newText": "Welt 🌎"
9825                    }),
9826                    None,
9827                )
9828                .await
9829                .unwrap();
9830            assert!(!out.is_error);
9831            let contents = std::fs::read_to_string(tmp.path().join("uni.txt")).unwrap();
9832            assert_eq!(contents, "Héllo Welt 🌎");
9833        });
9834    }
9835
9836    #[test]
9837    fn test_edit_missing_file() {
9838        asupersync::test_utils::run_test(|| async {
9839            let tmp = tempfile::tempdir().unwrap();
9840            let tool = EditTool::new(tmp.path());
9841            let err = tool
9842                .execute(
9843                    "t",
9844                    serde_json::json!({
9845                        "path": tmp.path().join("nope.txt").to_string_lossy(),
9846                        "oldText": "foo",
9847                        "newText": "bar"
9848                    }),
9849                    None,
9850                )
9851                .await;
9852            assert!(err.is_err());
9853        });
9854    }
9855
9856    // ========================================================================
9857    // Bash Tool Tests
9858    // ========================================================================
9859
9860    struct FailingReader {
9861        responses: std::collections::VecDeque<std::io::Result<Vec<u8>>>,
9862    }
9863
9864    impl FailingReader {
9865        fn new(responses: impl IntoIterator<Item = std::io::Result<Vec<u8>>>) -> Self {
9866            Self {
9867                responses: responses.into_iter().collect(),
9868            }
9869        }
9870    }
9871
9872    impl Read for FailingReader {
9873        fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
9874            match self.responses.pop_front().unwrap_or_else(|| Ok(Vec::new())) {
9875                Ok(bytes) => {
9876                    assert!(
9877                        bytes.len() <= buf.len(),
9878                        "test reader only supports single-chunk reads"
9879                    );
9880                    buf[..bytes.len()].copy_from_slice(&bytes);
9881                    Ok(bytes.len())
9882                }
9883                Err(err) => Err(err),
9884            }
9885        }
9886    }
9887
9888    #[test]
9889    fn test_bash_simple_command() {
9890        asupersync::test_utils::run_test(|| async {
9891            let tmp = tempfile::tempdir().unwrap();
9892            let tool = BashTool::new(tmp.path());
9893            let out = tool
9894                .execute(
9895                    "t",
9896                    serde_json::json!({ "command": "echo hello_from_bash" }),
9897                    None,
9898                )
9899                .await
9900                .unwrap();
9901            let text = get_text(&out.content);
9902            assert!(text.contains("hello_from_bash"));
9903            assert!(!out.is_error);
9904        });
9905    }
9906
9907    #[test]
9908    fn test_bash_exit_code_nonzero() {
9909        asupersync::test_utils::run_test(|| async {
9910            let tmp = tempfile::tempdir().unwrap();
9911            let tool = BashTool::new(tmp.path());
9912            let out = tool
9913                .execute("t", serde_json::json!({ "command": "exit 42" }), None)
9914                .await
9915                .expect("non-zero exit should return Ok with is_error=true");
9916            assert!(out.is_error, "non-zero exit must set is_error");
9917            let msg = get_text(&out.content);
9918            assert!(
9919                msg.contains("42"),
9920                "expected exit code 42 in output, got: {msg}"
9921            );
9922        });
9923    }
9924
9925    #[cfg(unix)]
9926    #[test]
9927    fn test_bash_signal_termination_is_error() {
9928        asupersync::test_utils::run_test(|| async {
9929            let tmp = tempfile::tempdir().unwrap();
9930            let tool = BashTool::new(tmp.path());
9931            let out = tool
9932                .execute("t", serde_json::json!({ "command": "kill -KILL $$" }), None)
9933                .await
9934                .expect("signal-terminated shell should return Ok with is_error=true");
9935            assert!(
9936                out.is_error,
9937                "signal-terminated shell must be reported as error"
9938            );
9939            let msg = get_text(&out.content);
9940            assert!(
9941                msg.contains("Command exited with code"),
9942                "expected explicit exit-code report, got: {msg}"
9943            );
9944            assert!(
9945                !msg.contains("Command exited with code 0"),
9946                "signal-terminated shell must not appear successful: {msg}"
9947            );
9948        });
9949    }
9950
9951    #[test]
9952    fn test_bash_stderr_capture() {
9953        asupersync::test_utils::run_test(|| async {
9954            let tmp = tempfile::tempdir().unwrap();
9955            let tool = BashTool::new(tmp.path());
9956            let out = tool
9957                .execute(
9958                    "t",
9959                    serde_json::json!({ "command": "echo stderr_msg >&2" }),
9960                    None,
9961                )
9962                .await
9963                .unwrap();
9964            let text = get_text(&out.content);
9965            assert!(
9966                text.contains("stderr_msg"),
9967                "expected stderr output in result, got: {text}"
9968            );
9969        });
9970    }
9971
9972    #[test]
9973    fn test_bash_timeout() {
9974        asupersync::test_utils::run_test(|| async {
9975            let tmp = tempfile::tempdir().unwrap();
9976            let tool = BashTool::new(tmp.path());
9977            let out = tool
9978                .execute(
9979                    "t",
9980                    serde_json::json!({ "command": "sleep 60", "timeout": 2 }),
9981                    None,
9982                )
9983                .await
9984                .expect("timeout should return Ok with is_error=true");
9985            assert!(out.is_error, "timeout must set is_error");
9986            let msg = get_text(&out.content);
9987            assert!(
9988                msg.to_lowercase().contains("timeout") || msg.to_lowercase().contains("timed out"),
9989                "expected timeout indication, got: {msg}"
9990            );
9991            let cancellation = out
9992                .details
9993                .as_ref()
9994                .and_then(|details| details.get("cancellation"))
9995                .expect("timeout should include structured cancellation details");
9996            assert_eq!(cancellation["schema"], BASH_CANCELLATION_SCHEMA_V1);
9997            assert_eq!(cancellation["status"], "cancelled");
9998            assert_eq!(cancellation["reason"], "timeout");
9999            assert_eq!(cancellation["cleanup"], "process_group_tree_terminated");
10000            assert_eq!(cancellation["timeoutMs"], 2000);
10001        });
10002    }
10003
10004    #[cfg(target_os = "linux")]
10005    #[test]
10006    fn test_bash_timeout_kills_process_tree() {
10007        asupersync::test_utils::run_test(|| async {
10008            let tmp = tempfile::tempdir().unwrap();
10009            let marker = tmp.path().join("leaked_child.txt");
10010            let tool = BashTool::new(tmp.path());
10011
10012            let out = tool
10013                .execute(
10014                    "t",
10015                    serde_json::json!({
10016                        "command": "(sleep 3; echo leaked > leaked_child.txt) & sleep 10",
10017                        "timeout": 1
10018                    }),
10019                    None,
10020                )
10021                .await
10022                .expect("timeout should return Ok with is_error=true");
10023
10024            assert!(out.is_error, "timeout must set is_error");
10025            let msg = get_text(&out.content);
10026            assert!(msg.contains("Command timed out"));
10027
10028            // If process tree cleanup fails, this file appears after ~3 seconds.
10029            std::thread::sleep(Duration::from_secs(4));
10030            assert!(
10031                !marker.exists(),
10032                "background child was not terminated on timeout"
10033            );
10034        });
10035    }
10036
10037    #[cfg(target_os = "linux")]
10038    #[test]
10039    fn test_bash_cancelled_context_kills_process_tree() {
10040        asupersync::test_utils::run_test(|| async {
10041            let tmp = tempfile::tempdir().unwrap();
10042            let marker = tmp.path().join("leaked_child.txt");
10043
10044            let ambient_cx = asupersync::Cx::for_testing();
10045            let cancel_cx = ambient_cx.clone();
10046            let _current = asupersync::Cx::set_current(Some(ambient_cx));
10047
10048            let cancel_thread = std::thread::spawn(move || {
10049                std::thread::sleep(Duration::from_millis(100));
10050                cancel_cx.set_cancel_requested(true);
10051            });
10052
10053            let result = run_bash_command(
10054                tmp.path(),
10055                None,
10056                None,
10057                "(sleep 3; echo leaked > leaked_child.txt) & sleep 10",
10058                Some(30),
10059                None,
10060            )
10061            .await
10062            .expect("cancelled bash should return a result");
10063
10064            cancel_thread.join().expect("cancel thread");
10065
10066            assert!(
10067                result.cancelled,
10068                "expected cancelled bash result: {result:?}"
10069            );
10070            assert_eq!(
10071                result.cancellation_reason,
10072                Some(BashCancellationReason::AmbientCancellation)
10073            );
10074
10075            std::thread::sleep(Duration::from_secs(4));
10076            assert!(
10077                !marker.exists(),
10078                "background child was not terminated on cancellation"
10079            );
10080        });
10081    }
10082
10083    #[test]
10084    fn test_bash_pump_stream_emits_io_error_frame_after_partial_output() {
10085        let reader = FailingReader::new([
10086            Ok(b"partial stdout".to_vec()),
10087            Err(std::io::Error::other("simulated stdout failure")),
10088        ]);
10089        let (tx, rx) = mpsc::sync_channel::<BashPipeFrame>(4);
10090
10091        pump_stream(reader, "stdout", &tx);
10092
10093        match rx.recv().expect("partial chunk") {
10094            BashPipeFrame::Chunk(chunk) => assert_eq!(chunk, b"partial stdout"),
10095            BashPipeFrame::Error(message) => {
10096                unreachable!("expected output chunk before error, got error frame: {message}")
10097            }
10098        }
10099
10100        match rx.recv().expect("io error frame") {
10101            BashPipeFrame::Chunk(chunk) => {
10102                unreachable!("expected io error after partial chunk, got chunk: {chunk:?}")
10103            }
10104            BashPipeFrame::Error(message) => {
10105                assert!(message.contains("Failed to read bash stdout"));
10106                assert!(message.contains("simulated stdout failure"));
10107            }
10108        }
10109
10110        assert!(matches!(rx.try_recv(), Err(mpsc::TryRecvError::Empty)));
10111    }
10112
10113    #[test]
10114    fn test_drain_bash_output_ignores_cancellation_after_process_exit() {
10115        asupersync::test_utils::run_test(|| async {
10116            let (tx, mut rx) = mpsc::sync_channel::<BashPipeFrame>(1);
10117            let mut bash_output = BashOutputState::new(DEFAULT_MAX_BYTES);
10118
10119            let ambient_cx = asupersync::Cx::for_testing();
10120            ambient_cx.set_cancel_requested(true);
10121            let _current = asupersync::Cx::set_current(Some(ambient_cx));
10122            let cx = AgentCx::for_current_or_request();
10123            let now = cx
10124                .cx()
10125                .timer_driver()
10126                .map_or_else(wall_now, |timer| timer.now());
10127
10128            let cancelled = drain_bash_output(
10129                &mut rx,
10130                &mut bash_output,
10131                &cx,
10132                now + std::time::Duration::from_millis(10),
10133                std::time::Duration::from_millis(1),
10134                false,
10135            )
10136            .await
10137            .expect("drain should complete without cancellation");
10138
10139            drop(tx);
10140
10141            assert!(
10142                !cancelled,
10143                "post-exit drain should ignore late ambient cancellation"
10144            );
10145            assert_eq!(bash_output.total_bytes, 0);
10146        });
10147    }
10148
10149    #[test]
10150    fn test_drain_bash_output_returns_pipe_read_error() {
10151        asupersync::test_utils::run_test(|| async {
10152            let (tx, mut rx) = mpsc::sync_channel::<BashPipeFrame>(2);
10153            tx.send(BashPipeFrame::Chunk(b"partial stderr".to_vec()))
10154                .expect("queue partial output");
10155            tx.send(BashPipeFrame::Error(
10156                "Failed to read bash stderr: simulated stderr failure".to_string(),
10157            ))
10158            .expect("queue error frame");
10159            drop(tx);
10160
10161            let mut bash_output = BashOutputState::new(DEFAULT_MAX_BYTES);
10162            let cx = AgentCx::for_current_or_request();
10163            let now = cx
10164                .cx()
10165                .timer_driver()
10166                .map_or_else(wall_now, |timer| timer.now());
10167
10168            let err = drain_bash_output(
10169                &mut rx,
10170                &mut bash_output,
10171                &cx,
10172                now + std::time::Duration::from_millis(10),
10173                std::time::Duration::from_millis(1),
10174                false,
10175            )
10176            .await
10177            .expect_err("pipe read failures must surface as errors");
10178
10179            let message = err.to_string();
10180            assert!(message.contains("Failed to read bash stderr"));
10181            assert!(message.contains("simulated stderr failure"));
10182            assert!(message.contains("Partial output before failure"));
10183            assert!(message.contains("partial stderr"));
10184            assert_eq!(bash_output.total_bytes, "partial stderr".len());
10185        });
10186    }
10187
10188    #[test]
10189    fn test_drain_bash_output_honors_cancellation_while_process_still_active() {
10190        asupersync::test_utils::run_test(|| async {
10191            let (_tx, mut rx) = mpsc::sync_channel::<BashPipeFrame>(1);
10192            let mut bash_output = BashOutputState::new(DEFAULT_MAX_BYTES);
10193
10194            let ambient_cx = asupersync::Cx::for_testing();
10195            ambient_cx.set_cancel_requested(true);
10196            let _current = asupersync::Cx::set_current(Some(ambient_cx));
10197            let cx = AgentCx::for_current_or_request();
10198            let now = cx
10199                .cx()
10200                .timer_driver()
10201                .map_or_else(wall_now, |timer| timer.now());
10202
10203            let cancelled = drain_bash_output(
10204                &mut rx,
10205                &mut bash_output,
10206                &cx,
10207                now + std::time::Duration::from_secs(1),
10208                std::time::Duration::from_millis(1),
10209                true,
10210            )
10211            .await
10212            .expect("drain should complete under cancellation");
10213
10214            assert!(
10215                cancelled,
10216                "active drain should still honor ambient cancellation"
10217            );
10218            assert_eq!(bash_output.total_bytes, 0);
10219        });
10220    }
10221
10222    #[test]
10223    fn test_bash_output_state_abandon_spill_file_clears_path_and_unlinks_file() {
10224        let tmp = tempfile::tempdir().unwrap();
10225        let spill_path = tmp.path().join("partial-bash.log");
10226        std::fs::write(&spill_path, b"partial output").unwrap();
10227
10228        let mut bash_output = BashOutputState::new(DEFAULT_MAX_BYTES);
10229        bash_output.temp_file_path = Some(spill_path.clone());
10230
10231        bash_output.abandon_spill_file();
10232
10233        assert!(bash_output.spill_failed);
10234        assert!(bash_output.temp_file.is_none());
10235        assert!(bash_output.temp_file_path.is_none());
10236        assert!(
10237            !spill_path.exists(),
10238            "abandoned spill files should not be advertised or left behind"
10239        );
10240    }
10241
10242    #[test]
10243    fn test_bash_hard_limit_retains_partial_spill_file() {
10244        asupersync::test_utils::run_test(|| async {
10245            let tmp = tempfile::tempdir().unwrap();
10246            let spill_path = tmp.path().join("hard-limit-bash.log");
10247            std::fs::write(&spill_path, b"partial output").unwrap();
10248
10249            let spill_file = asupersync::fs::OpenOptions::new()
10250                .append(true)
10251                .open(&spill_path)
10252                .await
10253                .unwrap();
10254
10255            let mut bash_output = BashOutputState::new(DEFAULT_MAX_BYTES);
10256            bash_output.total_bytes = BASH_FILE_LIMIT_BYTES;
10257            bash_output.temp_file_path = Some(spill_path.clone());
10258            bash_output.temp_file = Some(spill_file);
10259
10260            ingest_bash_chunk(vec![b'x'], &mut bash_output)
10261                .await
10262                .expect("hard-limit ingestion should still succeed");
10263
10264            assert!(!bash_output.spill_failed);
10265            assert!(bash_output.temp_file.is_none());
10266            assert!(bash_output.temp_file_path.is_some());
10267            assert!(
10268                spill_path.exists(),
10269                "partial spill files must be retained once the hard limit is reached for diagnostics"
10270            );
10271        });
10272    }
10273
10274    #[test]
10275    #[cfg(unix)]
10276    fn test_bash_working_directory() {
10277        asupersync::test_utils::run_test(|| async {
10278            let tmp = tempfile::tempdir().unwrap();
10279            let tool = BashTool::new(tmp.path());
10280            let out = tool
10281                .execute("t", serde_json::json!({ "command": "pwd" }), None)
10282                .await
10283                .unwrap();
10284            let text = get_text(&out.content);
10285            let canonical = tmp.path().canonicalize().unwrap();
10286            assert!(
10287                text.contains(&canonical.to_string_lossy().to_string()),
10288                "expected cwd in output, got: {text}"
10289            );
10290        });
10291    }
10292
10293    #[test]
10294    fn test_bash_multiline_output() {
10295        asupersync::test_utils::run_test(|| async {
10296            let tmp = tempfile::tempdir().unwrap();
10297            let tool = BashTool::new(tmp.path());
10298            let out = tool
10299                .execute(
10300                    "t",
10301                    serde_json::json!({ "command": "echo line1; echo line2; echo line3" }),
10302                    None,
10303                )
10304                .await
10305                .unwrap();
10306            let text = get_text(&out.content);
10307            assert!(text.contains("line1"));
10308            assert!(text.contains("line2"));
10309            assert!(text.contains("line3"));
10310        });
10311    }
10312
10313    // ========================================================================
10314    // Grep Tool Tests
10315    // ========================================================================
10316
10317    #[test]
10318    fn test_grep_basic_pattern() {
10319        asupersync::test_utils::run_test(|| async {
10320            let tmp = tempfile::tempdir().unwrap();
10321            std::fs::write(
10322                tmp.path().join("search.txt"),
10323                "apple\nbanana\napricot\ncherry",
10324            )
10325            .unwrap();
10326
10327            let tool = GrepTool::new(tmp.path());
10328            let out = tool
10329                .execute(
10330                    "t",
10331                    serde_json::json!({
10332                        "pattern": "ap",
10333                        "path": tmp.path().join("search.txt").to_string_lossy()
10334                    }),
10335                    None,
10336                )
10337                .await
10338                .unwrap();
10339            let text = get_text(&out.content);
10340            assert!(text.contains("apple"));
10341            assert!(text.contains("apricot"));
10342            assert!(!text.contains("banana"));
10343            assert!(!text.contains("cherry"));
10344        });
10345    }
10346
10347    #[test]
10348    fn test_grep_rejects_outside_cwd() {
10349        asupersync::test_utils::run_test(|| async {
10350            let cwd = tempfile::tempdir().unwrap();
10351            let outside = tempfile::tempdir().unwrap();
10352            std::fs::write(outside.path().join("secret.txt"), "secret").unwrap();
10353
10354            let tool = GrepTool::new(cwd.path());
10355            let err = tool
10356                .execute(
10357                    "t",
10358                    serde_json::json!({
10359                        "pattern": "secret",
10360                        "path": outside.path().join("secret.txt").to_string_lossy()
10361                    }),
10362                    None,
10363                )
10364                .await
10365                .unwrap_err();
10366            assert!(err.to_string().contains("outside the working directory"));
10367        });
10368    }
10369
10370    #[test]
10371    fn test_grep_rejects_zero_limit() {
10372        asupersync::test_utils::run_test(|| async {
10373            let tmp = tempfile::tempdir().unwrap();
10374            std::fs::write(tmp.path().join("search.txt"), "alpha\nbeta\n").unwrap();
10375
10376            let tool = GrepTool::new(tmp.path());
10377            let err = tool
10378                .execute(
10379                    "t",
10380                    serde_json::json!({
10381                        "pattern": "alpha",
10382                        "path": tmp.path().join("search.txt").to_string_lossy(),
10383                        "limit": 0
10384                    }),
10385                    None,
10386                )
10387                .await
10388                .unwrap_err();
10389            assert!(err.to_string().contains("`limit` must be greater than 0"));
10390        });
10391    }
10392
10393    #[test]
10394    #[cfg(unix)]
10395    fn test_grep_formats_paths_relative_to_symlinked_cwd() {
10396        asupersync::test_utils::run_test(|| async {
10397            let real = tempfile::tempdir().unwrap();
10398            let link_parent = tempfile::tempdir().unwrap();
10399            let link = link_parent.path().join("linked-cwd");
10400            std::os::unix::fs::symlink(real.path(), &link).unwrap();
10401            std::fs::write(real.path().join("needle.txt"), "needle\n").unwrap();
10402
10403            let tool = GrepTool::new(&link);
10404            let out = tool
10405                .execute("t", serde_json::json!({ "pattern": "needle" }), None)
10406                .await
10407                .unwrap();
10408
10409            let text = get_text(&out.content);
10410            assert!(
10411                text.contains("needle.txt:1: needle"),
10412                "grep output should use cwd-relative paths for symlinked cwd, got: {text}"
10413            );
10414            assert!(
10415                !text.contains(real.path().to_string_lossy().as_ref()),
10416                "grep output should not leak canonical temp root, got: {text}"
10417            );
10418        });
10419    }
10420
10421    #[test]
10422    fn test_grep_regex_pattern() {
10423        asupersync::test_utils::run_test(|| async {
10424            let tmp = tempfile::tempdir().unwrap();
10425            std::fs::write(
10426                tmp.path().join("regex.txt"),
10427                "foo123\nbar456\nbaz789\nfoo000",
10428            )
10429            .unwrap();
10430
10431            let tool = GrepTool::new(tmp.path());
10432            let out = tool
10433                .execute(
10434                    "t",
10435                    serde_json::json!({
10436                        "pattern": "foo\\d+",
10437                        "path": tmp.path().join("regex.txt").to_string_lossy()
10438                    }),
10439                    None,
10440                )
10441                .await
10442                .unwrap();
10443            let text = get_text(&out.content);
10444            assert!(text.contains("foo123"));
10445            assert!(text.contains("foo000"));
10446            assert!(!text.contains("bar456"));
10447        });
10448    }
10449
10450    #[test]
10451    fn test_grep_case_insensitive() {
10452        asupersync::test_utils::run_test(|| async {
10453            let tmp = tempfile::tempdir().unwrap();
10454            std::fs::write(tmp.path().join("case.txt"), "Hello\nhello\nHELLO").unwrap();
10455
10456            let tool = GrepTool::new(tmp.path());
10457            let out = tool
10458                .execute(
10459                    "t",
10460                    serde_json::json!({
10461                        "pattern": "hello",
10462                        "path": tmp.path().join("case.txt").to_string_lossy(),
10463                        "ignoreCase": true
10464                    }),
10465                    None,
10466                )
10467                .await
10468                .unwrap();
10469            let text = get_text(&out.content);
10470            assert!(text.contains("Hello"));
10471            assert!(text.contains("hello"));
10472            assert!(text.contains("HELLO"));
10473        });
10474    }
10475
10476    #[test]
10477    fn test_grep_case_sensitive_by_default() {
10478        asupersync::test_utils::run_test(|| async {
10479            let tmp = tempfile::tempdir().unwrap();
10480            std::fs::write(tmp.path().join("case_sensitive.txt"), "Hello\nHELLO").unwrap();
10481
10482            let tool = GrepTool::new(tmp.path());
10483            let out = tool
10484                .execute(
10485                    "t",
10486                    serde_json::json!({
10487                        "pattern": "hello",
10488                        "path": tmp.path().join("case_sensitive.txt").to_string_lossy()
10489                    }),
10490                    None,
10491                )
10492                .await
10493                .unwrap();
10494            let text = get_text(&out.content);
10495            assert!(
10496                text.contains("No matches found"),
10497                "expected case-sensitive search to find no matches, got: {text}"
10498            );
10499        });
10500    }
10501
10502    #[test]
10503    fn test_grep_append_non_matching_lines_invariant() {
10504        asupersync::test_utils::run_test(|| async {
10505            let tmp = tempfile::tempdir().unwrap();
10506            let file = tmp.path().join("base.txt");
10507            std::fs::write(&file, "needle one\nskip\nneedle two\n").unwrap();
10508
10509            let tool = GrepTool::new(tmp.path());
10510            let base_out = tool
10511                .execute(
10512                    "t",
10513                    serde_json::json!({
10514                        "pattern": "needle",
10515                        "path": file.to_string_lossy(),
10516                        "limit": 100
10517                    }),
10518                    None,
10519                )
10520                .await
10521                .unwrap();
10522            let base_text = get_text(&base_out.content);
10523
10524            std::fs::write(&file, "needle one\nskip\nneedle two\nalpha\nbeta\n").unwrap();
10525            let extended_out = tool
10526                .execute(
10527                    "t",
10528                    serde_json::json!({
10529                        "pattern": "needle",
10530                        "path": file.to_string_lossy(),
10531                        "limit": 100
10532                    }),
10533                    None,
10534                )
10535                .await
10536                .unwrap();
10537            let extended_text = get_text(&extended_out.content);
10538
10539            assert_eq!(
10540                base_text, extended_text,
10541                "adding non-matching lines should not alter grep output"
10542            );
10543        });
10544    }
10545
10546    #[test]
10547    fn test_grep_no_matches() {
10548        asupersync::test_utils::run_test(|| async {
10549            let tmp = tempfile::tempdir().unwrap();
10550            std::fs::write(tmp.path().join("nothing.txt"), "alpha\nbeta\ngamma").unwrap();
10551
10552            let tool = GrepTool::new(tmp.path());
10553            let out = tool
10554                .execute(
10555                    "t",
10556                    serde_json::json!({
10557                        "pattern": "ZZZZZ_NOMATCH",
10558                        "path": tmp.path().join("nothing.txt").to_string_lossy()
10559                    }),
10560                    None,
10561                )
10562                .await
10563                .unwrap();
10564            let text = get_text(&out.content);
10565            assert!(
10566                text.to_lowercase().contains("no match")
10567                    || text.is_empty()
10568                    || text.to_lowercase().contains("no results"),
10569                "expected no-match indication, got: {text}"
10570            );
10571        });
10572    }
10573
10574    #[test]
10575    fn test_grep_context_lines() {
10576        asupersync::test_utils::run_test(|| async {
10577            let tmp = tempfile::tempdir().unwrap();
10578            std::fs::write(
10579                tmp.path().join("ctx.txt"),
10580                "aaa\nbbb\nccc\ntarget\nddd\neee\nfff",
10581            )
10582            .unwrap();
10583
10584            let tool = GrepTool::new(tmp.path());
10585            let out = tool
10586                .execute(
10587                    "t",
10588                    serde_json::json!({
10589                        "pattern": "target",
10590                        "path": tmp.path().join("ctx.txt").to_string_lossy(),
10591                        "context": 1
10592                    }),
10593                    None,
10594                )
10595                .await
10596                .unwrap();
10597            let text = get_text(&out.content);
10598            assert!(text.contains("target"));
10599            assert!(text.contains("ccc"), "expected context line before match");
10600            assert!(text.contains("ddd"), "expected context line after match");
10601        });
10602    }
10603
10604    #[test]
10605    fn test_grep_limit() {
10606        asupersync::test_utils::run_test(|| async {
10607            let tmp = tempfile::tempdir().unwrap();
10608            let content: String = (0..200)
10609                .map(|i| format!("match_line_{i}"))
10610                .collect::<Vec<_>>()
10611                .join("\n");
10612            std::fs::write(tmp.path().join("many.txt"), &content).unwrap();
10613
10614            let tool = GrepTool::new(tmp.path());
10615            let out = tool
10616                .execute(
10617                    "t",
10618                    serde_json::json!({
10619                        "pattern": "match_line",
10620                        "path": tmp.path().join("many.txt").to_string_lossy(),
10621                        "limit": 5
10622                    }),
10623                    None,
10624                )
10625                .await
10626                .unwrap();
10627            let text = get_text(&out.content);
10628            // With limit=5, we should see at most 5 matches
10629            let match_count = text.matches("match_line_").count();
10630            assert!(
10631                match_count <= 5,
10632                "expected at most 5 matches with limit=5, got {match_count}"
10633            );
10634            let details = out.details.expect("expected limit details");
10635            assert_eq!(
10636                details
10637                    .get("matchLimitReached")
10638                    .and_then(serde_json::Value::as_u64),
10639                Some(5)
10640            );
10641        });
10642    }
10643
10644    #[test]
10645    fn test_grep_exact_limit_does_not_report_limit_reached() {
10646        asupersync::test_utils::run_test(|| async {
10647            let tmp = tempfile::tempdir().unwrap();
10648            let content = (0..5)
10649                .map(|i| format!("match_line_{i}"))
10650                .collect::<Vec<_>>()
10651                .join("\n");
10652            std::fs::write(tmp.path().join("exact.txt"), &content).unwrap();
10653
10654            let tool = GrepTool::new(tmp.path());
10655            let out = tool
10656                .execute(
10657                    "t",
10658                    serde_json::json!({
10659                        "pattern": "match_line",
10660                        "path": tmp.path().join("exact.txt").to_string_lossy(),
10661                        "limit": 5
10662                    }),
10663                    None,
10664                )
10665                .await
10666                .unwrap();
10667
10668            let text = get_text(&out.content);
10669            assert_eq!(text.matches("match_line_").count(), 5);
10670            assert!(
10671                !text.contains("matches limit reached"),
10672                "exact-limit grep results should not claim truncation: {text}"
10673            );
10674            assert!(
10675                out.details
10676                    .as_ref()
10677                    .and_then(|details| details.get("matchLimitReached"))
10678                    .is_none(),
10679                "exact-limit grep results should not set matchLimitReached"
10680            );
10681        });
10682    }
10683
10684    #[test]
10685    fn test_grep_large_output_does_not_deadlock_reader_threads() {
10686        asupersync::test_utils::run_test(|| async {
10687            use std::fmt::Write as _;
10688
10689            let tmp = tempfile::tempdir().unwrap();
10690            let mut content = String::with_capacity(80_000);
10691            for i in 0..5000 {
10692                let _ = writeln!(&mut content, "needle_line_{i}");
10693            }
10694            let file = tmp.path().join("large_grep.txt");
10695            std::fs::write(&file, content).unwrap();
10696
10697            let tool = GrepTool::new(tmp.path());
10698            let run = tool.execute(
10699                "t",
10700                serde_json::json!({
10701                    "pattern": "needle_line_",
10702                    "path": file.to_string_lossy(),
10703                    "limit": 6000
10704                }),
10705                None,
10706            );
10707
10708            let out = asupersync::time::timeout(
10709                asupersync::time::wall_now(),
10710                Duration::from_secs(15),
10711                Box::pin(run),
10712            )
10713            .await
10714            .expect("grep timed out; possible stdout/stderr reader deadlock")
10715            .expect("grep should succeed");
10716
10717            let text = get_text(&out.content);
10718            assert!(text.contains("needle_line_0"));
10719        });
10720    }
10721
10722    #[test]
10723    fn test_grep_respects_gitignore() {
10724        asupersync::test_utils::run_test(|| async {
10725            let tmp = tempfile::tempdir().unwrap();
10726            std::fs::write(tmp.path().join(".gitignore"), "ignored.txt\n").unwrap();
10727            std::fs::write(tmp.path().join("ignored.txt"), "needle in ignored file").unwrap();
10728            std::fs::write(tmp.path().join("visible.txt"), "nothing here").unwrap();
10729
10730            let tool = GrepTool::new(tmp.path());
10731            let out = tool
10732                .execute("t", serde_json::json!({ "pattern": "needle" }), None)
10733                .await
10734                .unwrap();
10735
10736            let text = get_text(&out.content);
10737            assert!(
10738                text.contains("No matches found"),
10739                "expected ignored file to be excluded, got: {text}"
10740            );
10741        });
10742    }
10743
10744    #[test]
10745    fn test_grep_literal_mode() {
10746        asupersync::test_utils::run_test(|| async {
10747            let tmp = tempfile::tempdir().unwrap();
10748            std::fs::write(tmp.path().join("literal.txt"), "a+b\na.b\nab\na\\+b").unwrap();
10749
10750            let tool = GrepTool::new(tmp.path());
10751            let out = tool
10752                .execute(
10753                    "t",
10754                    serde_json::json!({
10755                        "pattern": "a+b",
10756                        "path": tmp.path().join("literal.txt").to_string_lossy(),
10757                        "literal": true
10758                    }),
10759                    None,
10760                )
10761                .await
10762                .unwrap();
10763            let text = get_text(&out.content);
10764            assert!(text.contains("a+b"), "literal match should find 'a+b'");
10765        });
10766    }
10767
10768    #[test]
10769    fn test_grep_hashline_output() {
10770        asupersync::test_utils::run_test(|| async {
10771            let tmp = tempfile::tempdir().unwrap();
10772            std::fs::write(
10773                tmp.path().join("hash.txt"),
10774                "apple\nbanana\napricot\ncherry",
10775            )
10776            .unwrap();
10777
10778            let tool = GrepTool::new(tmp.path());
10779            let out = tool
10780                .execute(
10781                    "t",
10782                    serde_json::json!({
10783                        "pattern": "ap",
10784                        "path": tmp.path().join("hash.txt").to_string_lossy(),
10785                        "hashline": true
10786                    }),
10787                    None,
10788                )
10789                .await
10790                .unwrap();
10791            let text = get_text(&out.content);
10792            // Hashline output should contain N#AB tags instead of bare line numbers
10793            // Line 1 (apple) and line 3 (apricot) should match
10794            assert!(text.contains("apple"), "should contain apple");
10795            assert!(text.contains("apricot"), "should contain apricot");
10796            assert!(
10797                !text.contains("banana"),
10798                "should not contain banana context"
10799            );
10800            // Verify hashline tag format: digit(s) followed by # and two uppercase letters
10801            let re = regex::Regex::new(r"\d+#[A-Z]{2}").unwrap();
10802            assert!(
10803                re.is_match(&text),
10804                "hashline output should contain N#AB tags, got: {text}"
10805            );
10806        });
10807    }
10808
10809    #[test]
10810    fn test_grep_hashline_with_context() {
10811        asupersync::test_utils::run_test(|| async {
10812            let tmp = tempfile::tempdir().unwrap();
10813            std::fs::write(
10814                tmp.path().join("ctx.txt"),
10815                "line1\nline2\ntarget\nline4\nline5",
10816            )
10817            .unwrap();
10818
10819            let tool = GrepTool::new(tmp.path());
10820            let out = tool
10821                .execute(
10822                    "t",
10823                    serde_json::json!({
10824                        "pattern": "target",
10825                        "path": tmp.path().join("ctx.txt").to_string_lossy(),
10826                        "hashline": true,
10827                        "context": 1
10828                    }),
10829                    None,
10830                )
10831                .await
10832                .unwrap();
10833            let text = get_text(&out.content);
10834            // With context=1, should include line2, target, line4
10835            assert!(text.contains("line2"), "should contain context line2");
10836            assert!(text.contains("target"), "should contain match");
10837            assert!(text.contains("line4"), "should contain context line4");
10838            // Match lines use `:` separator, context lines use `-`
10839            let re_match = regex::Regex::new(r"\d+#[A-Z]{2}: target").unwrap();
10840            assert!(
10841                re_match.is_match(&text),
10842                "match line should use : separator with hashline tag, got: {text}"
10843            );
10844            let re_ctx = regex::Regex::new(r"\d+#[A-Z]{2}- line").unwrap();
10845            assert!(
10846                re_ctx.is_match(&text),
10847                "context line should use - separator with hashline tag, got: {text}"
10848            );
10849        });
10850    }
10851
10852    // ========================================================================
10853    // Find Tool Tests
10854    // ========================================================================
10855
10856    #[test]
10857    fn test_find_glob_pattern() {
10858        asupersync::test_utils::run_test(|| async {
10859            if find_fd_binary().is_none() {
10860                return;
10861            }
10862            let tmp = tempfile::tempdir().unwrap();
10863            std::fs::write(tmp.path().join("file1.rs"), "").unwrap();
10864            std::fs::write(tmp.path().join("file2.rs"), "").unwrap();
10865            std::fs::write(tmp.path().join("file3.txt"), "").unwrap();
10866
10867            let tool = FindTool::new(tmp.path());
10868            let out = tool
10869                .execute(
10870                    "t",
10871                    serde_json::json!({
10872                        "pattern": "*.rs",
10873                        "path": tmp.path().to_string_lossy()
10874                    }),
10875                    None,
10876                )
10877                .await
10878                .unwrap();
10879            let text = get_text(&out.content);
10880            assert!(text.contains("file1.rs"));
10881            assert!(text.contains("file2.rs"));
10882            assert!(!text.contains("file3.txt"));
10883        });
10884    }
10885
10886    #[test]
10887    fn test_find_append_non_matching_file_invariant() {
10888        asupersync::test_utils::run_test(|| async {
10889            if find_fd_binary().is_none() {
10890                return;
10891            }
10892            let tmp = tempfile::tempdir().unwrap();
10893            std::fs::write(tmp.path().join("match.txt"), "a").unwrap();
10894
10895            let tool = FindTool::new(tmp.path());
10896            let base_out = tool
10897                .execute(
10898                    "t",
10899                    serde_json::json!({
10900                        "pattern": "*.txt",
10901                        "path": tmp.path().to_string_lossy()
10902                    }),
10903                    None,
10904                )
10905                .await
10906                .unwrap();
10907            let base_text = get_text(&base_out.content);
10908
10909            std::fs::write(tmp.path().join("ignore.md"), "b").unwrap();
10910            let extended_out = tool
10911                .execute(
10912                    "t",
10913                    serde_json::json!({
10914                        "pattern": "*.txt",
10915                        "path": tmp.path().to_string_lossy()
10916                    }),
10917                    None,
10918                )
10919                .await
10920                .unwrap();
10921            let extended_text = get_text(&extended_out.content);
10922
10923            assert_eq!(
10924                base_text, extended_text,
10925                "adding non-matching files should not alter find output"
10926            );
10927        });
10928    }
10929
10930    #[test]
10931    fn test_find_rejects_outside_cwd() {
10932        asupersync::test_utils::run_test(|| async {
10933            let cwd = tempfile::tempdir().unwrap();
10934            let outside = tempfile::tempdir().unwrap();
10935            std::fs::write(outside.path().join("secret.txt"), "secret").unwrap();
10936
10937            let tool = FindTool::new(cwd.path());
10938            let err = tool
10939                .execute(
10940                    "t",
10941                    serde_json::json!({
10942                        "pattern": "*.txt",
10943                        "path": outside.path().to_string_lossy()
10944                    }),
10945                    None,
10946                )
10947                .await
10948                .unwrap_err();
10949            assert!(err.to_string().contains("outside the working directory"));
10950        });
10951    }
10952
10953    #[test]
10954    fn test_find_limit() {
10955        asupersync::test_utils::run_test(|| async {
10956            if find_fd_binary().is_none() {
10957                return;
10958            }
10959            let tmp = tempfile::tempdir().unwrap();
10960            for i in 0..20 {
10961                std::fs::write(tmp.path().join(format!("f{i}.txt")), "").unwrap();
10962            }
10963
10964            let tool = FindTool::new(tmp.path());
10965            let out = tool
10966                .execute(
10967                    "t",
10968                    serde_json::json!({
10969                        "pattern": "*.txt",
10970                        "path": tmp.path().to_string_lossy(),
10971                        "limit": 5
10972                    }),
10973                    None,
10974                )
10975                .await
10976                .unwrap();
10977            let text = get_text(&out.content);
10978            let file_count = text.lines().filter(|l| l.contains(".txt")).count();
10979            assert!(
10980                file_count <= 5,
10981                "expected at most 5 files with limit=5, got {file_count}"
10982            );
10983            let details = out.details.expect("expected limit details");
10984            assert_eq!(
10985                details
10986                    .get("resultLimitReached")
10987                    .and_then(serde_json::Value::as_u64),
10988                Some(5)
10989            );
10990        });
10991    }
10992
10993    #[test]
10994    fn test_find_exact_limit_does_not_report_limit_reached() {
10995        asupersync::test_utils::run_test(|| async {
10996            if find_fd_binary().is_none() {
10997                return;
10998            }
10999            let tmp = tempfile::tempdir().unwrap();
11000            for i in 0..5 {
11001                std::fs::write(tmp.path().join(format!("f{i}.txt")), "").unwrap();
11002            }
11003
11004            let tool = FindTool::new(tmp.path());
11005            let out = tool
11006                .execute(
11007                    "t",
11008                    serde_json::json!({
11009                        "pattern": "*.txt",
11010                        "path": tmp.path().to_string_lossy(),
11011                        "limit": 5
11012                    }),
11013                    None,
11014                )
11015                .await
11016                .unwrap();
11017
11018            let text = get_text(&out.content);
11019            assert_eq!(text.lines().filter(|line| line.contains(".txt")).count(), 5);
11020            assert!(
11021                !text.contains("results limit reached"),
11022                "exact-limit find results should not claim truncation: {text}"
11023            );
11024            assert!(
11025                out.details
11026                    .as_ref()
11027                    .and_then(|details| details.get("resultLimitReached"))
11028                    .is_none(),
11029                "exact-limit find results should not set resultLimitReached"
11030            );
11031        });
11032    }
11033
11034    #[test]
11035    fn test_find_zero_limit_is_rejected() {
11036        asupersync::test_utils::run_test(|| async {
11037            if find_fd_binary().is_none() {
11038                return;
11039            }
11040            let tmp = tempfile::tempdir().unwrap();
11041            std::fs::write(tmp.path().join("file.txt"), "").unwrap();
11042
11043            let tool = FindTool::new(tmp.path());
11044            let err = tool
11045                .execute(
11046                    "t",
11047                    serde_json::json!({
11048                        "pattern": "*.txt",
11049                        "path": tmp.path().to_string_lossy(),
11050                        "limit": 0
11051                    }),
11052                    None,
11053                )
11054                .await
11055                .expect_err("limit=0 should be rejected");
11056
11057            assert!(
11058                err.to_string().contains("`limit` must be greater than 0"),
11059                "expected validation error, got: {err}"
11060            );
11061        });
11062    }
11063
11064    #[test]
11065    fn test_find_no_matches() {
11066        asupersync::test_utils::run_test(|| async {
11067            if find_fd_binary().is_none() {
11068                return;
11069            }
11070            let tmp = tempfile::tempdir().unwrap();
11071            std::fs::write(tmp.path().join("only.txt"), "").unwrap();
11072
11073            let tool = FindTool::new(tmp.path());
11074            let out = tool
11075                .execute(
11076                    "t",
11077                    serde_json::json!({
11078                        "pattern": "*.rs",
11079                        "path": tmp.path().to_string_lossy()
11080                    }),
11081                    None,
11082                )
11083                .await
11084                .unwrap();
11085            let text = get_text(&out.content);
11086            assert!(
11087                text.to_lowercase().contains("no files found")
11088                    || text.to_lowercase().contains("no matches")
11089                    || text.is_empty(),
11090                "expected no-match indication, got: {text}"
11091            );
11092        });
11093    }
11094
11095    #[test]
11096    fn test_find_nonexistent_path() {
11097        asupersync::test_utils::run_test(|| async {
11098            if find_fd_binary().is_none() {
11099                return;
11100            }
11101            let tmp = tempfile::tempdir().unwrap();
11102            let tool = FindTool::new(tmp.path());
11103            let err = tool
11104                .execute(
11105                    "t",
11106                    serde_json::json!({
11107                        "pattern": "*.rs",
11108                        "path": tmp.path().join("nonexistent").to_string_lossy()
11109                    }),
11110                    None,
11111                )
11112                .await;
11113            assert!(err.is_err());
11114        });
11115    }
11116
11117    #[test]
11118    fn test_find_nested_directories() {
11119        asupersync::test_utils::run_test(|| async {
11120            if find_fd_binary().is_none() {
11121                return;
11122            }
11123            let tmp = tempfile::tempdir().unwrap();
11124            std::fs::create_dir_all(tmp.path().join("a/b/c")).unwrap();
11125            std::fs::write(tmp.path().join("top.rs"), "").unwrap();
11126            std::fs::write(tmp.path().join("a/mid.rs"), "").unwrap();
11127            std::fs::write(tmp.path().join("a/b/c/deep.rs"), "").unwrap();
11128
11129            let tool = FindTool::new(tmp.path());
11130            let out = tool
11131                .execute(
11132                    "t",
11133                    serde_json::json!({
11134                        "pattern": "*.rs",
11135                        "path": tmp.path().to_string_lossy()
11136                    }),
11137                    None,
11138                )
11139                .await
11140                .unwrap();
11141            let text = get_text(&out.content);
11142            assert!(text.contains("top.rs"));
11143            assert!(text.contains("mid.rs"));
11144            assert!(text.contains("deep.rs"));
11145        });
11146    }
11147
11148    #[test]
11149    fn test_find_results_are_sorted() {
11150        // FindTool sorts by modification time (most recent first), then alphabetically
11151        // as a tie-breaker for files with the same mtime.
11152        asupersync::test_utils::run_test(|| async {
11153            if find_fd_binary().is_none() {
11154                return;
11155            }
11156            let tmp = tempfile::tempdir().unwrap();
11157
11158            // Create files with delays to ensure distinct modification times.
11159            // Order: oldest first, so the expected output (most recent first) is reversed.
11160            std::fs::write(tmp.path().join("oldest.txt"), "").unwrap();
11161            std::thread::sleep(std::time::Duration::from_millis(50));
11162            std::fs::write(tmp.path().join("middle.txt"), "").unwrap();
11163            std::thread::sleep(std::time::Duration::from_millis(50));
11164            std::fs::write(tmp.path().join("newest.txt"), "").unwrap();
11165
11166            let tool = FindTool::new(tmp.path());
11167            let out = tool
11168                .execute(
11169                    "t",
11170                    serde_json::json!({
11171                        "pattern": "*.txt",
11172                        "path": tmp.path().to_string_lossy()
11173                    }),
11174                    None,
11175                )
11176                .await
11177                .unwrap();
11178            let lines: Vec<String> = get_text(&out.content)
11179                .lines()
11180                .map(str::trim)
11181                .filter(|line| !line.is_empty())
11182                .map(str::to_string)
11183                .collect();
11184
11185            // Expected order: most recent first
11186            assert_eq!(
11187                lines,
11188                vec!["newest.txt", "middle.txt", "oldest.txt"],
11189                "expected mtime-sorted find output (most recent first)"
11190            );
11191        });
11192    }
11193
11194    #[test]
11195    fn test_find_respects_gitignore() {
11196        asupersync::test_utils::run_test(|| async {
11197            if find_fd_binary().is_none() {
11198                return;
11199            }
11200            let tmp = tempfile::tempdir().unwrap();
11201            std::fs::write(tmp.path().join(".gitignore"), "ignored.txt\n").unwrap();
11202            std::fs::write(tmp.path().join("ignored.txt"), "").unwrap();
11203
11204            let tool = FindTool::new(tmp.path());
11205            let out = tool
11206                .execute(
11207                    "t",
11208                    serde_json::json!({
11209                        "pattern": "*.txt",
11210                        "path": tmp.path().to_string_lossy()
11211                    }),
11212                    None,
11213                )
11214                .await
11215                .unwrap();
11216            let text = get_text(&out.content);
11217            assert!(
11218                text.contains("No files found matching pattern"),
11219                "expected .gitignore'd files to be excluded, got: {text}"
11220            );
11221        });
11222    }
11223
11224    // ========================================================================
11225    // Ls Tool Tests
11226    // ========================================================================
11227
11228    #[test]
11229    fn test_ls_directory_listing() {
11230        asupersync::test_utils::run_test(|| async {
11231            let tmp = tempfile::tempdir().unwrap();
11232            std::fs::write(tmp.path().join("file_a.txt"), "content").unwrap();
11233            std::fs::write(tmp.path().join("file_b.rs"), "fn main() {}").unwrap();
11234            std::fs::create_dir(tmp.path().join("subdir")).unwrap();
11235
11236            let tool = LsTool::new(tmp.path());
11237            let out = tool
11238                .execute(
11239                    "t",
11240                    serde_json::json!({ "path": tmp.path().to_string_lossy() }),
11241                    None,
11242                )
11243                .await
11244                .unwrap();
11245            let text = get_text(&out.content);
11246            assert!(text.contains("file_a.txt"));
11247            assert!(text.contains("file_b.rs"));
11248            assert!(text.contains("subdir"));
11249        });
11250    }
11251
11252    #[test]
11253    fn test_ls_rejects_outside_cwd() {
11254        asupersync::test_utils::run_test(|| async {
11255            let cwd = tempfile::tempdir().unwrap();
11256            let outside = tempfile::tempdir().unwrap();
11257            std::fs::write(outside.path().join("secret.txt"), "secret").unwrap();
11258
11259            let tool = LsTool::new(cwd.path());
11260            let err = tool
11261                .execute(
11262                    "t",
11263                    serde_json::json!({ "path": outside.path().to_string_lossy() }),
11264                    None,
11265                )
11266                .await
11267                .unwrap_err();
11268            assert!(err.to_string().contains("outside the working directory"));
11269        });
11270    }
11271
11272    #[test]
11273    fn test_ls_trailing_slash_for_dirs() {
11274        asupersync::test_utils::run_test(|| async {
11275            let tmp = tempfile::tempdir().unwrap();
11276            std::fs::write(tmp.path().join("file.txt"), "").unwrap();
11277            std::fs::create_dir(tmp.path().join("mydir")).unwrap();
11278
11279            let tool = LsTool::new(tmp.path());
11280            let out = tool
11281                .execute(
11282                    "t",
11283                    serde_json::json!({ "path": tmp.path().to_string_lossy() }),
11284                    None,
11285                )
11286                .await
11287                .unwrap();
11288            let text = get_text(&out.content);
11289            assert!(
11290                text.contains("mydir/"),
11291                "expected trailing slash for directory, got: {text}"
11292            );
11293        });
11294    }
11295
11296    #[test]
11297    fn test_ls_limit() {
11298        asupersync::test_utils::run_test(|| async {
11299            let tmp = tempfile::tempdir().unwrap();
11300            for i in 0..20 {
11301                std::fs::write(tmp.path().join(format!("item_{i:02}.txt")), "").unwrap();
11302            }
11303
11304            let tool = LsTool::new(tmp.path());
11305            let out = tool
11306                .execute(
11307                    "t",
11308                    serde_json::json!({
11309                        "path": tmp.path().to_string_lossy(),
11310                        "limit": 5
11311                    }),
11312                    None,
11313                )
11314                .await
11315                .unwrap();
11316            let text = get_text(&out.content);
11317            let entry_count = text.lines().filter(|l| l.contains("item_")).count();
11318            assert!(
11319                entry_count <= 5,
11320                "expected at most 5 entries, got {entry_count}"
11321            );
11322            let details = out.details.expect("expected limit details");
11323            assert_eq!(
11324                details
11325                    .get("entryLimitReached")
11326                    .and_then(serde_json::Value::as_u64),
11327                Some(5)
11328            );
11329        });
11330    }
11331
11332    #[test]
11333    fn test_ls_zero_limit_is_rejected() {
11334        asupersync::test_utils::run_test(|| async {
11335            let tmp = tempfile::tempdir().unwrap();
11336            std::fs::write(tmp.path().join("item.txt"), "").unwrap();
11337
11338            let tool = LsTool::new(tmp.path());
11339            let err = tool
11340                .execute(
11341                    "t",
11342                    serde_json::json!({
11343                        "path": tmp.path().to_string_lossy(),
11344                        "limit": 0
11345                    }),
11346                    None,
11347                )
11348                .await
11349                .expect_err("limit=0 should be rejected");
11350
11351            assert!(
11352                err.to_string().contains("`limit` must be greater than 0"),
11353                "expected validation error, got: {err}"
11354            );
11355        });
11356    }
11357
11358    #[test]
11359    fn test_ls_nonexistent_directory() {
11360        asupersync::test_utils::run_test(|| async {
11361            let tmp = tempfile::tempdir().unwrap();
11362            let tool = LsTool::new(tmp.path());
11363            let err = tool
11364                .execute(
11365                    "t",
11366                    serde_json::json!({ "path": tmp.path().join("nope").to_string_lossy() }),
11367                    None,
11368                )
11369                .await;
11370            assert!(err.is_err());
11371        });
11372    }
11373
11374    #[test]
11375    fn test_ls_empty_directory() {
11376        asupersync::test_utils::run_test(|| async {
11377            let tmp = tempfile::tempdir().unwrap();
11378            let empty_dir = tmp.path().join("empty");
11379            std::fs::create_dir(&empty_dir).unwrap();
11380
11381            let tool = LsTool::new(tmp.path());
11382            let out = tool
11383                .execute(
11384                    "t",
11385                    serde_json::json!({ "path": empty_dir.to_string_lossy() }),
11386                    None,
11387                )
11388                .await
11389                .unwrap();
11390            assert!(!out.is_error);
11391        });
11392    }
11393
11394    #[test]
11395    fn test_ls_default_cwd() {
11396        asupersync::test_utils::run_test(|| async {
11397            let tmp = tempfile::tempdir().unwrap();
11398            std::fs::write(tmp.path().join("in_cwd.txt"), "").unwrap();
11399
11400            let tool = LsTool::new(tmp.path());
11401            let out = tool
11402                .execute("t", serde_json::json!({}), None)
11403                .await
11404                .unwrap();
11405            let text = get_text(&out.content);
11406            assert!(
11407                text.contains("in_cwd.txt"),
11408                "expected cwd listing to include the file, got: {text}"
11409            );
11410        });
11411    }
11412
11413    // ========================================================================
11414    // Additional helper tests
11415    // ========================================================================
11416
11417    #[test]
11418    fn test_truncate_head_no_truncation() {
11419        let content = "short".to_string();
11420        let result = truncate_head(content, 100, 1000);
11421        assert!(!result.truncated);
11422        assert_eq!(result.content, "short");
11423        assert_eq!(result.truncated_by, None);
11424    }
11425
11426    #[test]
11427    fn test_truncate_tail_no_truncation() {
11428        let content = "short".to_string();
11429        let result = truncate_tail(content, 100, 1000);
11430        assert!(!result.truncated);
11431        assert_eq!(result.content, "short");
11432    }
11433
11434    #[test]
11435    fn test_truncate_head_empty_input() {
11436        let result = truncate_head(String::new(), 100, 1000);
11437        assert!(!result.truncated);
11438        assert_eq!(result.content, "");
11439    }
11440
11441    #[test]
11442    fn test_truncate_tail_empty_input() {
11443        let result = truncate_tail(String::new(), 100, 1000);
11444        assert!(!result.truncated);
11445        assert_eq!(result.content, "");
11446    }
11447
11448    #[test]
11449    fn test_detect_line_ending_crlf() {
11450        assert_eq!(detect_line_ending("hello\r\nworld"), "\r\n");
11451    }
11452
11453    #[test]
11454    fn test_detect_line_ending_cr() {
11455        assert_eq!(detect_line_ending("hello\rworld"), "\r");
11456    }
11457
11458    #[test]
11459    fn test_detect_line_ending_lf() {
11460        assert_eq!(detect_line_ending("hello\nworld"), "\n");
11461    }
11462
11463    #[test]
11464    fn test_detect_line_ending_no_newline() {
11465        assert_eq!(detect_line_ending("hello world"), "\n");
11466    }
11467
11468    #[test]
11469    fn test_normalize_to_lf() {
11470        assert_eq!(normalize_to_lf("a\r\nb\rc\nd"), "a\nb\nc\nd");
11471    }
11472
11473    #[test]
11474    fn test_count_overlapping_occurrences() {
11475        assert_eq!(count_overlapping_occurrences("aaaa", "aa"), 3);
11476        assert_eq!(count_overlapping_occurrences("abababa", "aba"), 3);
11477        assert_eq!(count_overlapping_occurrences("abc", "d"), 0);
11478        assert_eq!(count_overlapping_occurrences("abc", ""), 0);
11479    }
11480
11481    proptest! {
11482        #![proptest_config(ProptestConfig { cases: 64, .. ProptestConfig::default() })]
11483
11484        #[test]
11485        fn proptest_line_ending_roundtrip_invariant(
11486            input in arbitrary_text(),
11487            ending in prop_oneof![
11488                Just("\n".to_string()),
11489                Just("\r\n".to_string()),
11490                Just("\r".to_string()),
11491            ],
11492        ) {
11493            let normalized = normalize_to_lf(&input);
11494            let restored = restore_line_endings(&normalized, &ending);
11495            let renormalized = normalize_to_lf(&restored);
11496            prop_assert_eq!(renormalized, normalized);
11497        }
11498    }
11499
11500    #[test]
11501    fn test_strip_bom_present() {
11502        let (result, had_bom) = strip_bom("\u{FEFF}hello");
11503        assert_eq!(result, "hello");
11504        assert!(had_bom);
11505    }
11506
11507    #[test]
11508    fn test_strip_bom_absent() {
11509        let (result, had_bom) = strip_bom("hello");
11510        assert_eq!(result, "hello");
11511        assert!(!had_bom);
11512    }
11513
11514    #[test]
11515    fn test_resolve_path_tilde_expansion() {
11516        let cwd = PathBuf::from("/home/user/project");
11517        let result = resolve_path("~/file.txt", &cwd);
11518        // Tilde expansion depends on environment, but should not be literal ~/
11519        assert!(!result.to_string_lossy().starts_with("~/"));
11520    }
11521
11522    fn arbitrary_text() -> impl Strategy<Value = String> {
11523        prop::collection::vec(any::<u8>(), 0..512)
11524            .prop_map(|bytes| String::from_utf8_lossy(&bytes).into_owned())
11525    }
11526
11527    fn match_char_strategy() -> impl Strategy<Value = char> {
11528        prop_oneof![
11529            8 => any::<char>(),
11530            1 => Just('\u{00A0}'),
11531            1 => Just('\u{202F}'),
11532            1 => Just('\u{205F}'),
11533            1 => Just('\u{3000}'),
11534            1 => Just('\u{2018}'),
11535            1 => Just('\u{2019}'),
11536            1 => Just('\u{201C}'),
11537            1 => Just('\u{201D}'),
11538            1 => Just('\u{201E}'),
11539            1 => Just('\u{201F}'),
11540            1 => Just('\u{2010}'),
11541            1 => Just('\u{2011}'),
11542            1 => Just('\u{2012}'),
11543            1 => Just('\u{2013}'),
11544            1 => Just('\u{2014}'),
11545            1 => Just('\u{2015}'),
11546            1 => Just('\u{2212}'),
11547            1 => Just('\u{200D}'),
11548            1 => Just('\u{0301}'),
11549        ]
11550    }
11551
11552    fn arbitrary_match_text() -> impl Strategy<Value = String> {
11553        prop_oneof![
11554            9 => prop::collection::vec(match_char_strategy(), 0..2048),
11555            1 => prop::collection::vec(match_char_strategy(), 8192..16384),
11556        ]
11557        .prop_map(|chars| chars.into_iter().collect())
11558    }
11559
11560    fn line_char_strategy() -> impl Strategy<Value = char> {
11561        prop_oneof![
11562            8 => any::<char>().prop_filter("single-line chars only", |c| *c != '\n'),
11563            1 => Just('é'),
11564            1 => Just('你'),
11565            1 => Just('😀'),
11566        ]
11567    }
11568
11569    fn boundary_line_text() -> impl Strategy<Value = String> {
11570        prop_oneof![
11571            Just(0usize),
11572            Just(GREP_MAX_LINE_LENGTH.saturating_sub(1)),
11573            Just(GREP_MAX_LINE_LENGTH),
11574            Just(GREP_MAX_LINE_LENGTH + 1),
11575            0usize..(GREP_MAX_LINE_LENGTH + 128),
11576        ]
11577        .prop_flat_map(|len| {
11578            prop::collection::vec(line_char_strategy(), len)
11579                .prop_map(|chars| chars.into_iter().collect())
11580        })
11581    }
11582
11583    fn safe_relative_segment() -> impl Strategy<Value = String> {
11584        prop_oneof![
11585            proptest::string::string_regex("[A-Za-z0-9._-]{1,12}")
11586                .expect("segment regex should compile"),
11587            Just("emoji😀".to_string()),
11588            Just("accent-é".to_string()),
11589            Just("rtl-עברית".to_string()),
11590            Just("line\nbreak".to_string()),
11591            Just("nul\0byte".to_string()),
11592        ]
11593        .prop_filter("segment cannot be . or ..", |segment| {
11594            segment != "." && segment != ".."
11595        })
11596    }
11597
11598    fn safe_relative_path() -> impl Strategy<Value = String> {
11599        prop::collection::vec(safe_relative_segment(), 1..6).prop_map(|segments| segments.join("/"))
11600    }
11601
11602    fn pathish_input() -> impl Strategy<Value = String> {
11603        prop_oneof![
11604            5 => safe_relative_path(),
11605            2 => safe_relative_path().prop_map(|p| format!("../{p}")),
11606            2 => safe_relative_path().prop_map(|p| format!("../../{p}")),
11607            1 => safe_relative_path().prop_map(|p| format!("/tmp/{p}")),
11608            1 => safe_relative_path().prop_map(|p| format!("~/{p}")),
11609            1 => Just("~".to_string()),
11610            1 => Just(".".to_string()),
11611            1 => Just("..".to_string()),
11612            1 => Just("././nested/../file.txt".to_string()),
11613        ]
11614    }
11615
11616    proptest! {
11617        #![proptest_config(ProptestConfig { cases: 64, .. ProptestConfig::default() })]
11618
11619        #[test]
11620        fn proptest_truncate_head_invariants(
11621            input in arbitrary_text(),
11622            max_lines in 0usize..32,
11623            max_bytes in 0usize..256,
11624        ) {
11625            let result = truncate_head(input.clone(), max_lines, max_bytes);
11626
11627            prop_assert!(result.output_lines <= max_lines);
11628            prop_assert!(result.output_bytes <= max_bytes);
11629            prop_assert_eq!(result.output_bytes, result.content.len());
11630
11631            prop_assert_eq!(result.truncated, result.truncated_by.is_some());
11632            prop_assert!(input.starts_with(&result.content));
11633
11634            let repeat = truncate_head(result.content.clone(), max_lines, max_bytes);
11635            prop_assert_eq!(&repeat.content, &result.content);
11636
11637            if result.truncated {
11638                prop_assert!(result.total_lines > max_lines || result.total_bytes > max_bytes);
11639            } else {
11640                prop_assert_eq!(&result.content, &input);
11641                prop_assert!(result.total_lines <= max_lines);
11642                prop_assert!(result.total_bytes <= max_bytes);
11643            }
11644
11645            if result.first_line_exceeds_limit {
11646                prop_assert!(result.truncated);
11647                prop_assert_eq!(result.truncated_by, Some(TruncatedBy::Bytes));
11648                prop_assert!(result.output_bytes <= max_bytes);
11649                prop_assert!(result.output_lines <= 1);
11650                prop_assert!(input.starts_with(&result.content));
11651            }
11652        }
11653
11654        #[test]
11655        fn proptest_truncate_tail_invariants(
11656            input in arbitrary_text(),
11657            max_lines in 0usize..32,
11658            max_bytes in 0usize..256,
11659        ) {
11660            let result = truncate_tail(input.clone(), max_lines, max_bytes);
11661
11662            prop_assert!(result.output_lines <= max_lines);
11663            prop_assert!(result.output_bytes <= max_bytes);
11664            prop_assert_eq!(result.output_bytes, result.content.len());
11665
11666            prop_assert_eq!(result.truncated, result.truncated_by.is_some());
11667            prop_assert!(input.ends_with(&result.content));
11668
11669            let repeat = truncate_tail(result.content.clone(), max_lines, max_bytes);
11670            prop_assert_eq!(&repeat.content, &result.content);
11671
11672            if result.last_line_partial {
11673                prop_assert!(result.truncated);
11674                prop_assert_eq!(result.truncated_by, Some(TruncatedBy::Bytes));
11675                // Partial output may span 1-2 lines when the input has a
11676                // trailing newline (the empty line after \n is preserved).
11677                prop_assert!(result.output_lines >= 1 && result.output_lines <= 2);
11678                let content_trimmed = result.content.trim_end_matches('\n');
11679                prop_assert!(input
11680                    .split('\n')
11681                    .rev()
11682                    .any(|line| line.ends_with(content_trimmed)));
11683            }
11684        }
11685
11686        #[test]
11687        fn proptest_truncate_head_monotonic_limits(
11688            input in arbitrary_text(),
11689            max_lines_a in 0usize..32,
11690            max_lines_b in 0usize..32,
11691            max_bytes_a in 0usize..256,
11692            max_bytes_b in 0usize..256,
11693        ) {
11694            let low_lines = max_lines_a.min(max_lines_b);
11695            let high_lines = max_lines_a.max(max_lines_b);
11696            let low_bytes = max_bytes_a.min(max_bytes_b);
11697            let high_bytes = max_bytes_a.max(max_bytes_b);
11698
11699            let small = truncate_head(input.clone(), low_lines, low_bytes);
11700            let large = truncate_head(input, high_lines, high_bytes);
11701
11702            prop_assert!(large.content.starts_with(&small.content));
11703            prop_assert!(large.output_bytes >= small.output_bytes);
11704            prop_assert!(large.output_lines >= small.output_lines);
11705        }
11706
11707        #[test]
11708        fn proptest_truncate_tail_monotonic_limits(
11709            input in arbitrary_text(),
11710            max_lines_a in 0usize..32,
11711            max_lines_b in 0usize..32,
11712            max_bytes_a in 0usize..256,
11713            max_bytes_b in 0usize..256,
11714        ) {
11715            let low_lines = max_lines_a.min(max_lines_b);
11716            let high_lines = max_lines_a.max(max_lines_b);
11717            let low_bytes = max_bytes_a.min(max_bytes_b);
11718            let high_bytes = max_bytes_a.max(max_bytes_b);
11719
11720            let small = truncate_tail(input.clone(), low_lines, low_bytes);
11721            let large = truncate_tail(input, high_lines, high_bytes);
11722
11723            prop_assert!(large.content.ends_with(&small.content));
11724            prop_assert!(large.output_bytes >= small.output_bytes);
11725            prop_assert!(large.output_lines >= small.output_lines);
11726        }
11727
11728        #[test]
11729        fn proptest_truncate_head_prefix_invariant_under_append(
11730            base in arbitrary_text(),
11731            suffix in arbitrary_text(),
11732            max_lines in 0usize..32,
11733            max_bytes in 0usize..256,
11734        ) {
11735            let base_result = truncate_head(base.clone(), max_lines, max_bytes);
11736            let extended_result = truncate_head(format!("{base}{suffix}"), max_lines, max_bytes);
11737            prop_assert!(extended_result.content.starts_with(&base_result.content));
11738        }
11739
11740        #[test]
11741        fn proptest_truncate_tail_suffix_invariant_under_prepend(
11742            base in arbitrary_text(),
11743            prefix in arbitrary_text(),
11744            max_lines in 0usize..32,
11745            max_bytes in 0usize..256,
11746        ) {
11747            let base_result = truncate_tail(base.clone(), max_lines, max_bytes);
11748            let extended_result = truncate_tail(format!("{prefix}{base}"), max_lines, max_bytes);
11749            prop_assert!(extended_result.content.ends_with(&base_result.content));
11750        }
11751    }
11752
11753    proptest! {
11754        #![proptest_config(ProptestConfig { cases: 128, .. ProptestConfig::default() })]
11755
11756        #[test]
11757        fn proptest_normalize_for_match_invariants(input in arbitrary_match_text()) {
11758            let normalized = normalize_for_match(&input);
11759            let renormalized = normalize_for_match(&normalized);
11760
11761            prop_assert_eq!(&renormalized, &normalized);
11762            prop_assert!(normalized.len() <= input.len());
11763            prop_assert!(
11764                normalized.chars().all(|c| {
11765                    !is_special_unicode_space(c)
11766                        && !matches!(
11767                            c,
11768                            '\u{2018}'
11769                                | '\u{2019}'
11770                                | '\u{201C}'
11771                                | '\u{201D}'
11772                                | '\u{201E}'
11773                                | '\u{201F}'
11774                                | '\u{2010}'
11775                                | '\u{2011}'
11776                                | '\u{2012}'
11777                                | '\u{2013}'
11778                                | '\u{2014}'
11779                                | '\u{2015}'
11780                                | '\u{2212}'
11781                        )
11782                }),
11783                "normalize_for_match should remove target punctuation/space variants"
11784            );
11785        }
11786
11787        #[test]
11788        fn proptest_truncate_line_boundary_invariants(line in boundary_line_text()) {
11789            const TRUNCATION_SUFFIX: &str = "... [truncated]";
11790
11791            let result = truncate_line(&line, GREP_MAX_LINE_LENGTH);
11792            let line_char_count = line.chars().count();
11793            let suffix_chars = TRUNCATION_SUFFIX.chars().count();
11794
11795            if line_char_count <= GREP_MAX_LINE_LENGTH {
11796                prop_assert!(!result.was_truncated);
11797                prop_assert_eq!(result.text, line);
11798            } else {
11799                prop_assert!(result.was_truncated);
11800                prop_assert!(result.text.ends_with(TRUNCATION_SUFFIX));
11801                let expected_prefix: String = line.chars().take(GREP_MAX_LINE_LENGTH).collect();
11802                let expected = format!("{expected_prefix}{TRUNCATION_SUFFIX}");
11803                prop_assert_eq!(&result.text, &expected);
11804                prop_assert!(result.text.chars().count() <= GREP_MAX_LINE_LENGTH + suffix_chars);
11805            }
11806        }
11807
11808        #[test]
11809        fn proptest_resolve_path_safe_relative_invariants(relative_path in safe_relative_path()) {
11810            let cwd = PathBuf::from("/tmp/pi-agent-rust-tools-proptest");
11811            let resolved = resolve_path(&relative_path, &cwd);
11812            let normalized = normalize_dot_segments(&resolved);
11813
11814            prop_assert_eq!(&resolved, &cwd.join(&relative_path));
11815            prop_assert!(resolved.starts_with(&cwd));
11816            prop_assert!(normalized.starts_with(&cwd));
11817            prop_assert_eq!(normalize_dot_segments(&normalized), normalized);
11818        }
11819
11820        #[test]
11821        fn proptest_normalize_dot_segments_pathish_invariants(path_input in pathish_input()) {
11822            let cwd = PathBuf::from("/tmp/pi-agent-rust-tools-proptest");
11823            let resolved = resolve_path(&path_input, &cwd);
11824            let normalized_once = normalize_dot_segments(&resolved);
11825            let normalized_twice = normalize_dot_segments(&normalized_once);
11826
11827            prop_assert_eq!(&normalized_once, &normalized_twice);
11828            prop_assert!(
11829                normalized_once
11830                    .components()
11831                    .all(|component| !matches!(component, std::path::Component::CurDir))
11832            );
11833
11834            if std::path::Path::new(&path_input).is_absolute() {
11835                prop_assert!(resolved.is_absolute());
11836                prop_assert!(normalized_once.is_absolute());
11837            }
11838        }
11839    }
11840
11841    // ========================================================================
11842    // Fuzzy find / edit-matching strategies
11843    // ========================================================================
11844
11845    /// Strategy generating content text with occasional Unicode normalization
11846    /// targets (curly quotes, special spaces, em-dashes) and trailing
11847    /// whitespace.
11848    fn fuzzy_content_strategy() -> impl Strategy<Value = String> {
11849        prop::collection::vec(
11850            prop_oneof![
11851                8 => any::<char>().prop_filter("no nul", |c| *c != '\0'),
11852                1 => Just('\u{00A0}'),
11853                1 => Just('\u{2019}'),
11854                1 => Just('\u{201C}'),
11855                1 => Just('\u{2014}'),
11856            ],
11857            1..512,
11858        )
11859        .prop_map(|chars| chars.into_iter().collect())
11860    }
11861
11862    /// Strategy for generating a needle substring from content. Picks a
11863    /// random sub-slice of the content (may be empty).
11864    fn needle_from_content(content: String) -> impl Strategy<Value = (String, String)> {
11865        let len = content.len();
11866        if len == 0 {
11867            return Just((content, String::new())).boxed();
11868        }
11869        (0..len)
11870            .prop_flat_map(move |start| {
11871                let c = content.clone();
11872                let remaining = c.len() - start;
11873                let max_needle = remaining.min(256);
11874                (Just(c), start..=start + max_needle.saturating_sub(1))
11875            })
11876            .prop_filter_map("valid char boundary", |(c, end)| {
11877                // Find the nearest valid char boundaries
11878                let start_candidates: Vec<usize> =
11879                    (0..c.len()).filter(|i| c.is_char_boundary(*i)).collect();
11880                if start_candidates.is_empty() {
11881                    return None;
11882                }
11883                let start = *start_candidates
11884                    .iter()
11885                    .min_by_key(|&&i| i.abs_diff(end.saturating_sub(end / 2)))
11886                    .unwrap_or(&0);
11887                let end_clamped = end.min(c.len());
11888                // Find next valid char boundary >= end_clamped
11889                let actual_end = (end_clamped..=c.len())
11890                    .find(|i| c.is_char_boundary(*i))
11891                    .unwrap_or(c.len());
11892                if start >= actual_end {
11893                    return Some((c, String::new()));
11894                }
11895                Some((c.clone(), c[start..actual_end].to_string()))
11896            })
11897            .boxed()
11898    }
11899
11900    proptest! {
11901        #![proptest_config(ProptestConfig { cases: 128, .. ProptestConfig::default() })]
11902
11903        /// Exact substrings of content are always found by `fuzzy_find_text`.
11904        #[test]
11905        fn proptest_fuzzy_find_text_exact_match_invariants(
11906            (content, needle) in fuzzy_content_strategy().prop_flat_map(needle_from_content)
11907        ) {
11908            let result = fuzzy_find_text(&content, &needle);
11909            if needle.is_empty() {
11910                // Empty needle: exact match at index 0 (str::find("") == Some(0))
11911                prop_assert!(result.found, "empty needle should always match");
11912                prop_assert_eq!(result.index, 0);
11913                prop_assert_eq!(result.match_length, 0);
11914            } else {
11915                prop_assert!(
11916                    result.found,
11917                    "exact substring must be found: content len={}, needle len={}",
11918                    content.len(),
11919                    needle.len()
11920                );
11921                // The matched span should be valid UTF-8 byte indices
11922                prop_assert!(content.is_char_boundary(result.index));
11923                prop_assert!(content.is_char_boundary(result.index + result.match_length));
11924                // The matched text should contain the needle (exact match path)
11925                let matched = &content[result.index..result.index + result.match_length];
11926                prop_assert_eq!(matched, needle.as_str());
11927            }
11928        }
11929
11930        /// Normalized text with Unicode variants is found via fuzzy matching.
11931        /// If we take content containing curly quotes / em-dashes, normalize
11932        /// it, then search for the normalized version, `fuzzy_find_text` must
11933        /// locate it.
11934        #[test]
11935        fn proptest_fuzzy_find_text_normalized_match_invariants(
11936            content in arbitrary_match_text()
11937        ) {
11938            // Normalize the whole content to get an ASCII-equivalent version
11939            let normalized = build_normalized_content(&content);
11940            if normalized.is_empty() {
11941                return Ok(());
11942            }
11943            // Take a prefix of normalized as needle (up to 128 chars)
11944            let needle_end = normalized
11945                .char_indices()
11946                .nth(128.min(normalized.chars().count().saturating_sub(1)))
11947                .map_or(normalized.len(), |(i, _)| i);
11948            // Find the nearest char boundary
11949            let needle_end = (needle_end..=normalized.len())
11950                .find(|i| normalized.is_char_boundary(*i))
11951                .unwrap_or(normalized.len());
11952            let needle = &normalized[..needle_end];
11953            if needle.is_empty() {
11954                return Ok(());
11955            }
11956
11957            let result = fuzzy_find_text(&content, needle);
11958            prop_assert!(
11959                result.found,
11960                "normalized needle should be found via fuzzy match: needle={:?}",
11961                needle
11962            );
11963            // Verify the result points to valid UTF-8
11964            prop_assert!(content.is_char_boundary(result.index));
11965            prop_assert!(content.is_char_boundary(result.index + result.match_length));
11966        }
11967
11968        /// `build_normalized_content` should be idempotent and never larger
11969        /// than the input.
11970        #[test]
11971        fn proptest_build_normalized_content_invariants(input in arbitrary_match_text()) {
11972            let normalized = build_normalized_content(&input);
11973            let renormalized = build_normalized_content(&normalized);
11974
11975            // Idempotency
11976            prop_assert_eq!(
11977                &renormalized,
11978                &normalized,
11979                "build_normalized_content should be idempotent"
11980            );
11981
11982            // Size: normalized text strips trailing whitespace per line and
11983            // may replace multi-byte Unicode with single-byte ASCII, so it
11984            // should never be larger than the input.
11985            prop_assert!(
11986                normalized.len() <= input.len(),
11987                "normalized should not be larger: {} vs {}",
11988                normalized.len(),
11989                input.len()
11990            );
11991
11992            // Line count should be preserved (normalization does not add or
11993            // remove newlines).
11994            let input_lines = input.split('\n').count();
11995            let norm_lines = normalized.split('\n').count();
11996            prop_assert_eq!(
11997                norm_lines, input_lines,
11998                "line count must be preserved by normalization"
11999            );
12000
12001            // No target Unicode chars should remain
12002            prop_assert!(
12003                normalized.chars().all(|c| {
12004                    !is_special_unicode_space(c)
12005                        && !matches!(
12006                            c,
12007                            '\u{2018}'
12008                                | '\u{2019}'
12009                                | '\u{201C}'
12010                                | '\u{201D}'
12011                                | '\u{201E}'
12012                                | '\u{201F}'
12013                                | '\u{2010}'
12014                                | '\u{2011}'
12015                                | '\u{2012}'
12016                                | '\u{2013}'
12017                                | '\u{2014}'
12018                                | '\u{2015}'
12019                                | '\u{2212}'
12020                        )
12021                }),
12022                "normalized content should not contain target Unicode chars"
12023            );
12024        }
12025
12026        /// Appending trailing whitespace to each line should not change the
12027        /// normalized content (metamorphic invariant).
12028        #[test]
12029        fn proptest_build_normalized_content_trailing_whitespace_invariant(
12030            input in arbitrary_match_text()
12031        ) {
12032            let normalized = build_normalized_content(&input);
12033            let mut with_trailing = String::new();
12034            let mut lines = input.split('\n').peekable();
12035
12036            while let Some(line) = lines.next() {
12037                with_trailing.push_str(line);
12038                with_trailing.push_str("  \t");
12039                if lines.peek().is_some() {
12040                    with_trailing.push('\n');
12041                }
12042            }
12043
12044            let normalized_trailing = build_normalized_content(&with_trailing);
12045            prop_assert_eq!(normalized_trailing, normalized);
12046        }
12047
12048        /// `map_normalized_range_to_original` should produce valid byte
12049        /// ranges in the original content and the extracted original slice,
12050        /// when re-normalized, should start with the expected normalized
12051        /// prefix. Trailing whitespace at line ends makes an exact match
12052        /// impossible (normalization strips it), so we verify the key
12053        /// structural invariant: the range is valid and the non-whitespace
12054        /// content round-trips correctly.
12055        #[test]
12056        fn proptest_map_normalized_range_roundtrip(input in arbitrary_match_text()) {
12057            let normalized = build_normalized_content(&input);
12058            if normalized.is_empty() {
12059                return Ok(());
12060            }
12061
12062            // Pick a range in the normalized text at char boundaries
12063            let norm_chars: Vec<(usize, char)> = normalized.char_indices().collect();
12064            let norm_len = norm_chars.len();
12065            if norm_len == 0 {
12066                return Ok(());
12067            }
12068
12069            // Use the first quarter as the match range for determinism
12070            let end_char = (norm_len / 4).max(1).min(norm_len);
12071            let norm_start = norm_chars[0].0;
12072            let norm_end = if end_char < norm_chars.len() {
12073                norm_chars[end_char].0
12074            } else {
12075                normalized.len()
12076            };
12077            let norm_match_len = norm_end - norm_start;
12078
12079            let (orig_start, orig_len) =
12080                map_normalized_range_to_original(&input, norm_start, norm_match_len);
12081
12082            // Invariant 1: result is within input bounds
12083            prop_assert!(
12084                orig_start + orig_len <= input.len(),
12085                "mapped range {orig_start}..{} exceeds input len {}",
12086                orig_start + orig_len,
12087                input.len()
12088            );
12089
12090            // Invariant 2: result is at valid char boundaries
12091            prop_assert!(
12092                input.is_char_boundary(orig_start),
12093                "orig_start {} is not a char boundary",
12094                orig_start
12095            );
12096            prop_assert!(
12097                input.is_char_boundary(orig_start + orig_len),
12098                "orig_end {} is not a char boundary",
12099                orig_start + orig_len
12100            );
12101
12102            // Invariant 3: original range is at least as large as
12103            // normalized range (original may include trailing whitespace
12104            // and multi-byte Unicode chars that normalize to fewer bytes)
12105            prop_assert!(
12106                orig_len >= norm_match_len
12107                    || orig_len == 0
12108                    || norm_match_len == 0,
12109                "original range ({orig_len}) should be >= normalized range ({norm_match_len})"
12110            );
12111
12112            // Invariant 4: the normalized expected slice, when searched
12113            // for in the original content via fuzzy_find_text, should be
12114            // found at or before the mapped position.
12115            let expected_norm = &normalized[norm_start..norm_end];
12116            if !expected_norm.is_empty() {
12117                let fuzzy_result = fuzzy_find_text(&input, expected_norm);
12118                prop_assert!(
12119                    fuzzy_result.found,
12120                    "normalized needle should be findable in original content"
12121                );
12122            }
12123        }
12124    }
12125
12126    #[test]
12127    fn test_truncate_head_preserves_newline() {
12128        // "Line1\nLine2" truncated to 1 line should be "Line1\n"
12129        let content = "Line1\nLine2".to_string();
12130        let result = truncate_head(content, 1, 1000);
12131        assert_eq!(result.content, "Line1\n");
12132
12133        // "Line1" truncated to 1 line should be "Line1"
12134        let content = "Line1".to_string();
12135        let result = truncate_head(content, 1, 1000);
12136        assert_eq!(result.content, "Line1");
12137
12138        // "Line1\n" truncated to 1 line should be "Line1\n"
12139        let content = "Line1\n".to_string();
12140        let result = truncate_head(content, 1, 1000);
12141        assert_eq!(result.content, "Line1\n");
12142    }
12143
12144    #[test]
12145    fn test_edit_crlf_content_correctness() {
12146        // Regression test: ensure we don't mix original indices with normalized content slices.
12147        asupersync::test_utils::run_test(|| async {
12148            let tmp = tempfile::tempdir().unwrap();
12149            let path = tmp.path().join("crlf.txt");
12150            // "line1" (5) + "\r\n" (2) + "line2" (5) + "\r\n" (2) + "line3" (5) = 19 bytes
12151            let content = "line1\r\nline2\r\nline3";
12152            std::fs::write(&path, content).unwrap();
12153
12154            let tool = EditTool::new(tmp.path());
12155
12156            // Replacing "line2" should work correctly and preserve CRLF.
12157            // Original "line2" is at index 7. Normalized "line2" is at index 6.
12158            // If we used original index (7) on normalized string ("line1\nline2\nline3"),
12159            // we would start at "ine2..." instead of "line2...", corrupting the file.
12160            let out = tool
12161                .execute(
12162                    "t",
12163                    serde_json::json!({
12164                        "path": path.to_string_lossy(),
12165                        "oldText": "line2",
12166                        "newText": "changed"
12167                    }),
12168                    None,
12169                )
12170                .await
12171                .unwrap();
12172
12173            assert!(!out.is_error);
12174            let new_content = std::fs::read_to_string(&path).unwrap();
12175
12176            // Expect: "line1\r\nchanged\r\nline3"
12177            assert_eq!(new_content, "line1\r\nchanged\r\nline3");
12178        });
12179    }
12180
12181    #[test]
12182    fn test_edit_cr_content_correctness() {
12183        asupersync::test_utils::run_test(|| async {
12184            let tmp = tempfile::tempdir().unwrap();
12185            let path = tmp.path().join("cr.txt");
12186            std::fs::write(&path, "line1\rline2\rline3").unwrap();
12187
12188            let tool = EditTool::new(tmp.path());
12189            let out = tool
12190                .execute(
12191                    "t",
12192                    serde_json::json!({
12193                        "path": path.to_string_lossy(),
12194                        "oldText": "line2",
12195                        "newText": "changed"
12196                    }),
12197                    None,
12198                )
12199                .await
12200                .unwrap();
12201
12202            assert!(!out.is_error);
12203            let new_content = std::fs::read_to_string(&path).unwrap();
12204            assert_eq!(new_content, "line1\rchanged\rline3");
12205        });
12206    }
12207
12208    // ========================================================================
12209    // Hashline tests
12210    // ========================================================================
12211
12212    #[test]
12213    fn test_compute_line_hash_basic() {
12214        // Same content at same index should produce same hash
12215        let h1 = compute_line_hash(0, "fn main() {");
12216        let h2 = compute_line_hash(0, "fn main() {");
12217        assert_eq!(h1, h2);
12218
12219        // Different content should (usually) produce different hash
12220        let h3 = compute_line_hash(0, "fn foo() {");
12221        // Not guaranteed different for all inputs, but these specific ones should differ
12222        assert_ne!(h1, h3);
12223
12224        // Hash is 2 bytes from NIBBLE_STR
12225        for &b in &h1 {
12226            assert!(NIBBLE_STR.contains(&b), "hash byte {b} not in NIBBLE_STR");
12227        }
12228    }
12229
12230    #[test]
12231    fn test_compute_line_hash_punctuation_only() {
12232        // Punctuation-only lines use line_idx as seed, so same content at
12233        // different indices should produce different hashes.
12234        let h1 = compute_line_hash(0, "}");
12235        let h2 = compute_line_hash(1, "}");
12236        assert_ne!(
12237            h1, h2,
12238            "punctuation-only lines at different indices should differ"
12239        );
12240
12241        // Blank lines also use idx as seed
12242        let h3 = compute_line_hash(0, "");
12243        let h4 = compute_line_hash(1, "");
12244        assert_ne!(h3, h4);
12245    }
12246
12247    #[test]
12248    fn test_compute_line_hash_whitespace_invariant() {
12249        // Leading/trailing whitespace should not affect hash (whitespace stripped)
12250        let h1 = compute_line_hash(0, "return 42;");
12251        let h2 = compute_line_hash(0, "    return 42;");
12252        let h3 = compute_line_hash(0, "\treturn 42;");
12253        assert_eq!(h1, h2);
12254        assert_eq!(h1, h3);
12255    }
12256
12257    #[test]
12258    fn test_format_hashline_tag() {
12259        let tag = format_hashline_tag(0, "fn main() {");
12260        // Should be "1#XX" format (1-indexed)
12261        assert!(
12262            tag.starts_with("1#"),
12263            "tag should start with 1#, got: {tag}"
12264        );
12265        assert_eq!(tag.len(), 4, "tag should be 4 chars: N#AB");
12266
12267        let tag10 = format_hashline_tag(9, "line 10");
12268        assert!(tag10.starts_with("10#"));
12269        assert_eq!(tag10.len(), 5); // "10#AB"
12270    }
12271
12272    #[test]
12273    fn test_parse_hashline_tag_valid() {
12274        // Simple valid tag
12275        let (line, hash) = parse_hashline_tag("5#KJ").unwrap();
12276        assert_eq!(line, 5);
12277        assert_eq!(hash, [b'K', b'J']);
12278
12279        // With spaces around #
12280        let (line, hash) = parse_hashline_tag("  10 # QR ").unwrap();
12281        assert_eq!(line, 10);
12282        assert_eq!(hash, [b'Q', b'R']);
12283
12284        // With diff markers
12285        let (line, hash) = parse_hashline_tag("> + 3#ZZ").unwrap();
12286        assert_eq!(line, 3);
12287        assert_eq!(hash, [b'Z', b'Z']);
12288    }
12289
12290    #[test]
12291    fn test_parse_hashline_tag_invalid() {
12292        // Line number 0
12293        assert!(parse_hashline_tag("0#KJ").is_err());
12294        // No hash
12295        assert!(parse_hashline_tag("5#").is_err());
12296        // Invalid chars in hash
12297        assert!(parse_hashline_tag("5#AA").is_err()); // 'A' not in NIBBLE_STR
12298        // No number
12299        assert!(parse_hashline_tag("#KJ").is_err());
12300        // Empty
12301        assert!(parse_hashline_tag("").is_err());
12302    }
12303
12304    #[test]
12305    fn test_strip_hashline_prefix() {
12306        assert_eq!(strip_hashline_prefix("5#KJ:hello world"), "hello world");
12307        assert_eq!(strip_hashline_prefix("100#ZZ:fn main() {"), "fn main() {");
12308        assert_eq!(strip_hashline_prefix(" 5 # KJ:hello world"), "hello world");
12309        assert_eq!(strip_hashline_prefix("> + 5#KJ:hello world"), "hello world");
12310        assert_eq!(strip_hashline_prefix("5#KJ :hello world"), "hello world");
12311        // No prefix → unchanged
12312        assert_eq!(strip_hashline_prefix("hello world"), "hello world");
12313        assert_eq!(strip_hashline_prefix(""), "");
12314    }
12315
12316    #[test]
12317    fn test_hashline_edit_single_replace() {
12318        asupersync::test_utils::run_test(|| async {
12319            let dir = tempfile::tempdir().unwrap();
12320            let file = dir.path().join("test.txt");
12321            std::fs::write(&file, "line1\nline2\nline3\n").unwrap();
12322
12323            let tool = HashlineEditTool::new(dir.path());
12324
12325            // Get the hash for line 2 (idx=1)
12326            let tag2 = format_hashline_tag(1, "line2");
12327
12328            let input = serde_json::json!({
12329                "path": file.to_str().unwrap(),
12330                "edits": [{
12331                    "op": "replace",
12332                    "pos": tag2,
12333                    "lines": ["changed"]
12334                }]
12335            });
12336
12337            let out = tool.execute("test", input, None).await.unwrap();
12338            assert!(!out.is_error);
12339
12340            let content = std::fs::read_to_string(&file).unwrap();
12341            assert_eq!(content, "line1\nchanged\nline3\n");
12342        });
12343    }
12344
12345    #[test]
12346    fn test_hashline_edit_range_replace() {
12347        asupersync::test_utils::run_test(|| async {
12348            let dir = tempfile::tempdir().unwrap();
12349            let file = dir.path().join("test.txt");
12350            std::fs::write(&file, "a\nb\nc\nd\ne\n").unwrap();
12351
12352            let tool = HashlineEditTool::new(dir.path());
12353
12354            let tag_b = format_hashline_tag(1, "b");
12355            let tag_d = format_hashline_tag(3, "d");
12356
12357            let input = serde_json::json!({
12358                "path": file.to_str().unwrap(),
12359                "edits": [{
12360                    "op": "replace",
12361                    "pos": tag_b,
12362                    "end": tag_d,
12363                    "lines": ["X", "Y"]
12364                }]
12365            });
12366
12367            let out = tool.execute("test", input, None).await.unwrap();
12368            assert!(!out.is_error);
12369
12370            let content = std::fs::read_to_string(&file).unwrap();
12371            assert_eq!(content, "a\nX\nY\ne\n");
12372        });
12373    }
12374
12375    #[test]
12376    fn test_hashline_edit_prepend() {
12377        asupersync::test_utils::run_test(|| async {
12378            let dir = tempfile::tempdir().unwrap();
12379            let file = dir.path().join("test.txt");
12380            std::fs::write(&file, "a\nb\nc\n").unwrap();
12381
12382            let tool = HashlineEditTool::new(dir.path());
12383            let tag_b = format_hashline_tag(1, "b");
12384
12385            let input = serde_json::json!({
12386                "path": file.to_str().unwrap(),
12387                "edits": [{
12388                    "op": "prepend",
12389                    "pos": tag_b,
12390                    "lines": ["inserted"]
12391                }]
12392            });
12393
12394            let out = tool.execute("test", input, None).await.unwrap();
12395            assert!(!out.is_error);
12396
12397            let content = std::fs::read_to_string(&file).unwrap();
12398            assert_eq!(content, "a\ninserted\nb\nc\n");
12399        });
12400    }
12401
12402    #[test]
12403    fn test_hashline_edit_append() {
12404        asupersync::test_utils::run_test(|| async {
12405            let dir = tempfile::tempdir().unwrap();
12406            let file = dir.path().join("test.txt");
12407            std::fs::write(&file, "a\nb\nc\n").unwrap();
12408
12409            let tool = HashlineEditTool::new(dir.path());
12410            let tag_b = format_hashline_tag(1, "b");
12411
12412            let input = serde_json::json!({
12413                "path": file.to_str().unwrap(),
12414                "edits": [{
12415                    "op": "append",
12416                    "pos": tag_b,
12417                    "lines": ["inserted"]
12418                }]
12419            });
12420
12421            let out = tool.execute("test", input, None).await.unwrap();
12422            assert!(!out.is_error);
12423
12424            let content = std::fs::read_to_string(&file).unwrap();
12425            assert_eq!(content, "a\nb\ninserted\nc\n");
12426        });
12427    }
12428
12429    #[test]
12430    fn test_hashline_edit_bottom_up_ordering() {
12431        asupersync::test_utils::run_test(|| async {
12432            let dir = tempfile::tempdir().unwrap();
12433            let file = dir.path().join("test.txt");
12434            std::fs::write(&file, "a\nb\nc\nd\n").unwrap();
12435
12436            let tool = HashlineEditTool::new(dir.path());
12437            let tag_b = format_hashline_tag(1, "b");
12438            let tag_d = format_hashline_tag(3, "d");
12439
12440            // Two edits at different positions — both should apply correctly
12441            let input = serde_json::json!({
12442                "path": file.to_str().unwrap(),
12443                "edits": [
12444                    { "op": "replace", "pos": tag_b, "lines": ["B"] },
12445                    { "op": "replace", "pos": tag_d, "lines": ["D"] }
12446                ]
12447            });
12448
12449            let out = tool.execute("test", input, None).await.unwrap();
12450            assert!(!out.is_error);
12451
12452            let content = std::fs::read_to_string(&file).unwrap();
12453            assert_eq!(content, "a\nB\nc\nD\n");
12454        });
12455    }
12456
12457    #[test]
12458    fn test_hashline_edit_hash_mismatch() {
12459        asupersync::test_utils::run_test(|| async {
12460            let dir = tempfile::tempdir().unwrap();
12461            let file = dir.path().join("test.txt");
12462            std::fs::write(&file, "hello\nworld\n").unwrap();
12463
12464            let tool = HashlineEditTool::new(dir.path());
12465
12466            // Use a deliberately wrong hash
12467            let input = serde_json::json!({
12468                "path": file.to_str().unwrap(),
12469                "edits": [{
12470                    "op": "replace",
12471                    "pos": "1#ZZ",
12472                    "lines": ["changed"]
12473                }]
12474            });
12475
12476            let result = tool.execute("test", input, None).await;
12477            assert!(result.is_err());
12478            let err_msg = result.unwrap_err().to_string();
12479            assert!(
12480                err_msg.contains("Hash validation failed"),
12481                "error should mention hash validation: {err_msg}"
12482            );
12483        });
12484    }
12485
12486    #[test]
12487    fn test_hashline_edit_dedup() {
12488        asupersync::test_utils::run_test(|| async {
12489            let dir = tempfile::tempdir().unwrap();
12490            let file = dir.path().join("test.txt");
12491            std::fs::write(&file, "a\nb\nc\n").unwrap();
12492
12493            let tool = HashlineEditTool::new(dir.path());
12494            let tag_b = format_hashline_tag(1, "b");
12495
12496            // Duplicate edits should be deduplicated
12497            let input = serde_json::json!({
12498                "path": file.to_str().unwrap(),
12499                "edits": [
12500                    { "op": "replace", "pos": &tag_b, "lines": ["B"] },
12501                    { "op": "replace", "pos": &tag_b, "lines": ["B"] }
12502                ]
12503            });
12504
12505            let out = tool.execute("test", input, None).await.unwrap();
12506            assert!(!out.is_error);
12507
12508            let content = std::fs::read_to_string(&file).unwrap();
12509            assert_eq!(content, "a\nB\nc\n");
12510        });
12511    }
12512
12513    #[test]
12514    fn test_hashline_edit_noop_detection() {
12515        asupersync::test_utils::run_test(|| async {
12516            let dir = tempfile::tempdir().unwrap();
12517            let file = dir.path().join("test.txt");
12518            std::fs::write(&file, "a\nb\nc\n").unwrap();
12519
12520            let tool = HashlineEditTool::new(dir.path());
12521            let tag_b = format_hashline_tag(1, "b");
12522
12523            // Replacing with identical content is a no-op
12524            let input = serde_json::json!({
12525                "path": file.to_str().unwrap(),
12526                "edits": [{
12527                    "op": "replace",
12528                    "pos": &tag_b,
12529                    "lines": ["b"]
12530                }]
12531            });
12532
12533            let result = tool.execute("test", input, None).await;
12534            assert!(result.is_err());
12535            let err_msg = result.unwrap_err().to_string();
12536            assert!(
12537                err_msg.contains("no-ops"),
12538                "error should mention no-ops: {err_msg}"
12539            );
12540        });
12541    }
12542
12543    #[test]
12544    fn test_hashline_read_output_format() {
12545        asupersync::test_utils::run_test(|| async {
12546            let dir = tempfile::tempdir().unwrap();
12547            let file = dir.path().join("test.txt");
12548            std::fs::write(&file, "fn main() {\n    println!(\"hello\");\n}\n").unwrap();
12549
12550            let tool = ReadTool::new(dir.path());
12551            let input = serde_json::json!({
12552                "path": file.to_str().unwrap(),
12553                "hashline": true
12554            });
12555
12556            let out = tool.execute("test", input, None).await.unwrap();
12557            assert!(!out.is_error);
12558            let text = get_text(&out.content);
12559
12560            // Each line should be in N#AB:content format
12561            for line in text.lines() {
12562                if line.starts_with('[') || line.is_empty() {
12563                    continue; // skip metadata lines
12564                }
12565                assert!(
12566                    hashline_tag_regex().is_match(line),
12567                    "line should match hashline format: {line:?}"
12568                );
12569                assert!(
12570                    line.contains(':'),
12571                    "line should contain ':' separator: {line:?}"
12572                );
12573            }
12574
12575            // First line should start with "1#"
12576            let first_line = text.lines().next().unwrap();
12577            assert!(first_line.starts_with("1#"), "first line: {first_line:?}");
12578        });
12579    }
12580
12581    #[test]
12582    fn test_hashline_edit_prefix_stripping() {
12583        asupersync::test_utils::run_test(|| async {
12584            let dir = tempfile::tempdir().unwrap();
12585            let file = dir.path().join("test.txt");
12586            std::fs::write(&file, "a\nb\nc\n").unwrap();
12587
12588            let tool = HashlineEditTool::new(dir.path());
12589            let tag_b = format_hashline_tag(1, "b");
12590
12591            // Model copies hashline tags into replacement — they should be stripped
12592            let input = serde_json::json!({
12593                "path": file.to_str().unwrap(),
12594                "edits": [{
12595                    "op": "replace",
12596                    "pos": &tag_b,
12597                    "lines": ["2#KJ:changed"]
12598                }]
12599            });
12600
12601            let out = tool.execute("test", input, None).await.unwrap();
12602            assert!(!out.is_error);
12603
12604            let content = std::fs::read_to_string(&file).unwrap();
12605            assert_eq!(content, "a\nchanged\nc\n");
12606        });
12607    }
12608
12609    #[test]
12610    fn test_hashline_edit_delete_lines() {
12611        asupersync::test_utils::run_test(|| async {
12612            let dir = tempfile::tempdir().unwrap();
12613            let file = dir.path().join("test.txt");
12614            std::fs::write(&file, "a\nb\nc\nd\n").unwrap();
12615
12616            let tool = HashlineEditTool::new(dir.path());
12617            let tag_b = format_hashline_tag(1, "b");
12618            let tag_c = format_hashline_tag(2, "c");
12619
12620            // Replace range with null (delete)
12621            let input = serde_json::json!({
12622                "path": file.to_str().unwrap(),
12623                "edits": [{
12624                    "op": "replace",
12625                    "pos": &tag_b,
12626                    "end": &tag_c,
12627                    "lines": null
12628                }]
12629            });
12630
12631            let out = tool.execute("test", input, None).await.unwrap();
12632            assert!(!out.is_error);
12633
12634            let content = std::fs::read_to_string(&file).unwrap();
12635            assert_eq!(content, "a\nd\n");
12636        });
12637    }
12638
12639    #[test]
12640    fn test_hashline_edit_crlf_preservation() {
12641        asupersync::test_utils::run_test(|| async {
12642            let dir = tempfile::tempdir().unwrap();
12643            let file = dir.path().join("test.txt");
12644            std::fs::write(&file, "line1\r\nline2\r\nline3").unwrap();
12645
12646            let tool = HashlineEditTool::new(dir.path());
12647            let tag2 = format_hashline_tag(1, "line2");
12648
12649            let input = serde_json::json!({
12650                "path": file.to_str().unwrap(),
12651                "edits": [{
12652                    "op": "replace",
12653                    "pos": tag2,
12654                    "lines": ["changed"]
12655                }]
12656            });
12657
12658            let out = tool.execute("test", input, None).await.unwrap();
12659            assert!(!out.is_error);
12660
12661            let content = std::fs::read_to_string(&file).unwrap();
12662            assert_eq!(content, "line1\r\nchanged\r\nline3");
12663        });
12664    }
12665
12666    #[test]
12667    fn test_hashline_edit_cr_preservation() {
12668        asupersync::test_utils::run_test(|| async {
12669            let dir = tempfile::tempdir().unwrap();
12670            let file = dir.path().join("test.txt");
12671            std::fs::write(&file, "line1\rline2\rline3").unwrap();
12672
12673            let tool = HashlineEditTool::new(dir.path());
12674            let tag2 = format_hashline_tag(1, "line2");
12675
12676            let input = serde_json::json!({
12677                "path": file.to_str().unwrap(),
12678                "edits": [{
12679                    "op": "replace",
12680                    "pos": tag2,
12681                    "lines": ["changed"]
12682                }]
12683            });
12684
12685            let out = tool.execute("test", input, None).await.unwrap();
12686            assert!(!out.is_error);
12687
12688            let content = std::fs::read_to_string(&file).unwrap();
12689            assert_eq!(content, "line1\rchanged\rline3");
12690        });
12691    }
12692
12693    #[test]
12694    fn test_hashline_edit_empty_file_append() {
12695        asupersync::test_utils::run_test(|| async {
12696            let dir = tempfile::tempdir().unwrap();
12697            let file = dir.path().join("empty.txt");
12698            std::fs::write(&file, "").unwrap();
12699
12700            let tool = HashlineEditTool::new(dir.path());
12701
12702            // EOF append with no pos on empty file
12703            let input = serde_json::json!({
12704                "path": file.to_str().unwrap(),
12705                "edits": [{
12706                    "op": "append",
12707                    "lines": ["new_line"]
12708                }]
12709            });
12710
12711            let out = tool.execute("test", input, None).await.unwrap();
12712            assert!(!out.is_error);
12713
12714            let content = std::fs::read_to_string(&file).unwrap();
12715            assert!(content.contains("new_line"));
12716        });
12717    }
12718
12719    #[test]
12720    fn test_hashline_edit_single_line_no_trailing_newline() {
12721        asupersync::test_utils::run_test(|| async {
12722            let dir = tempfile::tempdir().unwrap();
12723            let file = dir.path().join("single.txt");
12724            std::fs::write(&file, "hello").unwrap();
12725
12726            let tool = HashlineEditTool::new(dir.path());
12727            let tag = format_hashline_tag(0, "hello");
12728
12729            let input = serde_json::json!({
12730                "path": file.to_str().unwrap(),
12731                "edits": [{
12732                    "op": "replace",
12733                    "pos": tag,
12734                    "lines": ["world"]
12735                }]
12736            });
12737
12738            let out = tool.execute("test", input, None).await.unwrap();
12739            assert!(!out.is_error);
12740
12741            let content = std::fs::read_to_string(&file).unwrap();
12742            assert_eq!(content, "world");
12743        });
12744    }
12745
12746    #[test]
12747    fn test_hashline_edit_preserves_bom_hash_validation() {
12748        asupersync::test_utils::run_test(|| async {
12749            let dir = tempfile::tempdir().unwrap();
12750            let file = dir.path().join("bom.txt");
12751            let bom = "\u{FEFF}";
12752            std::fs::write(&file, format!("{bom}alpha\nbeta\n")).unwrap();
12753
12754            let tool = HashlineEditTool::new(dir.path());
12755            let tag1 = format_hashline_tag(0, &format!("{bom}alpha"));
12756
12757            let input = serde_json::json!({
12758                "path": file.to_str().unwrap(),
12759                "edits": [{
12760                    "op": "replace",
12761                    "pos": tag1,
12762                    "lines": ["gamma"]
12763                }]
12764            });
12765
12766            let out = tool.execute("test", input, None).await.unwrap();
12767            assert!(!out.is_error);
12768
12769            let content = std::fs::read_to_string(&file).unwrap();
12770            assert_eq!(content, format!("{bom}gamma\nbeta\n"));
12771        });
12772    }
12773
12774    #[test]
12775    fn test_hashline_edit_bof_prepend_no_pos() {
12776        asupersync::test_utils::run_test(|| async {
12777            let dir = tempfile::tempdir().unwrap();
12778            let file = dir.path().join("test.txt");
12779            std::fs::write(&file, "a\nb\nc\n").unwrap();
12780
12781            let tool = HashlineEditTool::new(dir.path());
12782
12783            // Prepend with no pos should insert at BOF (before line 0)
12784            let input = serde_json::json!({
12785                "path": file.to_str().unwrap(),
12786                "edits": [{
12787                    "op": "prepend",
12788                    "lines": ["header"]
12789                }]
12790            });
12791
12792            let out = tool.execute("test", input, None).await.unwrap();
12793            assert!(!out.is_error);
12794
12795            let content = std::fs::read_to_string(&file).unwrap();
12796            assert_eq!(content, "header\na\nb\nc\n");
12797        });
12798    }
12799
12800    #[test]
12801    fn test_hashline_edit_eof_append_no_pos() {
12802        asupersync::test_utils::run_test(|| async {
12803            let dir = tempfile::tempdir().unwrap();
12804            let file = dir.path().join("test.txt");
12805            std::fs::write(&file, "a\nb\nc\n").unwrap();
12806
12807            let tool = HashlineEditTool::new(dir.path());
12808
12809            // Append with no pos should insert at EOF (after last line)
12810            let input = serde_json::json!({
12811                "path": file.to_str().unwrap(),
12812                "edits": [{
12813                    "op": "append",
12814                    "lines": ["footer"]
12815                }]
12816            });
12817
12818            let out = tool.execute("test", input, None).await.unwrap();
12819            assert!(!out.is_error);
12820
12821            let content = std::fs::read_to_string(&file).unwrap();
12822            assert!(
12823                content.contains("footer"),
12824                "content should contain footer: {content:?}"
12825            );
12826        });
12827    }
12828
12829    #[test]
12830    fn test_hashline_edit_overlapping_replace_ranges_rejected() {
12831        asupersync::test_utils::run_test(|| async {
12832            let dir = tempfile::tempdir().unwrap();
12833            let file = dir.path().join("test.txt");
12834            std::fs::write(&file, "a\nb\nc\nd\ne\n").unwrap();
12835
12836            let tool = HashlineEditTool::new(dir.path());
12837            let tag_b = format_hashline_tag(1, "b");
12838            let tag_d = format_hashline_tag(3, "d");
12839            let tag_c = format_hashline_tag(2, "c");
12840            let tag_e = format_hashline_tag(4, "e");
12841
12842            // Two overlapping replace ranges: lines 2-4 and lines 3-5
12843            let input = serde_json::json!({
12844                "path": file.to_str().unwrap(),
12845                "edits": [
12846                    { "op": "replace", "pos": &tag_b, "end": &tag_d, "lines": ["X"] },
12847                    { "op": "replace", "pos": &tag_c, "end": &tag_e, "lines": ["Y"] }
12848                ]
12849            });
12850
12851            let result = tool.execute("test", input, None).await;
12852            assert!(result.is_err());
12853            let err_msg = result.unwrap_err().to_string();
12854            assert!(
12855                err_msg.contains("Overlapping"),
12856                "error should mention overlapping: {err_msg}"
12857            );
12858        });
12859    }
12860
12861    #[test]
12862    fn test_hashline_edit_reversed_range_rejected() {
12863        asupersync::test_utils::run_test(|| async {
12864            let dir = tempfile::tempdir().unwrap();
12865            let file = dir.path().join("test.txt");
12866            std::fs::write(&file, "a\nb\nc\nd\n").unwrap();
12867
12868            let tool = HashlineEditTool::new(dir.path());
12869            let tag_b = format_hashline_tag(1, "b");
12870            let tag_d = format_hashline_tag(3, "d");
12871
12872            // End anchor before start anchor
12873            let input = serde_json::json!({
12874                "path": file.to_str().unwrap(),
12875                "edits": [{
12876                    "op": "replace",
12877                    "pos": &tag_d,
12878                    "end": &tag_b,
12879                    "lines": ["X"]
12880                }]
12881            });
12882
12883            let result = tool.execute("test", input, None).await;
12884            assert!(result.is_err());
12885            let err_msg = result.unwrap_err().to_string();
12886            assert!(
12887                err_msg.contains("before start"),
12888                "error should mention before start: {err_msg}"
12889            );
12890        });
12891    }
12892
12893    #[test]
12894    fn test_hashline_edit_trailing_newline_semantics() {
12895        asupersync::test_utils::run_test(|| async {
12896            let dir = tempfile::tempdir().unwrap();
12897            let file = dir.path().join("test.txt");
12898            // File with trailing newline: split produces ["line1", "line2", ""]
12899            std::fs::write(&file, "line1\nline2\n").unwrap();
12900
12901            let tool = HashlineEditTool::new(dir.path());
12902            let tag2 = format_hashline_tag(1, "line2");
12903
12904            // Replace line2, trailing newline should be preserved
12905            let input = serde_json::json!({
12906                "path": file.to_str().unwrap(),
12907                "edits": [{
12908                    "op": "replace",
12909                    "pos": tag2,
12910                    "lines": ["changed"]
12911                }]
12912            });
12913
12914            let out = tool.execute("test", input, None).await.unwrap();
12915            assert!(!out.is_error);
12916
12917            let content = std::fs::read_to_string(&file).unwrap();
12918            assert_eq!(content, "line1\nchanged\n");
12919        });
12920    }
12921}