import { resolve } from "node:path"; import type { ExecBackend, ExecBackendResolver } from "@dispatch/exec-backend"; import type { ToolContract, ToolResult } from "@dispatch/kernel"; // --- Pure types --- interface ValidatedArgs { readonly path: string; readonly oldString: string; readonly newString: string; readonly replaceAll: boolean; } export type ReplacementError = | { readonly kind: "identical" } | { readonly kind: "notFound" } | { readonly kind: "notUnique"; readonly count: number }; export interface ReplacementSuccess { readonly content: string; readonly count: number; } // --- Pure functions --- /** Pure: validate and coerce args from the model. */ export function validateArgs(args: unknown): ValidatedArgs | { readonly error: string } { if (args === null || args === undefined || typeof args !== "object") { return { error: "Error: Arguments must be an object." }; } const obj = args as Record; const rawPath = obj.path; if (typeof rawPath !== "string" || rawPath.length === 0) { return { error: 'Error: Missing or invalid "path" parameter (must be a non-empty string).' }; } const rawOld = obj.oldString; if (typeof rawOld !== "string" || rawOld.length === 0) { return { error: 'Error: Missing or invalid "oldString" parameter (must be a non-empty string).', }; } const rawNew = obj.newString; if (typeof rawNew !== "string") { return { error: 'Error: Missing or invalid "newString" parameter (must be a string).', }; } const rawReplaceAll = obj.replaceAll; const replaceAll = rawReplaceAll === true; return { path: rawPath, oldString: rawOld, newString: rawNew, replaceAll }; } /** Pure: compute the replacement result given file content + params. */ export function computeReplacement( content: string, oldString: string, newString: string, replaceAll: boolean, ): ReplacementSuccess | ReplacementError { if (oldString === newString) { return { kind: "identical" }; } if (oldString === "") { return { kind: "notFound" }; } if (!content.includes(oldString)) { return { kind: "notFound" }; } if (replaceAll) { const parts = content.split(oldString); const count = parts.length - 1; return { content: parts.join(newString), count }; } // Single replacement — check uniqueness. const firstIndex = content.indexOf(oldString); const secondIndex = content.indexOf(oldString, firstIndex + oldString.length); if (secondIndex !== -1) { // Count total occurrences. let count = 0; let idx = 0; while (true) { idx = content.indexOf(oldString, idx); if (idx === -1) break; count++; idx += oldString.length; } return { kind: "notUnique", count }; } return { content: content.slice(0, firstIndex) + newString + content.slice(firstIndex + oldString.length), count: 1, }; } // --- Diagnostics hook --- /** * Optional post-edit diagnostics hook. Returns formatted diagnostics string * (empty if none) + timing metadata. Injected by the extension from the LSP * service; absent when no LSP is available (graceful degradation). */ export type DiagnosticsHook = (opts: { readonly filePath: string; readonly text: string; readonly cwd: string; }) => Promise<{ readonly formatted: string; readonly slow: boolean; readonly timedOut: boolean; }>; // --- Shell / edge --- /** * Factory: create an edit_file ToolContract. * * `resolveBackend` is the injected seam: each `execute` resolves an * `ExecBackend` from `ctx.computerId` (undefined → local `node:fs`; a set * id → a remote SSH backend in a later wave). The tool programs against the * `ExecBackend` surface, never `node:fs` directly, so it is transport-agnostic. * * `workdir` is the fallback base directory when `ctx.cwd` is omitted. It is * injected so the tool is testable; `execute` prefers `ctx.cwd` when present. * * `diagnostics` is the post-edit LSP hook (errors+warnings from LSP servers * are appended to successful edit results, only when errors exist). It is * invoked LAZILY at edit time — the extension defers the LSP service lookup so * it resolves after LSP activates. When `ctx.computerId` is set (REMOTE) the * diagnostics call is skipped: LSP servers are local processes that can't see * remote files over SFTP, so the no-LSP degradation path is used instead. */ export function createEditFileTool(deps: { readonly resolveBackend: ExecBackendResolver; readonly workdir?: string; readonly diagnostics: DiagnosticsHook; }): ToolContract { const workdir = deps.workdir !== undefined ? resolve(deps.workdir) : undefined; return { name: "edit_file", description: "Perform an exact string replacement in an existing file. " + "Provide oldString (the text to find) and newString (the replacement). " + "By default replaces a single occurrence; set replaceAll to replace every match.", parameters: { type: "object", properties: { path: { type: "string", description: "Path to the file, relative to the working directory.", }, oldString: { type: "string", description: "The exact string to find and replace.", }, newString: { type: "string", description: "The string to replace oldString with.", }, replaceAll: { type: "boolean", description: "Replace all occurrences (default: false).", default: false, }, }, required: ["path", "oldString", "newString"], }, concurrencySafe: false, async execute(args: unknown, ctx): Promise { const validated = validateArgs(args); if ("error" in validated) { return { content: validated.error, isError: true }; } const { path: relPath, oldString, newString, replaceAll } = validated; const effectiveBase = ctx.cwd ? resolve(ctx.cwd) : workdir; if (effectiveBase === undefined) { return { content: "Error: No working directory (neither ctx.cwd nor a baked workdir was provided).", isError: true, }; } const resolvedPath = resolve(effectiveBase, relPath); const backend: ExecBackend = deps.resolveBackend(ctx.computerId); // Read the file. let content: string; try { content = await backend.readFile(resolvedPath); } catch (err: unknown) { const code = (err as NodeJS.ErrnoException).code; if (code === "ENOENT") { return { content: `Error: File "${relPath}" not found.`, isError: true }; } return { content: `Error reading file: ${err instanceof Error ? err.message : String(err)}`, isError: true, }; } // Pure replacement decision. const result = computeReplacement(content, oldString, newString, replaceAll); if ("kind" in result) { switch (result.kind) { case "identical": return { content: "Error: newString must differ from oldString.", isError: true, }; case "notFound": return { content: `Error: oldString not found in content of "${relPath}".`, isError: true, }; case "notUnique": return { content: `Error: Found ${result.count} matches for oldString in "${relPath}"; provide more surrounding context to make it unique, or set replaceAll.`, isError: true, }; } } // Write the modified content back. try { await backend.writeFile(resolvedPath, result.content); } catch (err: unknown) { return { content: `Error writing file: ${err instanceof Error ? err.message : String(err)}`, isError: true, }; } const plural = result.count === 1 ? "" : "s"; let baseContent = `Replaced ${result.count} occurrence${plural} in "${relPath}".`; // After a successful edit, query LSP diagnostics (if available). // Only append if there are actual errors/warnings (no noise on clean edits). const diagnostics = deps.diagnostics; if (diagnostics) { let diag: { readonly formatted: string; readonly slow: boolean; readonly timedOut: boolean; }; if (ctx.computerId !== undefined) { // REMOTE: LSP servers are local processes that can't see remote // files over SFTP — skip the diagnostics call (the no-LSP // degradation path). Forward-compatible: computerId is always // undefined this wave, so behavior is byte-identical to today. diag = { formatted: "", slow: false, timedOut: false }; } else { try { const cwd = ctx.cwd ?? process.cwd(); diag = await diagnostics({ filePath: resolvedPath, text: result.content, cwd, }); } catch { // LSP diagnostics failure is non-fatal — the edit already succeeded. diag = { formatted: "", slow: false, timedOut: false }; } } const suffix: string[] = []; if (diag.slow) { suffix.push( "⚠️ LSP is taking unusually long. If this happens more than once, raise it to the user.", ); } if (diag.formatted) { suffix.push(diag.formatted); } if (suffix.length > 0) { baseContent += `\n\n${suffix.join("\n\n")}`; } } return { content: baseContent }; }, }; }