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            file.sync_all()?;
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#[cfg(unix)]
2167fn sync_parent_dir(path: &Path) -> std::io::Result<()> {
2168    let Some(parent) = path.parent() else {
2169        return Ok(());
2170    };
2171    std::fs::File::open(parent)?.sync_all()
2172}
2173
2174#[cfg(not(unix))]
2175fn sync_parent_dir(_path: &Path) -> std::io::Result<()> {
2176    Ok(())
2177}
2178
2179fn escape_file_tag_attribute(value: &str) -> String {
2180    let mut escaped = String::with_capacity(value.len());
2181    for ch in value.chars() {
2182        match ch {
2183            '&' => escaped.push_str("&amp;"),
2184            '"' => escaped.push_str("&quot;"),
2185            '<' => escaped.push_str("&lt;"),
2186            '>' => escaped.push_str("&gt;"),
2187            '\n' => escaped.push_str("&#10;"),
2188            '\r' => escaped.push_str("&#13;"),
2189            '\t' => escaped.push_str("&#9;"),
2190            _ => escaped.push(ch),
2191        }
2192    }
2193    escaped
2194}
2195
2196fn escaped_file_tag_name(path: &Path) -> String {
2197    escape_file_tag_attribute(&path.display().to_string())
2198}
2199
2200fn append_file_notice_block(out: &mut String, path: &Path, notice: &str) {
2201    let path_str = escaped_file_tag_name(path);
2202    let _ = writeln!(out, "<file name=\"{path_str}\">\n{notice}\n</file>");
2203}
2204
2205fn append_image_file_ref(out: &mut String, path: &Path, note: Option<&str>) {
2206    let path_str = escaped_file_tag_name(path);
2207    match note {
2208        Some(text) => {
2209            let _ = writeln!(out, "<file name=\"{path_str}\">{text}</file>");
2210        }
2211        None => {
2212            let _ = writeln!(out, "<file name=\"{path_str}\"></file>");
2213        }
2214    }
2215}
2216
2217fn append_text_file_block(out: &mut String, path: &Path, bytes: &[u8]) {
2218    let content = String::from_utf8_lossy(bytes);
2219    let path_str = escaped_file_tag_name(path);
2220    let _ = writeln!(out, "<file name=\"{path_str}\">");
2221
2222    let truncation = truncate_head(content.into_owned(), DEFAULT_MAX_LINES, DEFAULT_MAX_BYTES);
2223    let needs_trailing_newline = !truncation.truncated && !truncation.content.ends_with('\n');
2224    out.push_str(&truncation.content);
2225
2226    if truncation.truncated {
2227        let _ = write!(
2228            out,
2229            "\n... [Truncated: showing {}/{} lines, {}/{} bytes]",
2230            truncation.output_lines,
2231            truncation.total_lines,
2232            format_size(truncation.output_bytes),
2233            format_size(truncation.total_bytes)
2234        );
2235    } else if needs_trailing_newline {
2236        out.push('\n');
2237    }
2238    let _ = writeln!(out, "</file>");
2239}
2240
2241fn maybe_append_image_argument(
2242    out: &mut ProcessedFiles,
2243    absolute_path: &Path,
2244    bytes: &[u8],
2245    auto_resize_images: bool,
2246) -> Result<bool> {
2247    let Some(mime_type) = detect_supported_image_mime_type_from_bytes(bytes) else {
2248        return Ok(false);
2249    };
2250
2251    let resized = if auto_resize_images {
2252        resize_image_if_needed(bytes, mime_type)?
2253    } else {
2254        ResizedImage::original(bytes.to_vec(), mime_type)
2255    };
2256
2257    if resized.bytes.len() > IMAGE_MAX_BYTES {
2258        let msg = if resized.resized {
2259            format!(
2260                "[Image is too large ({} bytes) after resizing. Max allowed is {} bytes.]",
2261                resized.bytes.len(),
2262                IMAGE_MAX_BYTES
2263            )
2264        } else {
2265            format!(
2266                "[Image is too large ({} bytes). Max allowed is {} bytes.]",
2267                resized.bytes.len(),
2268                IMAGE_MAX_BYTES
2269            )
2270        };
2271        append_file_notice_block(&mut out.text, absolute_path, &msg);
2272        return Ok(true);
2273    }
2274
2275    let base64_data =
2276        base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &resized.bytes);
2277    out.images.push(ImageContent {
2278        data: base64_data,
2279        mime_type: resized.mime_type.to_string(),
2280    });
2281
2282    let note = if resized.resized {
2283        if let (Some(ow), Some(oh), Some(w), Some(h)) = (
2284            resized.original_width,
2285            resized.original_height,
2286            resized.width,
2287            resized.height,
2288        ) {
2289            if w > 0 {
2290                let scale = f64::from(ow) / f64::from(w);
2291                Some(format!(
2292                    "[Image: original {ow}x{oh}, displayed at {w}x{h}. Multiply coordinates by {scale:.2} to map to original image.]"
2293                ))
2294            } else {
2295                Some(format!(
2296                    "[Image: original {ow}x{oh}, displayed at {w}x{h}.]"
2297                ))
2298            }
2299        } else {
2300            None
2301        }
2302    } else {
2303        None
2304    };
2305    append_image_file_ref(&mut out.text, absolute_path, note.as_deref());
2306    Ok(true)
2307}
2308
2309/// Process `@file` arguments into a single text prefix and image attachments.
2310///
2311/// Matches the legacy TypeScript behavior:
2312/// - Resolves paths (including `~` expansion + macOS screenshot variants)
2313/// - Skips empty files
2314/// - For images: attaches image blocks and appends `<file name="...">...</file>` references
2315/// - For text: embeds the file contents inside `<file>` tags
2316pub fn process_file_arguments(
2317    file_args: &[String],
2318    cwd: &Path,
2319    auto_resize_images: bool,
2320) -> Result<ProcessedFiles> {
2321    let mut out = ProcessedFiles::default();
2322
2323    for file_arg in file_args {
2324        let resolved = resolve_read_path(file_arg, cwd);
2325        let absolute_path = normalize_dot_segments(&resolved);
2326        let absolute_path = enforce_read_scope(&absolute_path, cwd)?;
2327
2328        let meta = std::fs::metadata(&absolute_path).map_err(|e| {
2329            Error::tool(
2330                "read",
2331                format!("Cannot access file {}: {e}", absolute_path.display()),
2332            )
2333        })?;
2334        if meta.is_dir() {
2335            append_file_notice_block(
2336                &mut out.text,
2337                &absolute_path,
2338                "[Path is a directory, not a file. Use the list tool to view its contents.]",
2339            );
2340            continue;
2341        }
2342
2343        if meta.len() == 0 {
2344            continue;
2345        }
2346
2347        if meta.len() > READ_TOOL_MAX_BYTES {
2348            append_file_notice_block(
2349                &mut out.text,
2350                &absolute_path,
2351                &format!(
2352                    "[File is too large ({} bytes). Max allowed is {} bytes.]",
2353                    meta.len(),
2354                    READ_TOOL_MAX_BYTES
2355                ),
2356            );
2357            continue;
2358        }
2359
2360        let bytes = std::fs::read(&absolute_path).map_err(|e| {
2361            Error::tool(
2362                "read",
2363                format!("Could not read file {}: {e}", absolute_path.display()),
2364            )
2365        })?;
2366
2367        if maybe_append_image_argument(&mut out, &absolute_path, &bytes, auto_resize_images)? {
2368            continue;
2369        }
2370
2371        append_text_file_block(&mut out.text, &absolute_path, &bytes);
2372    }
2373
2374    Ok(out)
2375}
2376
2377/// Resolve a file path relative to the current working directory.
2378/// Public alias for `resolve_to_cwd` used by tools.
2379fn resolve_path(file_path: &str, cwd: &Path) -> PathBuf {
2380    normalize_dot_segments(&resolve_to_cwd(file_path, cwd))
2381}
2382
2383#[cfg(feature = "fuzzing")]
2384pub fn fuzz_resolve_path(file_path: &str, cwd: &Path) -> PathBuf {
2385    resolve_path(file_path, cwd)
2386}
2387
2388pub(crate) fn detect_supported_image_mime_type_from_bytes(bytes: &[u8]) -> Option<&'static str> {
2389    // Supported image types match the legacy tool: jpeg/png/gif/webp only.
2390    if bytes.len() >= 8 && bytes.starts_with(b"\x89PNG\r\n\x1A\n") {
2391        return Some("image/png");
2392    }
2393    if bytes.len() >= 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF {
2394        return Some("image/jpeg");
2395    }
2396    if bytes.len() >= 6 && (bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a")) {
2397        return Some("image/gif");
2398    }
2399    if bytes.len() >= 12 && bytes.starts_with(b"RIFF") && &bytes[8..12] == b"WEBP" {
2400        return Some("image/webp");
2401    }
2402    None
2403}
2404
2405#[derive(Debug, Clone)]
2406pub(crate) struct ResizedImage {
2407    pub(crate) bytes: Vec<u8>,
2408    pub(crate) mime_type: &'static str,
2409    pub(crate) resized: bool,
2410    pub(crate) width: Option<u32>,
2411    pub(crate) height: Option<u32>,
2412    pub(crate) original_width: Option<u32>,
2413    pub(crate) original_height: Option<u32>,
2414}
2415
2416impl ResizedImage {
2417    pub(crate) const fn original(bytes: Vec<u8>, mime_type: &'static str) -> Self {
2418        Self {
2419            bytes,
2420            mime_type,
2421            resized: false,
2422            width: None,
2423            height: None,
2424            original_width: None,
2425            original_height: None,
2426        }
2427    }
2428}
2429
2430#[cfg(feature = "image-resize")]
2431#[allow(clippy::too_many_lines)]
2432pub(crate) fn resize_image_if_needed(
2433    bytes: &[u8],
2434    mime_type: &'static str,
2435) -> Result<ResizedImage> {
2436    // Match legacy behavior from pi-mono `utils/image-resize.ts`.
2437    //
2438    // Strategy:
2439    // 1) If image already fits within max dims AND max bytes: return original
2440    // 2) Otherwise resize to maxWidth/maxHeight (2000x2000)
2441    // 3) Encode as PNG and JPEG, pick smaller
2442    // 4) If still too large, try JPEG with different quality steps
2443    // 5) If still too large, progressively scale down dimensions
2444    //
2445    // Note: even if dimensions don't change, an oversized image may be re-encoded to fit max bytes.
2446    use image::codecs::jpeg::JpegEncoder;
2447    use image::codecs::png::PngEncoder;
2448    use image::imageops::FilterType;
2449    use image::{GenericImageView, ImageEncoder, ImageReader, Limits};
2450    use std::io::Cursor;
2451
2452    const MAX_WIDTH: u32 = 2000;
2453    const MAX_HEIGHT: u32 = 2000;
2454    const DEFAULT_JPEG_QUALITY: u8 = 80;
2455    const QUALITY_STEPS: [u8; 4] = [85, 70, 55, 40];
2456    const SCALE_STEPS: [f64; 5] = [1.0, 0.75, 0.5, 0.35, 0.25];
2457
2458    fn scale_u32(value: u32, numerator: u32, denominator: u32) -> u32 {
2459        let den = u64::from(denominator).max(1);
2460        let num = u64::from(value) * u64::from(numerator);
2461        let rounded = (num + den / 2) / den;
2462        u32::try_from(rounded).unwrap_or(u32::MAX)
2463    }
2464
2465    fn encode_png(img: &image::DynamicImage) -> Result<Vec<u8>> {
2466        let rgba = img.to_rgba8();
2467        let mut out = Vec::new();
2468        PngEncoder::new(&mut out)
2469            .write_image(
2470                rgba.as_raw(),
2471                rgba.width(),
2472                rgba.height(),
2473                image::ExtendedColorType::Rgba8,
2474            )
2475            .map_err(|e| Error::tool("read", format!("Failed to encode PNG: {e}")))?;
2476        Ok(out)
2477    }
2478
2479    fn encode_jpeg(img: &image::DynamicImage, quality: u8) -> Result<Vec<u8>> {
2480        let rgb = img.to_rgb8();
2481        let mut out = Vec::new();
2482        JpegEncoder::new_with_quality(&mut out, quality)
2483            .write_image(
2484                rgb.as_raw(),
2485                rgb.width(),
2486                rgb.height(),
2487                image::ExtendedColorType::Rgb8,
2488            )
2489            .map_err(|e| Error::tool("read", format!("Failed to encode JPEG: {e}")))?;
2490        Ok(out)
2491    }
2492
2493    fn try_both_formats(
2494        img: &image::DynamicImage,
2495        width: u32,
2496        height: u32,
2497        jpeg_quality: u8,
2498    ) -> Result<(Vec<u8>, &'static str)> {
2499        let resized = img.resize_exact(width, height, FilterType::Lanczos3);
2500        let png = encode_png(&resized)?;
2501        let jpeg = encode_jpeg(&resized, jpeg_quality)?;
2502        if png.len() <= jpeg.len() {
2503            Ok((png, "image/png"))
2504        } else {
2505            Ok((jpeg, "image/jpeg"))
2506        }
2507    }
2508
2509    // Use ImageReader with explicit limits to prevent decompression bomb attacks.
2510    // 128MB allocation limit allows reasonable images but stops massive expansions.
2511    let mut limits = Limits::default();
2512    limits.max_alloc = Some(128 * 1024 * 1024);
2513
2514    let reader = ImageReader::new(Cursor::new(bytes))
2515        .with_guessed_format()
2516        .map_err(|e| Error::tool("read", format!("Failed to detect image format: {e}")))?;
2517
2518    let mut reader = reader;
2519    reader.limits(limits);
2520
2521    // ubs:ignore false positive: image decode, not JWT processing.
2522    let Ok(img) = reader.decode() else {
2523        return Ok(ResizedImage::original(bytes.to_vec(), mime_type));
2524    };
2525
2526    let (original_width, original_height) = img.dimensions();
2527    let original_size = bytes.len();
2528
2529    if original_width <= MAX_WIDTH
2530        && original_height <= MAX_HEIGHT
2531        && original_size <= IMAGE_MAX_BYTES
2532    {
2533        return Ok(ResizedImage {
2534            bytes: bytes.to_vec(),
2535            mime_type,
2536            resized: false,
2537            width: Some(original_width),
2538            height: Some(original_height),
2539            original_width: Some(original_width),
2540            original_height: Some(original_height),
2541        });
2542    }
2543
2544    let mut target_width = original_width;
2545    let mut target_height = original_height;
2546
2547    if target_width > MAX_WIDTH {
2548        target_height = scale_u32(target_height, MAX_WIDTH, target_width);
2549        target_width = MAX_WIDTH;
2550    }
2551    if target_height > MAX_HEIGHT {
2552        target_width = scale_u32(target_width, MAX_HEIGHT, target_height);
2553        target_height = MAX_HEIGHT;
2554    }
2555
2556    let mut best = try_both_formats(&img, target_width, target_height, DEFAULT_JPEG_QUALITY)?;
2557    let mut final_width = target_width;
2558    let mut final_height = target_height;
2559
2560    if best.0.len() <= IMAGE_MAX_BYTES {
2561        return Ok(ResizedImage {
2562            bytes: best.0,
2563            mime_type: best.1,
2564            resized: true,
2565            width: Some(final_width),
2566            height: Some(final_height),
2567            original_width: Some(original_width),
2568            original_height: Some(original_height),
2569        });
2570    }
2571
2572    for quality in QUALITY_STEPS {
2573        best = try_both_formats(&img, target_width, target_height, quality)?;
2574        if best.0.len() <= IMAGE_MAX_BYTES {
2575            return Ok(ResizedImage {
2576                bytes: best.0,
2577                mime_type: best.1,
2578                resized: true,
2579                width: Some(final_width),
2580                height: Some(final_height),
2581                original_width: Some(original_width),
2582                original_height: Some(original_height),
2583            });
2584        }
2585    }
2586
2587    for scale in SCALE_STEPS {
2588        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
2589        {
2590            final_width = (f64::from(target_width) * scale).round() as u32;
2591            final_height = (f64::from(target_height) * scale).round() as u32;
2592        }
2593
2594        if final_width < 100 || final_height < 100 {
2595            break;
2596        }
2597
2598        for quality in QUALITY_STEPS {
2599            best = try_both_formats(&img, final_width, final_height, quality)?;
2600            if best.0.len() <= IMAGE_MAX_BYTES {
2601                return Ok(ResizedImage {
2602                    bytes: best.0,
2603                    mime_type: best.1,
2604                    resized: true,
2605                    width: Some(final_width),
2606                    height: Some(final_height),
2607                    original_width: Some(original_width),
2608                    original_height: Some(original_height),
2609                });
2610            }
2611        }
2612    }
2613
2614    Ok(ResizedImage {
2615        bytes: best.0,
2616        mime_type: best.1,
2617        resized: true,
2618        width: Some(final_width),
2619        height: Some(final_height),
2620        original_width: Some(original_width),
2621        original_height: Some(original_height),
2622    })
2623}
2624
2625#[cfg(not(feature = "image-resize"))]
2626#[expect(
2627    clippy::unnecessary_wraps,
2628    reason = "The no-feature stub preserves the feature-enabled Result API at shared call sites."
2629)]
2630pub(crate) fn resize_image_if_needed(
2631    bytes: &[u8],
2632    mime_type: &'static str,
2633) -> Result<ResizedImage> {
2634    Ok(ResizedImage::original(bytes.to_vec(), mime_type))
2635}
2636
2637// ============================================================================
2638// Tool Registry
2639// ============================================================================
2640
2641/// Registry of enabled tools for a Pi run.
2642///
2643/// The registry is constructed from configuration (enabled tool names + settings) and is used for:
2644/// - Looking up a tool implementation by name during tool-call execution.
2645/// - Enumerating tool schemas when building provider requests.
2646pub struct ToolRegistry {
2647    tools: Vec<Box<dyn Tool>>,
2648}
2649
2650impl ToolRegistry {
2651    /// Create a new registry with the specified tools enabled.
2652    pub fn new(enabled: &[&str], cwd: &Path, config: Option<&Config>) -> Self {
2653        let mut tools: Vec<Box<dyn Tool>> = Vec::new();
2654        let shell_path = config.and_then(|c| c.shell_path.clone());
2655        let shell_command_prefix = config.and_then(|c| c.shell_command_prefix.clone());
2656        let image_auto_resize = config.is_none_or(Config::image_auto_resize);
2657        let block_images = config
2658            .and_then(|c| c.images.as_ref().and_then(|i| i.block_images))
2659            .unwrap_or(false);
2660
2661        for name in enabled {
2662            match *name {
2663                "read" => tools.push(Box::new(ReadTool::with_settings(
2664                    cwd,
2665                    image_auto_resize,
2666                    block_images,
2667                ))),
2668                "bash" => tools.push(Box::new(BashTool::with_shell(
2669                    cwd,
2670                    shell_path.clone(),
2671                    shell_command_prefix.clone(),
2672                ))),
2673                "edit" => tools.push(Box::new(EditTool::new(cwd))),
2674                "write" => tools.push(Box::new(WriteTool::new(cwd))),
2675                "grep" => tools.push(Box::new(GrepTool::new(cwd))),
2676                "find" => tools.push(Box::new(FindTool::new(cwd))),
2677                "ls" => tools.push(Box::new(LsTool::new(cwd))),
2678                "hashline_edit" => tools.push(Box::new(HashlineEditTool::new(cwd))),
2679                _ => {}
2680            }
2681        }
2682
2683        Self { tools }
2684    }
2685
2686    /// Construct a registry from a pre-built tool list.
2687    pub fn from_tools(tools: Vec<Box<dyn Tool>>) -> Self {
2688        Self { tools }
2689    }
2690
2691    /// Convert the registry into the owned tool list.
2692    pub fn into_tools(self) -> Vec<Box<dyn Tool>> {
2693        self.tools
2694    }
2695
2696    /// Append a tool.
2697    pub fn push(&mut self, tool: Box<dyn Tool>) {
2698        self.tools.push(tool);
2699    }
2700
2701    /// Extend the registry with additional tools.
2702    pub fn extend<I>(&mut self, tools: I)
2703    where
2704        I: IntoIterator<Item = Box<dyn Tool>>,
2705    {
2706        self.tools.extend(tools);
2707    }
2708
2709    /// Get all tools.
2710    pub fn tools(&self) -> &[Box<dyn Tool>] {
2711        &self.tools
2712    }
2713
2714    /// Find a tool by name.
2715    pub fn get(&self, name: &str) -> Option<&dyn Tool> {
2716        self.tools
2717            .iter()
2718            .find(|t| t.name() == name)
2719            .map(std::convert::AsRef::as_ref)
2720    }
2721}
2722
2723// ============================================================================
2724// Read Tool
2725// ============================================================================
2726
2727/// Input parameters for the read tool.
2728#[derive(Debug, Deserialize)]
2729#[serde(rename_all = "camelCase")]
2730struct ReadInput {
2731    path: String,
2732    offset: Option<i64>,
2733    limit: Option<i64>,
2734    #[serde(default)]
2735    hashline: bool,
2736}
2737
2738pub struct ReadTool {
2739    cwd: PathBuf,
2740    /// Whether to auto-resize images to fit token limits.
2741    auto_resize: bool,
2742    block_images: bool,
2743    artifact_root: Option<PathBuf>,
2744}
2745
2746impl ReadTool {
2747    pub fn new(cwd: &Path) -> Self {
2748        Self {
2749            cwd: cwd.to_path_buf(),
2750            auto_resize: true,
2751            block_images: false,
2752            artifact_root: None,
2753        }
2754    }
2755
2756    pub fn with_settings(cwd: &Path, auto_resize: bool, block_images: bool) -> Self {
2757        Self {
2758            cwd: cwd.to_path_buf(),
2759            auto_resize,
2760            block_images,
2761            artifact_root: None,
2762        }
2763    }
2764
2765    #[cfg(test)]
2766    fn with_artifact_root(cwd: &Path, artifact_root: &Path) -> Self {
2767        Self {
2768            cwd: cwd.to_path_buf(),
2769            auto_resize: true,
2770            block_images: false,
2771            artifact_root: Some(artifact_root.to_path_buf()),
2772        }
2773    }
2774}
2775
2776async fn read_some<R>(reader: &mut R, dst: &mut [u8]) -> std::io::Result<usize>
2777where
2778    R: AsyncRead + Unpin,
2779{
2780    if dst.is_empty() {
2781        return Ok(0);
2782    }
2783
2784    futures::future::poll_fn(|cx| {
2785        let mut read_buf = ReadBuf::new(dst);
2786        match std::pin::Pin::new(&mut *reader).poll_read(cx, &mut read_buf) {
2787            std::task::Poll::Ready(Ok(())) => std::task::Poll::Ready(Ok(read_buf.filled().len())),
2788            std::task::Poll::Ready(Err(err)) => std::task::Poll::Ready(Err(err)),
2789            std::task::Poll::Pending => std::task::Poll::Pending,
2790        }
2791    })
2792    .await
2793}
2794
2795#[async_trait]
2796#[allow(clippy::unnecessary_literal_bound)]
2797impl Tool for ReadTool {
2798    fn name(&self) -> &str {
2799        "read"
2800    }
2801    fn label(&self) -> &str {
2802        "read"
2803    }
2804    fn description(&self) -> &str {
2805        "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."
2806    }
2807
2808    fn parameters(&self) -> serde_json::Value {
2809        serde_json::json!({
2810            "type": "object",
2811            "properties": {
2812                "path": {
2813                    "type": "string",
2814                    "description": "Path to the file to read (relative or absolute)"
2815                },
2816                "offset": {
2817                    "type": "integer",
2818                    "description": "Line number to start reading from (1-indexed)"
2819                },
2820                "limit": {
2821                    "type": "integer",
2822                    "description": "Maximum number of lines to read"
2823                },
2824                "hashline": {
2825                    "type": "boolean",
2826                    "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."
2827                }
2828            },
2829            "required": ["path"]
2830        })
2831    }
2832
2833    fn effects(&self) -> ToolEffects {
2834        ToolEffects::read()
2835    }
2836
2837    #[allow(clippy::too_many_lines)]
2838    async fn execute(
2839        &self,
2840        tool_call_id: &str,
2841        input: serde_json::Value,
2842        _on_update: Option<Box<dyn Fn(ToolUpdate) + Send + Sync>>,
2843    ) -> Result<ToolOutput> {
2844        let input_value = input.clone();
2845        let input: ReadInput =
2846            serde_json::from_value(input).map_err(|e| Error::validation(e.to_string()))?;
2847
2848        if matches!(input.limit, Some(limit) if limit <= 0) {
2849            return Err(Error::validation(
2850                "`limit` must be greater than 0".to_string(),
2851            ));
2852        }
2853        if matches!(input.offset, Some(offset) if offset < 0) {
2854            return Err(Error::validation(
2855                "`offset` must be non-negative".to_string(),
2856            ));
2857        }
2858
2859        let path = resolve_read_path(&input.path, &self.cwd);
2860        let path = enforce_read_scope(&path, &self.cwd)?;
2861
2862        let meta = asupersync::fs::metadata(&path).await.ok();
2863        if let Some(meta) = &meta {
2864            if !meta.is_file() {
2865                return Err(Error::tool(
2866                    "read",
2867                    format!("Path {} is not a regular file", path.display()),
2868                ));
2869            }
2870        }
2871
2872        let cache_key = tool_cache_key("read", &self.cwd, &input_value);
2873        let cache_mode = ToolCacheFingerprintMode::FileContent;
2874        let cache_deps = cache_dependency_for_path(&path, cache_mode);
2875        if let Some(output) = cached_tool_output(&cache_key, cache_deps.as_deref()) {
2876            return Ok(output);
2877        }
2878
2879        let mut file = asupersync::fs::File::open(&path)
2880            .await
2881            .map_err(|e| Error::tool("read", e.to_string()))?;
2882
2883        // Read initial chunk for mime detection
2884        let mut buffer = [0u8; 8192];
2885        let mut initial_read = 0;
2886        loop {
2887            let n = read_some(&mut file, &mut buffer[initial_read..])
2888                .await
2889                .map_err(|e| Error::tool("read", format!("Failed to read file: {e}")))?;
2890            if n == 0 {
2891                break;
2892            }
2893            initial_read += n;
2894            if initial_read == buffer.len() {
2895                break;
2896            }
2897        }
2898        let initial_bytes = &buffer[..initial_read];
2899
2900        if let Some(mime_type) = detect_supported_image_mime_type_from_bytes(initial_bytes) {
2901            if self.block_images {
2902                return Err(Error::tool(
2903                    "read",
2904                    "Images are blocked by configuration".to_string(),
2905                ));
2906            }
2907
2908            // For images, allow a larger on-disk source as long as it stays
2909            // within the read-tool input bound; resize/re-encode may still
2910            // bring the API payload under IMAGE_MAX_BYTES.
2911            let max_image_input_bytes = usize::try_from(READ_TOOL_MAX_BYTES).unwrap_or(usize::MAX);
2912            if let Some(meta) = &meta {
2913                if meta.len() > READ_TOOL_MAX_BYTES {
2914                    return Err(Error::tool(
2915                        "read",
2916                        format!(
2917                            "Image is too large ({} bytes). Max allowed is {} bytes.",
2918                            meta.len(),
2919                            READ_TOOL_MAX_BYTES
2920                        ),
2921                    ));
2922                }
2923            }
2924            let mut all_bytes = Vec::with_capacity(initial_read);
2925            all_bytes.extend_from_slice(initial_bytes);
2926
2927            let remaining_limit = max_image_input_bytes.saturating_sub(initial_read);
2928            let mut limiter = file.take((remaining_limit as u64).saturating_add(1));
2929            limiter
2930                .read_to_end(&mut all_bytes)
2931                .await
2932                .map_err(|e| Error::tool("read", format!("Failed to read image: {e}")))?;
2933
2934            if all_bytes.len() > max_image_input_bytes {
2935                return Err(Error::tool(
2936                    "read",
2937                    format!(
2938                        "Image is too large ({} bytes). Max allowed is {} bytes.",
2939                        all_bytes.len(),
2940                        READ_TOOL_MAX_BYTES
2941                    ),
2942                ));
2943            }
2944
2945            let resized = if self.auto_resize {
2946                resize_image_if_needed(&all_bytes, mime_type)?
2947            } else {
2948                ResizedImage::original(all_bytes, mime_type)
2949            };
2950
2951            if resized.bytes.len() > IMAGE_MAX_BYTES {
2952                let message = if resized.resized {
2953                    format!(
2954                        "Image is too large ({} bytes) after resizing. Max allowed is {} bytes.",
2955                        resized.bytes.len(),
2956                        IMAGE_MAX_BYTES
2957                    )
2958                } else {
2959                    format!(
2960                        "Image is too large ({} bytes). Max allowed is {} bytes.",
2961                        resized.bytes.len(),
2962                        IMAGE_MAX_BYTES
2963                    )
2964                };
2965                return Err(Error::tool("read", message));
2966            }
2967
2968            let base64_data =
2969                base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &resized.bytes);
2970
2971            let mut note = format!("Read image file [{}]", resized.mime_type);
2972            if resized.resized {
2973                if let (Some(ow), Some(oh), Some(w), Some(h)) = (
2974                    resized.original_width,
2975                    resized.original_height,
2976                    resized.width,
2977                    resized.height,
2978                ) {
2979                    if w > 0 {
2980                        let scale = f64::from(ow) / f64::from(w);
2981                        let _ = write!(
2982                            note,
2983                            "\n[Image: original {ow}x{oh}, displayed at {w}x{h}. Multiply coordinates by {scale:.2} to map to original image.]"
2984                        );
2985                    } else {
2986                        let _ =
2987                            write!(note, "\n[Image: original {ow}x{oh}, displayed at {w}x{h}.]");
2988                    }
2989                }
2990            }
2991
2992            return Ok(ToolOutput {
2993                content: vec![
2994                    ContentBlock::Text(TextContent::new(note)),
2995                    ContentBlock::Image(ImageContent {
2996                        data: base64_data,
2997                        mime_type: resized.mime_type.to_string(),
2998                    }),
2999                ],
3000                details: None,
3001                is_error: false,
3002            });
3003        }
3004
3005        // Text path: optimized streaming read.
3006        // We need:
3007        // 1. Total line count.
3008        // 2. Content for the requested range (offset/limit) OR head/tail if no range.
3009
3010        // Reset file to start if we read some bytes
3011        if initial_read > 0 {
3012            file.seek(SeekFrom::Start(0))
3013                .await
3014                .map_err(|e| Error::tool("read", format!("Failed to seek: {e}")))?;
3015        }
3016
3017        let mut raw_content = Vec::new();
3018        let mut newlines_seen = 0usize;
3019
3020        // Input offset is 1-based. Convert to 0-based index.
3021        let start_line_idx = match input.offset {
3022            Some(n) if n > 0 => n.saturating_sub(1).try_into().unwrap_or(usize::MAX),
3023            _ => 0,
3024        };
3025        let limit_lines = input
3026            .limit
3027            .map_or(usize::MAX, |l| l.try_into().unwrap_or(usize::MAX));
3028        let end_line_idx = start_line_idx.saturating_add(limit_lines);
3029
3030        let mut collecting = start_line_idx == 0;
3031        let mut buf = vec![0u8; 64 * 1024].into_boxed_slice(); // 64KB chunks
3032        let mut last_byte_was_newline = false;
3033        let mut pending_cr = false;
3034
3035        // We need to track total_lines accurately for the output.
3036        // We will respect MAX_BYTES for *collected* content, but continue scanning for line counts
3037        // so pagination metadata is correct.
3038        let mut total_bytes_read = 0u64;
3039
3040        loop {
3041            let n = read_some(&mut file, &mut buf)
3042                .await
3043                .map_err(|e| Error::tool("read", e.to_string()))?;
3044            if n == 0 {
3045                break;
3046            }
3047            total_bytes_read = total_bytes_read.saturating_add(n as u64);
3048
3049            let chunk = normalize_line_endings_chunk(&buf[..n], &mut pending_cr);
3050            if chunk.is_empty() {
3051                continue;
3052            }
3053            last_byte_was_newline = chunk.last().is_some_and(|byte| *byte == b'\n');
3054            let mut chunk_cursor = 0;
3055
3056            for pos in memchr::memchr_iter(b'\n', &chunk) {
3057                // Check if this newline marks the end of a line we are collecting
3058                if collecting {
3059                    // newlines_seen is the index of the line ending at this newline
3060                    if newlines_seen + 1 == end_line_idx {
3061                        // We reached the limit. Collect up to this newline.
3062                        if raw_content.len() < DEFAULT_MAX_BYTES {
3063                            let remaining = DEFAULT_MAX_BYTES - raw_content.len();
3064                            let slice_len = (pos + 1 - chunk_cursor).min(remaining);
3065                            raw_content
3066                                .extend_from_slice(&chunk[chunk_cursor..chunk_cursor + slice_len]);
3067                        }
3068                        collecting = false;
3069                        chunk_cursor = pos + 1;
3070                    }
3071                }
3072
3073                newlines_seen += 1;
3074
3075                // Check if this newline marks the start of the window
3076                if !collecting && newlines_seen == start_line_idx {
3077                    collecting = true;
3078                    chunk_cursor = pos + 1;
3079                }
3080            }
3081
3082            // Append remainder of chunk if collecting
3083            if collecting && chunk_cursor < chunk.len() && raw_content.len() < DEFAULT_MAX_BYTES {
3084                let remaining = DEFAULT_MAX_BYTES - raw_content.len();
3085                let slice_len = (chunk.len() - chunk_cursor).min(remaining);
3086                raw_content.extend_from_slice(&chunk[chunk_cursor..chunk_cursor + slice_len]);
3087            }
3088        }
3089
3090        if pending_cr {
3091            last_byte_was_newline = true;
3092            if collecting && raw_content.len() < DEFAULT_MAX_BYTES {
3093                raw_content.push(b'\n');
3094            }
3095            newlines_seen += 1;
3096        }
3097
3098        // A trailing newline terminates the last line rather than starting a new one.
3099        // Also keep empty files at 0 lines so explicit positive offsets can error correctly.
3100        let total_lines = if total_bytes_read == 0 {
3101            0
3102        } else if last_byte_was_newline {
3103            newlines_seen
3104        } else {
3105            newlines_seen + 1
3106        };
3107        let text_content = String::from_utf8_lossy(&raw_content).into_owned();
3108
3109        // Handle empty file.
3110        // Offset=0 behaves like "start from beginning", but positive offsets should fail.
3111        if total_lines == 0 {
3112            if input.offset.unwrap_or(0) > 0 {
3113                let offset_display = input.offset.unwrap_or(0);
3114                return Err(Error::tool(
3115                    "read",
3116                    format!(
3117                        "Offset {offset_display} is beyond end of file ({total_lines} lines total)"
3118                    ),
3119                ));
3120            }
3121            let output = ToolOutput {
3122                content: vec![ContentBlock::Text(TextContent::new(""))],
3123                details: None,
3124                is_error: false,
3125            };
3126            cache_tool_output(
3127                cache_key,
3128                stable_cache_dependency_for_path(&path, cache_mode, cache_deps.as_deref()),
3129                &output,
3130            );
3131            return Ok(output);
3132        }
3133
3134        // Now we have the content (up to safety limit) in memory, but only for the requested window.
3135        // `text_content` starts at `start_line_idx`.
3136
3137        let start_line = start_line_idx;
3138        let start_line_display = start_line.saturating_add(1);
3139
3140        if start_line >= total_lines {
3141            let offset_display = input.offset.unwrap_or(0);
3142            return Err(Error::tool(
3143                "read",
3144                format!(
3145                    "Offset {offset_display} is beyond end of file ({total_lines} lines total)"
3146                ),
3147            ));
3148        }
3149
3150        let max_lines_for_truncation = input
3151            .limit
3152            .and_then(|l| usize::try_from(l).ok())
3153            .unwrap_or(DEFAULT_MAX_LINES);
3154        let display_limit = max_lines_for_truncation.saturating_add(1);
3155
3156        // We calculate lines to take based on the limit, but since we already filtered
3157        // during read, we can mostly trust `text_content`, except for `DEFAULT_MAX_BYTES` truncation.
3158
3159        let lines_to_take = limit_lines.min(display_limit);
3160
3161        let mut selected_content = String::new();
3162        let line_iter = text_content.split('\n');
3163
3164        // Note: we use skip(0) because text_content is already offset
3165        let effective_iter = if text_content.ends_with('\n') {
3166            line_iter.take(lines_to_take)
3167        } else {
3168            line_iter.take(usize::MAX)
3169        };
3170
3171        let max_line_num = start_line.saturating_add(lines_to_take).min(total_lines);
3172        let line_num_width = max_line_num.to_string().len().max(5);
3173
3174        for (i, line) in effective_iter.enumerate() {
3175            if i >= lines_to_take || start_line + i >= total_lines {
3176                break;
3177            }
3178            if i > 0 {
3179                selected_content.push('\n');
3180            }
3181            let line_idx = start_line + i; // 0-indexed
3182            let line = line.strip_suffix('\r').unwrap_or(line);
3183            if input.hashline {
3184                let tag = format_hashline_tag(line_idx, line);
3185                let _ = write!(selected_content, "{tag}:{line}");
3186            } else {
3187                let line_num = line_idx + 1;
3188                let _ = write!(selected_content, "{line_num:>line_num_width$}→{line}");
3189            }
3190
3191            if selected_content.len() > DEFAULT_MAX_BYTES * 2 {
3192                break;
3193            }
3194        }
3195
3196        let artifact_source = (selected_content.len() > TOOL_OUTPUT_ARTIFACT_THRESHOLD_BYTES)
3197            .then(|| selected_content.clone());
3198
3199        let mut truncation = truncate_head(
3200            selected_content,
3201            max_lines_for_truncation,
3202            DEFAULT_MAX_BYTES,
3203        );
3204        truncation.total_lines = total_lines;
3205
3206        let mut output_text = std::mem::take(&mut truncation.content);
3207        let mut details: Option<serde_json::Value> = None;
3208
3209        if truncation.first_line_exceeds_limit {
3210            let first_line = text_content.split('\n').next().unwrap_or("");
3211            let first_line = first_line.strip_suffix('\r').unwrap_or(first_line);
3212            let first_line_size = format_size(first_line.len());
3213            output_text = format!(
3214                "[Line {start_line_display} is {first_line_size}, exceeds {} limit. Use bash: sed -n '{start_line_display}p' '{}' | head -c {DEFAULT_MAX_BYTES}]",
3215                format_size(DEFAULT_MAX_BYTES),
3216                input.path.replace('\'', "'\\''")
3217            );
3218            details = Some(serde_json::json!({ "truncation": truncation }));
3219        } else if truncation.truncated {
3220            let end_line_display = start_line_display
3221                .saturating_add(truncation.output_lines)
3222                .saturating_sub(1);
3223            let next_offset = end_line_display.saturating_add(1);
3224
3225            if truncation.truncated_by == Some(TruncatedBy::Lines) {
3226                let _ = write!(
3227                    output_text,
3228                    "\n\n[Showing lines {start_line_display}-{end_line_display} of {total_lines}. Use offset={next_offset} to continue.]"
3229                );
3230            } else {
3231                let _ = write!(
3232                    output_text,
3233                    "\n\n[Showing lines {start_line_display}-{end_line_display} of {total_lines} ({} limit). Use offset={next_offset} to continue.]",
3234                    format_size(DEFAULT_MAX_BYTES)
3235                );
3236            }
3237
3238            details = Some(serde_json::json!({ "truncation": truncation }));
3239        } else {
3240            // Calculate how many lines we actually displayed
3241            let displayed_lines = truncation.output_lines;
3242            let end_line_display = start_line_display
3243                .saturating_add(displayed_lines)
3244                .saturating_sub(1);
3245
3246            if end_line_display < total_lines {
3247                let remaining = total_lines.saturating_sub(end_line_display);
3248                let next_offset = end_line_display.saturating_add(1);
3249                let _ = write!(
3250                    output_text,
3251                    "\n\n[{remaining} more lines in file. Use offset={next_offset} to continue.]"
3252                );
3253            }
3254        }
3255
3256        if let Some(artifact_source) = artifact_source.as_deref() {
3257            attach_text_artifact_if_needed_with_root(
3258                self.artifact_root.as_deref(),
3259                &mut output_text,
3260                &mut details,
3261                "read",
3262                tool_call_id,
3263                "selectedTextWindow",
3264                artifact_source,
3265            );
3266        }
3267
3268        let output = ToolOutput {
3269            content: vec![ContentBlock::Text(TextContent::new(output_text))],
3270            details,
3271            is_error: false,
3272        };
3273        cache_tool_output(
3274            cache_key,
3275            stable_cache_dependency_for_path(&path, cache_mode, cache_deps.as_deref()),
3276            &output,
3277        );
3278        Ok(output)
3279    }
3280}
3281
3282// ============================================================================
3283// Bash Tool
3284// ============================================================================
3285
3286/// Input parameters for the bash tool.
3287#[derive(Debug, Deserialize)]
3288#[serde(rename_all = "camelCase")]
3289struct BashInput {
3290    command: String,
3291    timeout: Option<u64>,
3292}
3293
3294pub struct BashTool {
3295    cwd: PathBuf,
3296    shell_path: Option<String>,
3297    command_prefix: Option<String>,
3298    artifact_root: Option<PathBuf>,
3299}
3300
3301#[derive(Debug, Clone)]
3302pub struct BashRunResult {
3303    pub output: String,
3304    pub exit_code: i32,
3305    pub cancelled: bool,
3306    pub cancellation_reason: Option<BashCancellationReason>,
3307    pub timeout_ms: Option<u64>,
3308    pub truncated: bool,
3309    pub full_output_path: Option<String>,
3310    pub truncation: Option<TruncationResult>,
3311}
3312
3313#[derive(Debug)]
3314enum BashPipeFrame {
3315    Chunk(Vec<u8>),
3316    Error(String),
3317}
3318
3319#[allow(clippy::unnecessary_lazy_evaluations)] // lazy eval needed on unix for signal()
3320fn exit_status_code(status: std::process::ExitStatus) -> i32 {
3321    status.code().unwrap_or_else(|| {
3322        #[cfg(unix)]
3323        {
3324            use std::os::unix::process::ExitStatusExt as _;
3325            status.signal().map_or(-1, |signal| -signal)
3326        }
3327        #[cfg(not(unix))]
3328        {
3329            -1
3330        }
3331    })
3332}
3333
3334fn bash_cancellation_details(
3335    reason: BashCancellationReason,
3336    timeout_ms: Option<u64>,
3337    exit_code: i32,
3338) -> serde_json::Value {
3339    serde_json::json!({
3340        "schema": BASH_CANCELLATION_SCHEMA_V1,
3341        "status": "cancelled",
3342        "reason": reason.as_str(),
3343        "cleanup": "process_group_tree_terminated",
3344        "exitCode": exit_code,
3345        "timeoutMs": timeout_ms,
3346    })
3347}
3348
3349#[allow(clippy::too_many_lines)]
3350pub(crate) async fn run_bash_command(
3351    cwd: &Path,
3352    shell_path: Option<&str>,
3353    command_prefix: Option<&str>,
3354    command: &str,
3355    timeout_secs: Option<u64>,
3356    on_update: Option<&(dyn Fn(ToolUpdate) + Send + Sync)>,
3357) -> Result<BashRunResult> {
3358    let timeout_secs = match timeout_secs {
3359        None => Some(DEFAULT_BASH_TIMEOUT_SECS),
3360        Some(0) => None,
3361        Some(value) => Some(value),
3362    };
3363    let command = command_prefix.filter(|p| !p.trim().is_empty()).map_or_else(
3364        || command.to_string(),
3365        |prefix| format!("{prefix}\n{command}"),
3366    );
3367    let command = format!("trap 'code=$?; wait; exit $code' EXIT\n{command}");
3368
3369    if !cwd.exists() {
3370        return Err(Error::tool(
3371            "bash",
3372            format!(
3373                "Working directory does not exist: {}\nCannot execute bash commands.",
3374                cwd.display()
3375            ),
3376        ));
3377    }
3378
3379    let shell = shell_path.unwrap_or_else(|| {
3380        for path in ["/bin/bash", "/usr/bin/bash", "/usr/local/bin/bash"] {
3381            if Path::new(path).exists() {
3382                return path;
3383            }
3384        }
3385        "sh"
3386    });
3387
3388    let mut cmd = command_with_default_sigpipe_in_dir(shell, cwd)
3389        .map_err(|e| Error::tool("bash", format!("Failed to prepare shell: {e}")))?;
3390    cmd.arg("-c")
3391        .arg(&command)
3392        .current_dir(cwd)
3393        .stdin(Stdio::null())
3394        .stdout(Stdio::piped())
3395        .stderr(Stdio::piped());
3396
3397    // Place the shell in its own process group so background children
3398    // can be killed reliably even if the shell exits first.
3399    isolate_command_process_group(&mut cmd);
3400
3401    let mut child = cmd
3402        .spawn()
3403        .map_err(|e| Error::tool("bash", format!("Failed to spawn shell: {e}")))?;
3404
3405    let stdout = child
3406        .stdout
3407        .take()
3408        .ok_or_else(|| Error::tool("bash", "Missing stdout".to_string()))?;
3409    let stderr = child
3410        .stderr
3411        .take()
3412        .ok_or_else(|| Error::tool("bash", "Missing stderr".to_string()))?;
3413
3414    // Wrap in ProcessGuard for cleanup (including tree kill)
3415    let mut guard = ProcessGuard::new(child, ProcessCleanupMode::ProcessGroupTree);
3416
3417    // We use a bounded channel to provide backpressure. If the child process
3418    // produces output faster than the async loop can drain it (and spill to disk),
3419    // the pump threads will block on send(), which stops them from reading from the OS pipe.
3420    // The OS pipe buffer will fill up, causing the child's `write()` calls to block.
3421    // This correctly pauses the child until we catch up, preventing unbounded memory growth (OOM).
3422    let (tx, rx) = mpsc::sync_channel::<BashPipeFrame>(1024);
3423    let tx_stdout = tx.clone();
3424
3425    // Design Decision (bd-xdcrh.4.3):
3426    // We intentionally use raw dedicated OS threads here rather than `asupersync::runtime::spawn_blocking`.
3427    // The `pump_stream` loop blocks indefinitely on `read()` until the subprocess closes the pipe (EOF).
3428    // If we used the runtime's blocking pool, concurrently running long-lived bash tools (like compilers
3429    // or servers) could easily exhaust the pool's thread limit, starving the rest of the application
3430    // of threads needed for short-lived blocking I/O (e.g., SQLite transactions or filesystem metadata).
3431    // Dedicated threads cleanly isolate this unbounded blocking risk.
3432    let stdout_thread = thread::spawn(move || pump_stream(stdout, "stdout", &tx_stdout));
3433    let stderr_thread = thread::spawn(move || pump_stream(stderr, "stderr", &tx));
3434
3435    let max_chunks_bytes = DEFAULT_MAX_BYTES.saturating_mul(2);
3436    let mut bash_output = BashOutputState::new(max_chunks_bytes);
3437    bash_output.timeout_ms = timeout_secs.map(|s| s.saturating_mul(1000));
3438
3439    let cx = AgentCx::for_current_or_request();
3440    let mut timed_out = false;
3441    let mut cancelled = false;
3442    let mut cancellation_reason: Option<BashCancellationReason> = None;
3443    let mut exit_code: Option<i32> = None;
3444    let start = cx
3445        .cx()
3446        .timer_driver()
3447        .map_or_else(wall_now, |timer| timer.now());
3448    let timeout = timeout_secs.map(Duration::from_secs);
3449    let mut terminate_deadline: Option<asupersync::Time> = None;
3450
3451    let tick = Duration::from_millis(10);
3452    loop {
3453        let mut updated = false;
3454        while let Ok(frame) = rx.try_recv() {
3455            if let Err(err) = ingest_bash_pipe_frame(frame, &mut bash_output).await {
3456                let _ = guard.kill();
3457                return Err(err);
3458            }
3459            updated = true;
3460        }
3461
3462        if updated {
3463            emit_bash_update(&bash_output, on_update)?;
3464        }
3465
3466        match guard.try_wait_child() {
3467            Ok(Some(status)) => {
3468                exit_code = Some(exit_status_code(status));
3469                break;
3470            }
3471            Ok(None) => {}
3472            Err(err) => return Err(Error::tool("bash", err.to_string())),
3473        }
3474
3475        let now = cx
3476            .cx()
3477            .timer_driver()
3478            .map_or_else(wall_now, |timer| timer.now());
3479
3480        if let Some(deadline) = terminate_deadline {
3481            if now >= deadline {
3482                if let Some(status) = guard.kill() {
3483                    exit_code = Some(exit_status_code(status));
3484                }
3485                break; // Guard now owns no child after kill()
3486            }
3487        } else if let Some(timeout) = timeout {
3488            let elapsed = std::time::Duration::from_nanos(now.duration_since(start));
3489            if elapsed >= timeout {
3490                timed_out = true;
3491                cancellation_reason = Some(BashCancellationReason::Timeout);
3492                let pid = guard.child.as_ref().map(std::process::Child::id);
3493                terminate_process_group_tree(pid);
3494                terminate_deadline = Some(now + Duration::from_secs(BASH_TERMINATE_GRACE_SECS));
3495            }
3496        }
3497
3498        if terminate_deadline.is_none() && cx.checkpoint().is_err() {
3499            cancelled = true;
3500            cancellation_reason = Some(BashCancellationReason::AmbientCancellation);
3501            let _ = guard.kill();
3502            exit_code = Some(-1);
3503            break;
3504        }
3505
3506        sleep(now, tick).await;
3507    }
3508
3509    // Drain any remaining channel frames while waiting for the pump threads
3510    // to observe EOF and exit. Because the channel is bounded, they may still
3511    // be blocked on send() until we consume the buffered output after the child
3512    // closes its pipe ends. The 5-second cap is a safety net for pathological
3513    // cases (e.g. the child spawned a grandchild that inherited the pipe fd
3514    // and is still running).
3515    {
3516        let drain_start = cx
3517            .cx()
3518            .timer_driver()
3519            .map_or_else(wall_now, |timer| timer.now());
3520        let drain_deadline = drain_start + Duration::from_secs(5);
3521        let allow_drain_cancellation = !cancelled && !timed_out && exit_code.is_none();
3522        loop {
3523            // Drain everything currently available in the channel.
3524            let mut got_data = false;
3525            while let Ok(frame) = rx.try_recv() {
3526                if let Err(err) = ingest_bash_pipe_frame(frame, &mut bash_output).await {
3527                    let _ = guard.kill();
3528                    return Err(err);
3529                }
3530                got_data = true;
3531            }
3532            if got_data {
3533                emit_bash_update(&bash_output, on_update)?;
3534            }
3535
3536            // If both pump threads have finished, all data is in the channel
3537            // and we've drained it above, so we're done.
3538            if stdout_thread.is_finished() && stderr_thread.is_finished() {
3539                // One final drain in case they sent items between our last
3540                // try_recv loop and the is_finished check.
3541                while let Ok(frame) = rx.try_recv() {
3542                    if let Err(err) = ingest_bash_pipe_frame(frame, &mut bash_output).await {
3543                        let _ = guard.kill();
3544                        return Err(err);
3545                    }
3546                }
3547                break;
3548            }
3549
3550            let now = cx
3551                .cx()
3552                .timer_driver()
3553                .map_or_else(wall_now, |timer| timer.now());
3554            if now >= drain_deadline {
3555                break;
3556            }
3557            if allow_drain_cancellation && cx.checkpoint().is_err() {
3558                cancelled = true;
3559                cancellation_reason.get_or_insert(BashCancellationReason::AmbientCancellation);
3560                break;
3561            }
3562            sleep(now, tick).await;
3563        }
3564    }
3565
3566    // Explicitly reap the child process to prevent zombies. try_wait_child()
3567    // uses WNOHANG which *should* reap the zombie on the first successful
3568    // return, but calling wait() as a belt-and-suspenders ensures the zombie
3569    // is cleaned up even if try_wait missed it (observed on macOS when the
3570    // child is in its own process group).
3571    if guard.child.is_some() {
3572        if let Ok(status) = guard.wait() {
3573            exit_code.get_or_insert_with(|| exit_status_code(status));
3574        }
3575    }
3576
3577    drop(bash_output.temp_file.take());
3578
3579    let raw_output = concat_chunks(&bash_output.chunks);
3580    let full_output = String::from_utf8_lossy(&raw_output).into_owned();
3581    let full_output_last_line_len = full_output.split('\n').next_back().map_or(0, str::len);
3582
3583    let mut truncation = truncate_tail(full_output, DEFAULT_MAX_LINES, DEFAULT_MAX_BYTES);
3584    if bash_output.total_bytes > bash_output.chunks_bytes {
3585        truncation.truncated = true;
3586        truncation.truncated_by = Some(TruncatedBy::Bytes);
3587        truncation.total_bytes = bash_output.total_bytes;
3588        truncation.total_lines = line_count_from_newline_count(
3589            bash_output.total_bytes,
3590            bash_output.line_count,
3591            bash_output.last_byte_was_newline,
3592        );
3593    }
3594
3595    let mut output_text = if truncation.content.is_empty() {
3596        "(no output)".to_string()
3597    } else {
3598        std::mem::take(&mut truncation.content)
3599    };
3600
3601    let mut full_output_path = None;
3602    if truncation.truncated {
3603        if let Some(path) = bash_output.temp_file_path.as_ref() {
3604            full_output_path = Some(path.display().to_string());
3605        }
3606
3607        let start_line = truncation
3608            .total_lines
3609            .saturating_sub(truncation.output_lines)
3610            .saturating_add(1);
3611        let end_line = truncation.total_lines;
3612
3613        let display_path = full_output_path.as_deref().unwrap_or("undefined");
3614        let file_limit_hit = bash_output.total_bytes > BASH_FILE_LIMIT_BYTES;
3615        let output_qualifier = if file_limit_hit {
3616            format!(
3617                "Partial output (capped at {})",
3618                format_size(BASH_FILE_LIMIT_BYTES)
3619            )
3620        } else {
3621            "Full output".to_string()
3622        };
3623
3624        if truncation.last_line_partial {
3625            let last_line_size = format_size(full_output_last_line_len);
3626            let _ = write!(
3627                output_text,
3628                "\n\n[Showing last {} of line {end_line} (line is {last_line_size}). {output_qualifier}: {display_path}]",
3629                format_size(truncation.output_bytes)
3630            );
3631        } else if truncation.truncated_by == Some(TruncatedBy::Lines) {
3632            let _ = write!(
3633                output_text,
3634                "\n\n[Showing lines {start_line}-{end_line} of {}. {output_qualifier}: {display_path}]",
3635                truncation.total_lines
3636            );
3637        } else {
3638            let _ = write!(
3639                output_text,
3640                "\n\n[Showing lines {start_line}-{end_line} of {} ({} limit). {output_qualifier}: {display_path}]",
3641                truncation.total_lines,
3642                format_size(DEFAULT_MAX_BYTES)
3643            );
3644        }
3645    }
3646
3647    if timed_out {
3648        cancelled = true;
3649        if !output_text.is_empty() {
3650            output_text.push_str("\n\n");
3651        }
3652        let timeout_display = timeout_secs.unwrap_or(0);
3653        let _ = write!(
3654            output_text,
3655            "Command timed out after {timeout_display} seconds"
3656        );
3657    }
3658
3659    let exit_code = exit_code.unwrap_or(-1);
3660    if !cancelled && exit_code != 0 {
3661        let _ = write!(output_text, "\n\nCommand exited with code {exit_code}");
3662    }
3663
3664    Ok(BashRunResult {
3665        output: output_text,
3666        exit_code,
3667        cancelled,
3668        cancellation_reason,
3669        timeout_ms: timeout_secs.map(|s| s.saturating_mul(1000)),
3670        truncated: truncation.truncated,
3671        full_output_path,
3672        truncation: if truncation.truncated {
3673            Some(truncation)
3674        } else {
3675            None
3676        },
3677    })
3678}
3679
3680impl BashTool {
3681    pub fn new(cwd: &Path) -> Self {
3682        Self {
3683            cwd: cwd.to_path_buf(),
3684            shell_path: None,
3685            command_prefix: None,
3686            artifact_root: None,
3687        }
3688    }
3689
3690    pub fn with_shell(
3691        cwd: &Path,
3692        shell_path: Option<String>,
3693        command_prefix: Option<String>,
3694    ) -> Self {
3695        Self {
3696            cwd: cwd.to_path_buf(),
3697            shell_path,
3698            command_prefix,
3699            artifact_root: None,
3700        }
3701    }
3702
3703    #[cfg(test)]
3704    fn with_artifact_root(cwd: &Path, artifact_root: &Path) -> Self {
3705        Self {
3706            cwd: cwd.to_path_buf(),
3707            shell_path: None,
3708            command_prefix: None,
3709            artifact_root: Some(artifact_root.to_path_buf()),
3710        }
3711    }
3712}
3713
3714#[async_trait]
3715#[allow(clippy::unnecessary_literal_bound)]
3716impl Tool for BashTool {
3717    fn name(&self) -> &str {
3718        "bash"
3719    }
3720    fn label(&self) -> &str {
3721        "bash"
3722    }
3723    fn description(&self) -> &str {
3724        "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."
3725    }
3726
3727    fn parameters(&self) -> serde_json::Value {
3728        serde_json::json!({
3729            "type": "object",
3730            "properties": {
3731                "command": {
3732                    "type": "string",
3733                    "description": "Bash command to execute"
3734                },
3735                "timeout": {
3736                    "type": "integer",
3737                    "description": "Timeout in seconds (default 120; set 0 to disable)"
3738                }
3739            },
3740            "required": ["command"]
3741        })
3742    }
3743
3744    fn effects(&self) -> ToolEffects {
3745        ToolEffects::process().union(ToolEffects::write())
3746    }
3747
3748    #[allow(clippy::too_many_lines)]
3749    async fn execute(
3750        &self,
3751        tool_call_id: &str,
3752        input: serde_json::Value,
3753        on_update: Option<Box<dyn Fn(ToolUpdate) + Send + Sync>>,
3754    ) -> Result<ToolOutput> {
3755        let input: BashInput =
3756            serde_json::from_value(input).map_err(|e| Error::validation(e.to_string()))?;
3757
3758        let result = run_bash_command(
3759            &self.cwd,
3760            self.shell_path.as_deref(),
3761            self.command_prefix.as_deref(),
3762            &input.command,
3763            input.timeout,
3764            on_update.as_deref(),
3765        )
3766        .await?;
3767
3768        let mut details_map = serde_json::Map::new();
3769        if let Some(truncation) = result.truncation.as_ref() {
3770            details_map.insert("truncation".to_string(), serde_json::to_value(truncation)?);
3771        }
3772        if let Some(path) = result.full_output_path.as_ref() {
3773            details_map.insert(
3774                "fullOutputPath".to_string(),
3775                serde_json::Value::String(path.clone()),
3776            );
3777        }
3778        if let Some(reason) = result.cancellation_reason {
3779            details_map.insert(
3780                "cancellation".to_string(),
3781                bash_cancellation_details(reason, result.timeout_ms, result.exit_code),
3782            );
3783        }
3784
3785        let details = if details_map.is_empty() {
3786            None
3787        } else {
3788            Some(serde_json::Value::Object(details_map))
3789        };
3790        let mut details = details;
3791        let mut output_text = result.output;
3792
3793        if let Some(path) = result.full_output_path.as_deref() {
3794            attach_text_artifact_from_path_if_needed_with_root(
3795                self.artifact_root.as_deref(),
3796                &mut output_text,
3797                &mut details,
3798                "bash",
3799                tool_call_id,
3800                "fullCommandOutput",
3801                Path::new(path),
3802            );
3803        }
3804
3805        let is_error = result.cancelled || result.exit_code != 0;
3806
3807        Ok(ToolOutput {
3808            content: vec![ContentBlock::Text(TextContent::new(output_text))],
3809            details,
3810            is_error,
3811        })
3812    }
3813}
3814
3815// ============================================================================
3816// Edit Tool
3817// ============================================================================
3818
3819/// Input parameters for the edit tool.
3820#[derive(Debug, Deserialize)]
3821#[serde(rename_all = "camelCase")]
3822struct EditInput {
3823    path: String,
3824    old_text: String,
3825    new_text: String,
3826}
3827
3828pub struct EditTool {
3829    cwd: PathBuf,
3830}
3831
3832impl EditTool {
3833    pub fn new(cwd: &Path) -> Self {
3834        Self {
3835            cwd: cwd.to_path_buf(),
3836        }
3837    }
3838}
3839
3840fn strip_bom(s: &str) -> (&str, bool) {
3841    s.strip_prefix('\u{FEFF}')
3842        .map_or_else(|| (s, false), |stripped| (stripped, true))
3843}
3844
3845fn detect_line_ending(content: &str) -> &'static str {
3846    let bytes = content.as_bytes();
3847    let mut idx = 0;
3848    while idx < bytes.len() {
3849        match bytes[idx] {
3850            b'\r' => {
3851                return if bytes.get(idx + 1) == Some(&b'\n') {
3852                    "\r\n"
3853                } else {
3854                    "\r"
3855                };
3856            }
3857            b'\n' => return "\n",
3858            _ => idx += 1,
3859        }
3860    }
3861    "\n"
3862}
3863
3864fn normalize_to_lf(text: &str) -> String {
3865    if !text.contains('\r') {
3866        return text.to_string();
3867    }
3868    let mut out = String::with_capacity(text.len());
3869    let mut chars = text.chars().peekable();
3870    while let Some(c) = chars.next() {
3871        if c == '\r' {
3872            out.push('\n');
3873            if chars.peek() == Some(&'\n') {
3874                chars.next();
3875            }
3876        } else {
3877            out.push(c);
3878        }
3879    }
3880    out
3881}
3882
3883fn normalize_line_endings_chunk<'a>(
3884    chunk: &'a [u8],
3885    pending_cr: &mut bool,
3886) -> std::borrow::Cow<'a, [u8]> {
3887    if !*pending_cr && memchr::memchr(b'\r', chunk).is_none() {
3888        return std::borrow::Cow::Borrowed(chunk);
3889    }
3890
3891    let mut normalized = Vec::with_capacity(chunk.len().saturating_add(usize::from(*pending_cr)));
3892    let mut idx = 0;
3893
3894    if *pending_cr {
3895        normalized.push(b'\n');
3896        if chunk.first() == Some(&b'\n') {
3897            idx = 1;
3898        }
3899        *pending_cr = false;
3900    }
3901
3902    while idx < chunk.len() {
3903        match chunk[idx] {
3904            b'\r' => {
3905                if chunk.get(idx + 1) == Some(&b'\n') {
3906                    normalized.push(b'\n');
3907                    idx += 2;
3908                } else if idx + 1 < chunk.len() {
3909                    normalized.push(b'\n');
3910                    idx += 1;
3911                } else {
3912                    *pending_cr = true;
3913                    idx += 1;
3914                }
3915            }
3916            byte => {
3917                normalized.push(byte);
3918                idx += 1;
3919            }
3920        }
3921    }
3922
3923    std::borrow::Cow::Owned(normalized)
3924}
3925
3926fn restore_line_endings(text: &str, ending: &str) -> String {
3927    match ending {
3928        "\r\n" => text.replace('\n', "\r\n"),
3929        "\r" => text.replace('\n', "\r"),
3930        _ => text.to_string(),
3931    }
3932}
3933
3934#[derive(Debug, Clone)]
3935struct FuzzyMatchResult {
3936    found: bool,
3937    index: usize,
3938    match_length: usize,
3939    exact_match: bool,
3940}
3941
3942/// Map a range in normalized content back to byte offsets in the original text.
3943///
3944/// Returns `(original_start_byte_idx, original_match_byte_len)`.
3945fn map_normalized_range_to_original(
3946    content: &str,
3947    norm_match_start: usize,
3948    norm_match_len: usize,
3949) -> (usize, usize) {
3950    let mut norm_idx = 0;
3951    let mut orig_idx = 0;
3952    let mut match_start = None;
3953    let mut match_end = None;
3954    let norm_match_end = norm_match_start + norm_match_len;
3955    let mut last_trimmed_end = 0;
3956    let mut last_has_newline = false;
3957
3958    for line in content.split_inclusive('\n') {
3959        let line_content = line.strip_suffix('\n').unwrap_or(line);
3960        let has_newline = line.ends_with('\n');
3961        let trimmed_len = line_content
3962            .trim_end_matches(|c: char| c.is_whitespace() || is_special_unicode_space(c))
3963            .len();
3964        let trimmed_end = orig_idx + trimmed_len;
3965        last_trimmed_end = trimmed_end;
3966        last_has_newline = has_newline;
3967
3968        for (char_offset, c) in line_content.char_indices() {
3969            // match_end can be detected at any position including trailing
3970            // whitespace — it correctly points to right after the last content char.
3971            if norm_idx == norm_match_end && match_end.is_none() {
3972                match_end = Some(orig_idx + char_offset);
3973            }
3974
3975            if char_offset >= trimmed_len {
3976                continue;
3977            }
3978
3979            // match_start must only be detected at non-trailing-whitespace positions.
3980            // During trailing whitespace, norm_idx is "frozen" at the value after the
3981            // last real char, which corresponds to the newline in normalized content —
3982            // not the trailing space. The post-loop newline check handles that case.
3983            if norm_idx == norm_match_start && match_start.is_none() {
3984                match_start = Some(orig_idx + char_offset);
3985            }
3986            if match_start.is_some() && match_end.is_some() {
3987                break;
3988            }
3989
3990            let normalized_char = if is_special_unicode_space(c) {
3991                ' '
3992            } else if matches!(c, '\u{2018}' | '\u{2019}') {
3993                '\''
3994            } else if matches!(c, '\u{201C}' | '\u{201D}' | '\u{201E}' | '\u{201F}') {
3995                '"'
3996            } else if matches!(
3997                c,
3998                '\u{2010}'
3999                    | '\u{2011}'
4000                    | '\u{2012}'
4001                    | '\u{2013}'
4002                    | '\u{2014}'
4003                    | '\u{2015}'
4004                    | '\u{2212}'
4005            ) {
4006                '-'
4007            } else {
4008                c
4009            };
4010
4011            norm_idx += normalized_char.len_utf8();
4012        }
4013
4014        orig_idx += line_content.len();
4015
4016        if has_newline {
4017            if norm_idx == norm_match_start && match_start.is_none() {
4018                match_start = Some(orig_idx);
4019            }
4020            if norm_idx == norm_match_end && match_end.is_none() {
4021                match_end = Some(trimmed_end);
4022            }
4023
4024            norm_idx += 1;
4025            orig_idx += 1;
4026        }
4027
4028        if match_start.is_some() && match_end.is_some() {
4029            break;
4030        }
4031    }
4032
4033    if norm_idx == norm_match_end && match_end.is_none() {
4034        match_end = Some(if last_has_newline {
4035            orig_idx
4036        } else {
4037            last_trimmed_end
4038        });
4039    }
4040
4041    let start = match_start.unwrap_or(0);
4042    let end = match_end.unwrap_or(content.len());
4043    (start, end.saturating_sub(start))
4044}
4045
4046fn build_normalized_content(content: &str) -> String {
4047    let mut normalized = String::with_capacity(content.len());
4048    let mut lines = content.split('\n').peekable();
4049
4050    while let Some(line) = lines.next() {
4051        let trimmed_len = line
4052            .trim_end_matches(|c: char| c.is_whitespace() || is_special_unicode_space(c))
4053            .len();
4054        for (char_offset, c) in line.char_indices() {
4055            if char_offset >= trimmed_len {
4056                continue;
4057            }
4058            let normalized_char = if is_special_unicode_space(c) {
4059                ' '
4060            } else if matches!(c, '\u{2018}' | '\u{2019}') {
4061                '\''
4062            } else if matches!(c, '\u{201C}' | '\u{201D}' | '\u{201E}' | '\u{201F}') {
4063                '"'
4064            } else if matches!(
4065                c,
4066                '\u{2010}'
4067                    | '\u{2011}'
4068                    | '\u{2012}'
4069                    | '\u{2013}'
4070                    | '\u{2014}'
4071                    | '\u{2015}'
4072                    | '\u{2212}'
4073            ) {
4074                '-'
4075            } else {
4076                c
4077            };
4078            normalized.push(normalized_char);
4079        }
4080        if lines.peek().is_some() {
4081            normalized.push('\n');
4082        }
4083    }
4084    normalized
4085}
4086
4087#[cfg(test)]
4088fn fuzzy_find_text(content: &str, old_text: &str) -> FuzzyMatchResult {
4089    fuzzy_find_text_with_normalized(content, old_text, None, None)
4090}
4091
4092/// Like [`fuzzy_find_text`], but accepts optional pre-computed normalized
4093/// versions.
4094fn fuzzy_find_text_with_normalized(
4095    content: &str,
4096    old_text: &str,
4097    precomputed_content: Option<&str>,
4098    precomputed_old: Option<&str>,
4099) -> FuzzyMatchResult {
4100    use std::borrow::Cow;
4101
4102    // First, try exact match (fastest path)
4103    if let Some(index) = content.find(old_text) {
4104        return FuzzyMatchResult {
4105            found: true,
4106            index,
4107            match_length: old_text.len(),
4108            exact_match: true,
4109        };
4110    }
4111
4112    // Build normalized versions (reuse pre-computed if available)
4113    let normalized_content = precomputed_content.map_or_else(
4114        || Cow::Owned(build_normalized_content(content)),
4115        Cow::Borrowed,
4116    );
4117    let normalized_old_text = precomputed_old.map_or_else(
4118        || Cow::Owned(build_normalized_content(old_text)),
4119        Cow::Borrowed,
4120    );
4121
4122    // Try to find the normalized old_text in normalized content
4123    if let Some(normalized_index) = normalized_content.find(normalized_old_text.as_ref()) {
4124        let (original_start, original_match_len) =
4125            map_normalized_range_to_original(content, normalized_index, normalized_old_text.len());
4126
4127        return FuzzyMatchResult {
4128            found: true,
4129            index: original_start,
4130            match_length: original_match_len,
4131            exact_match: false,
4132        };
4133    }
4134
4135    FuzzyMatchResult {
4136        found: false,
4137        index: 0,
4138        match_length: 0,
4139        exact_match: false,
4140    }
4141}
4142
4143fn count_overlapping_occurrences(haystack: &str, needle: &str) -> usize {
4144    if needle.is_empty() {
4145        return 0;
4146    }
4147
4148    haystack
4149        .char_indices()
4150        .filter(|(idx, _)| haystack[*idx..].starts_with(needle))
4151        .count()
4152}
4153
4154#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4155enum DiffTag {
4156    Equal,
4157    Added,
4158    Removed,
4159}
4160
4161#[derive(Debug, Clone)]
4162struct DiffPart {
4163    tag: DiffTag,
4164    value: String,
4165}
4166
4167fn diff_parts(old_content: &str, new_content: &str) -> Vec<DiffPart> {
4168    use similar::ChangeTag;
4169
4170    let diff = similar::TextDiff::from_lines(old_content, new_content);
4171
4172    let mut parts: Vec<DiffPart> = Vec::new();
4173    let mut current_tag: Option<DiffTag> = None;
4174    let mut current_lines: Vec<&str> = Vec::new();
4175
4176    for change in diff.iter_all_changes() {
4177        let tag = match change.tag() {
4178            ChangeTag::Equal => DiffTag::Equal,
4179            ChangeTag::Insert => DiffTag::Added,
4180            ChangeTag::Delete => DiffTag::Removed,
4181        };
4182
4183        let mut line = change.value();
4184        if let Some(stripped) = line.strip_suffix('\n') {
4185            line = stripped;
4186        }
4187
4188        if current_tag == Some(tag) {
4189            current_lines.push(line);
4190        } else {
4191            if let Some(prev_tag) = current_tag {
4192                parts.push(DiffPart {
4193                    tag: prev_tag,
4194                    value: current_lines.join("\n"),
4195                });
4196            }
4197            current_tag = Some(tag);
4198            current_lines = vec![line];
4199        }
4200    }
4201
4202    if let Some(tag) = current_tag {
4203        parts.push(DiffPart {
4204            tag,
4205            value: current_lines.join("\n"),
4206        });
4207    }
4208
4209    parts
4210}
4211
4212fn diff_line_num_width(old_content: &str, new_content: &str) -> usize {
4213    // Count newlines with memchr (avoids iterator-item overhead of split().count())
4214    let old_line_count = memchr::memchr_iter(b'\n', old_content.as_bytes()).count() + 1;
4215    let new_line_count = memchr::memchr_iter(b'\n', new_content.as_bytes()).count() + 1;
4216    let max_line_num = old_line_count.max(new_line_count).max(1);
4217    max_line_num.ilog10() as usize + 1
4218}
4219
4220fn split_diff_lines(value: &str) -> Vec<&str> {
4221    // value is joined by `\n` from a Vec<&str> in diff_parts, so there is no
4222    // spurious trailing newline. We can split exactly.
4223    // We only need to handle the case where value is empty but it originated from
4224    // 0 elements, but `diff_parts` only emits when there is at least 1 line.
4225    // If value is "", `split('\n')` returns `[""]`, which correctly represents 1 empty line.
4226    value.split('\n').collect()
4227}
4228
4229#[inline]
4230const fn is_change_tag(tag: DiffTag) -> bool {
4231    matches!(tag, DiffTag::Added | DiffTag::Removed)
4232}
4233
4234#[derive(Debug)]
4235struct DiffRenderState {
4236    output: String,
4237    old_line_num: usize,
4238    new_line_num: usize,
4239    last_was_change: bool,
4240    first_changed_line: Option<usize>,
4241    line_num_width: usize,
4242    context_lines: usize,
4243}
4244
4245impl DiffRenderState {
4246    const fn new(line_num_width: usize, context_lines: usize) -> Self {
4247        Self {
4248            output: String::new(),
4249            old_line_num: 1,
4250            new_line_num: 1,
4251            last_was_change: false,
4252            first_changed_line: None,
4253            line_num_width,
4254            context_lines,
4255        }
4256    }
4257
4258    #[inline]
4259    fn ensure_line_break(&mut self) {
4260        if !self.output.is_empty() {
4261            self.output.push('\n');
4262        }
4263    }
4264
4265    const fn mark_first_change(&mut self) {
4266        if self.first_changed_line.is_none() {
4267            self.first_changed_line = Some(self.new_line_num);
4268        }
4269    }
4270
4271    fn push_added_line(&mut self, line: &str) {
4272        self.ensure_line_break();
4273        let _ = write!(
4274            self.output,
4275            "+{line_num:>width$} {line}",
4276            line_num = self.new_line_num,
4277            width = self.line_num_width
4278        );
4279        self.new_line_num = self.new_line_num.saturating_add(1);
4280    }
4281
4282    fn push_removed_line(&mut self, line: &str) {
4283        self.ensure_line_break();
4284        let _ = write!(
4285            self.output,
4286            "-{line_num:>width$} {line}",
4287            line_num = self.old_line_num,
4288            width = self.line_num_width
4289        );
4290        self.old_line_num = self.old_line_num.saturating_add(1);
4291    }
4292
4293    fn push_context_line(&mut self, line: &str) {
4294        self.ensure_line_break();
4295        let _ = write!(
4296            self.output,
4297            " {line_num:>width$} {line}",
4298            line_num = self.old_line_num,
4299            width = self.line_num_width
4300        );
4301        self.old_line_num = self.old_line_num.saturating_add(1);
4302        self.new_line_num = self.new_line_num.saturating_add(1);
4303    }
4304
4305    fn push_skip_marker(&mut self, skip: usize) {
4306        if skip == 0 {
4307            return;
4308        }
4309        self.ensure_line_break();
4310        let _ = write!(
4311            self.output,
4312            " {:>width$} ...",
4313            " ",
4314            width = self.line_num_width
4315        );
4316        self.old_line_num = self.old_line_num.saturating_add(skip);
4317        self.new_line_num = self.new_line_num.saturating_add(skip);
4318    }
4319}
4320
4321fn render_changed_part(tag: DiffTag, raw: &[&str], state: &mut DiffRenderState) {
4322    state.mark_first_change();
4323    for line in raw {
4324        match tag {
4325            DiffTag::Added => state.push_added_line(line),
4326            DiffTag::Removed => state.push_removed_line(line),
4327            DiffTag::Equal => {}
4328        }
4329    }
4330    state.last_was_change = true;
4331}
4332
4333fn render_equal_part(raw: &[&str], next_part_is_change: bool, state: &mut DiffRenderState) {
4334    if !(state.last_was_change || next_part_is_change) {
4335        let raw_len = raw.len();
4336        state.old_line_num = state.old_line_num.saturating_add(raw_len);
4337        state.new_line_num = state.new_line_num.saturating_add(raw_len);
4338        state.last_was_change = false;
4339        return;
4340    }
4341
4342    if state.last_was_change
4343        && next_part_is_change
4344        && raw.len() > state.context_lines.saturating_mul(2)
4345    {
4346        for line in raw.iter().take(state.context_lines) {
4347            state.push_context_line(line);
4348        }
4349
4350        let skip = raw.len().saturating_sub(state.context_lines * 2);
4351        state.push_skip_marker(skip);
4352
4353        for line in raw
4354            .iter()
4355            .skip(raw.len().saturating_sub(state.context_lines))
4356        {
4357            state.push_context_line(line);
4358        }
4359    } else {
4360        // Compute slice bounds directly instead of cloning Vecs
4361        let start = if state.last_was_change {
4362            0
4363        } else {
4364            raw.len().saturating_sub(state.context_lines)
4365        };
4366        let lines_after_start = raw.len().saturating_sub(start);
4367        let (end, skip_end) = if !next_part_is_change && lines_after_start > state.context_lines {
4368            (
4369                start + state.context_lines,
4370                lines_after_start - state.context_lines,
4371            )
4372        } else {
4373            (raw.len(), 0)
4374        };
4375
4376        state.push_skip_marker(start);
4377        for line in &raw[start..end] {
4378            state.push_context_line(line);
4379        }
4380        state.push_skip_marker(skip_end);
4381    }
4382
4383    state.last_was_change = false;
4384}
4385
4386fn generate_diff_string(old_content: &str, new_content: &str) -> (String, Option<usize>) {
4387    let parts = diff_parts(old_content, new_content);
4388    let mut state = DiffRenderState::new(diff_line_num_width(old_content, new_content), 4);
4389
4390    for (i, part) in parts.iter().enumerate() {
4391        let raw = split_diff_lines(&part.value);
4392        let next_part_is_change = parts.get(i + 1).is_some_and(|next| is_change_tag(next.tag));
4393
4394        match part.tag {
4395            DiffTag::Added | DiffTag::Removed => render_changed_part(part.tag, &raw, &mut state),
4396            DiffTag::Equal => render_equal_part(&raw, next_part_is_change, &mut state),
4397        }
4398    }
4399
4400    (state.output, state.first_changed_line)
4401}
4402
4403#[async_trait]
4404#[allow(clippy::unnecessary_literal_bound)]
4405impl Tool for EditTool {
4406    fn name(&self) -> &str {
4407        "edit"
4408    }
4409    fn label(&self) -> &str {
4410        "edit"
4411    }
4412    fn description(&self) -> &str {
4413        "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."
4414    }
4415
4416    fn parameters(&self) -> serde_json::Value {
4417        serde_json::json!({
4418            "type": "object",
4419            "properties": {
4420                "path": {
4421                    "type": "string",
4422                    "description": "Path to the file to edit (relative or absolute)"
4423                },
4424                "oldText": {
4425                    "type": "string",
4426                    "minLength": 1,
4427                    "description": "Text to find and replace (must match uniquely; matching normalizes line endings, Unicode spaces/quotes/dashes, and ignores trailing whitespace)"
4428                },
4429                "newText": {
4430                    "type": "string",
4431                    "description": "New text to replace the old text with"
4432                }
4433            },
4434            "required": ["path", "oldText", "newText"]
4435        })
4436    }
4437
4438    #[allow(clippy::too_many_lines)]
4439    async fn execute(
4440        &self,
4441        _tool_call_id: &str,
4442        input: serde_json::Value,
4443        _on_update: Option<Box<dyn Fn(ToolUpdate) + Send + Sync>>,
4444    ) -> Result<ToolOutput> {
4445        let input: EditInput =
4446            serde_json::from_value(input).map_err(|e| Error::validation(e.to_string()))?;
4447
4448        if input.new_text.len() > WRITE_TOOL_MAX_BYTES {
4449            return Err(Error::validation(format!(
4450                "New text size exceeds maximum allowed ({} > {} bytes)",
4451                input.new_text.len(),
4452                WRITE_TOOL_MAX_BYTES
4453            )));
4454        }
4455
4456        let absolute_path = resolve_read_path(&input.path, &self.cwd);
4457        let absolute_path = enforce_cwd_scope(&absolute_path, &self.cwd, "edit")?;
4458
4459        let meta = asupersync::fs::metadata(&absolute_path)
4460            .await
4461            .map_err(|err| {
4462                let message = match err.kind() {
4463                    std::io::ErrorKind::NotFound => format!("File not found: {}", input.path),
4464                    std::io::ErrorKind::PermissionDenied => {
4465                        format!("Permission denied: {}", input.path)
4466                    }
4467                    _ => format!("Failed to access file {}: {err}", input.path),
4468                };
4469                Error::tool("edit", message)
4470            })?;
4471
4472        if !meta.is_file() {
4473            return Err(Error::tool(
4474                "edit",
4475                format!("Path {} is not a regular file", absolute_path.display()),
4476            ));
4477        }
4478        if meta.len() > READ_TOOL_MAX_BYTES {
4479            return Err(Error::tool(
4480                "edit",
4481                format!(
4482                    "File is too large ({} bytes). Max allowed for editing is {} bytes.",
4483                    meta.len(),
4484                    READ_TOOL_MAX_BYTES
4485                ),
4486            ));
4487        }
4488
4489        if let Err(err) = asupersync::fs::OpenOptions::new()
4490            .read(true)
4491            .write(true)
4492            .open(&absolute_path)
4493            .await
4494        {
4495            let message = match err.kind() {
4496                std::io::ErrorKind::NotFound => format!("File not found: {}", input.path),
4497                std::io::ErrorKind::PermissionDenied => {
4498                    format!("Permission denied: {}", input.path)
4499                }
4500                _ => format!("Failed to open file for editing: {err}"),
4501            };
4502            return Err(Error::tool("edit", message));
4503        }
4504
4505        // Read bytes strictly up to the limit to prevent OOM if metadata failed or file grows.
4506        let file = asupersync::fs::File::open(&absolute_path)
4507            .await
4508            .map_err(|e| Error::tool("edit", format!("Failed to open file: {e}")))?;
4509        let mut raw = Vec::new();
4510        let mut limiter = file.take(READ_TOOL_MAX_BYTES.saturating_add(1));
4511        limiter
4512            .read_to_end(&mut raw)
4513            .await
4514            .map_err(|e| Error::tool("edit", format!("Failed to read file: {e}")))?;
4515
4516        if raw.len() > usize::try_from(READ_TOOL_MAX_BYTES).unwrap_or(usize::MAX) {
4517            return Err(Error::tool(
4518                "edit",
4519                format!("File is too large (> {READ_TOOL_MAX_BYTES} bytes)."),
4520            ));
4521        }
4522
4523        let raw_content = String::from_utf8(raw).map_err(|_| {
4524            Error::tool(
4525                "edit",
4526                "File contains invalid UTF-8 characters and cannot be safely edited as text."
4527                    .to_string(),
4528            )
4529        })?;
4530
4531        // Strip BOM before matching (LLM won't include invisible BOM in oldText).
4532        let (content_no_bom, had_bom) = strip_bom(&raw_content);
4533
4534        let original_ending = detect_line_ending(content_no_bom);
4535        let normalized_content = normalize_to_lf(content_no_bom);
4536        let content_for_matching =
4537            if content_no_bom.contains('\r') && !content_no_bom.contains('\n') {
4538                std::borrow::Cow::Owned(content_no_bom.replace('\r', "\n"))
4539            } else {
4540                std::borrow::Cow::Borrowed(content_no_bom)
4541            };
4542        let normalized_old_text = normalize_to_lf(&input.old_text);
4543
4544        if normalized_old_text.is_empty() {
4545            return Err(Error::tool(
4546                "edit",
4547                "The old text cannot be empty. To prepend text, include the first line's content in oldText and newText.".to_string(),
4548            ));
4549        }
4550        if build_normalized_content(&normalized_old_text).is_empty() {
4551            return Err(Error::tool(
4552                "edit",
4553                "The old text must include at least one non-whitespace character.".to_string(),
4554            ));
4555        }
4556
4557        // Try variants of old_text to handle Unicode normalization differences (NFC vs NFD)
4558        // and potential input normalization (clipboard, LLM output).
4559        //
4560        // Note: normalized_content is already LF-normalized but preserves Unicode form
4561        // (from String::from_utf8).
4562
4563        let mut variants = Vec::with_capacity(3);
4564        variants.push(normalized_old_text.clone());
4565
4566        let nfc = normalized_old_text.nfc().collect::<String>();
4567        if nfc != normalized_old_text {
4568            variants.push(nfc);
4569        }
4570
4571        let nfd = normalized_old_text.nfd().collect::<String>();
4572        if nfd != normalized_old_text {
4573            variants.push(nfd);
4574        }
4575
4576        // Pre-compute normalized versions once and reuse for both matching and
4577        // occurrence counting (avoids 2x redundant O(n) normalization).
4578        let precomputed_content = build_normalized_content(content_for_matching.as_ref());
4579
4580        let mut best_match: Option<(FuzzyMatchResult, String, String)> = None;
4581
4582        for variant in variants {
4583            let precomputed_variant = build_normalized_content(&variant);
4584            let match_result = fuzzy_find_text_with_normalized(
4585                content_for_matching.as_ref(),
4586                &variant,
4587                Some(precomputed_content.as_str()),
4588                Some(precomputed_variant.as_str()),
4589            );
4590
4591            if match_result.found {
4592                best_match = Some((match_result, precomputed_variant, variant));
4593                break;
4594            }
4595        }
4596
4597        let Some((match_result, normalized_old_text, matched_variant)) = best_match else {
4598            return Err(Error::tool(
4599                "edit",
4600                format!(
4601                    "Could not find the exact text in {}. The old text must match exactly including all whitespace and newlines.",
4602                    input.path
4603                ),
4604            ));
4605        };
4606
4607        // Count occurrences in the same matching mode to avoid false ambiguity
4608        // when normalized matching collapses distinct trailing whitespace.
4609        let occurrences = if match_result.exact_match {
4610            count_overlapping_occurrences(content_for_matching.as_ref(), &matched_variant)
4611        } else {
4612            count_overlapping_occurrences(&precomputed_content, &normalized_old_text)
4613        };
4614
4615        if occurrences > 1 {
4616            return Err(Error::tool(
4617                "edit",
4618                format!(
4619                    "Found {occurrences} occurrences of the text in {}. The text must be unique. Please provide more context to make it unique.",
4620                    input.path
4621                ),
4622            ));
4623        }
4624
4625        // Perform replacement in the original coordinate space to preserve
4626        // line endings and unmatched content exactly.
4627        let idx = match_result.index;
4628        let match_len = match_result.match_length;
4629
4630        // Adapt new_text to match the file's line endings.
4631        // normalize_to_lf ensures we start from a known state (LF), then
4632        // restore_line_endings converts LFs to the target ending (e.g. CRLF).
4633        let adapted_new_text =
4634            restore_line_endings(&normalize_to_lf(&input.new_text), original_ending);
4635
4636        let new_len = content_no_bom.len() - match_len + adapted_new_text.len();
4637        let mut new_content = String::with_capacity(new_len);
4638        new_content.push_str(&content_no_bom[..idx]);
4639        new_content.push_str(&adapted_new_text);
4640        new_content.push_str(&content_no_bom[idx + match_len..]);
4641
4642        if content_no_bom.eq(&new_content) {
4643            return Err(Error::tool(
4644                "edit",
4645                format!(
4646                    "No changes made to {}. The replacement produced identical content. This might indicate an issue with special characters or the text not existing as expected.",
4647                    input.path
4648                ),
4649            ));
4650        }
4651
4652        let new_content_for_diff = normalize_to_lf(&new_content);
4653
4654        // Re-add BOM if present.
4655        let mut final_content = new_content;
4656        if had_bom {
4657            final_content = format!("\u{FEFF}{final_content}");
4658        }
4659
4660        // Atomic write (safe improvement vs legacy, behavior-equivalent).
4661        let absolute_path_clone = absolute_path.clone();
4662        let final_content_bytes = final_content.into_bytes();
4663        asupersync::runtime::spawn_blocking_io(move || {
4664            // Capture original permissions before the file is replaced.
4665            let original_perms = std::fs::metadata(&absolute_path_clone)
4666                .ok()
4667                .map(|m| m.permissions());
4668            let parent = absolute_path_clone
4669                .parent()
4670                .unwrap_or_else(|| Path::new("."));
4671            let mut temp_file = tempfile::NamedTempFile::new_in(parent)?;
4672
4673            temp_file.as_file_mut().write_all(&final_content_bytes)?;
4674            temp_file.as_file_mut().sync_all()?;
4675
4676            // Restore original file permissions (tempfile defaults to 0o600) before persisting.
4677            if let Some(perms) = original_perms {
4678                let _ = temp_file.as_file().set_permissions(perms);
4679            } else {
4680                // Default to 0644 (rw-r--r--) instead of tempfile's 0600 if we couldn't read original perms.
4681                #[cfg(unix)]
4682                {
4683                    use std::os::unix::fs::PermissionsExt;
4684                    let _ = temp_file
4685                        .as_file()
4686                        .set_permissions(std::fs::Permissions::from_mode(0o644));
4687                }
4688            }
4689
4690            temp_file
4691                .persist(&absolute_path_clone)
4692                .map_err(|e| e.error)?;
4693            sync_parent_dir(&absolute_path_clone)?;
4694            Ok(())
4695        })
4696        .await
4697        .map_err(|e| Error::tool("edit", format!("Failed to write file: {e}")))?;
4698
4699        let (diff, first_changed_line) =
4700            generate_diff_string(&normalized_content, &new_content_for_diff);
4701        let mut details = serde_json::Map::new();
4702        details.insert("diff".to_string(), serde_json::Value::String(diff));
4703        if let Some(line) = first_changed_line {
4704            details.insert(
4705                "firstChangedLine".to_string(),
4706                serde_json::Value::Number(serde_json::Number::from(line)),
4707            );
4708        }
4709
4710        Ok(ToolOutput {
4711            content: vec![ContentBlock::Text(TextContent::new(format!(
4712                "Successfully replaced text in {}.",
4713                input.path
4714            )))],
4715            details: Some(serde_json::Value::Object(details)),
4716            is_error: false,
4717        })
4718    }
4719}
4720
4721// ============================================================================
4722// Write Tool
4723// ============================================================================
4724
4725/// Input parameters for the write tool.
4726#[derive(Debug, Deserialize)]
4727#[serde(rename_all = "camelCase")]
4728struct WriteInput {
4729    path: String,
4730    content: String,
4731}
4732
4733pub struct WriteTool {
4734    cwd: PathBuf,
4735}
4736
4737impl WriteTool {
4738    pub fn new(cwd: &Path) -> Self {
4739        Self {
4740            cwd: cwd.to_path_buf(),
4741        }
4742    }
4743}
4744
4745#[async_trait]
4746#[allow(clippy::unnecessary_literal_bound)]
4747impl Tool for WriteTool {
4748    fn name(&self) -> &str {
4749        "write"
4750    }
4751    fn label(&self) -> &str {
4752        "write"
4753    }
4754    fn description(&self) -> &str {
4755        "Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories."
4756    }
4757
4758    fn parameters(&self) -> serde_json::Value {
4759        serde_json::json!({
4760            "type": "object",
4761            "properties": {
4762                "path": {
4763                    "type": "string",
4764                    "description": "Path to the file to write (relative or absolute)"
4765                },
4766                "content": {
4767                    "type": "string",
4768                    "description": "Content to write to the file"
4769                }
4770            },
4771            "required": ["path", "content"]
4772        })
4773    }
4774
4775    #[allow(clippy::too_many_lines)]
4776    async fn execute(
4777        &self,
4778        _tool_call_id: &str,
4779        input: serde_json::Value,
4780        _on_update: Option<Box<dyn Fn(ToolUpdate) + Send + Sync>>,
4781    ) -> Result<ToolOutput> {
4782        let input: WriteInput =
4783            serde_json::from_value(input).map_err(|e| Error::validation(e.to_string()))?;
4784
4785        if input.content.len() > WRITE_TOOL_MAX_BYTES {
4786            return Err(Error::validation(format!(
4787                "Content size exceeds maximum allowed ({} > {} bytes)",
4788                input.content.len(),
4789                WRITE_TOOL_MAX_BYTES
4790            )));
4791        }
4792
4793        let path = resolve_path(&input.path, &self.cwd);
4794        let path = enforce_cwd_scope(&path, &self.cwd, "write")?;
4795
4796        if let Ok(meta) = asupersync::fs::metadata(&path).await {
4797            if !meta.is_file() {
4798                return Err(Error::tool(
4799                    "write",
4800                    format!("Path {} is not a regular file", path.display()),
4801                ));
4802            }
4803            if let Err(err) = asupersync::fs::OpenOptions::new()
4804                .write(true)
4805                .open(&path)
4806                .await
4807            {
4808                let message = match err.kind() {
4809                    std::io::ErrorKind::PermissionDenied => {
4810                        format!("Permission denied: {}", input.path)
4811                    }
4812                    _ => format!("Failed to open file for writing: {err}"),
4813                };
4814                return Err(Error::tool("write", message));
4815            }
4816        }
4817
4818        // Create parent directories if needed
4819        if let Some(parent) = path.parent() {
4820            asupersync::fs::create_dir_all(parent)
4821                .await
4822                .map_err(|e| Error::tool("write", format!("Failed to create directories: {e}")))?;
4823        }
4824
4825        // Parity with legacy pi-mono: report JS string length (UTF-16 code units) as "bytes".
4826        let bytes_written = input.content.encode_utf16().count();
4827
4828        // Write atomically using tempfile on a blocking thread
4829        let path_clone = path.clone();
4830        let content_bytes = input.content.into_bytes();
4831        asupersync::runtime::spawn_blocking_io(move || {
4832            // Capture original permissions before the file is replaced (new files get None).
4833            let original_perms = std::fs::metadata(&path_clone).ok().map(|m| m.permissions());
4834            let parent = path_clone.parent().unwrap_or_else(|| Path::new("."));
4835            let mut temp_file = tempfile::NamedTempFile::new_in(parent)?;
4836
4837            temp_file.as_file_mut().write_all(&content_bytes)?;
4838            temp_file.as_file_mut().sync_all()?;
4839
4840            // Restore original file permissions (tempfile defaults to 0o600) before persisting.
4841            if let Some(perms) = original_perms {
4842                let _ = temp_file.as_file().set_permissions(perms);
4843            } else {
4844                // New file: default to 0644 (rw-r--r--) instead of tempfile's 0600.
4845                #[cfg(unix)]
4846                {
4847                    use std::os::unix::fs::PermissionsExt;
4848                    let _ = temp_file
4849                        .as_file()
4850                        .set_permissions(std::fs::Permissions::from_mode(0o644));
4851                }
4852            }
4853
4854            // Persist (atomic rename)
4855            temp_file.persist(&path_clone).map_err(|e| e.error)?;
4856            sync_parent_dir(&path_clone)?;
4857            Ok(())
4858        })
4859        .await
4860        .map_err(|e| Error::tool("write", format!("Failed to write file: {e}")))?;
4861
4862        Ok(ToolOutput {
4863            content: vec![ContentBlock::Text(TextContent::new(format!(
4864                "Successfully wrote {} bytes to {}",
4865                bytes_written, input.path
4866            )))],
4867            details: None,
4868            is_error: false,
4869        })
4870    }
4871}
4872
4873// ============================================================================
4874// Grep Tool
4875// ============================================================================
4876
4877/// Input parameters for the grep tool.
4878#[derive(Debug, Deserialize)]
4879#[serde(rename_all = "camelCase")]
4880struct GrepInput {
4881    pattern: String,
4882    path: Option<String>,
4883    glob: Option<String>,
4884    ignore_case: Option<bool>,
4885    literal: Option<bool>,
4886    context: Option<usize>,
4887    limit: Option<usize>,
4888    #[serde(default)]
4889    hashline: bool,
4890}
4891
4892pub struct GrepTool {
4893    cwd: PathBuf,
4894    artifact_root: Option<PathBuf>,
4895}
4896
4897impl GrepTool {
4898    pub fn new(cwd: &Path) -> Self {
4899        Self {
4900            cwd: cwd.to_path_buf(),
4901            artifact_root: None,
4902        }
4903    }
4904
4905    #[cfg(test)]
4906    fn with_artifact_root(cwd: &Path, artifact_root: &Path) -> Self {
4907        Self {
4908            cwd: cwd.to_path_buf(),
4909            artifact_root: Some(artifact_root.to_path_buf()),
4910        }
4911    }
4912}
4913
4914/// Result of truncating a single grep output line.
4915#[derive(Debug, Clone, PartialEq, Eq)]
4916struct TruncateLineResult {
4917    text: String,
4918    was_truncated: bool,
4919}
4920
4921/// Truncate a single line to max characters, adding a marker suffix.
4922///
4923/// Matches pi-mono behavior: `${line.slice(0, maxChars)}... [truncated]`.
4924fn truncate_line(line: &str, max_chars: usize) -> TruncateLineResult {
4925    let mut chars = line.chars();
4926    let prefix: String = chars.by_ref().take(max_chars).collect();
4927    if chars.next().is_none() {
4928        return TruncateLineResult {
4929            text: line.to_string(),
4930            was_truncated: false,
4931        };
4932    }
4933
4934    TruncateLineResult {
4935        text: format!("{prefix}... [truncated]"),
4936        was_truncated: true,
4937    }
4938}
4939
4940fn process_rg_json_match_line(
4941    line_res: std::io::Result<String>,
4942    matches: &mut Vec<(PathBuf, usize)>,
4943    match_count: &mut usize,
4944    match_limit_reached: &mut bool,
4945    scan_limit: usize,
4946) {
4947    if *match_limit_reached {
4948        return;
4949    }
4950
4951    let line = match line_res {
4952        Ok(l) => l,
4953        Err(e) => {
4954            tracing::debug!("Skipping ripgrep output line due to read error: {e}");
4955            return;
4956        }
4957    };
4958    if line.trim().is_empty() {
4959        return;
4960    }
4961
4962    let Ok(event) = serde_json::from_str::<serde_json::Value>(&line) else {
4963        return;
4964    };
4965
4966    if event.get("type").and_then(serde_json::Value::as_str) != Some("match") {
4967        return;
4968    }
4969
4970    let file_path = event
4971        .pointer("/data/path/text")
4972        .and_then(serde_json::Value::as_str)
4973        .map(PathBuf::from);
4974    let line_number = event
4975        .pointer("/data/line_number")
4976        .and_then(serde_json::Value::as_u64)
4977        .and_then(|n| usize::try_from(n).ok());
4978
4979    if let (Some(fp), Some(ln)) = (file_path, line_number) {
4980        matches.push((fp, ln));
4981        *match_count += 1;
4982        if *match_count >= scan_limit {
4983            *match_limit_reached = true;
4984        }
4985    }
4986}
4987
4988fn drain_rg_stdout(
4989    stdout_rx: &std::sync::mpsc::Receiver<std::io::Result<String>>,
4990    matches: &mut Vec<(PathBuf, usize)>,
4991    match_count: &mut usize,
4992    match_limit_reached: &mut bool,
4993    scan_limit: usize,
4994) {
4995    while let Ok(line_res) = stdout_rx.try_recv() {
4996        process_rg_json_match_line(
4997            line_res,
4998            matches,
4999            match_count,
5000            match_limit_reached,
5001            scan_limit,
5002        );
5003        if *match_limit_reached {
5004            break;
5005        }
5006    }
5007}
5008
5009fn drain_rg_stderr(
5010    stderr_rx: &std::sync::mpsc::Receiver<std::result::Result<Vec<u8>, String>>,
5011    stderr_bytes: &mut Vec<u8>,
5012) -> Result<()> {
5013    while let Ok(chunk_result) = stderr_rx.try_recv() {
5014        let chunk = chunk_result
5015            .map_err(|err| Error::tool("grep", format!("Failed to read stderr: {err}")))?;
5016        stderr_bytes.extend_from_slice(&chunk);
5017    }
5018    Ok(())
5019}
5020
5021#[async_trait]
5022#[allow(clippy::unnecessary_literal_bound)]
5023impl Tool for GrepTool {
5024    fn name(&self) -> &str {
5025        "grep"
5026    }
5027    fn label(&self) -> &str {
5028        "grep"
5029    }
5030    fn description(&self) -> &str {
5031        "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."
5032    }
5033
5034    fn parameters(&self) -> serde_json::Value {
5035        serde_json::json!({
5036            "type": "object",
5037            "properties": {
5038                "pattern": {
5039                    "type": "string",
5040                    "description": "Search pattern (regex or literal string)"
5041                },
5042                "path": {
5043                    "type": "string",
5044                    "description": "Directory or file to search (default: current directory)"
5045                },
5046                "glob": {
5047                    "type": "string",
5048                    "description": "Filter files by glob pattern, e.g. '*.ts' or '**/*.spec.ts'"
5049                },
5050                "ignoreCase": {
5051                    "type": "boolean",
5052                    "description": "Case-insensitive search (default: false)"
5053                },
5054                "literal": {
5055                    "type": "boolean",
5056                    "description": "Treat pattern as literal string instead of regex (default: false)"
5057                },
5058                "context": {
5059                    "type": "integer",
5060                    "description": "Number of lines to show before and after each match (default: 0)"
5061                },
5062                "limit": {
5063                    "type": "integer",
5064                    "description": "Maximum number of matches to return (default: 100)"
5065                },
5066                "hashline": {
5067                    "type": "boolean",
5068                    "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."
5069                }
5070            },
5071            "required": ["pattern"]
5072        })
5073    }
5074
5075    fn effects(&self) -> ToolEffects {
5076        ToolEffects::read()
5077    }
5078
5079    #[allow(clippy::too_many_lines)]
5080    async fn execute(
5081        &self,
5082        tool_call_id: &str,
5083        input: serde_json::Value,
5084        _on_update: Option<Box<dyn Fn(ToolUpdate) + Send + Sync>>,
5085    ) -> Result<ToolOutput> {
5086        let input_value = input.clone();
5087        let input: GrepInput =
5088            serde_json::from_value(input).map_err(|e| Error::validation(e.to_string()))?;
5089
5090        if matches!(input.limit, Some(0)) {
5091            return Err(Error::validation(
5092                "`limit` must be greater than 0".to_string(),
5093            ));
5094        }
5095
5096        if !rg_available() {
5097            return Err(Error::tool(
5098                "grep",
5099                "ripgrep (rg) is not available (please install ripgrep)".to_string(),
5100            ));
5101        }
5102
5103        let search_dir = input.path.as_deref().unwrap_or(".");
5104        let search_path = resolve_read_path(search_dir, &self.cwd);
5105        let search_path = enforce_cwd_scope(&search_path, &self.cwd, "grep")?;
5106
5107        let is_directory = asupersync::fs::metadata(&search_path)
5108            .await
5109            .map_err(|e| {
5110                Error::tool(
5111                    "grep",
5112                    format!("Cannot access path {}: {e}", search_path.display()),
5113                )
5114            })?
5115            .is_dir();
5116
5117        let context_value = input.context.unwrap_or(0);
5118        let effective_limit = input.limit.unwrap_or(DEFAULT_GREP_LIMIT).max(1);
5119        // Overfetch one match so limit notices only appear after confirmed overflow.
5120        let scan_limit = effective_limit.saturating_add(1);
5121        let cache_key = tool_cache_key("grep", &self.cwd, &input_value);
5122        let cache_mode = if is_directory {
5123            ToolCacheFingerprintMode::DirectoryRecursive
5124        } else {
5125            ToolCacheFingerprintMode::FileContent
5126        };
5127        let cache_deps = cache_dependency_for_path(&search_path, cache_mode);
5128        if let Some(output) = cached_tool_output(&cache_key, cache_deps.as_deref()) {
5129            return Ok(output);
5130        }
5131
5132        let mut args: Vec<String> = vec![
5133            "--json".to_string(),
5134            "--line-number".to_string(),
5135            "--color=never".to_string(),
5136            "--hidden".to_string(),
5137            // Prevent massive JSON lines from minified files causing OOM
5138            "--max-columns=10000".to_string(),
5139        ];
5140
5141        if input.ignore_case.unwrap_or(false) {
5142            args.push("--ignore-case".to_string());
5143        }
5144        if input.literal.unwrap_or(false) {
5145            args.push("--fixed-strings".to_string());
5146        }
5147        if let Some(glob) = &input.glob {
5148            args.push("--glob".to_string());
5149            args.push(glob.clone());
5150        }
5151
5152        // Mirror find-tool behavior: explicitly pass root/nested .gitignore files
5153        // so ignore rules apply consistently even outside a git worktree.
5154        let ignore_root = if is_directory {
5155            search_path.clone()
5156        } else {
5157            search_path
5158                .parent()
5159                .unwrap_or_else(|| Path::new("."))
5160                .to_path_buf()
5161        };
5162        // NOTE: We rely on rg's native .gitignore discovery. We only explicitly pass
5163        // the root .gitignore if it exists, to ensure it's respected even if the
5164        // search path logic might otherwise miss it (e.g. searching a subdir).
5165        // We do NOT perform a blocking `glob("**/.gitignore")` here, as that stalls
5166        // the async runtime on large repos.
5167        let workspace_gitignore = self.cwd.join(".gitignore");
5168        if workspace_gitignore.exists() {
5169            args.push("--ignore-file".to_string());
5170            args.push(workspace_gitignore.display().to_string());
5171        }
5172        let root_gitignore = ignore_root.join(".gitignore");
5173        if root_gitignore != workspace_gitignore && root_gitignore.exists() {
5174            args.push("--ignore-file".to_string());
5175            args.push(root_gitignore.display().to_string());
5176        }
5177
5178        args.push("--".to_string());
5179        args.push(input.pattern.clone());
5180        args.push(search_path.display().to_string());
5181
5182        let rg_cmd = find_rg_binary().ok_or_else(|| {
5183            Error::tool(
5184                "grep",
5185                "rg is not available (please install ripgrep or rg)".to_string(),
5186            )
5187        })?;
5188
5189        let mut child = command_with_default_sigpipe(rg_cmd)
5190            .map_err(|e| Error::tool("grep", format!("Failed to prepare ripgrep: {e}")))?
5191            .args(args)
5192            .stdout(Stdio::piped())
5193            .stderr(Stdio::piped())
5194            .spawn()
5195            .map_err(|e| Error::tool("grep", format!("Failed to run ripgrep: {e}")))?;
5196
5197        let stdout = child
5198            .stdout
5199            .take()
5200            .ok_or_else(|| Error::tool("grep", "Missing stdout".to_string()))?;
5201        let stderr = child
5202            .stderr
5203            .take()
5204            .ok_or_else(|| Error::tool("grep", "Missing stderr".to_string()))?;
5205
5206        let mut guard = ProcessGuard::new(child, ProcessCleanupMode::ChildOnly);
5207
5208        let (stdout_tx, stdout_rx) = std::sync::mpsc::sync_channel(1024);
5209        let (stderr_tx, stderr_rx) =
5210            std::sync::mpsc::sync_channel::<std::result::Result<Vec<u8>, String>>(1024);
5211
5212        let stdout_thread = std::thread::spawn(move || {
5213            let reader = std::io::BufReader::new(stdout);
5214            for line in reader.lines() {
5215                if stdout_tx.send(line).is_err() {
5216                    break;
5217                }
5218            }
5219        });
5220
5221        let stderr_thread = std::thread::spawn(move || {
5222            let reader = std::io::BufReader::new(stderr);
5223            let _ = stderr_tx.send(read_to_end_capped_and_drain(reader, READ_TOOL_MAX_BYTES));
5224        });
5225
5226        let mut matches: Vec<(PathBuf, usize)> = Vec::new();
5227        let mut match_count: usize = 0;
5228        let mut match_scan_limit_reached = false;
5229        let mut stderr_bytes = Vec::new();
5230
5231        let tick = Duration::from_millis(10);
5232        let mut cx_cancelled = false;
5233
5234        let exit_status = loop {
5235            let agent_cx = AgentCx::for_current_or_request();
5236            let cx = agent_cx.cx();
5237            if cx.checkpoint().is_err() {
5238                cx_cancelled = true;
5239                break None;
5240            }
5241
5242            drain_rg_stdout(
5243                &stdout_rx,
5244                &mut matches,
5245                &mut match_count,
5246                &mut match_scan_limit_reached,
5247                scan_limit,
5248            );
5249            drain_rg_stderr(&stderr_rx, &mut stderr_bytes)?;
5250
5251            if match_scan_limit_reached {
5252                break None;
5253            }
5254
5255            match guard.try_wait_child() {
5256                Ok(Some(status)) => break Some(status),
5257                Ok(None) => {
5258                    let now = cx.timer_driver().map_or_else(wall_now, |timer| timer.now());
5259                    sleep(now, tick).await;
5260                }
5261                Err(e) => return Err(Error::tool("grep", e.to_string())),
5262            }
5263        };
5264
5265        drain_rg_stdout(
5266            &stdout_rx,
5267            &mut matches,
5268            &mut match_count,
5269            &mut match_scan_limit_reached,
5270            scan_limit,
5271        );
5272
5273        let code = if match_scan_limit_reached || cx_cancelled {
5274            // Avoid buffering unbounded stdout/stderr once we've hit the match limit.
5275            // `kill()` terminates the process, and we reap it in a background thread
5276            // so the stdout reader threads can exit promptly without blocking this task.
5277            let _ = guard.kill();
5278            // Drop any buffered stdout/stderr lines that were queued before termination.
5279            while stdout_rx.try_recv().is_ok() {}
5280            while stderr_rx.try_recv().is_ok() {}
5281            0
5282        } else {
5283            let status = exit_status.expect("rg exit status");
5284            status.code().unwrap_or(0)
5285        };
5286
5287        // Keep draining while waiting for reader threads to finish; otherwise a
5288        // bounded channel can fill and block the sender thread, causing join()
5289        // to hang after ripgrep has already exited.
5290        while !stdout_thread.is_finished() || !stderr_thread.is_finished() {
5291            if match_scan_limit_reached || cx_cancelled {
5292                while stdout_rx.try_recv().is_ok() {}
5293            } else {
5294                drain_rg_stdout(
5295                    &stdout_rx,
5296                    &mut matches,
5297                    &mut match_count,
5298                    &mut match_scan_limit_reached,
5299                    scan_limit,
5300                );
5301            }
5302            drain_rg_stderr(&stderr_rx, &mut stderr_bytes)?;
5303            sleep(wall_now(), Duration::from_millis(1)).await;
5304        }
5305
5306        if cx_cancelled {
5307            return Err(Error::tool("grep", "Command cancelled"));
5308        }
5309
5310        // Ensure stdout/stderr reader threads have fully drained the pipes before
5311        // we decide whether matches were found. Without this, fast ripgrep runs can
5312        // exit before the reader thread has delivered JSON match lines, causing
5313        // false "No matches found" results.
5314        stdout_thread
5315            .join()
5316            .map_err(|_| Error::tool("grep", "ripgrep stdout reader thread panicked"))?;
5317        stderr_thread
5318            .join()
5319            .map_err(|_| Error::tool("grep", "ripgrep stderr reader thread panicked"))?;
5320
5321        // Drain any remaining stdout/stderr produced after the last poll.
5322        if match_scan_limit_reached {
5323            while stdout_rx.try_recv().is_ok() {}
5324        } else {
5325            drain_rg_stdout(
5326                &stdout_rx,
5327                &mut matches,
5328                &mut match_count,
5329                &mut match_scan_limit_reached,
5330                scan_limit,
5331            );
5332        }
5333        drain_rg_stderr(&stderr_rx, &mut stderr_bytes)?;
5334
5335        let mut stderr_text = String::from_utf8_lossy(&stderr_bytes).trim().to_string();
5336        if stderr_bytes.len() as u64 > READ_TOOL_MAX_BYTES {
5337            stderr_text.push_str("\n... [stderr truncated] ...");
5338        }
5339        if !match_scan_limit_reached && code != 0 && code != 1 {
5340            let msg = if stderr_text.is_empty() {
5341                format!("ripgrep exited with code {code}")
5342            } else {
5343                stderr_text
5344            };
5345            return Err(Error::tool("grep", msg));
5346        }
5347
5348        let match_limit_reached = match_count > effective_limit;
5349        if match_limit_reached {
5350            matches.truncate(effective_limit);
5351            match_count = effective_limit;
5352        }
5353
5354        if match_count == 0 {
5355            let output = ToolOutput {
5356                content: vec![ContentBlock::Text(TextContent::new("No matches found"))],
5357                details: None,
5358                is_error: false,
5359            };
5360            cache_tool_output(
5361                cache_key,
5362                stable_cache_dependency_for_path(&search_path, cache_mode, cache_deps.as_deref()),
5363                &output,
5364            );
5365            return Ok(output);
5366        }
5367
5368        let mut file_cache: HashMap<PathBuf, Vec<String>> = HashMap::new();
5369        let mut output_builder = HeadTruncatingLineWriter::new(DEFAULT_MAX_BYTES);
5370        let mut artifact_source = String::new();
5371        let mut lines_truncated = false;
5372
5373        // Group matches by file to merge overlapping context windows
5374        let mut file_order: Vec<PathBuf> = Vec::new();
5375        let mut matches_by_file: HashMap<PathBuf, Vec<usize>> = HashMap::new();
5376        for (file_path, line_number) in &matches {
5377            if !matches_by_file.contains_key(file_path) {
5378                file_order.push(file_path.clone());
5379            }
5380            matches_by_file
5381                .entry(file_path.clone())
5382                .or_default()
5383                .push(*line_number);
5384        }
5385
5386        for file_path in file_order {
5387            let Some(mut match_lines) = matches_by_file.remove(&file_path) else {
5388                continue;
5389            };
5390            let relative_path = format_grep_path(&file_path, &self.cwd);
5391            let lines = get_file_lines_async(&file_path, &mut file_cache).await;
5392
5393            if lines.is_empty() {
5394                if let Some(first_match) = match_lines.first() {
5395                    let line = format!(
5396                        "{relative_path}:{first_match}: (unable to read file or too large)"
5397                    );
5398                    output_builder.push_line(&line);
5399                    append_artifact_source_line(&mut artifact_source, &line);
5400                }
5401                continue;
5402            }
5403
5404            match_lines.sort_unstable();
5405            match_lines.dedup();
5406
5407            let mut blocks: Vec<(usize, usize)> = Vec::new();
5408            for &line_number in &match_lines {
5409                let start = if context_value > 0 {
5410                    line_number.saturating_sub(context_value).max(1)
5411                } else {
5412                    line_number
5413                };
5414                let end = if context_value > 0 {
5415                    line_number.saturating_add(context_value).min(lines.len())
5416                } else {
5417                    line_number
5418                };
5419
5420                if let Some(last_block) = blocks.last_mut() {
5421                    if start <= last_block.1.saturating_add(1) {
5422                        last_block.1 = last_block.1.max(end);
5423                        continue;
5424                    }
5425                }
5426                blocks.push((start, end));
5427            }
5428
5429            for (i, (start, end)) in blocks.into_iter().enumerate() {
5430                if i > 0 {
5431                    output_builder.push_line("--");
5432                    append_artifact_source_line(&mut artifact_source, "--");
5433                }
5434                for current in start..=end {
5435                    let line_text = lines.get(current - 1).map_or("", String::as_str);
5436                    let sanitized = line_text.replace('\r', "");
5437                    let truncated = truncate_line(&sanitized, GREP_MAX_LINE_LENGTH);
5438                    if truncated.was_truncated {
5439                        lines_truncated = true;
5440                    }
5441
5442                    if input.hashline {
5443                        let line_idx = current - 1; // 0-indexed for hashline
5444                        let tag = format_hashline_tag(line_idx, &sanitized);
5445                        let line = if match_lines.binary_search(&current).is_ok() {
5446                            format!("{relative_path}:{tag}: {}", truncated.text)
5447                        } else {
5448                            format!("{relative_path}-{tag}- {}", truncated.text)
5449                        };
5450                        output_builder.push_line(&line);
5451                        append_artifact_source_line(&mut artifact_source, &line);
5452                    } else if match_lines.binary_search(&current).is_ok() {
5453                        let line = format!("{relative_path}:{current}: {}", truncated.text);
5454                        output_builder.push_line(&line);
5455                        append_artifact_source_line(&mut artifact_source, &line);
5456                    } else {
5457                        let line = format!("{relative_path}-{current}- {}", truncated.text);
5458                        output_builder.push_line(&line);
5459                        append_artifact_source_line(&mut artifact_source, &line);
5460                    }
5461                }
5462            }
5463        }
5464
5465        // Apply byte truncation while writing, avoiding a second joined copy.
5466        let mut truncation = output_builder.finish();
5467
5468        let mut output = std::mem::take(&mut truncation.content);
5469        let mut notices: Vec<String> = Vec::new();
5470        let mut details_map = serde_json::Map::new();
5471
5472        if match_limit_reached {
5473            notices.push(format!(
5474                "{effective_limit} matches limit reached. Use limit={} for more, or refine pattern",
5475                effective_limit * 2
5476            ));
5477            details_map.insert(
5478                "matchLimitReached".to_string(),
5479                serde_json::Value::Number(serde_json::Number::from(effective_limit)),
5480            );
5481        }
5482
5483        if truncation.truncated {
5484            notices.push(format!("{} limit reached", format_size(DEFAULT_MAX_BYTES)));
5485            details_map.insert("truncation".to_string(), serde_json::to_value(truncation)?);
5486        }
5487
5488        if lines_truncated {
5489            notices.push(format!(
5490                "Some lines truncated to {GREP_MAX_LINE_LENGTH} chars. Use read tool to see full lines"
5491            ));
5492            details_map.insert("linesTruncated".to_string(), serde_json::Value::Bool(true));
5493        }
5494
5495        if !notices.is_empty() {
5496            let _ = write!(output, "\n\n[{}]", notices.join(". "));
5497        }
5498
5499        let mut details = if details_map.is_empty() {
5500            None
5501        } else {
5502            Some(serde_json::Value::Object(details_map))
5503        };
5504
5505        attach_text_artifact_if_needed_with_root(
5506            self.artifact_root.as_deref(),
5507            &mut output,
5508            &mut details,
5509            "grep",
5510            tool_call_id,
5511            "searchResults",
5512            &artifact_source,
5513        );
5514
5515        let output = ToolOutput {
5516            content: vec![ContentBlock::Text(TextContent::new(output))],
5517            details,
5518            is_error: false,
5519        };
5520        cache_tool_output(
5521            cache_key,
5522            stable_cache_dependency_for_path(&search_path, cache_mode, cache_deps.as_deref()),
5523            &output,
5524        );
5525        Ok(output)
5526    }
5527}
5528
5529// ============================================================================
5530// Find Tool
5531// ============================================================================
5532
5533/// Input parameters for the find tool.
5534#[derive(Debug, Deserialize)]
5535#[serde(rename_all = "camelCase")]
5536struct FindInput {
5537    pattern: String,
5538    path: Option<String>,
5539    limit: Option<usize>,
5540}
5541
5542#[derive(Debug)]
5543struct FindEntry {
5544    rel: String,
5545    modified: Option<SystemTime>,
5546}
5547
5548pub struct FindTool {
5549    cwd: PathBuf,
5550    artifact_root: Option<PathBuf>,
5551}
5552
5553impl FindTool {
5554    pub fn new(cwd: &Path) -> Self {
5555        Self {
5556            cwd: cwd.to_path_buf(),
5557            artifact_root: None,
5558        }
5559    }
5560}
5561
5562#[async_trait]
5563#[allow(clippy::unnecessary_literal_bound)]
5564impl Tool for FindTool {
5565    fn name(&self) -> &str {
5566        "find"
5567    }
5568    fn label(&self) -> &str {
5569        "find"
5570    }
5571    fn description(&self) -> &str {
5572        "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)."
5573    }
5574
5575    fn parameters(&self) -> serde_json::Value {
5576        serde_json::json!({
5577            "type": "object",
5578            "properties": {
5579                "pattern": {
5580                    "type": "string",
5581                    "description": "Glob pattern to match files, e.g. '*.ts', '**/*.json', or 'src/**/*.spec.ts'"
5582                },
5583                "path": {
5584                    "type": "string",
5585                    "description": "Directory to search in (default: current directory)"
5586                },
5587                "limit": {
5588                    "type": "integer",
5589                    "description": "Maximum number of results (default: 1000)"
5590                }
5591            },
5592            "required": ["pattern"]
5593        })
5594    }
5595
5596    fn effects(&self) -> ToolEffects {
5597        ToolEffects::read()
5598    }
5599
5600    #[allow(clippy::too_many_lines)]
5601    async fn execute(
5602        &self,
5603        tool_call_id: &str,
5604        input: serde_json::Value,
5605        _on_update: Option<Box<dyn Fn(ToolUpdate) + Send + Sync>>,
5606    ) -> Result<ToolOutput> {
5607        let input_value = input.clone();
5608        let input: FindInput =
5609            serde_json::from_value(input).map_err(|e| Error::validation(e.to_string()))?;
5610
5611        if matches!(input.limit, Some(0)) {
5612            return Err(Error::validation(
5613                "`limit` must be greater than 0".to_string(),
5614            ));
5615        }
5616
5617        let search_dir = input.path.as_deref().unwrap_or(".");
5618        let search_path = resolve_read_path(search_dir, &self.cwd);
5619        let search_path = enforce_cwd_scope(&search_path, &self.cwd, "find")?;
5620        let search_path = strip_unc_prefix(search_path);
5621        let effective_limit = input.limit.unwrap_or(DEFAULT_FIND_LIMIT);
5622        // Overfetch one result so limit notices only appear after confirmed overflow.
5623        let scan_limit = effective_limit.saturating_add(1);
5624
5625        if !search_path.exists() {
5626            return Err(Error::tool(
5627                "find",
5628                format!("Path not found: {}", search_path.display()),
5629            ));
5630        }
5631
5632        let cache_key = tool_cache_key("find", &self.cwd, &input_value);
5633        let cache_mode = if search_path.is_dir() {
5634            ToolCacheFingerprintMode::DirectoryRecursive
5635        } else {
5636            ToolCacheFingerprintMode::FileContent
5637        };
5638        let cache_deps = cache_dependency_for_path(&search_path, cache_mode);
5639        if let Some(output) = cached_tool_output(&cache_key, cache_deps.as_deref()) {
5640            return Ok(output);
5641        }
5642
5643        let fd_cmd = find_fd_binary().ok_or_else(|| {
5644            Error::tool(
5645                "find",
5646                "fd is not available (please install fd-find or fd)".to_string(),
5647            )
5648        })?;
5649
5650        // Build fd arguments
5651        let mut args: Vec<String> = vec![
5652            "--glob".to_string(),
5653            "--color=never".to_string(),
5654            "--hidden".to_string(),
5655            "--max-results".to_string(),
5656            scan_limit.to_string(),
5657        ];
5658
5659        // NOTE: We rely on fd's native .gitignore discovery. We only explicitly pass
5660        // the root .gitignore if it exists, to ensure it's respected even if the
5661        // search path logic might otherwise miss it.
5662        // We do NOT perform a blocking `glob("**/.gitignore")` here.
5663        let workspace_gitignore = self.cwd.join(".gitignore");
5664        if workspace_gitignore.exists() {
5665            args.push("--ignore-file".to_string());
5666            args.push(workspace_gitignore.display().to_string());
5667        }
5668        let root_gitignore = search_path.join(".gitignore");
5669        if root_gitignore != workspace_gitignore && root_gitignore.exists() {
5670            args.push("--ignore-file".to_string());
5671            args.push(root_gitignore.display().to_string());
5672        }
5673
5674        args.push("--".to_string());
5675        args.push(input.pattern.clone());
5676        args.push(search_path.display().to_string());
5677
5678        let mut child = command_with_default_sigpipe_in_dir(fd_cmd, &self.cwd)
5679            .map_err(|e| Error::tool("find", format!("Failed to prepare fd: {e}")))?
5680            .args(args)
5681            .current_dir(&self.cwd)
5682            .stdin(Stdio::null())
5683            .stdout(Stdio::piped())
5684            .stderr(Stdio::piped())
5685            .spawn()
5686            .map_err(|e| Error::tool("find", format!("Failed to run fd: {e}")))?;
5687
5688        let stdout_pipe = child
5689            .stdout
5690            .take()
5691            .ok_or_else(|| Error::tool("find", "Missing stdout"))?;
5692        let stderr_pipe = child
5693            .stderr
5694            .take()
5695            .ok_or_else(|| Error::tool("find", "Missing stderr"))?;
5696
5697        let mut guard = ProcessGuard::new(child, ProcessCleanupMode::ChildOnly);
5698
5699        let stdout_handle = std::thread::spawn(move || -> std::result::Result<Vec<u8>, String> {
5700            read_to_end_capped_and_drain(stdout_pipe, READ_TOOL_MAX_BYTES)
5701        });
5702
5703        let stderr_handle = std::thread::spawn(move || -> std::result::Result<Vec<u8>, String> {
5704            read_to_end_capped_and_drain(stderr_pipe, READ_TOOL_MAX_BYTES)
5705        });
5706
5707        let tick = Duration::from_millis(10);
5708        let start_time = std::time::Instant::now();
5709        let timeout_ms = 60_000; // 60 seconds
5710        let mut timed_out = false;
5711        let mut cx_cancelled = false;
5712
5713        let status = loop {
5714            let agent_cx = AgentCx::for_current_or_request();
5715            let cx = agent_cx.cx();
5716            if cx.checkpoint().is_err() {
5717                cx_cancelled = true;
5718                let _ = guard.kill();
5719                break None;
5720            }
5721
5722            // Check if process is done
5723            match guard.try_wait_child() {
5724                Ok(Some(status)) => break Some(status),
5725                Ok(None) => {
5726                    if start_time.elapsed().as_millis() > timeout_ms {
5727                        timed_out = true;
5728                        let _ = guard.kill();
5729                        break None;
5730                    }
5731                    let now = cx.timer_driver().map_or_else(wall_now, |timer| timer.now());
5732                    sleep(now, tick).await;
5733                }
5734                Err(e) => return Err(Error::tool("find", e.to_string())),
5735            }
5736        };
5737
5738        let stdout_bytes = stdout_handle
5739            .join()
5740            .map_err(|_| Error::tool("find", "fd stdout reader thread panicked"))?
5741            .map_err(|err| Error::tool("find", format!("Failed to read fd stdout: {err}")))?;
5742        let stderr_bytes = stderr_handle
5743            .join()
5744            .map_err(|_| Error::tool("find", "fd stderr reader thread panicked"))?
5745            .map_err(|err| Error::tool("find", format!("Failed to read fd stderr: {err}")))?;
5746
5747        if cx_cancelled {
5748            return Err(Error::tool("find", "Command cancelled"));
5749        }
5750        if timed_out {
5751            return Err(Error::tool("find", "Command timed out after 60 seconds"));
5752        }
5753        let status = status.expect("fd exit status after successful completion");
5754
5755        let mut stdout = String::from_utf8_lossy(&stdout_bytes).trim().to_string();
5756        if stdout_bytes.len() as u64 > READ_TOOL_MAX_BYTES {
5757            stdout.push_str("\n... [stdout truncated] ...");
5758        }
5759        let mut stderr = String::from_utf8_lossy(&stderr_bytes).trim().to_string();
5760        if stderr_bytes.len() as u64 > READ_TOOL_MAX_BYTES {
5761            stderr.push_str("\n... [stderr truncated] ...");
5762        }
5763
5764        if !status.success() && stdout.is_empty() {
5765            if status.code() == Some(1) && stderr.is_empty() {
5766                // fd uses exit code 1 for "no matches"; treat as empty result.
5767            } else {
5768                let code = status.code().unwrap_or(1);
5769                let msg = if stderr.is_empty() {
5770                    format!("fd exited with code {code}")
5771                } else {
5772                    stderr
5773                };
5774                return Err(Error::tool("find", msg));
5775            }
5776        }
5777
5778        if stdout.is_empty() {
5779            let output = ToolOutput {
5780                content: vec![ContentBlock::Text(TextContent::new(
5781                    "No files found matching pattern",
5782                ))],
5783                details: None,
5784                is_error: false,
5785            };
5786            cache_tool_output(
5787                cache_key,
5788                stable_cache_dependency_for_path(&search_path, cache_mode, cache_deps.as_deref()),
5789                &output,
5790            );
5791            return Ok(output);
5792        }
5793
5794        let mut entries: Vec<FindEntry> = Vec::new();
5795        for raw_line in stdout.lines() {
5796            let line = raw_line.trim_end_matches('\r').trim();
5797            if line.is_empty() {
5798                continue;
5799            }
5800
5801            // On Windows, fd may emit `//?/…` or `\\?\…` extended-length
5802            // paths. Strip the prefix so relativization works correctly.
5803            let clean = strip_unc_prefix(PathBuf::from(line));
5804            let line_path = clean.as_path();
5805            let mut rel = if line_path.is_absolute() {
5806                line_path.strip_prefix(&search_path).map_or_else(
5807                    |_| line_path.to_string_lossy().to_string(),
5808                    |stripped| stripped.to_string_lossy().to_string(),
5809                )
5810            } else {
5811                line_path.to_string_lossy().to_string()
5812            };
5813
5814            let full_path = if line_path.is_absolute() {
5815                line_path.to_path_buf()
5816            } else {
5817                search_path.join(line_path)
5818            };
5819            if full_path.is_dir() && !rel.ends_with('/') {
5820                rel.push('/');
5821            }
5822
5823            let modified = std::fs::metadata(&full_path)
5824                .and_then(|meta| meta.modified())
5825                .ok();
5826            entries.push(FindEntry { rel, modified });
5827        }
5828
5829        entries.sort_by(|a, b| {
5830            let ordering = match (&a.modified, &b.modified) {
5831                (Some(a_time), Some(b_time)) => b_time.cmp(a_time),
5832                (Some(_), None) => Ordering::Less,
5833                (None, Some(_)) => Ordering::Greater,
5834                (None, None) => Ordering::Equal,
5835            };
5836            ordering.then_with(|| {
5837                let a_lower = a.rel.to_lowercase();
5838                let b_lower = b.rel.to_lowercase();
5839                a_lower.cmp(&b_lower).then_with(|| a.rel.cmp(&b.rel))
5840            })
5841        });
5842
5843        if entries.is_empty() {
5844            let output = ToolOutput {
5845                content: vec![ContentBlock::Text(TextContent::new(
5846                    "No files found matching pattern",
5847                ))],
5848                details: None,
5849                is_error: false,
5850            };
5851            cache_tool_output(
5852                cache_key,
5853                stable_cache_dependency_for_path(&search_path, cache_mode, cache_deps.as_deref()),
5854                &output,
5855            );
5856            return Ok(output);
5857        }
5858
5859        let result_limit_reached = entries.len() > effective_limit;
5860        let mut output_builder = HeadTruncatingLineWriter::new(DEFAULT_MAX_BYTES);
5861        let mut artifact_source = String::new();
5862        for entry in entries.into_iter().take(effective_limit) {
5863            output_builder.push_line(&entry.rel);
5864            append_artifact_source_line(&mut artifact_source, &entry.rel);
5865        }
5866        let mut truncation = output_builder.finish();
5867
5868        let mut result_output = std::mem::take(&mut truncation.content);
5869        let mut notices: Vec<String> = Vec::new();
5870        let mut details_map = serde_json::Map::new();
5871
5872        if !status.success() {
5873            let code = status.code().unwrap_or(1);
5874            notices.push(format!("fd exited with code {code}"));
5875        }
5876
5877        if result_limit_reached {
5878            notices.push(format!(
5879                "{effective_limit} results limit reached. Use limit={} for more, or refine pattern",
5880                effective_limit * 2
5881            ));
5882            details_map.insert(
5883                "resultLimitReached".to_string(),
5884                serde_json::Value::Number(serde_json::Number::from(effective_limit)),
5885            );
5886        }
5887
5888        if truncation.truncated {
5889            notices.push(format!("{} limit reached", format_size(DEFAULT_MAX_BYTES)));
5890            details_map.insert("truncation".to_string(), serde_json::to_value(truncation)?);
5891        }
5892
5893        if !notices.is_empty() {
5894            let _ = write!(result_output, "\n\n[{}]", notices.join(". "));
5895        }
5896
5897        let mut details = if details_map.is_empty() {
5898            None
5899        } else {
5900            Some(serde_json::Value::Object(details_map))
5901        };
5902
5903        attach_text_artifact_if_needed_with_root(
5904            self.artifact_root.as_deref(),
5905            &mut result_output,
5906            &mut details,
5907            "find",
5908            tool_call_id,
5909            "fileResults",
5910            &artifact_source,
5911        );
5912
5913        let output = ToolOutput {
5914            content: vec![ContentBlock::Text(TextContent::new(result_output))],
5915            details,
5916            is_error: false,
5917        };
5918        cache_tool_output(
5919            cache_key,
5920            stable_cache_dependency_for_path(&search_path, cache_mode, cache_deps.as_deref()),
5921            &output,
5922        );
5923        Ok(output)
5924    }
5925}
5926
5927// ============================================================================
5928// Ls Tool
5929// ============================================================================
5930
5931/// Input parameters for the ls tool.
5932#[derive(Debug, Deserialize)]
5933#[serde(rename_all = "camelCase")]
5934struct LsInput {
5935    path: Option<String>,
5936    limit: Option<usize>,
5937}
5938
5939pub struct LsTool {
5940    cwd: PathBuf,
5941    artifact_root: Option<PathBuf>,
5942}
5943
5944impl LsTool {
5945    pub fn new(cwd: &Path) -> Self {
5946        Self {
5947            cwd: cwd.to_path_buf(),
5948            artifact_root: None,
5949        }
5950    }
5951
5952    #[cfg(test)]
5953    fn with_artifact_root(cwd: &Path, artifact_root: &Path) -> Self {
5954        Self {
5955            cwd: cwd.to_path_buf(),
5956            artifact_root: Some(artifact_root.to_path_buf()),
5957        }
5958    }
5959}
5960
5961#[async_trait]
5962#[allow(clippy::unnecessary_literal_bound, clippy::too_many_lines)]
5963impl Tool for LsTool {
5964    fn name(&self) -> &str {
5965        "ls"
5966    }
5967    fn label(&self) -> &str {
5968        "ls"
5969    }
5970    fn description(&self) -> &str {
5971        "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)."
5972    }
5973
5974    fn parameters(&self) -> serde_json::Value {
5975        serde_json::json!({
5976            "type": "object",
5977            "properties": {
5978                "path": {
5979                    "type": "string",
5980                    "description": "Directory to list (default: current directory)"
5981                },
5982                "limit": {
5983                    "type": "integer",
5984                    "description": "Maximum number of entries to return (default: 500)"
5985                }
5986            }
5987        })
5988    }
5989
5990    fn effects(&self) -> ToolEffects {
5991        ToolEffects::read()
5992    }
5993
5994    async fn execute(
5995        &self,
5996        tool_call_id: &str,
5997        input: serde_json::Value,
5998        _on_update: Option<Box<dyn Fn(ToolUpdate) + Send + Sync>>,
5999    ) -> Result<ToolOutput> {
6000        let input_value = input.clone();
6001        let input: LsInput =
6002            serde_json::from_value(input).map_err(|e| Error::validation(e.to_string()))?;
6003
6004        if matches!(input.limit, Some(0)) {
6005            return Err(Error::validation(
6006                "`limit` must be greater than 0".to_string(),
6007            ));
6008        }
6009
6010        let dir_path = input
6011            .path
6012            .as_ref()
6013            .map_or_else(|| self.cwd.clone(), |p| resolve_read_path(p, &self.cwd));
6014        let dir_path = enforce_cwd_scope(&dir_path, &self.cwd, "list")?;
6015
6016        let effective_limit = input.limit.unwrap_or(DEFAULT_LS_LIMIT);
6017
6018        if !dir_path.exists() {
6019            return Err(Error::tool(
6020                "ls",
6021                format!("Path not found: {}", dir_path.display()),
6022            ));
6023        }
6024        if !dir_path.is_dir() {
6025            return Err(Error::tool(
6026                "ls",
6027                format!("Not a directory: {}", dir_path.display()),
6028            ));
6029        }
6030
6031        let cache_key = tool_cache_key("ls", &self.cwd, &input_value);
6032        let cache_mode = ToolCacheFingerprintMode::DirectoryImmediate;
6033        let cache_deps = cache_dependency_for_path(&dir_path, cache_mode);
6034        if let Some(output) = cached_tool_output(&cache_key, cache_deps.as_deref()) {
6035            return Ok(output);
6036        }
6037
6038        let mut entries = Vec::new();
6039        let mut read_dir = asupersync::fs::read_dir(&dir_path)
6040            .await
6041            .map_err(|e| Error::tool("ls", format!("Cannot read directory: {e}")))?;
6042
6043        let mut scan_limit_reached = false;
6044        while let Some(entry) = read_dir
6045            .next_entry()
6046            .await
6047            .map_err(|e| Error::tool("ls", format!("Cannot read directory entry: {e}")))?
6048        {
6049            if entries.len() >= LS_SCAN_HARD_LIMIT {
6050                scan_limit_reached = true;
6051                break;
6052            }
6053            let name = entry.file_name().to_string_lossy().to_string();
6054            // Handle broken symlinks or permission errors by treating them as non-directories
6055            // Optimization: use file_type() first to avoid stat overhead on every file.
6056            let is_dir = match entry.file_type().await {
6057                Ok(ft) => {
6058                    if ft.is_dir() {
6059                        true
6060                    } else if ft.is_symlink() {
6061                        // Only stat if it's a symlink to see if it points to a directory
6062                        entry.metadata().await.is_ok_and(|meta| meta.is_dir())
6063                    } else {
6064                        false
6065                    }
6066                }
6067                Err(_) => entry.metadata().await.is_ok_and(|meta| meta.is_dir()),
6068            };
6069            entries.push((name, is_dir));
6070        }
6071
6072        // Sort alphabetically (case-insensitive).
6073        entries.sort_by_cached_key(|(a, _)| a.to_lowercase());
6074
6075        let mut output_builder = HeadTruncatingLineWriter::new(DEFAULT_MAX_BYTES);
6076        let mut artifact_source = String::new();
6077        let mut emitted_entries = 0usize;
6078        let mut entry_limit_reached = false;
6079
6080        for (entry, is_dir) in entries {
6081            if emitted_entries >= effective_limit {
6082                entry_limit_reached = true;
6083                break;
6084            }
6085            let line = if is_dir { format!("{entry}/") } else { entry };
6086            output_builder.push_line(&line);
6087            append_artifact_source_line(&mut artifact_source, &line);
6088            emitted_entries = emitted_entries.saturating_add(1);
6089        }
6090
6091        if emitted_entries == 0 {
6092            let output = ToolOutput {
6093                content: vec![ContentBlock::Text(TextContent::new("(empty directory)"))],
6094                details: None,
6095                is_error: false,
6096            };
6097            cache_tool_output(
6098                cache_key,
6099                stable_cache_dependency_for_path(&dir_path, cache_mode, cache_deps.as_deref()),
6100                &output,
6101            );
6102            return Ok(output);
6103        }
6104
6105        // Apply byte truncation while writing, avoiding a second joined copy.
6106        let mut truncation = output_builder.finish();
6107
6108        let mut output = std::mem::take(&mut truncation.content);
6109        let mut details_map = serde_json::Map::new();
6110        let mut notices: Vec<String> = Vec::new();
6111
6112        if entry_limit_reached {
6113            notices.push(format!(
6114                "{effective_limit} entries limit reached. Use limit={} for more",
6115                effective_limit * 2
6116            ));
6117            details_map.insert(
6118                "entryLimitReached".to_string(),
6119                serde_json::Value::Number(serde_json::Number::from(effective_limit)),
6120            );
6121        }
6122
6123        if scan_limit_reached {
6124            notices.push(format!(
6125                "Directory scan limited to {LS_SCAN_HARD_LIMIT} entries to prevent system overload"
6126            ));
6127            details_map.insert(
6128                "scanLimitReached".to_string(),
6129                serde_json::Value::Number(serde_json::Number::from(LS_SCAN_HARD_LIMIT)),
6130            );
6131        }
6132
6133        if truncation.truncated {
6134            notices.push(format!("{} limit reached", format_size(DEFAULT_MAX_BYTES)));
6135            details_map.insert("truncation".to_string(), serde_json::to_value(truncation)?);
6136        }
6137
6138        if !notices.is_empty() {
6139            let _ = write!(output, "\n\n[{}]", notices.join(". "));
6140        }
6141
6142        let mut details = if details_map.is_empty() {
6143            None
6144        } else {
6145            Some(serde_json::Value::Object(details_map))
6146        };
6147
6148        attach_text_artifact_if_needed_with_root(
6149            self.artifact_root.as_deref(),
6150            &mut output,
6151            &mut details,
6152            "ls",
6153            tool_call_id,
6154            "directoryEntries",
6155            &artifact_source,
6156        );
6157
6158        let output = ToolOutput {
6159            content: vec![ContentBlock::Text(TextContent::new(output))],
6160            details,
6161            is_error: false,
6162        };
6163        cache_tool_output(
6164            cache_key,
6165            stable_cache_dependency_for_path(&dir_path, cache_mode, cache_deps.as_deref()),
6166            &output,
6167        );
6168        Ok(output)
6169    }
6170}
6171
6172// ============================================================================
6173// Cleanup
6174// ============================================================================
6175
6176/// Clean up old temporary files created by the bash tool.
6177///
6178/// Scans the system temporary directory for files matching `pi-bash-*.log`
6179/// that are older than 24 hours and deletes them. This prevents indefinite
6180/// accumulation of log files from long-running sessions.
6181pub fn cleanup_temp_files() {
6182    // Run in a detached thread to avoid blocking startup/shutdown.
6183    std::thread::spawn(|| {
6184        let temp_dir = std::env::temp_dir();
6185        let Ok(entries) = std::fs::read_dir(&temp_dir) else {
6186            return;
6187        };
6188
6189        for entry in entries.flatten() {
6190            let path = entry.path();
6191            if !path.is_file() {
6192                continue;
6193            }
6194
6195            let Some(file_name) = path.file_name().and_then(|n| n.to_str()) else {
6196                continue;
6197            };
6198
6199            // Match "pi-bash-" or "pi-rpc-bash-" prefix and ".log" suffix.
6200            if (file_name.starts_with("pi-bash-") || file_name.starts_with("pi-rpc-bash-"))
6201                && std::path::Path::new(file_name)
6202                    .extension()
6203                    .is_some_and(|ext| ext.eq_ignore_ascii_case("log"))
6204                && let Ok(metadata) = entry.metadata()
6205                && metadata.modified().is_ok_and(|modified| {
6206                    modified
6207                        .elapsed()
6208                        .is_ok_and(|age| age > Duration::from_secs(24 * 60 * 60))
6209                })
6210                && let Err(e) = std::fs::remove_file(&path)
6211            {
6212                // Log but don't panic on cleanup failure
6213                tracing::debug!("Failed to remove temp file {}: {}", path.display(), e);
6214            }
6215        }
6216    });
6217}
6218
6219// ============================================================================
6220// Helper functions
6221// ============================================================================
6222
6223fn rg_available() -> bool {
6224    find_rg_binary().is_some()
6225}
6226
6227fn pump_stream<R: Read + Send + 'static>(
6228    mut reader: R,
6229    stream_name: &'static str,
6230    tx: &mpsc::SyncSender<BashPipeFrame>,
6231) {
6232    let mut buf = vec![0u8; 8192];
6233    loop {
6234        match reader.read(&mut buf) {
6235            Ok(0) => break,
6236            Ok(n) => {
6237                if tx.send(BashPipeFrame::Chunk(buf[..n].to_vec())).is_err() {
6238                    break;
6239                }
6240            }
6241            Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => {}
6242            Err(err) => {
6243                let _ = tx.send(BashPipeFrame::Error(format!(
6244                    "Failed to read bash {stream_name}: {err}"
6245                )));
6246                break;
6247            }
6248        }
6249    }
6250}
6251
6252async fn ingest_bash_pipe_frame(frame: BashPipeFrame, state: &mut BashOutputState) -> Result<()> {
6253    match frame {
6254        BashPipeFrame::Chunk(chunk) => ingest_bash_chunk(chunk, state).await,
6255        BashPipeFrame::Error(message) => {
6256            let error_message = bash_capture_error_message(&message, state);
6257            state.abandon_spill_file();
6258            Err(Error::tool("bash", error_message))
6259        }
6260    }
6261}
6262
6263fn bash_capture_error_message(message: &str, state: &BashOutputState) -> String {
6264    let raw = concat_chunks(&state.chunks);
6265    if raw.is_empty() {
6266        return message.to_string();
6267    }
6268
6269    let full_text = String::from_utf8_lossy(&raw).into_owned();
6270    let truncation = truncate_tail(full_text, DEFAULT_MAX_LINES, DEFAULT_MAX_BYTES);
6271    let mut error_message = message.to_string();
6272    let partial_output = if truncation.content.is_empty() {
6273        "(no output)".to_string()
6274    } else {
6275        truncation.content
6276    };
6277    let _ = write!(
6278        error_message,
6279        "\n\nPartial output before failure:\n{partial_output}"
6280    );
6281    if truncation.truncated || state.total_bytes > state.chunks_bytes {
6282        let _ = write!(
6283            error_message,
6284            "\n\n[Partial output truncated before failure]"
6285        );
6286    }
6287    error_message
6288}
6289
6290/// Read from a subprocess pipe until EOF while retaining only the first
6291/// `max_bytes + 1` bytes in memory so callers can detect truncation without
6292/// changing child-process behavior by closing the pipe early.
6293pub(crate) fn read_to_end_capped_and_drain<R: Read>(
6294    mut reader: R,
6295    max_bytes: u64,
6296) -> std::result::Result<Vec<u8>, String> {
6297    let capture_limit = usize::try_from(max_bytes.saturating_add(1)).unwrap_or(usize::MAX);
6298    let mut captured = Vec::with_capacity(capture_limit.min(8192));
6299    let mut chunk = [0u8; 8192];
6300
6301    loop {
6302        match reader.read(&mut chunk) {
6303            Ok(0) => break,
6304            Ok(read) => {
6305                let remaining = capture_limit.saturating_sub(captured.len());
6306                if remaining > 0 {
6307                    let keep = remaining.min(read);
6308                    captured.extend_from_slice(&chunk[..keep]);
6309                }
6310            }
6311            Err(err) if matches!(err.kind(), std::io::ErrorKind::Interrupted) => {}
6312            Err(err) => return Err(err.to_string()),
6313        }
6314    }
6315
6316    Ok(captured)
6317}
6318
6319// Keep `rx` as `&mut Receiver`: `std::sync::mpsc::Receiver` is `Send` but not
6320// `Sync`, and this helper awaits between polls, so `&Receiver` would make the
6321// surrounding future non-Send.
6322#[allow(clippy::needless_pass_by_ref_mut)]
6323#[cfg(test)]
6324async fn drain_bash_output(
6325    rx: &mut mpsc::Receiver<BashPipeFrame>,
6326    bash_output: &mut BashOutputState,
6327    cx: &AgentCx,
6328    drain_deadline: asupersync::Time,
6329    tick: Duration,
6330    allow_cancellation: bool,
6331) -> Result<bool> {
6332    loop {
6333        match rx.try_recv() {
6334            Ok(frame) => ingest_bash_pipe_frame(frame, bash_output).await?,
6335            Err(mpsc::TryRecvError::Empty) => {
6336                let now = cx
6337                    .cx()
6338                    .timer_driver()
6339                    .map_or_else(wall_now, |timer| timer.now());
6340                if now >= drain_deadline {
6341                    return Ok(false);
6342                }
6343                if allow_cancellation && cx.checkpoint().is_err() {
6344                    return Ok(true);
6345                }
6346                sleep(now, tick).await;
6347            }
6348            Err(mpsc::TryRecvError::Disconnected) => return Ok(false),
6349        }
6350    }
6351}
6352
6353fn concat_chunks(chunks: &VecDeque<Vec<u8>>) -> Vec<u8> {
6354    let total: usize = chunks.iter().map(Vec::len).sum();
6355    let mut out = Vec::with_capacity(total);
6356    for chunk in chunks {
6357        out.extend_from_slice(chunk);
6358    }
6359    out
6360}
6361
6362struct BashOutputState {
6363    total_bytes: usize,
6364    line_count: usize,
6365    last_byte_was_newline: bool,
6366    start_time: std::time::Instant,
6367    timeout_ms: Option<u64>,
6368    temp_file_path: Option<PathBuf>,
6369    temp_file: Option<asupersync::fs::File>,
6370    chunks: VecDeque<Vec<u8>>,
6371    chunks_bytes: usize,
6372    max_chunks_bytes: usize,
6373    spill_failed: bool,
6374}
6375
6376impl BashOutputState {
6377    fn new(max_chunks_bytes: usize) -> Self {
6378        Self {
6379            total_bytes: 0,
6380            line_count: 0,
6381            last_byte_was_newline: false,
6382            start_time: std::time::Instant::now(),
6383            timeout_ms: None,
6384            temp_file_path: None,
6385            temp_file: None,
6386            chunks: VecDeque::new(),
6387            chunks_bytes: 0,
6388            max_chunks_bytes,
6389            spill_failed: false,
6390        }
6391    }
6392
6393    fn abandon_spill_file(&mut self) {
6394        self.spill_failed = true;
6395        self.temp_file = None;
6396        if let Some(path) = self.temp_file_path.take() {
6397            if let Err(e) = std::fs::remove_file(&path)
6398                && e.kind() != std::io::ErrorKind::NotFound
6399            {
6400                tracing::debug!(
6401                    "Failed to remove incomplete bash spill file {}: {}",
6402                    path.display(),
6403                    e
6404                );
6405            }
6406        }
6407    }
6408}
6409
6410#[allow(clippy::too_many_lines)]
6411async fn ingest_bash_chunk(chunk: Vec<u8>, state: &mut BashOutputState) -> Result<()> {
6412    if chunk.is_empty() {
6413        return Ok(());
6414    }
6415
6416    state.last_byte_was_newline = chunk.last().is_some_and(|byte| *byte == b'\n');
6417    state.total_bytes = state.total_bytes.saturating_add(chunk.len());
6418    state.line_count = state
6419        .line_count
6420        .saturating_add(memchr::memchr_iter(b'\n', &chunk).count());
6421
6422    if state.total_bytes > DEFAULT_MAX_BYTES
6423        && state.temp_file.is_none()
6424        && state.temp_file_path.is_none()
6425        && !state.spill_failed
6426    {
6427        let id_full = Uuid::new_v4().simple().to_string();
6428        let id = &id_full[..16];
6429        let path = std::env::temp_dir().join(format!("pi-bash-{id}.log"));
6430
6431        // Create the file synchronously with restricted permissions to avoid
6432        // a race condition where the file is world-readable before we fix it.
6433        // We also capture the inode (on Unix) to verify identity later.
6434        let path_clone = path.clone();
6435        let expected_inode: Option<u64> =
6436            asupersync::runtime::spawn_blocking_io(move || -> std::io::Result<Option<u64>> {
6437                let mut options = std::fs::OpenOptions::new();
6438                options.write(true).create_new(true);
6439
6440                #[cfg(unix)]
6441                {
6442                    use std::os::unix::fs::OpenOptionsExt;
6443                    options.mode(0o600);
6444                }
6445
6446                match options.open(&path_clone) {
6447                    Ok(file) => {
6448                        #[cfg(unix)]
6449                        {
6450                            use std::os::unix::fs::MetadataExt;
6451                            Ok(file.metadata().ok().map(|m| m.ino()))
6452                        }
6453                        #[cfg(not(unix))]
6454                        {
6455                            drop(file);
6456                            Ok(None)
6457                        }
6458                    }
6459                    Err(e) => {
6460                        tracing::warn!("Failed to create bash temp file: {e}");
6461                        Ok(None)
6462                    }
6463                }
6464            })
6465            .await
6466            .unwrap_or(None);
6467
6468        if expected_inode.is_some() || !cfg!(unix) {
6469            match asupersync::fs::OpenOptions::new()
6470                .append(true)
6471                .open(&path)
6472                .await
6473            {
6474                Ok(mut file) => {
6475                    #[cfg_attr(not(unix), allow(unused_mut))]
6476                    let mut identity_match = true;
6477                    #[cfg(unix)]
6478                    if let Some(expected) = expected_inode {
6479                        use std::os::unix::fs::MetadataExt;
6480                        // asupersync 0.3.6's fs::Metadata no longer exposes the
6481                        // inode (and fs::File has no general AsRawFd), so re-stat
6482                        // the path with std symlink_metadata (does not follow
6483                        // symlinks) for the TOCTOU/identity guard.
6484                        match std::fs::symlink_metadata(&path) {
6485                            Ok(meta) => {
6486                                if !meta.ino().eq(&expected) {
6487                                    tracing::warn!(
6488                                        "Temp file identity mismatch (possible TOCTOU attack)"
6489                                    );
6490                                    identity_match = false;
6491                                }
6492                            }
6493                            Err(e) => {
6494                                tracing::warn!("Failed to stat temp file: {e}");
6495                                identity_match = false;
6496                            }
6497                        }
6498                    }
6499
6500                    if identity_match {
6501                        // Write buffered chunks to file first so it contains output from the beginning.
6502                        let mut failed_flush = false;
6503                        for existing in &state.chunks {
6504                            if let Err(e) = file.write_all(existing).await {
6505                                tracing::warn!("Failed to flush bash chunk to temp file: {e}");
6506                                failed_flush = true;
6507                                break;
6508                            }
6509                        }
6510
6511                        state.temp_file_path = Some(path);
6512                        if failed_flush {
6513                            state.abandon_spill_file();
6514                        } else {
6515                            state.temp_file = Some(file);
6516                        }
6517                    } else {
6518                        state.temp_file_path = Some(path);
6519                        state.abandon_spill_file();
6520                    }
6521                }
6522                Err(e) => {
6523                    tracing::warn!("Failed to open temp file async: {e}");
6524                    state.temp_file_path = Some(path);
6525                    state.abandon_spill_file();
6526                }
6527            }
6528        } else {
6529            state.spill_failed = true;
6530        }
6531    }
6532
6533    let mut close_spill_file = false;
6534    if let Some(file) = state.temp_file.as_mut() {
6535        let mut abandon_spill_file = false;
6536        if state.total_bytes <= BASH_FILE_LIMIT_BYTES {
6537            if let Err(e) = file.write_all(&chunk).await {
6538                tracing::warn!("Failed to write bash chunk to temp file: {e}");
6539                abandon_spill_file = true;
6540            }
6541        } else {
6542            // Hard limit reached. Stop writing and close the file to release the FD.
6543            if !state.spill_failed {
6544                tracing::warn!("Bash output exceeded hard limit; stopping file log");
6545                close_spill_file = true;
6546            }
6547        }
6548        if abandon_spill_file {
6549            state.abandon_spill_file();
6550        }
6551    }
6552    if close_spill_file {
6553        state.temp_file = None;
6554    }
6555
6556    state.chunks_bytes = state.chunks_bytes.saturating_add(chunk.len());
6557    state.chunks.push_back(chunk);
6558    while state.chunks_bytes > state.max_chunks_bytes && state.chunks.len() > 1 {
6559        if let Some(front) = state.chunks.pop_front() {
6560            state.chunks_bytes = state.chunks_bytes.saturating_sub(front.len());
6561        }
6562    }
6563    Ok(())
6564}
6565
6566const fn line_count_from_newline_count(
6567    total_bytes: usize,
6568    newline_count: usize,
6569    last_byte_was_newline: bool,
6570) -> usize {
6571    if total_bytes == 0 {
6572        0
6573    } else if last_byte_was_newline {
6574        newline_count
6575    } else {
6576        newline_count.saturating_add(1)
6577    }
6578}
6579
6580fn emit_bash_update(
6581    state: &BashOutputState,
6582    on_update: Option<&(dyn Fn(ToolUpdate) + Send + Sync)>,
6583) -> Result<()> {
6584    if let Some(callback) = on_update {
6585        let raw = concat_chunks(&state.chunks);
6586        let full_text = String::from_utf8_lossy(&raw);
6587        let truncation =
6588            truncate_tail(full_text.into_owned(), DEFAULT_MAX_LINES, DEFAULT_MAX_BYTES);
6589
6590        // Build the progress + details JSON using the json! macro instead of
6591        // manual Map::insert calls.  This eliminates 7+ String heap
6592        // allocations per update for the constant field-name keys
6593        // ("elapsedMs", "lineCount", …) that the manual path required.
6594        let elapsed_ms = state.start_time.elapsed().as_millis();
6595        let line_count = line_count_from_newline_count(
6596            state.total_bytes,
6597            state.line_count,
6598            state.last_byte_was_newline,
6599        );
6600        let mut details = serde_json::json!({
6601            "progress": {
6602                "elapsedMs": elapsed_ms,
6603                "lineCount": line_count,
6604                "byteCount": state.total_bytes
6605            }
6606        });
6607        let Some(details_map) = details.as_object_mut() else {
6608            return Ok(());
6609        };
6610
6611        if let Some(timeout) = state.timeout_ms {
6612            if let Some(progress) = details_map
6613                .get_mut("progress")
6614                .and_then(|v| v.as_object_mut())
6615            {
6616                progress.insert("timeoutMs".into(), serde_json::json!(timeout));
6617            }
6618        }
6619        if truncation.truncated {
6620            details_map.insert("truncation".into(), serde_json::to_value(&truncation)?);
6621        }
6622        if let Some(path) = state.temp_file_path.as_ref() {
6623            details_map.insert(
6624                "fullOutputPath".into(),
6625                serde_json::Value::String(path.display().to_string()),
6626            );
6627        }
6628
6629        callback(ToolUpdate {
6630            content: vec![ContentBlock::Text(TextContent::new(truncation.content))],
6631            details: Some(details),
6632        });
6633    }
6634    Ok(())
6635}
6636
6637pub(crate) struct ProcessGuard {
6638    child: Option<std::process::Child>,
6639    cleanup_mode: ProcessCleanupMode,
6640}
6641
6642#[derive(Clone, Copy, Debug, Eq, PartialEq)]
6643pub(crate) enum ProcessCleanupMode {
6644    ChildOnly,
6645    ProcessGroupTree,
6646}
6647
6648impl ProcessGuard {
6649    pub(crate) const fn new(child: std::process::Child, cleanup_mode: ProcessCleanupMode) -> Self {
6650        Self {
6651            child: Some(child),
6652            cleanup_mode,
6653        }
6654    }
6655
6656    pub(crate) fn try_wait_child(&mut self) -> std::io::Result<Option<std::process::ExitStatus>> {
6657        self.child
6658            .as_mut()
6659            .map_or(Ok(None), std::process::Child::try_wait)
6660    }
6661
6662    pub(crate) fn kill(&mut self) -> Option<std::process::ExitStatus> {
6663        if let Some(mut child) = self.child.take() {
6664            cleanup_child(Some(child.id()), self.cleanup_mode);
6665            let _ = child.kill();
6666            std::thread::spawn(move || {
6667                let _ = child.wait();
6668            });
6669            // We cannot return the exit status synchronously without blocking,
6670            // so we return None to indicate the process was forcefully killed.
6671            return None;
6672        }
6673        None
6674    }
6675
6676    pub(crate) fn wait(&mut self) -> std::io::Result<std::process::ExitStatus> {
6677        if let Some(mut child) = self.child.take() {
6678            return child.wait();
6679        }
6680        Err(std::io::Error::other("Already waited"))
6681    }
6682}
6683
6684impl Drop for ProcessGuard {
6685    fn drop(&mut self) {
6686        if let Some(mut child) = self.child.take() {
6687            match child.try_wait() {
6688                Ok(None) => {}
6689                Ok(Some(_)) | Err(_) => return,
6690            }
6691            let cleanup_mode = self.cleanup_mode;
6692            std::thread::spawn(move || {
6693                cleanup_child(Some(child.id()), cleanup_mode);
6694                let _ = child.kill();
6695                let _ = child.wait();
6696            });
6697        }
6698    }
6699}
6700
6701fn cleanup_child(pid: Option<u32>, cleanup_mode: ProcessCleanupMode) {
6702    if cleanup_mode == ProcessCleanupMode::ProcessGroupTree {
6703        kill_process_group_tree(pid);
6704    }
6705}
6706
6707pub fn kill_process_tree(pid: Option<u32>) {
6708    kill_process_tree_with(pid, sysinfo::Signal::Kill, false);
6709}
6710
6711pub(crate) fn kill_process_group_tree(pid: Option<u32>) {
6712    kill_process_tree_with(pid, sysinfo::Signal::Kill, true);
6713}
6714
6715fn terminate_process_group_tree(pid: Option<u32>) {
6716    kill_process_tree_with(pid, sysinfo::Signal::Term, true);
6717}
6718
6719fn kill_process_tree_with(pid: Option<u32>, signal: sysinfo::Signal, include_process_group: bool) {
6720    let Some(pid) = pid else {
6721        return;
6722    };
6723
6724    let root = sysinfo::Pid::from_u32(pid);
6725
6726    let mut sys = sysinfo::System::new();
6727    sys.refresh_processes(sysinfo::ProcessesToUpdate::All, true);
6728
6729    let mut children_map: HashMap<sysinfo::Pid, Vec<sysinfo::Pid>> = HashMap::new();
6730    for (p, proc_) in sys.processes() {
6731        if let Some(parent) = proc_.parent() {
6732            children_map.entry(parent).or_default().push(*p);
6733        }
6734    }
6735
6736    let mut to_kill = Vec::new();
6737    let mut visited = std::collections::HashSet::new();
6738    collect_process_tree(root, &children_map, &mut to_kill, &mut visited);
6739
6740    if include_process_group {
6741        // Some subprocess surfaces isolate the child into its own process group.
6742        // When they do, killing the group first catches background children even
6743        // if they have already been reparented away from the original root PID.
6744        #[cfg(unix)]
6745        {
6746            let sig_num = match signal {
6747                sysinfo::Signal::Kill => "9",
6748                _ => "15",
6749            };
6750            let _ = Command::new("kill")
6751                .arg(format!("-{sig_num}"))
6752                .arg("--")
6753                .arg(format!("-{pid}"))
6754                .stdin(Stdio::null())
6755                .stdout(Stdio::null())
6756                .stderr(Stdio::null())
6757                .status();
6758        }
6759    }
6760
6761    // Kill children first.
6762    for pid in to_kill.into_iter().rev() {
6763        if let Some(proc_) = sys.process(pid) {
6764            match proc_.kill_with(signal) {
6765                Some(true) => {}
6766                Some(false) | None => {
6767                    let _ = proc_.kill();
6768                }
6769            }
6770        }
6771    }
6772}
6773
6774fn collect_process_tree(
6775    pid: sysinfo::Pid,
6776    children_map: &HashMap<sysinfo::Pid, Vec<sysinfo::Pid>>,
6777    out: &mut Vec<sysinfo::Pid>,
6778    visited: &mut std::collections::HashSet<sysinfo::Pid>,
6779) {
6780    if !visited.insert(pid) {
6781        return;
6782    }
6783    out.push(pid);
6784    if let Some(children) = children_map.get(&pid) {
6785        for child in children {
6786            collect_process_tree(*child, children_map, out, visited);
6787        }
6788    }
6789}
6790
6791/// Build a child command whose Unix process image starts with SIGPIPE restored
6792/// to the platform default, without using `Command::pre_exec`.
6793///
6794/// Rust binaries ignore SIGPIPE by default, and POSIX inherits that disposition
6795/// across `exec(2)`. The tiny `/bin/sh` trampoline resets PIPE and then `exec`s
6796/// the requested program, preserving argv, cwd, stdio, and the process id that
6797/// later becomes the isolated process-group leader.
6798pub(crate) const SIGPIPE_TRAMPOLINE_EXEC_FAILURE_PREFIX: &str = "pi-sigpipe-reset: exec failed:";
6799
6800pub(crate) fn command_with_default_sigpipe(program: impl AsRef<OsStr>) -> std::io::Result<Command> {
6801    command_with_default_sigpipe_for_cwd(program.as_ref(), None)
6802}
6803
6804/// Variant of [`command_with_default_sigpipe`] for commands that will run with
6805/// `current_dir(cwd)`. This preserves relative `./program` lookup semantics.
6806pub(crate) fn command_with_default_sigpipe_in_dir(
6807    program: impl AsRef<OsStr>,
6808    cwd: &Path,
6809) -> std::io::Result<Command> {
6810    command_with_default_sigpipe_for_cwd(program.as_ref(), Some(cwd))
6811}
6812
6813#[cfg(unix)]
6814fn command_with_default_sigpipe_for_cwd(
6815    program: &OsStr,
6816    cwd: Option<&Path>,
6817) -> std::io::Result<Command> {
6818    let program = resolve_executable_for_shell_trampoline(program, cwd)?;
6819    let mut command = Command::new("/bin/sh");
6820    command
6821        .arg("-c")
6822        .arg(
6823            "trap - PIPE\n\
6824             exec \"$@\"\n\
6825             status=$?\n\
6826             printf 'pi-sigpipe-reset: exec failed: %s\\n' \"$1\" >&2\n\
6827             exit \"$status\"",
6828        )
6829        .arg("pi-sigpipe-reset")
6830        .arg(program);
6831    Ok(command)
6832}
6833
6834#[cfg(not(unix))]
6835fn command_with_default_sigpipe_for_cwd(
6836    program: &OsStr,
6837    _cwd: Option<&Path>,
6838) -> std::io::Result<Command> {
6839    let command = Command::new(program); // ubs:ignore policy-checked non-Unix command runner
6840    Ok(command)
6841}
6842
6843#[cfg(unix)]
6844fn resolve_executable_for_shell_trampoline(
6845    program: &OsStr,
6846    cwd: Option<&Path>,
6847) -> std::io::Result<OsString> {
6848    use std::os::unix::ffi::OsStrExt as _;
6849    use std::os::unix::fs::PermissionsExt as _;
6850
6851    fn executable_candidate(path: &Path) -> std::io::Result<bool> {
6852        let metadata = std::fs::metadata(path)?;
6853        Ok(metadata.is_file() && metadata.permissions().mode() & 0o111 != 0)
6854    }
6855
6856    fn absolutize_candidate(path: &Path, cwd: Option<&Path>) -> std::io::Result<PathBuf> {
6857        if path.is_absolute() {
6858            return Ok(path.to_path_buf());
6859        }
6860
6861        let base = std::env::current_dir()?;
6862        Ok(cwd.map_or_else(|| base.join(path), |cwd| base.join(cwd).join(path)))
6863    }
6864
6865    if program.as_bytes().contains(&b'/') {
6866        let path = Path::new(program);
6867        let candidate = absolutize_candidate(path, cwd)?;
6868        if executable_candidate(&candidate)? {
6869            return Ok(candidate.into_os_string());
6870        }
6871        return Err(std::io::Error::new(
6872            std::io::ErrorKind::PermissionDenied,
6873            format!("not an executable file: {}", candidate.display()),
6874        ));
6875    }
6876
6877    let mut permission_denied = false;
6878    let paths = std::env::var_os("PATH").unwrap_or_else(|| OsString::from("/bin:/usr/bin"));
6879    for dir in std::env::split_paths(&paths) {
6880        let candidate = absolutize_candidate(&dir.join(program), cwd)?;
6881        match executable_candidate(&candidate) {
6882            Ok(true) => return Ok(candidate.into_os_string()),
6883            Ok(false) => permission_denied = true,
6884            Err(err) if matches!(err.kind(), std::io::ErrorKind::NotFound) => {}
6885            Err(err) if matches!(err.kind(), std::io::ErrorKind::PermissionDenied) => {
6886                permission_denied = true;
6887            }
6888            Err(_) => {}
6889        }
6890    }
6891
6892    if permission_denied {
6893        Err(std::io::Error::new(
6894            std::io::ErrorKind::PermissionDenied,
6895            format!("command is not executable: {}", program.to_string_lossy()),
6896        ))
6897    } else {
6898        Err(std::io::Error::new(
6899            std::io::ErrorKind::NotFound,
6900            format!("command not found: {}", program.to_string_lossy()),
6901        ))
6902    }
6903}
6904
6905/// Detach a child process from pi's controlling terminal.
6906pub(crate) fn isolate_command_process_group(command: &mut Command) {
6907    #[cfg(unix)]
6908    {
6909        use std::os::unix::process::CommandExt as _;
6910        command.process_group(0);
6911    }
6912
6913    #[cfg(not(unix))]
6914    {
6915        let _ = command;
6916    }
6917}
6918
6919fn format_grep_path(file_path: &Path, cwd: &Path) -> String {
6920    if let Ok(rel) = file_path.strip_prefix(cwd) {
6921        let rel_str = rel.display().to_string().replace('\\', "/");
6922        if !rel_str.is_empty() {
6923            return rel_str;
6924        }
6925    }
6926
6927    let canonical_file = safe_canonicalize(file_path);
6928    let canonical_cwd = safe_canonicalize(cwd);
6929    if let Ok(rel) = canonical_file.strip_prefix(&canonical_cwd) {
6930        let rel_str = rel.display().to_string().replace('\\', "/");
6931        if !rel_str.is_empty() {
6932            return rel_str;
6933        }
6934    }
6935
6936    file_path.display().to_string().replace('\\', "/")
6937}
6938
6939async fn get_file_lines_async<'a>(
6940    path: &Path,
6941    cache: &'a mut HashMap<PathBuf, Vec<String>>,
6942) -> &'a [String] {
6943    if !cache.contains_key(path) {
6944        // Prevent OOM on huge files and hangs on pipes
6945        if let Ok(meta) = asupersync::fs::metadata(path).await {
6946            if !meta.is_file() || meta.len() > 10 * 1024 * 1024 {
6947                cache.insert(path.to_path_buf(), Vec::new());
6948                return &[];
6949            }
6950        } else {
6951            cache.insert(path.to_path_buf(), Vec::new());
6952            return &[];
6953        }
6954
6955        // Match Node's `readFileSync(..., "utf-8")` behavior: decode lossily rather than failing.
6956        let bytes = match asupersync::fs::read(path).await {
6957            Ok(bytes) => bytes,
6958            Err(err) => {
6959                tracing::debug!("Failed to read grep file {}: {err}", path.display());
6960                cache.insert(path.to_path_buf(), Vec::new());
6961                return &[];
6962            }
6963        };
6964        let content = String::from_utf8_lossy(&bytes);
6965        let mut lines = Vec::new();
6966        for line in content.split('\n') {
6967            let trimmed = line.strip_suffix('\r').unwrap_or(line);
6968            for piece in trimmed.split('\r') {
6969                lines.push(piece.to_string());
6970            }
6971        }
6972        if content.ends_with('\n') && lines.last().is_some_and(std::string::String::is_empty) {
6973            lines.pop();
6974        }
6975        cache.insert(path.to_path_buf(), lines);
6976    }
6977    if let Some(lines) = cache.get(path) {
6978        lines.as_slice()
6979    } else {
6980        &[]
6981    }
6982}
6983
6984fn find_fd_binary() -> Option<&'static str> {
6985    static BINARY: OnceLock<Option<&'static str>> = OnceLock::new();
6986    *BINARY.get_or_init(|| {
6987        if std::process::Command::new("fd")
6988            .arg("--version")
6989            .stdout(Stdio::null())
6990            .stderr(Stdio::null())
6991            .status()
6992            .is_ok()
6993        {
6994            return Some("fd");
6995        }
6996        if std::process::Command::new("fdfind")
6997            .arg("--version")
6998            .stdout(Stdio::null())
6999            .stderr(Stdio::null())
7000            .status()
7001            .is_ok()
7002        {
7003            return Some("fdfind");
7004        }
7005        None
7006    })
7007}
7008
7009fn find_rg_binary() -> Option<&'static str> {
7010    static BINARY: OnceLock<Option<&'static str>> = OnceLock::new();
7011    *BINARY.get_or_init(|| {
7012        if std::process::Command::new("rg")
7013            .arg("--version")
7014            .stdout(Stdio::null())
7015            .stderr(Stdio::null())
7016            .status()
7017            .is_ok()
7018        {
7019            return Some("rg");
7020        }
7021        if std::process::Command::new("ripgrep")
7022            .arg("--version")
7023            .stdout(Stdio::null())
7024            .stderr(Stdio::null())
7025            .status()
7026            .is_ok()
7027        {
7028            return Some("ripgrep");
7029        }
7030        None
7031    })
7032}
7033
7034// ============================================================================
7035// Hashline Edit Tool
7036// ============================================================================
7037
7038/// Custom nibble-encoding alphabet used for hashline tags.
7039const NIBBLE_STR: &[u8; 16] = b"ZPMQVRWSNKTXJBYH";
7040
7041/// Pre-computed 256-entry lookup table mapping each byte value to its
7042/// 2-character NIBBLE_STR encoding.
7043static HASHLINE_DICT: OnceLock<[[u8; 2]; 256]> = OnceLock::new();
7044
7045fn hashline_dict() -> &'static [[u8; 2]; 256] {
7046    HASHLINE_DICT.get_or_init(|| {
7047        let mut dict = [[0u8; 2]; 256];
7048        for i in 0..256 {
7049            dict[i] = [NIBBLE_STR[i & 0x0F], NIBBLE_STR[(i >> 4) & 0x0F]];
7050        }
7051        dict
7052    })
7053}
7054
7055/// Compute a 2-character hash tag for a line at the given 0-indexed position.
7056///
7057/// The algorithm:
7058/// 1. Strip trailing `\r`
7059/// 2. Remove all whitespace to get a "significant" string
7060/// 3. If the significant string contains at least one letter or digit, seed = 0;
7061///    otherwise seed = line index (to disambiguate punctuation-only or blank lines)
7062/// 4. Compute `xxh32(significant_bytes, seed) & 0xFF`
7063/// 5. Encode the low byte as 2 nibble chars from `NIBBLE_STR`
7064fn compute_line_hash(line_idx: usize, line: &str) -> [u8; 2] {
7065    let line = line.strip_suffix('\r').unwrap_or(line);
7066    // Remove all whitespace
7067    let significant: String = line.chars().filter(|c| !c.is_whitespace()).collect();
7068    let has_alnum = significant.chars().any(char::is_alphanumeric);
7069    let seed = if has_alnum {
7070        0
7071    } else {
7072        #[allow(clippy::cast_possible_truncation)]
7073        let s = line_idx as u32;
7074        s
7075    };
7076    let hash = xxhash_rust::xxh32::xxh32(significant.as_bytes(), seed);
7077    let byte = (hash & 0xFF) as usize;
7078    hashline_dict()[byte]
7079}
7080
7081/// Format a hashline tag as `"N#AB"` where N is the 1-indexed line number.
7082fn format_hashline_tag(line_idx: usize, line: &str) -> String {
7083    let h = compute_line_hash(line_idx, line);
7084    format!("{}#{}{}", line_idx + 1, h[0] as char, h[1] as char)
7085}
7086
7087/// Compute a hashline tag, reapplying a stripped BOM for the first line if needed.
7088fn format_hashline_tag_with_bom(line_idx: usize, line: &str, had_bom: bool) -> String {
7089    let h = compute_line_hash_with_bom(line_idx, line, had_bom);
7090    format!("{}#{}{}", line_idx + 1, h[0] as char, h[1] as char)
7091}
7092
7093fn compute_line_hash_with_bom(line_idx: usize, line: &str, had_bom: bool) -> [u8; 2] {
7094    if had_bom && line_idx == 0 {
7095        let mut with_bom = String::with_capacity(line.len().saturating_add(1));
7096        with_bom.push('\u{FEFF}');
7097        with_bom.push_str(line);
7098        compute_line_hash(line_idx, &with_bom)
7099    } else {
7100        compute_line_hash(line_idx, line)
7101    }
7102}
7103
7104/// Regex for parsing hashline references like `5#KJ` or ` > +  5 # KJ `.
7105/// Tolerates leading whitespace, diff markers (`>`, `+`, `-`), and spaces around `#`.
7106static HASHLINE_TAG_RE: OnceLock<regex::Regex> = OnceLock::new();
7107
7108fn hashline_tag_regex() -> &'static regex::Regex {
7109    HASHLINE_TAG_RE.get_or_init(|| {
7110        regex::Regex::new(r"^[\s>+\-]*(\d+)\s*#\s*([ZPMQVRWSNKTXJBYH]{2})")
7111            .expect("valid hashline regex")
7112    })
7113}
7114
7115/// Parse a hashline tag reference string into (1-indexed line number, 2-byte hash).
7116fn parse_hashline_tag(ref_str: &str) -> std::result::Result<(usize, [u8; 2]), String> {
7117    let re = hashline_tag_regex();
7118    let caps = re
7119        .captures(ref_str)
7120        .ok_or_else(|| format!("Invalid hashline reference: {ref_str:?}"))?;
7121    let line_num: usize = caps[1]
7122        .parse()
7123        .map_err(|e| format!("Invalid line number in {ref_str:?}: {e}"))?;
7124    if line_num == 0 {
7125        return Err(format!("Line number must be >= 1, got 0 in {ref_str:?}"));
7126    }
7127    let hash_bytes = caps[2].as_bytes();
7128    Ok((line_num, [hash_bytes[0], hash_bytes[1]]))
7129}
7130
7131/// Strip hashline tag prefixes that models sometimes copy into replacement content.
7132/// Matches patterns like `5#KJ:content` and returns just `content`.
7133static HASHLINE_PREFIX_RE: OnceLock<regex::Regex> = OnceLock::new();
7134
7135fn strip_hashline_prefix(line: &str) -> &str {
7136    let re = HASHLINE_PREFIX_RE.get_or_init(|| {
7137        regex::Regex::new(r"^[\s>+\-]*\d+\s*#\s*[ZPMQVRWSNKTXJBYH]{2}\s*:")
7138            .expect("valid hashline prefix regex")
7139    });
7140    re.find(line).map_or(line, |m| &line[m.end()..])
7141}
7142
7143/// Input parameters for the hashline edit tool.
7144#[derive(Debug, Deserialize)]
7145#[serde(rename_all = "camelCase")]
7146struct HashlineEditInput {
7147    path: String,
7148    edits: Vec<HashlineOp>,
7149}
7150
7151/// A single hashline edit operation.
7152#[derive(Debug, Clone, Deserialize)]
7153#[serde(rename_all = "camelCase")]
7154struct HashlineOp {
7155    /// Operation type: "replace", "prepend", or "append"
7156    op: String,
7157    /// Start anchor in "LINE#HASH" format (optional for BOF prepend / EOF append)
7158    pos: Option<String>,
7159    /// End anchor for range replace (inclusive)
7160    end: Option<String>,
7161    /// Replacement / insertion lines
7162    lines: Option<serde_json::Value>,
7163}
7164
7165impl HashlineOp {
7166    /// Extract lines from the `lines` field, handling string, array, and null variants.
7167    fn get_lines(&self) -> Vec<String> {
7168        match &self.lines {
7169            None | Some(serde_json::Value::Null) => vec![],
7170            Some(serde_json::Value::String(s)) => {
7171                normalize_to_lf(s).split('\n').map(String::from).collect()
7172            }
7173            Some(serde_json::Value::Array(arr)) => arr
7174                .iter()
7175                .map(|v| match v {
7176                    serde_json::Value::String(s) => normalize_to_lf(s),
7177                    other => normalize_to_lf(&other.to_string()),
7178                })
7179                .collect(),
7180            Some(other) => vec![normalize_to_lf(&other.to_string())],
7181        }
7182    }
7183}
7184
7185/// A resolved hashline edit operation ready for application.
7186struct ResolvedEdit<'a> {
7187    op: &'a str,
7188    /// 0-indexed start line (or 0 for BOF, `file_lines.len()` for EOF)
7189    start: usize,
7190    /// 0-indexed end line (inclusive, same as start for single-line ops)
7191    end: usize,
7192    lines: Vec<String>,
7193}
7194
7195pub struct HashlineEditTool {
7196    cwd: PathBuf,
7197}
7198
7199impl HashlineEditTool {
7200    pub fn new(cwd: &Path) -> Self {
7201        Self {
7202            cwd: cwd.to_path_buf(),
7203        }
7204    }
7205}
7206
7207/// Validate a hashline tag reference against actual file lines.
7208/// Returns `Ok(0-indexed line)` or `Err(message)` with context.
7209fn validate_line_ref(
7210    ref_str: &str,
7211    file_lines: &[&str],
7212    had_bom: bool,
7213) -> std::result::Result<usize, String> {
7214    let (line_num, expected_hash) = parse_hashline_tag(ref_str)?;
7215    let line_idx = line_num - 1;
7216    if line_idx >= file_lines.len() {
7217        return Err(format!(
7218            "Line {line_num} out of range (file has {} lines)",
7219            file_lines.len()
7220        ));
7221    }
7222    let actual_hash = compute_line_hash_with_bom(line_idx, file_lines[line_idx], had_bom);
7223    if actual_hash != expected_hash {
7224        let tag = format_hashline_tag_with_bom(line_idx, file_lines[line_idx], had_bom);
7225        return Err(format!(
7226            "Hash mismatch at line {line_num}: expected {}#{}{}, actual is {tag}",
7227            line_num, expected_hash[0] as char, expected_hash[1] as char,
7228        ));
7229    }
7230    Ok(line_idx)
7231}
7232
7233/// Build a context snippet around a mismatched line for error reporting.
7234fn mismatch_context(file_lines: &[&str], line_idx: usize, context: usize, had_bom: bool) -> String {
7235    let start = line_idx.saturating_sub(context);
7236    let end = (line_idx + context + 1).min(file_lines.len());
7237    let mut out = String::new();
7238    for (i, &file_line) in file_lines.iter().enumerate().take(end).skip(start) {
7239        let tag = format_hashline_tag_with_bom(i, file_line, had_bom);
7240        if i == line_idx {
7241            let _ = writeln!(out, ">>> {tag}:{file_line}");
7242        } else {
7243            let _ = writeln!(out, "    {tag}:{file_line}");
7244        }
7245    }
7246    out
7247}
7248
7249/// Collect all hash mismatches from a set of edits, returning a combined error message.
7250fn collect_mismatches(
7251    edits: &[HashlineOp],
7252    file_lines: &[&str],
7253    had_bom: bool,
7254) -> std::result::Result<(), String> {
7255    let mut errors = Vec::new();
7256    for edit in edits {
7257        if let Some(ref pos) = edit.pos {
7258            if let Err(e) = validate_line_ref(pos, file_lines, had_bom) {
7259                // Find the line index for context
7260                if let Ok((line_num, _)) = parse_hashline_tag(pos) {
7261                    let idx = (line_num - 1).min(file_lines.len().saturating_sub(1));
7262                    errors.push(format!(
7263                        "{e}\n{}",
7264                        mismatch_context(file_lines, idx, 2, had_bom)
7265                    ));
7266                } else {
7267                    errors.push(e);
7268                }
7269            }
7270        }
7271        if let Some(ref end) = edit.end {
7272            if let Err(e) = validate_line_ref(end, file_lines, had_bom) {
7273                if let Ok((line_num, _)) = parse_hashline_tag(end) {
7274                    let idx = (line_num - 1).min(file_lines.len().saturating_sub(1));
7275                    errors.push(format!(
7276                        "{e}\n{}",
7277                        mismatch_context(file_lines, idx, 2, had_bom)
7278                    ));
7279                } else {
7280                    errors.push(e);
7281                }
7282            }
7283        }
7284    }
7285    if errors.is_empty() {
7286        Ok(())
7287    } else {
7288        Err(errors.join("\n"))
7289    }
7290}
7291
7292/// Normalized representation of an edit for deduplication.
7293#[derive(Debug, Clone, PartialEq, Eq, Hash)]
7294struct NormalizedEdit {
7295    op: String,
7296    pos_line: Option<usize>,
7297    end_line: Option<usize>,
7298    lines: Vec<String>,
7299}
7300
7301/// Sort precedence for overlapping edits at the same line.
7302fn op_precedence(op: &str) -> u8 {
7303    match op {
7304        "replace" => 0,
7305        "append" => 1,
7306        "prepend" => 2,
7307        _ => 3,
7308    }
7309}
7310
7311#[async_trait]
7312#[allow(clippy::unnecessary_literal_bound)]
7313impl Tool for HashlineEditTool {
7314    fn name(&self) -> &str {
7315        "hashline_edit"
7316    }
7317    fn label(&self) -> &str {
7318        "hashline edit"
7319    }
7320    fn description(&self) -> &str {
7321        "Apply precise file edits using LINE#HASH tags from a prior read with hashline=true. \
7322         Each edit specifies an op (replace/prepend/append), a pos anchor (\"N#AB\"), an optional \
7323         end anchor for range replace, and replacement lines. Edits are validated against current \
7324         file hashes and applied bottom-up to avoid index invalidation."
7325    }
7326
7327    fn parameters(&self) -> serde_json::Value {
7328        serde_json::json!({
7329            "type": "object",
7330            "properties": {
7331                "path": {
7332                    "type": "string",
7333                    "description": "Path to the file to edit (relative or absolute)"
7334                },
7335                "edits": {
7336                    "type": "array",
7337                    "description": "Array of edit operations to apply",
7338                    "items": {
7339                        "type": "object",
7340                        "properties": {
7341                            "op": {
7342                                "type": "string",
7343                                "enum": ["replace", "prepend", "append"],
7344                                "description": "Operation type"
7345                            },
7346                            "pos": {
7347                                "type": "string",
7348                                "description": "Anchor line reference in LINE#HASH format (e.g. \"5#KJ\")"
7349                            },
7350                            "end": {
7351                                "type": "string",
7352                                "description": "End anchor for range replace (inclusive)"
7353                            },
7354                            "lines": {
7355                                "description": "Replacement/insertion content as array of strings, single string, or null for deletion",
7356                                "oneOf": [
7357                                    { "type": "array", "items": { "type": "string" } },
7358                                    { "type": "string" },
7359                                    { "type": "null" }
7360                                ]
7361                            }
7362                        },
7363                        "required": ["op"]
7364                    }
7365                }
7366            },
7367            "required": ["path", "edits"]
7368        })
7369    }
7370
7371    #[allow(clippy::too_many_lines)]
7372    async fn execute(
7373        &self,
7374        _tool_call_id: &str,
7375        input: serde_json::Value,
7376        _on_update: Option<Box<dyn Fn(ToolUpdate) + Send + Sync>>,
7377    ) -> Result<ToolOutput> {
7378        let input: HashlineEditInput = serde_json::from_value(input)
7379            .map_err(|e| Error::tool("hashline_edit", format!("Invalid input: {e}")))?;
7380
7381        if input.edits.is_empty() {
7382            return Err(Error::tool("hashline_edit", "No edits provided"));
7383        }
7384
7385        // Resolve file path and enforce scope before touching the filesystem.
7386        let resolved = resolve_read_path(&input.path, &self.cwd);
7387        let absolute_path = enforce_cwd_scope(&resolved, &self.cwd, "hashline_edit")?;
7388
7389        // Check file size
7390        let metadata = asupersync::fs::metadata(&absolute_path)
7391            .await
7392            .map_err(|err| {
7393                let message = match err.kind() {
7394                    std::io::ErrorKind::NotFound => format!("File not found: {}", input.path),
7395                    std::io::ErrorKind::PermissionDenied => {
7396                        format!("Permission denied: {}", input.path)
7397                    }
7398                    _ => format!("Cannot read file metadata: {err}"),
7399                };
7400                Error::tool("hashline_edit", message)
7401            })?;
7402        if !metadata.is_file() {
7403            return Err(Error::tool(
7404                "hashline_edit",
7405                format!("Path {} is not a regular file", absolute_path.display()),
7406            ));
7407        }
7408        if metadata.len() > READ_TOOL_MAX_BYTES {
7409            return Err(Error::tool(
7410                "hashline_edit",
7411                format!(
7412                    "File too large ({} bytes, max {} bytes)",
7413                    metadata.len(),
7414                    READ_TOOL_MAX_BYTES
7415                ),
7416            ));
7417        }
7418
7419        // Read file content
7420        let file = asupersync::fs::File::open(&absolute_path)
7421            .await
7422            .map_err(|e| Error::tool("hashline_edit", format!("Cannot open file: {e}")))?;
7423        let mut raw = Vec::new();
7424        let mut limiter = file.take(READ_TOOL_MAX_BYTES.saturating_add(1));
7425        limiter
7426            .read_to_end(&mut raw)
7427            .await
7428            .map_err(|e| Error::tool("hashline_edit", format!("Cannot read file: {e}")))?;
7429
7430        if raw.len() as u64 > READ_TOOL_MAX_BYTES {
7431            return Err(Error::tool(
7432                "hashline_edit",
7433                format!("File too large (> {READ_TOOL_MAX_BYTES} bytes)"),
7434            ));
7435        }
7436
7437        let raw_content = String::from_utf8(raw).map_err(|_| {
7438            Error::tool(
7439                "hashline_edit",
7440                "File contains invalid UTF-8 characters and cannot be safely edited as text."
7441                    .to_string(),
7442            )
7443        })?;
7444
7445        let (content_no_bom, had_bom) = strip_bom(&raw_content);
7446        let original_ending = detect_line_ending(content_no_bom);
7447        let normalized = normalize_to_lf(content_no_bom);
7448        let file_lines: Vec<&str> = normalized.split('\n').collect();
7449
7450        // Validate all hash references before making any changes
7451        if let Err(e) = collect_mismatches(&input.edits, &file_lines, had_bom) {
7452            return Err(Error::tool(
7453                "hashline_edit",
7454                format!("Hash validation failed — re-read the file to get current tags.\n\n{e}"),
7455            ));
7456        }
7457
7458        // Deduplicate edits
7459        let mut seen = std::collections::HashSet::new();
7460        let mut deduped_edits: Vec<&HashlineOp> = Vec::new();
7461        for edit in &input.edits {
7462            let pos_line = edit
7463                .pos
7464                .as_ref()
7465                .and_then(|p| parse_hashline_tag(p).ok())
7466                .map(|(n, _)| n);
7467            let end_line = edit
7468                .end
7469                .as_ref()
7470                .and_then(|e| parse_hashline_tag(e).ok())
7471                .map(|(n, _)| n);
7472            let key = NormalizedEdit {
7473                op: edit.op.clone(),
7474                pos_line,
7475                end_line,
7476                lines: edit.get_lines(),
7477            };
7478            if seen.insert(key) {
7479                deduped_edits.push(edit);
7480            }
7481        }
7482
7483        // Resolve line indices and sort bottom-up
7484        let mut resolved: Vec<ResolvedEdit<'_>> = Vec::new();
7485        for edit in &deduped_edits {
7486            let replacement_lines: Vec<String> = edit
7487                .get_lines()
7488                .into_iter()
7489                .map(|l| strip_hashline_prefix(&l).to_string())
7490                .collect();
7491
7492            match edit.op.as_str() {
7493                "replace" => {
7494                    let start_idx = match &edit.pos {
7495                        Some(pos) => validate_line_ref(pos, &file_lines, had_bom)
7496                            .map_err(|e| Error::tool("hashline_edit", e))?,
7497                        None => {
7498                            return Err(Error::tool(
7499                                "hashline_edit",
7500                                "replace operation requires a pos anchor",
7501                            ));
7502                        }
7503                    };
7504                    let end_idx = match &edit.end {
7505                        Some(end) => validate_line_ref(end, &file_lines, had_bom)
7506                            .map_err(|e| Error::tool("hashline_edit", e))?,
7507                        None => start_idx,
7508                    };
7509                    if end_idx < start_idx {
7510                        return Err(Error::tool(
7511                            "hashline_edit",
7512                            format!(
7513                                "End anchor (line {}) is before start anchor (line {})",
7514                                end_idx + 1,
7515                                start_idx + 1
7516                            ),
7517                        ));
7518                    }
7519                    resolved.push(ResolvedEdit {
7520                        op: "replace",
7521                        start: start_idx,
7522                        end: end_idx,
7523                        lines: replacement_lines,
7524                    });
7525                }
7526                "prepend" => {
7527                    let idx = match &edit.pos {
7528                        Some(pos) => validate_line_ref(pos, &file_lines, had_bom)
7529                            .map_err(|e| Error::tool("hashline_edit", e))?,
7530                        None => 0, // BOF
7531                    };
7532                    let end_idx = if file_lines == [""] && edit.pos.is_none() {
7533                        0 // replace the empty line
7534                    } else {
7535                        idx
7536                    };
7537                    resolved.push(ResolvedEdit {
7538                        op: if file_lines == [""] && edit.pos.is_none() {
7539                            "replace"
7540                        } else {
7541                            "prepend"
7542                        },
7543                        start: idx,
7544                        end: end_idx,
7545                        lines: replacement_lines,
7546                    });
7547                }
7548                "append" => {
7549                    let idx = match &edit.pos {
7550                        Some(pos) => validate_line_ref(pos, &file_lines, had_bom)
7551                            .map_err(|e| Error::tool("hashline_edit", e))?,
7552                        None => {
7553                            if file_lines.len() > 1 && file_lines.last() == Some(&"") {
7554                                file_lines.len() - 2
7555                            } else {
7556                                file_lines.len().saturating_sub(1)
7557                            }
7558                        }
7559                    };
7560                    let end_idx = if file_lines == [""] && edit.pos.is_none() {
7561                        0 // replace the empty line
7562                    } else {
7563                        idx
7564                    };
7565                    resolved.push(ResolvedEdit {
7566                        op: if file_lines == [""] && edit.pos.is_none() {
7567                            "replace"
7568                        } else {
7569                            "append"
7570                        },
7571                        start: idx,
7572                        end: end_idx,
7573                        lines: replacement_lines,
7574                    });
7575                }
7576                other => {
7577                    return Err(Error::tool(
7578                        "hashline_edit",
7579                        format!("Unknown op: {other:?}. Must be replace, prepend, or append."),
7580                    ));
7581                }
7582            }
7583        }
7584
7585        // Sort bottom-up: highest line first, then by precedence (replace < append < prepend)
7586        resolved.sort_by(|a, b| {
7587            b.start
7588                .cmp(&a.start)
7589                .then_with(|| op_precedence(a.op).cmp(&op_precedence(b.op)))
7590        });
7591
7592        // Detect overlapping edit ranges (undefined behavior if applied bottom-up)
7593        for i in 0..resolved.len() {
7594            for j in (i + 1)..resolved.len() {
7595                let a = &resolved[i];
7596                let b = &resolved[j];
7597                if a.start <= b.end && b.start <= a.end {
7598                    return Err(Error::tool(
7599                        "hashline_edit",
7600                        format!(
7601                            "Overlapping edits detected: {} at line {}-{} and {} at line {}-{}. \
7602                             Please combine overlapping edits into a single operation.",
7603                            a.op,
7604                            a.start + 1,
7605                            a.end + 1,
7606                            b.op,
7607                            b.start + 1,
7608                            b.end + 1
7609                        ),
7610                    ));
7611                }
7612            }
7613        }
7614
7615        // Apply splices bottom-up on a mutable Vec of lines
7616        let mut lines: Vec<String> = file_lines.iter().map(|s| (*s).to_string()).collect();
7617        let mut any_change = false;
7618
7619        for edit in &resolved {
7620            match edit.op {
7621                "replace" => {
7622                    // Check if it's a no-op
7623                    let existing: Vec<&str> = lines[edit.start..=edit.end]
7624                        .iter()
7625                        .map(String::as_str)
7626                        .collect();
7627                    if existing.eq(&edit.lines.iter().map(String::as_str).collect::<Vec<&str>>()) {
7628                        continue; // no-op
7629                    }
7630                    // Splice: remove old range, insert new lines
7631                    lines.splice(edit.start..=edit.end, edit.lines.iter().cloned());
7632                    any_change = true;
7633                }
7634                "prepend" => {
7635                    // Insert before the target line
7636                    lines.splice(edit.start..edit.start, edit.lines.iter().cloned());
7637                    if !edit.lines.is_empty() {
7638                        any_change = true;
7639                    }
7640                }
7641                "append" => {
7642                    // Insert after the target line
7643                    let insert_at = edit.start + 1;
7644                    lines.splice(insert_at..insert_at, edit.lines.iter().cloned());
7645                    if !edit.lines.is_empty() {
7646                        any_change = true;
7647                    }
7648                }
7649                _ => {} // unreachable due to earlier validation
7650            }
7651        }
7652
7653        if !any_change {
7654            return Err(Error::tool(
7655                "hashline_edit",
7656                format!(
7657                    "No changes made to {}. All edits were no-ops (replacement identical to existing content).",
7658                    input.path
7659                ),
7660            ));
7661        }
7662
7663        // Reconstruct content
7664        let new_normalized = lines.join("\n");
7665        let new_content = restore_line_endings(&new_normalized, original_ending);
7666        let mut final_content = new_content;
7667        if had_bom {
7668            final_content = format!("\u{FEFF}{final_content}");
7669        }
7670
7671        // Atomic write (same pattern as EditTool)
7672        let absolute_path_clone = absolute_path.clone();
7673        let final_content_bytes = final_content.into_bytes();
7674        asupersync::runtime::spawn_blocking_io(move || {
7675            let original_perms = std::fs::metadata(&absolute_path_clone)
7676                .ok()
7677                .map(|m| m.permissions());
7678            let parent = absolute_path_clone
7679                .parent()
7680                .unwrap_or_else(|| Path::new("."));
7681            let mut temp_file = tempfile::NamedTempFile::new_in(parent)?;
7682
7683            temp_file.as_file_mut().write_all(&final_content_bytes)?;
7684            temp_file.as_file_mut().sync_all()?;
7685
7686            if let Some(perms) = original_perms {
7687                let _ = temp_file.as_file().set_permissions(perms);
7688            } else {
7689                #[cfg(unix)]
7690                {
7691                    use std::os::unix::fs::PermissionsExt;
7692                    let _ = temp_file
7693                        .as_file()
7694                        .set_permissions(std::fs::Permissions::from_mode(0o644));
7695                }
7696            }
7697
7698            temp_file
7699                .persist(&absolute_path_clone)
7700                .map_err(|e| e.error)?;
7701            Ok(())
7702        })
7703        .await
7704        .map_err(|e| Error::tool("hashline_edit", format!("Failed to write file: {e}")))?;
7705
7706        // Generate diff
7707        let (diff, first_changed_line) = generate_diff_string(&normalized, &new_normalized);
7708        let mut details = serde_json::Map::new();
7709        details.insert("diff".to_string(), serde_json::Value::String(diff));
7710        if let Some(line) = first_changed_line {
7711            details.insert(
7712                "firstChangedLine".to_string(),
7713                serde_json::Value::Number(serde_json::Number::from(line)),
7714            );
7715        }
7716
7717        Ok(ToolOutput {
7718            content: vec![ContentBlock::Text(TextContent::new(format!(
7719                "Successfully applied hashline edits to {}.",
7720                input.path
7721            )))],
7722            details: Some(serde_json::Value::Object(details)),
7723            is_error: false,
7724        })
7725    }
7726}
7727
7728// ============================================================================
7729// Tests
7730// ============================================================================
7731
7732#[cfg(test)]
7733mod tests {
7734    use super::*;
7735    use proptest::prelude::*;
7736    #[cfg(target_os = "linux")]
7737    use std::time::Duration;
7738
7739    #[test]
7740    fn test_truncate_head() {
7741        let content = "line1\nline2\nline3\nline4\nline5".to_string();
7742        let result = truncate_head(content, 3, 1000);
7743
7744        assert_eq!(result.content, "line1\nline2\nline3\n");
7745        assert!(result.truncated);
7746        assert_eq!(result.truncated_by, Some(TruncatedBy::Lines));
7747        assert_eq!(result.total_lines, 5);
7748        assert_eq!(result.output_lines, 3);
7749    }
7750
7751    #[test]
7752    fn test_truncate_tail() {
7753        let content = "line1\nline2\nline3\nline4\nline5".to_string();
7754        let result = truncate_tail(content, 3, 1000);
7755
7756        assert_eq!(result.content, "line3\nline4\nline5");
7757        assert!(result.truncated);
7758        assert_eq!(result.truncated_by, Some(TruncatedBy::Lines));
7759        assert_eq!(result.total_lines, 5);
7760        assert_eq!(result.output_lines, 3);
7761    }
7762
7763    fn assert_same_head_truncation(actual: &TruncationResult, expected: &TruncationResult) {
7764        assert_eq!(actual.content, expected.content);
7765        assert_eq!(actual.truncated, expected.truncated);
7766        assert_eq!(actual.truncated_by, expected.truncated_by);
7767        assert_eq!(actual.total_lines, expected.total_lines);
7768        assert_eq!(actual.total_bytes, expected.total_bytes);
7769        assert_eq!(actual.output_lines, expected.output_lines);
7770        assert_eq!(actual.output_bytes, expected.output_bytes);
7771        assert_eq!(actual.last_line_partial, expected.last_line_partial);
7772        assert_eq!(
7773            actual.first_line_exceeds_limit,
7774            expected.first_line_exceeds_limit
7775        );
7776        assert_eq!(actual.max_lines, expected.max_lines);
7777        assert_eq!(actual.max_bytes, expected.max_bytes);
7778    }
7779
7780    fn write_lines_with_builder(lines: &[&str], max_bytes: usize) -> TruncationResult {
7781        let mut writer = HeadTruncatingLineWriter::new(max_bytes);
7782        for line in lines {
7783            writer.push_line(line);
7784        }
7785        writer.finish()
7786    }
7787
7788    #[test]
7789    fn head_truncating_line_writer_matches_join_without_truncation() {
7790        let lines = ["alpha", "beta", "gamma"];
7791        let expected = truncate_head(lines.join("\n"), usize::MAX, 1000);
7792        let actual = write_lines_with_builder(&lines, 1000);
7793
7794        assert_same_head_truncation(&actual, &expected);
7795    }
7796
7797    #[test]
7798    fn head_truncating_line_writer_matches_join_at_byte_boundary() {
7799        let lines = ["alpha", "beta", "gamma"];
7800        let expected = truncate_head(lines.join("\n"), usize::MAX, 8);
7801        let actual = write_lines_with_builder(&lines, 8);
7802
7803        assert_same_head_truncation(&actual, &expected);
7804        assert_eq!(actual.content, "alpha\nbe");
7805    }
7806
7807    #[test]
7808    fn head_truncating_line_writer_preserves_utf8_boundary_and_order() {
7809        let lines = ["alpha", "βeta", "gamma"];
7810        let expected = truncate_head(lines.join("\n"), usize::MAX, 8);
7811        let actual = write_lines_with_builder(&lines, 8);
7812
7813        assert_same_head_truncation(&actual, &expected);
7814        assert_eq!(actual.content, "alpha\nβ");
7815    }
7816
7817    fn first_text(output: &ToolOutput) -> &str {
7818        output
7819            .content
7820            .first()
7821            .and_then(|block| match block {
7822                ContentBlock::Text(text) => Some(text.text.as_str()),
7823                _ => None,
7824            })
7825            .unwrap_or("")
7826    }
7827
7828    fn artifact_json(details: Option<&serde_json::Value>) -> &serde_json::Value {
7829        details
7830            .and_then(|value| value.get("artifact"))
7831            .expect("artifact details")
7832    }
7833
7834    fn artifact_str_field<'a>(artifact: &'a serde_json::Value, field: &str) -> &'a str {
7835        artifact
7836            .get(field)
7837            .and_then(serde_json::Value::as_str)
7838            .unwrap_or("")
7839    }
7840
7841    #[test]
7842    fn tool_output_artifact_respects_spill_threshold() {
7843        let tmp = tempfile::tempdir().expect("artifact root");
7844        let mut output = "small preview".to_string();
7845        let mut details = None;
7846        let spilled = attach_text_artifact_if_needed_at_root(
7847            tmp.path(),
7848            &mut output,
7849            &mut details,
7850            "read",
7851            "call-small",
7852            "selectedTextWindow",
7853            "small body",
7854        );
7855
7856        assert!(!spilled);
7857        assert_eq!(output, "small preview");
7858        assert!(details.is_none());
7859    }
7860
7861    #[test]
7862    fn tool_output_artifact_writes_content_addressed_text_and_metadata()
7863    -> std::result::Result<(), Box<dyn std::error::Error>> {
7864        let tmp = tempfile::tempdir().expect("artifact root");
7865        let full = "a".repeat(TOOL_OUTPUT_ARTIFACT_THRESHOLD_BYTES + 1);
7866        let mut output = "bounded preview".to_string();
7867        let mut details = None;
7868        let _session_guard =
7869            register_tool_output_artifact_session("call/text:1", "session/artifacts:one");
7870        let spilled = attach_text_artifact_if_needed_at_root(
7871            tmp.path(),
7872            &mut output,
7873            &mut details,
7874            "read",
7875            "call/text:1",
7876            "selectedTextWindow",
7877            &full,
7878        );
7879
7880        assert!(spilled);
7881        assert!(output.contains("Full tool output artifact:"));
7882        let artifact = artifact_json(details.as_ref());
7883        assert_eq!(artifact["schema"], TOOL_OUTPUT_ARTIFACT_SCHEMA_V1);
7884        assert_eq!(artifact["toolName"], "read");
7885        assert_eq!(artifact["sourceKind"], "selectedTextWindow");
7886        assert_eq!(artifact["sessionId"], "session/artifacts:one");
7887        assert_eq!(
7888            artifact["byteCount"].as_u64().unwrap(),
7889            u64::try_from(full.len()).unwrap()
7890        );
7891
7892        let path_value = artifact_str_field(artifact, "path");
7893        let metadata_path_value = artifact_str_field(artifact, "metadataPath");
7894        assert!(!path_value.is_empty(), "artifact path must be a string");
7895        assert!(
7896            !metadata_path_value.is_empty(),
7897            "artifact metadataPath must be a string"
7898        );
7899        let path = PathBuf::from(path_value);
7900        let metadata_path = PathBuf::from(metadata_path_value);
7901        assert!(path.starts_with(tmp.path().join("session_artifacts_one").join("call_text_1")));
7902        assert_eq!(std::fs::read_to_string(path)?, full);
7903        let metadata_bytes = std::fs::read(metadata_path)?;
7904        let metadata: serde_json::Value = serde_json::from_slice(&metadata_bytes)?;
7905        assert_eq!(metadata["sha256"], artifact["sha256"]);
7906        assert_eq!(
7907            metadata["retentionClass"],
7908            TOOL_OUTPUT_ARTIFACT_RETENTION_CLASS
7909        );
7910        assert_eq!(
7911            metadata["spilloverReason"],
7912            TOOL_OUTPUT_ARTIFACT_SPILLOVER_REASON
7913        );
7914        assert_eq!(metadata["safeDeleteCandidate"], true);
7915        assert_eq!(
7916            metadata["redactionSummary"]["policy"],
7917            TOOL_OUTPUT_ARTIFACT_REDACTION_POLICY_V1
7918        );
7919        assert_eq!(metadata["redactionSummary"]["status"], "clean");
7920        assert_eq!(metadata["redactionSummary"]["rawSecretBytesEmitted"], 0);
7921        Ok(())
7922    }
7923
7924    #[test]
7925    fn tool_output_artifact_redacts_sensitive_text_before_persisting()
7926    -> std::result::Result<(), Box<dyn std::error::Error>> {
7927        let tmp = tempfile::tempdir().expect("artifact root");
7928        let leaked_token = "sk-redactionfixture1234567890";
7929        let leaked_bearer = "ghp_redactionfixture1234567890";
7930        let full = format!(
7931            "API_TOKEN={leaked_token}\nAuthorization: Bearer {leaked_bearer}\n{}",
7932            "x".repeat(TOOL_OUTPUT_ARTIFACT_THRESHOLD_BYTES + 1)
7933        );
7934        let mut output = "bounded preview".to_string();
7935        let mut details = None;
7936
7937        let spilled = attach_text_artifact_if_needed_at_root(
7938            tmp.path(),
7939            &mut output,
7940            &mut details,
7941            "read",
7942            "call-secret",
7943            "selectedTextWindow",
7944            &full,
7945        );
7946
7947        assert!(spilled);
7948        let artifact = artifact_json(details.as_ref());
7949        let path = PathBuf::from(artifact_str_field(artifact, "path"));
7950        let metadata_path = PathBuf::from(artifact_str_field(artifact, "metadataPath"));
7951        let persisted = std::fs::read_to_string(path)?;
7952        let metadata: serde_json::Value = serde_json::from_slice(&std::fs::read(metadata_path)?)?;
7953
7954        assert!(!persisted.contains(leaked_token));
7955        assert!(!persisted.contains(leaked_bearer));
7956        assert!(persisted.contains("API_TOKEN=[REDACTED]"));
7957        assert_eq!(artifact["redactionSummary"]["status"], "redacted");
7958        assert_eq!(artifact["redactionSummary"]["rawSecretBytesEmitted"], 0);
7959        assert_eq!(metadata["redactionSummary"], artifact["redactionSummary"]);
7960        let fields = artifact["redactionSummary"]["fields"]
7961            .as_array()
7962            .expect("redaction fields");
7963        assert!(fields.iter().any(|field| field == "api_token"));
7964        assert!(fields.iter().any(|field| field == "authorization"));
7965        Ok(())
7966    }
7967
7968    #[test]
7969    fn tool_output_artifact_marks_binaryish_payloads_in_lifecycle_manifest() {
7970        let tmp = tempfile::tempdir().expect("artifact root");
7971        let full = format!(
7972            "{}\0{}",
7973            "z".repeat(TOOL_OUTPUT_ARTIFACT_THRESHOLD_BYTES / 2),
7974            "z".repeat(TOOL_OUTPUT_ARTIFACT_THRESHOLD_BYTES / 2 + 2)
7975        );
7976        let mut output = "bounded preview".to_string();
7977        let mut details = None;
7978
7979        let spilled = attach_text_artifact_if_needed_at_root(
7980            tmp.path(),
7981            &mut output,
7982            &mut details,
7983            "read",
7984            "call-binaryish",
7985            "selectedTextWindow",
7986            &full,
7987        );
7988
7989        assert!(spilled);
7990        let artifact = artifact_json(details.as_ref());
7991        assert_eq!(artifact["redactionSummary"]["binarySuspect"], true);
7992        assert_eq!(artifact["redactionSummary"]["rawSecretBytesEmitted"], 0);
7993        assert_eq!(artifact["safeDeleteCandidate"], true);
7994    }
7995
7996    #[test]
7997    fn tool_output_artifact_failure_records_degraded_preview() {
7998        let tmp = tempfile::tempdir().expect("artifact root parent");
7999        let root_file = tmp.path().join("not-a-directory");
8000        std::fs::write(&root_file, "not a directory").expect("root file");
8001        let full = "b".repeat(TOOL_OUTPUT_ARTIFACT_THRESHOLD_BYTES + 1);
8002        let mut output = "bounded preview".to_string();
8003        let mut details = None;
8004
8005        let spilled = attach_text_artifact_if_needed_at_root(
8006            &root_file,
8007            &mut output,
8008            &mut details,
8009            "read",
8010            "call-fail",
8011            "selectedTextWindow",
8012            &full,
8013        );
8014
8015        assert!(!spilled);
8016        assert!(output.contains("Tool output artifact persistence failed"));
8017        assert!(
8018            details
8019                .as_ref()
8020                .and_then(|value| value.get("artifactError"))
8021                .is_some()
8022        );
8023    }
8024
8025    #[test]
8026    fn read_tool_spills_oversized_selected_text_window_to_artifact() {
8027        asupersync::test_utils::run_test(|| async {
8028            let tmp = tempfile::tempdir().expect("workspace");
8029            let artifact_root = tempfile::tempdir().expect("artifact root");
8030
8031            let body = "r".repeat(TOOL_OUTPUT_ARTIFACT_THRESHOLD_BYTES + 8);
8032            std::fs::write(tmp.path().join("large.txt"), &body).expect("large file");
8033            let read_tool = ReadTool::with_artifact_root(tmp.path(), artifact_root.path());
8034            let output = read_tool
8035                .execute(
8036                    "read-artifact-call",
8037                    serde_json::json!({ "path": "large.txt" }),
8038                    None,
8039                )
8040                .await
8041                .expect("read large file");
8042
8043            assert!(first_text(&output).contains("Full tool output artifact:"));
8044            let artifact = artifact_json(output.details.as_ref());
8045            assert_eq!(artifact["toolName"], "read");
8046            assert_eq!(artifact["sourceKind"], "selectedTextWindow");
8047            let path_value = artifact_str_field(artifact, "path");
8048            assert!(!path_value.is_empty(), "artifact path must be a string");
8049            let path = PathBuf::from(path_value);
8050            let spilled = match std::fs::read_to_string(&path) {
8051                Ok(spilled) => spilled,
8052                Err(err) => {
8053                    assert!(false, "read spilled artifact {}: {err}", path.display());
8054                    return;
8055                }
8056            };
8057            let prefix = "    1→";
8058            assert_eq!(spilled.len(), prefix.len() + DEFAULT_MAX_BYTES);
8059            assert_eq!(
8060                artifact["byteCount"].as_u64().unwrap(),
8061                u64::try_from(spilled.len()).unwrap()
8062            );
8063            assert!(spilled.starts_with(prefix));
8064            assert!(spilled[prefix.len()..].bytes().all(|byte| byte == b'r'));
8065            assert_eq!(
8066                artifact["retentionClass"],
8067                TOOL_OUTPUT_ARTIFACT_RETENTION_CLASS
8068            );
8069            assert_eq!(
8070                artifact["spilloverReason"],
8071                TOOL_OUTPUT_ARTIFACT_SPILLOVER_REASON
8072            );
8073            assert_eq!(artifact["safeDeleteCandidate"], true);
8074        });
8075    }
8076
8077    #[test]
8078    fn bash_tool_spills_truncated_full_output_to_artifact() {
8079        asupersync::test_utils::run_test(|| async {
8080            if !Path::new("/dev/zero").exists() {
8081                return;
8082            }
8083
8084            let tmp = tempfile::tempdir().expect("workspace");
8085            let artifact_root = tempfile::tempdir().expect("artifact root");
8086
8087            let bash_tool = BashTool::with_artifact_root(tmp.path(), artifact_root.path());
8088            let output = bash_tool
8089                .execute(
8090                    "bash-artifact-call",
8091                    serde_json::json!({
8092                        "command": "head -c 1001000 /dev/zero | tr '\\0' x",
8093                        "timeout": 10
8094                    }),
8095                    None,
8096                )
8097                .await
8098                .expect("bash large output");
8099
8100            assert!(first_text(&output).contains("Full tool output artifact:"));
8101            let artifact = artifact_json(output.details.as_ref());
8102            assert_eq!(artifact["toolName"], "bash");
8103            assert_eq!(artifact["sourceKind"], "fullCommandOutput");
8104            let path = PathBuf::from(artifact_str_field(artifact, "path"));
8105            assert_eq!(std::fs::metadata(path).unwrap().len(), 1_001_000);
8106            assert_eq!(artifact["redactionSummary"]["status"], "clean");
8107            assert_eq!(artifact["safeDeleteCandidate"], true);
8108        });
8109    }
8110
8111    #[test]
8112    fn bash_tool_redacts_secret_like_full_output_artifacts() {
8113        asupersync::test_utils::run_test(|| async {
8114            if !Path::new("/dev/zero").exists() {
8115                return;
8116            }
8117
8118            let tmp = tempfile::tempdir().expect("workspace");
8119            let artifact_root = tempfile::tempdir().expect("artifact root");
8120            let leaked_token = "sk-bashredactionfixture1234567890";
8121
8122            let bash_tool = BashTool::with_artifact_root(tmp.path(), artifact_root.path());
8123            let output = bash_tool
8124                .execute(
8125                    "bash-secret-artifact-call",
8126                    serde_json::json!({
8127                        "command": format!("printf 'API_TOKEN={leaked_token}\\n'; head -c 1001000 /dev/zero | tr '\\0' x"),
8128                        "timeout": 10
8129                    }),
8130                    None,
8131                )
8132                .await
8133                .expect("bash large output");
8134
8135            assert!(first_text(&output).contains("Full tool output artifact:"));
8136            let artifact = artifact_json(output.details.as_ref());
8137            assert_eq!(artifact["toolName"], "bash");
8138            assert_eq!(artifact["redactionSummary"]["status"], "redacted");
8139            assert_eq!(artifact["redactionSummary"]["rawSecretBytesEmitted"], 0);
8140            let path = PathBuf::from(artifact_str_field(artifact, "path"));
8141            let persisted = std::fs::read_to_string(path).expect("read redacted bash artifact");
8142            assert!(!persisted.contains(leaked_token));
8143            assert!(persisted.contains("API_TOKEN=[REDACTED]"));
8144        });
8145    }
8146
8147    #[test]
8148    fn grep_tool_spills_large_search_results_with_lifecycle_manifest() {
8149        asupersync::test_utils::run_test(|| async {
8150            if !rg_available() {
8151                return;
8152            }
8153
8154            let tmp = tempfile::tempdir().expect("workspace");
8155            let artifact_root = tempfile::tempdir().expect("artifact root");
8156            let mut body = String::new();
8157            let suffix = "g".repeat(560);
8158            for idx in 0..2200 {
8159                let _ = writeln!(body, "target {idx:04} {suffix}");
8160            }
8161            std::fs::write(tmp.path().join("large-grep.txt"), body).expect("write grep fixture");
8162
8163            let grep_tool = GrepTool::with_artifact_root(tmp.path(), artifact_root.path());
8164            let output = grep_tool
8165                .execute(
8166                    "grep-artifact-call",
8167                    serde_json::json!({
8168                        "pattern": "target",
8169                        "path": "large-grep.txt",
8170                        "literal": true,
8171                        "limit": 2200
8172                    }),
8173                    None,
8174                )
8175                .await
8176                .expect("grep large output");
8177
8178            assert!(first_text(&output).contains("Full tool output artifact:"));
8179            let artifact = artifact_json(output.details.as_ref());
8180            assert_eq!(artifact["toolName"], "grep");
8181            assert_eq!(artifact["sourceKind"], "searchResults");
8182            assert_eq!(
8183                artifact["retentionClass"],
8184                TOOL_OUTPUT_ARTIFACT_RETENTION_CLASS
8185            );
8186            assert_eq!(artifact["safeDeleteCandidate"], true);
8187            assert_eq!(artifact["redactionSummary"]["status"], "clean");
8188            let path = PathBuf::from(artifact_str_field(artifact, "path"));
8189            let persisted = std::fs::read_to_string(path).expect("read grep artifact");
8190            assert!(persisted.contains("large-grep.txt:1: target 0000"));
8191            assert!(
8192                artifact["byteCount"].as_u64().unwrap()
8193                    > u64::try_from(TOOL_OUTPUT_ARTIFACT_THRESHOLD_BYTES).unwrap()
8194            );
8195        });
8196    }
8197
8198    #[test]
8199    fn read_tool_denied_path_does_not_emit_lifecycle_artifact() {
8200        asupersync::test_utils::run_test(|| async {
8201            let cwd = tempfile::tempdir().expect("workspace");
8202            let outside = tempfile::tempdir().expect("outside");
8203            let artifact_root = tempfile::tempdir().expect("artifact root");
8204            let outside_path = outside.path().join("secret.txt");
8205            std::fs::write(&outside_path, "API_TOKEN=sk-deniedpathfixture1234567890")
8206                .expect("outside secret");
8207
8208            let read_tool = ReadTool::with_artifact_root(cwd.path(), artifact_root.path());
8209            let err = read_tool
8210                .execute(
8211                    "read-denied-artifact-call",
8212                    serde_json::json!({ "path": outside_path }),
8213                    None,
8214                )
8215                .await
8216                .expect_err("outside read should be denied");
8217
8218            assert!(
8219                err.to_string()
8220                    .contains("Cannot read outside the working directory or agent dir")
8221            );
8222            let mut entries = std::fs::read_dir(artifact_root.path()).expect("artifact root");
8223            assert!(
8224                entries.next().is_none(),
8225                "denied reads must not write artifacts"
8226            );
8227        });
8228    }
8229
8230    #[test]
8231    fn ls_tool_spills_oversized_directory_listing_to_artifact() {
8232        asupersync::test_utils::run_test(|| async {
8233            let tmp = tempfile::tempdir().expect("workspace");
8234            let artifact_root = tempfile::tempdir().expect("artifact root");
8235            let suffix = "x".repeat(224);
8236            for i in 0..4_500 {
8237                let name = format!("entry-{i:04}-{suffix}.txt");
8238                std::fs::write(tmp.path().join(name), "").expect("write listing fixture");
8239            }
8240
8241            let ls_tool = LsTool::with_artifact_root(tmp.path(), artifact_root.path());
8242            let output = ls_tool
8243                .execute(
8244                    "ls-artifact-call",
8245                    serde_json::json!({ "path": ".", "limit": 4500 }),
8246                    None,
8247                )
8248                .await
8249                .expect("ls large directory");
8250
8251            assert!(first_text(&output).contains("Full tool output artifact:"));
8252            let artifact = artifact_json(output.details.as_ref());
8253            assert_eq!(artifact["toolName"], "ls");
8254            assert_eq!(artifact["sourceKind"], "directoryEntries");
8255            assert!(
8256                artifact["byteCount"].as_u64().unwrap()
8257                    > u64::try_from(TOOL_OUTPUT_ARTIFACT_THRESHOLD_BYTES).unwrap()
8258            );
8259            let path = PathBuf::from(artifact_str_field(artifact, "path"));
8260            assert!(
8261                std::fs::read_to_string(path)
8262                    .unwrap()
8263                    .contains("entry-0000-")
8264            );
8265        });
8266    }
8267
8268    async fn assert_read_cache_hit_and_stale(tmp: &Path) {
8269        let note = tmp.join("note.txt");
8270        std::fs::write(&note, "alpha\n").expect("write note");
8271
8272        let read_tool = ReadTool::new(tmp);
8273        let read_input = serde_json::json!({ "path": "note.txt" });
8274        let first = read_tool
8275            .execute("read-1", read_input.clone(), None)
8276            .await
8277            .expect("first read");
8278        assert!(first_text(&first).contains("alpha"));
8279
8280        let hits_before = tool_output_cache_stats_for_tests().hits;
8281        let second = read_tool
8282            .execute("read-2", read_input.clone(), None)
8283            .await
8284            .expect("cached read");
8285        assert_eq!(first_text(&first), first_text(&second));
8286        assert!(tool_output_cache_stats_for_tests().hits > hits_before);
8287
8288        let invalidations_before = tool_output_cache_stats_for_tests().invalidations;
8289        std::fs::write(&note, "beta\n").expect("rewrite note");
8290        let third = read_tool
8291            .execute("read-3", read_input.clone(), None)
8292            .await
8293            .expect("invalidated read");
8294        assert!(first_text(&third).contains("beta"));
8295        assert!(!first_text(&third).contains("alpha"));
8296        assert!(tool_output_cache_stats_for_tests().invalidations > invalidations_before);
8297    }
8298
8299    async fn assert_ls_cache_hit_and_stale(tmp: &Path) {
8300        let ls_tool = LsTool::new(tmp);
8301        let ls_input = serde_json::json!({ "path": "." });
8302        let ls_first = ls_tool
8303            .execute("ls-1", ls_input.clone(), None)
8304            .await
8305            .expect("first ls");
8306        assert!(first_text(&ls_first).contains("note.txt"));
8307
8308        let hits_before = tool_output_cache_stats_for_tests().hits;
8309        let ls_second = ls_tool
8310            .execute("ls-2", ls_input.clone(), None)
8311            .await
8312            .expect("cached ls");
8313        assert_eq!(first_text(&ls_first), first_text(&ls_second));
8314        assert!(tool_output_cache_stats_for_tests().hits > hits_before);
8315
8316        let invalidations_before = tool_output_cache_stats_for_tests().invalidations;
8317        std::fs::write(tmp.join("new.txt"), "new\n").expect("write new file");
8318        let ls_third = ls_tool
8319            .execute("ls-3", ls_input.clone(), None)
8320            .await
8321            .expect("invalidated ls");
8322        assert!(first_text(&ls_third).contains("new.txt"));
8323        assert!(tool_output_cache_stats_for_tests().invalidations > invalidations_before);
8324    }
8325
8326    async fn assert_grep_cache_hit_and_stale_when_available(tmp: &Path) {
8327        if find_rg_binary().is_none() {
8328            return;
8329        }
8330
8331        let grep_tool = GrepTool::new(tmp);
8332        let grep_input = serde_json::json!({ "pattern": "needle", "path": "." });
8333        std::fs::write(tmp.join("a.txt"), "needle\n").expect("write grep file");
8334
8335        let grep_first = grep_tool
8336            .execute("grep-1", grep_input.clone(), None)
8337            .await
8338            .expect("first grep");
8339        assert!(first_text(&grep_first).contains("a.txt"));
8340
8341        let hits_before = tool_output_cache_stats_for_tests().hits;
8342        let grep_second = grep_tool
8343            .execute("grep-2", grep_input.clone(), None)
8344            .await
8345            .expect("cached grep");
8346        assert_eq!(first_text(&grep_first), first_text(&grep_second));
8347        assert!(tool_output_cache_stats_for_tests().hits > hits_before);
8348
8349        let invalidations_before = tool_output_cache_stats_for_tests().invalidations;
8350        std::fs::write(tmp.join("b.txt"), "needle\n").expect("write new match");
8351        let grep_third = grep_tool
8352            .execute("grep-3", grep_input.clone(), None)
8353            .await
8354            .expect("invalidated grep");
8355        assert!(first_text(&grep_third).contains("b.txt"));
8356        assert!(tool_output_cache_stats_for_tests().invalidations > invalidations_before);
8357    }
8358
8359    async fn assert_find_cache_hit_and_stale_when_available(tmp: &Path) {
8360        if find_fd_binary().is_none() {
8361            return;
8362        }
8363
8364        let find_tool = FindTool::new(tmp);
8365        let find_input = serde_json::json!({ "pattern": "*find*.txt", "path": "." });
8366        std::fs::write(tmp.join("find-a.txt"), "find\n").expect("write first find file");
8367
8368        let find_first = find_tool
8369            .execute("find-1", find_input.clone(), None)
8370            .await
8371            .expect("first find");
8372        assert!(first_text(&find_first).contains("find-a.txt"));
8373
8374        let hits_before = tool_output_cache_stats_for_tests().hits;
8375        let find_second = find_tool
8376            .execute("find-2", find_input.clone(), None)
8377            .await
8378            .expect("cached find");
8379        assert_eq!(first_text(&find_first), first_text(&find_second));
8380        assert!(tool_output_cache_stats_for_tests().hits > hits_before);
8381
8382        let invalidations_before = tool_output_cache_stats_for_tests().invalidations;
8383        std::fs::write(tmp.join("find-b.txt"), "find\n").expect("write second find file");
8384        let find_third = find_tool
8385            .execute("find-3", find_input.clone(), None)
8386            .await
8387            .expect("invalidated find");
8388        assert!(first_text(&find_third).contains("find-b.txt"));
8389        assert!(tool_output_cache_stats_for_tests().invalidations > invalidations_before);
8390    }
8391
8392    async fn assert_side_effect_tools_remain_uncached(tmp: &Path) {
8393        let side_effect_stats_before = tool_output_cache_stats_for_tests();
8394        let write_tool = WriteTool::new(tmp);
8395        write_tool
8396            .execute(
8397                "write-1",
8398                serde_json::json!({
8399                    "path": "side-effect.txt",
8400                    "content": "one\n"
8401                }),
8402                None,
8403            )
8404            .await
8405            .expect("write side-effect file");
8406
8407        let edit_tool = EditTool::new(tmp);
8408        edit_tool
8409            .execute(
8410                "edit-1",
8411                serde_json::json!({
8412                    "path": "side-effect.txt",
8413                    "oldText": "one",
8414                    "newText": "two"
8415                }),
8416                None,
8417            )
8418            .await
8419            .expect("edit side-effect file");
8420
8421        let bash_tool = BashTool::new(tmp);
8422        bash_tool
8423            .execute(
8424                "bash-1",
8425                serde_json::json!({
8426                    "command": "printf 'cache-uncached\\n'",
8427                    "timeout": 5
8428                }),
8429                None,
8430            )
8431            .await
8432            .expect("run uncached bash");
8433
8434        let side_effect_stats_after = tool_output_cache_stats_for_tests();
8435        assert_eq!(
8436            (
8437                side_effect_stats_after.side_effect_accesses,
8438                side_effect_stats_after.side_effect_insert_attempts
8439            ),
8440            (
8441                side_effect_stats_before.side_effect_accesses,
8442                side_effect_stats_before.side_effect_insert_attempts
8443            ),
8444            "write, edit, and bash must not consult or populate the read-only output cache"
8445        );
8446    }
8447
8448    #[test]
8449    fn tool_output_cache_reuses_and_invalidates_read_only_tool_outputs() {
8450        asupersync::test_utils::run_test(|| async {
8451            reset_tool_output_cache_for_tests();
8452
8453            let tmp = tempfile::tempdir().expect("create temp dir");
8454            assert_read_cache_hit_and_stale(tmp.path()).await;
8455            assert_ls_cache_hit_and_stale(tmp.path()).await;
8456            assert_grep_cache_hit_and_stale_when_available(tmp.path()).await;
8457            assert_find_cache_hit_and_stale_when_available(tmp.path()).await;
8458            assert_side_effect_tools_remain_uncached(tmp.path()).await;
8459        });
8460    }
8461
8462    #[test]
8463    fn tool_output_context_cache_evidence_jsonl_covers_required_decisions()
8464    -> std::result::Result<(), String> {
8465        let evidence = include_str!("../docs/evidence/tool-output-context-cache.jsonl");
8466        let mut saw_read_hit = false;
8467        let mut saw_grep_stale = false;
8468        let mut saw_find_stale = false;
8469        let mut saw_ls_stale = false;
8470        let mut saw_write_uncached = false;
8471        let mut saw_edit_uncached = false;
8472        let mut saw_bash_uncached = false;
8473
8474        for (line_number, line) in evidence.lines().enumerate() {
8475            if line.trim().is_empty() {
8476                continue;
8477            }
8478
8479            let event: serde_json::Value = serde_json::from_str(line).map_err(|err| {
8480                format!(
8481                    "invalid context-cache JSONL at line {}: {err}",
8482                    line_number + 1
8483                )
8484            })?;
8485            assert_eq!(
8486                event.get("schema").and_then(serde_json::Value::as_str),
8487                Some("pi.tool_output_context_cache.evidence.v1")
8488            );
8489            assert_eq!(
8490                event.get("bead").and_then(serde_json::Value::as_str),
8491                Some("bd-dklqn.1")
8492            );
8493            let related_beads = event
8494                .get("related_beads")
8495                .and_then(serde_json::Value::as_array)
8496                .ok_or_else(|| format!("missing related_beads at line {}", line_number + 1))?;
8497            assert!(
8498                related_beads
8499                    .iter()
8500                    .any(|bead| bead.as_str() == Some("bd-dklqn.2")),
8501                "evidence line {} must cover bd-dklqn.2",
8502                line_number + 1
8503            );
8504
8505            let tool = event
8506                .get("tool")
8507                .and_then(serde_json::Value::as_str)
8508                .expect("tool");
8509            let outcome = event
8510                .get("outcome")
8511                .and_then(serde_json::Value::as_str)
8512                .expect("outcome");
8513            let reason = event
8514                .get("reason")
8515                .and_then(serde_json::Value::as_str)
8516                .expect("reason");
8517
8518            match (tool, outcome, reason) {
8519                ("read", "hit", "unchanged_file_fingerprint") => saw_read_hit = true,
8520                ("grep", "stale", "recursive_directory_fingerprint_changed") => {
8521                    saw_grep_stale = true;
8522                }
8523                ("find", "stale", "recursive_directory_fingerprint_changed") => {
8524                    saw_find_stale = true;
8525                }
8526                ("ls", "stale", "directory_entry_fingerprint_changed") => saw_ls_stale = true,
8527                ("write", "uncached", "write_effect_tool") => saw_write_uncached = true,
8528                ("edit", "uncached", "write_effect_tool") => saw_edit_uncached = true,
8529                ("bash", "uncached", "process_effect_tool") => saw_bash_uncached = true,
8530                _ => {}
8531            }
8532        }
8533
8534        assert!(saw_read_hit, "evidence must include a read cache hit");
8535        assert!(saw_grep_stale, "evidence must include grep stale bypass");
8536        assert!(saw_find_stale, "evidence must include find stale bypass");
8537        assert!(saw_ls_stale, "evidence must include ls stale bypass");
8538        assert!(saw_write_uncached, "evidence must include write uncached");
8539        assert!(saw_edit_uncached, "evidence must include edit uncached");
8540        assert!(saw_bash_uncached, "evidence must include bash uncached");
8541        Ok(())
8542    }
8543
8544    #[test]
8545    fn test_truncate_tail_zero_lines_returns_empty_output() {
8546        let result = truncate_tail("line1\nline2".to_string(), 0, 1000);
8547
8548        assert!(result.truncated);
8549        assert_eq!(result.truncated_by, Some(TruncatedBy::Lines));
8550        assert_eq!(result.output_lines, 0);
8551        assert_eq!(result.output_bytes, 0);
8552        assert!(result.content.is_empty());
8553    }
8554
8555    #[test]
8556    fn test_line_count_from_newline_count_matches_trailing_newline_semantics() {
8557        assert_eq!(line_count_from_newline_count(0, 0, false), 0);
8558        assert_eq!(line_count_from_newline_count(2, 1, true), 1);
8559        assert_eq!(line_count_from_newline_count(1, 0, false), 1);
8560        assert_eq!(line_count_from_newline_count(3, 1, false), 2);
8561    }
8562
8563    #[test]
8564    fn test_rg_match_requires_path_and_line_number() {
8565        let mut matches = Vec::new();
8566        let mut match_count = 0usize;
8567        let mut match_limit_reached = false;
8568        let scan_limit = 1;
8569
8570        let missing_line =
8571            Ok(r#"{"type":"match","data":{"path":{"text":"file.txt"}}}"#.to_string());
8572        process_rg_json_match_line(
8573            missing_line,
8574            &mut matches,
8575            &mut match_count,
8576            &mut match_limit_reached,
8577            scan_limit,
8578        );
8579        assert!(matches.is_empty());
8580        assert_eq!(match_count, 0);
8581        assert!(!match_limit_reached);
8582
8583        let valid_line = Ok(
8584            r#"{"type":"match","data":{"path":{"text":"file.txt"},"line_number":3}}"#.to_string(),
8585        );
8586        process_rg_json_match_line(
8587            valid_line,
8588            &mut matches,
8589            &mut match_count,
8590            &mut match_limit_reached,
8591            scan_limit,
8592        );
8593        assert_eq!(matches.len(), 1);
8594        assert_eq!(matches[0].1, 3);
8595        assert_eq!(match_count, 1);
8596        assert!(match_limit_reached);
8597    }
8598
8599    #[test]
8600    fn test_truncate_by_bytes() {
8601        let content = "short\nthis is a longer line\nanother".to_string();
8602        let result = truncate_head(content, 100, 15);
8603
8604        assert!(result.truncated);
8605        assert_eq!(result.truncated_by, Some(TruncatedBy::Bytes));
8606    }
8607
8608    #[cfg(any(target_os = "linux", target_os = "freebsd"))]
8609    #[test]
8610    fn test_command_with_default_sigpipe_restores_pipe_disposition() {
8611        // Verify the spawned child does NOT inherit the parent's
8612        // SIGPIPE=SIG_IGN. The probe parses the SigIgn: hex mask exposed by
8613        // Linux-format /proc/<pid>/status — available natively on Linux and,
8614        // on FreeBSD, through the linprocfs compat module mounted at
8615        // /compat/linux/proc. Skip with a one-line notice when linprocfs is
8616        // not mounted rather than failing the test.
8617        #[cfg(target_os = "freebsd")]
8618        let status_dir = {
8619            let probe = format!("/compat/linux/proc/{}/status", std::process::id());
8620            if !std::path::Path::new(&probe).exists() {
8621                eprintln!(
8622                    "skipping sigpipe disposition test: linprocfs not mounted \
8623                     at /compat/linux/proc — add `linprocfs /compat/linux/proc \
8624                     linprocfs rw 0 0` to /etc/fstab and `mount /compat/linux/proc` \
8625                     to enable"
8626                );
8627                return;
8628            }
8629            "/compat/linux/proc"
8630        };
8631        #[cfg(not(target_os = "freebsd"))]
8632        let status_dir = "/proc";
8633
8634        let probe_cmd = format!(
8635            "while read name value _; do [ \"$name\" = SigIgn: ] && \
8636             {{ printf '%s' \"$value\"; exit 0; }}; done < {status_dir}/$$/status"
8637        );
8638
8639        let output = command_with_default_sigpipe("sh")
8640            .expect("prepare sigpipe disposition probe")
8641            .args(["-c", &probe_cmd])
8642            .stdout(std::process::Stdio::piped())
8643            .output()
8644            .expect("spawn sigpipe disposition probe");
8645
8646        assert!(output.status.success(), "probe failed: {output:?}");
8647        let sigign = String::from_utf8(output.stdout).expect("SigIgn should be utf8");
8648        let ignored_mask =
8649            u64::from_str_radix(sigign.trim(), 16).expect("SigIgn should be a hex mask");
8650        let sigpipe_bit = 1_u64 << (13 - 1);
8651        assert_eq!(
8652            ignored_mask & sigpipe_bit,
8653            0,
8654            "child should not inherit ignored SIGPIPE: SigIgn={sigign}"
8655        );
8656    }
8657
8658    #[cfg(unix)]
8659    #[test]
8660    fn test_command_with_default_sigpipe_in_dir_resolves_relative_program_after_cwd() {
8661        use std::os::unix::fs::PermissionsExt as _;
8662
8663        let tmp = tempfile::tempdir().expect("create temp dir");
8664        let script = tmp.path().join("relative-probe");
8665        std::fs::write(&script, "#!/bin/sh\nprintf cwd-relative-ok\n").expect("write script");
8666        let mut permissions = std::fs::metadata(&script)
8667            .expect("stat script")
8668            .permissions();
8669        permissions.set_mode(0o755);
8670        std::fs::set_permissions(&script, permissions).expect("make script executable");
8671
8672        let output = command_with_default_sigpipe_in_dir("./relative-probe", tmp.path())
8673            .expect("prepare relative executable")
8674            .current_dir(tmp.path())
8675            .stdout(std::process::Stdio::piped())
8676            .output()
8677            .expect("spawn relative executable");
8678
8679        assert!(output.status.success(), "probe failed: {output:?}");
8680        assert_eq!(
8681            String::from_utf8(output.stdout).expect("probe stdout should be utf8"),
8682            "cwd-relative-ok"
8683        );
8684    }
8685
8686    #[cfg(target_os = "linux")]
8687    #[test]
8688    fn test_read_to_end_capped_and_drain_preserves_writer_exit_status() {
8689        let mut child = std::process::Command::new("dd")
8690            .args(["if=/dev/zero", "bs=1", "count=70000", "status=none"])
8691            .stdout(std::process::Stdio::piped())
8692            .spawn()
8693            .expect("spawn dd");
8694
8695        let stdout = child.stdout.take().expect("dd stdout");
8696        let captured = read_to_end_capped_and_drain(stdout, 1024).expect("capture bounded stdout");
8697        let status = child.wait().expect("wait for dd");
8698
8699        assert!(
8700            status.success(),
8701            "bounded reader should drain to EOF instead of SIGPIPEing the writer: {status:?}"
8702        );
8703        assert_eq!(captured.len(), 1025);
8704    }
8705
8706    #[cfg(unix)]
8707    #[test]
8708    fn test_get_file_lines_async_unreadable_file_returns_empty() {
8709        asupersync::test_utils::run_test(|| async {
8710            use std::os::unix::fs::PermissionsExt;
8711
8712            let tmp = tempfile::tempdir().unwrap();
8713            let path = tmp.path().join("secret.txt");
8714            std::fs::write(&path, "secret\n").unwrap();
8715
8716            let mut perms = std::fs::metadata(&path).unwrap().permissions();
8717            perms.set_mode(0o000);
8718            std::fs::set_permissions(&path, perms).unwrap();
8719
8720            let mut cache = HashMap::new();
8721            let lines = get_file_lines_async(&path, &mut cache).await;
8722            assert!(lines.is_empty());
8723        });
8724    }
8725
8726    #[test]
8727    fn test_resolve_path_absolute() {
8728        let cwd = PathBuf::from("/home/user/project");
8729        let result = resolve_path("/absolute/path", &cwd);
8730        assert_eq!(result, PathBuf::from("/absolute/path"));
8731    }
8732
8733    #[test]
8734    fn test_resolve_path_relative() {
8735        let cwd = PathBuf::from("/home/user/project");
8736        let result = resolve_path("src/main.rs", &cwd);
8737        assert_eq!(result, PathBuf::from("/home/user/project/src/main.rs"));
8738    }
8739
8740    #[test]
8741    fn test_normalize_dot_segments_preserves_root() {
8742        let result = normalize_dot_segments(std::path::Path::new("/../etc/passwd"));
8743        assert_eq!(result, PathBuf::from("/etc/passwd"));
8744    }
8745
8746    #[test]
8747    fn test_normalize_dot_segments_preserves_leading_parent_for_relative() {
8748        let result = normalize_dot_segments(std::path::Path::new("../a/../b"));
8749        assert_eq!(result, PathBuf::from("../b"));
8750    }
8751
8752    #[test]
8753    fn test_detect_supported_image_mime_type_from_bytes() {
8754        assert_eq!(
8755            detect_supported_image_mime_type_from_bytes(b"\x89PNG\r\n\x1A\n"),
8756            Some("image/png")
8757        );
8758        assert_eq!(
8759            detect_supported_image_mime_type_from_bytes(b"\xFF\xD8\xFF"),
8760            Some("image/jpeg")
8761        );
8762        assert_eq!(
8763            detect_supported_image_mime_type_from_bytes(b"GIF89a"),
8764            Some("image/gif")
8765        );
8766        assert_eq!(
8767            detect_supported_image_mime_type_from_bytes(b"RIFF1234WEBP"),
8768            Some("image/webp")
8769        );
8770        assert_eq!(
8771            detect_supported_image_mime_type_from_bytes(b"not an image"),
8772            None
8773        );
8774    }
8775
8776    #[test]
8777    fn test_format_size() {
8778        assert_eq!(format_size(500), "500B");
8779        assert_eq!(format_size(1024), "1.0KB");
8780        assert_eq!(format_size(1536), "1.5KB");
8781        assert_eq!(format_size(1_048_576), "1.0MB");
8782        assert_eq!(format_size(1_073_741_824), "1024.0MB");
8783    }
8784
8785    #[test]
8786    fn test_js_string_length() {
8787        assert_eq!(js_string_length("hello"), 5);
8788        assert_eq!(js_string_length("😀"), 2);
8789    }
8790
8791    #[test]
8792    fn test_truncate_line() {
8793        let short = "short line";
8794        let result = truncate_line(short, 100);
8795        assert_eq!(result.text, "short line");
8796        assert!(!result.was_truncated);
8797
8798        let long = "a".repeat(600);
8799        let result = truncate_line(&long, 500);
8800        assert!(result.was_truncated);
8801        assert!(result.text.ends_with("... [truncated]"));
8802    }
8803
8804    // ========================================================================
8805    // Helper: extract text from ToolOutput content blocks
8806    // ========================================================================
8807
8808    fn get_text(content: &[ContentBlock]) -> String {
8809        content
8810            .iter()
8811            .filter_map(|block| {
8812                if let ContentBlock::Text(text) = block {
8813                    Some(text.text.clone())
8814                } else {
8815                    None
8816                }
8817            })
8818            .collect::<String>()
8819    }
8820
8821    // ========================================================================
8822    // Read Tool Tests
8823    // ========================================================================
8824
8825    #[test]
8826    fn test_read_valid_file() {
8827        asupersync::test_utils::run_test(|| async {
8828            let tmp = tempfile::tempdir().unwrap();
8829            std::fs::write(tmp.path().join("hello.txt"), "alpha\nbeta\ngamma").unwrap();
8830
8831            let tool = ReadTool::new(tmp.path());
8832            let out = tool
8833                .execute(
8834                    "t",
8835                    serde_json::json!({ "path": tmp.path().join("hello.txt").to_string_lossy() }),
8836                    None,
8837                )
8838                .await
8839                .unwrap();
8840            let text = get_text(&out.content);
8841            assert!(text.contains("alpha"));
8842            assert!(text.contains("beta"));
8843            assert!(text.contains("gamma"));
8844            assert!(!out.is_error);
8845        });
8846    }
8847
8848    #[test]
8849    fn test_read_nonexistent_file() {
8850        asupersync::test_utils::run_test(|| async {
8851            let tmp = tempfile::tempdir().unwrap();
8852            let tool = ReadTool::new(tmp.path());
8853            let err = tool
8854                .execute(
8855                    "t",
8856                    serde_json::json!({ "path": tmp.path().join("nope.txt").to_string_lossy() }),
8857                    None,
8858                )
8859                .await;
8860            assert!(err.is_err());
8861        });
8862    }
8863
8864    #[test]
8865    fn test_read_rejects_outside_cwd() {
8866        asupersync::test_utils::run_test(|| async {
8867            let cwd = tempfile::tempdir().unwrap();
8868            let outside = tempfile::tempdir().unwrap();
8869            std::fs::write(outside.path().join("secret.txt"), "secret").unwrap();
8870
8871            let tool = ReadTool::new(cwd.path());
8872            let err = tool
8873                .execute(
8874                    "t",
8875                    serde_json::json!({ "path": outside.path().join("secret.txt").to_string_lossy() }),
8876                    None,
8877                )
8878                .await
8879                .unwrap_err();
8880            assert!(err.to_string().contains("outside the working directory"));
8881        });
8882    }
8883
8884    /// Issue #71: skill files, prompt templates, and themes live under the
8885    /// agent dir (`~/.pi/agent/`, default). The agent legitimately needs to
8886    /// read these even when cwd is a user project on a different path.
8887    /// Ensure `enforce_read_scope_with_roots` accepts the agent dir as a
8888    /// second valid root without breaking the cwd-only contract for paths
8889    /// that are under neither.
8890    #[test]
8891    fn test_enforce_read_scope_allows_agent_dir_outside_cwd() {
8892        let cwd = tempfile::tempdir().unwrap();
8893        let agent_dir = tempfile::tempdir().unwrap();
8894        let skill_dir = agent_dir.path().join("skills").join("freebsd-jails");
8895        std::fs::create_dir_all(&skill_dir).unwrap();
8896        let skill_path = skill_dir.join("SKILL.md");
8897        std::fs::write(&skill_path, "---\nname: test\n---\n# body\n").unwrap();
8898
8899        let resolved =
8900            enforce_read_scope_with_roots(&skill_path, cwd.path(), agent_dir.path()).unwrap();
8901        assert!(
8902            resolved.starts_with(
8903                agent_dir
8904                    .path()
8905                    .canonicalize()
8906                    .unwrap_or_else(|_| agent_dir.path().to_path_buf())
8907            ),
8908            "agent-dir path must be allowed and returned canonicalised"
8909        );
8910    }
8911
8912    #[test]
8913    fn test_enforce_read_scope_still_rejects_unrelated_paths() {
8914        // Paths under neither cwd nor agent_dir must keep failing closed.
8915        let cwd = tempfile::tempdir().unwrap();
8916        let agent_dir = tempfile::tempdir().unwrap();
8917        let unrelated = tempfile::tempdir().unwrap();
8918        std::fs::write(unrelated.path().join("secret.txt"), "secret").unwrap();
8919        let secret_path = unrelated.path().join("secret.txt");
8920
8921        let err =
8922            enforce_read_scope_with_roots(&secret_path, cwd.path(), agent_dir.path()).unwrap_err();
8923        let msg = err.to_string();
8924        assert!(
8925            msg.contains("outside the working directory") && msg.contains("agent dir"),
8926            "error must mention both denied roots, got: {msg}"
8927        );
8928    }
8929
8930    #[test]
8931    fn test_enforce_read_scope_prefers_cwd_when_path_is_under_cwd() {
8932        // When a path is under cwd, we must not silently switch to agent-dir
8933        // resolution. This locks in the order of the prefix checks.
8934        let cwd = tempfile::tempdir().unwrap();
8935        let agent_dir = tempfile::tempdir().unwrap();
8936        std::fs::write(cwd.path().join("a.txt"), "in cwd").unwrap();
8937
8938        let resolved =
8939            enforce_read_scope_with_roots(&cwd.path().join("a.txt"), cwd.path(), agent_dir.path())
8940                .unwrap();
8941        assert!(
8942            resolved.starts_with(
8943                cwd.path()
8944                    .canonicalize()
8945                    .unwrap_or_else(|_| cwd.path().to_path_buf())
8946            )
8947        );
8948    }
8949
8950    #[test]
8951    fn test_read_empty_file() {
8952        asupersync::test_utils::run_test(|| async {
8953            let tmp = tempfile::tempdir().unwrap();
8954            std::fs::write(tmp.path().join("empty.txt"), "").unwrap();
8955
8956            let tool = ReadTool::new(tmp.path());
8957            let out = tool
8958                .execute(
8959                    "t",
8960                    serde_json::json!({ "path": tmp.path().join("empty.txt").to_string_lossy() }),
8961                    None,
8962                )
8963                .await
8964                .unwrap();
8965            let text = get_text(&out.content);
8966            assert_eq!(text, "");
8967            assert!(!out.is_error);
8968        });
8969    }
8970
8971    #[test]
8972    fn test_read_empty_file_positive_offset_errors() {
8973        asupersync::test_utils::run_test(|| async {
8974            let tmp = tempfile::tempdir().unwrap();
8975            std::fs::write(tmp.path().join("empty.txt"), "").unwrap();
8976
8977            let tool = ReadTool::new(tmp.path());
8978            let err = tool
8979                .execute(
8980                    "t",
8981                    serde_json::json!({
8982                        "path": tmp.path().join("empty.txt").to_string_lossy(),
8983                        "offset": 1
8984                    }),
8985                    None,
8986                )
8987                .await;
8988            assert!(err.is_err());
8989            let msg = err.unwrap_err().to_string();
8990            assert!(msg.contains("beyond end of file"));
8991        });
8992    }
8993
8994    #[test]
8995    fn test_read_rejects_zero_limit() {
8996        asupersync::test_utils::run_test(|| async {
8997            let tmp = tempfile::tempdir().unwrap();
8998            std::fs::write(tmp.path().join("lines.txt"), "a\nb\nc\n").unwrap();
8999
9000            let tool = ReadTool::new(tmp.path());
9001            let err = tool
9002                .execute(
9003                    "t",
9004                    serde_json::json!({
9005                        "path": tmp.path().join("lines.txt").to_string_lossy(),
9006                        "limit": 0
9007                    }),
9008                    None,
9009                )
9010                .await;
9011            assert!(err.is_err());
9012            assert!(
9013                err.unwrap_err()
9014                    .to_string()
9015                    .contains("`limit` must be greater than 0")
9016            );
9017        });
9018    }
9019
9020    #[test]
9021    fn test_read_offset_and_limit() {
9022        asupersync::test_utils::run_test(|| async {
9023            let tmp = tempfile::tempdir().unwrap();
9024            std::fs::write(
9025                tmp.path().join("lines.txt"),
9026                "L1\nL2\nL3\nL4\nL5\nL6\nL7\nL8\nL9\nL10",
9027            )
9028            .unwrap();
9029
9030            let tool = ReadTool::new(tmp.path());
9031            let out = tool
9032                .execute(
9033                    "t",
9034                    serde_json::json!({
9035                        "path": tmp.path().join("lines.txt").to_string_lossy(),
9036                        "offset": 3,
9037                        "limit": 2
9038                    }),
9039                    None,
9040                )
9041                .await
9042                .unwrap();
9043            let text = get_text(&out.content);
9044            assert!(text.contains("L3"));
9045            assert!(text.contains("L4"));
9046            assert!(!text.contains("L2"));
9047            assert!(!text.contains("L5"));
9048        });
9049    }
9050
9051    #[test]
9052    fn test_read_offset_and_limit_with_cr_only_line_endings() {
9053        asupersync::test_utils::run_test(|| async {
9054            let tmp = tempfile::tempdir().unwrap();
9055            std::fs::write(tmp.path().join("lines.txt"), b"L1\rL2\rL3\r").unwrap();
9056
9057            let tool = ReadTool::new(tmp.path());
9058            let out = tool
9059                .execute(
9060                    "t",
9061                    serde_json::json!({
9062                        "path": tmp.path().join("lines.txt").to_string_lossy(),
9063                        "offset": 2,
9064                        "limit": 1
9065                    }),
9066                    None,
9067                )
9068                .await
9069                .unwrap();
9070            let text = get_text(&out.content);
9071            assert!(text.contains("L2"));
9072            assert!(!text.contains("L1"));
9073            assert!(!text.contains("L3"));
9074            assert!(text.contains("offset=3"));
9075            assert!(!text.contains('\r'));
9076        });
9077    }
9078
9079    #[test]
9080    fn test_read_offset_and_limit_with_split_crlf_chunk_boundary() {
9081        asupersync::test_utils::run_test(|| async {
9082            let tmp = tempfile::tempdir().unwrap();
9083            let mut content = vec![b'x'; (64 * 1024) - 1];
9084            content.extend_from_slice(b"\r\nSECOND\r\nTHIRD");
9085            std::fs::write(tmp.path().join("lines.txt"), content).unwrap();
9086
9087            let tool = ReadTool::new(tmp.path());
9088            let out = tool
9089                .execute(
9090                    "t",
9091                    serde_json::json!({
9092                        "path": tmp.path().join("lines.txt").to_string_lossy(),
9093                        "offset": 2,
9094                        "limit": 1
9095                    }),
9096                    None,
9097                )
9098                .await
9099                .unwrap();
9100            let text = get_text(&out.content);
9101            assert!(text.contains("SECOND"));
9102            assert!(!text.contains("THIRD"));
9103            assert!(!text.contains("xxxx"));
9104            assert!(text.contains("offset=3"));
9105        });
9106    }
9107
9108    #[test]
9109    fn test_read_offset_beyond_eof() {
9110        asupersync::test_utils::run_test(|| async {
9111            let tmp = tempfile::tempdir().unwrap();
9112            std::fs::write(tmp.path().join("short.txt"), "a\nb").unwrap();
9113
9114            let tool = ReadTool::new(tmp.path());
9115            let err = tool
9116                .execute(
9117                    "t",
9118                    serde_json::json!({
9119                        "path": tmp.path().join("short.txt").to_string_lossy(),
9120                        "offset": 100
9121                    }),
9122                    None,
9123                )
9124                .await;
9125            assert!(err.is_err());
9126            let msg = err.unwrap_err().to_string();
9127            assert!(msg.contains("beyond end of file"));
9128        });
9129    }
9130
9131    #[test]
9132    fn test_map_normalized_with_trailing_whitespace() {
9133        // "A   \nB" -> "A\nB" (normalized strips trailing spaces)
9134        let content = "A   \nB";
9135        let normalized = build_normalized_content(content);
9136        assert_eq!(normalized, "A\nB");
9137
9138        // Find "A" (norm idx 0)
9139        let (start, len) = map_normalized_range_to_original(content, 0, 1);
9140        assert_eq!(start, 0);
9141        assert_eq!(len, 1);
9142        assert_eq!(&content[start..start + len], "A");
9143
9144        // Find "\n" (norm idx 1)
9145        let (start, len) = map_normalized_range_to_original(content, 1, 1);
9146        assert_eq!(start, 4);
9147        assert_eq!(len, 1);
9148        assert_eq!(&content[start..start + len], "\n");
9149
9150        // Find "B" (norm idx 2)
9151        let (start, len) = map_normalized_range_to_original(content, 2, 1);
9152        assert_eq!(start, 5);
9153        assert_eq!(len, 1);
9154        assert_eq!(&content[start..start + len], "B");
9155    }
9156
9157    #[test]
9158    fn test_read_binary_file_lossy() {
9159        asupersync::test_utils::run_test(|| async {
9160            let tmp = tempfile::tempdir().unwrap();
9161            let binary_data: Vec<u8> = (0..=255).collect();
9162            std::fs::write(tmp.path().join("binary.bin"), &binary_data).unwrap();
9163
9164            let tool = ReadTool::new(tmp.path());
9165            let out = tool
9166                .execute(
9167                    "t",
9168                    serde_json::json!({ "path": tmp.path().join("binary.bin").to_string_lossy() }),
9169                    None,
9170                )
9171                .await
9172                .unwrap();
9173            // Binary files are read as lossy UTF-8 with replacement characters
9174            let text = get_text(&out.content);
9175            assert!(!text.is_empty());
9176            assert!(!out.is_error);
9177        });
9178    }
9179
9180    #[test]
9181    fn test_read_image_detection() {
9182        asupersync::test_utils::run_test(|| async {
9183            let tmp = tempfile::tempdir().unwrap();
9184            // Minimal valid PNG header
9185            let png_header: Vec<u8> = vec![
9186                0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature
9187                0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, // IHDR chunk
9188                0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, // 1x1 pixel
9189                0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53,
9190                0xDE, // bit depth, color type, etc
9191                0x00, 0x00, 0x00, 0x0C, 0x49, 0x44, 0x41, 0x54, // IDAT chunk
9192                0x08, 0xD7, 0x63, 0xF8, 0xCF, 0xC0, 0x00, 0x00, // compressed data
9193                0x00, 0x02, 0x00, 0x01, 0xE2, 0x21, 0xBC, 0x33, // CRC
9194                0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, // IEND chunk
9195                0xAE, 0x42, 0x60, 0x82,
9196            ];
9197            std::fs::write(tmp.path().join("test.png"), &png_header).unwrap();
9198
9199            let tool = ReadTool::new(tmp.path());
9200            let out = tool
9201                .execute(
9202                    "t",
9203                    serde_json::json!({ "path": tmp.path().join("test.png").to_string_lossy() }),
9204                    None,
9205                )
9206                .await
9207                .unwrap();
9208
9209            // Should return an image content block
9210            let has_image = out
9211                .content
9212                .iter()
9213                .any(|b| matches!(b, ContentBlock::Image(_)));
9214            assert!(has_image, "expected image content block for PNG file");
9215        });
9216    }
9217
9218    #[cfg(feature = "image-resize")]
9219    #[test]
9220    fn test_read_resizes_large_source_image_before_api_limit_check() {
9221        asupersync::test_utils::run_test(|| async {
9222            use image::codecs::png::PngEncoder;
9223            use image::{ExtendedColorType, ImageEncoder, Rgb, RgbImage};
9224
9225            let tmp = tempfile::tempdir().unwrap();
9226            let image = RgbImage::from_fn(2600, 2600, |x, y| {
9227                let seed = x.wrapping_mul(1_973)
9228                    ^ y.wrapping_mul(9_277)
9229                    ^ x.rotate_left(7)
9230                    ^ y.rotate_left(13);
9231                Rgb([
9232                    u8::try_from(seed % 256).unwrap_or(0),
9233                    u8::try_from((seed >> 8) % 256).unwrap_or(0),
9234                    u8::try_from((seed >> 16) % 256).unwrap_or(0),
9235                ])
9236            });
9237
9238            let mut png_bytes = Vec::new();
9239            PngEncoder::new(&mut png_bytes)
9240                .write_image(
9241                    image.as_raw(),
9242                    image.width(),
9243                    image.height(),
9244                    ExtendedColorType::Rgb8,
9245                )
9246                .unwrap();
9247
9248            assert!(
9249                png_bytes.len() > IMAGE_MAX_BYTES,
9250                "fixture must exceed API image limit to exercise resize path"
9251            );
9252            assert!(
9253                png_bytes.len() < usize::try_from(READ_TOOL_MAX_BYTES).unwrap_or(usize::MAX),
9254                "fixture must stay within read-tool input bound"
9255            );
9256
9257            let image_path = tmp.path().join("large.png");
9258            std::fs::write(&image_path, &png_bytes).unwrap();
9259
9260            let tool = ReadTool::new(tmp.path());
9261            let out = tool
9262                .execute(
9263                    "t",
9264                    serde_json::json!({ "path": image_path.to_string_lossy() }),
9265                    None,
9266                )
9267                .await
9268                .unwrap();
9269
9270            assert!(!out.is_error, "resizable large images should succeed");
9271            assert!(
9272                out.content
9273                    .iter()
9274                    .any(|block| matches!(block, ContentBlock::Image(_))),
9275                "expected an image attachment after resizing"
9276            );
9277
9278            let text = get_text(&out.content);
9279            assert!(text.contains("Read image file"));
9280            assert!(
9281                text.contains("displayed at"),
9282                "expected resize note in read output, got: {text}"
9283            );
9284        });
9285    }
9286
9287    #[test]
9288    fn test_read_blocked_images() {
9289        asupersync::test_utils::run_test(|| async {
9290            let tmp = tempfile::tempdir().unwrap();
9291            let png_header: Vec<u8> =
9292                vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00];
9293            std::fs::write(tmp.path().join("test.png"), &png_header).unwrap();
9294
9295            let tool = ReadTool::with_settings(tmp.path(), false, true);
9296            let err = tool
9297                .execute(
9298                    "t",
9299                    serde_json::json!({ "path": tmp.path().join("test.png").to_string_lossy() }),
9300                    None,
9301                )
9302                .await;
9303            assert!(err.is_err());
9304            assert!(err.unwrap_err().to_string().contains("blocked"));
9305        });
9306    }
9307
9308    #[test]
9309    fn test_read_truncation_at_max_lines() {
9310        asupersync::test_utils::run_test(|| async {
9311            let tmp = tempfile::tempdir().unwrap();
9312            let content: String = (0..DEFAULT_MAX_LINES + 500)
9313                .map(|i| format!("line {i}"))
9314                .collect::<Vec<_>>()
9315                .join("\n");
9316            std::fs::write(tmp.path().join("big.txt"), &content).unwrap();
9317
9318            let tool = ReadTool::new(tmp.path());
9319            let out = tool
9320                .execute(
9321                    "t",
9322                    serde_json::json!({ "path": tmp.path().join("big.txt").to_string_lossy() }),
9323                    None,
9324                )
9325                .await
9326                .unwrap();
9327            // Should have truncation details
9328            assert!(out.details.is_some(), "expected truncation details");
9329            let text = get_text(&out.content);
9330            assert!(text.contains("offset="));
9331        });
9332    }
9333
9334    #[test]
9335    fn test_read_first_line_exceeds_max_bytes() {
9336        asupersync::test_utils::run_test(|| async {
9337            let tmp = tempfile::tempdir().unwrap();
9338            let long_line = "a".repeat(DEFAULT_MAX_BYTES + 128);
9339            std::fs::write(tmp.path().join("too_long.txt"), long_line).unwrap();
9340
9341            let tool = ReadTool::new(tmp.path());
9342            let out = tool
9343                .execute(
9344                    "t",
9345                    serde_json::json!({ "path": tmp.path().join("too_long.txt").to_string_lossy() }),
9346                    None,
9347                )
9348                .await
9349                .unwrap();
9350
9351            let text = get_text(&out.content);
9352            let expected_limit = format!("exceeds {} limit", format_size(DEFAULT_MAX_BYTES));
9353            assert!(
9354                text.contains(&expected_limit),
9355                "expected limit hint '{expected_limit}', got: {text}"
9356            );
9357            let details = out.details.expect("expected truncation details");
9358            assert_eq!(
9359                details
9360                    .get("truncation")
9361                    .and_then(|v| v.get("firstLineExceedsLimit"))
9362                    .and_then(serde_json::Value::as_bool),
9363                Some(true)
9364            );
9365        });
9366    }
9367
9368    #[test]
9369    fn test_read_unicode_content() {
9370        asupersync::test_utils::run_test(|| async {
9371            let tmp = tempfile::tempdir().unwrap();
9372            std::fs::write(tmp.path().join("uni.txt"), "Hello 你好 🌍\nLine 2 café").unwrap();
9373
9374            let tool = ReadTool::new(tmp.path());
9375            let out = tool
9376                .execute(
9377                    "t",
9378                    serde_json::json!({ "path": tmp.path().join("uni.txt").to_string_lossy() }),
9379                    None,
9380                )
9381                .await
9382                .unwrap();
9383            let text = get_text(&out.content);
9384            assert!(text.contains("你好"));
9385            assert!(text.contains("🌍"));
9386            assert!(text.contains("café"));
9387        });
9388    }
9389
9390    // ========================================================================
9391    // Write Tool Tests
9392    // ========================================================================
9393
9394    #[test]
9395    fn test_write_new_file() {
9396        asupersync::test_utils::run_test(|| async {
9397            let tmp = tempfile::tempdir().unwrap();
9398            let tool = WriteTool::new(tmp.path());
9399            let out = tool
9400                .execute(
9401                    "t",
9402                    serde_json::json!({
9403                        "path": tmp.path().join("new.txt").to_string_lossy(),
9404                        "content": "hello world"
9405                    }),
9406                    None,
9407                )
9408                .await
9409                .unwrap();
9410            assert!(!out.is_error);
9411            let contents = std::fs::read_to_string(tmp.path().join("new.txt")).unwrap();
9412            assert_eq!(contents, "hello world");
9413        });
9414    }
9415
9416    #[test]
9417    fn test_write_overwrite_existing() {
9418        asupersync::test_utils::run_test(|| async {
9419            let tmp = tempfile::tempdir().unwrap();
9420            std::fs::write(tmp.path().join("exist.txt"), "old content").unwrap();
9421
9422            let tool = WriteTool::new(tmp.path());
9423            let out = tool
9424                .execute(
9425                    "t",
9426                    serde_json::json!({
9427                        "path": tmp.path().join("exist.txt").to_string_lossy(),
9428                        "content": "new content"
9429                    }),
9430                    None,
9431                )
9432                .await
9433                .unwrap();
9434            assert!(!out.is_error);
9435            let contents = std::fs::read_to_string(tmp.path().join("exist.txt")).unwrap();
9436            assert_eq!(contents, "new content");
9437        });
9438    }
9439
9440    #[test]
9441    fn test_write_creates_parent_dirs() {
9442        asupersync::test_utils::run_test(|| async {
9443            let tmp = tempfile::tempdir().unwrap();
9444            let tool = WriteTool::new(tmp.path());
9445            let deep_path = tmp.path().join("a/b/c/deep.txt");
9446            let out = tool
9447                .execute(
9448                    "t",
9449                    serde_json::json!({
9450                        "path": deep_path.to_string_lossy(),
9451                        "content": "deep file"
9452                    }),
9453                    None,
9454                )
9455                .await
9456                .unwrap();
9457            assert!(!out.is_error);
9458            assert!(deep_path.exists());
9459            assert_eq!(std::fs::read_to_string(&deep_path).unwrap(), "deep file");
9460        });
9461    }
9462
9463    #[test]
9464    fn test_write_empty_file() {
9465        asupersync::test_utils::run_test(|| async {
9466            let tmp = tempfile::tempdir().unwrap();
9467            let tool = WriteTool::new(tmp.path());
9468            let out = tool
9469                .execute(
9470                    "t",
9471                    serde_json::json!({
9472                        "path": tmp.path().join("empty.txt").to_string_lossy(),
9473                        "content": ""
9474                    }),
9475                    None,
9476                )
9477                .await
9478                .unwrap();
9479            assert!(!out.is_error);
9480            let contents = std::fs::read_to_string(tmp.path().join("empty.txt")).unwrap();
9481            assert_eq!(contents, "");
9482            let text = get_text(&out.content);
9483            assert!(text.contains("Successfully wrote 0 bytes"));
9484        });
9485    }
9486
9487    #[test]
9488    fn test_write_rejects_outside_cwd() {
9489        asupersync::test_utils::run_test(|| async {
9490            let cwd = tempfile::tempdir().unwrap();
9491            let outside = tempfile::tempdir().unwrap();
9492            let tool = WriteTool::new(cwd.path());
9493            let err = tool
9494                .execute(
9495                    "t",
9496                    serde_json::json!({
9497                        "path": outside.path().join("escape.txt").to_string_lossy(),
9498                        "content": "nope"
9499                    }),
9500                    None,
9501                )
9502                .await
9503                .unwrap_err();
9504            assert!(err.to_string().contains("outside the working directory"));
9505
9506            let err = tool
9507                .execute(
9508                    "t",
9509                    serde_json::json!({
9510                        "path": "../escape.txt",
9511                        "content": "nope"
9512                    }),
9513                    None,
9514                )
9515                .await
9516                .unwrap_err();
9517            assert!(err.to_string().contains("outside the working directory"));
9518        });
9519    }
9520
9521    #[test]
9522    fn test_write_unicode_content() {
9523        asupersync::test_utils::run_test(|| async {
9524            let tmp = tempfile::tempdir().unwrap();
9525            let tool = WriteTool::new(tmp.path());
9526            let out = tool
9527                .execute(
9528                    "t",
9529                    serde_json::json!({
9530                        "path": tmp.path().join("unicode.txt").to_string_lossy(),
9531                        "content": "日本語 🎉 Ñoño"
9532                    }),
9533                    None,
9534                )
9535                .await
9536                .unwrap();
9537            assert!(!out.is_error);
9538            let contents = std::fs::read_to_string(tmp.path().join("unicode.txt")).unwrap();
9539            assert_eq!(contents, "日本語 🎉 Ñoño");
9540        });
9541    }
9542
9543    #[test]
9544    #[cfg(unix)]
9545    fn test_write_file_permissions_unix() {
9546        use std::os::unix::fs::PermissionsExt;
9547        asupersync::test_utils::run_test(|| async {
9548            let tmp = tempfile::tempdir().unwrap();
9549            let tool = WriteTool::new(tmp.path());
9550            let path = tmp.path().join("perms.txt");
9551            let out = tool
9552                .execute(
9553                    "t",
9554                    serde_json::json!({
9555                        "path": path.to_string_lossy(),
9556                        "content": "check perms"
9557                    }),
9558                    None,
9559                )
9560                .await
9561                .unwrap();
9562            assert!(!out.is_error);
9563
9564            let meta = std::fs::metadata(&path).unwrap();
9565            let mode = meta.permissions().mode();
9566            assert_eq!(
9567                mode & 0o777,
9568                0o644,
9569                "Expected default 0o644 permissions for new files"
9570            );
9571        });
9572    }
9573
9574    // ========================================================================
9575    // Edit Tool Tests
9576    // ========================================================================
9577
9578    #[test]
9579    fn test_edit_exact_match_replace() {
9580        asupersync::test_utils::run_test(|| async {
9581            let tmp = tempfile::tempdir().unwrap();
9582            std::fs::write(tmp.path().join("code.rs"), "fn foo() { bar() }").unwrap();
9583
9584            let tool = EditTool::new(tmp.path());
9585            let out = tool
9586                .execute(
9587                    "t",
9588                    serde_json::json!({
9589                        "path": tmp.path().join("code.rs").to_string_lossy(),
9590                        "oldText": "bar()",
9591                        "newText": "baz()"
9592                    }),
9593                    None,
9594                )
9595                .await
9596                .unwrap();
9597            assert!(!out.is_error);
9598            let contents = std::fs::read_to_string(tmp.path().join("code.rs")).unwrap();
9599            assert_eq!(contents, "fn foo() { baz() }");
9600        });
9601    }
9602
9603    #[test]
9604    fn test_edit_no_match_error() {
9605        asupersync::test_utils::run_test(|| async {
9606            let tmp = tempfile::tempdir().unwrap();
9607            std::fs::write(tmp.path().join("code.rs"), "fn foo() {}").unwrap();
9608
9609            let tool = EditTool::new(tmp.path());
9610            let err = tool
9611                .execute(
9612                    "t",
9613                    serde_json::json!({
9614                        "path": tmp.path().join("code.rs").to_string_lossy(),
9615                        "oldText": "NONEXISTENT TEXT",
9616                        "newText": "replacement"
9617                    }),
9618                    None,
9619                )
9620                .await;
9621            assert!(err.is_err());
9622        });
9623    }
9624
9625    #[test]
9626    fn test_edit_empty_old_text_error() {
9627        asupersync::test_utils::run_test(|| async {
9628            let tmp = tempfile::tempdir().unwrap();
9629            let path = tmp.path().join("code.rs");
9630            std::fs::write(&path, "fn foo() {}").unwrap();
9631
9632            let tool = EditTool::new(tmp.path());
9633            let err = tool
9634                .execute(
9635                    "t",
9636                    serde_json::json!({
9637                        "path": path.to_string_lossy(),
9638                        "oldText": "",
9639                        "newText": "prefix"
9640                    }),
9641                    None,
9642                )
9643                .await
9644                .expect_err("empty oldText should be rejected");
9645
9646            let msg = err.to_string();
9647            assert!(
9648                msg.contains("old text cannot be empty"),
9649                "unexpected error: {msg}"
9650            );
9651            let after = std::fs::read_to_string(path).unwrap();
9652            assert_eq!(after, "fn foo() {}");
9653        });
9654    }
9655
9656    #[test]
9657    fn test_edit_ambiguous_match_error() {
9658        asupersync::test_utils::run_test(|| async {
9659            let tmp = tempfile::tempdir().unwrap();
9660            std::fs::write(tmp.path().join("dup.txt"), "hello hello hello").unwrap();
9661
9662            let tool = EditTool::new(tmp.path());
9663            let err = tool
9664                .execute(
9665                    "t",
9666                    serde_json::json!({
9667                        "path": tmp.path().join("dup.txt").to_string_lossy(),
9668                        "oldText": "hello",
9669                        "newText": "world"
9670                    }),
9671                    None,
9672                )
9673                .await;
9674            assert!(err.is_err(), "expected error for ambiguous match");
9675        });
9676    }
9677
9678    #[test]
9679    fn test_edit_multi_line_replacement() {
9680        asupersync::test_utils::run_test(|| async {
9681            let tmp = tempfile::tempdir().unwrap();
9682            std::fs::write(
9683                tmp.path().join("multi.txt"),
9684                "line 1\nline 2\nline 3\nline 4",
9685            )
9686            .unwrap();
9687
9688            let tool = EditTool::new(tmp.path());
9689            let out = tool
9690                .execute(
9691                    "t",
9692                    serde_json::json!({
9693                        "path": tmp.path().join("multi.txt").to_string_lossy(),
9694                        "oldText": "line 2\nline 3",
9695                        "newText": "replaced 2\nreplaced 3\nextra line"
9696                    }),
9697                    None,
9698                )
9699                .await
9700                .unwrap();
9701            assert!(!out.is_error);
9702            let contents = std::fs::read_to_string(tmp.path().join("multi.txt")).unwrap();
9703            assert_eq!(
9704                contents,
9705                "line 1\nreplaced 2\nreplaced 3\nextra line\nline 4"
9706            );
9707        });
9708    }
9709
9710    #[test]
9711    fn test_edit_unicode_content() {
9712        asupersync::test_utils::run_test(|| async {
9713            let tmp = tempfile::tempdir().unwrap();
9714            std::fs::write(tmp.path().join("uni.txt"), "Héllo wörld 🌍").unwrap();
9715
9716            let tool = EditTool::new(tmp.path());
9717            let out = tool
9718                .execute(
9719                    "t",
9720                    serde_json::json!({
9721                        "path": tmp.path().join("uni.txt").to_string_lossy(),
9722                        "oldText": "wörld 🌍",
9723                        "newText": "Welt 🌎"
9724                    }),
9725                    None,
9726                )
9727                .await
9728                .unwrap();
9729            assert!(!out.is_error);
9730            let contents = std::fs::read_to_string(tmp.path().join("uni.txt")).unwrap();
9731            assert_eq!(contents, "Héllo Welt 🌎");
9732        });
9733    }
9734
9735    #[test]
9736    fn test_edit_missing_file() {
9737        asupersync::test_utils::run_test(|| async {
9738            let tmp = tempfile::tempdir().unwrap();
9739            let tool = EditTool::new(tmp.path());
9740            let err = tool
9741                .execute(
9742                    "t",
9743                    serde_json::json!({
9744                        "path": tmp.path().join("nope.txt").to_string_lossy(),
9745                        "oldText": "foo",
9746                        "newText": "bar"
9747                    }),
9748                    None,
9749                )
9750                .await;
9751            assert!(err.is_err());
9752        });
9753    }
9754
9755    // ========================================================================
9756    // Bash Tool Tests
9757    // ========================================================================
9758
9759    struct FailingReader {
9760        responses: std::collections::VecDeque<std::io::Result<Vec<u8>>>,
9761    }
9762
9763    impl FailingReader {
9764        fn new(responses: impl IntoIterator<Item = std::io::Result<Vec<u8>>>) -> Self {
9765            Self {
9766                responses: responses.into_iter().collect(),
9767            }
9768        }
9769    }
9770
9771    impl Read for FailingReader {
9772        fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
9773            match self.responses.pop_front().unwrap_or_else(|| Ok(Vec::new())) {
9774                Ok(bytes) => {
9775                    assert!(
9776                        bytes.len() <= buf.len(),
9777                        "test reader only supports single-chunk reads"
9778                    );
9779                    buf[..bytes.len()].copy_from_slice(&bytes);
9780                    Ok(bytes.len())
9781                }
9782                Err(err) => Err(err),
9783            }
9784        }
9785    }
9786
9787    #[test]
9788    fn test_bash_simple_command() {
9789        asupersync::test_utils::run_test(|| async {
9790            let tmp = tempfile::tempdir().unwrap();
9791            let tool = BashTool::new(tmp.path());
9792            let out = tool
9793                .execute(
9794                    "t",
9795                    serde_json::json!({ "command": "echo hello_from_bash" }),
9796                    None,
9797                )
9798                .await
9799                .unwrap();
9800            let text = get_text(&out.content);
9801            assert!(text.contains("hello_from_bash"));
9802            assert!(!out.is_error);
9803        });
9804    }
9805
9806    #[test]
9807    fn test_bash_exit_code_nonzero() {
9808        asupersync::test_utils::run_test(|| async {
9809            let tmp = tempfile::tempdir().unwrap();
9810            let tool = BashTool::new(tmp.path());
9811            let out = tool
9812                .execute("t", serde_json::json!({ "command": "exit 42" }), None)
9813                .await
9814                .expect("non-zero exit should return Ok with is_error=true");
9815            assert!(out.is_error, "non-zero exit must set is_error");
9816            let msg = get_text(&out.content);
9817            assert!(
9818                msg.contains("42"),
9819                "expected exit code 42 in output, got: {msg}"
9820            );
9821        });
9822    }
9823
9824    #[cfg(unix)]
9825    #[test]
9826    fn test_bash_signal_termination_is_error() {
9827        asupersync::test_utils::run_test(|| async {
9828            let tmp = tempfile::tempdir().unwrap();
9829            let tool = BashTool::new(tmp.path());
9830            let out = tool
9831                .execute("t", serde_json::json!({ "command": "kill -KILL $$" }), None)
9832                .await
9833                .expect("signal-terminated shell should return Ok with is_error=true");
9834            assert!(
9835                out.is_error,
9836                "signal-terminated shell must be reported as error"
9837            );
9838            let msg = get_text(&out.content);
9839            assert!(
9840                msg.contains("Command exited with code"),
9841                "expected explicit exit-code report, got: {msg}"
9842            );
9843            assert!(
9844                !msg.contains("Command exited with code 0"),
9845                "signal-terminated shell must not appear successful: {msg}"
9846            );
9847        });
9848    }
9849
9850    #[test]
9851    fn test_bash_stderr_capture() {
9852        asupersync::test_utils::run_test(|| async {
9853            let tmp = tempfile::tempdir().unwrap();
9854            let tool = BashTool::new(tmp.path());
9855            let out = tool
9856                .execute(
9857                    "t",
9858                    serde_json::json!({ "command": "echo stderr_msg >&2" }),
9859                    None,
9860                )
9861                .await
9862                .unwrap();
9863            let text = get_text(&out.content);
9864            assert!(
9865                text.contains("stderr_msg"),
9866                "expected stderr output in result, got: {text}"
9867            );
9868        });
9869    }
9870
9871    #[test]
9872    fn test_bash_timeout() {
9873        asupersync::test_utils::run_test(|| async {
9874            let tmp = tempfile::tempdir().unwrap();
9875            let tool = BashTool::new(tmp.path());
9876            let out = tool
9877                .execute(
9878                    "t",
9879                    serde_json::json!({ "command": "sleep 60", "timeout": 2 }),
9880                    None,
9881                )
9882                .await
9883                .expect("timeout should return Ok with is_error=true");
9884            assert!(out.is_error, "timeout must set is_error");
9885            let msg = get_text(&out.content);
9886            assert!(
9887                msg.to_lowercase().contains("timeout") || msg.to_lowercase().contains("timed out"),
9888                "expected timeout indication, got: {msg}"
9889            );
9890            let cancellation = out
9891                .details
9892                .as_ref()
9893                .and_then(|details| details.get("cancellation"))
9894                .expect("timeout should include structured cancellation details");
9895            assert_eq!(cancellation["schema"], BASH_CANCELLATION_SCHEMA_V1);
9896            assert_eq!(cancellation["status"], "cancelled");
9897            assert_eq!(cancellation["reason"], "timeout");
9898            assert_eq!(cancellation["cleanup"], "process_group_tree_terminated");
9899            assert_eq!(cancellation["timeoutMs"], 2000);
9900        });
9901    }
9902
9903    #[cfg(target_os = "linux")]
9904    #[test]
9905    fn test_bash_timeout_kills_process_tree() {
9906        asupersync::test_utils::run_test(|| async {
9907            let tmp = tempfile::tempdir().unwrap();
9908            let marker = tmp.path().join("leaked_child.txt");
9909            let tool = BashTool::new(tmp.path());
9910
9911            let out = tool
9912                .execute(
9913                    "t",
9914                    serde_json::json!({
9915                        "command": "(sleep 3; echo leaked > leaked_child.txt) & sleep 10",
9916                        "timeout": 1
9917                    }),
9918                    None,
9919                )
9920                .await
9921                .expect("timeout should return Ok with is_error=true");
9922
9923            assert!(out.is_error, "timeout must set is_error");
9924            let msg = get_text(&out.content);
9925            assert!(msg.contains("Command timed out"));
9926
9927            // If process tree cleanup fails, this file appears after ~3 seconds.
9928            std::thread::sleep(Duration::from_secs(4));
9929            assert!(
9930                !marker.exists(),
9931                "background child was not terminated on timeout"
9932            );
9933        });
9934    }
9935
9936    #[cfg(target_os = "linux")]
9937    #[test]
9938    fn test_bash_cancelled_context_kills_process_tree() {
9939        asupersync::test_utils::run_test(|| async {
9940            let tmp = tempfile::tempdir().unwrap();
9941            let marker = tmp.path().join("leaked_child.txt");
9942
9943            let ambient_cx = asupersync::Cx::for_testing();
9944            let cancel_cx = ambient_cx.clone();
9945            let _current = asupersync::Cx::set_current(Some(ambient_cx));
9946
9947            let cancel_thread = std::thread::spawn(move || {
9948                std::thread::sleep(Duration::from_millis(100));
9949                cancel_cx.set_cancel_requested(true);
9950            });
9951
9952            let result = run_bash_command(
9953                tmp.path(),
9954                None,
9955                None,
9956                "(sleep 3; echo leaked > leaked_child.txt) & sleep 10",
9957                Some(30),
9958                None,
9959            )
9960            .await
9961            .expect("cancelled bash should return a result");
9962
9963            cancel_thread.join().expect("cancel thread");
9964
9965            assert!(
9966                result.cancelled,
9967                "expected cancelled bash result: {result:?}"
9968            );
9969            assert_eq!(
9970                result.cancellation_reason,
9971                Some(BashCancellationReason::AmbientCancellation)
9972            );
9973
9974            std::thread::sleep(Duration::from_secs(4));
9975            assert!(
9976                !marker.exists(),
9977                "background child was not terminated on cancellation"
9978            );
9979        });
9980    }
9981
9982    #[test]
9983    fn test_bash_pump_stream_emits_io_error_frame_after_partial_output() {
9984        let reader = FailingReader::new([
9985            Ok(b"partial stdout".to_vec()),
9986            Err(std::io::Error::other("simulated stdout failure")),
9987        ]);
9988        let (tx, rx) = mpsc::sync_channel::<BashPipeFrame>(4);
9989
9990        pump_stream(reader, "stdout", &tx);
9991
9992        match rx.recv().expect("partial chunk") {
9993            BashPipeFrame::Chunk(chunk) => assert_eq!(chunk, b"partial stdout"),
9994            BashPipeFrame::Error(message) => {
9995                unreachable!("expected output chunk before error, got error frame: {message}")
9996            }
9997        }
9998
9999        match rx.recv().expect("io error frame") {
10000            BashPipeFrame::Chunk(chunk) => {
10001                unreachable!("expected io error after partial chunk, got chunk: {chunk:?}")
10002            }
10003            BashPipeFrame::Error(message) => {
10004                assert!(message.contains("Failed to read bash stdout"));
10005                assert!(message.contains("simulated stdout failure"));
10006            }
10007        }
10008
10009        assert!(matches!(rx.try_recv(), Err(mpsc::TryRecvError::Empty)));
10010    }
10011
10012    #[test]
10013    fn test_drain_bash_output_ignores_cancellation_after_process_exit() {
10014        asupersync::test_utils::run_test(|| async {
10015            let (tx, mut rx) = mpsc::sync_channel::<BashPipeFrame>(1);
10016            let mut bash_output = BashOutputState::new(DEFAULT_MAX_BYTES);
10017
10018            let ambient_cx = asupersync::Cx::for_testing();
10019            ambient_cx.set_cancel_requested(true);
10020            let _current = asupersync::Cx::set_current(Some(ambient_cx));
10021            let cx = AgentCx::for_current_or_request();
10022            let now = cx
10023                .cx()
10024                .timer_driver()
10025                .map_or_else(wall_now, |timer| timer.now());
10026
10027            let cancelled = drain_bash_output(
10028                &mut rx,
10029                &mut bash_output,
10030                &cx,
10031                now + std::time::Duration::from_millis(10),
10032                std::time::Duration::from_millis(1),
10033                false,
10034            )
10035            .await
10036            .expect("drain should complete without cancellation");
10037
10038            drop(tx);
10039
10040            assert!(
10041                !cancelled,
10042                "post-exit drain should ignore late ambient cancellation"
10043            );
10044            assert_eq!(bash_output.total_bytes, 0);
10045        });
10046    }
10047
10048    #[test]
10049    fn test_drain_bash_output_returns_pipe_read_error() {
10050        asupersync::test_utils::run_test(|| async {
10051            let (tx, mut rx) = mpsc::sync_channel::<BashPipeFrame>(2);
10052            tx.send(BashPipeFrame::Chunk(b"partial stderr".to_vec()))
10053                .expect("queue partial output");
10054            tx.send(BashPipeFrame::Error(
10055                "Failed to read bash stderr: simulated stderr failure".to_string(),
10056            ))
10057            .expect("queue error frame");
10058            drop(tx);
10059
10060            let mut bash_output = BashOutputState::new(DEFAULT_MAX_BYTES);
10061            let cx = AgentCx::for_current_or_request();
10062            let now = cx
10063                .cx()
10064                .timer_driver()
10065                .map_or_else(wall_now, |timer| timer.now());
10066
10067            let err = drain_bash_output(
10068                &mut rx,
10069                &mut bash_output,
10070                &cx,
10071                now + std::time::Duration::from_millis(10),
10072                std::time::Duration::from_millis(1),
10073                false,
10074            )
10075            .await
10076            .expect_err("pipe read failures must surface as errors");
10077
10078            let message = err.to_string();
10079            assert!(message.contains("Failed to read bash stderr"));
10080            assert!(message.contains("simulated stderr failure"));
10081            assert!(message.contains("Partial output before failure"));
10082            assert!(message.contains("partial stderr"));
10083            assert_eq!(bash_output.total_bytes, "partial stderr".len());
10084        });
10085    }
10086
10087    #[test]
10088    fn test_drain_bash_output_honors_cancellation_while_process_still_active() {
10089        asupersync::test_utils::run_test(|| async {
10090            let (_tx, mut rx) = mpsc::sync_channel::<BashPipeFrame>(1);
10091            let mut bash_output = BashOutputState::new(DEFAULT_MAX_BYTES);
10092
10093            let ambient_cx = asupersync::Cx::for_testing();
10094            ambient_cx.set_cancel_requested(true);
10095            let _current = asupersync::Cx::set_current(Some(ambient_cx));
10096            let cx = AgentCx::for_current_or_request();
10097            let now = cx
10098                .cx()
10099                .timer_driver()
10100                .map_or_else(wall_now, |timer| timer.now());
10101
10102            let cancelled = drain_bash_output(
10103                &mut rx,
10104                &mut bash_output,
10105                &cx,
10106                now + std::time::Duration::from_secs(1),
10107                std::time::Duration::from_millis(1),
10108                true,
10109            )
10110            .await
10111            .expect("drain should complete under cancellation");
10112
10113            assert!(
10114                cancelled,
10115                "active drain should still honor ambient cancellation"
10116            );
10117            assert_eq!(bash_output.total_bytes, 0);
10118        });
10119    }
10120
10121    #[test]
10122    fn test_bash_output_state_abandon_spill_file_clears_path_and_unlinks_file() {
10123        let tmp = tempfile::tempdir().unwrap();
10124        let spill_path = tmp.path().join("partial-bash.log");
10125        std::fs::write(&spill_path, b"partial output").unwrap();
10126
10127        let mut bash_output = BashOutputState::new(DEFAULT_MAX_BYTES);
10128        bash_output.temp_file_path = Some(spill_path.clone());
10129
10130        bash_output.abandon_spill_file();
10131
10132        assert!(bash_output.spill_failed);
10133        assert!(bash_output.temp_file.is_none());
10134        assert!(bash_output.temp_file_path.is_none());
10135        assert!(
10136            !spill_path.exists(),
10137            "abandoned spill files should not be advertised or left behind"
10138        );
10139    }
10140
10141    #[test]
10142    fn test_bash_hard_limit_retains_partial_spill_file() {
10143        asupersync::test_utils::run_test(|| async {
10144            let tmp = tempfile::tempdir().unwrap();
10145            let spill_path = tmp.path().join("hard-limit-bash.log");
10146            std::fs::write(&spill_path, b"partial output").unwrap();
10147
10148            let spill_file = asupersync::fs::OpenOptions::new()
10149                .append(true)
10150                .open(&spill_path)
10151                .await
10152                .unwrap();
10153
10154            let mut bash_output = BashOutputState::new(DEFAULT_MAX_BYTES);
10155            bash_output.total_bytes = BASH_FILE_LIMIT_BYTES;
10156            bash_output.temp_file_path = Some(spill_path.clone());
10157            bash_output.temp_file = Some(spill_file);
10158
10159            ingest_bash_chunk(vec![b'x'], &mut bash_output)
10160                .await
10161                .expect("hard-limit ingestion should still succeed");
10162
10163            assert!(!bash_output.spill_failed);
10164            assert!(bash_output.temp_file.is_none());
10165            assert!(bash_output.temp_file_path.is_some());
10166            assert!(
10167                spill_path.exists(),
10168                "partial spill files must be retained once the hard limit is reached for diagnostics"
10169            );
10170        });
10171    }
10172
10173    #[test]
10174    #[cfg(unix)]
10175    fn test_bash_working_directory() {
10176        asupersync::test_utils::run_test(|| async {
10177            let tmp = tempfile::tempdir().unwrap();
10178            let tool = BashTool::new(tmp.path());
10179            let out = tool
10180                .execute("t", serde_json::json!({ "command": "pwd" }), None)
10181                .await
10182                .unwrap();
10183            let text = get_text(&out.content);
10184            let canonical = tmp.path().canonicalize().unwrap();
10185            assert!(
10186                text.contains(&canonical.to_string_lossy().to_string()),
10187                "expected cwd in output, got: {text}"
10188            );
10189        });
10190    }
10191
10192    #[test]
10193    fn test_bash_multiline_output() {
10194        asupersync::test_utils::run_test(|| async {
10195            let tmp = tempfile::tempdir().unwrap();
10196            let tool = BashTool::new(tmp.path());
10197            let out = tool
10198                .execute(
10199                    "t",
10200                    serde_json::json!({ "command": "echo line1; echo line2; echo line3" }),
10201                    None,
10202                )
10203                .await
10204                .unwrap();
10205            let text = get_text(&out.content);
10206            assert!(text.contains("line1"));
10207            assert!(text.contains("line2"));
10208            assert!(text.contains("line3"));
10209        });
10210    }
10211
10212    // ========================================================================
10213    // Grep Tool Tests
10214    // ========================================================================
10215
10216    #[test]
10217    fn test_grep_basic_pattern() {
10218        asupersync::test_utils::run_test(|| async {
10219            let tmp = tempfile::tempdir().unwrap();
10220            std::fs::write(
10221                tmp.path().join("search.txt"),
10222                "apple\nbanana\napricot\ncherry",
10223            )
10224            .unwrap();
10225
10226            let tool = GrepTool::new(tmp.path());
10227            let out = tool
10228                .execute(
10229                    "t",
10230                    serde_json::json!({
10231                        "pattern": "ap",
10232                        "path": tmp.path().join("search.txt").to_string_lossy()
10233                    }),
10234                    None,
10235                )
10236                .await
10237                .unwrap();
10238            let text = get_text(&out.content);
10239            assert!(text.contains("apple"));
10240            assert!(text.contains("apricot"));
10241            assert!(!text.contains("banana"));
10242            assert!(!text.contains("cherry"));
10243        });
10244    }
10245
10246    #[test]
10247    fn test_grep_rejects_outside_cwd() {
10248        asupersync::test_utils::run_test(|| async {
10249            let cwd = tempfile::tempdir().unwrap();
10250            let outside = tempfile::tempdir().unwrap();
10251            std::fs::write(outside.path().join("secret.txt"), "secret").unwrap();
10252
10253            let tool = GrepTool::new(cwd.path());
10254            let err = tool
10255                .execute(
10256                    "t",
10257                    serde_json::json!({
10258                        "pattern": "secret",
10259                        "path": outside.path().join("secret.txt").to_string_lossy()
10260                    }),
10261                    None,
10262                )
10263                .await
10264                .unwrap_err();
10265            assert!(err.to_string().contains("outside the working directory"));
10266        });
10267    }
10268
10269    #[test]
10270    fn test_grep_rejects_zero_limit() {
10271        asupersync::test_utils::run_test(|| async {
10272            let tmp = tempfile::tempdir().unwrap();
10273            std::fs::write(tmp.path().join("search.txt"), "alpha\nbeta\n").unwrap();
10274
10275            let tool = GrepTool::new(tmp.path());
10276            let err = tool
10277                .execute(
10278                    "t",
10279                    serde_json::json!({
10280                        "pattern": "alpha",
10281                        "path": tmp.path().join("search.txt").to_string_lossy(),
10282                        "limit": 0
10283                    }),
10284                    None,
10285                )
10286                .await
10287                .unwrap_err();
10288            assert!(err.to_string().contains("`limit` must be greater than 0"));
10289        });
10290    }
10291
10292    #[test]
10293    #[cfg(unix)]
10294    fn test_grep_formats_paths_relative_to_symlinked_cwd() {
10295        asupersync::test_utils::run_test(|| async {
10296            let real = tempfile::tempdir().unwrap();
10297            let link_parent = tempfile::tempdir().unwrap();
10298            let link = link_parent.path().join("linked-cwd");
10299            std::os::unix::fs::symlink(real.path(), &link).unwrap();
10300            std::fs::write(real.path().join("needle.txt"), "needle\n").unwrap();
10301
10302            let tool = GrepTool::new(&link);
10303            let out = tool
10304                .execute("t", serde_json::json!({ "pattern": "needle" }), None)
10305                .await
10306                .unwrap();
10307
10308            let text = get_text(&out.content);
10309            assert!(
10310                text.contains("needle.txt:1: needle"),
10311                "grep output should use cwd-relative paths for symlinked cwd, got: {text}"
10312            );
10313            assert!(
10314                !text.contains(real.path().to_string_lossy().as_ref()),
10315                "grep output should not leak canonical temp root, got: {text}"
10316            );
10317        });
10318    }
10319
10320    #[test]
10321    fn test_grep_regex_pattern() {
10322        asupersync::test_utils::run_test(|| async {
10323            let tmp = tempfile::tempdir().unwrap();
10324            std::fs::write(
10325                tmp.path().join("regex.txt"),
10326                "foo123\nbar456\nbaz789\nfoo000",
10327            )
10328            .unwrap();
10329
10330            let tool = GrepTool::new(tmp.path());
10331            let out = tool
10332                .execute(
10333                    "t",
10334                    serde_json::json!({
10335                        "pattern": "foo\\d+",
10336                        "path": tmp.path().join("regex.txt").to_string_lossy()
10337                    }),
10338                    None,
10339                )
10340                .await
10341                .unwrap();
10342            let text = get_text(&out.content);
10343            assert!(text.contains("foo123"));
10344            assert!(text.contains("foo000"));
10345            assert!(!text.contains("bar456"));
10346        });
10347    }
10348
10349    #[test]
10350    fn test_grep_case_insensitive() {
10351        asupersync::test_utils::run_test(|| async {
10352            let tmp = tempfile::tempdir().unwrap();
10353            std::fs::write(tmp.path().join("case.txt"), "Hello\nhello\nHELLO").unwrap();
10354
10355            let tool = GrepTool::new(tmp.path());
10356            let out = tool
10357                .execute(
10358                    "t",
10359                    serde_json::json!({
10360                        "pattern": "hello",
10361                        "path": tmp.path().join("case.txt").to_string_lossy(),
10362                        "ignoreCase": true
10363                    }),
10364                    None,
10365                )
10366                .await
10367                .unwrap();
10368            let text = get_text(&out.content);
10369            assert!(text.contains("Hello"));
10370            assert!(text.contains("hello"));
10371            assert!(text.contains("HELLO"));
10372        });
10373    }
10374
10375    #[test]
10376    fn test_grep_case_sensitive_by_default() {
10377        asupersync::test_utils::run_test(|| async {
10378            let tmp = tempfile::tempdir().unwrap();
10379            std::fs::write(tmp.path().join("case_sensitive.txt"), "Hello\nHELLO").unwrap();
10380
10381            let tool = GrepTool::new(tmp.path());
10382            let out = tool
10383                .execute(
10384                    "t",
10385                    serde_json::json!({
10386                        "pattern": "hello",
10387                        "path": tmp.path().join("case_sensitive.txt").to_string_lossy()
10388                    }),
10389                    None,
10390                )
10391                .await
10392                .unwrap();
10393            let text = get_text(&out.content);
10394            assert!(
10395                text.contains("No matches found"),
10396                "expected case-sensitive search to find no matches, got: {text}"
10397            );
10398        });
10399    }
10400
10401    #[test]
10402    fn test_grep_append_non_matching_lines_invariant() {
10403        asupersync::test_utils::run_test(|| async {
10404            let tmp = tempfile::tempdir().unwrap();
10405            let file = tmp.path().join("base.txt");
10406            std::fs::write(&file, "needle one\nskip\nneedle two\n").unwrap();
10407
10408            let tool = GrepTool::new(tmp.path());
10409            let base_out = tool
10410                .execute(
10411                    "t",
10412                    serde_json::json!({
10413                        "pattern": "needle",
10414                        "path": file.to_string_lossy(),
10415                        "limit": 100
10416                    }),
10417                    None,
10418                )
10419                .await
10420                .unwrap();
10421            let base_text = get_text(&base_out.content);
10422
10423            std::fs::write(&file, "needle one\nskip\nneedle two\nalpha\nbeta\n").unwrap();
10424            let extended_out = tool
10425                .execute(
10426                    "t",
10427                    serde_json::json!({
10428                        "pattern": "needle",
10429                        "path": file.to_string_lossy(),
10430                        "limit": 100
10431                    }),
10432                    None,
10433                )
10434                .await
10435                .unwrap();
10436            let extended_text = get_text(&extended_out.content);
10437
10438            assert_eq!(
10439                base_text, extended_text,
10440                "adding non-matching lines should not alter grep output"
10441            );
10442        });
10443    }
10444
10445    #[test]
10446    fn test_grep_no_matches() {
10447        asupersync::test_utils::run_test(|| async {
10448            let tmp = tempfile::tempdir().unwrap();
10449            std::fs::write(tmp.path().join("nothing.txt"), "alpha\nbeta\ngamma").unwrap();
10450
10451            let tool = GrepTool::new(tmp.path());
10452            let out = tool
10453                .execute(
10454                    "t",
10455                    serde_json::json!({
10456                        "pattern": "ZZZZZ_NOMATCH",
10457                        "path": tmp.path().join("nothing.txt").to_string_lossy()
10458                    }),
10459                    None,
10460                )
10461                .await
10462                .unwrap();
10463            let text = get_text(&out.content);
10464            assert!(
10465                text.to_lowercase().contains("no match")
10466                    || text.is_empty()
10467                    || text.to_lowercase().contains("no results"),
10468                "expected no-match indication, got: {text}"
10469            );
10470        });
10471    }
10472
10473    #[test]
10474    fn test_grep_context_lines() {
10475        asupersync::test_utils::run_test(|| async {
10476            let tmp = tempfile::tempdir().unwrap();
10477            std::fs::write(
10478                tmp.path().join("ctx.txt"),
10479                "aaa\nbbb\nccc\ntarget\nddd\neee\nfff",
10480            )
10481            .unwrap();
10482
10483            let tool = GrepTool::new(tmp.path());
10484            let out = tool
10485                .execute(
10486                    "t",
10487                    serde_json::json!({
10488                        "pattern": "target",
10489                        "path": tmp.path().join("ctx.txt").to_string_lossy(),
10490                        "context": 1
10491                    }),
10492                    None,
10493                )
10494                .await
10495                .unwrap();
10496            let text = get_text(&out.content);
10497            assert!(text.contains("target"));
10498            assert!(text.contains("ccc"), "expected context line before match");
10499            assert!(text.contains("ddd"), "expected context line after match");
10500        });
10501    }
10502
10503    #[test]
10504    fn test_grep_limit() {
10505        asupersync::test_utils::run_test(|| async {
10506            let tmp = tempfile::tempdir().unwrap();
10507            let content: String = (0..200)
10508                .map(|i| format!("match_line_{i}"))
10509                .collect::<Vec<_>>()
10510                .join("\n");
10511            std::fs::write(tmp.path().join("many.txt"), &content).unwrap();
10512
10513            let tool = GrepTool::new(tmp.path());
10514            let out = tool
10515                .execute(
10516                    "t",
10517                    serde_json::json!({
10518                        "pattern": "match_line",
10519                        "path": tmp.path().join("many.txt").to_string_lossy(),
10520                        "limit": 5
10521                    }),
10522                    None,
10523                )
10524                .await
10525                .unwrap();
10526            let text = get_text(&out.content);
10527            // With limit=5, we should see at most 5 matches
10528            let match_count = text.matches("match_line_").count();
10529            assert!(
10530                match_count <= 5,
10531                "expected at most 5 matches with limit=5, got {match_count}"
10532            );
10533            let details = out.details.expect("expected limit details");
10534            assert_eq!(
10535                details
10536                    .get("matchLimitReached")
10537                    .and_then(serde_json::Value::as_u64),
10538                Some(5)
10539            );
10540        });
10541    }
10542
10543    #[test]
10544    fn test_grep_exact_limit_does_not_report_limit_reached() {
10545        asupersync::test_utils::run_test(|| async {
10546            let tmp = tempfile::tempdir().unwrap();
10547            let content = (0..5)
10548                .map(|i| format!("match_line_{i}"))
10549                .collect::<Vec<_>>()
10550                .join("\n");
10551            std::fs::write(tmp.path().join("exact.txt"), &content).unwrap();
10552
10553            let tool = GrepTool::new(tmp.path());
10554            let out = tool
10555                .execute(
10556                    "t",
10557                    serde_json::json!({
10558                        "pattern": "match_line",
10559                        "path": tmp.path().join("exact.txt").to_string_lossy(),
10560                        "limit": 5
10561                    }),
10562                    None,
10563                )
10564                .await
10565                .unwrap();
10566
10567            let text = get_text(&out.content);
10568            assert_eq!(text.matches("match_line_").count(), 5);
10569            assert!(
10570                !text.contains("matches limit reached"),
10571                "exact-limit grep results should not claim truncation: {text}"
10572            );
10573            assert!(
10574                out.details
10575                    .as_ref()
10576                    .and_then(|details| details.get("matchLimitReached"))
10577                    .is_none(),
10578                "exact-limit grep results should not set matchLimitReached"
10579            );
10580        });
10581    }
10582
10583    #[test]
10584    fn test_grep_large_output_does_not_deadlock_reader_threads() {
10585        asupersync::test_utils::run_test(|| async {
10586            use std::fmt::Write as _;
10587
10588            let tmp = tempfile::tempdir().unwrap();
10589            let mut content = String::with_capacity(80_000);
10590            for i in 0..5000 {
10591                let _ = writeln!(&mut content, "needle_line_{i}");
10592            }
10593            let file = tmp.path().join("large_grep.txt");
10594            std::fs::write(&file, content).unwrap();
10595
10596            let tool = GrepTool::new(tmp.path());
10597            let run = tool.execute(
10598                "t",
10599                serde_json::json!({
10600                    "pattern": "needle_line_",
10601                    "path": file.to_string_lossy(),
10602                    "limit": 6000
10603                }),
10604                None,
10605            );
10606
10607            let out = asupersync::time::timeout(
10608                asupersync::time::wall_now(),
10609                Duration::from_secs(15),
10610                Box::pin(run),
10611            )
10612            .await
10613            .expect("grep timed out; possible stdout/stderr reader deadlock")
10614            .expect("grep should succeed");
10615
10616            let text = get_text(&out.content);
10617            assert!(text.contains("needle_line_0"));
10618        });
10619    }
10620
10621    #[test]
10622    fn test_grep_respects_gitignore() {
10623        asupersync::test_utils::run_test(|| async {
10624            let tmp = tempfile::tempdir().unwrap();
10625            std::fs::write(tmp.path().join(".gitignore"), "ignored.txt\n").unwrap();
10626            std::fs::write(tmp.path().join("ignored.txt"), "needle in ignored file").unwrap();
10627            std::fs::write(tmp.path().join("visible.txt"), "nothing here").unwrap();
10628
10629            let tool = GrepTool::new(tmp.path());
10630            let out = tool
10631                .execute("t", serde_json::json!({ "pattern": "needle" }), None)
10632                .await
10633                .unwrap();
10634
10635            let text = get_text(&out.content);
10636            assert!(
10637                text.contains("No matches found"),
10638                "expected ignored file to be excluded, got: {text}"
10639            );
10640        });
10641    }
10642
10643    #[test]
10644    fn test_grep_literal_mode() {
10645        asupersync::test_utils::run_test(|| async {
10646            let tmp = tempfile::tempdir().unwrap();
10647            std::fs::write(tmp.path().join("literal.txt"), "a+b\na.b\nab\na\\+b").unwrap();
10648
10649            let tool = GrepTool::new(tmp.path());
10650            let out = tool
10651                .execute(
10652                    "t",
10653                    serde_json::json!({
10654                        "pattern": "a+b",
10655                        "path": tmp.path().join("literal.txt").to_string_lossy(),
10656                        "literal": true
10657                    }),
10658                    None,
10659                )
10660                .await
10661                .unwrap();
10662            let text = get_text(&out.content);
10663            assert!(text.contains("a+b"), "literal match should find 'a+b'");
10664        });
10665    }
10666
10667    #[test]
10668    fn test_grep_hashline_output() {
10669        asupersync::test_utils::run_test(|| async {
10670            let tmp = tempfile::tempdir().unwrap();
10671            std::fs::write(
10672                tmp.path().join("hash.txt"),
10673                "apple\nbanana\napricot\ncherry",
10674            )
10675            .unwrap();
10676
10677            let tool = GrepTool::new(tmp.path());
10678            let out = tool
10679                .execute(
10680                    "t",
10681                    serde_json::json!({
10682                        "pattern": "ap",
10683                        "path": tmp.path().join("hash.txt").to_string_lossy(),
10684                        "hashline": true
10685                    }),
10686                    None,
10687                )
10688                .await
10689                .unwrap();
10690            let text = get_text(&out.content);
10691            // Hashline output should contain N#AB tags instead of bare line numbers
10692            // Line 1 (apple) and line 3 (apricot) should match
10693            assert!(text.contains("apple"), "should contain apple");
10694            assert!(text.contains("apricot"), "should contain apricot");
10695            assert!(
10696                !text.contains("banana"),
10697                "should not contain banana context"
10698            );
10699            // Verify hashline tag format: digit(s) followed by # and two uppercase letters
10700            let re = regex::Regex::new(r"\d+#[A-Z]{2}").unwrap();
10701            assert!(
10702                re.is_match(&text),
10703                "hashline output should contain N#AB tags, got: {text}"
10704            );
10705        });
10706    }
10707
10708    #[test]
10709    fn test_grep_hashline_with_context() {
10710        asupersync::test_utils::run_test(|| async {
10711            let tmp = tempfile::tempdir().unwrap();
10712            std::fs::write(
10713                tmp.path().join("ctx.txt"),
10714                "line1\nline2\ntarget\nline4\nline5",
10715            )
10716            .unwrap();
10717
10718            let tool = GrepTool::new(tmp.path());
10719            let out = tool
10720                .execute(
10721                    "t",
10722                    serde_json::json!({
10723                        "pattern": "target",
10724                        "path": tmp.path().join("ctx.txt").to_string_lossy(),
10725                        "hashline": true,
10726                        "context": 1
10727                    }),
10728                    None,
10729                )
10730                .await
10731                .unwrap();
10732            let text = get_text(&out.content);
10733            // With context=1, should include line2, target, line4
10734            assert!(text.contains("line2"), "should contain context line2");
10735            assert!(text.contains("target"), "should contain match");
10736            assert!(text.contains("line4"), "should contain context line4");
10737            // Match lines use `:` separator, context lines use `-`
10738            let re_match = regex::Regex::new(r"\d+#[A-Z]{2}: target").unwrap();
10739            assert!(
10740                re_match.is_match(&text),
10741                "match line should use : separator with hashline tag, got: {text}"
10742            );
10743            let re_ctx = regex::Regex::new(r"\d+#[A-Z]{2}- line").unwrap();
10744            assert!(
10745                re_ctx.is_match(&text),
10746                "context line should use - separator with hashline tag, got: {text}"
10747            );
10748        });
10749    }
10750
10751    // ========================================================================
10752    // Find Tool Tests
10753    // ========================================================================
10754
10755    #[test]
10756    fn test_find_glob_pattern() {
10757        asupersync::test_utils::run_test(|| async {
10758            if find_fd_binary().is_none() {
10759                return;
10760            }
10761            let tmp = tempfile::tempdir().unwrap();
10762            std::fs::write(tmp.path().join("file1.rs"), "").unwrap();
10763            std::fs::write(tmp.path().join("file2.rs"), "").unwrap();
10764            std::fs::write(tmp.path().join("file3.txt"), "").unwrap();
10765
10766            let tool = FindTool::new(tmp.path());
10767            let out = tool
10768                .execute(
10769                    "t",
10770                    serde_json::json!({
10771                        "pattern": "*.rs",
10772                        "path": tmp.path().to_string_lossy()
10773                    }),
10774                    None,
10775                )
10776                .await
10777                .unwrap();
10778            let text = get_text(&out.content);
10779            assert!(text.contains("file1.rs"));
10780            assert!(text.contains("file2.rs"));
10781            assert!(!text.contains("file3.txt"));
10782        });
10783    }
10784
10785    #[test]
10786    fn test_find_append_non_matching_file_invariant() {
10787        asupersync::test_utils::run_test(|| async {
10788            if find_fd_binary().is_none() {
10789                return;
10790            }
10791            let tmp = tempfile::tempdir().unwrap();
10792            std::fs::write(tmp.path().join("match.txt"), "a").unwrap();
10793
10794            let tool = FindTool::new(tmp.path());
10795            let base_out = tool
10796                .execute(
10797                    "t",
10798                    serde_json::json!({
10799                        "pattern": "*.txt",
10800                        "path": tmp.path().to_string_lossy()
10801                    }),
10802                    None,
10803                )
10804                .await
10805                .unwrap();
10806            let base_text = get_text(&base_out.content);
10807
10808            std::fs::write(tmp.path().join("ignore.md"), "b").unwrap();
10809            let extended_out = tool
10810                .execute(
10811                    "t",
10812                    serde_json::json!({
10813                        "pattern": "*.txt",
10814                        "path": tmp.path().to_string_lossy()
10815                    }),
10816                    None,
10817                )
10818                .await
10819                .unwrap();
10820            let extended_text = get_text(&extended_out.content);
10821
10822            assert_eq!(
10823                base_text, extended_text,
10824                "adding non-matching files should not alter find output"
10825            );
10826        });
10827    }
10828
10829    #[test]
10830    fn test_find_rejects_outside_cwd() {
10831        asupersync::test_utils::run_test(|| async {
10832            let cwd = tempfile::tempdir().unwrap();
10833            let outside = tempfile::tempdir().unwrap();
10834            std::fs::write(outside.path().join("secret.txt"), "secret").unwrap();
10835
10836            let tool = FindTool::new(cwd.path());
10837            let err = tool
10838                .execute(
10839                    "t",
10840                    serde_json::json!({
10841                        "pattern": "*.txt",
10842                        "path": outside.path().to_string_lossy()
10843                    }),
10844                    None,
10845                )
10846                .await
10847                .unwrap_err();
10848            assert!(err.to_string().contains("outside the working directory"));
10849        });
10850    }
10851
10852    #[test]
10853    fn test_find_limit() {
10854        asupersync::test_utils::run_test(|| async {
10855            if find_fd_binary().is_none() {
10856                return;
10857            }
10858            let tmp = tempfile::tempdir().unwrap();
10859            for i in 0..20 {
10860                std::fs::write(tmp.path().join(format!("f{i}.txt")), "").unwrap();
10861            }
10862
10863            let tool = FindTool::new(tmp.path());
10864            let out = tool
10865                .execute(
10866                    "t",
10867                    serde_json::json!({
10868                        "pattern": "*.txt",
10869                        "path": tmp.path().to_string_lossy(),
10870                        "limit": 5
10871                    }),
10872                    None,
10873                )
10874                .await
10875                .unwrap();
10876            let text = get_text(&out.content);
10877            let file_count = text.lines().filter(|l| l.contains(".txt")).count();
10878            assert!(
10879                file_count <= 5,
10880                "expected at most 5 files with limit=5, got {file_count}"
10881            );
10882            let details = out.details.expect("expected limit details");
10883            assert_eq!(
10884                details
10885                    .get("resultLimitReached")
10886                    .and_then(serde_json::Value::as_u64),
10887                Some(5)
10888            );
10889        });
10890    }
10891
10892    #[test]
10893    fn test_find_exact_limit_does_not_report_limit_reached() {
10894        asupersync::test_utils::run_test(|| async {
10895            if find_fd_binary().is_none() {
10896                return;
10897            }
10898            let tmp = tempfile::tempdir().unwrap();
10899            for i in 0..5 {
10900                std::fs::write(tmp.path().join(format!("f{i}.txt")), "").unwrap();
10901            }
10902
10903            let tool = FindTool::new(tmp.path());
10904            let out = tool
10905                .execute(
10906                    "t",
10907                    serde_json::json!({
10908                        "pattern": "*.txt",
10909                        "path": tmp.path().to_string_lossy(),
10910                        "limit": 5
10911                    }),
10912                    None,
10913                )
10914                .await
10915                .unwrap();
10916
10917            let text = get_text(&out.content);
10918            assert_eq!(text.lines().filter(|line| line.contains(".txt")).count(), 5);
10919            assert!(
10920                !text.contains("results limit reached"),
10921                "exact-limit find results should not claim truncation: {text}"
10922            );
10923            assert!(
10924                out.details
10925                    .as_ref()
10926                    .and_then(|details| details.get("resultLimitReached"))
10927                    .is_none(),
10928                "exact-limit find results should not set resultLimitReached"
10929            );
10930        });
10931    }
10932
10933    #[test]
10934    fn test_find_zero_limit_is_rejected() {
10935        asupersync::test_utils::run_test(|| async {
10936            if find_fd_binary().is_none() {
10937                return;
10938            }
10939            let tmp = tempfile::tempdir().unwrap();
10940            std::fs::write(tmp.path().join("file.txt"), "").unwrap();
10941
10942            let tool = FindTool::new(tmp.path());
10943            let err = tool
10944                .execute(
10945                    "t",
10946                    serde_json::json!({
10947                        "pattern": "*.txt",
10948                        "path": tmp.path().to_string_lossy(),
10949                        "limit": 0
10950                    }),
10951                    None,
10952                )
10953                .await
10954                .expect_err("limit=0 should be rejected");
10955
10956            assert!(
10957                err.to_string().contains("`limit` must be greater than 0"),
10958                "expected validation error, got: {err}"
10959            );
10960        });
10961    }
10962
10963    #[test]
10964    fn test_find_no_matches() {
10965        asupersync::test_utils::run_test(|| async {
10966            if find_fd_binary().is_none() {
10967                return;
10968            }
10969            let tmp = tempfile::tempdir().unwrap();
10970            std::fs::write(tmp.path().join("only.txt"), "").unwrap();
10971
10972            let tool = FindTool::new(tmp.path());
10973            let out = tool
10974                .execute(
10975                    "t",
10976                    serde_json::json!({
10977                        "pattern": "*.rs",
10978                        "path": tmp.path().to_string_lossy()
10979                    }),
10980                    None,
10981                )
10982                .await
10983                .unwrap();
10984            let text = get_text(&out.content);
10985            assert!(
10986                text.to_lowercase().contains("no files found")
10987                    || text.to_lowercase().contains("no matches")
10988                    || text.is_empty(),
10989                "expected no-match indication, got: {text}"
10990            );
10991        });
10992    }
10993
10994    #[test]
10995    fn test_find_nonexistent_path() {
10996        asupersync::test_utils::run_test(|| async {
10997            if find_fd_binary().is_none() {
10998                return;
10999            }
11000            let tmp = tempfile::tempdir().unwrap();
11001            let tool = FindTool::new(tmp.path());
11002            let err = tool
11003                .execute(
11004                    "t",
11005                    serde_json::json!({
11006                        "pattern": "*.rs",
11007                        "path": tmp.path().join("nonexistent").to_string_lossy()
11008                    }),
11009                    None,
11010                )
11011                .await;
11012            assert!(err.is_err());
11013        });
11014    }
11015
11016    #[test]
11017    fn test_find_nested_directories() {
11018        asupersync::test_utils::run_test(|| async {
11019            if find_fd_binary().is_none() {
11020                return;
11021            }
11022            let tmp = tempfile::tempdir().unwrap();
11023            std::fs::create_dir_all(tmp.path().join("a/b/c")).unwrap();
11024            std::fs::write(tmp.path().join("top.rs"), "").unwrap();
11025            std::fs::write(tmp.path().join("a/mid.rs"), "").unwrap();
11026            std::fs::write(tmp.path().join("a/b/c/deep.rs"), "").unwrap();
11027
11028            let tool = FindTool::new(tmp.path());
11029            let out = tool
11030                .execute(
11031                    "t",
11032                    serde_json::json!({
11033                        "pattern": "*.rs",
11034                        "path": tmp.path().to_string_lossy()
11035                    }),
11036                    None,
11037                )
11038                .await
11039                .unwrap();
11040            let text = get_text(&out.content);
11041            assert!(text.contains("top.rs"));
11042            assert!(text.contains("mid.rs"));
11043            assert!(text.contains("deep.rs"));
11044        });
11045    }
11046
11047    #[test]
11048    fn test_find_results_are_sorted() {
11049        // FindTool sorts by modification time (most recent first), then alphabetically
11050        // as a tie-breaker for files with the same mtime.
11051        asupersync::test_utils::run_test(|| async {
11052            if find_fd_binary().is_none() {
11053                return;
11054            }
11055            let tmp = tempfile::tempdir().unwrap();
11056
11057            // Create files with delays to ensure distinct modification times.
11058            // Order: oldest first, so the expected output (most recent first) is reversed.
11059            std::fs::write(tmp.path().join("oldest.txt"), "").unwrap();
11060            std::thread::sleep(std::time::Duration::from_millis(50));
11061            std::fs::write(tmp.path().join("middle.txt"), "").unwrap();
11062            std::thread::sleep(std::time::Duration::from_millis(50));
11063            std::fs::write(tmp.path().join("newest.txt"), "").unwrap();
11064
11065            let tool = FindTool::new(tmp.path());
11066            let out = tool
11067                .execute(
11068                    "t",
11069                    serde_json::json!({
11070                        "pattern": "*.txt",
11071                        "path": tmp.path().to_string_lossy()
11072                    }),
11073                    None,
11074                )
11075                .await
11076                .unwrap();
11077            let lines: Vec<String> = get_text(&out.content)
11078                .lines()
11079                .map(str::trim)
11080                .filter(|line| !line.is_empty())
11081                .map(str::to_string)
11082                .collect();
11083
11084            // Expected order: most recent first
11085            assert_eq!(
11086                lines,
11087                vec!["newest.txt", "middle.txt", "oldest.txt"],
11088                "expected mtime-sorted find output (most recent first)"
11089            );
11090        });
11091    }
11092
11093    #[test]
11094    fn test_find_respects_gitignore() {
11095        asupersync::test_utils::run_test(|| async {
11096            if find_fd_binary().is_none() {
11097                return;
11098            }
11099            let tmp = tempfile::tempdir().unwrap();
11100            std::fs::write(tmp.path().join(".gitignore"), "ignored.txt\n").unwrap();
11101            std::fs::write(tmp.path().join("ignored.txt"), "").unwrap();
11102
11103            let tool = FindTool::new(tmp.path());
11104            let out = tool
11105                .execute(
11106                    "t",
11107                    serde_json::json!({
11108                        "pattern": "*.txt",
11109                        "path": tmp.path().to_string_lossy()
11110                    }),
11111                    None,
11112                )
11113                .await
11114                .unwrap();
11115            let text = get_text(&out.content);
11116            assert!(
11117                text.contains("No files found matching pattern"),
11118                "expected .gitignore'd files to be excluded, got: {text}"
11119            );
11120        });
11121    }
11122
11123    // ========================================================================
11124    // Ls Tool Tests
11125    // ========================================================================
11126
11127    #[test]
11128    fn test_ls_directory_listing() {
11129        asupersync::test_utils::run_test(|| async {
11130            let tmp = tempfile::tempdir().unwrap();
11131            std::fs::write(tmp.path().join("file_a.txt"), "content").unwrap();
11132            std::fs::write(tmp.path().join("file_b.rs"), "fn main() {}").unwrap();
11133            std::fs::create_dir(tmp.path().join("subdir")).unwrap();
11134
11135            let tool = LsTool::new(tmp.path());
11136            let out = tool
11137                .execute(
11138                    "t",
11139                    serde_json::json!({ "path": tmp.path().to_string_lossy() }),
11140                    None,
11141                )
11142                .await
11143                .unwrap();
11144            let text = get_text(&out.content);
11145            assert!(text.contains("file_a.txt"));
11146            assert!(text.contains("file_b.rs"));
11147            assert!(text.contains("subdir"));
11148        });
11149    }
11150
11151    #[test]
11152    fn test_ls_rejects_outside_cwd() {
11153        asupersync::test_utils::run_test(|| async {
11154            let cwd = tempfile::tempdir().unwrap();
11155            let outside = tempfile::tempdir().unwrap();
11156            std::fs::write(outside.path().join("secret.txt"), "secret").unwrap();
11157
11158            let tool = LsTool::new(cwd.path());
11159            let err = tool
11160                .execute(
11161                    "t",
11162                    serde_json::json!({ "path": outside.path().to_string_lossy() }),
11163                    None,
11164                )
11165                .await
11166                .unwrap_err();
11167            assert!(err.to_string().contains("outside the working directory"));
11168        });
11169    }
11170
11171    #[test]
11172    fn test_ls_trailing_slash_for_dirs() {
11173        asupersync::test_utils::run_test(|| async {
11174            let tmp = tempfile::tempdir().unwrap();
11175            std::fs::write(tmp.path().join("file.txt"), "").unwrap();
11176            std::fs::create_dir(tmp.path().join("mydir")).unwrap();
11177
11178            let tool = LsTool::new(tmp.path());
11179            let out = tool
11180                .execute(
11181                    "t",
11182                    serde_json::json!({ "path": tmp.path().to_string_lossy() }),
11183                    None,
11184                )
11185                .await
11186                .unwrap();
11187            let text = get_text(&out.content);
11188            assert!(
11189                text.contains("mydir/"),
11190                "expected trailing slash for directory, got: {text}"
11191            );
11192        });
11193    }
11194
11195    #[test]
11196    fn test_ls_limit() {
11197        asupersync::test_utils::run_test(|| async {
11198            let tmp = tempfile::tempdir().unwrap();
11199            for i in 0..20 {
11200                std::fs::write(tmp.path().join(format!("item_{i:02}.txt")), "").unwrap();
11201            }
11202
11203            let tool = LsTool::new(tmp.path());
11204            let out = tool
11205                .execute(
11206                    "t",
11207                    serde_json::json!({
11208                        "path": tmp.path().to_string_lossy(),
11209                        "limit": 5
11210                    }),
11211                    None,
11212                )
11213                .await
11214                .unwrap();
11215            let text = get_text(&out.content);
11216            let entry_count = text.lines().filter(|l| l.contains("item_")).count();
11217            assert!(
11218                entry_count <= 5,
11219                "expected at most 5 entries, got {entry_count}"
11220            );
11221            let details = out.details.expect("expected limit details");
11222            assert_eq!(
11223                details
11224                    .get("entryLimitReached")
11225                    .and_then(serde_json::Value::as_u64),
11226                Some(5)
11227            );
11228        });
11229    }
11230
11231    #[test]
11232    fn test_ls_zero_limit_is_rejected() {
11233        asupersync::test_utils::run_test(|| async {
11234            let tmp = tempfile::tempdir().unwrap();
11235            std::fs::write(tmp.path().join("item.txt"), "").unwrap();
11236
11237            let tool = LsTool::new(tmp.path());
11238            let err = tool
11239                .execute(
11240                    "t",
11241                    serde_json::json!({
11242                        "path": tmp.path().to_string_lossy(),
11243                        "limit": 0
11244                    }),
11245                    None,
11246                )
11247                .await
11248                .expect_err("limit=0 should be rejected");
11249
11250            assert!(
11251                err.to_string().contains("`limit` must be greater than 0"),
11252                "expected validation error, got: {err}"
11253            );
11254        });
11255    }
11256
11257    #[test]
11258    fn test_ls_nonexistent_directory() {
11259        asupersync::test_utils::run_test(|| async {
11260            let tmp = tempfile::tempdir().unwrap();
11261            let tool = LsTool::new(tmp.path());
11262            let err = tool
11263                .execute(
11264                    "t",
11265                    serde_json::json!({ "path": tmp.path().join("nope").to_string_lossy() }),
11266                    None,
11267                )
11268                .await;
11269            assert!(err.is_err());
11270        });
11271    }
11272
11273    #[test]
11274    fn test_ls_empty_directory() {
11275        asupersync::test_utils::run_test(|| async {
11276            let tmp = tempfile::tempdir().unwrap();
11277            let empty_dir = tmp.path().join("empty");
11278            std::fs::create_dir(&empty_dir).unwrap();
11279
11280            let tool = LsTool::new(tmp.path());
11281            let out = tool
11282                .execute(
11283                    "t",
11284                    serde_json::json!({ "path": empty_dir.to_string_lossy() }),
11285                    None,
11286                )
11287                .await
11288                .unwrap();
11289            assert!(!out.is_error);
11290        });
11291    }
11292
11293    #[test]
11294    fn test_ls_default_cwd() {
11295        asupersync::test_utils::run_test(|| async {
11296            let tmp = tempfile::tempdir().unwrap();
11297            std::fs::write(tmp.path().join("in_cwd.txt"), "").unwrap();
11298
11299            let tool = LsTool::new(tmp.path());
11300            let out = tool
11301                .execute("t", serde_json::json!({}), None)
11302                .await
11303                .unwrap();
11304            let text = get_text(&out.content);
11305            assert!(
11306                text.contains("in_cwd.txt"),
11307                "expected cwd listing to include the file, got: {text}"
11308            );
11309        });
11310    }
11311
11312    // ========================================================================
11313    // Additional helper tests
11314    // ========================================================================
11315
11316    #[test]
11317    fn test_truncate_head_no_truncation() {
11318        let content = "short".to_string();
11319        let result = truncate_head(content, 100, 1000);
11320        assert!(!result.truncated);
11321        assert_eq!(result.content, "short");
11322        assert_eq!(result.truncated_by, None);
11323    }
11324
11325    #[test]
11326    fn test_truncate_tail_no_truncation() {
11327        let content = "short".to_string();
11328        let result = truncate_tail(content, 100, 1000);
11329        assert!(!result.truncated);
11330        assert_eq!(result.content, "short");
11331    }
11332
11333    #[test]
11334    fn test_truncate_head_empty_input() {
11335        let result = truncate_head(String::new(), 100, 1000);
11336        assert!(!result.truncated);
11337        assert_eq!(result.content, "");
11338    }
11339
11340    #[test]
11341    fn test_truncate_tail_empty_input() {
11342        let result = truncate_tail(String::new(), 100, 1000);
11343        assert!(!result.truncated);
11344        assert_eq!(result.content, "");
11345    }
11346
11347    #[test]
11348    fn test_detect_line_ending_crlf() {
11349        assert_eq!(detect_line_ending("hello\r\nworld"), "\r\n");
11350    }
11351
11352    #[test]
11353    fn test_detect_line_ending_cr() {
11354        assert_eq!(detect_line_ending("hello\rworld"), "\r");
11355    }
11356
11357    #[test]
11358    fn test_detect_line_ending_lf() {
11359        assert_eq!(detect_line_ending("hello\nworld"), "\n");
11360    }
11361
11362    #[test]
11363    fn test_detect_line_ending_no_newline() {
11364        assert_eq!(detect_line_ending("hello world"), "\n");
11365    }
11366
11367    #[test]
11368    fn test_normalize_to_lf() {
11369        assert_eq!(normalize_to_lf("a\r\nb\rc\nd"), "a\nb\nc\nd");
11370    }
11371
11372    #[test]
11373    fn test_count_overlapping_occurrences() {
11374        assert_eq!(count_overlapping_occurrences("aaaa", "aa"), 3);
11375        assert_eq!(count_overlapping_occurrences("abababa", "aba"), 3);
11376        assert_eq!(count_overlapping_occurrences("abc", "d"), 0);
11377        assert_eq!(count_overlapping_occurrences("abc", ""), 0);
11378    }
11379
11380    proptest! {
11381        #![proptest_config(ProptestConfig { cases: 64, .. ProptestConfig::default() })]
11382
11383        #[test]
11384        fn proptest_line_ending_roundtrip_invariant(
11385            input in arbitrary_text(),
11386            ending in prop_oneof![
11387                Just("\n".to_string()),
11388                Just("\r\n".to_string()),
11389                Just("\r".to_string()),
11390            ],
11391        ) {
11392            let normalized = normalize_to_lf(&input);
11393            let restored = restore_line_endings(&normalized, &ending);
11394            let renormalized = normalize_to_lf(&restored);
11395            prop_assert_eq!(renormalized, normalized);
11396        }
11397    }
11398
11399    #[test]
11400    fn test_strip_bom_present() {
11401        let (result, had_bom) = strip_bom("\u{FEFF}hello");
11402        assert_eq!(result, "hello");
11403        assert!(had_bom);
11404    }
11405
11406    #[test]
11407    fn test_strip_bom_absent() {
11408        let (result, had_bom) = strip_bom("hello");
11409        assert_eq!(result, "hello");
11410        assert!(!had_bom);
11411    }
11412
11413    #[test]
11414    fn test_resolve_path_tilde_expansion() {
11415        let cwd = PathBuf::from("/home/user/project");
11416        let result = resolve_path("~/file.txt", &cwd);
11417        // Tilde expansion depends on environment, but should not be literal ~/
11418        assert!(!result.to_string_lossy().starts_with("~/"));
11419    }
11420
11421    fn arbitrary_text() -> impl Strategy<Value = String> {
11422        prop::collection::vec(any::<u8>(), 0..512)
11423            .prop_map(|bytes| String::from_utf8_lossy(&bytes).into_owned())
11424    }
11425
11426    fn match_char_strategy() -> impl Strategy<Value = char> {
11427        prop_oneof![
11428            8 => any::<char>(),
11429            1 => Just('\u{00A0}'),
11430            1 => Just('\u{202F}'),
11431            1 => Just('\u{205F}'),
11432            1 => Just('\u{3000}'),
11433            1 => Just('\u{2018}'),
11434            1 => Just('\u{2019}'),
11435            1 => Just('\u{201C}'),
11436            1 => Just('\u{201D}'),
11437            1 => Just('\u{201E}'),
11438            1 => Just('\u{201F}'),
11439            1 => Just('\u{2010}'),
11440            1 => Just('\u{2011}'),
11441            1 => Just('\u{2012}'),
11442            1 => Just('\u{2013}'),
11443            1 => Just('\u{2014}'),
11444            1 => Just('\u{2015}'),
11445            1 => Just('\u{2212}'),
11446            1 => Just('\u{200D}'),
11447            1 => Just('\u{0301}'),
11448        ]
11449    }
11450
11451    fn arbitrary_match_text() -> impl Strategy<Value = String> {
11452        prop_oneof![
11453            9 => prop::collection::vec(match_char_strategy(), 0..2048),
11454            1 => prop::collection::vec(match_char_strategy(), 8192..16384),
11455        ]
11456        .prop_map(|chars| chars.into_iter().collect())
11457    }
11458
11459    fn line_char_strategy() -> impl Strategy<Value = char> {
11460        prop_oneof![
11461            8 => any::<char>().prop_filter("single-line chars only", |c| *c != '\n'),
11462            1 => Just('é'),
11463            1 => Just('你'),
11464            1 => Just('😀'),
11465        ]
11466    }
11467
11468    fn boundary_line_text() -> impl Strategy<Value = String> {
11469        prop_oneof![
11470            Just(0usize),
11471            Just(GREP_MAX_LINE_LENGTH.saturating_sub(1)),
11472            Just(GREP_MAX_LINE_LENGTH),
11473            Just(GREP_MAX_LINE_LENGTH + 1),
11474            0usize..(GREP_MAX_LINE_LENGTH + 128),
11475        ]
11476        .prop_flat_map(|len| {
11477            prop::collection::vec(line_char_strategy(), len)
11478                .prop_map(|chars| chars.into_iter().collect())
11479        })
11480    }
11481
11482    fn safe_relative_segment() -> impl Strategy<Value = String> {
11483        prop_oneof![
11484            proptest::string::string_regex("[A-Za-z0-9._-]{1,12}")
11485                .expect("segment regex should compile"),
11486            Just("emoji😀".to_string()),
11487            Just("accent-é".to_string()),
11488            Just("rtl-עברית".to_string()),
11489            Just("line\nbreak".to_string()),
11490            Just("nul\0byte".to_string()),
11491        ]
11492        .prop_filter("segment cannot be . or ..", |segment| {
11493            segment != "." && segment != ".."
11494        })
11495    }
11496
11497    fn safe_relative_path() -> impl Strategy<Value = String> {
11498        prop::collection::vec(safe_relative_segment(), 1..6).prop_map(|segments| segments.join("/"))
11499    }
11500
11501    fn pathish_input() -> impl Strategy<Value = String> {
11502        prop_oneof![
11503            5 => safe_relative_path(),
11504            2 => safe_relative_path().prop_map(|p| format!("../{p}")),
11505            2 => safe_relative_path().prop_map(|p| format!("../../{p}")),
11506            1 => safe_relative_path().prop_map(|p| format!("/tmp/{p}")),
11507            1 => safe_relative_path().prop_map(|p| format!("~/{p}")),
11508            1 => Just("~".to_string()),
11509            1 => Just(".".to_string()),
11510            1 => Just("..".to_string()),
11511            1 => Just("././nested/../file.txt".to_string()),
11512        ]
11513    }
11514
11515    proptest! {
11516        #![proptest_config(ProptestConfig { cases: 64, .. ProptestConfig::default() })]
11517
11518        #[test]
11519        fn proptest_truncate_head_invariants(
11520            input in arbitrary_text(),
11521            max_lines in 0usize..32,
11522            max_bytes in 0usize..256,
11523        ) {
11524            let result = truncate_head(input.clone(), max_lines, max_bytes);
11525
11526            prop_assert!(result.output_lines <= max_lines);
11527            prop_assert!(result.output_bytes <= max_bytes);
11528            prop_assert_eq!(result.output_bytes, result.content.len());
11529
11530            prop_assert_eq!(result.truncated, result.truncated_by.is_some());
11531            prop_assert!(input.starts_with(&result.content));
11532
11533            let repeat = truncate_head(result.content.clone(), max_lines, max_bytes);
11534            prop_assert_eq!(&repeat.content, &result.content);
11535
11536            if result.truncated {
11537                prop_assert!(result.total_lines > max_lines || result.total_bytes > max_bytes);
11538            } else {
11539                prop_assert_eq!(&result.content, &input);
11540                prop_assert!(result.total_lines <= max_lines);
11541                prop_assert!(result.total_bytes <= max_bytes);
11542            }
11543
11544            if result.first_line_exceeds_limit {
11545                prop_assert!(result.truncated);
11546                prop_assert_eq!(result.truncated_by, Some(TruncatedBy::Bytes));
11547                prop_assert!(result.output_bytes <= max_bytes);
11548                prop_assert!(result.output_lines <= 1);
11549                prop_assert!(input.starts_with(&result.content));
11550            }
11551        }
11552
11553        #[test]
11554        fn proptest_truncate_tail_invariants(
11555            input in arbitrary_text(),
11556            max_lines in 0usize..32,
11557            max_bytes in 0usize..256,
11558        ) {
11559            let result = truncate_tail(input.clone(), max_lines, max_bytes);
11560
11561            prop_assert!(result.output_lines <= max_lines);
11562            prop_assert!(result.output_bytes <= max_bytes);
11563            prop_assert_eq!(result.output_bytes, result.content.len());
11564
11565            prop_assert_eq!(result.truncated, result.truncated_by.is_some());
11566            prop_assert!(input.ends_with(&result.content));
11567
11568            let repeat = truncate_tail(result.content.clone(), max_lines, max_bytes);
11569            prop_assert_eq!(&repeat.content, &result.content);
11570
11571            if result.last_line_partial {
11572                prop_assert!(result.truncated);
11573                prop_assert_eq!(result.truncated_by, Some(TruncatedBy::Bytes));
11574                // Partial output may span 1-2 lines when the input has a
11575                // trailing newline (the empty line after \n is preserved).
11576                prop_assert!(result.output_lines >= 1 && result.output_lines <= 2);
11577                let content_trimmed = result.content.trim_end_matches('\n');
11578                prop_assert!(input
11579                    .split('\n')
11580                    .rev()
11581                    .any(|line| line.ends_with(content_trimmed)));
11582            }
11583        }
11584
11585        #[test]
11586        fn proptest_truncate_head_monotonic_limits(
11587            input in arbitrary_text(),
11588            max_lines_a in 0usize..32,
11589            max_lines_b in 0usize..32,
11590            max_bytes_a in 0usize..256,
11591            max_bytes_b in 0usize..256,
11592        ) {
11593            let low_lines = max_lines_a.min(max_lines_b);
11594            let high_lines = max_lines_a.max(max_lines_b);
11595            let low_bytes = max_bytes_a.min(max_bytes_b);
11596            let high_bytes = max_bytes_a.max(max_bytes_b);
11597
11598            let small = truncate_head(input.clone(), low_lines, low_bytes);
11599            let large = truncate_head(input, high_lines, high_bytes);
11600
11601            prop_assert!(large.content.starts_with(&small.content));
11602            prop_assert!(large.output_bytes >= small.output_bytes);
11603            prop_assert!(large.output_lines >= small.output_lines);
11604        }
11605
11606        #[test]
11607        fn proptest_truncate_tail_monotonic_limits(
11608            input in arbitrary_text(),
11609            max_lines_a in 0usize..32,
11610            max_lines_b in 0usize..32,
11611            max_bytes_a in 0usize..256,
11612            max_bytes_b in 0usize..256,
11613        ) {
11614            let low_lines = max_lines_a.min(max_lines_b);
11615            let high_lines = max_lines_a.max(max_lines_b);
11616            let low_bytes = max_bytes_a.min(max_bytes_b);
11617            let high_bytes = max_bytes_a.max(max_bytes_b);
11618
11619            let small = truncate_tail(input.clone(), low_lines, low_bytes);
11620            let large = truncate_tail(input, high_lines, high_bytes);
11621
11622            prop_assert!(large.content.ends_with(&small.content));
11623            prop_assert!(large.output_bytes >= small.output_bytes);
11624            prop_assert!(large.output_lines >= small.output_lines);
11625        }
11626
11627        #[test]
11628        fn proptest_truncate_head_prefix_invariant_under_append(
11629            base in arbitrary_text(),
11630            suffix in arbitrary_text(),
11631            max_lines in 0usize..32,
11632            max_bytes in 0usize..256,
11633        ) {
11634            let base_result = truncate_head(base.clone(), max_lines, max_bytes);
11635            let extended_result = truncate_head(format!("{base}{suffix}"), max_lines, max_bytes);
11636            prop_assert!(extended_result.content.starts_with(&base_result.content));
11637        }
11638
11639        #[test]
11640        fn proptest_truncate_tail_suffix_invariant_under_prepend(
11641            base in arbitrary_text(),
11642            prefix in arbitrary_text(),
11643            max_lines in 0usize..32,
11644            max_bytes in 0usize..256,
11645        ) {
11646            let base_result = truncate_tail(base.clone(), max_lines, max_bytes);
11647            let extended_result = truncate_tail(format!("{prefix}{base}"), max_lines, max_bytes);
11648            prop_assert!(extended_result.content.ends_with(&base_result.content));
11649        }
11650    }
11651
11652    proptest! {
11653        #![proptest_config(ProptestConfig { cases: 128, .. ProptestConfig::default() })]
11654
11655        #[test]
11656        fn proptest_normalize_for_match_invariants(input in arbitrary_match_text()) {
11657            let normalized = normalize_for_match(&input);
11658            let renormalized = normalize_for_match(&normalized);
11659
11660            prop_assert_eq!(&renormalized, &normalized);
11661            prop_assert!(normalized.len() <= input.len());
11662            prop_assert!(
11663                normalized.chars().all(|c| {
11664                    !is_special_unicode_space(c)
11665                        && !matches!(
11666                            c,
11667                            '\u{2018}'
11668                                | '\u{2019}'
11669                                | '\u{201C}'
11670                                | '\u{201D}'
11671                                | '\u{201E}'
11672                                | '\u{201F}'
11673                                | '\u{2010}'
11674                                | '\u{2011}'
11675                                | '\u{2012}'
11676                                | '\u{2013}'
11677                                | '\u{2014}'
11678                                | '\u{2015}'
11679                                | '\u{2212}'
11680                        )
11681                }),
11682                "normalize_for_match should remove target punctuation/space variants"
11683            );
11684        }
11685
11686        #[test]
11687        fn proptest_truncate_line_boundary_invariants(line in boundary_line_text()) {
11688            const TRUNCATION_SUFFIX: &str = "... [truncated]";
11689
11690            let result = truncate_line(&line, GREP_MAX_LINE_LENGTH);
11691            let line_char_count = line.chars().count();
11692            let suffix_chars = TRUNCATION_SUFFIX.chars().count();
11693
11694            if line_char_count <= GREP_MAX_LINE_LENGTH {
11695                prop_assert!(!result.was_truncated);
11696                prop_assert_eq!(result.text, line);
11697            } else {
11698                prop_assert!(result.was_truncated);
11699                prop_assert!(result.text.ends_with(TRUNCATION_SUFFIX));
11700                let expected_prefix: String = line.chars().take(GREP_MAX_LINE_LENGTH).collect();
11701                let expected = format!("{expected_prefix}{TRUNCATION_SUFFIX}");
11702                prop_assert_eq!(&result.text, &expected);
11703                prop_assert!(result.text.chars().count() <= GREP_MAX_LINE_LENGTH + suffix_chars);
11704            }
11705        }
11706
11707        #[test]
11708        fn proptest_resolve_path_safe_relative_invariants(relative_path in safe_relative_path()) {
11709            let cwd = PathBuf::from("/tmp/pi-agent-rust-tools-proptest");
11710            let resolved = resolve_path(&relative_path, &cwd);
11711            let normalized = normalize_dot_segments(&resolved);
11712
11713            prop_assert_eq!(&resolved, &cwd.join(&relative_path));
11714            prop_assert!(resolved.starts_with(&cwd));
11715            prop_assert!(normalized.starts_with(&cwd));
11716            prop_assert_eq!(normalize_dot_segments(&normalized), normalized);
11717        }
11718
11719        #[test]
11720        fn proptest_normalize_dot_segments_pathish_invariants(path_input in pathish_input()) {
11721            let cwd = PathBuf::from("/tmp/pi-agent-rust-tools-proptest");
11722            let resolved = resolve_path(&path_input, &cwd);
11723            let normalized_once = normalize_dot_segments(&resolved);
11724            let normalized_twice = normalize_dot_segments(&normalized_once);
11725
11726            prop_assert_eq!(&normalized_once, &normalized_twice);
11727            prop_assert!(
11728                normalized_once
11729                    .components()
11730                    .all(|component| !matches!(component, std::path::Component::CurDir))
11731            );
11732
11733            if std::path::Path::new(&path_input).is_absolute() {
11734                prop_assert!(resolved.is_absolute());
11735                prop_assert!(normalized_once.is_absolute());
11736            }
11737        }
11738    }
11739
11740    // ========================================================================
11741    // Fuzzy find / edit-matching strategies
11742    // ========================================================================
11743
11744    /// Strategy generating content text with occasional Unicode normalization
11745    /// targets (curly quotes, special spaces, em-dashes) and trailing
11746    /// whitespace.
11747    fn fuzzy_content_strategy() -> impl Strategy<Value = String> {
11748        prop::collection::vec(
11749            prop_oneof![
11750                8 => any::<char>().prop_filter("no nul", |c| *c != '\0'),
11751                1 => Just('\u{00A0}'),
11752                1 => Just('\u{2019}'),
11753                1 => Just('\u{201C}'),
11754                1 => Just('\u{2014}'),
11755            ],
11756            1..512,
11757        )
11758        .prop_map(|chars| chars.into_iter().collect())
11759    }
11760
11761    /// Strategy for generating a needle substring from content. Picks a
11762    /// random sub-slice of the content (may be empty).
11763    fn needle_from_content(content: String) -> impl Strategy<Value = (String, String)> {
11764        let len = content.len();
11765        if len == 0 {
11766            return Just((content, String::new())).boxed();
11767        }
11768        (0..len)
11769            .prop_flat_map(move |start| {
11770                let c = content.clone();
11771                let remaining = c.len() - start;
11772                let max_needle = remaining.min(256);
11773                (Just(c), start..=start + max_needle.saturating_sub(1))
11774            })
11775            .prop_filter_map("valid char boundary", |(c, end)| {
11776                // Find the nearest valid char boundaries
11777                let start_candidates: Vec<usize> =
11778                    (0..c.len()).filter(|i| c.is_char_boundary(*i)).collect();
11779                if start_candidates.is_empty() {
11780                    return None;
11781                }
11782                let start = *start_candidates
11783                    .iter()
11784                    .min_by_key(|&&i| i.abs_diff(end.saturating_sub(end / 2)))
11785                    .unwrap_or(&0);
11786                let end_clamped = end.min(c.len());
11787                // Find next valid char boundary >= end_clamped
11788                let actual_end = (end_clamped..=c.len())
11789                    .find(|i| c.is_char_boundary(*i))
11790                    .unwrap_or(c.len());
11791                if start >= actual_end {
11792                    return Some((c, String::new()));
11793                }
11794                Some((c.clone(), c[start..actual_end].to_string()))
11795            })
11796            .boxed()
11797    }
11798
11799    proptest! {
11800        #![proptest_config(ProptestConfig { cases: 128, .. ProptestConfig::default() })]
11801
11802        /// Exact substrings of content are always found by `fuzzy_find_text`.
11803        #[test]
11804        fn proptest_fuzzy_find_text_exact_match_invariants(
11805            (content, needle) in fuzzy_content_strategy().prop_flat_map(needle_from_content)
11806        ) {
11807            let result = fuzzy_find_text(&content, &needle);
11808            if needle.is_empty() {
11809                // Empty needle: exact match at index 0 (str::find("") == Some(0))
11810                prop_assert!(result.found, "empty needle should always match");
11811                prop_assert_eq!(result.index, 0);
11812                prop_assert_eq!(result.match_length, 0);
11813            } else {
11814                prop_assert!(
11815                    result.found,
11816                    "exact substring must be found: content len={}, needle len={}",
11817                    content.len(),
11818                    needle.len()
11819                );
11820                // The matched span should be valid UTF-8 byte indices
11821                prop_assert!(content.is_char_boundary(result.index));
11822                prop_assert!(content.is_char_boundary(result.index + result.match_length));
11823                // The matched text should contain the needle (exact match path)
11824                let matched = &content[result.index..result.index + result.match_length];
11825                prop_assert_eq!(matched, needle.as_str());
11826            }
11827        }
11828
11829        /// Normalized text with Unicode variants is found via fuzzy matching.
11830        /// If we take content containing curly quotes / em-dashes, normalize
11831        /// it, then search for the normalized version, `fuzzy_find_text` must
11832        /// locate it.
11833        #[test]
11834        fn proptest_fuzzy_find_text_normalized_match_invariants(
11835            content in arbitrary_match_text()
11836        ) {
11837            // Normalize the whole content to get an ASCII-equivalent version
11838            let normalized = build_normalized_content(&content);
11839            if normalized.is_empty() {
11840                return Ok(());
11841            }
11842            // Take a prefix of normalized as needle (up to 128 chars)
11843            let needle_end = normalized
11844                .char_indices()
11845                .nth(128.min(normalized.chars().count().saturating_sub(1)))
11846                .map_or(normalized.len(), |(i, _)| i);
11847            // Find the nearest char boundary
11848            let needle_end = (needle_end..=normalized.len())
11849                .find(|i| normalized.is_char_boundary(*i))
11850                .unwrap_or(normalized.len());
11851            let needle = &normalized[..needle_end];
11852            if needle.is_empty() {
11853                return Ok(());
11854            }
11855
11856            let result = fuzzy_find_text(&content, needle);
11857            prop_assert!(
11858                result.found,
11859                "normalized needle should be found via fuzzy match: needle={:?}",
11860                needle
11861            );
11862            // Verify the result points to valid UTF-8
11863            prop_assert!(content.is_char_boundary(result.index));
11864            prop_assert!(content.is_char_boundary(result.index + result.match_length));
11865        }
11866
11867        /// `build_normalized_content` should be idempotent and never larger
11868        /// than the input.
11869        #[test]
11870        fn proptest_build_normalized_content_invariants(input in arbitrary_match_text()) {
11871            let normalized = build_normalized_content(&input);
11872            let renormalized = build_normalized_content(&normalized);
11873
11874            // Idempotency
11875            prop_assert_eq!(
11876                &renormalized,
11877                &normalized,
11878                "build_normalized_content should be idempotent"
11879            );
11880
11881            // Size: normalized text strips trailing whitespace per line and
11882            // may replace multi-byte Unicode with single-byte ASCII, so it
11883            // should never be larger than the input.
11884            prop_assert!(
11885                normalized.len() <= input.len(),
11886                "normalized should not be larger: {} vs {}",
11887                normalized.len(),
11888                input.len()
11889            );
11890
11891            // Line count should be preserved (normalization does not add or
11892            // remove newlines).
11893            let input_lines = input.split('\n').count();
11894            let norm_lines = normalized.split('\n').count();
11895            prop_assert_eq!(
11896                norm_lines, input_lines,
11897                "line count must be preserved by normalization"
11898            );
11899
11900            // No target Unicode chars should remain
11901            prop_assert!(
11902                normalized.chars().all(|c| {
11903                    !is_special_unicode_space(c)
11904                        && !matches!(
11905                            c,
11906                            '\u{2018}'
11907                                | '\u{2019}'
11908                                | '\u{201C}'
11909                                | '\u{201D}'
11910                                | '\u{201E}'
11911                                | '\u{201F}'
11912                                | '\u{2010}'
11913                                | '\u{2011}'
11914                                | '\u{2012}'
11915                                | '\u{2013}'
11916                                | '\u{2014}'
11917                                | '\u{2015}'
11918                                | '\u{2212}'
11919                        )
11920                }),
11921                "normalized content should not contain target Unicode chars"
11922            );
11923        }
11924
11925        /// Appending trailing whitespace to each line should not change the
11926        /// normalized content (metamorphic invariant).
11927        #[test]
11928        fn proptest_build_normalized_content_trailing_whitespace_invariant(
11929            input in arbitrary_match_text()
11930        ) {
11931            let normalized = build_normalized_content(&input);
11932            let mut with_trailing = String::new();
11933            let mut lines = input.split('\n').peekable();
11934
11935            while let Some(line) = lines.next() {
11936                with_trailing.push_str(line);
11937                with_trailing.push_str("  \t");
11938                if lines.peek().is_some() {
11939                    with_trailing.push('\n');
11940                }
11941            }
11942
11943            let normalized_trailing = build_normalized_content(&with_trailing);
11944            prop_assert_eq!(normalized_trailing, normalized);
11945        }
11946
11947        /// `map_normalized_range_to_original` should produce valid byte
11948        /// ranges in the original content and the extracted original slice,
11949        /// when re-normalized, should start with the expected normalized
11950        /// prefix. Trailing whitespace at line ends makes an exact match
11951        /// impossible (normalization strips it), so we verify the key
11952        /// structural invariant: the range is valid and the non-whitespace
11953        /// content round-trips correctly.
11954        #[test]
11955        fn proptest_map_normalized_range_roundtrip(input in arbitrary_match_text()) {
11956            let normalized = build_normalized_content(&input);
11957            if normalized.is_empty() {
11958                return Ok(());
11959            }
11960
11961            // Pick a range in the normalized text at char boundaries
11962            let norm_chars: Vec<(usize, char)> = normalized.char_indices().collect();
11963            let norm_len = norm_chars.len();
11964            if norm_len == 0 {
11965                return Ok(());
11966            }
11967
11968            // Use the first quarter as the match range for determinism
11969            let end_char = (norm_len / 4).max(1).min(norm_len);
11970            let norm_start = norm_chars[0].0;
11971            let norm_end = if end_char < norm_chars.len() {
11972                norm_chars[end_char].0
11973            } else {
11974                normalized.len()
11975            };
11976            let norm_match_len = norm_end - norm_start;
11977
11978            let (orig_start, orig_len) =
11979                map_normalized_range_to_original(&input, norm_start, norm_match_len);
11980
11981            // Invariant 1: result is within input bounds
11982            prop_assert!(
11983                orig_start + orig_len <= input.len(),
11984                "mapped range {orig_start}..{} exceeds input len {}",
11985                orig_start + orig_len,
11986                input.len()
11987            );
11988
11989            // Invariant 2: result is at valid char boundaries
11990            prop_assert!(
11991                input.is_char_boundary(orig_start),
11992                "orig_start {} is not a char boundary",
11993                orig_start
11994            );
11995            prop_assert!(
11996                input.is_char_boundary(orig_start + orig_len),
11997                "orig_end {} is not a char boundary",
11998                orig_start + orig_len
11999            );
12000
12001            // Invariant 3: original range is at least as large as
12002            // normalized range (original may include trailing whitespace
12003            // and multi-byte Unicode chars that normalize to fewer bytes)
12004            prop_assert!(
12005                orig_len >= norm_match_len
12006                    || orig_len == 0
12007                    || norm_match_len == 0,
12008                "original range ({orig_len}) should be >= normalized range ({norm_match_len})"
12009            );
12010
12011            // Invariant 4: the normalized expected slice, when searched
12012            // for in the original content via fuzzy_find_text, should be
12013            // found at or before the mapped position.
12014            let expected_norm = &normalized[norm_start..norm_end];
12015            if !expected_norm.is_empty() {
12016                let fuzzy_result = fuzzy_find_text(&input, expected_norm);
12017                prop_assert!(
12018                    fuzzy_result.found,
12019                    "normalized needle should be findable in original content"
12020                );
12021            }
12022        }
12023    }
12024
12025    #[test]
12026    fn test_truncate_head_preserves_newline() {
12027        // "Line1\nLine2" truncated to 1 line should be "Line1\n"
12028        let content = "Line1\nLine2".to_string();
12029        let result = truncate_head(content, 1, 1000);
12030        assert_eq!(result.content, "Line1\n");
12031
12032        // "Line1" truncated to 1 line should be "Line1"
12033        let content = "Line1".to_string();
12034        let result = truncate_head(content, 1, 1000);
12035        assert_eq!(result.content, "Line1");
12036
12037        // "Line1\n" truncated to 1 line should be "Line1\n"
12038        let content = "Line1\n".to_string();
12039        let result = truncate_head(content, 1, 1000);
12040        assert_eq!(result.content, "Line1\n");
12041    }
12042
12043    #[test]
12044    fn test_edit_crlf_content_correctness() {
12045        // Regression test: ensure we don't mix original indices with normalized content slices.
12046        asupersync::test_utils::run_test(|| async {
12047            let tmp = tempfile::tempdir().unwrap();
12048            let path = tmp.path().join("crlf.txt");
12049            // "line1" (5) + "\r\n" (2) + "line2" (5) + "\r\n" (2) + "line3" (5) = 19 bytes
12050            let content = "line1\r\nline2\r\nline3";
12051            std::fs::write(&path, content).unwrap();
12052
12053            let tool = EditTool::new(tmp.path());
12054
12055            // Replacing "line2" should work correctly and preserve CRLF.
12056            // Original "line2" is at index 7. Normalized "line2" is at index 6.
12057            // If we used original index (7) on normalized string ("line1\nline2\nline3"),
12058            // we would start at "ine2..." instead of "line2...", corrupting the file.
12059            let out = tool
12060                .execute(
12061                    "t",
12062                    serde_json::json!({
12063                        "path": path.to_string_lossy(),
12064                        "oldText": "line2",
12065                        "newText": "changed"
12066                    }),
12067                    None,
12068                )
12069                .await
12070                .unwrap();
12071
12072            assert!(!out.is_error);
12073            let new_content = std::fs::read_to_string(&path).unwrap();
12074
12075            // Expect: "line1\r\nchanged\r\nline3"
12076            assert_eq!(new_content, "line1\r\nchanged\r\nline3");
12077        });
12078    }
12079
12080    #[test]
12081    fn test_edit_cr_content_correctness() {
12082        asupersync::test_utils::run_test(|| async {
12083            let tmp = tempfile::tempdir().unwrap();
12084            let path = tmp.path().join("cr.txt");
12085            std::fs::write(&path, "line1\rline2\rline3").unwrap();
12086
12087            let tool = EditTool::new(tmp.path());
12088            let out = tool
12089                .execute(
12090                    "t",
12091                    serde_json::json!({
12092                        "path": path.to_string_lossy(),
12093                        "oldText": "line2",
12094                        "newText": "changed"
12095                    }),
12096                    None,
12097                )
12098                .await
12099                .unwrap();
12100
12101            assert!(!out.is_error);
12102            let new_content = std::fs::read_to_string(&path).unwrap();
12103            assert_eq!(new_content, "line1\rchanged\rline3");
12104        });
12105    }
12106
12107    // ========================================================================
12108    // Hashline tests
12109    // ========================================================================
12110
12111    #[test]
12112    fn test_compute_line_hash_basic() {
12113        // Same content at same index should produce same hash
12114        let h1 = compute_line_hash(0, "fn main() {");
12115        let h2 = compute_line_hash(0, "fn main() {");
12116        assert_eq!(h1, h2);
12117
12118        // Different content should (usually) produce different hash
12119        let h3 = compute_line_hash(0, "fn foo() {");
12120        // Not guaranteed different for all inputs, but these specific ones should differ
12121        assert_ne!(h1, h3);
12122
12123        // Hash is 2 bytes from NIBBLE_STR
12124        for &b in &h1 {
12125            assert!(NIBBLE_STR.contains(&b), "hash byte {b} not in NIBBLE_STR");
12126        }
12127    }
12128
12129    #[test]
12130    fn test_compute_line_hash_punctuation_only() {
12131        // Punctuation-only lines use line_idx as seed, so same content at
12132        // different indices should produce different hashes.
12133        let h1 = compute_line_hash(0, "}");
12134        let h2 = compute_line_hash(1, "}");
12135        assert_ne!(
12136            h1, h2,
12137            "punctuation-only lines at different indices should differ"
12138        );
12139
12140        // Blank lines also use idx as seed
12141        let h3 = compute_line_hash(0, "");
12142        let h4 = compute_line_hash(1, "");
12143        assert_ne!(h3, h4);
12144    }
12145
12146    #[test]
12147    fn test_compute_line_hash_whitespace_invariant() {
12148        // Leading/trailing whitespace should not affect hash (whitespace stripped)
12149        let h1 = compute_line_hash(0, "return 42;");
12150        let h2 = compute_line_hash(0, "    return 42;");
12151        let h3 = compute_line_hash(0, "\treturn 42;");
12152        assert_eq!(h1, h2);
12153        assert_eq!(h1, h3);
12154    }
12155
12156    #[test]
12157    fn test_format_hashline_tag() {
12158        let tag = format_hashline_tag(0, "fn main() {");
12159        // Should be "1#XX" format (1-indexed)
12160        assert!(
12161            tag.starts_with("1#"),
12162            "tag should start with 1#, got: {tag}"
12163        );
12164        assert_eq!(tag.len(), 4, "tag should be 4 chars: N#AB");
12165
12166        let tag10 = format_hashline_tag(9, "line 10");
12167        assert!(tag10.starts_with("10#"));
12168        assert_eq!(tag10.len(), 5); // "10#AB"
12169    }
12170
12171    #[test]
12172    fn test_parse_hashline_tag_valid() {
12173        // Simple valid tag
12174        let (line, hash) = parse_hashline_tag("5#KJ").unwrap();
12175        assert_eq!(line, 5);
12176        assert_eq!(hash, [b'K', b'J']);
12177
12178        // With spaces around #
12179        let (line, hash) = parse_hashline_tag("  10 # QR ").unwrap();
12180        assert_eq!(line, 10);
12181        assert_eq!(hash, [b'Q', b'R']);
12182
12183        // With diff markers
12184        let (line, hash) = parse_hashline_tag("> + 3#ZZ").unwrap();
12185        assert_eq!(line, 3);
12186        assert_eq!(hash, [b'Z', b'Z']);
12187    }
12188
12189    #[test]
12190    fn test_parse_hashline_tag_invalid() {
12191        // Line number 0
12192        assert!(parse_hashline_tag("0#KJ").is_err());
12193        // No hash
12194        assert!(parse_hashline_tag("5#").is_err());
12195        // Invalid chars in hash
12196        assert!(parse_hashline_tag("5#AA").is_err()); // 'A' not in NIBBLE_STR
12197        // No number
12198        assert!(parse_hashline_tag("#KJ").is_err());
12199        // Empty
12200        assert!(parse_hashline_tag("").is_err());
12201    }
12202
12203    #[test]
12204    fn test_strip_hashline_prefix() {
12205        assert_eq!(strip_hashline_prefix("5#KJ:hello world"), "hello world");
12206        assert_eq!(strip_hashline_prefix("100#ZZ:fn main() {"), "fn main() {");
12207        assert_eq!(strip_hashline_prefix(" 5 # KJ:hello world"), "hello world");
12208        assert_eq!(strip_hashline_prefix("> + 5#KJ:hello world"), "hello world");
12209        assert_eq!(strip_hashline_prefix("5#KJ :hello world"), "hello world");
12210        // No prefix → unchanged
12211        assert_eq!(strip_hashline_prefix("hello world"), "hello world");
12212        assert_eq!(strip_hashline_prefix(""), "");
12213    }
12214
12215    #[test]
12216    fn test_hashline_edit_single_replace() {
12217        asupersync::test_utils::run_test(|| async {
12218            let dir = tempfile::tempdir().unwrap();
12219            let file = dir.path().join("test.txt");
12220            std::fs::write(&file, "line1\nline2\nline3\n").unwrap();
12221
12222            let tool = HashlineEditTool::new(dir.path());
12223
12224            // Get the hash for line 2 (idx=1)
12225            let tag2 = format_hashline_tag(1, "line2");
12226
12227            let input = serde_json::json!({
12228                "path": file.to_str().unwrap(),
12229                "edits": [{
12230                    "op": "replace",
12231                    "pos": tag2,
12232                    "lines": ["changed"]
12233                }]
12234            });
12235
12236            let out = tool.execute("test", input, None).await.unwrap();
12237            assert!(!out.is_error);
12238
12239            let content = std::fs::read_to_string(&file).unwrap();
12240            assert_eq!(content, "line1\nchanged\nline3\n");
12241        });
12242    }
12243
12244    #[test]
12245    fn test_hashline_edit_range_replace() {
12246        asupersync::test_utils::run_test(|| async {
12247            let dir = tempfile::tempdir().unwrap();
12248            let file = dir.path().join("test.txt");
12249            std::fs::write(&file, "a\nb\nc\nd\ne\n").unwrap();
12250
12251            let tool = HashlineEditTool::new(dir.path());
12252
12253            let tag_b = format_hashline_tag(1, "b");
12254            let tag_d = format_hashline_tag(3, "d");
12255
12256            let input = serde_json::json!({
12257                "path": file.to_str().unwrap(),
12258                "edits": [{
12259                    "op": "replace",
12260                    "pos": tag_b,
12261                    "end": tag_d,
12262                    "lines": ["X", "Y"]
12263                }]
12264            });
12265
12266            let out = tool.execute("test", input, None).await.unwrap();
12267            assert!(!out.is_error);
12268
12269            let content = std::fs::read_to_string(&file).unwrap();
12270            assert_eq!(content, "a\nX\nY\ne\n");
12271        });
12272    }
12273
12274    #[test]
12275    fn test_hashline_edit_prepend() {
12276        asupersync::test_utils::run_test(|| async {
12277            let dir = tempfile::tempdir().unwrap();
12278            let file = dir.path().join("test.txt");
12279            std::fs::write(&file, "a\nb\nc\n").unwrap();
12280
12281            let tool = HashlineEditTool::new(dir.path());
12282            let tag_b = format_hashline_tag(1, "b");
12283
12284            let input = serde_json::json!({
12285                "path": file.to_str().unwrap(),
12286                "edits": [{
12287                    "op": "prepend",
12288                    "pos": tag_b,
12289                    "lines": ["inserted"]
12290                }]
12291            });
12292
12293            let out = tool.execute("test", input, None).await.unwrap();
12294            assert!(!out.is_error);
12295
12296            let content = std::fs::read_to_string(&file).unwrap();
12297            assert_eq!(content, "a\ninserted\nb\nc\n");
12298        });
12299    }
12300
12301    #[test]
12302    fn test_hashline_edit_append() {
12303        asupersync::test_utils::run_test(|| async {
12304            let dir = tempfile::tempdir().unwrap();
12305            let file = dir.path().join("test.txt");
12306            std::fs::write(&file, "a\nb\nc\n").unwrap();
12307
12308            let tool = HashlineEditTool::new(dir.path());
12309            let tag_b = format_hashline_tag(1, "b");
12310
12311            let input = serde_json::json!({
12312                "path": file.to_str().unwrap(),
12313                "edits": [{
12314                    "op": "append",
12315                    "pos": tag_b,
12316                    "lines": ["inserted"]
12317                }]
12318            });
12319
12320            let out = tool.execute("test", input, None).await.unwrap();
12321            assert!(!out.is_error);
12322
12323            let content = std::fs::read_to_string(&file).unwrap();
12324            assert_eq!(content, "a\nb\ninserted\nc\n");
12325        });
12326    }
12327
12328    #[test]
12329    fn test_hashline_edit_bottom_up_ordering() {
12330        asupersync::test_utils::run_test(|| async {
12331            let dir = tempfile::tempdir().unwrap();
12332            let file = dir.path().join("test.txt");
12333            std::fs::write(&file, "a\nb\nc\nd\n").unwrap();
12334
12335            let tool = HashlineEditTool::new(dir.path());
12336            let tag_b = format_hashline_tag(1, "b");
12337            let tag_d = format_hashline_tag(3, "d");
12338
12339            // Two edits at different positions — both should apply correctly
12340            let input = serde_json::json!({
12341                "path": file.to_str().unwrap(),
12342                "edits": [
12343                    { "op": "replace", "pos": tag_b, "lines": ["B"] },
12344                    { "op": "replace", "pos": tag_d, "lines": ["D"] }
12345                ]
12346            });
12347
12348            let out = tool.execute("test", input, None).await.unwrap();
12349            assert!(!out.is_error);
12350
12351            let content = std::fs::read_to_string(&file).unwrap();
12352            assert_eq!(content, "a\nB\nc\nD\n");
12353        });
12354    }
12355
12356    #[test]
12357    fn test_hashline_edit_hash_mismatch() {
12358        asupersync::test_utils::run_test(|| async {
12359            let dir = tempfile::tempdir().unwrap();
12360            let file = dir.path().join("test.txt");
12361            std::fs::write(&file, "hello\nworld\n").unwrap();
12362
12363            let tool = HashlineEditTool::new(dir.path());
12364
12365            // Use a deliberately wrong hash
12366            let input = serde_json::json!({
12367                "path": file.to_str().unwrap(),
12368                "edits": [{
12369                    "op": "replace",
12370                    "pos": "1#ZZ",
12371                    "lines": ["changed"]
12372                }]
12373            });
12374
12375            let result = tool.execute("test", input, None).await;
12376            assert!(result.is_err());
12377            let err_msg = result.unwrap_err().to_string();
12378            assert!(
12379                err_msg.contains("Hash validation failed"),
12380                "error should mention hash validation: {err_msg}"
12381            );
12382        });
12383    }
12384
12385    #[test]
12386    fn test_hashline_edit_dedup() {
12387        asupersync::test_utils::run_test(|| async {
12388            let dir = tempfile::tempdir().unwrap();
12389            let file = dir.path().join("test.txt");
12390            std::fs::write(&file, "a\nb\nc\n").unwrap();
12391
12392            let tool = HashlineEditTool::new(dir.path());
12393            let tag_b = format_hashline_tag(1, "b");
12394
12395            // Duplicate edits should be deduplicated
12396            let input = serde_json::json!({
12397                "path": file.to_str().unwrap(),
12398                "edits": [
12399                    { "op": "replace", "pos": &tag_b, "lines": ["B"] },
12400                    { "op": "replace", "pos": &tag_b, "lines": ["B"] }
12401                ]
12402            });
12403
12404            let out = tool.execute("test", input, None).await.unwrap();
12405            assert!(!out.is_error);
12406
12407            let content = std::fs::read_to_string(&file).unwrap();
12408            assert_eq!(content, "a\nB\nc\n");
12409        });
12410    }
12411
12412    #[test]
12413    fn test_hashline_edit_noop_detection() {
12414        asupersync::test_utils::run_test(|| async {
12415            let dir = tempfile::tempdir().unwrap();
12416            let file = dir.path().join("test.txt");
12417            std::fs::write(&file, "a\nb\nc\n").unwrap();
12418
12419            let tool = HashlineEditTool::new(dir.path());
12420            let tag_b = format_hashline_tag(1, "b");
12421
12422            // Replacing with identical content is a no-op
12423            let input = serde_json::json!({
12424                "path": file.to_str().unwrap(),
12425                "edits": [{
12426                    "op": "replace",
12427                    "pos": &tag_b,
12428                    "lines": ["b"]
12429                }]
12430            });
12431
12432            let result = tool.execute("test", input, None).await;
12433            assert!(result.is_err());
12434            let err_msg = result.unwrap_err().to_string();
12435            assert!(
12436                err_msg.contains("no-ops"),
12437                "error should mention no-ops: {err_msg}"
12438            );
12439        });
12440    }
12441
12442    #[test]
12443    fn test_hashline_read_output_format() {
12444        asupersync::test_utils::run_test(|| async {
12445            let dir = tempfile::tempdir().unwrap();
12446            let file = dir.path().join("test.txt");
12447            std::fs::write(&file, "fn main() {\n    println!(\"hello\");\n}\n").unwrap();
12448
12449            let tool = ReadTool::new(dir.path());
12450            let input = serde_json::json!({
12451                "path": file.to_str().unwrap(),
12452                "hashline": true
12453            });
12454
12455            let out = tool.execute("test", input, None).await.unwrap();
12456            assert!(!out.is_error);
12457            let text = get_text(&out.content);
12458
12459            // Each line should be in N#AB:content format
12460            for line in text.lines() {
12461                if line.starts_with('[') || line.is_empty() {
12462                    continue; // skip metadata lines
12463                }
12464                assert!(
12465                    hashline_tag_regex().is_match(line),
12466                    "line should match hashline format: {line:?}"
12467                );
12468                assert!(
12469                    line.contains(':'),
12470                    "line should contain ':' separator: {line:?}"
12471                );
12472            }
12473
12474            // First line should start with "1#"
12475            let first_line = text.lines().next().unwrap();
12476            assert!(first_line.starts_with("1#"), "first line: {first_line:?}");
12477        });
12478    }
12479
12480    #[test]
12481    fn test_hashline_edit_prefix_stripping() {
12482        asupersync::test_utils::run_test(|| async {
12483            let dir = tempfile::tempdir().unwrap();
12484            let file = dir.path().join("test.txt");
12485            std::fs::write(&file, "a\nb\nc\n").unwrap();
12486
12487            let tool = HashlineEditTool::new(dir.path());
12488            let tag_b = format_hashline_tag(1, "b");
12489
12490            // Model copies hashline tags into replacement — they should be stripped
12491            let input = serde_json::json!({
12492                "path": file.to_str().unwrap(),
12493                "edits": [{
12494                    "op": "replace",
12495                    "pos": &tag_b,
12496                    "lines": ["2#KJ:changed"]
12497                }]
12498            });
12499
12500            let out = tool.execute("test", input, None).await.unwrap();
12501            assert!(!out.is_error);
12502
12503            let content = std::fs::read_to_string(&file).unwrap();
12504            assert_eq!(content, "a\nchanged\nc\n");
12505        });
12506    }
12507
12508    #[test]
12509    fn test_hashline_edit_delete_lines() {
12510        asupersync::test_utils::run_test(|| async {
12511            let dir = tempfile::tempdir().unwrap();
12512            let file = dir.path().join("test.txt");
12513            std::fs::write(&file, "a\nb\nc\nd\n").unwrap();
12514
12515            let tool = HashlineEditTool::new(dir.path());
12516            let tag_b = format_hashline_tag(1, "b");
12517            let tag_c = format_hashline_tag(2, "c");
12518
12519            // Replace range with null (delete)
12520            let input = serde_json::json!({
12521                "path": file.to_str().unwrap(),
12522                "edits": [{
12523                    "op": "replace",
12524                    "pos": &tag_b,
12525                    "end": &tag_c,
12526                    "lines": null
12527                }]
12528            });
12529
12530            let out = tool.execute("test", input, None).await.unwrap();
12531            assert!(!out.is_error);
12532
12533            let content = std::fs::read_to_string(&file).unwrap();
12534            assert_eq!(content, "a\nd\n");
12535        });
12536    }
12537
12538    #[test]
12539    fn test_hashline_edit_crlf_preservation() {
12540        asupersync::test_utils::run_test(|| async {
12541            let dir = tempfile::tempdir().unwrap();
12542            let file = dir.path().join("test.txt");
12543            std::fs::write(&file, "line1\r\nline2\r\nline3").unwrap();
12544
12545            let tool = HashlineEditTool::new(dir.path());
12546            let tag2 = format_hashline_tag(1, "line2");
12547
12548            let input = serde_json::json!({
12549                "path": file.to_str().unwrap(),
12550                "edits": [{
12551                    "op": "replace",
12552                    "pos": tag2,
12553                    "lines": ["changed"]
12554                }]
12555            });
12556
12557            let out = tool.execute("test", input, None).await.unwrap();
12558            assert!(!out.is_error);
12559
12560            let content = std::fs::read_to_string(&file).unwrap();
12561            assert_eq!(content, "line1\r\nchanged\r\nline3");
12562        });
12563    }
12564
12565    #[test]
12566    fn test_hashline_edit_cr_preservation() {
12567        asupersync::test_utils::run_test(|| async {
12568            let dir = tempfile::tempdir().unwrap();
12569            let file = dir.path().join("test.txt");
12570            std::fs::write(&file, "line1\rline2\rline3").unwrap();
12571
12572            let tool = HashlineEditTool::new(dir.path());
12573            let tag2 = format_hashline_tag(1, "line2");
12574
12575            let input = serde_json::json!({
12576                "path": file.to_str().unwrap(),
12577                "edits": [{
12578                    "op": "replace",
12579                    "pos": tag2,
12580                    "lines": ["changed"]
12581                }]
12582            });
12583
12584            let out = tool.execute("test", input, None).await.unwrap();
12585            assert!(!out.is_error);
12586
12587            let content = std::fs::read_to_string(&file).unwrap();
12588            assert_eq!(content, "line1\rchanged\rline3");
12589        });
12590    }
12591
12592    #[test]
12593    fn test_hashline_edit_empty_file_append() {
12594        asupersync::test_utils::run_test(|| async {
12595            let dir = tempfile::tempdir().unwrap();
12596            let file = dir.path().join("empty.txt");
12597            std::fs::write(&file, "").unwrap();
12598
12599            let tool = HashlineEditTool::new(dir.path());
12600
12601            // EOF append with no pos on empty file
12602            let input = serde_json::json!({
12603                "path": file.to_str().unwrap(),
12604                "edits": [{
12605                    "op": "append",
12606                    "lines": ["new_line"]
12607                }]
12608            });
12609
12610            let out = tool.execute("test", input, None).await.unwrap();
12611            assert!(!out.is_error);
12612
12613            let content = std::fs::read_to_string(&file).unwrap();
12614            assert!(content.contains("new_line"));
12615        });
12616    }
12617
12618    #[test]
12619    fn test_hashline_edit_single_line_no_trailing_newline() {
12620        asupersync::test_utils::run_test(|| async {
12621            let dir = tempfile::tempdir().unwrap();
12622            let file = dir.path().join("single.txt");
12623            std::fs::write(&file, "hello").unwrap();
12624
12625            let tool = HashlineEditTool::new(dir.path());
12626            let tag = format_hashline_tag(0, "hello");
12627
12628            let input = serde_json::json!({
12629                "path": file.to_str().unwrap(),
12630                "edits": [{
12631                    "op": "replace",
12632                    "pos": tag,
12633                    "lines": ["world"]
12634                }]
12635            });
12636
12637            let out = tool.execute("test", input, None).await.unwrap();
12638            assert!(!out.is_error);
12639
12640            let content = std::fs::read_to_string(&file).unwrap();
12641            assert_eq!(content, "world");
12642        });
12643    }
12644
12645    #[test]
12646    fn test_hashline_edit_preserves_bom_hash_validation() {
12647        asupersync::test_utils::run_test(|| async {
12648            let dir = tempfile::tempdir().unwrap();
12649            let file = dir.path().join("bom.txt");
12650            let bom = "\u{FEFF}";
12651            std::fs::write(&file, format!("{bom}alpha\nbeta\n")).unwrap();
12652
12653            let tool = HashlineEditTool::new(dir.path());
12654            let tag1 = format_hashline_tag(0, &format!("{bom}alpha"));
12655
12656            let input = serde_json::json!({
12657                "path": file.to_str().unwrap(),
12658                "edits": [{
12659                    "op": "replace",
12660                    "pos": tag1,
12661                    "lines": ["gamma"]
12662                }]
12663            });
12664
12665            let out = tool.execute("test", input, None).await.unwrap();
12666            assert!(!out.is_error);
12667
12668            let content = std::fs::read_to_string(&file).unwrap();
12669            assert_eq!(content, format!("{bom}gamma\nbeta\n"));
12670        });
12671    }
12672
12673    #[test]
12674    fn test_hashline_edit_bof_prepend_no_pos() {
12675        asupersync::test_utils::run_test(|| async {
12676            let dir = tempfile::tempdir().unwrap();
12677            let file = dir.path().join("test.txt");
12678            std::fs::write(&file, "a\nb\nc\n").unwrap();
12679
12680            let tool = HashlineEditTool::new(dir.path());
12681
12682            // Prepend with no pos should insert at BOF (before line 0)
12683            let input = serde_json::json!({
12684                "path": file.to_str().unwrap(),
12685                "edits": [{
12686                    "op": "prepend",
12687                    "lines": ["header"]
12688                }]
12689            });
12690
12691            let out = tool.execute("test", input, None).await.unwrap();
12692            assert!(!out.is_error);
12693
12694            let content = std::fs::read_to_string(&file).unwrap();
12695            assert_eq!(content, "header\na\nb\nc\n");
12696        });
12697    }
12698
12699    #[test]
12700    fn test_hashline_edit_eof_append_no_pos() {
12701        asupersync::test_utils::run_test(|| async {
12702            let dir = tempfile::tempdir().unwrap();
12703            let file = dir.path().join("test.txt");
12704            std::fs::write(&file, "a\nb\nc\n").unwrap();
12705
12706            let tool = HashlineEditTool::new(dir.path());
12707
12708            // Append with no pos should insert at EOF (after last line)
12709            let input = serde_json::json!({
12710                "path": file.to_str().unwrap(),
12711                "edits": [{
12712                    "op": "append",
12713                    "lines": ["footer"]
12714                }]
12715            });
12716
12717            let out = tool.execute("test", input, None).await.unwrap();
12718            assert!(!out.is_error);
12719
12720            let content = std::fs::read_to_string(&file).unwrap();
12721            assert!(
12722                content.contains("footer"),
12723                "content should contain footer: {content:?}"
12724            );
12725        });
12726    }
12727
12728    #[test]
12729    fn test_hashline_edit_overlapping_replace_ranges_rejected() {
12730        asupersync::test_utils::run_test(|| async {
12731            let dir = tempfile::tempdir().unwrap();
12732            let file = dir.path().join("test.txt");
12733            std::fs::write(&file, "a\nb\nc\nd\ne\n").unwrap();
12734
12735            let tool = HashlineEditTool::new(dir.path());
12736            let tag_b = format_hashline_tag(1, "b");
12737            let tag_d = format_hashline_tag(3, "d");
12738            let tag_c = format_hashline_tag(2, "c");
12739            let tag_e = format_hashline_tag(4, "e");
12740
12741            // Two overlapping replace ranges: lines 2-4 and lines 3-5
12742            let input = serde_json::json!({
12743                "path": file.to_str().unwrap(),
12744                "edits": [
12745                    { "op": "replace", "pos": &tag_b, "end": &tag_d, "lines": ["X"] },
12746                    { "op": "replace", "pos": &tag_c, "end": &tag_e, "lines": ["Y"] }
12747                ]
12748            });
12749
12750            let result = tool.execute("test", input, None).await;
12751            assert!(result.is_err());
12752            let err_msg = result.unwrap_err().to_string();
12753            assert!(
12754                err_msg.contains("Overlapping"),
12755                "error should mention overlapping: {err_msg}"
12756            );
12757        });
12758    }
12759
12760    #[test]
12761    fn test_hashline_edit_reversed_range_rejected() {
12762        asupersync::test_utils::run_test(|| async {
12763            let dir = tempfile::tempdir().unwrap();
12764            let file = dir.path().join("test.txt");
12765            std::fs::write(&file, "a\nb\nc\nd\n").unwrap();
12766
12767            let tool = HashlineEditTool::new(dir.path());
12768            let tag_b = format_hashline_tag(1, "b");
12769            let tag_d = format_hashline_tag(3, "d");
12770
12771            // End anchor before start anchor
12772            let input = serde_json::json!({
12773                "path": file.to_str().unwrap(),
12774                "edits": [{
12775                    "op": "replace",
12776                    "pos": &tag_d,
12777                    "end": &tag_b,
12778                    "lines": ["X"]
12779                }]
12780            });
12781
12782            let result = tool.execute("test", input, None).await;
12783            assert!(result.is_err());
12784            let err_msg = result.unwrap_err().to_string();
12785            assert!(
12786                err_msg.contains("before start"),
12787                "error should mention before start: {err_msg}"
12788            );
12789        });
12790    }
12791
12792    #[test]
12793    fn test_hashline_edit_trailing_newline_semantics() {
12794        asupersync::test_utils::run_test(|| async {
12795            let dir = tempfile::tempdir().unwrap();
12796            let file = dir.path().join("test.txt");
12797            // File with trailing newline: split produces ["line1", "line2", ""]
12798            std::fs::write(&file, "line1\nline2\n").unwrap();
12799
12800            let tool = HashlineEditTool::new(dir.path());
12801            let tag2 = format_hashline_tag(1, "line2");
12802
12803            // Replace line2, trailing newline should be preserved
12804            let input = serde_json::json!({
12805                "path": file.to_str().unwrap(),
12806                "edits": [{
12807                    "op": "replace",
12808                    "pos": tag2,
12809                    "lines": ["changed"]
12810                }]
12811            });
12812
12813            let out = tool.execute("test", input, None).await.unwrap();
12814            assert!(!out.is_error);
12815
12816            let content = std::fs::read_to_string(&file).unwrap();
12817            assert_eq!(content, "line1\nchanged\n");
12818        });
12819    }
12820}