From 4ad5a209c484bb502155dc4aebc7e5a7ec9d39f1 Mon Sep 17 00:00:00 2001 From: imfozilbek Date: Mon, 1 Dec 2025 01:44:45 +0500 Subject: [PATCH] feat(ipuaro): add edit tools (v0.6.0) Add file editing capabilities: - EditLinesTool: replace lines with hash conflict detection - CreateFileTool: create files with directory auto-creation - DeleteFileTool: delete files from filesystem and storage Total: 664 tests, 97.77% coverage --- packages/ipuaro/CHANGELOG.md | 33 ++ packages/ipuaro/package.json | 2 +- .../tools/edit/CreateFileTool.ts | 140 +++++ .../tools/edit/DeleteFileTool.ts | 136 +++++ .../tools/edit/EditLinesTool.ts | 226 ++++++++ .../src/infrastructure/tools/edit/index.ts | 4 + .../ipuaro/src/infrastructure/tools/index.ts | 5 + .../domain/value-objects/ChatMessage.test.ts | 7 + .../infrastructure/indexer/ASTParser.test.ts | 60 +++ .../indexer/FileScanner.test.ts | 26 + .../indexer/IndexBuilder.test.ts | 40 ++ .../indexer/MetaAnalyzer.test.ts | 38 ++ .../infrastructure/indexer/Watchdog.test.ts | 58 +++ .../infrastructure/llm/OllamaClient.test.ts | 184 +++++++ .../unit/infrastructure/llm/prompts.test.ts | 439 ++++++++++++++++ .../tools/edit/CreateFileTool.test.ts | 335 ++++++++++++ .../tools/edit/DeleteFileTool.test.ts | 274 ++++++++++ .../tools/edit/EditLinesTool.test.ts | 493 ++++++++++++++++++ packages/ipuaro/vitest.config.ts | 8 +- 19 files changed, 2503 insertions(+), 5 deletions(-) create mode 100644 packages/ipuaro/src/infrastructure/tools/edit/CreateFileTool.ts create mode 100644 packages/ipuaro/src/infrastructure/tools/edit/DeleteFileTool.ts create mode 100644 packages/ipuaro/src/infrastructure/tools/edit/EditLinesTool.ts create mode 100644 packages/ipuaro/src/infrastructure/tools/edit/index.ts create mode 100644 packages/ipuaro/tests/unit/infrastructure/tools/edit/CreateFileTool.test.ts create mode 100644 packages/ipuaro/tests/unit/infrastructure/tools/edit/DeleteFileTool.test.ts create mode 100644 packages/ipuaro/tests/unit/infrastructure/tools/edit/EditLinesTool.test.ts diff --git a/packages/ipuaro/CHANGELOG.md b/packages/ipuaro/CHANGELOG.md index e894dc5..7ea6a42 100644 --- a/packages/ipuaro/CHANGELOG.md +++ b/packages/ipuaro/CHANGELOG.md @@ -5,6 +5,39 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.6.0] - 2025-12-01 - Edit Tools + +### Added + +- **EditLinesTool (0.6.1)** + - `edit_lines(path, start, end, content)`: Replace lines in a file + - Hash conflict detection (prevents editing externally modified files) + - Confirmation required with diff preview + - Automatic storage update after edit + - 35 unit tests + +- **CreateFileTool (0.6.2)** + - `create_file(path, content)`: Create new file with content + - Automatic directory creation if needed + - Path validation (must be within project root) + - Prevents overwriting existing files + - Confirmation required before creation + - 26 unit tests + +- **DeleteFileTool (0.6.3)** + - `delete_file(path)`: Delete file from filesystem and storage + - Removes file data, AST, and meta from Redis + - Confirmation required with file content preview + - 20 unit tests + +### Changed + +- Total tests: 664 (was 540) +- Coverage: 97.71% lines, 91.89% branches +- Coverage thresholds: 95% lines/functions/statements, 90% branches + +--- + ## [0.5.0] - 2025-12-01 - Read Tools ### Added diff --git a/packages/ipuaro/package.json b/packages/ipuaro/package.json index 60803a3..7c99222 100644 --- a/packages/ipuaro/package.json +++ b/packages/ipuaro/package.json @@ -1,6 +1,6 @@ { "name": "@samiyev/ipuaro", - "version": "0.5.0", + "version": "0.6.0", "description": "Local AI agent for codebase operations with infinite context feeling", "author": "Fozilbek Samiyev ", "license": "MIT", diff --git a/packages/ipuaro/src/infrastructure/tools/edit/CreateFileTool.ts b/packages/ipuaro/src/infrastructure/tools/edit/CreateFileTool.ts new file mode 100644 index 0000000..7815bee --- /dev/null +++ b/packages/ipuaro/src/infrastructure/tools/edit/CreateFileTool.ts @@ -0,0 +1,140 @@ +import { promises as fs } from "node:fs" +import * as path from "node:path" +import type { ITool, ToolContext, ToolParameterSchema } from "../../../domain/services/ITool.js" +import { createFileData } from "../../../domain/value-objects/FileData.js" +import { + createErrorResult, + createSuccessResult, + type ToolResult, +} from "../../../domain/value-objects/ToolResult.js" +import { hashLines } from "../../../shared/utils/hash.js" + +/** + * Result data from create_file tool. + */ +export interface CreateFileResult { + path: string + lines: number + size: number +} + +/** + * Tool for creating new files. + * Creates a new file with the specified content. + * Requires user confirmation before creating. + */ +export class CreateFileTool implements ITool { + readonly name = "create_file" + readonly description = + "Create a new file with the specified content. " + + "The file path must be within the project root. " + + "Requires confirmation before creating." + readonly parameters: ToolParameterSchema[] = [ + { + name: "path", + type: "string", + description: "File path relative to project root", + required: true, + }, + { + name: "content", + type: "string", + description: "File content", + required: true, + }, + ] + readonly requiresConfirmation = true + readonly category = "edit" as const + + validateParams(params: Record): string | null { + if (typeof params.path !== "string" || params.path.trim() === "") { + return "Parameter 'path' is required and must be a non-empty string" + } + + if (typeof params.content !== "string") { + return "Parameter 'content' is required and must be a string" + } + + return null + } + + async execute(params: Record, ctx: ToolContext): Promise { + const startTime = Date.now() + const callId = `${this.name}-${String(startTime)}` + + const relativePath = params.path as string + const content = params.content as string + + const absolutePath = path.resolve(ctx.projectRoot, relativePath) + + if (!absolutePath.startsWith(ctx.projectRoot)) { + return createErrorResult( + callId, + "Path must be within project root", + Date.now() - startTime, + ) + } + + try { + const exists = await this.fileExists(absolutePath) + if (exists) { + return createErrorResult( + callId, + `File already exists: ${relativePath}`, + Date.now() - startTime, + ) + } + + const lines = content.split("\n") + + const confirmed = await ctx.requestConfirmation( + `Create new file: ${relativePath} (${String(lines.length)} lines)`, + { + filePath: relativePath, + oldLines: [], + newLines: lines, + startLine: 1, + }, + ) + + if (!confirmed) { + return createErrorResult( + callId, + "File creation cancelled by user", + Date.now() - startTime, + ) + } + + const dirPath = path.dirname(absolutePath) + await fs.mkdir(dirPath, { recursive: true }) + await fs.writeFile(absolutePath, content, "utf-8") + + const stats = await fs.stat(absolutePath) + const fileData = createFileData(lines, hashLines(lines), stats.size, stats.mtimeMs) + await ctx.storage.setFile(relativePath, fileData) + + const result: CreateFileResult = { + path: relativePath, + lines: lines.length, + size: stats.size, + } + + return createSuccessResult(callId, result, Date.now() - startTime) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return createErrorResult(callId, message, Date.now() - startTime) + } + } + + /** + * Check if file exists. + */ + private async fileExists(filePath: string): Promise { + try { + await fs.access(filePath) + return true + } catch { + return false + } + } +} diff --git a/packages/ipuaro/src/infrastructure/tools/edit/DeleteFileTool.ts b/packages/ipuaro/src/infrastructure/tools/edit/DeleteFileTool.ts new file mode 100644 index 0000000..70ad445 --- /dev/null +++ b/packages/ipuaro/src/infrastructure/tools/edit/DeleteFileTool.ts @@ -0,0 +1,136 @@ +import { promises as fs } from "node:fs" +import * as path from "node:path" +import type { ITool, ToolContext, ToolParameterSchema } from "../../../domain/services/ITool.js" +import { + createErrorResult, + createSuccessResult, + type ToolResult, +} from "../../../domain/value-objects/ToolResult.js" + +/** + * Result data from delete_file tool. + */ +export interface DeleteFileResult { + path: string + deleted: boolean +} + +/** + * Tool for deleting files. + * Deletes a file from the filesystem and storage. + * Requires user confirmation before deleting. + */ +export class DeleteFileTool implements ITool { + readonly name = "delete_file" + readonly description = + "Delete a file from the project. " + + "The file path must be within the project root. " + + "Requires confirmation before deleting." + readonly parameters: ToolParameterSchema[] = [ + { + name: "path", + type: "string", + description: "File path relative to project root", + required: true, + }, + ] + readonly requiresConfirmation = true + readonly category = "edit" as const + + validateParams(params: Record): string | null { + if (typeof params.path !== "string" || params.path.trim() === "") { + return "Parameter 'path' is required and must be a non-empty string" + } + + return null + } + + async execute(params: Record, ctx: ToolContext): Promise { + const startTime = Date.now() + const callId = `${this.name}-${String(startTime)}` + + const relativePath = params.path as string + const absolutePath = path.resolve(ctx.projectRoot, relativePath) + + if (!absolutePath.startsWith(ctx.projectRoot)) { + return createErrorResult( + callId, + "Path must be within project root", + Date.now() - startTime, + ) + } + + try { + const exists = await this.fileExists(absolutePath) + if (!exists) { + return createErrorResult( + callId, + `File not found: ${relativePath}`, + Date.now() - startTime, + ) + } + + const fileContent = await this.getFileContent(absolutePath, relativePath, ctx) + + const confirmed = await ctx.requestConfirmation(`Delete file: ${relativePath}`, { + filePath: relativePath, + oldLines: fileContent, + newLines: [], + startLine: 1, + }) + + if (!confirmed) { + return createErrorResult( + callId, + "File deletion cancelled by user", + Date.now() - startTime, + ) + } + + await fs.unlink(absolutePath) + + await ctx.storage.deleteFile(relativePath) + await ctx.storage.deleteAST(relativePath) + await ctx.storage.deleteMeta(relativePath) + + const result: DeleteFileResult = { + path: relativePath, + deleted: true, + } + + return createSuccessResult(callId, result, Date.now() - startTime) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return createErrorResult(callId, message, Date.now() - startTime) + } + } + + /** + * Check if file exists. + */ + private async fileExists(filePath: string): Promise { + try { + const stats = await fs.stat(filePath) + return stats.isFile() + } catch { + return false + } + } + + /** + * Get file content for diff display. + */ + private async getFileContent( + absolutePath: string, + relativePath: string, + ctx: ToolContext, + ): Promise { + const fileData = await ctx.storage.getFile(relativePath) + if (fileData) { + return fileData.lines + } + + const content = await fs.readFile(absolutePath, "utf-8") + return content.split("\n") + } +} diff --git a/packages/ipuaro/src/infrastructure/tools/edit/EditLinesTool.ts b/packages/ipuaro/src/infrastructure/tools/edit/EditLinesTool.ts new file mode 100644 index 0000000..fe193b2 --- /dev/null +++ b/packages/ipuaro/src/infrastructure/tools/edit/EditLinesTool.ts @@ -0,0 +1,226 @@ +import { promises as fs } from "node:fs" +import * as path from "node:path" +import type { ITool, ToolContext, ToolParameterSchema } from "../../../domain/services/ITool.js" +import { createFileData } from "../../../domain/value-objects/FileData.js" +import { + createErrorResult, + createSuccessResult, + type ToolResult, +} from "../../../domain/value-objects/ToolResult.js" +import { hashLines } from "../../../shared/utils/hash.js" + +/** + * Result data from edit_lines tool. + */ +export interface EditLinesResult { + path: string + startLine: number + endLine: number + linesReplaced: number + linesInserted: number + totalLines: number +} + +/** + * Tool for editing specific lines in a file. + * Replaces lines from start to end with new content. + * Requires user confirmation before applying changes. + */ +export class EditLinesTool implements ITool { + readonly name = "edit_lines" + readonly description = + "Replace lines in a file. Replaces lines from start to end (inclusive) with new content. " + + "Requires confirmation before applying changes." + readonly parameters: ToolParameterSchema[] = [ + { + name: "path", + type: "string", + description: "File path relative to project root", + required: true, + }, + { + name: "start", + type: "number", + description: "Start line number (1-based, inclusive)", + required: true, + }, + { + name: "end", + type: "number", + description: "End line number (1-based, inclusive)", + required: true, + }, + { + name: "content", + type: "string", + description: "New content to insert (can be multi-line)", + required: true, + }, + ] + readonly requiresConfirmation = true + readonly category = "edit" as const + + validateParams(params: Record): string | null { + if (typeof params.path !== "string" || params.path.trim() === "") { + return "Parameter 'path' is required and must be a non-empty string" + } + + if (typeof params.start !== "number" || !Number.isInteger(params.start)) { + return "Parameter 'start' is required and must be an integer" + } + if (params.start < 1) { + return "Parameter 'start' must be >= 1" + } + + if (typeof params.end !== "number" || !Number.isInteger(params.end)) { + return "Parameter 'end' is required and must be an integer" + } + if (params.end < 1) { + return "Parameter 'end' must be >= 1" + } + + if (params.start > params.end) { + return "Parameter 'start' must be <= 'end'" + } + + if (typeof params.content !== "string") { + return "Parameter 'content' is required and must be a string" + } + + return null + } + + async execute(params: Record, ctx: ToolContext): Promise { + const startTime = Date.now() + const callId = `${this.name}-${String(startTime)}` + + const relativePath = params.path as string + const startLine = params.start as number + const endLine = params.end as number + const newContent = params.content as string + + const absolutePath = path.resolve(ctx.projectRoot, relativePath) + + if (!absolutePath.startsWith(ctx.projectRoot)) { + return createErrorResult( + callId, + "Path must be within project root", + Date.now() - startTime, + ) + } + + try { + const currentLines = await this.getCurrentLines(absolutePath, relativePath, ctx) + const totalLines = currentLines.length + + if (startLine > totalLines) { + return createErrorResult( + callId, + `Start line ${String(startLine)} exceeds file length (${String(totalLines)} lines)`, + Date.now() - startTime, + ) + } + + const adjustedEnd = Math.min(endLine, totalLines) + const conflictCheck = await this.checkHashConflict(relativePath, currentLines, ctx) + if (conflictCheck) { + return createErrorResult(callId, conflictCheck, Date.now() - startTime) + } + + const oldLines = currentLines.slice(startLine - 1, adjustedEnd) + const newLines = newContent.split("\n") + + const confirmed = await ctx.requestConfirmation( + `Replace lines ${String(startLine)}-${String(adjustedEnd)} in ${relativePath}`, + { + filePath: relativePath, + oldLines, + newLines, + startLine, + }, + ) + + if (!confirmed) { + return createErrorResult(callId, "Edit cancelled by user", Date.now() - startTime) + } + + const updatedLines = [ + ...currentLines.slice(0, startLine - 1), + ...newLines, + ...currentLines.slice(adjustedEnd), + ] + + await this.applyChanges(absolutePath, relativePath, updatedLines, ctx) + + const result: EditLinesResult = { + path: relativePath, + startLine, + endLine: adjustedEnd, + linesReplaced: adjustedEnd - startLine + 1, + linesInserted: newLines.length, + totalLines: updatedLines.length, + } + + return createSuccessResult(callId, result, Date.now() - startTime) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return createErrorResult(callId, message, Date.now() - startTime) + } + } + + /** + * Get current file lines from storage or filesystem. + */ + private async getCurrentLines( + absolutePath: string, + relativePath: string, + ctx: ToolContext, + ): Promise { + const fileData = await ctx.storage.getFile(relativePath) + if (fileData) { + return fileData.lines + } + + const content = await fs.readFile(absolutePath, "utf-8") + return content.split("\n") + } + + /** + * Check if file has changed since it was indexed. + * Returns error message if conflict detected, null otherwise. + */ + private async checkHashConflict( + relativePath: string, + currentLines: string[], + ctx: ToolContext, + ): Promise { + const storedFile = await ctx.storage.getFile(relativePath) + if (!storedFile) { + return null + } + + const currentHash = hashLines(currentLines) + if (storedFile.hash !== currentHash) { + return "File has been modified externally. Please refresh the file before editing." + } + + return null + } + + /** + * Apply changes to filesystem and storage. + */ + private async applyChanges( + absolutePath: string, + relativePath: string, + lines: string[], + ctx: ToolContext, + ): Promise { + const content = lines.join("\n") + await fs.writeFile(absolutePath, content, "utf-8") + + const stats = await fs.stat(absolutePath) + const fileData = createFileData(lines, hashLines(lines), stats.size, stats.mtimeMs) + await ctx.storage.setFile(relativePath, fileData) + } +} diff --git a/packages/ipuaro/src/infrastructure/tools/edit/index.ts b/packages/ipuaro/src/infrastructure/tools/edit/index.ts new file mode 100644 index 0000000..03a3cd8 --- /dev/null +++ b/packages/ipuaro/src/infrastructure/tools/edit/index.ts @@ -0,0 +1,4 @@ +// Edit tools exports +export { EditLinesTool, type EditLinesResult } from "./EditLinesTool.js" +export { CreateFileTool, type CreateFileResult } from "./CreateFileTool.js" +export { DeleteFileTool, type DeleteFileResult } from "./DeleteFileTool.js" diff --git a/packages/ipuaro/src/infrastructure/tools/index.ts b/packages/ipuaro/src/infrastructure/tools/index.ts index c5dd578..60ea147 100644 --- a/packages/ipuaro/src/infrastructure/tools/index.ts +++ b/packages/ipuaro/src/infrastructure/tools/index.ts @@ -10,3 +10,8 @@ export { type GetStructureResult, type TreeNode, } from "./read/GetStructureTool.js" + +// Edit tools +export { EditLinesTool, type EditLinesResult } from "./edit/EditLinesTool.js" +export { CreateFileTool, type CreateFileResult } from "./edit/CreateFileTool.js" +export { DeleteFileTool, type DeleteFileResult } from "./edit/DeleteFileTool.js" diff --git a/packages/ipuaro/tests/unit/domain/value-objects/ChatMessage.test.ts b/packages/ipuaro/tests/unit/domain/value-objects/ChatMessage.test.ts index 2dddb6c..980e01a 100644 --- a/packages/ipuaro/tests/unit/domain/value-objects/ChatMessage.test.ts +++ b/packages/ipuaro/tests/unit/domain/value-objects/ChatMessage.test.ts @@ -63,6 +63,13 @@ describe("ChatMessage", () => { expect(msg.content).toContain("[2] Error: Not found") }) + + it("should handle error result without error message", () => { + const results = [{ callId: "3", success: false, executionTimeMs: 5 }] + const msg = createToolMessage(results) + + expect(msg.content).toContain("[3] Error: Unknown error") + }) }) describe("createSystemMessage", () => { diff --git a/packages/ipuaro/tests/unit/infrastructure/indexer/ASTParser.test.ts b/packages/ipuaro/tests/unit/infrastructure/indexer/ASTParser.test.ts index e2413c0..fc4aa45 100644 --- a/packages/ipuaro/tests/unit/infrastructure/indexer/ASTParser.test.ts +++ b/packages/ipuaro/tests/unit/infrastructure/indexer/ASTParser.test.ts @@ -301,6 +301,66 @@ describe("ASTParser", () => { }) }) + describe("import string formats", () => { + it("should handle single-quoted imports", () => { + const code = `import { foo } from './module'` + const ast = parser.parse(code, "ts") + + expect(ast.imports).toHaveLength(1) + expect(ast.imports[0].from).toBe("./module") + }) + + it("should handle double-quoted imports", () => { + const code = `import { bar } from "./other"` + const ast = parser.parse(code, "ts") + + expect(ast.imports).toHaveLength(1) + expect(ast.imports[0].from).toBe("./other") + }) + }) + + describe("parameter types", () => { + it("should handle simple identifier parameters", () => { + const code = `const fn = (x) => x * 2` + const ast = parser.parse(code, "ts") + + expect(ast.functions.length).toBeGreaterThanOrEqual(0) + }) + + it("should handle optional parameters with defaults", () => { + const code = `function greet(name: string = "World"): string { return name }` + const ast = parser.parse(code, "ts") + + expect(ast.functions).toHaveLength(1) + const fn = ast.functions[0] + expect(fn.params.some((p) => p.hasDefault)).toBe(true) + }) + + it("should handle arrow function with untyped params", () => { + const code = `const add = (a, b) => a + b` + const ast = parser.parse(code, "ts") + + expect(ast.functions.length).toBeGreaterThanOrEqual(0) + }) + + it("should handle multiple parameter types", () => { + const code = ` +function mix( + required: string, + optional?: number, + withDefault: boolean = true +) {} +` + const ast = parser.parse(code, "ts") + + expect(ast.functions).toHaveLength(1) + const fn = ast.functions[0] + expect(fn.params).toHaveLength(3) + expect(fn.params.some((p) => p.optional)).toBe(true) + expect(fn.params.some((p) => p.hasDefault)).toBe(true) + }) + }) + describe("complex file", () => { it("should parse complex TypeScript file", () => { const code = ` diff --git a/packages/ipuaro/tests/unit/infrastructure/indexer/FileScanner.test.ts b/packages/ipuaro/tests/unit/infrastructure/indexer/FileScanner.test.ts index a4bd015..ea8eda2 100644 --- a/packages/ipuaro/tests/unit/infrastructure/indexer/FileScanner.test.ts +++ b/packages/ipuaro/tests/unit/infrastructure/indexer/FileScanner.test.ts @@ -212,6 +212,32 @@ describe("FileScanner", () => { }) }) + describe("empty file handling", () => { + it("should consider empty files as text files", async () => { + const emptyFile = path.join(FIXTURES_DIR, "empty-file.ts") + await fs.writeFile(emptyFile, "") + + try { + const isText = await FileScanner.isTextFile(emptyFile) + expect(isText).toBe(true) + } finally { + await fs.unlink(emptyFile) + } + }) + + it("should read empty file content", async () => { + const emptyFile = path.join(FIXTURES_DIR, "empty-content.ts") + await fs.writeFile(emptyFile, "") + + try { + const content = await FileScanner.readFileContent(emptyFile) + expect(content).toBe("") + } finally { + await fs.unlink(emptyFile) + } + }) + }) + describe("empty directory handling", () => { let emptyDir: string diff --git a/packages/ipuaro/tests/unit/infrastructure/indexer/IndexBuilder.test.ts b/packages/ipuaro/tests/unit/infrastructure/indexer/IndexBuilder.test.ts index ebe0d03..040d168 100644 --- a/packages/ipuaro/tests/unit/infrastructure/indexer/IndexBuilder.test.ts +++ b/packages/ipuaro/tests/unit/infrastructure/indexer/IndexBuilder.test.ts @@ -605,4 +605,44 @@ export type ServiceResult = { success: true; data: T } | { success: false; er ) }) }) + + describe("jsx to tsx resolution", () => { + it("should resolve .jsx imports to .tsx files", () => { + const mainCode = `import { Button } from "./Button.jsx"` + const buttonCode = `export function Button() { return null }` + + const asts = new Map([ + ["/project/src/main.ts", parser.parse(mainCode, "ts")], + ["/project/src/Button.tsx", parser.parse(buttonCode, "tsx")], + ]) + + const graph = builder.buildDepsGraph(asts) + + expect(graph.imports.get("/project/src/main.ts")).toContain("/project/src/Button.tsx") + }) + }) + + describe("edge cases", () => { + it("should handle empty deps graph for circular dependencies", () => { + const graph = { + imports: new Map(), + importedBy: new Map(), + } + + const cycles = builder.findCircularDependencies(graph) + expect(cycles).toEqual([]) + }) + + it("should handle single file with no imports", () => { + const code = `export const x = 1` + const asts = new Map([ + ["/project/src/single.ts", parser.parse(code, "ts")], + ]) + + const graph = builder.buildDepsGraph(asts) + const cycles = builder.findCircularDependencies(graph) + + expect(cycles).toEqual([]) + }) + }) }) diff --git a/packages/ipuaro/tests/unit/infrastructure/indexer/MetaAnalyzer.test.ts b/packages/ipuaro/tests/unit/infrastructure/indexer/MetaAnalyzer.test.ts index 8915d53..d90f7f4 100644 --- a/packages/ipuaro/tests/unit/infrastructure/indexer/MetaAnalyzer.test.ts +++ b/packages/ipuaro/tests/unit/infrastructure/indexer/MetaAnalyzer.test.ts @@ -544,6 +544,44 @@ const b = 2` }) }) + describe("dependency resolution with different extensions", () => { + it("should resolve imports from index files", () => { + const content = `import { utils } from "./utils/index"` + const ast = parser.parse(content, "ts") + const allASTs = new Map() + allASTs.set("/project/src/main.ts", ast) + allASTs.set("/project/src/utils/index.ts", createEmptyFileAST()) + + const meta = analyzer.analyze("/project/src/main.ts", ast, content, allASTs) + + expect(meta.dependencies).toContain("/project/src/utils/index.ts") + }) + + it("should convert .js extension to .ts when resolving", () => { + const content = `import { helper } from "./helper.js"` + const ast = parser.parse(content, "ts") + const allASTs = new Map() + allASTs.set("/project/src/main.ts", ast) + allASTs.set("/project/src/helper.ts", createEmptyFileAST()) + + const meta = analyzer.analyze("/project/src/main.ts", ast, content, allASTs) + + expect(meta.dependencies).toContain("/project/src/helper.ts") + }) + + it("should convert .jsx extension to .tsx when resolving", () => { + const content = `import { Button } from "./Button.jsx"` + const ast = parser.parse(content, "ts") + const allASTs = new Map() + allASTs.set("/project/src/App.tsx", ast) + allASTs.set("/project/src/Button.tsx", createEmptyFileAST()) + + const meta = analyzer.analyze("/project/src/App.tsx", ast, content, allASTs) + + expect(meta.dependencies).toContain("/project/src/Button.tsx") + }) + }) + describe("analyze", () => { it("should produce complete FileMeta", () => { const content = `import { helper } from "./helper" diff --git a/packages/ipuaro/tests/unit/infrastructure/indexer/Watchdog.test.ts b/packages/ipuaro/tests/unit/infrastructure/indexer/Watchdog.test.ts index c792a72..04abce8 100644 --- a/packages/ipuaro/tests/unit/infrastructure/indexer/Watchdog.test.ts +++ b/packages/ipuaro/tests/unit/infrastructure/indexer/Watchdog.test.ts @@ -94,12 +94,70 @@ describe("Watchdog", () => { it("should return empty array when not watching", () => { expect(watchdog.getWatchedPaths()).toEqual([]) }) + + it("should return paths when watching", async () => { + const testFile = path.join(tempDir, "exists.ts") + await fs.writeFile(testFile, "const x = 1") + + watchdog.start(tempDir) + await new Promise((resolve) => setTimeout(resolve, 200)) + + const paths = watchdog.getWatchedPaths() + expect(Array.isArray(paths)).toBe(true) + }) }) describe("flushAll", () => { it("should not throw when no pending changes", () => { expect(() => watchdog.flushAll()).not.toThrow() }) + + it("should flush all pending changes", async () => { + const events: FileChangeEvent[] = [] + watchdog.onFileChange((event) => events.push(event)) + watchdog.start(tempDir) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + const testFile = path.join(tempDir, "flush-test.ts") + await fs.writeFile(testFile, "const x = 1") + + await new Promise((resolve) => setTimeout(resolve, 20)) + + watchdog.flushAll() + + await new Promise((resolve) => setTimeout(resolve, 50)) + }) + }) + + describe("ignore patterns", () => { + it("should handle glob patterns with wildcards", async () => { + const customWatchdog = new Watchdog({ + debounceMs: 50, + ignorePatterns: ["*.log", "**/*.tmp"], + }) + + customWatchdog.start(tempDir) + await new Promise((resolve) => setTimeout(resolve, 100)) + + expect(customWatchdog.isWatching()).toBe(true) + + await customWatchdog.stop() + }) + + it("should handle simple directory patterns", async () => { + const customWatchdog = new Watchdog({ + debounceMs: 50, + ignorePatterns: ["node_modules", "dist"], + }) + + customWatchdog.start(tempDir) + await new Promise((resolve) => setTimeout(resolve, 100)) + + expect(customWatchdog.isWatching()).toBe(true) + + await customWatchdog.stop() + }) }) describe("file change detection", () => { diff --git a/packages/ipuaro/tests/unit/infrastructure/llm/OllamaClient.test.ts b/packages/ipuaro/tests/unit/infrastructure/llm/OllamaClient.test.ts index 07d23f7..68ff90f 100644 --- a/packages/ipuaro/tests/unit/infrastructure/llm/OllamaClient.test.ts +++ b/packages/ipuaro/tests/unit/infrastructure/llm/OllamaClient.test.ts @@ -301,4 +301,188 @@ describe("OllamaClient", () => { expect(() => client.abort()).not.toThrow() }) }) + + describe("message conversion", () => { + it("should convert system messages", async () => { + const client = new OllamaClient(defaultConfig) + const messages = [ + { + role: "system" as const, + content: "You are a helpful assistant", + timestamp: Date.now(), + }, + ] + + await client.chat(messages) + + expect(mockOllamaInstance.chat).toHaveBeenCalledWith( + expect.objectContaining({ + messages: expect.arrayContaining([ + expect.objectContaining({ + role: "system", + content: "You are a helpful assistant", + }), + ]), + }), + ) + }) + + it("should convert tool result messages", async () => { + const client = new OllamaClient(defaultConfig) + const messages = [ + { + role: "tool" as const, + content: '{"result": "success"}', + timestamp: Date.now(), + toolResults: [ + { callId: "call_1", success: true, data: "success", executionTimeMs: 10 }, + ], + }, + ] + + await client.chat(messages) + + expect(mockOllamaInstance.chat).toHaveBeenCalledWith( + expect.objectContaining({ + messages: expect.arrayContaining([ + expect.objectContaining({ + role: "tool", + content: '{"result": "success"}', + }), + ]), + }), + ) + }) + + it("should convert assistant messages with tool calls", async () => { + const client = new OllamaClient(defaultConfig) + const messages = [ + { + role: "assistant" as const, + content: "I will read the file", + timestamp: Date.now(), + toolCalls: [{ id: "call_1", name: "get_lines", params: { path: "test.ts" } }], + }, + ] + + await client.chat(messages) + + expect(mockOllamaInstance.chat).toHaveBeenCalledWith( + expect.objectContaining({ + messages: expect.arrayContaining([ + expect.objectContaining({ + role: "assistant", + content: "I will read the file", + tool_calls: expect.arrayContaining([ + expect.objectContaining({ + function: expect.objectContaining({ + name: "get_lines", + arguments: { path: "test.ts" }, + }), + }), + ]), + }), + ]), + }), + ) + }) + }) + + describe("response handling", () => { + it("should estimate tokens when eval_count is undefined", async () => { + mockOllamaInstance.chat.mockResolvedValue({ + message: { + role: "assistant", + content: "Hello world response", + tool_calls: undefined, + }, + eval_count: undefined, + done_reason: "stop", + }) + + const client = new OllamaClient(defaultConfig) + const response = await client.chat([createUserMessage("Hello")]) + + expect(response.tokens).toBeGreaterThan(0) + }) + + it("should return length stop reason", async () => { + mockOllamaInstance.chat.mockResolvedValue({ + message: { + role: "assistant", + content: "Truncated...", + tool_calls: undefined, + }, + eval_count: 100, + done_reason: "length", + }) + + const client = new OllamaClient(defaultConfig) + const response = await client.chat([createUserMessage("Hello")]) + + expect(response.stopReason).toBe("length") + }) + }) + + describe("tool parameter conversion", () => { + it("should include enum values when present", async () => { + const client = new OllamaClient(defaultConfig) + const messages = [createUserMessage("Get status")] + const tools = [ + { + name: "get_status", + description: "Get status", + parameters: [ + { + name: "type", + type: "string" as const, + description: "Status type", + required: true, + enum: ["active", "inactive", "pending"], + }, + ], + }, + ] + + await client.chat(messages, tools) + + expect(mockOllamaInstance.chat).toHaveBeenCalledWith( + expect.objectContaining({ + tools: expect.arrayContaining([ + expect.objectContaining({ + function: expect.objectContaining({ + parameters: expect.objectContaining({ + properties: expect.objectContaining({ + type: expect.objectContaining({ + enum: ["active", "inactive", "pending"], + }), + }), + }), + }), + }), + ]), + }), + ) + }) + }) + + describe("error handling", () => { + it("should handle ECONNREFUSED errors", async () => { + mockOllamaInstance.chat.mockRejectedValue(new Error("ECONNREFUSED")) + + const client = new OllamaClient(defaultConfig) + + await expect(client.chat([createUserMessage("Hello")])).rejects.toThrow( + /Cannot connect to Ollama/, + ) + }) + + it("should handle generic errors with context", async () => { + mockOllamaInstance.pull.mockRejectedValue(new Error("Unknown error")) + + const client = new OllamaClient(defaultConfig) + + await expect(client.pullModel("test")).rejects.toThrow(/Failed to pull model/) + }) + }) }) diff --git a/packages/ipuaro/tests/unit/infrastructure/llm/prompts.test.ts b/packages/ipuaro/tests/unit/infrastructure/llm/prompts.test.ts index a5ce54a..2303e94 100644 --- a/packages/ipuaro/tests/unit/infrastructure/llm/prompts.test.ts +++ b/packages/ipuaro/tests/unit/infrastructure/llm/prompts.test.ts @@ -249,6 +249,445 @@ describe("prompts", () => { }) }) + describe("buildFileContext - edge cases", () => { + it("should handle empty imports", () => { + const ast: FileAST = { + imports: [], + exports: [], + functions: [], + classes: [], + interfaces: [], + typeAliases: [], + parseError: false, + } + + const context = buildFileContext("empty.ts", ast) + + expect(context).toContain("## empty.ts") + expect(context).not.toContain("### Imports") + }) + + it("should handle empty exports", () => { + const ast: FileAST = { + imports: [{ name: "x", from: "./x", line: 1, type: "internal", isDefault: false }], + exports: [], + functions: [], + classes: [], + interfaces: [], + typeAliases: [], + parseError: false, + } + + const context = buildFileContext("no-exports.ts", ast) + + expect(context).toContain("### Imports") + expect(context).not.toContain("### Exports") + }) + + it("should handle empty functions", () => { + const ast: FileAST = { + imports: [], + exports: [], + functions: [], + classes: [ + { + name: "MyClass", + lineStart: 1, + lineEnd: 10, + methods: [], + properties: [], + implements: [], + isExported: false, + isAbstract: false, + }, + ], + interfaces: [], + typeAliases: [], + parseError: false, + } + + const context = buildFileContext("no-functions.ts", ast) + + expect(context).not.toContain("### Functions") + expect(context).toContain("### Classes") + }) + + it("should handle empty classes", () => { + const ast: FileAST = { + imports: [], + exports: [], + functions: [ + { + name: "test", + lineStart: 1, + lineEnd: 5, + params: [], + isAsync: false, + isExported: false, + }, + ], + classes: [], + interfaces: [], + typeAliases: [], + parseError: false, + } + + const context = buildFileContext("no-classes.ts", ast) + + expect(context).toContain("### Functions") + expect(context).not.toContain("### Classes") + }) + + it("should handle class without extends", () => { + const ast: FileAST = { + imports: [], + exports: [], + functions: [], + classes: [ + { + name: "Standalone", + lineStart: 1, + lineEnd: 10, + methods: [], + properties: [], + implements: ["IFoo"], + isExported: false, + isAbstract: false, + }, + ], + interfaces: [], + typeAliases: [], + parseError: false, + } + + const context = buildFileContext("standalone.ts", ast) + + expect(context).toContain("Standalone implements IFoo") + expect(context).not.toContain("extends") + }) + + it("should handle class without implements", () => { + const ast: FileAST = { + imports: [], + exports: [], + functions: [], + classes: [ + { + name: "Child", + lineStart: 1, + lineEnd: 10, + methods: [], + properties: [], + extends: "Parent", + implements: [], + isExported: false, + isAbstract: false, + }, + ], + interfaces: [], + typeAliases: [], + parseError: false, + } + + const context = buildFileContext("child.ts", ast) + + expect(context).toContain("Child extends Parent") + expect(context).not.toContain("implements") + }) + + it("should handle method with private visibility", () => { + const ast: FileAST = { + imports: [], + exports: [], + functions: [], + classes: [ + { + name: "WithPrivate", + lineStart: 1, + lineEnd: 20, + methods: [ + { + name: "secretMethod", + lineStart: 5, + lineEnd: 10, + params: [], + isAsync: false, + visibility: "private", + isStatic: false, + }, + ], + properties: [], + implements: [], + isExported: false, + isAbstract: false, + }, + ], + interfaces: [], + typeAliases: [], + parseError: false, + } + + const context = buildFileContext("private.ts", ast) + + expect(context).toContain("private secretMethod()") + }) + + it("should handle non-async function", () => { + const ast: FileAST = { + imports: [], + exports: [], + functions: [ + { + name: "syncFn", + lineStart: 1, + lineEnd: 5, + params: [{ name: "x", optional: false, hasDefault: false }], + isAsync: false, + isExported: false, + }, + ], + classes: [], + interfaces: [], + typeAliases: [], + parseError: false, + } + + const context = buildFileContext("sync.ts", ast) + + expect(context).toContain("syncFn(x)") + expect(context).not.toContain("async syncFn") + }) + + it("should handle export without default", () => { + const ast: FileAST = { + imports: [], + exports: [{ name: "foo", line: 1, isDefault: false, kind: "variable" }], + functions: [], + classes: [], + interfaces: [], + typeAliases: [], + parseError: false, + } + + const context = buildFileContext("named-export.ts", ast) + + expect(context).toContain("variable foo") + expect(context).not.toContain("(default)") + }) + }) + + describe("buildInitialContext - edge cases", () => { + it("should handle nested directory names", () => { + const structure: ProjectStructure = { + name: "test", + rootPath: "/test", + files: [], + directories: ["src/components/ui"], + } + const asts = new Map() + + const context = buildInitialContext(structure, asts) + + expect(context).toContain("ui/") + }) + + it("should handle file with only interfaces", () => { + const structure: ProjectStructure = { + name: "test", + rootPath: "/test", + files: ["types.ts"], + directories: [], + } + const asts = new Map([ + [ + "types.ts", + { + imports: [], + exports: [], + functions: [], + classes: [], + interfaces: [{ name: "IFoo", lineStart: 1, lineEnd: 5, isExported: true }], + typeAliases: [], + parseError: false, + }, + ], + ]) + + const context = buildInitialContext(structure, asts) + + expect(context).toContain("interface: IFoo") + }) + + it("should handle file with only type aliases", () => { + const structure: ProjectStructure = { + name: "test", + rootPath: "/test", + files: ["types.ts"], + directories: [], + } + const asts = new Map([ + [ + "types.ts", + { + imports: [], + exports: [], + functions: [], + classes: [], + interfaces: [], + typeAliases: [ + { name: "MyType", lineStart: 1, lineEnd: 1, isExported: true }, + ], + parseError: false, + }, + ], + ]) + + const context = buildInitialContext(structure, asts) + + expect(context).toContain("type: MyType") + }) + + it("should handle file with no AST content", () => { + const structure: ProjectStructure = { + name: "test", + rootPath: "/test", + files: ["empty.ts"], + directories: [], + } + const asts = new Map([ + [ + "empty.ts", + { + imports: [], + exports: [], + functions: [], + classes: [], + interfaces: [], + typeAliases: [], + parseError: false, + }, + ], + ]) + + const context = buildInitialContext(structure, asts) + + expect(context).toContain("- empty.ts") + }) + + it("should handle meta with only hub flag", () => { + const structure: ProjectStructure = { + name: "test", + rootPath: "/test", + files: ["hub.ts"], + directories: [], + } + const asts = new Map([ + [ + "hub.ts", + { + imports: [], + exports: [], + functions: [], + classes: [], + interfaces: [], + typeAliases: [], + parseError: false, + }, + ], + ]) + const metas = new Map([ + [ + "hub.ts", + { + complexity: { loc: 10, nesting: 1, cyclomaticComplexity: 1, score: 10 }, + dependencies: [], + dependents: [], + isHub: true, + isEntryPoint: false, + fileType: "source", + }, + ], + ]) + + const context = buildInitialContext(structure, asts, metas) + + expect(context).toContain("(hub)") + expect(context).not.toContain("entry") + expect(context).not.toContain("complex") + }) + + it("should handle meta with no flags", () => { + const structure: ProjectStructure = { + name: "test", + rootPath: "/test", + files: ["normal.ts"], + directories: [], + } + const asts = new Map([ + [ + "normal.ts", + { + imports: [], + exports: [], + functions: [], + classes: [], + interfaces: [], + typeAliases: [], + parseError: false, + }, + ], + ]) + const metas = new Map([ + [ + "normal.ts", + { + complexity: { loc: 10, nesting: 1, cyclomaticComplexity: 1, score: 10 }, + dependencies: [], + dependents: [], + isHub: false, + isEntryPoint: false, + fileType: "source", + }, + ], + ]) + + const context = buildInitialContext(structure, asts, metas) + + expect(context).toContain("- normal.ts") + expect(context).not.toContain("(hub") + expect(context).not.toContain("entry") + expect(context).not.toContain("complex") + }) + + it("should skip files not in AST map", () => { + const structure: ProjectStructure = { + name: "test", + rootPath: "/test", + files: ["exists.ts", "missing.ts"], + directories: [], + } + const asts = new Map([ + [ + "exists.ts", + { + imports: [], + exports: [], + functions: [], + classes: [], + interfaces: [], + typeAliases: [], + parseError: false, + }, + ], + ]) + + const context = buildInitialContext(structure, asts) + + expect(context).toContain("exists.ts") + expect(context).not.toContain("missing.ts") + }) + }) + describe("truncateContext", () => { it("should return original context if within limit", () => { const context = "Short context" diff --git a/packages/ipuaro/tests/unit/infrastructure/tools/edit/CreateFileTool.test.ts b/packages/ipuaro/tests/unit/infrastructure/tools/edit/CreateFileTool.test.ts new file mode 100644 index 0000000..84f8a58 --- /dev/null +++ b/packages/ipuaro/tests/unit/infrastructure/tools/edit/CreateFileTool.test.ts @@ -0,0 +1,335 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" +import { promises as fs } from "node:fs" +import * as path from "node:path" +import * as os from "node:os" +import { + CreateFileTool, + type CreateFileResult, +} from "../../../../../src/infrastructure/tools/edit/CreateFileTool.js" +import type { ToolContext } from "../../../../../src/domain/services/ITool.js" +import type { IStorage } from "../../../../../src/domain/services/IStorage.js" +import { hashLines } from "../../../../../src/shared/utils/hash.js" + +function createMockStorage(): IStorage { + return { + getFile: vi.fn().mockResolvedValue(null), + setFile: vi.fn().mockResolvedValue(undefined), + deleteFile: vi.fn(), + getAllFiles: vi.fn(), + getFileCount: vi.fn(), + getAST: vi.fn(), + setAST: vi.fn(), + deleteAST: vi.fn(), + getAllASTs: vi.fn(), + getMeta: vi.fn(), + setMeta: vi.fn(), + deleteMeta: vi.fn(), + getAllMetas: vi.fn(), + getSymbolIndex: vi.fn(), + setSymbolIndex: vi.fn(), + getDepsGraph: vi.fn(), + setDepsGraph: vi.fn(), + getProjectConfig: vi.fn(), + setProjectConfig: vi.fn(), + connect: vi.fn(), + disconnect: vi.fn(), + isConnected: vi.fn(), + clear: vi.fn(), + } as unknown as IStorage +} + +function createMockContext( + storage?: IStorage, + confirmResult = true, + projectRoot = "/test/project", +): ToolContext { + return { + projectRoot, + storage: storage ?? createMockStorage(), + requestConfirmation: vi.fn().mockResolvedValue(confirmResult), + onProgress: vi.fn(), + } +} + +describe("CreateFileTool", () => { + let tool: CreateFileTool + + beforeEach(() => { + tool = new CreateFileTool() + }) + + describe("metadata", () => { + it("should have correct name", () => { + expect(tool.name).toBe("create_file") + }) + + it("should have correct category", () => { + expect(tool.category).toBe("edit") + }) + + it("should require confirmation", () => { + expect(tool.requiresConfirmation).toBe(true) + }) + + it("should have correct parameters", () => { + expect(tool.parameters).toHaveLength(2) + expect(tool.parameters[0].name).toBe("path") + expect(tool.parameters[0].required).toBe(true) + expect(tool.parameters[1].name).toBe("content") + expect(tool.parameters[1].required).toBe(true) + }) + + it("should have description mentioning confirmation", () => { + expect(tool.description).toContain("confirmation") + }) + }) + + describe("validateParams", () => { + it("should return null for valid params", () => { + expect( + tool.validateParams({ path: "src/new-file.ts", content: "const x = 1" }), + ).toBeNull() + }) + + it("should return error for missing path", () => { + expect(tool.validateParams({ content: "x" })).toBe( + "Parameter 'path' is required and must be a non-empty string", + ) + }) + + it("should return error for empty path", () => { + expect(tool.validateParams({ path: "", content: "x" })).toBe( + "Parameter 'path' is required and must be a non-empty string", + ) + expect(tool.validateParams({ path: " ", content: "x" })).toBe( + "Parameter 'path' is required and must be a non-empty string", + ) + }) + + it("should return error for non-string path", () => { + expect(tool.validateParams({ path: 123, content: "x" })).toBe( + "Parameter 'path' is required and must be a non-empty string", + ) + }) + + it("should return error for missing content", () => { + expect(tool.validateParams({ path: "test.ts" })).toBe( + "Parameter 'content' is required and must be a string", + ) + }) + + it("should return error for non-string content", () => { + expect(tool.validateParams({ path: "test.ts", content: 123 })).toBe( + "Parameter 'content' is required and must be a string", + ) + }) + + it("should allow empty content string", () => { + expect(tool.validateParams({ path: "test.ts", content: "" })).toBeNull() + }) + }) + + describe("execute", () => { + let tempDir: string + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "create-file-test-")) + }) + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }) + }) + + it("should create new file with content", async () => { + const storage = createMockStorage() + const ctx = createMockContext(storage, true, tempDir) + + const content = "line 1\nline 2\nline 3" + const result = await tool.execute({ path: "new-file.ts", content }, ctx) + + expect(result.success).toBe(true) + const data = result.data as CreateFileResult + expect(data.path).toBe("new-file.ts") + expect(data.lines).toBe(3) + + const filePath = path.join(tempDir, "new-file.ts") + const fileContent = await fs.readFile(filePath, "utf-8") + expect(fileContent).toBe(content) + }) + + it("should create directories if they do not exist", async () => { + const storage = createMockStorage() + const ctx = createMockContext(storage, true, tempDir) + + const result = await tool.execute( + { path: "deep/nested/dir/file.ts", content: "test" }, + ctx, + ) + + expect(result.success).toBe(true) + + const filePath = path.join(tempDir, "deep/nested/dir/file.ts") + const fileContent = await fs.readFile(filePath, "utf-8") + expect(fileContent).toBe("test") + }) + + it("should call requestConfirmation with diff info", async () => { + const storage = createMockStorage() + const ctx = createMockContext(storage, true, tempDir) + + await tool.execute({ path: "new-file.ts", content: "line 1\nline 2" }, ctx) + + expect(ctx.requestConfirmation).toHaveBeenCalledWith( + "Create new file: new-file.ts (2 lines)", + { + filePath: "new-file.ts", + oldLines: [], + newLines: ["line 1", "line 2"], + startLine: 1, + }, + ) + }) + + it("should cancel creation when confirmation rejected", async () => { + const storage = createMockStorage() + const ctx = createMockContext(storage, false, tempDir) + + const result = await tool.execute({ path: "new-file.ts", content: "test" }, ctx) + + expect(result.success).toBe(false) + expect(result.error).toBe("File creation cancelled by user") + + const filePath = path.join(tempDir, "new-file.ts") + await expect(fs.access(filePath)).rejects.toThrow() + }) + + it("should update storage after creation", async () => { + const storage = createMockStorage() + const ctx = createMockContext(storage, true, tempDir) + + await tool.execute({ path: "new-file.ts", content: "line 1\nline 2" }, ctx) + + expect(storage.setFile).toHaveBeenCalledWith( + "new-file.ts", + expect.objectContaining({ + lines: ["line 1", "line 2"], + hash: hashLines(["line 1", "line 2"]), + }), + ) + }) + + it("should return error for path outside project root", async () => { + const ctx = createMockContext(undefined, true, tempDir) + + const result = await tool.execute({ path: "../outside/file.ts", content: "test" }, ctx) + + expect(result.success).toBe(false) + expect(result.error).toBe("Path must be within project root") + }) + + it("should return error if file already exists", async () => { + const existingFile = path.join(tempDir, "existing.ts") + await fs.writeFile(existingFile, "original content", "utf-8") + + const storage = createMockStorage() + const ctx = createMockContext(storage, true, tempDir) + + const result = await tool.execute({ path: "existing.ts", content: "new content" }, ctx) + + expect(result.success).toBe(false) + expect(result.error).toBe("File already exists: existing.ts") + + const content = await fs.readFile(existingFile, "utf-8") + expect(content).toBe("original content") + }) + + it("should handle empty content", async () => { + const storage = createMockStorage() + const ctx = createMockContext(storage, true, tempDir) + + const result = await tool.execute({ path: "empty.ts", content: "" }, ctx) + + expect(result.success).toBe(true) + const data = result.data as CreateFileResult + expect(data.lines).toBe(1) + + const filePath = path.join(tempDir, "empty.ts") + const fileContent = await fs.readFile(filePath, "utf-8") + expect(fileContent).toBe("") + }) + + it("should handle single line content", async () => { + const storage = createMockStorage() + const ctx = createMockContext(storage, true, tempDir) + + const result = await tool.execute( + { path: "single.ts", content: "export const x = 1" }, + ctx, + ) + + expect(result.success).toBe(true) + const data = result.data as CreateFileResult + expect(data.lines).toBe(1) + }) + + it("should return correct file size", async () => { + const storage = createMockStorage() + const ctx = createMockContext(storage, true, tempDir) + + const content = "hello world" + const result = await tool.execute({ path: "file.ts", content }, ctx) + + expect(result.success).toBe(true) + const data = result.data as CreateFileResult + expect(data.size).toBe(Buffer.byteLength(content, "utf-8")) + }) + + it("should include callId in result", async () => { + const storage = createMockStorage() + const ctx = createMockContext(storage, true, tempDir) + + const result = await tool.execute({ path: "new.ts", content: "test" }, ctx) + + expect(result.callId).toMatch(/^create_file-\d+$/) + }) + + it("should include executionTimeMs in result", async () => { + const storage = createMockStorage() + const ctx = createMockContext(storage, true, tempDir) + + const result = await tool.execute({ path: "new.ts", content: "test" }, ctx) + + expect(result.executionTimeMs).toBeGreaterThanOrEqual(0) + }) + + it("should handle multi-line content correctly", async () => { + const storage = createMockStorage() + const ctx = createMockContext(storage, true, tempDir) + + const content = "import { x } from './x'\n\nexport function foo() {\n return x\n}\n" + const result = await tool.execute({ path: "foo.ts", content }, ctx) + + expect(result.success).toBe(true) + const data = result.data as CreateFileResult + expect(data.lines).toBe(6) + + const filePath = path.join(tempDir, "foo.ts") + const fileContent = await fs.readFile(filePath, "utf-8") + expect(fileContent).toBe(content) + }) + + it("should handle special characters in content", async () => { + const storage = createMockStorage() + const ctx = createMockContext(storage, true, tempDir) + + const content = "const emoji = '🚀'\nconst quote = \"hello 'world'\"" + const result = await tool.execute({ path: "special.ts", content }, ctx) + + expect(result.success).toBe(true) + + const filePath = path.join(tempDir, "special.ts") + const fileContent = await fs.readFile(filePath, "utf-8") + expect(fileContent).toBe(content) + }) + }) +}) diff --git a/packages/ipuaro/tests/unit/infrastructure/tools/edit/DeleteFileTool.test.ts b/packages/ipuaro/tests/unit/infrastructure/tools/edit/DeleteFileTool.test.ts new file mode 100644 index 0000000..703081c --- /dev/null +++ b/packages/ipuaro/tests/unit/infrastructure/tools/edit/DeleteFileTool.test.ts @@ -0,0 +1,274 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" +import { promises as fs } from "node:fs" +import * as path from "node:path" +import * as os from "node:os" +import { + DeleteFileTool, + type DeleteFileResult, +} from "../../../../../src/infrastructure/tools/edit/DeleteFileTool.js" +import type { ToolContext } from "../../../../../src/domain/services/ITool.js" +import type { IStorage } from "../../../../../src/domain/services/IStorage.js" + +function createMockStorage(fileData: { lines: string[] } | null = null): IStorage { + return { + getFile: vi.fn().mockResolvedValue(fileData), + setFile: vi.fn().mockResolvedValue(undefined), + deleteFile: vi.fn().mockResolvedValue(undefined), + getAllFiles: vi.fn(), + getFileCount: vi.fn(), + getAST: vi.fn(), + setAST: vi.fn(), + deleteAST: vi.fn().mockResolvedValue(undefined), + getAllASTs: vi.fn(), + getMeta: vi.fn(), + setMeta: vi.fn(), + deleteMeta: vi.fn().mockResolvedValue(undefined), + getAllMetas: vi.fn(), + getSymbolIndex: vi.fn(), + setSymbolIndex: vi.fn(), + getDepsGraph: vi.fn(), + setDepsGraph: vi.fn(), + getProjectConfig: vi.fn(), + setProjectConfig: vi.fn(), + connect: vi.fn(), + disconnect: vi.fn(), + isConnected: vi.fn(), + clear: vi.fn(), + } as unknown as IStorage +} + +function createMockContext( + storage?: IStorage, + confirmResult = true, + projectRoot = "/test/project", +): ToolContext { + return { + projectRoot, + storage: storage ?? createMockStorage(), + requestConfirmation: vi.fn().mockResolvedValue(confirmResult), + onProgress: vi.fn(), + } +} + +describe("DeleteFileTool", () => { + let tool: DeleteFileTool + + beforeEach(() => { + tool = new DeleteFileTool() + }) + + describe("metadata", () => { + it("should have correct name", () => { + expect(tool.name).toBe("delete_file") + }) + + it("should have correct category", () => { + expect(tool.category).toBe("edit") + }) + + it("should require confirmation", () => { + expect(tool.requiresConfirmation).toBe(true) + }) + + it("should have correct parameters", () => { + expect(tool.parameters).toHaveLength(1) + expect(tool.parameters[0].name).toBe("path") + expect(tool.parameters[0].required).toBe(true) + }) + + it("should have description mentioning confirmation", () => { + expect(tool.description).toContain("confirmation") + }) + }) + + describe("validateParams", () => { + it("should return null for valid params", () => { + expect(tool.validateParams({ path: "src/file.ts" })).toBeNull() + }) + + it("should return error for missing path", () => { + expect(tool.validateParams({})).toBe( + "Parameter 'path' is required and must be a non-empty string", + ) + }) + + it("should return error for empty path", () => { + expect(tool.validateParams({ path: "" })).toBe( + "Parameter 'path' is required and must be a non-empty string", + ) + expect(tool.validateParams({ path: " " })).toBe( + "Parameter 'path' is required and must be a non-empty string", + ) + }) + + it("should return error for non-string path", () => { + expect(tool.validateParams({ path: 123 })).toBe( + "Parameter 'path' is required and must be a non-empty string", + ) + }) + }) + + describe("execute", () => { + let tempDir: string + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "delete-file-test-")) + }) + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }) + }) + + it("should delete existing file", async () => { + const testFile = path.join(tempDir, "to-delete.ts") + await fs.writeFile(testFile, "content to delete", "utf-8") + + const storage = createMockStorage({ lines: ["content to delete"] }) + const ctx = createMockContext(storage, true, tempDir) + + const result = await tool.execute({ path: "to-delete.ts" }, ctx) + + expect(result.success).toBe(true) + const data = result.data as DeleteFileResult + expect(data.path).toBe("to-delete.ts") + expect(data.deleted).toBe(true) + + await expect(fs.access(testFile)).rejects.toThrow() + }) + + it("should delete file from storage", async () => { + const testFile = path.join(tempDir, "to-delete.ts") + await fs.writeFile(testFile, "content", "utf-8") + + const storage = createMockStorage({ lines: ["content"] }) + const ctx = createMockContext(storage, true, tempDir) + + await tool.execute({ path: "to-delete.ts" }, ctx) + + expect(storage.deleteFile).toHaveBeenCalledWith("to-delete.ts") + expect(storage.deleteAST).toHaveBeenCalledWith("to-delete.ts") + expect(storage.deleteMeta).toHaveBeenCalledWith("to-delete.ts") + }) + + it("should call requestConfirmation with diff info", async () => { + const testFile = path.join(tempDir, "to-delete.ts") + await fs.writeFile(testFile, "line 1\nline 2", "utf-8") + + const storage = createMockStorage({ lines: ["line 1", "line 2"] }) + const ctx = createMockContext(storage, true, tempDir) + + await tool.execute({ path: "to-delete.ts" }, ctx) + + expect(ctx.requestConfirmation).toHaveBeenCalledWith("Delete file: to-delete.ts", { + filePath: "to-delete.ts", + oldLines: ["line 1", "line 2"], + newLines: [], + startLine: 1, + }) + }) + + it("should cancel deletion when confirmation rejected", async () => { + const testFile = path.join(tempDir, "keep.ts") + await fs.writeFile(testFile, "keep this", "utf-8") + + const storage = createMockStorage({ lines: ["keep this"] }) + const ctx = createMockContext(storage, false, tempDir) + + const result = await tool.execute({ path: "keep.ts" }, ctx) + + expect(result.success).toBe(false) + expect(result.error).toBe("File deletion cancelled by user") + + const content = await fs.readFile(testFile, "utf-8") + expect(content).toBe("keep this") + }) + + it("should return error for path outside project root", async () => { + const ctx = createMockContext(undefined, true, tempDir) + + const result = await tool.execute({ path: "../outside/file.ts" }, ctx) + + expect(result.success).toBe(false) + expect(result.error).toBe("Path must be within project root") + }) + + it("should return error if file does not exist", async () => { + const storage = createMockStorage(null) + const ctx = createMockContext(storage, true, tempDir) + + const result = await tool.execute({ path: "nonexistent.ts" }, ctx) + + expect(result.success).toBe(false) + expect(result.error).toBe("File not found: nonexistent.ts") + }) + + it("should read content from filesystem if not in storage", async () => { + const testFile = path.join(tempDir, "not-indexed.ts") + await fs.writeFile(testFile, "filesystem content\nline 2", "utf-8") + + const storage = createMockStorage(null) + const ctx = createMockContext(storage, true, tempDir) + + await tool.execute({ path: "not-indexed.ts" }, ctx) + + expect(ctx.requestConfirmation).toHaveBeenCalledWith( + "Delete file: not-indexed.ts", + expect.objectContaining({ + oldLines: ["filesystem content", "line 2"], + }), + ) + }) + + it("should include callId in result", async () => { + const testFile = path.join(tempDir, "file.ts") + await fs.writeFile(testFile, "x", "utf-8") + + const storage = createMockStorage({ lines: ["x"] }) + const ctx = createMockContext(storage, true, tempDir) + + const result = await tool.execute({ path: "file.ts" }, ctx) + + expect(result.callId).toMatch(/^delete_file-\d+$/) + }) + + it("should include executionTimeMs in result", async () => { + const testFile = path.join(tempDir, "file.ts") + await fs.writeFile(testFile, "x", "utf-8") + + const storage = createMockStorage({ lines: ["x"] }) + const ctx = createMockContext(storage, true, tempDir) + + const result = await tool.execute({ path: "file.ts" }, ctx) + + expect(result.executionTimeMs).toBeGreaterThanOrEqual(0) + }) + + it("should not delete directories", async () => { + const dirPath = path.join(tempDir, "some-dir") + await fs.mkdir(dirPath) + + const storage = createMockStorage(null) + const ctx = createMockContext(storage, true, tempDir) + + const result = await tool.execute({ path: "some-dir" }, ctx) + + expect(result.success).toBe(false) + expect(result.error).toBe("File not found: some-dir") + }) + + it("should handle nested file paths", async () => { + const nestedDir = path.join(tempDir, "a/b/c") + await fs.mkdir(nestedDir, { recursive: true }) + const testFile = path.join(nestedDir, "file.ts") + await fs.writeFile(testFile, "nested", "utf-8") + + const storage = createMockStorage({ lines: ["nested"] }) + const ctx = createMockContext(storage, true, tempDir) + + const result = await tool.execute({ path: "a/b/c/file.ts" }, ctx) + + expect(result.success).toBe(true) + await expect(fs.access(testFile)).rejects.toThrow() + }) + }) +}) diff --git a/packages/ipuaro/tests/unit/infrastructure/tools/edit/EditLinesTool.test.ts b/packages/ipuaro/tests/unit/infrastructure/tools/edit/EditLinesTool.test.ts new file mode 100644 index 0000000..2bab6db --- /dev/null +++ b/packages/ipuaro/tests/unit/infrastructure/tools/edit/EditLinesTool.test.ts @@ -0,0 +1,493 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" +import { promises as fs } from "node:fs" +import * as path from "node:path" +import * as os from "node:os" +import { + EditLinesTool, + type EditLinesResult, +} from "../../../../../src/infrastructure/tools/edit/EditLinesTool.js" +import type { ToolContext } from "../../../../../src/domain/services/ITool.js" +import type { IStorage } from "../../../../../src/domain/services/IStorage.js" +import { hashLines } from "../../../../../src/shared/utils/hash.js" + +function createMockStorage(fileData: { lines: string[]; hash: string } | null = null): IStorage { + return { + getFile: vi.fn().mockResolvedValue(fileData), + setFile: vi.fn().mockResolvedValue(undefined), + deleteFile: vi.fn(), + getAllFiles: vi.fn(), + getFileCount: vi.fn(), + getAST: vi.fn(), + setAST: vi.fn(), + deleteAST: vi.fn(), + getAllASTs: vi.fn(), + getMeta: vi.fn(), + setMeta: vi.fn(), + deleteMeta: vi.fn(), + getAllMetas: vi.fn(), + getSymbolIndex: vi.fn(), + setSymbolIndex: vi.fn(), + getDepsGraph: vi.fn(), + setDepsGraph: vi.fn(), + getProjectConfig: vi.fn(), + setProjectConfig: vi.fn(), + connect: vi.fn(), + disconnect: vi.fn(), + isConnected: vi.fn(), + clear: vi.fn(), + } as unknown as IStorage +} + +function createMockContext( + storage?: IStorage, + confirmResult = true, + projectRoot = "/test/project", +): ToolContext { + return { + projectRoot, + storage: storage ?? createMockStorage(), + requestConfirmation: vi.fn().mockResolvedValue(confirmResult), + onProgress: vi.fn(), + } +} + +describe("EditLinesTool", () => { + let tool: EditLinesTool + + beforeEach(() => { + tool = new EditLinesTool() + }) + + describe("metadata", () => { + it("should have correct name", () => { + expect(tool.name).toBe("edit_lines") + }) + + it("should have correct category", () => { + expect(tool.category).toBe("edit") + }) + + it("should require confirmation", () => { + expect(tool.requiresConfirmation).toBe(true) + }) + + it("should have correct parameters", () => { + expect(tool.parameters).toHaveLength(4) + expect(tool.parameters[0].name).toBe("path") + expect(tool.parameters[0].required).toBe(true) + expect(tool.parameters[1].name).toBe("start") + expect(tool.parameters[1].required).toBe(true) + expect(tool.parameters[2].name).toBe("end") + expect(tool.parameters[2].required).toBe(true) + expect(tool.parameters[3].name).toBe("content") + expect(tool.parameters[3].required).toBe(true) + }) + + it("should have description mentioning confirmation", () => { + expect(tool.description).toContain("confirmation") + }) + }) + + describe("validateParams", () => { + it("should return null for valid params", () => { + expect( + tool.validateParams({ + path: "src/index.ts", + start: 1, + end: 5, + content: "new content", + }), + ).toBeNull() + }) + + it("should return error for missing path", () => { + expect(tool.validateParams({ start: 1, end: 5, content: "x" })).toBe( + "Parameter 'path' is required and must be a non-empty string", + ) + }) + + it("should return error for empty path", () => { + expect(tool.validateParams({ path: "", start: 1, end: 5, content: "x" })).toBe( + "Parameter 'path' is required and must be a non-empty string", + ) + expect(tool.validateParams({ path: " ", start: 1, end: 5, content: "x" })).toBe( + "Parameter 'path' is required and must be a non-empty string", + ) + }) + + it("should return error for non-string path", () => { + expect(tool.validateParams({ path: 123, start: 1, end: 5, content: "x" })).toBe( + "Parameter 'path' is required and must be a non-empty string", + ) + }) + + it("should return error for missing start", () => { + expect(tool.validateParams({ path: "test.ts", end: 5, content: "x" })).toBe( + "Parameter 'start' is required and must be an integer", + ) + }) + + it("should return error for non-integer start", () => { + expect(tool.validateParams({ path: "test.ts", start: 1.5, end: 5, content: "x" })).toBe( + "Parameter 'start' is required and must be an integer", + ) + expect(tool.validateParams({ path: "test.ts", start: "1", end: 5, content: "x" })).toBe( + "Parameter 'start' is required and must be an integer", + ) + }) + + it("should return error for start < 1", () => { + expect(tool.validateParams({ path: "test.ts", start: 0, end: 5, content: "x" })).toBe( + "Parameter 'start' must be >= 1", + ) + expect(tool.validateParams({ path: "test.ts", start: -1, end: 5, content: "x" })).toBe( + "Parameter 'start' must be >= 1", + ) + }) + + it("should return error for missing end", () => { + expect(tool.validateParams({ path: "test.ts", start: 1, content: "x" })).toBe( + "Parameter 'end' is required and must be an integer", + ) + }) + + it("should return error for non-integer end", () => { + expect(tool.validateParams({ path: "test.ts", start: 1, end: 5.5, content: "x" })).toBe( + "Parameter 'end' is required and must be an integer", + ) + }) + + it("should return error for end < 1", () => { + expect(tool.validateParams({ path: "test.ts", start: 1, end: 0, content: "x" })).toBe( + "Parameter 'end' must be >= 1", + ) + }) + + it("should return error for start > end", () => { + expect(tool.validateParams({ path: "test.ts", start: 10, end: 5, content: "x" })).toBe( + "Parameter 'start' must be <= 'end'", + ) + }) + + it("should return error for missing content", () => { + expect(tool.validateParams({ path: "test.ts", start: 1, end: 5 })).toBe( + "Parameter 'content' is required and must be a string", + ) + }) + + it("should return error for non-string content", () => { + expect(tool.validateParams({ path: "test.ts", start: 1, end: 5, content: 123 })).toBe( + "Parameter 'content' is required and must be a string", + ) + }) + + it("should allow empty content string", () => { + expect( + tool.validateParams({ path: "test.ts", start: 1, end: 5, content: "" }), + ).toBeNull() + }) + }) + + describe("execute", () => { + let tempDir: string + let testFilePath: string + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "edit-lines-test-")) + testFilePath = path.join(tempDir, "test.ts") + }) + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }) + }) + + it("should replace lines with new content", async () => { + const originalLines = ["line 1", "line 2", "line 3", "line 4", "line 5"] + const originalContent = originalLines.join("\n") + await fs.writeFile(testFilePath, originalContent, "utf-8") + + const lines = [...originalLines] + const hash = hashLines(lines) + const storage = createMockStorage({ lines, hash }) + const ctx = createMockContext(storage, true, tempDir) + + const result = await tool.execute( + { path: "test.ts", start: 2, end: 4, content: "new line A\nnew line B" }, + ctx, + ) + + expect(result.success).toBe(true) + const data = result.data as EditLinesResult + expect(data.path).toBe("test.ts") + expect(data.startLine).toBe(2) + expect(data.endLine).toBe(4) + expect(data.linesReplaced).toBe(3) + expect(data.linesInserted).toBe(2) + expect(data.totalLines).toBe(4) + + const newContent = await fs.readFile(testFilePath, "utf-8") + expect(newContent).toBe("line 1\nnew line A\nnew line B\nline 5") + }) + + it("should call requestConfirmation with diff info", async () => { + const originalLines = ["line 1", "line 2", "line 3"] + await fs.writeFile(testFilePath, originalLines.join("\n"), "utf-8") + + const hash = hashLines(originalLines) + const storage = createMockStorage({ lines: originalLines, hash }) + const ctx = createMockContext(storage, true, tempDir) + + await tool.execute({ path: "test.ts", start: 2, end: 2, content: "replaced" }, ctx) + + expect(ctx.requestConfirmation).toHaveBeenCalledWith("Replace lines 2-2 in test.ts", { + filePath: "test.ts", + oldLines: ["line 2"], + newLines: ["replaced"], + startLine: 2, + }) + }) + + it("should cancel edit when confirmation rejected", async () => { + const originalLines = ["line 1", "line 2", "line 3"] + const originalContent = originalLines.join("\n") + await fs.writeFile(testFilePath, originalContent, "utf-8") + + const hash = hashLines(originalLines) + const storage = createMockStorage({ lines: originalLines, hash }) + const ctx = createMockContext(storage, false, tempDir) + + const result = await tool.execute( + { path: "test.ts", start: 1, end: 1, content: "changed" }, + ctx, + ) + + expect(result.success).toBe(false) + expect(result.error).toBe("Edit cancelled by user") + + const content = await fs.readFile(testFilePath, "utf-8") + expect(content).toBe(originalContent) + }) + + it("should update storage after edit", async () => { + const originalLines = ["line 1", "line 2"] + await fs.writeFile(testFilePath, originalLines.join("\n"), "utf-8") + + const hash = hashLines(originalLines) + const storage = createMockStorage({ lines: originalLines, hash }) + const ctx = createMockContext(storage, true, tempDir) + + await tool.execute({ path: "test.ts", start: 1, end: 1, content: "changed" }, ctx) + + expect(storage.setFile).toHaveBeenCalledWith( + "test.ts", + expect.objectContaining({ + lines: ["changed", "line 2"], + hash: hashLines(["changed", "line 2"]), + }), + ) + }) + + it("should return error for path outside project root", async () => { + const ctx = createMockContext() + + const result = await tool.execute( + { path: "../outside/file.ts", start: 1, end: 1, content: "x" }, + ctx, + ) + + expect(result.success).toBe(false) + expect(result.error).toBe("Path must be within project root") + }) + + it("should return error when start exceeds file length", async () => { + const originalLines = ["line 1", "line 2"] + await fs.writeFile(testFilePath, originalLines.join("\n"), "utf-8") + + const hash = hashLines(originalLines) + const storage = createMockStorage({ lines: originalLines, hash }) + const ctx = createMockContext(storage, true, tempDir) + + const result = await tool.execute( + { path: "test.ts", start: 10, end: 15, content: "x" }, + ctx, + ) + + expect(result.success).toBe(false) + expect(result.error).toBe("Start line 10 exceeds file length (2 lines)") + }) + + it("should adjust end to file length if it exceeds", async () => { + const originalLines = ["line 1", "line 2", "line 3"] + await fs.writeFile(testFilePath, originalLines.join("\n"), "utf-8") + + const hash = hashLines(originalLines) + const storage = createMockStorage({ lines: originalLines, hash }) + const ctx = createMockContext(storage, true, tempDir) + + const result = await tool.execute( + { path: "test.ts", start: 2, end: 100, content: "new" }, + ctx, + ) + + expect(result.success).toBe(true) + const data = result.data as EditLinesResult + expect(data.endLine).toBe(3) + expect(data.linesReplaced).toBe(2) + }) + + it("should detect hash conflict", async () => { + const originalLines = ["line 1", "line 2"] + await fs.writeFile(testFilePath, originalLines.join("\n"), "utf-8") + + const oldHash = hashLines(["old content"]) + const storage = createMockStorage({ lines: originalLines, hash: oldHash }) + const ctx = createMockContext(storage, true, tempDir) + + const result = await tool.execute( + { path: "test.ts", start: 1, end: 1, content: "new" }, + ctx, + ) + + expect(result.success).toBe(false) + expect(result.error).toBe( + "File has been modified externally. Please refresh the file before editing.", + ) + }) + + it("should allow edit when file not in storage", async () => { + const originalLines = ["line 1", "line 2"] + await fs.writeFile(testFilePath, originalLines.join("\n"), "utf-8") + + const storage = createMockStorage(null) + const ctx = createMockContext(storage, true, tempDir) + + const result = await tool.execute( + { path: "test.ts", start: 1, end: 1, content: "new" }, + ctx, + ) + + expect(result.success).toBe(true) + }) + + it("should handle single line replacement", async () => { + const originalLines = ["line 1", "line 2", "line 3"] + await fs.writeFile(testFilePath, originalLines.join("\n"), "utf-8") + + const hash = hashLines(originalLines) + const storage = createMockStorage({ lines: originalLines, hash }) + const ctx = createMockContext(storage, true, tempDir) + + const result = await tool.execute( + { path: "test.ts", start: 2, end: 2, content: "replaced line 2" }, + ctx, + ) + + expect(result.success).toBe(true) + const content = await fs.readFile(testFilePath, "utf-8") + expect(content).toBe("line 1\nreplaced line 2\nline 3") + }) + + it("should handle replacing all lines", async () => { + const originalLines = ["line 1", "line 2"] + await fs.writeFile(testFilePath, originalLines.join("\n"), "utf-8") + + const hash = hashLines(originalLines) + const storage = createMockStorage({ lines: originalLines, hash }) + const ctx = createMockContext(storage, true, tempDir) + + const result = await tool.execute( + { path: "test.ts", start: 1, end: 2, content: "completely\nnew\nfile" }, + ctx, + ) + + expect(result.success).toBe(true) + const content = await fs.readFile(testFilePath, "utf-8") + expect(content).toBe("completely\nnew\nfile") + }) + + it("should handle inserting more lines than replaced", async () => { + const originalLines = ["line 1", "line 2"] + await fs.writeFile(testFilePath, originalLines.join("\n"), "utf-8") + + const hash = hashLines(originalLines) + const storage = createMockStorage({ lines: originalLines, hash }) + const ctx = createMockContext(storage, true, tempDir) + + const result = await tool.execute( + { path: "test.ts", start: 1, end: 1, content: "a\nb\nc\nd" }, + ctx, + ) + + expect(result.success).toBe(true) + const data = result.data as EditLinesResult + expect(data.linesReplaced).toBe(1) + expect(data.linesInserted).toBe(4) + expect(data.totalLines).toBe(5) + }) + + it("should handle deleting lines (empty content)", async () => { + const originalLines = ["line 1", "line 2", "line 3"] + await fs.writeFile(testFilePath, originalLines.join("\n"), "utf-8") + + const hash = hashLines(originalLines) + const storage = createMockStorage({ lines: originalLines, hash }) + const ctx = createMockContext(storage, true, tempDir) + + const result = await tool.execute( + { path: "test.ts", start: 2, end: 2, content: "" }, + ctx, + ) + + expect(result.success).toBe(true) + const data = result.data as EditLinesResult + expect(data.linesReplaced).toBe(1) + expect(data.linesInserted).toBe(1) + expect(data.totalLines).toBe(3) + }) + + it("should include callId in result", async () => { + const originalLines = ["line 1"] + await fs.writeFile(testFilePath, originalLines.join("\n"), "utf-8") + + const hash = hashLines(originalLines) + const storage = createMockStorage({ lines: originalLines, hash }) + const ctx = createMockContext(storage, true, tempDir) + + const result = await tool.execute( + { path: "test.ts", start: 1, end: 1, content: "new" }, + ctx, + ) + + expect(result.callId).toMatch(/^edit_lines-\d+$/) + }) + + it("should include executionTimeMs in result", async () => { + const originalLines = ["line 1"] + await fs.writeFile(testFilePath, originalLines.join("\n"), "utf-8") + + const hash = hashLines(originalLines) + const storage = createMockStorage({ lines: originalLines, hash }) + const ctx = createMockContext(storage, true, tempDir) + + const result = await tool.execute( + { path: "test.ts", start: 1, end: 1, content: "new" }, + ctx, + ) + + expect(result.executionTimeMs).toBeGreaterThanOrEqual(0) + }) + + it("should return error when file not found", async () => { + const storage = createMockStorage(null) + const ctx = createMockContext(storage, true, tempDir) + + const result = await tool.execute( + { path: "nonexistent.ts", start: 1, end: 1, content: "x" }, + ctx, + ) + + expect(result.success).toBe(false) + expect(result.error).toContain("ENOENT") + }) + }) +}) diff --git a/packages/ipuaro/vitest.config.ts b/packages/ipuaro/vitest.config.ts index 507dc77..5525cb1 100644 --- a/packages/ipuaro/vitest.config.ts +++ b/packages/ipuaro/vitest.config.ts @@ -11,10 +11,10 @@ export default defineConfig({ include: ["src/**/*.ts", "src/**/*.tsx"], exclude: ["src/**/*.d.ts", "src/**/index.ts", "src/**/*.test.ts"], thresholds: { - lines: 80, - functions: 80, - branches: 80, - statements: 80, + lines: 95, + functions: 95, + branches: 90, + statements: 95, }, }, },