/ src / utils / lib.ts
lib.ts
   1  import fs from "fs/promises";
   2  import path from "path";
   3  import os from "os";
   4  import { randomBytes } from "crypto";
   5  import { diffLines, createTwoFilesPatch } from "diff";
   6  import { minimatch } from "minimatch";
   7  import { normalizePath, expandHome } from "./path-utils.js";
   8  import { isPathWithinAllowedDirectories } from "./path-validation.js";
   9  
  10  // Global configuration - set by the main module
  11  let allowedDirectories: string[] = [];
  12  let ignoredFolders: string[] = [];
  13  let enabledTools: string[] = [];
  14  
  15  // Function to set allowed directories from the main module
  16  export function setAllowedDirectories(directories: string[]): void {
  17    allowedDirectories = [...directories];
  18  }
  19  
  20  // Function to get current allowed directories
  21  export function getAllowedDirectories(): string[] {
  22    return [...allowedDirectories];
  23  }
  24  
  25  // Function to set ignored folders from the main module
  26  export function setIgnoredFolders(folders: string[]): void {
  27    ignoredFolders = [...folders];
  28  }
  29  
  30  // Function to get current ignored folders
  31  export function getIgnoredFolders(): string[] {
  32    return [...ignoredFolders];
  33  }
  34  
  35  // Function to set enabled tools from the main module
  36  export function setEnabledTools(tools: string[]): void {
  37    enabledTools = [...tools];
  38  }
  39  
  40  // Function to get current enabled tools
  41  export function getEnabledTools(): string[] {
  42    return [...enabledTools];
  43  }
  44  
  45  // Function to check if a folder should be ignored
  46  export function shouldIgnoreFolder(folderName: string): boolean {
  47    if (ignoredFolders.length === 0) {
  48      return false;
  49    }
  50  
  51    // Check exact matches first
  52    if (ignoredFolders.includes(folderName)) {
  53      return true;
  54    }
  55  
  56    // Check glob patterns
  57    return ignoredFolders.some((pattern) => {
  58      try {
  59        return minimatch(folderName, pattern);
  60      } catch (error) {
  61        // If pattern is invalid, log warning but don't ignore
  62        console.warn(`Invalid ignore pattern: ${pattern}`);
  63        return false;
  64      }
  65    });
  66  }
  67  
  68  // Type definitions
  69  interface FileInfo {
  70    size: number;
  71    created: Date;
  72    modified: Date;
  73    accessed: Date;
  74    isDirectory: boolean;
  75    isFile: boolean;
  76    permissions: string;
  77  }
  78  
  79  export interface SearchOptions {
  80    excludePatterns?: string[];
  81  }
  82  
  83  export interface SearchResult {
  84    path: string;
  85    isDirectory: boolean;
  86  }
  87  
  88  // Grep interfaces and types
  89  export interface GrepOptions {
  90    caseInsensitive?: boolean;
  91    contextBefore?: number;
  92    contextAfter?: number;
  93    outputMode?: "content" | "files_with_matches" | "count";
  94    headLimit?: number;
  95    multiline?: boolean;
  96    fileType?: string;
  97    globPattern?: string;
  98  }
  99  
 100  export interface GrepMatch {
 101    file: string;
 102    line: number;
 103    content: string;
 104    contextBefore?: string[];
 105    contextAfter?: string[];
 106  }
 107  
 108  export interface GrepResult {
 109    mode: "content" | "files_with_matches" | "count";
 110    matches?: GrepMatch[];
 111    files?: string[];
 112    counts?: Map<string, number>;
 113    totalMatches: number;
 114    filesSearched: number;
 115  }
 116  
 117  // File type extensions mapping (ripgrep-compatible)
 118  const FILE_TYPE_EXTENSIONS: Record<string, string[]> = {
 119    js: [".js", ".jsx", ".mjs", ".cjs"],
 120    ts: [".ts", ".tsx", ".mts", ".cts"],
 121    py: [".py", ".pyi", ".pyw"],
 122    rust: [".rs"],
 123    go: [".go"],
 124    java: [".java"],
 125    cpp: [".cpp", ".cc", ".cxx", ".hpp", ".h", ".hxx"],
 126    c: [".c", ".h"],
 127    rb: [".rb"],
 128    php: [".php"],
 129    css: [".css", ".scss", ".sass", ".less"],
 130    html: [".html", ".htm"],
 131    json: [".json"],
 132    yaml: [".yaml", ".yml"],
 133    xml: [".xml"],
 134    md: [".md", ".markdown"],
 135    sql: [".sql"],
 136    sh: [".sh", ".bash"],
 137  };
 138  
 139  function getExtensionsForType(type: string): string[] {
 140    return FILE_TYPE_EXTENSIONS[type.toLowerCase()] || [];
 141  }
 142  
 143  // Pure Utility Functions
 144  export function formatSize(bytes: number): string {
 145    const units = ["B", "KB", "MB", "GB", "TB"];
 146    if (bytes === 0) return "0 B";
 147  
 148    const i = Math.floor(Math.log(bytes) / Math.log(1024));
 149  
 150    if (i < 0 || i === 0) return `${bytes} ${units[0]}`;
 151  
 152    const unitIndex = Math.min(i, units.length - 1);
 153    return `${(bytes / Math.pow(1024, unitIndex)).toFixed(2)} ${
 154      units[unitIndex]
 155    }`;
 156  }
 157  
 158  export function normalizeLineEndings(text: string): string {
 159    return text.replace(/\r\n/g, "\n");
 160  }
 161  
 162  export function detectLineEnding(content: string): "\r\n" | "\n" {
 163    return content.includes("\r\n") ? "\r\n" : "\n";
 164  }
 165  
 166  // Calculate diff statistics
 167  function calculateDiffStats(
 168    original: string,
 169    modified: string,
 170  ): {
 171    added: number;
 172    removed: number;
 173    modified: number;
 174  } {
 175    const originalLines = original.split("\n");
 176    const modifiedLines = modified.split("\n");
 177  
 178    let added = 0;
 179    let removed = 0;
 180  
 181    if (modifiedLines.length > originalLines.length) {
 182      added = modifiedLines.length - originalLines.length;
 183    } else if (originalLines.length > modifiedLines.length) {
 184      removed = originalLines.length - modifiedLines.length;
 185    }
 186  
 187    // Count modified lines (lines that exist in both but are different)
 188    const minLength = Math.min(originalLines.length, modifiedLines.length);
 189    let modifiedCount = 0;
 190    for (let i = 0; i < minLength; i++) {
 191      if (originalLines[i] !== modifiedLines[i]) {
 192        modifiedCount++;
 193      }
 194    }
 195  
 196    return { added, removed, modified: modifiedCount };
 197  }
 198  
 199  // Format detailed diff output with edit summaries
 200  function formatDetailedDiff(
 201    original: string,
 202    modified: string,
 203    filePath: string,
 204    results: MatchResult[],
 205  ): string {
 206    let output = "";
 207  
 208    // Edit Summary
 209    if (results.length > 0) {
 210      output += "Edit Summary:\n";
 211      results.forEach((result, idx) => {
 212        const editNum = results.length > 1 ? ` ${idx + 1}` : "";
 213        output += `  Edit${editNum}:\n`;
 214        output += `    Strategy: ${result.strategy}`;
 215  
 216        // Add strategy description for non-exact matches
 217        if (result.strategy === "flexible") {
 218          output += " (whitespace-insensitive)";
 219        } else if (result.strategy === "fuzzy") {
 220          output += " (token-based pattern matching)";
 221        }
 222        output += "\n";
 223  
 224        output += `    ${result.message}\n`;
 225  
 226        if (result.lineRange) {
 227          const lineDesc =
 228            result.lineRange.start === result.lineRange.end
 229              ? `line ${result.lineRange.start}`
 230              : `lines ${result.lineRange.start}-${result.lineRange.end}`;
 231          output += `    Location: ${lineDesc}\n`;
 232        }
 233  
 234        if (result.warning) {
 235          output += `    ⚠️  Warning: ${result.warning}\n`;
 236        }
 237  
 238        if (result.ambiguity) {
 239          output += `    ⚠️  Multiple matches found at ${result.ambiguity.locations}\n`;
 240          output += `        Replaced first occurrence only\n`;
 241          output += `        ${result.ambiguity.suggestion}\n`;
 242        }
 243      });
 244      output += "\n";
 245    }
 246  
 247    // Diff Statistics
 248    const stats = calculateDiffStats(original, modified);
 249    const totalChanges = stats.added + stats.removed + stats.modified;
 250  
 251    if (totalChanges > 0) {
 252      output += "Diff Statistics:\n";
 253      if (stats.added > 0) {
 254        output += `  +${stats.added} line${stats.added !== 1 ? "s" : ""} added\n`;
 255      }
 256      if (stats.removed > 0) {
 257        output += `  -${stats.removed} line${
 258          stats.removed !== 1 ? "s" : ""
 259        } removed\n`;
 260      }
 261      if (stats.modified > 0) {
 262        output += `  ~${stats.modified} line${
 263          stats.modified !== 1 ? "s" : ""
 264        } modified\n`;
 265      }
 266      output += `  Total: ${totalChanges} line${
 267        totalChanges !== 1 ? "s" : ""
 268      } changed\n\n`;
 269    }
 270  
 271    // Unified Diff
 272    const diff = createUnifiedDiff(original, modified, filePath);
 273    output += diff;
 274  
 275    // Notes
 276    const usedNonExact = results.some((r) => r.strategy !== "exact");
 277    const hasWarnings = results.some((r) => r.warning);
 278  
 279    if (usedNonExact || hasWarnings) {
 280      output += "\n\n";
 281      if (usedNonExact) {
 282        output +=
 283          "📝 Note: Non-exact matching strategies were used. Changes were applied successfully.\n";
 284      }
 285      if (hasWarnings) {
 286        output +=
 287          "⚠️  Please review the changes carefully to ensure they match your intentions.\n";
 288      }
 289    }
 290  
 291    return output;
 292  }
 293  
 294  export function createUnifiedDiff(
 295    originalContent: string,
 296    newContent: string,
 297    filepath: string = "file",
 298  ): string {
 299    // Ensure consistent line endings for diff
 300    const normalizedOriginal = normalizeLineEndings(originalContent);
 301    const normalizedNew = normalizeLineEndings(newContent);
 302  
 303    return createTwoFilesPatch(
 304      filepath,
 305      filepath,
 306      normalizedOriginal,
 307      normalizedNew,
 308      "original",
 309      "modified",
 310    );
 311  }
 312  
 313  // Security & Validation Functions
 314  export interface ValidatePathOptions {
 315    /**
 316     * When true, validatePath will attempt to create any missing parent
 317     * directories for the requested path, as long as they are within
 318     * the configured allowedDirectories.
 319     *
 320     * This is intended for write operations only. Read/search tools
 321     * should use the default behavior (no directory creation).
 322     */
 323    createParentIfMissing?: boolean;
 324  }
 325  
 326  async function ensureParentDirectoryExists(
 327    absolutePath: string,
 328  ): Promise<void> {
 329    const parentDir = path.dirname(absolutePath);
 330  
 331    // Fast path: if parentDir already exists, let the existing logic handle it
 332    try {
 333      const realParentPath = await fs.realpath(parentDir);
 334      const normalizedParent = normalizePath(realParentPath);
 335      if (!isPathWithinAllowedDirectories(normalizedParent, allowedDirectories)) {
 336        throw new Error(
 337          `Access denied - parent directory outside allowed directories: ${realParentPath} not in ${allowedDirectories.join(
 338            ", ",
 339          )}`,
 340        );
 341      }
 342      return;
 343    } catch (error) {
 344      if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
 345        // If it's not a "does not exist" error, rethrow and let validatePath handle it
 346        throw error;
 347      }
 348    }
 349  
 350    // Parent (or some ancestor) does not exist - we may need to create it.
 351    // Security: We only create directories if the final requested path is
 352    // within allowedDirectories (checked in validatePath before calling this),
 353    // and we validate each created directory as we go.
 354    const segments: string[] = [];
 355    let current = parentDir;
 356  
 357    // Walk up until we find an existing ancestor or hit filesystem root
 358    // Note: We don't trust dirname("/") to progress forever; stop when stable.
 359    while (true) {
 360      segments.push(current);
 361      const parent = path.dirname(current);
 362      if (parent === current) {
 363        break;
 364      }
 365      try {
 366        await fs.lstat(current);
 367        break; // Found an existing path
 368      } catch (error) {
 369        if ((error as NodeJS.ErrnoException).code === "ENOENT") {
 370          current = parent;
 371          continue;
 372        }
 373        throw error;
 374      }
 375    }
 376  
 377    // We collected segments from leaf up to the first existing ancestor/root.
 378    // Create them from top-most missing down to the immediate parentDir.
 379    for (let i = segments.length - 1; i >= 0; i--) {
 380      const dir = segments[i];
 381  
 382      // Normalize and check allowedDirectories before creating
 383      const normalizedDir = normalizePath(dir);
 384      if (!isPathWithinAllowedDirectories(normalizedDir, allowedDirectories)) {
 385        throw new Error(
 386          `Access denied - cannot create directory outside allowed directories: ${dir} not in ${allowedDirectories.join(
 387            ", ",
 388          )}`,
 389        );
 390      }
 391  
 392      try {
 393        await fs.mkdir(dir);
 394      } catch (error) {
 395        if ((error as NodeJS.ErrnoException).code === "EEXIST") {
 396          // Something already exists at this path; verify it's a directory
 397          const stats = await fs.lstat(dir);
 398          if (!stats.isDirectory()) {
 399            throw new Error(
 400              `Cannot create directory; path exists and is not a directory: ${dir}`,
 401            );
 402          }
 403          continue;
 404        }
 405        throw error;
 406      }
 407  
 408      // After creation, resolve real path and ensure it is still within allowed directories
 409      const realCreatedPath = await fs.realpath(dir);
 410      const normalizedCreated = normalizePath(realCreatedPath);
 411      if (
 412        !isPathWithinAllowedDirectories(normalizedCreated, allowedDirectories)
 413      ) {
 414        throw new Error(
 415          `Access denied - created directory symlink target outside allowed directories: ${realCreatedPath} not in ${allowedDirectories.join(
 416            ", ",
 417          )}`,
 418        );
 419      }
 420    }
 421  }
 422  
 423  export async function validatePath(
 424    requestedPath: string,
 425    options?: ValidatePathOptions,
 426  ): Promise<string> {
 427    const expandedPath = expandHome(requestedPath);
 428    const absolute = path.isAbsolute(expandedPath)
 429      ? path.resolve(expandedPath)
 430      : path.resolve(process.cwd(), expandedPath);
 431  
 432    const normalizedRequested = normalizePath(absolute);
 433  
 434    // Security: Check if path is within allowed directories before any file operations
 435    const isAllowed = isPathWithinAllowedDirectories(
 436      normalizedRequested,
 437      allowedDirectories,
 438    );
 439    if (!isAllowed) {
 440      throw new Error(
 441        `Access denied - path outside allowed directories: ${absolute} not in ${allowedDirectories.join(
 442          ", ",
 443        )}`,
 444      );
 445    }
 446  
 447    const createParentIfMissing = options?.createParentIfMissing === true;
 448  
 449    // Security: Handle symlinks by checking their real path to prevent symlink attacks
 450    // This prevents attackers from creating symlinks that point outside allowed directories
 451    try {
 452      const realPath = await fs.realpath(absolute);
 453      const normalizedReal = normalizePath(realPath);
 454      if (!isPathWithinAllowedDirectories(normalizedReal, allowedDirectories)) {
 455        throw new Error(
 456          `Access denied - symlink target outside allowed directories: ${realPath} not in ${allowedDirectories.join(
 457            ", ",
 458          )}`,
 459        );
 460      }
 461      return realPath;
 462    } catch (error) {
 463      // Security: For new files that don't exist yet, verify parent directory
 464      // This ensures we can't create files in unauthorized locations
 465      if ((error as NodeJS.ErrnoException).code === "ENOENT") {
 466        const parentDir = path.dirname(absolute);
 467        try {
 468          const realParentPath = await fs.realpath(parentDir);
 469          const normalizedParent = normalizePath(realParentPath);
 470          if (
 471            !isPathWithinAllowedDirectories(normalizedParent, allowedDirectories)
 472          ) {
 473            throw new Error(
 474              `Access denied - parent directory outside allowed directories: ${realParentPath} not in ${allowedDirectories.join(
 475                ", ",
 476              )}`,
 477            );
 478          }
 479          return absolute;
 480        } catch (parentError) {
 481          if (!createParentIfMissing) {
 482            throw new Error(`Parent directory does not exist: ${parentDir}`);
 483          }
 484  
 485          // Attempt to securely create the missing parent directory chain
 486          await ensureParentDirectoryExists(absolute);
 487          // After creation, return the absolute path for the new file. We intentionally
 488          // do not call fs.realpath on the file itself here, because it still may not
 489          // exist yet (for new files).
 490          return absolute;
 491        }
 492      }
 493      throw error;
 494    }
 495  }
 496  
 497  // File Operations
 498  export async function getFileStats(filePath: string): Promise<FileInfo> {
 499    const stats = await fs.stat(filePath);
 500    return {
 501      size: stats.size,
 502      created: stats.birthtime,
 503      modified: stats.mtime,
 504      accessed: stats.atime,
 505      isDirectory: stats.isDirectory(),
 506      isFile: stats.isFile(),
 507      permissions: stats.mode.toString(8).slice(-3),
 508    };
 509  }
 510  
 511  export async function readFileContent(
 512    filePath: string,
 513    encoding: string = "utf-8",
 514  ): Promise<string> {
 515    return await fs.readFile(filePath, encoding as BufferEncoding);
 516  }
 517  
 518  export async function writeFileContent(
 519    filePath: string,
 520    content: string,
 521  ): Promise<void> {
 522    try {
 523      // Security: 'wx' flag ensures exclusive creation - fails if file/symlink exists,
 524      // preventing writes through pre-existing symlinks
 525      await fs.writeFile(filePath, content, { encoding: "utf-8", flag: "wx" });
 526    } catch (error) {
 527      if ((error as NodeJS.ErrnoException).code === "EEXIST") {
 528        // Security: Use atomic rename to prevent race conditions where symlinks
 529        // could be created between validation and write. Rename operations
 530        // replace the target file atomically and don't follow symlinks.
 531        const tempPath = `${filePath}.${randomBytes(16).toString("hex")}.tmp`;
 532        try {
 533          await fs.writeFile(tempPath, content, "utf-8");
 534          await fs.rename(tempPath, filePath);
 535        } catch (renameError) {
 536          try {
 537            await fs.unlink(tempPath);
 538          } catch {}
 539          throw renameError;
 540        }
 541      } else {
 542        throw error;
 543      }
 544    }
 545  }
 546  
 547  // File Editing Functions
 548  export interface FileEdit {
 549    oldText: string;
 550    newText: string;
 551    instruction?: string;
 552    expectedOccurrences?: number;
 553  }
 554  
 555  interface MatchResult {
 556    strategy: "exact" | "flexible" | "fuzzy";
 557    occurrences: number;
 558    modifiedContent: string;
 559    message: string;
 560    warning?: string;
 561    ambiguity?: {
 562      locations: string;
 563      suggestion: string;
 564    };
 565    lineRange?: {
 566      start: number;
 567      end: number;
 568    };
 569  }
 570  
 571  // Helper function to escape regex special characters
 572  function escapeRegex(str: string): string {
 573    return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
 574  }
 575  
 576  // Tier 1: Exact Match
 577  function tryExactMatch(
 578    content: string,
 579    oldText: string,
 580    newText: string,
 581  ): MatchResult | null {
 582    const searchText = normalizeLineEndings(oldText);
 583    const replaceText = normalizeLineEndings(newText);
 584  
 585    // Count occurrences
 586    const escapedSearch = escapeRegex(searchText);
 587    const occurrences = (content.match(new RegExp(escapedSearch, "g")) || [])
 588      .length;
 589  
 590    if (occurrences === 0) {
 591      return null;
 592    }
 593  
 594    // Replace first occurrence only
 595    const modified = content.replace(searchText, replaceText);
 596  
 597    // Find the line number where the match occurred
 598    const matchIndex = content.indexOf(searchText);
 599    const linesBeforeMatch = content.substring(0, matchIndex).split("\n").length;
 600  
 601    return {
 602      strategy: "exact",
 603      occurrences,
 604      modifiedContent: modified,
 605      message: `Exact match found (${occurrences} occurrence${
 606        occurrences > 1 ? "s" : ""
 607      })`,
 608      lineRange: {
 609        start: linesBeforeMatch,
 610        end: linesBeforeMatch + searchText.split("\n").length - 1,
 611      },
 612      ambiguity:
 613        occurrences > 1
 614          ? {
 615              locations: `${occurrences} locations in file`,
 616              suggestion:
 617                "Consider adding more context to oldText to uniquely identify the target",
 618            }
 619          : undefined,
 620    };
 621  }
 622  
 623  // Tier 2: Flexible Match (whitespace-insensitive)
 624  function tryFlexibleMatch(
 625    content: string,
 626    oldText: string,
 627    newText: string,
 628  ): MatchResult | null {
 629    const contentLines = content.split("\n");
 630    const searchLines = oldText.split("\n");
 631    const replaceLines = newText.split("\n");
 632  
 633    const matches: Array<{
 634      startLine: number;
 635      endLine: number;
 636      indentation: string;
 637    }> = [];
 638  
 639    // Find all matching windows
 640    for (let i = 0; i <= contentLines.length - searchLines.length; i++) {
 641      const window = contentLines.slice(i, i + searchLines.length);
 642  
 643      // Compare with trimmed content (whitespace-insensitive)
 644      const isMatch = searchLines.every(
 645        (searchLine, j) => searchLine.trim() === window[j].trim(),
 646      );
 647  
 648      if (isMatch) {
 649        const indentation = window[0].match(/^(\s*)/)?.[0] || "";
 650        matches.push({
 651          startLine: i + 1,
 652          endLine: i + searchLines.length,
 653          indentation,
 654        });
 655      }
 656    }
 657  
 658    if (matches.length === 0) {
 659      return null;
 660    }
 661  
 662    // Apply replacement to first match, preserving indentation
 663    const firstMatch = matches[0];
 664    const indentedReplaceLines = replaceLines.map((line) => {
 665      if (line.trim() === "") return ""; // Preserve empty lines
 666      return firstMatch.indentation + line.trim();
 667    });
 668  
 669    contentLines.splice(
 670      firstMatch.startLine - 1,
 671      firstMatch.endLine - firstMatch.startLine + 1,
 672      ...indentedReplaceLines,
 673    );
 674  
 675    return {
 676      strategy: "flexible",
 677      occurrences: matches.length,
 678      modifiedContent: contentLines.join("\n"),
 679      message: `Flexible match found at line ${firstMatch.startLine} (${
 680        matches.length
 681      } total occurrence${matches.length > 1 ? "s" : ""})`,
 682      lineRange: {
 683        start: firstMatch.startLine,
 684        end: firstMatch.endLine,
 685      },
 686      ambiguity:
 687        matches.length > 1
 688          ? {
 689              locations: matches
 690                .map((m) => `lines ${m.startLine}-${m.endLine}`)
 691                .join(", "),
 692              suggestion:
 693                "Consider adding more context to oldText to uniquely identify the target",
 694            }
 695          : undefined,
 696    };
 697  }
 698  
 699  // Tier 3: Fuzzy Match (token-based regex)
 700  function tryFuzzyMatch(
 701    content: string,
 702    oldText: string,
 703    newText: string,
 704  ): MatchResult | null {
 705    // Tokenize around code delimiters
 706    const delimiters = [
 707      "(",
 708      ")",
 709      ":",
 710      "[",
 711      "]",
 712      "{",
 713      "}",
 714      ">",
 715      "<",
 716      "=",
 717      ";",
 718      ",",
 719    ];
 720  
 721    let tokenizedSearch = oldText;
 722    for (const delim of delimiters) {
 723      tokenizedSearch = tokenizedSearch.split(delim).join(` ${delim} `);
 724    }
 725  
 726    // Split on whitespace and filter empties
 727    const tokens = tokenizedSearch.split(/\s+/).filter(Boolean);
 728  
 729    if (tokens.length === 0) {
 730      return null;
 731    }
 732  
 733    // Build regex: tokens separated by flexible whitespace
 734    const escapedTokens = tokens.map((t) => escapeRegex(t));
 735    const pattern = `^(\\s*)${escapedTokens.join("\\s*")}`;
 736    const fuzzyRegex = new RegExp(pattern, "m");
 737  
 738    const match = fuzzyRegex.exec(content);
 739  
 740    if (!match) {
 741      return null;
 742    }
 743  
 744    const matchedText = match[0];
 745    const indentation = match[1] || "";
 746  
 747    // Apply indentation to replacement
 748    const replaceLines = newText.split("\n");
 749    const indentedReplace = replaceLines
 750      .map((line) => {
 751        if (line.trim() === "") return "";
 752        return indentation + line.trim();
 753      })
 754      .join("\n");
 755  
 756    const modified = content.replace(fuzzyRegex, indentedReplace);
 757  
 758    // Calculate approximate position
 759    const linesBeforeMatch = content.substring(0, match.index).split("\n").length;
 760  
 761    return {
 762      strategy: "fuzzy",
 763      occurrences: 1, // Fuzzy match only replaces first occurrence
 764      modifiedContent: modified,
 765      message: `Fuzzy match found near line ${linesBeforeMatch}`,
 766      lineRange: {
 767        start: linesBeforeMatch,
 768        end: linesBeforeMatch + matchedText.split("\n").length - 1,
 769      },
 770      warning:
 771        "Fuzzy matching was used. Please review changes carefully to ensure accuracy.",
 772    };
 773  }
 774  
 775  // Apply edit with strategy selection
 776  function applyEditWithStrategy(
 777    content: string,
 778    edit: FileEdit,
 779    strategy: "exact" | "flexible" | "fuzzy" | "auto",
 780    failOnAmbiguous: boolean,
 781  ): MatchResult {
 782    let result: MatchResult | null = null;
 783    const attemptedStrategies: string[] = [];
 784  
 785    if (strategy === "auto") {
 786      // Try each strategy in order
 787      result = tryExactMatch(content, edit.oldText, edit.newText);
 788      if (result) {
 789        if (
 790          failOnAmbiguous &&
 791          result.occurrences > 1 &&
 792          (!edit.expectedOccurrences || edit.expectedOccurrences === 1)
 793        ) {
 794          throw new Error(
 795            `Ambiguous match: found ${result.occurrences} occurrences of the search text.\n` +
 796              (edit.instruction ? `Edit: ${edit.instruction}\n` : "") +
 797              `Locations: ${result.ambiguity?.locations || "multiple"}\n` +
 798              `Suggestion: ${
 799                result.ambiguity?.suggestion ||
 800                "Add more context to uniquely identify the target"
 801              }`,
 802          );
 803        }
 804        return result;
 805      }
 806      attemptedStrategies.push("exact");
 807  
 808      result = tryFlexibleMatch(content, edit.oldText, edit.newText);
 809      if (result) {
 810        if (
 811          failOnAmbiguous &&
 812          result.occurrences > 1 &&
 813          (!edit.expectedOccurrences || edit.expectedOccurrences === 1)
 814        ) {
 815          throw new Error(
 816            `Ambiguous match: found ${result.occurrences} occurrences of the search text.\n` +
 817              (edit.instruction ? `Edit: ${edit.instruction}\n` : "") +
 818              `Locations: ${result.ambiguity?.locations || "multiple"}\n` +
 819              `Suggestion: ${
 820                result.ambiguity?.suggestion ||
 821                "Add more context to uniquely identify the target"
 822              }`,
 823          );
 824        }
 825        return result;
 826      }
 827      attemptedStrategies.push("flexible");
 828  
 829      result = tryFuzzyMatch(content, edit.oldText, edit.newText);
 830      if (result) return result;
 831      attemptedStrategies.push("fuzzy");
 832    } else {
 833      // Use specified strategy only
 834      switch (strategy) {
 835        case "exact":
 836          result = tryExactMatch(content, edit.oldText, edit.newText);
 837          attemptedStrategies.push("exact");
 838          break;
 839        case "flexible":
 840          result = tryFlexibleMatch(content, edit.oldText, edit.newText);
 841          attemptedStrategies.push("flexible");
 842          break;
 843        case "fuzzy":
 844          result = tryFuzzyMatch(content, edit.oldText, edit.newText);
 845          attemptedStrategies.push("fuzzy");
 846          break;
 847      }
 848  
 849      if (
 850        result &&
 851        failOnAmbiguous &&
 852        result.occurrences > 1 &&
 853        (!edit.expectedOccurrences || edit.expectedOccurrences === 1)
 854      ) {
 855        throw new Error(
 856          `Ambiguous match: found ${result.occurrences} occurrences of the search text.\n` +
 857            (edit.instruction ? `Edit: ${edit.instruction}\n` : "") +
 858            `Locations: ${result.ambiguity?.locations || "multiple"}\n` +
 859            `Suggestion: ${
 860              result.ambiguity?.suggestion ||
 861              "Add more context to uniquely identify the target"
 862            }`,
 863        );
 864      }
 865    }
 866  
 867    // No match found - throw error
 868    if (!result) {
 869      let errorMsg = "Failed to apply edit";
 870      if (edit.instruction) {
 871        errorMsg += `\nEdit goal: ${edit.instruction}`;
 872      }
 873      errorMsg += `\n\nSearched for:\n${edit.oldText}\n`;
 874      errorMsg += `\nAttempted strategies: ${attemptedStrategies.join(", ")}`;
 875      errorMsg += `\n\nTroubleshooting tips:`;
 876      errorMsg += `\n- Ensure oldText matches the file content exactly (check whitespace, indentation)`;
 877      errorMsg += `\n- Use the read_file tool to verify current file content`;
 878      errorMsg += `\n- Include 3-5 lines of context before and after the target change`;
 879      errorMsg += `\n- Try matchingStrategy: "flexible" if whitespace is the issue`;
 880  
 881      throw new Error(errorMsg);
 882    }
 883  
 884    // Validate occurrence count
 885    if (
 886      edit.expectedOccurrences &&
 887      result.occurrences !== edit.expectedOccurrences
 888    ) {
 889      throw new Error(
 890        `Expected ${edit.expectedOccurrences} occurrence(s) but found ${result.occurrences}\n` +
 891          (edit.instruction ? `Edit: ${edit.instruction}\n` : "") +
 892          `Strategy used: ${result.strategy}`,
 893      );
 894    }
 895  
 896    return result;
 897  }
 898  
 899  export async function applyFileEdits(
 900    filePath: string,
 901    edits: FileEdit[],
 902    dryRun: boolean = false,
 903    matchingStrategy: "exact" | "flexible" | "fuzzy" | "auto" = "auto",
 904    failOnAmbiguous: boolean = true,
 905    returnMetadata?: boolean,
 906  ): Promise<string | { diff: string; metadata: MatchResult[] }> {
 907    // Read file content and detect original line ending
 908    const rawContent = await fs.readFile(filePath, "utf-8");
 909    const originalLineEnding = detectLineEnding(rawContent);
 910  
 911    // Normalize line endings for processing
 912    const content = normalizeLineEndings(rawContent);
 913  
 914    // Apply edits sequentially, tracking results
 915    let modifiedContent = content;
 916    const editResults: MatchResult[] = [];
 917  
 918    for (const edit of edits) {
 919      const result = applyEditWithStrategy(
 920        modifiedContent,
 921        edit,
 922        matchingStrategy,
 923        failOnAmbiguous,
 924      );
 925      editResults.push(result);
 926      modifiedContent = result.modifiedContent;
 927    }
 928  
 929    // Create detailed diff output with edit summaries
 930    const detailedDiff = formatDetailedDiff(
 931      content,
 932      modifiedContent,
 933      filePath,
 934      editResults,
 935    );
 936  
 937    // Format diff with appropriate number of backticks
 938    let numBackticks = 3;
 939    while (detailedDiff.includes("`".repeat(numBackticks))) {
 940      numBackticks++;
 941    }
 942    const formattedDiff = `${"`".repeat(
 943      numBackticks,
 944    )}diff\n${detailedDiff}${"`".repeat(numBackticks)}\n\n`;
 945  
 946    if (!dryRun) {
 947      // Restore original line endings before writing
 948      const contentToWrite =
 949        originalLineEnding === "\r\n"
 950          ? modifiedContent.replace(/\n/g, "\r\n")
 951          : modifiedContent;
 952  
 953      // Security: Use atomic rename to prevent race conditions where symlinks
 954      // could be created between validation and write. Rename operations
 955      // replace the target file atomically and don't follow symlinks.
 956      const tempPath = `${filePath}.${randomBytes(16).toString("hex")}.tmp`;
 957      try {
 958        await fs.writeFile(tempPath, contentToWrite, "utf-8");
 959        await fs.rename(tempPath, filePath);
 960      } catch (error) {
 961        try {
 962          await fs.unlink(tempPath);
 963        } catch {}
 964        throw error;
 965      }
 966    }
 967  
 968    if (returnMetadata) {
 969      return {
 970        diff: formattedDiff,
 971        metadata: editResults,
 972      };
 973    }
 974  
 975    return formattedDiff;
 976  }
 977  
 978  // Memory-efficient implementation to get the last N lines of a file
 979  export async function tailFile(
 980    filePath: string,
 981    numLines: number,
 982  ): Promise<string> {
 983    const CHUNK_SIZE = 1024; // Read 1KB at a time
 984    const stats = await fs.stat(filePath);
 985    const fileSize = stats.size;
 986  
 987    if (fileSize === 0) return "";
 988  
 989    // Open file for reading
 990    const fileHandle = await fs.open(filePath, "r");
 991    try {
 992      const lines: string[] = [];
 993      let position = fileSize;
 994      let chunk = Buffer.alloc(CHUNK_SIZE);
 995      let linesFound = 0;
 996      let remainingText = "";
 997  
 998      // Read chunks from the end of the file until we have enough lines
 999      while (position > 0 && linesFound < numLines) {
1000        const size = Math.min(CHUNK_SIZE, position);
1001        position -= size;
1002  
1003        const { bytesRead } = await fileHandle.read(chunk, 0, size, position);
1004        if (!bytesRead) break;
1005  
1006        // Get the chunk as a string and prepend any remaining text from previous iteration
1007        const readData = chunk.slice(0, bytesRead).toString("utf-8");
1008        const chunkText = readData + remainingText;
1009  
1010        // Split by newlines and count
1011        const chunkLines = normalizeLineEndings(chunkText).split("\n");
1012  
1013        // If this isn't the end of the file, the first line is likely incomplete
1014        // Save it to prepend to the next chunk
1015        if (position > 0) {
1016          remainingText = chunkLines[0];
1017          chunkLines.shift(); // Remove the first (incomplete) line
1018        }
1019  
1020        // Add lines to our result (up to the number we need)
1021        for (
1022          let i = chunkLines.length - 1;
1023          i >= 0 && linesFound < numLines;
1024          i--
1025        ) {
1026          lines.unshift(chunkLines[i]);
1027          linesFound++;
1028        }
1029      }
1030  
1031      return lines.join("\n");
1032    } finally {
1033      await fileHandle.close();
1034    }
1035  }
1036  
1037  // New function to get the first N lines of a file
1038  export async function headFile(
1039    filePath: string,
1040    numLines: number,
1041  ): Promise<string> {
1042    const fileHandle = await fs.open(filePath, "r");
1043    try {
1044      const lines: string[] = [];
1045      let buffer = "";
1046      let bytesRead = 0;
1047      const chunk = Buffer.alloc(1024); // 1KB buffer
1048  
1049      // Read chunks and count lines until we have enough or reach EOF
1050      while (lines.length < numLines) {
1051        const result = await fileHandle.read(chunk, 0, chunk.length, bytesRead);
1052        if (result.bytesRead === 0) break; // End of file
1053        bytesRead += result.bytesRead;
1054        buffer += chunk.slice(0, result.bytesRead).toString("utf-8");
1055  
1056        const newLineIndex = buffer.lastIndexOf("\n");
1057        if (newLineIndex !== -1) {
1058          const completeLines = buffer.slice(0, newLineIndex).split("\n");
1059          buffer = buffer.slice(newLineIndex + 1);
1060          for (const line of completeLines) {
1061            lines.push(line);
1062            if (lines.length >= numLines) break;
1063          }
1064        }
1065      }
1066  
1067      // If there is leftover content and we still need lines, add it
1068      if (buffer.length > 0 && lines.length < numLines) {
1069        lines.push(buffer);
1070      }
1071  
1072      return lines.join("\n");
1073    } finally {
1074      await fileHandle.close();
1075    }
1076  }
1077  
1078  /**
1079   * Read a specific range of lines from a file
1080   * Memory-efficient implementation that reads sequentially and stops after reaching endLine
1081   * @param filePath - Path to the file to read
1082   * @param startLine - Starting line number (1-indexed, inclusive)
1083   * @param endLine - Ending line number (1-indexed, inclusive)
1084   * @returns Promise resolving to the requested lines as a string
1085   */
1086  export async function rangeFile(
1087    filePath: string,
1088    startLine: number,
1089    endLine: number,
1090  ): Promise<string> {
1091    const CHUNK_SIZE = 1024; // Read 1KB at a time
1092    const fileHandle = await fs.open(filePath, "r");
1093  
1094    try {
1095      const targetLines: string[] = [];
1096      let currentLineNumber = 0;
1097      let buffer = "";
1098      let bytesRead = 0;
1099      const chunk = Buffer.alloc(CHUNK_SIZE);
1100  
1101      // Read file sequentially until we reach the end line
1102      while (currentLineNumber < endLine) {
1103        const result = await fileHandle.read(chunk, 0, chunk.length, bytesRead);
1104  
1105        // End of file reached
1106        if (result.bytesRead === 0) {
1107          // Process any remaining buffer content
1108          if (buffer.length > 0 && currentLineNumber + 1 >= startLine) {
1109            currentLineNumber++;
1110            if (currentLineNumber >= startLine && currentLineNumber <= endLine) {
1111              targetLines.push(buffer);
1112            }
1113          }
1114          break;
1115        }
1116  
1117        bytesRead += result.bytesRead;
1118        buffer += chunk.slice(0, result.bytesRead).toString("utf-8");
1119  
1120        // Process complete lines in buffer
1121        let newLineIndex = buffer.indexOf("\n");
1122        while (newLineIndex !== -1) {
1123          const line = buffer.slice(0, newLineIndex);
1124          buffer = buffer.slice(newLineIndex + 1);
1125          currentLineNumber++;
1126  
1127          // Check if this line is within our target range
1128          if (currentLineNumber >= startLine && currentLineNumber <= endLine) {
1129            targetLines.push(line);
1130          }
1131  
1132          // Early exit if we've collected all needed lines
1133          if (currentLineNumber >= endLine) {
1134            break;
1135          }
1136  
1137          newLineIndex = buffer.indexOf("\n");
1138        }
1139  
1140        // Early exit if we've reached the end line
1141        if (currentLineNumber >= endLine) {
1142          break;
1143        }
1144      }
1145  
1146      return targetLines.join("\n");
1147    } finally {
1148      await fileHandle.close();
1149    }
1150  }
1151  
1152  export async function searchFilesWithValidation(
1153    rootPath: string,
1154    pattern: string,
1155    allowedDirectories: string[],
1156    options: SearchOptions = {},
1157  ): Promise<string[]> {
1158    const { excludePatterns = [] } = options;
1159    const results: string[] = [];
1160  
1161    // Check if pattern requires recursive search (contains ** or has path separators)
1162    const needsRecursion =
1163      pattern.includes("**") || pattern.includes("/") || pattern.includes("\\");
1164  
1165    async function search(currentPath: string, currentDepth: number = 0) {
1166      const entries = await fs.readdir(currentPath, { withFileTypes: true });
1167  
1168      for (const entry of entries) {
1169        const fullPath = path.join(currentPath, entry.name);
1170  
1171        try {
1172          await validatePath(fullPath);
1173  
1174          const relativePath = path.relative(rootPath, fullPath);
1175          const shouldExclude = excludePatterns.some((excludePattern) =>
1176            minimatch(relativePath, excludePattern, { dot: true }),
1177          );
1178  
1179          if (shouldExclude) continue;
1180  
1181          // Use glob matching for the search pattern
1182          if (minimatch(relativePath, pattern, { dot: true })) {
1183            results.push(fullPath);
1184          }
1185  
1186          // Only recurse if pattern requires it and we're not too deep
1187          // Limit recursion depth to prevent infinite loops or excessive searching
1188          if (entry.isDirectory() && needsRecursion && currentDepth < 10) {
1189            await search(fullPath, currentDepth + 1);
1190          }
1191        } catch {
1192          continue;
1193        }
1194      }
1195    }
1196  
1197    await search(rootPath);
1198    return results;
1199  }
1200  
1201  export async function grepFilesWithValidation(
1202    pattern: string,
1203    searchPath: string,
1204    allowedDirectories: string[],
1205    options: GrepOptions = {},
1206  ): Promise<GrepResult> {
1207    const {
1208      caseInsensitive = false,
1209      contextBefore = 0,
1210      contextAfter = 0,
1211      outputMode = "content",
1212      headLimit,
1213      multiline = false,
1214      fileType,
1215      globPattern,
1216    } = options;
1217  
1218    // Create regex with appropriate flags (no 'g' flag to avoid stateful matching)
1219    const flags = caseInsensitive ? "i" : "";
1220    const dotAllFlag = multiline ? "s" : "";
1221    let regex: RegExp;
1222  
1223    try {
1224      regex = new RegExp(pattern, flags + dotAllFlag);
1225    } catch (error) {
1226      throw new Error(
1227        `Invalid regex pattern: ${pattern} - ${
1228          error instanceof Error ? error.message : String(error)
1229        }`,
1230      );
1231    }
1232  
1233    const result: GrepResult = {
1234      mode: outputMode,
1235      matches: outputMode === "content" ? [] : undefined,
1236      files: outputMode === "files_with_matches" ? [] : undefined,
1237      counts: outputMode === "count" ? new Map() : undefined,
1238      totalMatches: 0,
1239      filesSearched: 0,
1240    };
1241  
1242    // Determine if we need to search recursively
1243    const stats = await fs.stat(searchPath);
1244    const isDirectory = stats.isDirectory();
1245  
1246    async function searchFile(filePath: string): Promise<void> {
1247      // Validate path against allowed directories
1248      try {
1249        await validatePath(filePath);
1250      } catch {
1251        return; // Skip files outside allowed directories
1252      }
1253  
1254      // Apply file type filter
1255      if (fileType) {
1256        const extensions = getExtensionsForType(fileType);
1257        if (extensions.length > 0) {
1258          const ext = path.extname(filePath).toLowerCase();
1259          if (!extensions.includes(ext)) {
1260            return; // Skip files that don't match type
1261          }
1262        }
1263      }
1264  
1265      // Apply glob filter
1266      if (globPattern) {
1267        const relativePath = path.relative(searchPath, filePath);
1268        if (!minimatch(relativePath, globPattern, { dot: true })) {
1269          return; // Skip files that don't match glob
1270        }
1271      }
1272  
1273      // Check file size (limit to 100MB to prevent memory issues)
1274      const MAX_FILE_SIZE = 100 * 1024 * 1024;
1275      try {
1276        const fileStats = await fs.stat(filePath);
1277        if (fileStats.size > MAX_FILE_SIZE) {
1278          console.warn(
1279            `Skipping large file: ${filePath} (${formatSize(fileStats.size)})`,
1280          );
1281          return;
1282        }
1283      } catch {
1284        return; // Skip if can't stat
1285      }
1286  
1287      result.filesSearched++;
1288  
1289      try {
1290        const content = await fs.readFile(filePath, "utf-8");
1291        let fileMatchCount = 0;
1292        let fileHasMatch = false;
1293  
1294        if (multiline) {
1295          // For multiline mode, search the entire content
1296          const matches = content.match(new RegExp(pattern, flags + "g"));
1297          if (matches) {
1298            fileHasMatch = true;
1299            fileMatchCount = matches.length;
1300            result.totalMatches += matches.length;
1301  
1302            if (outputMode === "content") {
1303              // For multiline, we'll split by lines and find which lines have matches
1304              const lines = normalizeLineEndings(content).split("\n");
1305              for (let i = 0; i < lines.length; i++) {
1306                const line = lines[i];
1307                if (regex.test(line)) {
1308                  if (headLimit && result.matches!.length >= headLimit) {
1309                    break;
1310                  }
1311  
1312                  const match: GrepMatch = {
1313                    file: filePath,
1314                    line: i + 1,
1315                    content: line,
1316                  };
1317  
1318                  // Add context lines if requested
1319                  if (contextBefore > 0) {
1320                    match.contextBefore = [];
1321                    for (let j = Math.max(0, i - contextBefore); j < i; j++) {
1322                      match.contextBefore.push(lines[j]);
1323                    }
1324                  }
1325  
1326                  if (contextAfter > 0) {
1327                    match.contextAfter = [];
1328                    for (
1329                      let j = i + 1;
1330                      j < Math.min(lines.length, i + 1 + contextAfter);
1331                      j++
1332                    ) {
1333                      match.contextAfter.push(lines[j]);
1334                    }
1335                  }
1336  
1337                  result.matches!.push(match);
1338                }
1339              }
1340            }
1341          }
1342        } else {
1343          // For single-line mode, search line by line
1344          const lines = normalizeLineEndings(content).split("\n");
1345          for (let i = 0; i < lines.length; i++) {
1346            const line = lines[i];
1347  
1348            if (regex.test(line)) {
1349              fileHasMatch = true;
1350              fileMatchCount++;
1351              result.totalMatches++;
1352  
1353              // Handle different output modes
1354              if (outputMode === "content") {
1355                // Check head limit for content mode
1356                if (headLimit && result.matches!.length >= headLimit) {
1357                  break; // Stop processing this file
1358                }
1359  
1360                const match: GrepMatch = {
1361                  file: filePath,
1362                  line: i + 1,
1363                  content: line,
1364                };
1365  
1366                // Add context lines if requested
1367                if (contextBefore > 0) {
1368                  match.contextBefore = [];
1369                  for (let j = Math.max(0, i - contextBefore); j < i; j++) {
1370                    match.contextBefore.push(lines[j]);
1371                  }
1372                }
1373  
1374                if (contextAfter > 0) {
1375                  match.contextAfter = [];
1376                  for (
1377                    let j = i + 1;
1378                    j < Math.min(lines.length, i + 1 + contextAfter);
1379                    j++
1380                  ) {
1381                    match.contextAfter.push(lines[j]);
1382                  }
1383                }
1384  
1385                result.matches!.push(match);
1386              }
1387            }
1388          }
1389        }
1390  
1391        // Handle files_with_matches mode
1392        if (outputMode === "files_with_matches" && fileHasMatch) {
1393          if (!headLimit || result.files!.length < headLimit) {
1394            result.files!.push(filePath);
1395          }
1396        }
1397  
1398        // Handle count mode
1399        if (outputMode === "count" && fileMatchCount > 0) {
1400          result.counts!.set(filePath, fileMatchCount);
1401        }
1402      } catch (error) {
1403        // Skip binary files or files we can't read
1404        return;
1405      }
1406    }
1407  
1408    async function searchDirectory(
1409      dirPath: string,
1410      depth: number = 0,
1411    ): Promise<void> {
1412      // Limit recursion depth
1413      if (depth > 10) return;
1414  
1415      try {
1416        const entries = await fs.readdir(dirPath, { withFileTypes: true });
1417  
1418        for (const entry of entries) {
1419          const fullPath = path.join(dirPath, entry.name);
1420  
1421          // Skip ignored folders
1422          if (entry.isDirectory() && shouldIgnoreFolder(entry.name)) {
1423            continue;
1424          }
1425  
1426          if (entry.isDirectory()) {
1427            await searchDirectory(fullPath, depth + 1);
1428          } else if (entry.isFile()) {
1429            await searchFile(fullPath);
1430  
1431            // Check global head limit
1432            if (headLimit) {
1433              if (
1434                outputMode === "content" &&
1435                result.matches!.length >= headLimit
1436              ) {
1437                return; // Stop searching
1438              }
1439              if (
1440                outputMode === "files_with_matches" &&
1441                result.files!.length >= headLimit
1442              ) {
1443                return;
1444              }
1445            }
1446          }
1447        }
1448      } catch {
1449        // Skip directories we can't read
1450        return;
1451      }
1452    }
1453  
1454    // Execute search
1455    if (isDirectory) {
1456      await searchDirectory(searchPath);
1457    } else {
1458      await searchFile(searchPath);
1459    }
1460  
1461    return result;
1462  }